Add .gitattributes: LF in the repository, CRLF for batch files
Normalize the files that had been committed with CRLF (line endings only, no content changes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1d7b29f77
commit
51a7e77e56
14 files changed
+1461
-1447
No files matched your search
+205
-205
@@ -1,205 +1,205 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import ClientDisconnect
|
||||
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.models import GameVersion, Save, User
|
||||
from app.routers.games import get_game
|
||||
from app.schemas import 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:
|
||||
return v.status == "ready" 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, "Пустой файл")
|
||||
|
||||
version.status = "queued"
|
||||
version.progress = "В очереди на конвертацию"
|
||||
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
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
|
||||
async def fork_version(version_id: int, body: VersionIn,
|
||||
user: Annotated[User, Depends(require("games.play", "versions.fork"))],
|
||||
db: DB) -> GameVersion:
|
||||
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",
|
||||
manifest=parent.manifest,
|
||||
report=parent.report,
|
||||
size_bytes=parent.size_bytes,
|
||||
)
|
||||
db.add(fork)
|
||||
await db.commit()
|
||||
return fork
|
||||
|
||||
|
||||
@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()
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import ClientDisconnect
|
||||
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.models import GameVersion, Save, User
|
||||
from app.routers.games import get_game
|
||||
from app.schemas import 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:
|
||||
return v.status == "ready" 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, "Пустой файл")
|
||||
|
||||
version.status = "queued"
|
||||
version.progress = "В очереди на конвертацию"
|
||||
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
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
|
||||
async def fork_version(version_id: int, body: VersionIn,
|
||||
user: Annotated[User, Depends(require("games.play", "versions.fork"))],
|
||||
db: DB) -> GameVersion:
|
||||
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",
|
||||
manifest=parent.manifest,
|
||||
report=parent.report,
|
||||
size_bytes=parent.size_bytes,
|
||||
)
|
||||
db.add(fork)
|
||||
await db.commit()
|
||||
return fork
|
||||
|
||||
|
||||
@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()
|
||||
Reference in new issue
Block a user