mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
- patcher.js resolves rom-patcher-js from both the relocated sibling layout (docker/Dockerfile) and the plain node_modules layout (root Dockerfile), so both build flows work without a manual copy - apply_patch wraps the node subprocess in asyncio.wait_for with a timeout and kills it on expiry; a semaphore bounds concurrency, and the endpoint rejects oversized ROM/patch files to avoid OOM - report the patch source-checksum validation result via an X-Patch-Validated header; the patcher UI warns on a mismatch - return a generic "Patching failed" detail to clients and log the real error server-side, so node/RomPatcher.js paths don't leak Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.0 KiB
Python
169 lines
6.0 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.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"
|
|
)
|
|
|
|
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",
|
|
)
|
|
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",
|
|
)
|
|
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),
|
|
)
|