344 lines
15 KiB
Python
344 lines
15 KiB
Python
import asyncio
|
|
from typing import Annotated, Any
|
|
|
|
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 import translation
|
|
from app.deps import DB, CurrentUser, has_perm, require
|
|
from app.gallery import available as gallery_available
|
|
from app.models import Game, GameVersion, Revision, Save, User, VersionHistory
|
|
from app.routers.games import get_game
|
|
from app.schemas import (
|
|
ForkIn,
|
|
GallerySettingsIn,
|
|
HistoryOut,
|
|
PublishIn,
|
|
SaveDetailOut,
|
|
SaveIn,
|
|
SaveOut,
|
|
VersionDetailOut,
|
|
VersionIn,
|
|
VersionOut,
|
|
)
|
|
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.archive import ArchiveError
|
|
from app.converter.compatibility import CompatibilityError
|
|
from app.converter.preflight import probe_archive
|
|
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}
|
|
if getattr(game, "main_version_id", None) is None:
|
|
game.engine = probe["engine"]
|
|
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) -> dict[str, Any]:
|
|
v = await get_version(db, user, version_id)
|
|
return {**VersionDetailOut.model_validate(v).model_dump(),
|
|
"gallery_available": await run_in_threadpool(gallery_available, v.manifest)}
|
|
|
|
|
|
@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, "galleryUnlocked": v.gallery_unlocked}
|
|
|
|
|
|
@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,
|
|
gallery_unlocked=parent.gallery_unlocked,
|
|
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.patch("/versions/{version_id}/gallery", response_model=VersionOut)
|
|
async def update_gallery(version_id: int, body: GallerySettingsIn, user: Player, db: DB) -> GameVersion:
|
|
v = await get_version(db, user, version_id)
|
|
if v.owner_id != user.id:
|
|
raise HTTPException(403, "Открыть галерею можно только в своей версии")
|
|
if v.status != "ready":
|
|
raise HTTPException(409, "Версия ещё не готова")
|
|
if body.gallery_unlocked and not await run_in_threadpool(gallery_available, v.manifest):
|
|
raise HTTPException(422, "Галерея этой игры пока не поддерживается")
|
|
v.gallery_unlocked = body.gallery_unlocked
|
|
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)
|
|
|
|
|
|
# --- cloud saves -----------------------------------------------------------
|
|
# Saves live in the browser by default; these endpoints are the opt-in cloud copy.
|
|
CloudSaves = Annotated[User, Depends(require("games.play", "saves.cloud"))]
|
|
|
|
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: CloudSaves, 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: CloudSaves, 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: CloudSaves, 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: CloudSaves, db: DB) -> None:
|
|
save = await _find_save(db, user, version_id, slot)
|
|
if save:
|
|
await db.delete(save)
|
|
await db.commit()
|