mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
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>
188 lines
6.9 KiB
Python
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),
|
|
)
|