Files
pgames-manager/backend/app/routers/media.py
T
mrleo1nid c8d83b3fe7 feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
2026-10-25 04:42:48 +03:00

55 lines
2.6 KiB
Python

"""Authenticated media delivery; nginx only receives validated internal paths."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy import exists, or_, select, text
from app.blobs import BLOB_ID_RE, blob_path
from app.deps import DB, require
from app.models import Game, GameVersion, User
from app.routers.games import visible_versions_filter
from app.routers.versions import get_version
router = APIRouter(tags=["media"])
Player = Annotated[User, Depends(require("games.play"))]
def deliver(blob_id: str, request: Request):
if not BLOB_ID_RE.fullmatch(blob_id) or not blob_path(blob_id).is_file():
raise HTTPException(404)
headers = {"Cache-Control": "private, no-cache", "ETag": f'"{blob_id}"',
"X-Content-Type-Options": "nosniff", "Vary": "Cookie"}
if request.headers.get("if-none-match") == headers["ETag"]:
return Response(status_code=304, headers=headers)
if request.headers.get("x-pgames-accel") == "1":
headers["X-Accel-Redirect"] = f"/_protected_blobs/{blob_id[:2]}/{blob_id[2:4]}/{blob_id}"
return Response(headers=headers)
return FileResponse(blob_path(blob_id), headers=headers)
@router.api_route("/api/versions/{version_id}/files/{blob_id}", methods=["GET", "HEAD"])
async def version_file(version_id: int, blob_id: str, request: Request, user: Player, db: DB):
v = await get_version(db, user, version_id)
if not v.manifest or not any(f["hash"] == blob_id for f in v.manifest["files"].values()):
raise HTTPException(404)
return deliver(blob_id, request)
@router.api_route("/blobs/{blob_id}", methods=["GET", "HEAD"])
async def legacy_file(blob_id: str, request: Request, user: Player, db: DB):
"""Compatibility for cached cover URLs; hashes never grant access themselves."""
if not BLOB_ID_RE.fullmatch(blob_id):
raise HTTPException(404)
visible = visible_versions_filter(user)
cover = await db.scalar(select(Game.id).where(Game.cover_hash == blob_id,
or_(Game.created_by_id == user.id, exists().where(GameVersion.game_id == Game.id, visible))).limit(1))
if cover is None:
# Keep old clients functional, with exactly the same version visibility rules.
found = await db.scalar(select(GameVersion.id).where(visible,
text("EXISTS (SELECT 1 FROM jsonb_each(game_versions.manifest->'files') f WHERE f.value->>'hash' = :blob)")
).params(blob=blob_id).limit(1))
if found is None:
raise HTTPException(404)
return deliver(blob_id, request)