Add server-side ROM patching endpoint using RomPatcher.js

Introduces POST /api/roms/{id}/patch that applies patch files to ROM files
server-side, enabling third-party apps to request ROM patching using games
and patches already in the library without needing client-side JS support.

- backend/utils/patcher.js: Node.js helper that loads RomPatcher.js and
  applies a patch file to a ROM, writing the result to an output path
- backend/endpoints/roms/patch.py: FastAPI endpoint that looks up ROM and
  patch files by ID, invokes the Node.js patcher via subprocess, and
  streams the patched ROM back as a download
- Supports all 9 patch formats: IPS, UPS, BPS, PPF, RUP, APS, BDF, PMSR, VCDIFF
- Requires roms.read scope for authentication
- Temp files are cleaned up via Starlette BackgroundTask after response

https://claude.ai/code/session_01HS6ZvAiBjmLPVB3gGw8eEt
This commit is contained in:
Claude
2026-04-06 22:22:40 +00:00
parent 74cd9d2d6a
commit 1398aa09cf
3 changed files with 293 additions and 0 deletions

View File

@@ -72,6 +72,7 @@ from utils.validation import ValidationError
from .files import router as files_router
from .manual import router as manual_router
from .notes import router as notes_router
from .patch import router as patch_router
from .upload import router as upload_router
router = APIRouter(
@@ -82,6 +83,7 @@ router.include_router(upload_router)
router.include_router(files_router)
router.include_router(manual_router)
router.include_router(notes_router)
router.include_router(patch_router)
def safe_int_or_none(value: Any) -> int | None:

View File

@@ -0,0 +1,197 @@
import asyncio
import json
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 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.router import APIRouter
router = APIRouter()
PATCHER_SCRIPT = Path(__file__).resolve().parent.parent / "utils" / "patcher.js"
SUPPORTED_PATCH_EXTENSIONS = frozenset(
(".ips", ".ups", ".bps", ".ppf", ".rup", ".aps", ".bdf", ".pmsr", ".vcdiff")
)
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: 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"
)
# Look up ROM file
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",
)
# Look up patch file
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",
)
# Validate the patch file extension
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))}",
)
# Resolve filesystem paths
rom_path = fs_rom_handler.validate_path(rom_file.full_path)
patch_path = fs_rom_handler.validate_path(patch_file.full_path)
# Build output filename
if patch_request.output_file_name:
# Strip any extension from custom name and use ROM's extension
custom_base = Path(patch_request.output_file_name).stem
rom_ext = Path(rom_file.file_name).suffix
output_file_name = f"{custom_base}{rom_ext}"
else:
rom_base = Path(rom_file.file_name).stem
patch_base = Path(patch_file.file_name).stem
rom_ext = Path(rom_file.file_name).suffix
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)}"
)
# Create temp directory for the patched output
tmp_dir = tempfile.mkdtemp(prefix="romm_patch_")
output_path = Path(tmp_dir) / output_file_name
try:
# Run the Node.js patcher script
proc = await asyncio.create_subprocess_exec(
"node",
str(PATCHER_SCRIPT),
str(rom_path),
str(patch_path),
str(output_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = "Patching failed"
try:
err_data = json.loads(stderr.decode())
error_msg = err_data.get("error", error_msg)
except (json.JSONDecodeError, UnicodeDecodeError):
if stderr:
error_msg = stderr.decode(errors="replace").strip()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_msg,
)
if not output_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Patcher did not produce an output file",
)
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)"
)
# Return the patched file as a download, cleaning up the temp
# directory after the response body has been sent.
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),
},
background=BackgroundTask(shutil.rmtree, tmp_dir, True),
)
except HTTPException:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise
except Exception as e:
shutil.rmtree(tmp_dir, ignore_errors=True)
log.error(f"Patching error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unexpected patching error: {e}",
) from e

94
backend/utils/patcher.js Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Server-side ROM patcher helper script.
*
* Uses RomPatcher.js (https://github.com/marcrobledo/RomPatcher.js/) to apply
* a patch file to a ROM file, writing the result to an output path.
*
* Usage:
* node patcher.js <rom_path> <patch_path> <output_path>
*
* Exit codes:
* 0 - success
* 1 - usage / argument error
* 2 - patching error
*/
const path = require("path");
const fs = require("fs");
// Resolve RomPatcher.js from the frontend node_modules
const ROM_PATCHER_BASE = path.resolve(
__dirname,
"../../frontend/node_modules/rom-patcher/rom-patcher-js"
);
// Load the library (sets globals that RomPatcher.js expects)
require(path.join(ROM_PATCHER_BASE, "modules", "BinFile.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "HashCalculator.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.aps_gba.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.aps_n64.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.bdf.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.bps.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ips.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.pmsr.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ppf.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.rup.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ups.js"));
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.vcdiff.js"));
const RomPatcher = require(path.join(ROM_PATCHER_BASE, "RomPatcher.js"));
const args = process.argv.slice(2);
if (args.length !== 3) {
console.error("Usage: node patcher.js <rom_path> <patch_path> <output_path>");
process.exit(1);
}
const [romPath, patchPath, outputPath] = args;
try {
// Validate input files exist
if (!fs.existsSync(romPath)) {
throw new Error(`ROM file not found: ${romPath}`);
}
if (!fs.existsSync(patchPath)) {
throw new Error(`Patch file not found: ${patchPath}`);
}
// Load files using BinFile (Node.js mode accepts file paths)
const romFile = new BinFile(romPath);
const patchFile = new BinFile(patchPath);
// Parse the patch format
const patch = RomPatcher.parsePatchFile(patchFile);
if (!patch) {
throw new Error("Unsupported or invalid patch format");
}
// Apply patch
const patchedRom = RomPatcher.applyPatch(romFile, patch, {
requireValidation: false,
fixChecksum: false,
outputSuffix: false,
});
// Extract binary data and write to output
const data = patchedRom._u8array || patchedRom.u8array || patchedRom.data;
if (!data) {
throw new Error("Failed to extract patched ROM data");
}
// Ensure output directory exists
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, Buffer.from(data.buffer, data.byteOffset, data.byteLength));
console.log(JSON.stringify({ success: true, output: outputPath, size: data.byteLength }));
process.exit(0);
} catch (err) {
console.error(JSON.stringify({ success: false, error: err.message || String(err) }));
process.exit(2);
}