Files
pgames-manager/backend/app/routers/mods.py
T
mrleo1nid c8d83b3fe7 feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
2026-10-25 04:42:48 +03:00

306 lines
15 KiB
Python

"""Mod editor, revision history, three-way merge and rebase."""
import copy
import tempfile
from pathlib import Path
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from app import mods
from app.blobs import put_file
from app.config import get_settings
from app.converter import assets
from app.deps import DB, has_perm, require
from app.models import GameVersion, Revision, User
from app.routers.versions import can_manage, get_version, lock_version
from app.translation import file_json, put_json
router = APIRouter(prefix="/api/versions", tags=["mods"])
Player = Annotated[User, Depends(require("games.play"))]
Editor = Annotated[User, Depends(require("games.play", "versions.fork"))]
def editable(v, user):
if v.status != "ready" or not v.manifest:
raise HTTPException(409, "Версия ещё не готова")
if v.kind != "mod" or v.visibility != "draft" or not can_manage(user, v):
raise HTTPException(403, "Редактировать можно свой черновик мода")
def expected(v, revision):
if v.revision_id != revision:
raise HTTPException(409, "Версия изменилась. Загрузите её заново; локальные правки сохранены в редакторе")
def update_manifest(v, manifest):
v.manifest = manifest
v.size_bytes = sum(f["size"] for f in manifest["files"].values())
@router.get("/{version_id}/editor")
async def read_editor(version_id: int, user: Player, db: DB):
v = await get_version(db, user, version_id)
if not v.manifest:
raise HTTPException(409, "Версия ещё не готова")
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
old = base.manifest["files"] if base else {}
changes = [p for p in sorted(set(old) | set(v.manifest["files"]))
if old.get(p) != v.manifest["files"].get(p)] if base else []
diff = await run_in_threadpool(lambda: mods.differences(
mods.comparable(mods.prepare(mods.document(base.manifest))),
mods.comparable(mods.prepare(mods.document(v.manifest))))) if base else []
return {"revision": v.revision_id, "baseRevision": v.base_revision_id, "changes": changes,
"diff": diff,
"document": await run_in_threadpool(lambda: mods.prepare(mods.document(v.manifest))),
"files": v.manifest["files"], "editable": has_perm(user, "versions.fork") and
v.kind == "mod" and v.visibility == "draft" and can_manage(user, v)}
class EditIn(BaseModel):
revision: str
document: dict[str, Any]
@router.put("/{version_id}/editor")
async def save_editor(version_id: int, body: EditIn, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, body.revision)
try:
manifest = await run_in_threadpool(mods.store, body.document, v.manifest)
except (ValueError, KeyError, TypeError, RecursionError) as e:
raise HTTPException(422, f"Некорректный PGVN: {e}") from None
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
@router.put("/{version_id}/asset")
async def replace_asset(version_id: int, path: str, revision: str, filename: str,
request: Request, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, revision)
if path not in v.manifest["files"] or path == "game.json" or path.startswith(("script/", "locales/")):
raise HTTPException(422, "Выберите существующий медиафайл")
suffix = Path(filename).suffix.lower()
if assets.kind_of(Path(path)) not in ("image", "audio", "video", "font") or assets.kind_of(Path(filename)) != assets.kind_of(Path(path)):
raise HTTPException(422, "Тип загруженного файла не совпадает с заменяемым")
get_settings().work_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=get_settings().work_dir) as folder:
root = Path(folder)
source = root / ("upload" + suffix)
count = 0
with source.open("wb") as out:
async for chunk in request.stream():
count += len(chunk)
if count > 512 * 1024 * 1024:
raise HTTPException(413, "Максимум 512 МБ")
out.write(chunk)
if not count:
raise HTTPException(422, "Пустой файл")
def convert():
# Do not accept unparsed webm passthrough for editor uploads.
if suffix == ".webm":
output = assets._ffmpeg(source, root / "verified.webm",
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime", "-cpu-used", "8", "-c:a", "libopus"])
return put_file(output, ".webm", move=True)
if assets.kind_of(source) == "font":
signatures = {".ttf": b"\x00\x01\x00\x00", ".otf": b"OTTO", ".woff": b"wOFF", ".woff2": b"wOF2", ".ttc": b"ttcf"}
with source.open("rb") as stream:
if stream.read(4) != signatures.get(suffix):
raise ValueError("Некорректный шрифт")
output, ext = assets.convert(source, root)
return put_file(output, ext, move=True)
try:
blob_id, size = await run_in_threadpool(convert)
except Exception as e:
raise HTTPException(422, f"Не удалось обработать файл: {e}") from None
manifest = copy.deepcopy(v.manifest)
manifest["files"][path] = {"hash": blob_id, "size": size}
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
async def revision_graph(db, game_id):
return {r.id: r for r in (await db.scalars(select(Revision).where(Revision.game_id == game_id))).all()}
def ancestors(graph, rid):
found, queue = {}, [(rid, 0)]
while queue:
key, depth = queue.pop(0)
if key in found or key not in graph:
continue
found[key] = depth
queue.extend((p, depth + 1) for p in graph[key].parents)
return found
@router.get("/{version_id}/revisions")
async def history(version_id: int, user: Player, db: DB):
v = await get_version(db, user, version_id)
graph = await revision_graph(db, v.game_id)
reachable = ancestors(graph, v.revision_id)
return [{"id": key, "parents": graph[key].parents, "created_at": graph[key].created_at,
"user_id": graph[key].user_id} for key in sorted(reachable, key=lambda k: graph[k].created_at, reverse=True)]
class RestoreIn(BaseModel):
revision: str
restore: str
@router.post("/{version_id}/revisions/restore")
async def restore(version_id: int, body: RestoreIn, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, body.revision)
graph = await revision_graph(db, v.game_id)
if body.restore not in ancestors(graph, v.revision_id):
raise HTTPException(404, "Ревизия не найдена")
update_manifest(v, copy.deepcopy(graph[body.restore].manifest))
await db.commit()
return {"revision": v.revision_id}
class MergeIn(BaseModel):
target_id: int
source_revision: str
target_revision: str
mode: Literal["merge", "rebase"] = "merge"
choices: dict[str, Literal["left", "right"]] = Field(default_factory=dict)
mapping: dict[str, str] = Field(default_factory=dict)
name: str = Field(default="Объединённый мод", min_length=1, max_length=255)
apply: bool = False
def merge_tree(manifest):
doc = mods.comparable(mods.prepare(mods.document(manifest)))
for info in doc["game"].get("locales", {}).values():
for key in ("progress", "lines", "strings_count"):
info.pop(key, None)
extras = {}
for p, entry in manifest["files"].items():
if p == "game.json" or p in doc["scripts"]:
continue
extras[p] = {"json": copy.deepcopy(file_json(manifest, p))} if p.startswith("locales/") and p.endswith(".json") else entry
return {"document": doc, "files": extras}
@router.post("/{version_id}/merge")
async def merge(version_id: int, body: MergeIn, user: Editor, db: DB):
source = await get_version(db, user, version_id)
target = await get_version(db, user, body.target_id)
if source.game_id != target.game_id or source.id == target.id or source.kind != "mod":
raise HTTPException(422, "Нужен мод и другая версия той же игры")
if source.status != "ready" or target.status != "ready":
raise HTTPException(409, "Версии ещё не готовы")
if body.apply:
# Stable lock ordering avoids deadlocks in simultaneous opposite merges.
locked = {key: await lock_version(db, key) for key in sorted((source.id, target.id))}
source, target = locked[source.id], locked[target.id]
expected(source, body.source_revision)
expected(target, body.target_revision)
graph = await revision_graph(db, source.game_id)
if body.mode == "rebase":
base_id = source.base_revision_id
else:
a, b = ancestors(graph, source.revision_id), ancestors(graph, target.revision_id)
common = set(a) & set(b)
base_id = min(common, key=lambda k: (max(a[k], b[k]), a[k] + b[k], k)) if common else None
if not base_id or base_id not in graph:
raise HTTPException(409, "Нет достоверной базовой ревизии. Для старого мода создайте новый форк и перенесите правки вручную")
def build():
base = merge_tree(graph[base_id].manifest)
left, right = merge_tree(source.manifest), merge_tree(target.manifest)
unmatched = []
aliases, target_tids = {}, {}
if body.mode == "rebase":
before = right["document"]
right["document"], unmatched = mods.align(base["document"], before, body.mapping)
def identities(raw, aligned):
if isinstance(raw, dict) and isinstance(aligned, dict):
if raw.get("id") and aligned.get("id"):
aliases[aligned["id"]] = raw["id"]
if raw.get("tid") and aligned.get("tid"):
target_tids[aligned["tid"]] = raw["tid"]
for key in raw.keys() & aligned.keys():
identities(raw[key], aligned[key])
elif isinstance(raw, list) and isinstance(aligned, list):
for a, b in zip(raw, aligned):
identities(a, b)
identities(before, right["document"])
from app.converter.locales import walk
tids = {}
for path, module in before["scripts"].items():
for old_label, new_label in zip(module["labels"], right["document"]["scripts"][path]["labels"]):
for old_node, new_node in zip(walk(old_label["body"]), walk(new_label["body"])):
if old_node.get("tid") and new_node.get("tid"):
tids[old_node["tid"]] = new_node["tid"]
for entry in right["files"].values():
if set(entry) == {"json"} and isinstance(entry["json"], dict):
entry["json"] = {tids.get(k, k): v for k, v in entry["json"].items()}
conflicts = []
result = mods.merge_value(base, left, right, "", conflicts, body.choices)
if body.mode == "rebase" and body.apply and not conflicts:
# Compare in old-base identity, then adopt the new original's canonical
# IDs. This makes the frozen target a valid base for the NEXT rebase.
def canonical(value):
if isinstance(value, dict):
for key, v in value.items():
if key == "id" and isinstance(v, str):
value[key] = aliases.get(v, v)
elif key == "tid" and isinstance(v, str):
value[key] = target_tids.get(v, v)
else:
canonical(v)
elif isinstance(value, list):
for v in value:
canonical(v)
canonical(result["document"])
for entry in result["files"].values():
if set(entry) == {"json"} and isinstance(entry["json"], dict):
entry["json"] = {target_tids.get(k, k): v for k, v in entry["json"].items()}
return result, conflicts, unmatched
try:
result, conflicts, unmatched = await run_in_threadpool(build)
except (ValueError, TypeError, KeyError) as e:
raise HTTPException(422, str(e)) from None
if not body.apply:
return {"baseRevision": base_id, "conflicts": conflicts, "unmatched": unmatched,
"document": result["document"]}
if conflicts:
raise HTTPException(409, "Остались нерешённые конфликты")
def write():
manifest = {"format": "pgvn/1", "files": {}}
# Keep script entries until store() replaces them and validates references.
for p, f in target.manifest["files"].items():
if p == "game.json" or p in result["document"]["scripts"]:
manifest["files"][p] = f
for p, entry in result["files"].items():
if set(entry) == {"json"}:
put_json(manifest["files"], p, entry["json"])
else:
manifest["files"][p] = entry
return mods.store(result["document"], manifest)
try:
manifest = await run_in_threadpool(write)
except (ValueError, KeyError, TypeError) as e:
raise HTTPException(422, f"Результат не прошёл проверку: {e}") from None
fork = GameVersion(game_id=source.game_id, parent_id=target.id, owner_id=user.id,
name=body.name, kind="mod", visibility="draft", status="ready", manifest=manifest,
base_revision_id=target.revision_id if body.mode == "rebase" else base_id,
size_bytes=sum(f["size"] for f in manifest["files"].values()))
fork._revision_parents = [source.revision_id, target.revision_id]
db.add(fork)
await db.commit()
return {"id": fork.id, "revision": fork.revision_id}