diff --git a/backend/src/migrations/008_game_saves.sql b/backend/src/migrations/008_game_saves.sql new file mode 100644 index 0000000..477e137 --- /dev/null +++ b/backend/src/migrations/008_game_saves.sql @@ -0,0 +1,21 @@ +-- =================================== +-- Migration 008: Game saves +-- =================================== +-- Copies of the saves games keep in a player's browser, so a fresh browser can +-- restore them. Each row is one snapshot; the newest few per user and game are +-- kept as backups. + +CREATE TABLE IF NOT EXISTS game_save_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + game_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + data TEXT NOT NULL, + size INTEGER NOT NULL, + saved_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_game_save_snapshots_user_game + ON game_save_snapshots(user_id, game_id, id DESC); diff --git a/backend/src/routes/game-saves.test.ts b/backend/src/routes/game-saves.test.ts new file mode 100644 index 0000000..854b776 --- /dev/null +++ b/backend/src/routes/game-saves.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import express, { Express } from 'express'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import fs from 'fs'; + +vi.mock('../utils/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +// UserDatabaseService creates the directory +const tempDir = vi.hoisted( + () => `${process.env.TMPDIR ?? '/tmp'}/game-saves-${process.pid}-${Date.now()}` +); + +vi.mock('../config', async (importOriginal) => { + const actual = await importOriginal(); + return { config: { ...actual.config, userDbPath: `${tempDir}/user.db` } }; +}); + +import gameSavesRouter from './game-saves'; +import { errorHandler } from '../middleware/errorHandler'; +import { UserDatabaseService } from '../services/UserDatabaseService'; +import { AuthService } from '../services/AuthService'; + +const GAME_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df'; + +describe('game saves API', () => { + let app: Express; + let alice: string; + let bob: string; + + beforeAll(async () => { + await UserDatabaseService.initialize(); + const authService = new AuthService(); + alice = (await authService.trustedLogin('alice', null)).tokens.accessToken; + bob = (await authService.trustedLogin('bobby', null)).tokens.accessToken; + app = express(); + app.use(cookieParser()); + app.use('/api/game-saves', gameSavesRouter); + app.use(errorHandler); + }); + + afterAll(() => { + UserDatabaseService.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + const save = (token: string, hash: string, data: object = { localStorage: { k: hash } }) => + request(app) + .post(`/api/game-saves/${GAME_ID}`) + .set('Authorization', `Bearer ${token}`) + .send({ data, hash, savedAt: '2026-10-12T10:00:00.000Z' }); + + const fetchSaves = (token: string) => + request(app).get(`/api/game-saves/${GAME_ID}`).set('Authorization', `Bearer ${token}`); + + it('starts empty and requires a signed-in user', async () => { + expect((await request(app).get(`/api/game-saves/${GAME_ID}`)).status).toBe(401); + expect((await fetchSaves(alice)).body).toEqual({ latest: null, snapshots: [] }); + }); + + it('stores saves, skipping one identical to the current save', async () => { + const first = await save(alice, 'h1'); + expect(first.status).toBe(201); + expect((await save(alice, 'h1')).status).toBe(200); + expect((await save(alice, 'h2')).status).toBe(201); + + const { body } = await fetchSaves(alice); + expect(body.latest).toMatchObject({ hash: 'h2', data: { localStorage: { k: 'h2' } } }); + expect(body.snapshots.map((s: { hash: string }) => s.hash)).toEqual(['h2', 'h1']); + + const older = await request(app) + .get(`/api/game-saves/${GAME_ID}/${first.body.id}`) + .set('Authorization', `Bearer ${alice}`); + expect(older.body).toMatchObject({ hash: 'h1', data: { localStorage: { k: 'h1' } } }); + }); + + it('keeps only the newest 10 saves', async () => { + for (let i = 3; i <= 14; i++) { + await save(alice, `h${i}`); + } + const { body } = await fetchSaves(alice); + expect(body.snapshots).toHaveLength(10); + expect(body.snapshots[0].hash).toBe('h14'); + expect(body.snapshots[9].hash).toBe('h5'); + }); + + it("keeps each user's saves separate", async () => { + expect((await fetchSaves(bob)).body.latest).toBeNull(); + const aliceSnapshot = (await fetchSaves(alice)).body.latest.id; + const res = await request(app) + .get(`/api/game-saves/${GAME_ID}/${aliceSnapshot}`) + .set('Authorization', `Bearer ${bob}`); + expect(res.status).toBe(404); + }); + + it('accepts saves over the app-wide 1 MB body limit, and clamps future timestamps', async () => { + const big = { indexedDB: 'x'.repeat(2 * 1024 * 1024) }; + const res = await request(app) + .post(`/api/game-saves/${GAME_ID}`) + .set('Authorization', `Bearer ${bob}`) + .send({ data: big, hash: 'big', savedAt: '2999-01-01T00:00:00.000Z' }); + expect(res.status).toBe(201); + expect(res.body.size).toBeGreaterThan(2 * 1024 * 1024); + expect(Date.parse(res.body.savedAt)).toBeLessThanOrEqual(Date.now()); + }); + + it('rejects invalid input', async () => { + const bad = await request(app) + .post('/api/game-saves/not-a-game') + .set('Authorization', `Bearer ${alice}`) + .send({ data: {}, hash: 'x', savedAt: '2026-10-12T10:00:00.000Z' }); + expect(bad.status).toBe(400); + const noHash = await request(app) + .post(`/api/game-saves/${GAME_ID}`) + .set('Authorization', `Bearer ${alice}`) + .send({ data: {}, savedAt: '2026-10-12T10:00:00.000Z' }); + expect(noHash.status).toBe(400); + }); +}); diff --git a/backend/src/routes/game-saves.ts b/backend/src/routes/game-saves.ts new file mode 100644 index 0000000..dfa7b97 --- /dev/null +++ b/backend/src/routes/game-saves.ts @@ -0,0 +1,74 @@ +import express, { Router } from 'express'; +import { z } from 'zod'; +import { authenticate } from '../middleware/auth'; +import { requirePermission } from '../middleware/rbac'; +import { AppError } from '../middleware/errorHandler'; +import { asyncHandler } from '../middleware/asyncHandler'; +import { GameSaveService } from '../services/GameSaveService'; + +const router = Router(); +const gameSaveService = new GameSaveService(); + +/** Saves can outgrow the app's 1 MB JSON limit; server.ts leaves parsing to this router. */ +export const GAME_SAVE_BODY_LIMIT = '10mb'; + +const gameIdSchema = z.string().uuid('Invalid game ID format'); + +const saveSchema = z.object({ + data: z.record(z.unknown()), + hash: z.string().min(1).max(64), + savedAt: z.string().datetime(), +}); + +router.use(express.json({ limit: GAME_SAVE_BODY_LIMIT })); +router.use(authenticate, requirePermission('games.play')); + +function userId(req: express.Request): number { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } + return req.user.id; +} + +// The current save with its data, and every kept snapshot's metadata +router.get( + '/:gameId', + asyncHandler(async (req, res) => { + const gameId = gameIdSchema.parse(req.params.gameId); + const id = userId(req); + res.json({ + latest: gameSaveService.get(id, gameId), + snapshots: gameSaveService.list(id, gameId), + }); + }) +); + +router.get( + '/:gameId/:snapshotId', + asyncHandler(async (req, res) => { + const gameId = gameIdSchema.parse(req.params.gameId); + const snapshotId = parseInt(req.params.snapshotId, 10); + if (isNaN(snapshotId)) { + throw new AppError(400, 'Invalid snapshot ID'); + } + const snapshot = gameSaveService.get(userId(req), gameId, snapshotId); + if (!snapshot) { + throw new AppError(404, 'Save not found'); + } + res.json(snapshot); + }) +); + +router.post( + '/:gameId', + asyncHandler(async (req, res) => { + const gameId = gameIdSchema.parse(req.params.gameId); + const body = saveSchema.parse(req.body); + // A browser's clock can run ahead; a save can't be from the future + const savedAt = new Date(Math.min(Date.parse(body.savedAt), Date.now())).toISOString(); + const result = gameSaveService.save(userId(req), gameId, body.data, body.hash, savedAt); + res.status(result.created ? 201 : 200).json(result.snapshot); + }) +); + +export default router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 2423f73..1abd0bd 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -22,6 +22,7 @@ import userPlaylistsRouter from './user-playlists'; import sharedPlaylistsRouter from './shared-playlists'; import favoritesRouter from './favorites'; import ratingsRouter from './ratings'; +import gameSavesRouter from './game-saves'; import ruffleRouter from './ruffle'; import githubRouter from './github'; import domainsRouter from './domains'; @@ -49,6 +50,7 @@ export function setupRoutes(app: Express): void { app.use('/api/playlists/shared', sharedPlaylistsRouter); app.use('/api/favorites', favoritesRouter); app.use('/api/ratings', ratingsRouter); + app.use('/api/game-saves', gameSavesRouter); app.use('/api/games', gamesRouter); app.use('/api/games', downloadsRouter); app.use('/api/platforms', platformsRouter); diff --git a/backend/src/server.ts b/backend/src/server.ts index 55bdfd9..244be37 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -209,7 +209,11 @@ async function startServer() { }) ); - app.use(express.json({ limit: '1mb' })); + // Game saves parse their own, larger bodies (routes/game-saves.ts) + const jsonParser = express.json({ limit: '1mb' }); + app.use((req, res, next) => + req.path.startsWith('/api/game-saves') ? next() : jsonParser(req, res, next) + ); app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(cookieParser()); diff --git a/backend/src/services/GameSaveService.ts b/backend/src/services/GameSaveService.ts new file mode 100644 index 0000000..b7334f7 --- /dev/null +++ b/backend/src/services/GameSaveService.ts @@ -0,0 +1,102 @@ +import { UserDatabaseService } from './UserDatabaseService'; + +/** Snapshots kept per user and game, the newest being the current save. */ +export const SNAPSHOTS_KEPT = 10; + +export interface GameSaveSnapshotMeta { + id: number; + hash: string; + size: number; + savedAt: string; + createdAt: string; +} + +export interface GameSaveSnapshot extends GameSaveSnapshotMeta { + data: unknown; +} + +interface SnapshotRow { + id: number; + content_hash: string; + size: number; + saved_at: string; + created_at: string; + data?: string; +} + +function toMeta(row: SnapshotRow): GameSaveSnapshotMeta { + return { + id: row.id, + hash: row.content_hash, + size: row.size, + savedAt: row.saved_at, + createdAt: row.created_at, + }; +} + +/** Stores copies of the saves games keep in a player's browser. */ +export class GameSaveService { + /** All snapshots for a game, newest first. */ + list(userId: number, gameId: string): GameSaveSnapshotMeta[] { + return UserDatabaseService.all( + `SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots + WHERE user_id = ? AND game_id = ? ORDER BY id DESC`, + [userId, gameId] + ).map(toMeta); + } + + get(userId: number, gameId: string, id?: number): GameSaveSnapshot | null { + const row = UserDatabaseService.get( + `SELECT id, content_hash, size, saved_at, created_at, data FROM game_save_snapshots + WHERE user_id = ? AND game_id = ? ${id === undefined ? '' : 'AND id = ?'} + ORDER BY id DESC LIMIT 1`, + id === undefined ? [userId, gameId] : [userId, gameId, id] + ); + return row ? { ...toMeta(row), data: JSON.parse(row.data ?? 'null') } : null; + } + + /** + * Stores a snapshot as the game's current save, unless it matches the current + * one, then drops snapshots beyond the newest SNAPSHOTS_KEPT. + */ + save( + userId: number, + gameId: string, + data: unknown, + hash: string, + savedAt: string + ): { snapshot: GameSaveSnapshotMeta; created: boolean } { + const json = JSON.stringify(data); + const db = UserDatabaseService.getDatabase(); + return db.transaction(() => { + const latest = db + .prepare( + `SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots + WHERE user_id = ? AND game_id = ? ORDER BY id DESC LIMIT 1` + ) + .get(userId, gameId) as SnapshotRow | undefined; + if (latest?.content_hash === hash) { + return { snapshot: toMeta(latest), created: false }; + } + + const result = db + .prepare( + `INSERT INTO game_save_snapshots (user_id, game_id, content_hash, data, size, saved_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run(userId, gameId, hash, json, Buffer.byteLength(json), savedAt); + db.prepare( + `DELETE FROM game_save_snapshots WHERE user_id = ? AND game_id = ? AND id NOT IN ( + SELECT id FROM game_save_snapshots WHERE user_id = ? AND game_id = ? + ORDER BY id DESC LIMIT ?)` + ).run(userId, gameId, userId, gameId, SNAPSHOTS_KEPT); + + const row = db + .prepare( + `SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots WHERE id = ?` + ) + .get(result.lastInsertRowid) as SnapshotRow; + return { snapshot: toMeta(row), created: true }; + })(); + } +}