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.
314 lines
14 KiB
Python
314 lines
14 KiB
Python
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, Revision, Save, User, VersionHistory
|
|
from app.routers.games import get_game
|
|
from app.schemas import (
|
|
ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn,
|
|
VersionOut,
|
|
)
|
|
from app import translation
|
|
from app.uploads import remove_upload, upload_path
|
|
|
|
router = APIRouter(prefix="/api", tags=["versions"])
|
|
Player = Annotated[User, Depends(require("games.play"))]
|
|
Uploader = Annotated[User, Depends(require("games.upload"))]
|
|
|
|
ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
|
|
|
|
|
|
def can_see(user: User, v: GameVersion) -> bool:
|
|
published = v.visibility == "published" or has_perm(user, "versions.publish")
|
|
return (v.status == "ready" and published) or v.owner_id == user.id or can_manage(user, v)
|
|
|
|
|
|
def can_manage(user: User, v: GameVersion) -> bool:
|
|
return (
|
|
v.owner_id == user.id
|
|
or has_perm(user, "versions.manage_any")
|
|
or has_perm(user, "games.delete")
|
|
)
|
|
|
|
|
|
async def get_version(db: DB, user: User, version_id: int) -> GameVersion:
|
|
v = await db.get(GameVersion, version_id)
|
|
if not v or not can_see(user, v):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
|
|
return v
|
|
|
|
|
|
@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201)
|
|
async def upload_version(
|
|
game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал"
|
|
) -> GameVersion:
|
|
"""Stream a raw archive body to disk and queue it for conversion."""
|
|
game = await get_game(db, game_id)
|
|
if not filename.lower().endswith(ARCHIVE_EXTS):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST,
|
|
f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}")
|
|
version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал",
|
|
upload_name=filename, status="uploading")
|
|
db.add(version)
|
|
await db.commit()
|
|
|
|
path = upload_path(version.id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
size = 0
|
|
try:
|
|
with path.open("wb") as f:
|
|
async for chunk in request.stream():
|
|
f.write(chunk)
|
|
size += len(chunk)
|
|
except Exception as e:
|
|
remove_upload(version.id)
|
|
await db.delete(version)
|
|
await db.commit()
|
|
if isinstance(e, ClientDisconnect):
|
|
# usually a proxy in front cut the body off (e.g. its own 413)
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None
|
|
raise
|
|
if size == 0:
|
|
remove_upload(version.id)
|
|
await db.delete(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.report = {"engine": probe["engine"], "compatible": None}
|
|
version.size_bytes = size
|
|
await db.commit()
|
|
return version
|
|
|
|
|
|
@router.get("/versions/{version_id}", response_model=VersionDetailOut)
|
|
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
|
|
return await get_version(db, user, version_id)
|
|
|
|
|
|
@router.get("/versions/{version_id}/manifest")
|
|
async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]:
|
|
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, "revision": v.revision_id, "versionId": v.id}
|
|
|
|
|
|
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
|
|
async def fork_version(version_id: int, body: ForkIn,
|
|
user: Annotated[User, Depends(require("games.play"))],
|
|
db: DB) -> GameVersion:
|
|
needed = "translations.edit" if body.kind == "translation" else "versions.fork"
|
|
if not has_perm(user, needed):
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Нет прав: {needed}")
|
|
parent = await get_version(db, user, version_id)
|
|
if parent.status != "ready":
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
|
|
fork = GameVersion(
|
|
game_id=parent.game_id,
|
|
parent_id=parent.id,
|
|
owner_id=user.id,
|
|
name=body.name.strip(),
|
|
description=body.description,
|
|
status="ready",
|
|
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
|
|
|
|
|
|
Publisher = Annotated[User, Depends(require("versions.publish"))]
|
|
|
|
|
|
async def lock_version(db: DB, version_id: int) -> GameVersion:
|
|
"""Row-locked version for read-modify-write of its manifest."""
|
|
v = await db.scalar(select(GameVersion).where(GameVersion.id == version_id)
|
|
.with_for_update(of=GameVersion)
|
|
.execution_options(populate_existing=True))
|
|
if v is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
|
|
return v
|
|
|
|
|
|
@router.post("/versions/{version_id}/publish", response_model=VersionOut)
|
|
async def publish_version(version_id: int, body: PublishIn, user: Publisher, db: DB) -> GameVersion:
|
|
"""Publish a draft. A translation is merged into the game's main version in place,
|
|
so players keep their saves; the previous manifest goes to the history."""
|
|
v = await get_version(db, user, version_id)
|
|
if v.status != "ready":
|
|
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:
|
|
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._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)
|
|
return v
|
|
|
|
|
|
@router.post("/versions/{version_id}/make-main", response_model=VersionOut)
|
|
async def make_main(version_id: int, user: Publisher, db: DB) -> GameVersion:
|
|
v = await get_version(db, user, version_id)
|
|
if v.status != "ready":
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
|
|
game = await db.get(Game, v.game_id)
|
|
game.main_version_id = v.id
|
|
v.visibility = "published"
|
|
await db.commit()
|
|
await db.refresh(v)
|
|
return v
|
|
|
|
|
|
@router.get("/versions/{version_id}/history", response_model=list[HistoryOut])
|
|
async def version_history(version_id: int, user: Player, db: DB) -> list[VersionHistory]:
|
|
await get_version(db, user, version_id)
|
|
return list((await db.scalars(select(VersionHistory).where(VersionHistory.version_id == version_id)
|
|
.order_by(VersionHistory.id.desc()))).all())
|
|
|
|
|
|
@router.post("/versions/{version_id}/history/{history_id}/restore", response_model=VersionOut)
|
|
async def restore_history(version_id: int, history_id: int, user: Publisher, db: DB) -> GameVersion:
|
|
v = await lock_version(db, version_id)
|
|
h = await db.get(VersionHistory, history_id)
|
|
if h is None or h.version_id != v.id:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Запись истории не найдена")
|
|
db.add(VersionHistory(version_id=v.id, user_id=user.id, manifest=v.manifest,
|
|
note=f"До отката к записи от {h.created_at:%d.%m.%Y %H:%M}"))
|
|
v.manifest = h.manifest
|
|
await db.commit()
|
|
await db.refresh(v)
|
|
return v
|
|
|
|
|
|
@router.patch("/versions/{version_id}", response_model=VersionOut)
|
|
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
|
|
v = await get_version(db, user, version_id)
|
|
if not can_manage(user, v):
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
|
|
v.name = body.name.strip()
|
|
v.description = body.description
|
|
await db.commit()
|
|
return v
|
|
|
|
|
|
@router.post("/versions/{version_id}/retry", response_model=VersionOut)
|
|
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
|
|
v = await get_version(db, user, version_id)
|
|
if not can_manage(user, v):
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
|
|
if v.status != "failed" or not upload_path(v.id).exists():
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна")
|
|
v.status = "queued"
|
|
v.progress = "В очереди на конвертацию"
|
|
await db.commit()
|
|
return v
|
|
|
|
|
|
@router.delete("/versions/{version_id}", status_code=204)
|
|
async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
|
|
v = await get_version(db, user, version_id)
|
|
if not can_manage(user, v):
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
|
|
if v.status in ("converting", "importing"):
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
|
|
await db.delete(v)
|
|
await db.commit()
|
|
remove_upload(version_id)
|
|
|
|
|
|
# --- saves -----------------------------------------------------------------
|
|
|
|
def _check_slot(slot: str) -> str:
|
|
if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот")
|
|
return slot
|
|
|
|
|
|
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
|
|
async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
|
|
await get_version(db, user, version_id)
|
|
return list((await db.scalars(
|
|
select(Save).where(Save.user_id == user.id, Save.version_id == version_id)
|
|
.order_by(Save.updated_at.desc())
|
|
)).all())
|
|
|
|
|
|
async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None:
|
|
return await db.scalar(select(Save).where(
|
|
Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot)))
|
|
|
|
|
|
@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
|
|
async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
|
|
save = await _find_save(db, user, version_id, slot)
|
|
if not save:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
|
|
return save
|
|
|
|
|
|
@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut)
|
|
async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save:
|
|
await get_version(db, user, version_id)
|
|
save = await _find_save(db, user, version_id, slot)
|
|
if save is None:
|
|
save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255],
|
|
data=body.data)
|
|
db.add(save)
|
|
else:
|
|
save.title = body.title[:255]
|
|
save.data = body.data
|
|
await db.commit()
|
|
await db.refresh(save)
|
|
return save
|
|
|
|
|
|
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
|
|
async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
|
|
save = await _find_save(db, user, version_id, slot)
|
|
if save:
|
|
await db.delete(save)
|
|
await db.commit()
|