feat: local browser saves with opt-in cloud sync behind saves.cloud permission
CI / frontend (push) Successful in 21s
CI / backend (push) Successful in 47s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:44:42 +03:00
1 parent 4418da47eb
commit 47fc121e7d
8 files changed
+377 -31

No files matched your search

+3 -2
View File
@@ -9,6 +9,7 @@ PERMISSIONS: dict[str, str] = {
"versions.manage_any": "Управление чужими версиями",
"translations.edit": "Перевод игр в своих версиях",
"versions.publish": "Публикация версий и переводов, выбор главной версии",
"saves.cloud": "Облачные сохранения (синхронизация между устройствами)",
"users.manage": "Управление пользователями",
"roles.manage": "Управление ролями",
}
@@ -18,6 +19,6 @@ ALL = sorted(PERMISSIONS)
SYSTEM_ROLES: dict[str, list[str]] = {
"admin": ALL,
"moderator": ["games.play", "games.upload", "games.edit", "versions.fork",
"translations.edit", "versions.publish"],
"player": ["games.play"],
"translations.edit", "versions.publish", "saves.cloud"],
"player": ["games.play", "saves.cloud"],
}
+7 -5
View File
@@ -268,7 +268,9 @@ async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
remove_upload(version_id)
# --- saves -----------------------------------------------------------------
# --- 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):
@@ -277,7 +279,7 @@ def _check_slot(slot: str) -> str:
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
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)
@@ -291,7 +293,7 @@ async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | N
@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
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, "Сохранение не найдено")
@@ -299,7 +301,7 @@ async def get_save(version_id: int, slot: str, user: Player, db: DB) -> 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:
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:
@@ -315,7 +317,7 @@ async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: D
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
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)