feat(saves): store game saves on the player's account
Adds game_save_snapshots and /api/game-saves for copies of the saves games keep in a player's browser. An upload matching the current save is a no-op, the newest 10 snapshots per user and game are kept as backups, and saves may be up to 10 MB, so the router parses its own bodies past the app's 1 MB JSON limit.
This commit is contained in:
@@ -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);
|
||||
@@ -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<typeof import('../config')>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<SnapshotRow>(
|
||||
`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<SnapshotRow>(
|
||||
`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 };
|
||||
})();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user