diff --git a/server/src/app.ts b/server/src/app.ts index 7987c62..0ce2d18 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -17,6 +17,7 @@ import { registerLibraryRoutes } from './routes/libraryRoutes.js' import { registerStreamRoutes } from './routes/streamRoutes.js' import { registerLoanRoutes } from './routes/loanRoutes.js' import { registerStatsRoutes } from './routes/statsRoutes.js' +import { registerDataRoutes } from './routes/dataRoutes.js' declare module 'fastify' { interface FastifyInstance { @@ -58,6 +59,7 @@ export async function buildApp(opts: AppOptions): Promise { await registerStreamRoutes(app) await registerLoanRoutes(app) await registerStatsRoutes(app) + await registerDataRoutes(app) // artwork cache (always available) await app.register(fastifyStatic, { diff --git a/server/src/routes/dataRoutes.ts b/server/src/routes/dataRoutes.ts new file mode 100644 index 0000000..fc85895 --- /dev/null +++ b/server/src/routes/dataRoutes.ts @@ -0,0 +1,71 @@ +import { FastifyInstance } from 'fastify' +import { existsSync, readdirSync, statSync, unlinkSync } from 'node:fs' +import path from 'node:path' +import { requireAuth, requireAdmin } from './authRoutes.js' + +function matchLinksFor(db: any, userId: number) { + return db + .prepare( + `SELECT ml.item_id AS itemId, ml.album_id AS albumId FROM match_links ml WHERE ml.user_id = ?` + ) + .all(userId) +} + +function listBackups(dir: string): { file: string; sizeBytes: number; createdAt: string }[] { + try { + return readdirSync(dir) + .filter((f) => f.endsWith('.db')) + .map((file) => { + const full = path.join(dir, file) + const st = statSync(full) + return { file, sizeBytes: st.size, createdAt: st.mtime.toISOString() } + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + } catch { + return [] + } +} + +export async function registerDataRoutes(app: FastifyInstance): Promise { + app.get('/api/export', { preHandler: [requireAuth] }, async (request) => { + const db = request.server.db + const userId = request.user!.id + const items = db + .prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added') + .all(userId) + const loans = db.prepare('SELECT * FROM loans WHERE user_id = ?').all(userId) + return { + exportedAt: new Date().toISOString(), + items, + loans, + matchLinks: matchLinksFor(db, userId), + } + }) + + app.post('/api/backup', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { + const config = request.server.config + const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19) + // second-granularity stamp: make the destination unique for rapid successive backups + let dest = path.join(config.backupsDir, `record-shop-${stamp}.db`) + let suffix = 1 + while (existsSync(dest)) { + dest = path.join(config.backupsDir, `record-shop-${stamp}-${suffix}.db`) + suffix += 1 + } + await request.server.db.backup(dest) + // prune to newest 7 + const backups = listBackups(config.backupsDir) + for (const old of backups.slice(7)) { + try { + unlinkSync(path.join(config.backupsDir, old.file)) + } catch { + // best-effort prune + } + } + return { file: path.basename(dest) } + }) + + app.get('/api/backups', { preHandler: [requireAuth, requireAdmin] }, async (request) => { + return { backups: listBackups(request.server.config.backupsDir) } + }) +} diff --git a/server/test/data.test.ts b/server/test/data.test.ts new file mode 100644 index 0000000..c731ac7 --- /dev/null +++ b/server/test/data.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js' + +async function appWithData() { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'TCO' }] }, + }) + return { app, cookie } +} + +describe('export', () => { + it('returns per-user data without secrets', async () => { + const { app, cookie } = await appWithData() + await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 'secret-token' } }) + const res = await app.inject({ method: 'GET', url: '/api/export', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.exportedAt).toBeTruthy() + expect(body.items).toEqual([]) + expect(body.loans).toEqual([]) + expect(body.matchLinks).toEqual([]) + expect(JSON.stringify(body)).not.toContain('secret-token') + await app.close() + }) +}) + +describe('backups', () => { + it('non-admin cannot trigger backups', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const bob = await loginAs(app, cookie, 'bob', 'bobpass123') + const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(bob) }) + expect(res.statusCode).toBe(403) + await app.close() + }) + + it('admin creates a backup file and lists it', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + expect(res.json().file).toMatch(/record-shop-.*\.db$/) + const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) }) + expect(list.json().backups).toHaveLength(1) + expect(list.json().backups[0].file).toMatch(/\.db$/) + await app.close() + }) + + it('prunes to the newest 7 backups', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + for (let i = 0; i < 9; i++) { + await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) }) + } + const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) }) + expect(list.json().backups).toHaveLength(7) + await app.close() + }) +})