Add notes backup management
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { audit } from './audit-log.js';
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const clientOf = db => typeof db?.execute === 'function' ? db : db?.db;
|
||||
const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } };
|
||||
@@ -7,6 +9,109 @@ const number = value => Number(value) || 0;
|
||||
export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, backupDir = null, backfillStep = null }) {
|
||||
const sql = clientOf(db);
|
||||
|
||||
const backupFile = date => join(backupDir || '.', `notes-${date}.json`);
|
||||
const backupDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
|
||||
async function readBackup(date) {
|
||||
if (!backupDate(date)) return null;
|
||||
try {
|
||||
const data = JSON.parse(await readFile(backupFile(date), 'utf8'));
|
||||
if (!Array.isArray(data.notes) || data.notes.some(note => !note?.videoId || !['lyrics', 'chapters'].includes(note.kind) || !note.data)) return null;
|
||||
return data;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function createBackup({ safety = false } = {}) {
|
||||
if (!backupDir || typeof db.allNotes !== 'function') throw new Error('Backup storage unavailable');
|
||||
await mkdir(safety ? join(backupDir, 'snapshots') : backupDir, { recursive: true });
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
const file = safety ? join(backupDir, 'snapshots', `notes-${date}-${now.toISOString().slice(11, 19).replaceAll(':', '-')}-safety.json`) : backupFile(date);
|
||||
const payload = { exportedAt: now.toISOString(), notes: await db.allNotes() };
|
||||
await writeFile(file, JSON.stringify(payload));
|
||||
return { file, date, notes: payload.notes.length };
|
||||
}
|
||||
|
||||
app.get('/api/admin/backups', adminAuth, async c => {
|
||||
if (!backupDir) return c.json({ ok: false, error: 'Backup storage unavailable' }, 503);
|
||||
let names = []; try { names = await readdir(backupDir); } catch {}
|
||||
const backups = [];
|
||||
for (const name of names.filter(value => /^notes-\d{4}-\d{2}-\d{2}\.json$/.test(value)).sort().reverse()) {
|
||||
const date = name.slice(6, 16); const data = await readBackup(date); const info = await stat(backupFile(date)).catch(() => null);
|
||||
backups.push({ date, bytes: info?.size || 0, exportedAt: data?.exportedAt || null, notes: data?.notes.length || 0, valid: !!data });
|
||||
}
|
||||
return c.json({ ok: true, backups }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
app.get('/api/admin/backups/:date', adminAuth, async c => {
|
||||
const data = await readBackup(c.req.param('date'));
|
||||
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
|
||||
const live = await db.allNotes();
|
||||
const index = rows => new Map(rows.map(row => [`${row.videoId}:${row.kind}`, JSON.stringify(row.data)]));
|
||||
const old = index(data.notes), current = index(live);
|
||||
const added = [...old.keys()].filter(key => !current.has(key));
|
||||
const removed = [...current.keys()].filter(key => !old.has(key));
|
||||
const changed = [...old.keys()].filter(key => current.has(key) && old.get(key) !== current.get(key));
|
||||
return c.json({ ok: true, date: c.req.param('date'), exportedAt: data.exportedAt, total: data.notes.length,
|
||||
diff: { added: added.length, removed: removed.length, changed: changed.length, preview: [...changed.slice(0, 30).map(key => ({ key, change: 'changed' })), ...added.slice(0, 15).map(key => ({ key, change: 'only in backup' })), ...removed.slice(0, 15).map(key => ({ key, change: 'only live' }))] } });
|
||||
});
|
||||
|
||||
app.get('/api/admin/backups/:date/download', adminAuth, async c => {
|
||||
const data = await readBackup(c.req.param('date'));
|
||||
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
|
||||
c.header('Content-Disposition', `attachment; filename="notes-${c.req.param('date')}.json"`);
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
app.post('/api/admin/backups', adminAuth, async c => {
|
||||
try {
|
||||
const result = await createBackup();
|
||||
await audit(db, 'admin', 'backup.create', result.date, { notes: result.notes });
|
||||
return c.json({ ok: true, ...result });
|
||||
} catch (error) { return c.json({ ok: false, error: error.message }, 503); }
|
||||
});
|
||||
|
||||
app.post('/api/admin/backups/:date/restore', adminAuth, async c => {
|
||||
const date = c.req.param('date'); const data = await readBackup(date);
|
||||
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
|
||||
let body = {}; try { body = await c.req.json(); } catch {}
|
||||
const selected = body.videoId ? data.notes.filter(note => note.videoId === body.videoId) : data.notes;
|
||||
if (body.videoId && !selected.length) return c.json({ ok: false, error: 'Song is not present in this backup' }, 404);
|
||||
const safety = await createBackup({ safety: true });
|
||||
for (const note of selected) await db.saveNote({ videoId: note.videoId, kind: note.kind, data: note.data, source: 'restore', updatedBy: `admin (backup ${date})`, force: true });
|
||||
if (!body.videoId) {
|
||||
const keep = new Set(data.notes.map(note => `${note.videoId}:${note.kind}`));
|
||||
for (const note of await db.allNotes()) if (!keep.has(`${note.videoId}:${note.kind}`)) {
|
||||
await sql.execute({ sql: 'DELETE FROM video_notes WHERE video_id=? AND kind=?', args: [note.videoId, note.kind] });
|
||||
}
|
||||
}
|
||||
await audit(db, 'admin', 'backup.restore', date, { videoId: body.videoId || null, restored: selected.length, safetySnapshot: safety.file });
|
||||
return c.json({ ok: true, restored: selected.length, safetySnapshot: safety.file });
|
||||
});
|
||||
|
||||
app.get('/api/admin/backups/export/sqlite-dump', adminAuth, async c => {
|
||||
const schema = await sql.execute("SELECT type,name,sql FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END,name");
|
||||
const quote = value => {
|
||||
if (value == null) return 'NULL';
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
|
||||
if (typeof value === 'bigint') return String(value);
|
||||
if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString('hex')}'`;
|
||||
return `'${String(value).replaceAll("'", "''")}'`;
|
||||
};
|
||||
const statements = ['PRAGMA foreign_keys=OFF;', 'BEGIN TRANSACTION;'];
|
||||
for (const table of schema.rows.filter(row => row.type === 'table')) {
|
||||
statements.push(`${table.sql};`);
|
||||
const rows = await sql.execute(`SELECT * FROM "${table.name.replaceAll('"', '""')}"`);
|
||||
for (const row of rows.rows) {
|
||||
const columns = Object.keys(row);
|
||||
if (!columns.length) continue;
|
||||
statements.push(`INSERT INTO "${table.name}" (${columns.map(name => `"${name.replaceAll('"', '""')}"`).join(',')}) VALUES (${columns.map(name => quote(row[name])).join(',')});`);
|
||||
}
|
||||
}
|
||||
for (const object of schema.rows.filter(row => row.type !== 'table')) statements.push(`${object.sql};`);
|
||||
statements.push('COMMIT;');
|
||||
c.header('Content-Disposition', 'attachment; filename="ytplayer-notes-dump.sql"');
|
||||
return c.text(statements.join('\n'), 200, { 'Content-Type': 'application/sql; charset=utf-8' });
|
||||
});
|
||||
|
||||
app.get('/api/admin/lyrics/quality', adminAuth, async c => {
|
||||
const minLines = Math.max(1, Math.min(200, Number(c.req.query('minLines')) || 8));
|
||||
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||
|
||||
Reference in New Issue
Block a user