mirror of
https://github.com/HyPnoTiiK/stream-fusion.git
synced 2026-07-28 15:02:46 +00:00
Introduces a secure peer-to-peer cache sharing system using HMAC-SHA256 request signatures and Fernet (AES-128-CBC) response encryption. - New peer_keys table for per-peer credential management (key_id, secret) - New /api/peer/check endpoint returns encrypted debrid availability data - New /api/peer/items endpoint returns encrypted torrent metadata - Rate limiting per peer key with configurable limits and windows - Admin UI for creating, viewing, revoking, and deleting peer keys - Peer cache enrichment integrated into BaseDebrid L2.5 lookup pipeline - StremThruDebrid override with cross-service PG L2 check + peer cache - Removed old /api/share/cache endpoint (replaced by /api/peer/*) - Added cryptography package dependency BREAKING CHANGE: The /api/share/cache endpoint has been removed and replaced with /api/peer/check. Clients must update to use HMAC authentication with X-Peer-Key-Id, X-Peer-Timestamp, and X-Peer-Signature headers.
392 lines
13 KiB
Python
392 lines
13 KiB
Python
from fastapi import APIRouter, Request, Depends, HTTPException, Form
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.security import APIKeyHeader
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.status import HTTP_303_SEE_OTHER
|
|
import secrets
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
from stream_fusion.services.postgresql.dependencies import get_db_session
|
|
|
|
from stream_fusion.services.postgresql.schemas.apikey_schemas import APIKeyCreate, APIKeyUpdate
|
|
from stream_fusion.utils.security.security_secret import SecretManager
|
|
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
|
from stream_fusion.services.postgresql.dao.peerkey_dao import PeerKeyDAO
|
|
from stream_fusion.web.api.auth.schemas import UsageLogs, UsageLog
|
|
from stream_fusion.logging_config import logger
|
|
from stream_fusion.settings import settings
|
|
from stream_fusion.services.redis.redis_config import get_redis_dependency
|
|
from stream_fusion.web.api.utils import ensure_uuid
|
|
|
|
router = APIRouter()
|
|
|
|
templates = Jinja2Templates(directory=settings.admin_template_dir)
|
|
|
|
SECRET_KEY_NAME = "secret-key"
|
|
secret_header = APIKeyHeader(name=SECRET_KEY_NAME, auto_error=False)
|
|
|
|
secret = SecretManager()
|
|
|
|
|
|
def custom_url_for(name: str, **path_params: any) -> str:
|
|
def wrapper(request: Request):
|
|
url = request.url_for(name, **path_params)
|
|
if settings.use_https:
|
|
return str(url.replace(scheme="https"))
|
|
return str(url)
|
|
|
|
return wrapper
|
|
|
|
|
|
templates.env.globals["url_for"] = custom_url_for
|
|
|
|
|
|
def redirect_to_login(request: Request):
|
|
return RedirectResponse(
|
|
url=custom_url_for("login_page")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
async def get_session_id_from_request(request: Request):
|
|
session_id = request.session.get("session_id")
|
|
if not session_id:
|
|
logger.warning("Attempt to access protected route without session ID")
|
|
return None
|
|
return session_id
|
|
|
|
|
|
async def session_based_security(
|
|
request: Request,
|
|
session_id: str = Depends(get_session_id_from_request),
|
|
redis_client=get_redis_dependency(),
|
|
):
|
|
if not session_id:
|
|
logger.warning("No session ID found, redirecting to login")
|
|
return redirect_to_login(request)
|
|
|
|
secret_key = redis_client.get(session_id)
|
|
if not secret_key:
|
|
logger.warning("Session expired or invalid, redirecting to login")
|
|
request.session.clear()
|
|
return redirect_to_login(request)
|
|
|
|
if not secrets.compare_digest(secret_key.decode(), secret.value):
|
|
logger.warning("Invalid secret key in session, redirecting to login")
|
|
redis_client.delete(session_id)
|
|
request.session.clear()
|
|
return redirect_to_login(request)
|
|
|
|
# Refresh the session TTL
|
|
redis_client.expire(session_id, timedelta(hours=2))
|
|
return True
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
async def login_page(request: Request):
|
|
logger.info("Rendering login page")
|
|
return templates.TemplateResponse("login.html", {"request": request})
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(
|
|
request: Request, secret_key: str = Form(...), redis_client=get_redis_dependency()
|
|
):
|
|
logger.info("Processing login attempt")
|
|
if secrets.compare_digest(secret_key, secret.value):
|
|
session_id = str(uuid.uuid4())
|
|
redis_client.setex(session_id, timedelta(hours=2), secret_key)
|
|
request.session["session_id"] = session_id
|
|
logger.info("Login successful")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
else:
|
|
logger.warning("Login attempt with invalid secret key")
|
|
return templates.TemplateResponse(
|
|
"login.html", {"request": request, "error": "Invalid secret key"}
|
|
)
|
|
|
|
|
|
@router.get("/api-keys", response_class=HTMLResponse)
|
|
async def list_api_keys(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
logger.info("Retrieving API key usage stats")
|
|
usage_stats = await apikey_dao.get_usage_stats()
|
|
usage_logs = UsageLogs(
|
|
logs=[
|
|
UsageLog(
|
|
api_key=key.api_key,
|
|
is_active=key.is_active,
|
|
never_expire=key.never_expire,
|
|
expiration_date=(
|
|
key.expiration_date if key.expiration_date else "Unlimited"
|
|
),
|
|
latest_query_date=(
|
|
key.latest_query_date if key.latest_query_date else "None"
|
|
),
|
|
total_queries=key.total_queries,
|
|
name=key.name if key.name else "JohnDoe",
|
|
proxied_links=key.proxied_links,
|
|
)
|
|
for key in usage_stats
|
|
]
|
|
)
|
|
logger.info(f"Retrieved {len(usage_logs.logs)} API key usage logs")
|
|
return templates.TemplateResponse(
|
|
"api_keys.html", {"request": request, "logs": usage_logs.logs}
|
|
)
|
|
|
|
|
|
@router.get("/create-api-key", response_class=HTMLResponse)
|
|
async def create_api_key_page(
|
|
request: Request, authenticated: bool = Depends(session_based_security)
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
logger.info("Rendering create API key page")
|
|
return templates.TemplateResponse("create_api_key.html", {"request": request})
|
|
|
|
|
|
@router.post("/create-api-key")
|
|
async def create_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
name: str = Form(None),
|
|
never_expires: bool = Form(False),
|
|
proxied_links: bool = Form(False),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
logger.info(f"Creating new API key. Name: {name}, Never expires: {never_expires}, Proxied links: {proxied_links}")
|
|
key = APIKeyCreate(name=name, never_expire=never_expires, proxied_links=proxied_links)
|
|
new_key = await apikey_dao.create_key(key)
|
|
logger.info("New API key created successfully.")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/revoke-api-key")
|
|
async def revoke_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
logger.info(f"Revoking API key: {api_key_uuid}")
|
|
result = await apikey_dao.revoke_key(api_key_uuid)
|
|
if result:
|
|
logger.info(f"API key revoked successfully: {api_key_uuid}")
|
|
else:
|
|
logger.warning(f"Failed to revoke API key: {api_key_uuid}")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
@router.post("/renew-api-key")
|
|
async def renew_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
logger.info(f"Renewing API key: {api_key_uuid}")
|
|
try:
|
|
renewed_key = await apikey_dao.renew_key(api_key_uuid)
|
|
logger.info(f"API key renewed successfully: {api_key_uuid}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to renew API key {api_key_uuid}: {str(e)}")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
@router.post("/delete-api-key")
|
|
async def delete_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
logger.info(f"Deleting API key: {api_key_uuid}")
|
|
try:
|
|
result = await apikey_dao.delete_key(api_key_uuid)
|
|
if result:
|
|
logger.info(f"API key deleted successfully: {api_key_uuid}")
|
|
else:
|
|
logger.warning(f"API key not found for deletion: {api_key_uuid}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete API key {api_key_uuid}: {str(e)}")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/toggle-proxied-links")
|
|
async def toggle_proxied_links(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
logger.info(f"Toggling proxied links for API key: {api_key_uuid}")
|
|
|
|
try:
|
|
# Récupérer la clé API
|
|
key_info = await apikey_dao.get_key_by_uuid(api_key_uuid)
|
|
if not key_info:
|
|
logger.warning(f"API key not found for toggling proxied links: {api_key_uuid}")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
# Mettre à jour avec la valeur inverse
|
|
current_value = getattr(key_info, 'proxied_links', False)
|
|
update_data = APIKeyUpdate(proxied_links=not current_value)
|
|
|
|
await apikey_dao.update_key(api_key_uuid, update_data)
|
|
logger.info(f"Proxied links toggled to {not current_value} for API key: {api_key_uuid}")
|
|
except Exception as e:
|
|
logger.error(f"Error toggling proxied links for API key {api_key_uuid}: {str(e)}")
|
|
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
@router.get("/logout")
|
|
async def logout(request: Request, redis_client=get_redis_dependency()):
|
|
logger.info("User logging out")
|
|
session_id = request.session.get("session_id")
|
|
if session_id:
|
|
redis_client.delete(session_id)
|
|
request.session.clear()
|
|
return RedirectResponse(
|
|
url=custom_url_for("login_page")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
# ── Peer Key Management ────────────────────────────────────────────────────────
|
|
|
|
@router.get("/peer-keys", response_class=HTMLResponse)
|
|
async def list_peer_keys(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
keys = await dao.list_keys()
|
|
|
|
# Pop one-time new key credentials from session (shown once after creation)
|
|
new_key = request.session.pop("new_peer_key", None)
|
|
|
|
return templates.TemplateResponse(
|
|
"peer_keys.html", {"request": request, "keys": keys, "new_key": new_key}
|
|
)
|
|
|
|
|
|
@router.get("/create-peer-key", response_class=HTMLResponse)
|
|
async def create_peer_key_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
return templates.TemplateResponse("create_peer_key.html", {"request": request})
|
|
|
|
|
|
@router.post("/create-peer-key")
|
|
async def create_peer_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
name: str = Form(...),
|
|
rate_limit: int = Form(60),
|
|
rate_window: int = Form(60),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
result = await dao.create_key(name=name, rate_limit=rate_limit, rate_window=rate_window)
|
|
logger.success(f"Admin: peer key created for '{name}' ({result['key_id'][:8]}…)")
|
|
|
|
# Store credentials in session — shown once on the list page
|
|
request.session["new_peer_key"] = result
|
|
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_peer_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/revoke-peer-key")
|
|
async def revoke_peer_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
key_id: str = Form(...),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
result = await dao.revoke_key(key_id)
|
|
if result:
|
|
logger.info(f"Admin: peer key revoked: {key_id[:8]}…")
|
|
else:
|
|
logger.warning(f"Admin: peer key not found for revoke: {key_id[:8]}…")
|
|
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_peer_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/delete-peer-key")
|
|
async def delete_peer_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
key_id: str = Form(...),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
result = await dao.delete_key(key_id)
|
|
if result:
|
|
logger.info(f"Admin: peer key deleted: {key_id[:8]}…")
|
|
else:
|
|
logger.warning(f"Admin: peer key not found for deletion: {key_id[:8]}…")
|
|
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_peer_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|