Files
romm/backend/endpoints/roms/patch.py
zurdi 7d45795408 fix(permissions): address gantoine review + Postgres-safe single migration
Visibility-coverage gaps (404-mask hidden entities, mirroring the existing
delete/read paths):
- update_rom (PUT /roms/{id}) and update_rom_user (PUT /roms/{id}/props)
- add_firmware: platform-hide now cascades to firmware uploads
- patch_rom: resolve the parent rom of both the base and patch files and
  404 when hidden, so a hidden rom's bytes can no longer be streamed back
- activity feeds (get_all_activity / get_rom_activity): drop sessions whose
  rom is hidden from the caller

Migration: make the role enum -> varchar narrowing Postgres-safe. The cast
now uses postgresql_using, the orphaned native role type is dropped on
upgrade, and downgrade recreates it explicitly (create_type=False) before
re-typing the column. Verified up/down/up on Postgres 16 and MariaDB.

Also collapses the two permission migrations into a single 0092 and notes
the override own_only replacement granularity limit in the resolver.

AI assistance: implemented with Claude Code (review-fix pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:10:00 +00:00

188 lines
6.9 KiB
Python

import shutil
import tempfile
from pathlib import Path
from typing import Annotated
from urllib.parse import quote
from fastapi import Body, HTTPException
from fastapi import Path as PathVar
from fastapi import Request, status
from pydantic import BaseModel, Field
from starlette.background import BackgroundTask
from starlette.responses import FileResponse
from config import ROM_PATCHER_MAX_FILE_SIZE_BYTES
from decorators.auth import protected_route
from handler.auth.constants import Scope
from handler.auth.dependencies import get_permissions
from handler.database import db_rom_handler
from handler.filesystem import fs_rom_handler
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from utils.rom_patcher import SUPPORTED_PATCH_EXTENSIONS, PatcherError, apply_patch
from utils.router import APIRouter
router = APIRouter()
class PatchRequest(BaseModel):
patch_file_id: int = Field(description="ID of the patch file (RomFile) to apply.")
output_file_name: str | None = Field(
default=None,
description="Custom output file name. If omitted, derived from ROM + patch names.",
)
class PatchResponse(BaseModel):
message: str
output_file_name: str
output_file_size: int
@protected_route(
router.post,
"/{id}/patch",
[Scope.ROMS_READ],
responses={
status.HTTP_400_BAD_REQUEST: {},
status.HTTP_404_NOT_FOUND: {},
status.HTTP_500_INTERNAL_SERVER_ERROR: {},
},
)
async def patch_rom(
request: Request,
id: Annotated[int, PathVar(description="ROM file ID (the base game file).", ge=1)],
patch_request: Annotated[PatchRequest, Body()],
):
"""Apply a patch to a ROM file server-side and return the patched file.
Both the ROM file and the patch file must already exist in the library.
The patched ROM is streamed back as a download.
"""
current_username = (
request.user.username if request.user.is_authenticated else "unknown"
)
perms = get_permissions(request)
rom_file = db_rom_handler.get_rom_file_by_id(id)
if not rom_file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ROM file with id {id} not found",
)
# 404-mask file bytes of roms hidden from the caller.
base_rom = db_rom_handler.get_rom(rom_file.rom_id)
if not base_rom or not perms.can_see_rom(base_rom.id, base_rom.platform_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ROM file with id {id} not found",
)
if rom_file.missing_from_fs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ROM file '{rom_file.file_name}' is missing from filesystem",
)
patch_file = db_rom_handler.get_rom_file_by_id(patch_request.patch_file_id)
if not patch_file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Patch file with id {patch_request.patch_file_id} not found",
)
# The patch file's bytes are read too; mask it if its rom is hidden.
patch_rom_parent = db_rom_handler.get_rom(patch_file.rom_id)
if not patch_rom_parent or not perms.can_see_rom(
patch_rom_parent.id, patch_rom_parent.platform_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Patch file with id {patch_request.patch_file_id} not found",
)
if patch_file.missing_from_fs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Patch file '{patch_file.file_name}' is missing from filesystem",
)
patch_ext = Path(patch_file.file_name).suffix.lower()
if patch_ext not in SUPPORTED_PATCH_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported patch format '{patch_ext}'. Supported: {', '.join(sorted(SUPPORTED_PATCH_EXTENSIONS))}",
)
# RomPatcher.js loads the whole ROM into memory, so reject oversized inputs.
for label, file in (("ROM", rom_file), ("Patch", patch_file)):
if file.file_size_bytes > ROM_PATCHER_MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"{label} file is too large to patch "
f"({file.file_size_bytes} bytes, max {ROM_PATCHER_MAX_FILE_SIZE_BYTES})"
),
)
rom_path = fs_rom_handler.validate_path(rom_file.full_path)
patch_path = fs_rom_handler.validate_path(patch_file.full_path)
rom_ext = Path(rom_file.file_name).suffix
if patch_request.output_file_name:
output_file_name = f"{Path(patch_request.output_file_name).stem}{rom_ext}"
else:
rom_base = Path(rom_file.file_name).stem
patch_base = Path(patch_file.file_name).stem
output_file_name = f"{rom_base} (patched-{patch_base}){rom_ext}"
log.info(
f"User {hl(current_username, color=BLUE)} is patching "
f"ROM file {hl(rom_file.file_name)} with patch {hl(patch_file.file_name)}"
)
tmp_dir = tempfile.mkdtemp(prefix="romm_patch_")
output_path = Path(tmp_dir) / output_file_name
try:
validated = await apply_patch(rom_path, patch_path, output_path)
except PatcherError as e:
shutil.rmtree(tmp_dir, ignore_errors=True)
# Detail may contain server paths from node/RomPatcher.js; keep it server-side.
log.error(f"Patching failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Patching failed",
) from e
except Exception as e:
shutil.rmtree(tmp_dir, ignore_errors=True)
log.error(f"Unexpected patching error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Patching failed",
) from e
output_size = output_path.stat().st_size
log.info(
f"Successfully patched ROM for user {hl(current_username, color=BLUE)}: "
f"{hl(output_file_name)} ({output_size} bytes)"
)
if not validated:
log.warning(
f"Patch {hl(patch_file.file_name)} source checksum did not match "
f"ROM {hl(rom_file.file_name)}; output may be incorrect"
)
return FileResponse(
path=str(output_path),
filename=output_file_name,
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(output_file_name)}; filename=\"{quote(output_file_name)}\"",
"Content-Length": str(output_size),
# Lets callers warn when the patch's source checksum didn't match the ROM.
"X-Patch-Validated": "true" if validated else "false",
},
background=BackgroundTask(shutil.rmtree, tmp_dir, True),
)