diff --git a/backend/app/permissions.py b/backend/app/permissions.py index 8e3bf3f..419b95f 100644 --- a/backend/app/permissions.py +++ b/backend/app/permissions.py @@ -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"], } diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py index 445ea6a..eceaeff 100644 --- a/backend/app/routers/versions.py +++ b/backend/app/routers/versions.py @@ -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) diff --git a/backend/migrations/versions/0004_cloud_saves_permission.py b/backend/migrations/versions/0004_cloud_saves_permission.py new file mode 100644 index 0000000..e73dcd5 --- /dev/null +++ b/backend/migrations/versions/0004_cloud_saves_permission.py @@ -0,0 +1,18 @@ +"""Cloud saves became an explicit permission; keep them for existing system roles.""" +import sqlalchemy as sa +from alembic import op + +revision = "0004" +down_revision = "0003" +branch_labels = depends_on = None + + +def upgrade(): + op.get_bind().execute(sa.text( + "UPDATE roles SET permissions = array_append(permissions, 'saves.cloud') " + "WHERE is_system AND name IN ('moderator', 'player') AND NOT ('saves.cloud' = ANY(permissions))")) + + +def downgrade(): + op.get_bind().execute(sa.text( + "UPDATE roles SET permissions = array_remove(permissions, 'saves.cloud')")) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d0475f..bffb714 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { NewGame } from "./pages/NewGame"; import { ModEditor } from "./pages/ModEditor"; import { Play } from "./pages/Play"; import { Roles } from "./pages/Roles"; +import { Saves } from "./pages/Saves"; import { TranslationEditor } from "./pages/TranslationEditor"; import { Users } from "./pages/Users"; import { useTheme } from "./theme"; @@ -60,6 +61,7 @@ function UserMenu() {
{me.role.name}
Аккаунт и пароль + Сохранения {(can("users.manage") || can("roles.manage")) &&
Администрирование
} {can("users.manage") && ( Пользователи @@ -117,6 +119,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/Saves.tsx b/frontend/src/pages/Saves.tsx new file mode 100644 index 0000000..e232016 --- /dev/null +++ b/frontend/src/pages/Saves.tsx @@ -0,0 +1,71 @@ +import { useRef, useState } from "react"; +import { useAuth } from "../auth"; +import { cloudEnabled, exportSaves, importSaves, setCloudEnabled } from "../player/saves"; +import { PageHeader } from "../ui/Bits"; +import { Icon } from "../ui/Icon"; +import { useUi } from "../ui/Ui"; + +export function Saves() { + const { can } = useAuth(); + const ui = useUi(); + const allowed = can("saves.cloud"); + const [cloud, setCloud] = useState(cloudEnabled()); + const file = useRef(null); + + const toggle = (on: boolean) => { + setCloudEnabled(on); + setCloud(on); + ui.toast(on ? "Облачные сохранения включены на этом устройстве" : "Облачные сохранения выключены", "ok"); + }; + + const download = async () => { + const url = URL.createObjectURL(new Blob([await exportSaves()], { type: "application/json" })); + const a = document.createElement("a"); + a.href = url; + a.download = "pgames-saves.json"; + a.click(); + URL.revokeObjectURL(url); + }; + + const upload = async (f: File | undefined) => { + if (!f) return; + try { + const n = await importSaves(await f.text()); + ui.toast(n ? `Загружено сохранений: ${n}` : "Новых сохранений в файле нет", "ok"); + } catch (e) { + ui.toast((e as Error).message, "error"); + } + if (file.current) file.current.value = ""; + }; + + return ( +
+ +
+

Облако

+

+ Облачные сохранения копируются на сервер и доступны с других устройств. + Включаются отдельно на каждом устройстве; если сервер недоступен, игра сохраняется локально. +

+ {allowed ? ( + + ) : ( +

У вашей роли нет права «Облачные сохранения» — обратитесь к администратору.

+ )} +
+
+

Перенос файлом

+

Скачайте все локальные сохранения или загрузите файл с другого устройства.

+
+ + + void upload(e.target.files?.[0])} /> +
+
+
+ ); +} diff --git a/frontend/src/player/index.ts b/frontend/src/player/index.ts index 991d3da..3d15404 100644 --- a/frontend/src/player/index.ts +++ b/frontend/src/player/index.ts @@ -2,6 +2,7 @@ import "./player.css"; import { Engine, type SaveApi } from "./engine"; import { loadTranslator, preferredLanguage } from "./i18n"; import { RUNTIMES } from "./runtimes"; +import { cloudActive, makeSaveApi } from "./saves"; import type { GameData, Manifest, SaveData, ScriptModule } from "./types"; async function getJSON(url: string): Promise { @@ -16,28 +17,8 @@ function blob(manifest: Manifest, path: string) { return `/api/versions/${manifest.versionId}/files/${f.hash}`; } -function saveApi(versionId: number): SaveApi { - const base = `/api/versions/${versionId}/saves`; - return { - list: () => getJSON(base), - async get(slot) { - const r = await fetch(`${base}/${slot}`); - if (r.status === 404) return null; - if (!r.ok) throw new Error(`${r.status}`); - return (await r.json()).data as T; - }, - async put(slot, title, data) { - const r = await fetch(`${base}/${slot}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, data }), - }); - if (!r.ok) throw new Error(`${r.status}`); - }, - async remove(slot) { - await fetch(`${base}/${slot}`, { method: "DELETE" }); - }, - }; +function saveApi(versionId: number, cloud: boolean): SaveApi { + return makeSaveApi(versionId, cloud); } export interface PlayerHandle { destroy(): void } @@ -56,12 +37,13 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () => Promise.all(game.scripts.map((s) => getJSON(blob(manifest, s)))), loadTranslator(game, manifest, lang), ]); + const cloud = await cloudActive(); if (game.runtime) { const load = RUNTIMES[game.runtime]; if (!load) throw new Error(`Неизвестная среда исполнения игры: ${game.runtime}`); - return (await load())({ root, game, manifest, modules, url, tr, onExit, versionId, saveApi }); + return (await load())({ root, game, manifest, modules, url, tr, onExit, versionId, saveApi: (id) => saveApi(id, cloud) }); } - const engine = new Engine(root, game, manifest, modules, saveApi(versionId), + const engine = new Engine(root, game, manifest, modules, saveApi(versionId, cloud), `pgvn:persistent:${versionId}`, onExit, tr, (code) => loadTranslator(game, manifest, code)); const at = url.get("at"); diff --git a/frontend/src/player/saves.ts b/frontend/src/player/saves.ts new file mode 100644 index 0000000..0efc499 --- /dev/null +++ b/frontend/src/player/saves.ts @@ -0,0 +1,195 @@ +// Saves are local to every browser (IndexedDB). The server copy is an opt-in +// extra: it is used only when the user switched it on here and has `saves.cloud`. +import type { SaveApi, SaveMeta } from "./engine-util"; + +export interface StoredSave extends SaveMeta { + versionId: number; + data: T; +} + +const DB_NAME = "pgvn-saves"; +const STORE = "saves"; +const CLOUD_KEY = "pgvn:cloud-saves"; + +export function cloudEnabled(): boolean { + try { return localStorage.getItem(CLOUD_KEY) === "1"; } catch { return false; } +} + +export function setCloudEnabled(on: boolean) { + try { localStorage.setItem(CLOUD_KEY, on ? "1" : "0"); } catch { /* storage unavailable */ } +} + +const memory = new Map(); +let opened: Promise | null = null; + +function open(): Promise { + opened ??= new Promise((resolve) => { + try { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "key" }); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + } catch { + resolve(null); // private mode etc.: fall back to memory + } + }); + return opened; +} + +function request(db: IDBDatabase, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise { + return new Promise((resolve, reject) => { + const req = run(db.transaction(STORE, mode).objectStore(STORE)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +const keyOf = (versionId: number, slot: string) => `${versionId}:${slot}`; + +export async function localAll(): Promise { + const db = await open(); + if (!db) return [...memory.values()]; + return (await request(db, "readonly", (s) => s.getAll())) as StoredSave[]; +} + +export async function localGet(versionId: number, slot: string): Promise { + const db = await open(); + if (!db) return memory.get(keyOf(versionId, slot)) ?? null; + return ((await request(db, "readonly", (s) => s.get(keyOf(versionId, slot)))) as StoredSave | undefined) ?? null; +} + +export async function localPut(save: StoredSave): Promise { + const row = { ...save, key: keyOf(save.versionId, save.slot) }; + const db = await open(); + if (!db) memory.set(row.key, row); + else await request(db, "readwrite", (s) => s.put(row)); +} + +export async function localRemove(versionId: number, slot: string): Promise { + const db = await open(); + if (!db) memory.delete(keyOf(versionId, slot)); + else await request(db, "readwrite", (s) => s.delete(keyOf(versionId, slot))); +} + +const time = (iso: string) => Date.parse(iso) || 0; + +/** Cloud availability: switched on in this browser and allowed for the account. */ +export async function cloudActive(): Promise { + if (!cloudEnabled()) return false; + try { + const r = await fetch("/api/auth/me", { credentials: "same-origin" }); + return r.ok && ((await r.json()).permissions as string[]).includes("saves.cloud"); + } catch { + return false; + } +} + +export function makeSaveApi(versionId: number, cloud: boolean): SaveApi { + const base = `/api/versions/${versionId}/saves`; + const cloudList = async (): Promise => { + if (!cloud) return []; + try { + const r = await fetch(base, { credentials: "same-origin" }); + return r.ok ? await r.json() : []; + } catch { + return []; // offline: local saves keep working + } + }; + const cloudPut = async (slot: string, title: string, data: unknown): Promise => { + try { + const r = await fetch(`${base}/${slot}`, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, data }), + }); + return r.ok ? (await r.json()).updated_at : null; + } catch { + return null; + } + }; + const cloudGet = async (slot: string): Promise | null> => { + try { + const r = await fetch(`${base}/${slot}`, { credentials: "same-origin" }); + if (!r.ok) return null; + const body = await r.json(); + return { versionId, slot, title: body.title, updated_at: body.updated_at, data: body.data }; + } catch { + return null; + } + }; + return { + async list() { + const [local, remote] = await Promise.all([ + localAll().then((all) => all.filter((s) => s.versionId === versionId)), cloudList(), + ]); + const best = new Map(); + for (const s of [...local, ...remote]) { + const prev = best.get(s.slot); + if (!prev || time(s.updated_at) > time(prev.updated_at)) { + best.set(s.slot, { slot: s.slot, title: s.title, updated_at: s.updated_at }); + } + } + // local copies the cloud lacks or has older are pushed in the background + if (cloud) { + const theirs = new Map(remote.map((s) => [s.slot, time(s.updated_at)])); + for (const s of local) { + if (time(s.updated_at) > (theirs.get(s.slot) ?? -1)) { + void cloudPut(s.slot, s.title, s.data).then((at) => { if (at) void localPut({ ...s, updated_at: at }); }); + } + } + } + return [...best.values()].sort((a, b) => time(b.updated_at) - time(a.updated_at)); + }, + async get(slot) { + const local = (await localGet(versionId, slot)) as StoredSave | null; + if (!cloud) return local?.data ?? null; + const remote = await cloudGet(slot); + if (remote && (!local || time(remote.updated_at) > time(local.updated_at))) { + await localPut(remote); + return remote.data; + } + return local?.data ?? null; + }, + async put(slot, title, data) { + const row: StoredSave = { versionId, slot, title, data, updated_at: new Date().toISOString() }; + await localPut(row); + if (cloud) { + const at = await cloudPut(slot, title, data); + if (at) await localPut({ ...row, updated_at: at }); + } + }, + async remove(slot) { + await localRemove(versionId, slot); + if (cloud) await fetch(`${base}/${slot}`, { method: "DELETE" }).catch(() => {}); + }, + }; +} + +// --- file export / import (moving saves between browsers without the cloud) --- + +const FILE_TAG = "pgvn-saves"; + +export async function exportSaves(): Promise { + const saves = (await localAll()).map(({ versionId, slot, title, updated_at, data }) => ({ + versionId, slot, title, updated_at, data, + })); + return JSON.stringify({ format: FILE_TAG, version: 1, saves }); +} + +/** Returns how many saves were imported; an existing newer local save wins. */ +export async function importSaves(text: string): Promise { + const file = JSON.parse(text); + if (file?.format !== FILE_TAG || !Array.isArray(file.saves)) throw new Error("Это не файл сохранений PGames"); + let count = 0; + for (const s of file.saves) { + const valid = Number.isInteger(s?.versionId) && typeof s.slot === "string" && /^[\w-]{1,32}$/.test(s.slot) + && typeof s.data === "object" && s.data !== null; + if (!valid) continue; + const have = await localGet(s.versionId, s.slot); + if (have && time(have.updated_at) >= time(String(s.updated_at))) continue; + await localPut({ + versionId: s.versionId, slot: s.slot, title: String(s.title ?? "").slice(0, 255), + updated_at: String(s.updated_at), data: s.data, + }); + count++; + } + return count; +} diff --git a/frontend/tests/saves.test.mjs b/frontend/tests/saves.test.mjs new file mode 100644 index 0000000..ce28d77 --- /dev/null +++ b/frontend/tests/saves.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { beforeEach, test } from "node:test"; +import ts from "typescript"; + +// No indexedDB in node: the module falls back to its in-memory store, which is what we exercise. +const source = readFileSync(new URL("../src/player/saves.ts", import.meta.url), "utf8"); +const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }); +const saves = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`); + +let remote, calls; +beforeEach(async () => { + for (const s of await saves.localAll()) await saves.localRemove(s.versionId, s.slot); + remote = new Map(); + calls = []; + globalThis.fetch = async (url, init = {}) => { + const method = init.method ?? "GET"; + calls.push(`${method} ${url}`); + const m = /saves\/(\w+)$/.exec(url); + const json = (body, ok = true) => ({ ok, json: async () => body }); + if (!m) return json([...remote.entries()].map(([slot, s]) => ({ slot, title: s.title, updated_at: s.updated_at }))); + if (method === "PUT") { + const body = JSON.parse(init.body); + const row = { ...body, updated_at: "2030-01-01T00:00:00Z" }; + remote.set(m[1], row); + return json(row); + } + return remote.has(m[1]) ? json(remote.get(m[1])) : json({}, false); + }; +}); + +test("without the cloud nothing touches the network", async () => { + const api = saves.makeSaveApi(1, false); + await api.put("a", "t", { x: 1 }); + assert.deepEqual(await api.get("a"), { x: 1 }); + assert.equal((await api.list()).length, 1); + assert.deepEqual(calls, []); + await api.remove("a"); + assert.equal(await api.get("a"), null); +}); + +test("saves are separated by game version", async () => { + await saves.makeSaveApi(1, false).put("a", "one", { v: 1 }); + await saves.makeSaveApi(2, false).put("a", "two", { v: 2 }); + assert.deepEqual(await saves.makeSaveApi(1, false).get("a"), { v: 1 }); + assert.equal((await saves.makeSaveApi(2, false).list())[0].title, "two"); +}); + +test("cloud put keeps a local copy and survives a server outage", async () => { + const api = saves.makeSaveApi(1, true); + await api.put("a", "t", { x: 1 }); + assert.deepEqual(remote.get("a").data, { x: 1 }); + globalThis.fetch = async () => { throw new Error("offline"); }; + await api.put("b", "t", { y: 2 }); + assert.deepEqual(await api.get("b"), { y: 2 }); + assert.equal((await api.list()).length, 2); +}); + +test("the newer copy wins when loading", async () => { + remote.set("a", { title: "cloud", data: { from: "cloud" }, updated_at: "2031-01-01T00:00:00Z" }); + await saves.localPut({ versionId: 1, slot: "a", title: "local", data: { from: "local" }, updated_at: "2020-01-01T00:00:00Z" }); + assert.deepEqual(await saves.makeSaveApi(1, true).get("a"), { from: "cloud" }); + assert.equal((await saves.localGet(1, "a")).title, "cloud"); +}); + +test("export and import round-trip and reject foreign files", async () => { + await saves.makeSaveApi(3, false).put("quick", "q", { z: 3 }); + const file = await saves.exportSaves(); + await saves.localRemove(3, "quick"); + assert.equal(await saves.importSaves(file), 1); + assert.deepEqual(await saves.makeSaveApi(3, false).get("quick"), { z: 3 }); + assert.equal(await saves.importSaves(file), 0); + await assert.rejects(saves.importSaves('{"a":1}')); +});