stream-fusion-better/stream_fusion/web/api/auth/views.py
LimeDrive 5e183c48a0 refactor: async fixes, security hardening, and code deduplication
Async correctness:
- Wrap blocking time.sleep/requests.get calls with asyncio.to_thread()
  in torrent_service.py and torr9_service.py
- Run cache_container_items() as fire-and-forget background task via
  asyncio.create_task(asyncio.to_thread(...)) in search/views.py
- Make RDTokenManager.get_access_token() and new_access_token() async,
  replace requests.post with aiohttp (token_manager.py)
- Make RealDebrid.get_headers() async, await all callers (realdebrid.py)

Security:
- Remove API key, device code, and PIN values from log output
  (auth/views.py, admin/views.py)
- Add WARNING on startup when SESSION_KEY uses the insecure default
  value (settings.py)
- Make admin template dir configurable via settings.admin_template_dir

Performance:
- Parallelize TMDB metadata fetches with asyncio.gather() (catalog/views.py)
- Add tmdb_language setting instead of hardcoded "fr-FR"

Deduplication:
- Extract ensure_uuid() to stream_fusion/web/api/utils.py
- Extract get_client_ip() to stream_fusion/web/utils.py
- Replace 8-way if/elif debrid factory with _SERVICE_MAP dict +
  _build_service() helper (get_debrid_service.py)
- Extract _model_to_schema() in APIKeyDAO to remove 6 repeated blocks
- Simplify ProxyStreamer: remove manual BytesIO buffer, stream directly
  via iter_any() (stream/views.py)

Inspired by commits 823bc77 and e306eb5 from limedrive/develop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:05:33 +01:00

270 lines
No EOL
10 KiB
Python

from datetime import datetime, timezone
from typing import List, Optional
from uuid import UUID
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Query
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
from stream_fusion.services.postgresql.schemas.apikey_schemas import APIKeyCreate, APIKeyUpdate
from stream_fusion.services.rd_conn.token_manager import RealDebridService
from stream_fusion.settings import settings
from stream_fusion.utils.security import secret_based_security
from stream_fusion.web.api.auth.schemas import UsageLog, UsageLogs
from stream_fusion.logging_config import logger
from stream_fusion.web.api.utils import ensure_uuid
router = APIRouter()
rd_service = RealDebridService()
@router.post(
"/new",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def create_new_api_key(
name: str = Query(None, description="Set API key name"),
never_expires: bool = Query(False, description="If set, the created API key will never be considered expired"),
apikey_dao: APIKeyDAO = Depends()
) -> str:
logger.info(f"Creating new API key. Name: {name}, Never expires: {never_expires}")
api_key = await apikey_dao.create_key(APIKeyCreate(
name=name,
never_expire=never_expires
))
logger.info("New API key created successfully.")
return api_key.api_key
@router.get(
"/get/{api_key}",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def get_api_key(
api_key: str,
apikey_dao: APIKeyDAO = Depends()
):
api_key_uuid = ensure_uuid(api_key)
logger.info(f"Retrieving API key: {api_key_uuid}")
key = await apikey_dao.get_key_by_uuid(api_key_uuid)
if not key:
logger.warning(f"API key not found: {api_key_uuid}")
raise HTTPException(status_code=404, detail="API key not found")
return key.dict()
@router.get(
"/get_by_name/{name}",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def get_api_keys_by_name(
name: str,
apikey_dao: APIKeyDAO = Depends()
):
logger.info(f"Retrieving API keys with name: {name}")
keys = await apikey_dao.get_keys_by_name(name)
return [key.dict() for key in keys]
@router.put(
"/revoke/{api_key}",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def revoke_api_key(
api_key: str,
apikey_dao: APIKeyDAO = Depends()
):
api_key_uuid = ensure_uuid(api_key)
logger.info(f"Attempting to revoke API key: {api_key_uuid}")
result = await apikey_dao.revoke_key(api_key_uuid)
if not result:
logger.warning(f"API key not found for revocation: {api_key_uuid}")
raise HTTPException(status_code=404, detail="API key not found")
logger.info(f"API key successfully revoked: {api_key_uuid}")
return {"message": "API key successfully revoked"}
@router.put(
"/renew/{api_key}",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def renew_api_key(
api_key: str,
apikey_dao: APIKeyDAO = Depends()
):
api_key_uuid = ensure_uuid(api_key)
logger.info(f"Attempting to renew API key: {api_key_uuid}")
try:
updated_key = await apikey_dao.renew_key(api_key_uuid)
logger.info(f"API key renewed successfully: {api_key_uuid}")
return updated_key.dict()
except HTTPException as e:
logger.error(f"HTTP exception occurred while renewing API key {api_key_uuid}: {str(e)}")
raise e
except Exception as e:
logger.error(f"Unexpected error occurred while renewing API key {api_key_uuid}: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/logs",
dependencies=[Depends(secret_based_security)],
response_model=UsageLogs,
include_in_schema=settings.security_hide_docs,
)
async def get_api_key_usage_logs(
apikey_dao: APIKeyDAO = Depends()
):
logger.info("Retrieving usage logs for all API keys")
usage_stats = await apikey_dao.get_usage_stats()
logger.info(f"Retrieved usage stats for {len(usage_stats)} API keys")
current_time = datetime.now(timezone.utc)
return UsageLogs(
logs=[
UsageLog(
api_key=key.api_key,
# Update is_active based on expiration status
is_active=key.is_active and (key.never_expire or (key.expiration_date and key.expiration_date > current_time)),
never_expire=key.never_expire,
expiration_date=key.expiration_date,
latest_query_date=key.latest_query_date,
total_queries=key.total_queries,
name=key.name,
)
for key in usage_stats
],
)
@router.get(
"/list",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def list_active_api_keys(
apikey_dao: APIKeyDAO = Depends()
) -> List[dict]:
logger.info("Retrieving list of active API keys")
active_keys = await apikey_dao.list_active_keys()
logger.info(f"Retrieved {len(active_keys)} active API keys")
return [key.dict() for key in active_keys]
@router.put(
"/update/{api_key}",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def update_api_key(
api_key: str,
name: Optional[str] = Query(None, description="New name for the API key"),
is_active: Optional[bool] = Query(None, description="Set the active status of the API key"),
expiration_date: Optional[datetime] = Query(None, description="New expiration date for the API key"),
apikey_dao: APIKeyDAO = Depends()
):
api_key_uuid = ensure_uuid(api_key)
logger.info(f"Attempting to update API key: {api_key_uuid}")
update_data = APIKeyUpdate(
name=name,
is_active=is_active,
expiration_date=expiration_date
)
try:
updated_key = await apikey_dao.update_key(api_key_uuid, update_data)
logger.info(f"API key updated successfully: {api_key_uuid}")
return updated_key.dict()
except HTTPException as e:
logger.error(f"HTTP exception occurred while updating API key {api_key_uuid}: {str(e)}")
raise e
except Exception as e:
logger.error(f"Unexpected error occurred while updating API key {api_key_uuid}: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"/delete/{api_key}",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def delete_api_key(
api_key: str,
apikey_dao: APIKeyDAO = Depends()
):
api_key_uuid = ensure_uuid(api_key)
logger.info(f"Attempting to delete 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}")
return {"message": "API key successfully deleted"}
else:
logger.warning(f"API key not found for deletion: {api_key_uuid}")
raise HTTPException(status_code=404, detail="API key not found")
except Exception as e:
logger.error(f"An error occurred while deleting API key {api_key_uuid}: {str(e)}")
raise HTTPException(status_code=500, detail=f"An error occurred while deleting the API key: {str(e)}")
@router.get(
"/check/{api_key}",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
async def check_api_key(
api_key: str,
apikey_dao: APIKeyDAO = Depends()
):
api_key_uuid = ensure_uuid(api_key)
logger.info(f"Checking API key: {api_key_uuid}")
is_valid = await apikey_dao.check_key(api_key_uuid)
return {"is_valid": is_valid}
@router.post(
"/realdebrid/device_code",
include_in_schema=settings.security_hide_docs,
)
async def get_device_code():
logger.info("Requesting device code from RealDebrid")
results = await rd_service.get_device_code()
logger.info("Device code received successfully")
return results
@router.post("/realdebrid/credentials",
include_in_schema=settings.security_hide_docs
)
async def get_credentials(device_code: str):
logger.info("Requesting credentials for device code")
results = await rd_service.get_credentials(device_code)
logger.info("Credentials received successfully")
return results
@router.post("/realdebrid/token",
include_in_schema=settings.security_hide_docs
)
async def get_token(client_id: str, client_secret: str, device_code: str):
logger.info(f"Requesting token for device code: {device_code}")
results = await rd_service.get_token(client_id, client_secret, device_code)
logger.info("Token received successfully")
return results
@router.get("/alldebrid/pin/get")
async def get_alldebrid_pin():
logger.info("Requesting PIN code for AllDebrid")
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.alldebrid.com/v4/pin/get?agent={settings.ad_user_app}") as response:
if response.status != 200:
logger.error(f"Error retrieving AllDebrid PIN: {response.status}")
raise HTTPException(status_code=response.status, detail="Error retrieving AllDebrid PIN")
data = await response.json()
logger.info("AllDebrid PIN code received successfully")
return data
@router.get("/alldebrid/pin/check")
async def check_alldebrid_pin(check: str, pin: str):
logger.info("Verifying AllDebrid PIN code")
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.alldebrid.com/v4/pin/check?check={check}&pin={pin}&agent={settings.ad_user_app}") as response:
if response.status != 200:
logger.error(f"Error verifying AllDebrid PIN: {response.status}")
raise HTTPException(status_code=response.status, detail="Error verifying AllDebrid PIN")
data = await response.json()
logger.info("AllDebrid PIN code verification successful")
return data