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.
This commit is contained in:
mrleo1nid committed 2026-10-25 04:42:48 +03:00
1 parent 06ff19ac4a
commit c8d83b3fe7
65 files changed
+3825 -68

No files matched your search

+35 -9
View File
@@ -1,11 +1,13 @@
from typing import Annotated, Any
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from app.deps import DB, CurrentUser, has_perm, require
from app.models import Game, GameVersion, Save, User, VersionHistory
from app.models import Game, GameVersion, Revision, Save, User, VersionHistory
from app.routers.games import get_game
from app.schemas import (
ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn,
@@ -77,8 +79,20 @@ async def upload_version(
await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
from app.converter.preflight import probe_archive
from app.converter.archive import ArchiveError
from app.converter.compatibility import CompatibilityError
try:
probe = await asyncio.to_thread(probe_archive, path, filename)
except (CompatibilityError, ArchiveError, OSError, ValueError) as exc:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise HTTPException(422, str(exc)) from None
version.status = "queued"
version.progress = "В очереди на конвертацию"
version.progress = "В очереди на проверку совместимости"
version.report = {"engine": probe["engine"], "compatible": None}
version.size_bytes = size
await db.commit()
return version
@@ -94,7 +108,7 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A
v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v.manifest
return {**v.manifest, "revision": v.revision_id, "versionId": v.id}
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
@@ -117,9 +131,11 @@ async def fork_version(version_id: int, body: ForkIn,
visibility="draft",
kind=body.kind,
manifest=parent.manifest,
base_revision_id=parent.revision_id,
report=parent.report,
size_bytes=parent.size_bytes,
)
fork._revision_parents = [parent.revision_id] if parent.revision_id else []
db.add(fork)
await db.commit()
return fork
@@ -147,16 +163,26 @@ async def publish_version(version_id: int, body: PublishIn, user: Publisher, db:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
game = await db.get(Game, v.game_id)
if v.kind == "translation" and game.main_version_id and game.main_version_id != v.id:
main = await lock_version(db, game.main_version_id)
parent = await db.get(GameVersion, v.parent_id) if v.parent_id else None
langs = translation.changed_languages(v.manifest, parent.manifest if parent else None)
locked = {key: await lock_version(db, key) for key in sorted((v.id, game.main_version_id))}
v, main = locked[v.id], locked[game.main_version_id]
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
if base is None:
raise HTTPException(409, "Историческая база перевода неизвестна. Создайте новый форк перевода")
langs = translation.changed_languages(v.manifest, base.manifest)
if not langs:
raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов")
merged, conflicts = await run_in_threadpool(translation.merge_translation, base.manifest, v.manifest, main.manifest)
if conflicts:
raise HTTPException(409, "Конфликт перевода; согласуйте изменения строк: " + ", ".join(c["path"] for c in conflicts[:10]))
db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest,
note=f"Перевод из «{v.name}»: {', '.join(langs)}"))
main.manifest = translation.merge_languages(main.manifest, v.manifest, langs)
elif body.make_main:
game.main_version_id = v.id
main._revision_parents = [main.revision_id, v.revision_id]
main.manifest = merged
main.size_bytes = sum(f["size"] for f in merged["files"].values())
else:
v = await lock_version(db, v.id)
if body.make_main:
game.main_version_id = v.id
v.visibility = "published"
await db.commit()
await db.refresh(v)