feat: json export and sqlite backup endpoints
This commit is contained in:
@@ -17,6 +17,7 @@ import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
|||||||
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
||||||
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
||||||
import { registerStatsRoutes } from './routes/statsRoutes.js'
|
import { registerStatsRoutes } from './routes/statsRoutes.js'
|
||||||
|
import { registerDataRoutes } from './routes/dataRoutes.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -58,6 +59,7 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerStreamRoutes(app)
|
await registerStreamRoutes(app)
|
||||||
await registerLoanRoutes(app)
|
await registerLoanRoutes(app)
|
||||||
await registerStatsRoutes(app)
|
await registerStatsRoutes(app)
|
||||||
|
await registerDataRoutes(app)
|
||||||
|
|
||||||
// artwork cache (always available)
|
// artwork cache (always available)
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
|
|||||||
71
server/src/routes/dataRoutes.ts
Normal file
71
server/src/routes/dataRoutes.ts
Normal file
@@ -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<void> {
|
||||||
|
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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
64
server/test/data.test.ts
Normal file
64
server/test/data.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user