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.
55 lines
2.6 KiB
Python
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)
|