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(or_(Game.cover_hash == blob_id, Game.gallery_hashes.any(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)
|