Add searchable admin audit history
This commit is contained in:
53
server/audit-log.js
Normal file
53
server/audit-log.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const asJson = value => {
|
||||
try { return JSON.stringify(value ?? {}); } catch { return '{}'; }
|
||||
}
|
||||
|
||||
export async function audit(db, actor, action, target = '', detail = {}) {
|
||||
const client = typeof db?.execute === 'function' ? db : db?.db;
|
||||
if (!client?.execute) throw new TypeError('audit database client is unavailable');
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO audit_log (ts,actor,action,target,detail) VALUES (unixepoch(),?,?,?,?)',
|
||||
args: [String(actor || 'admin').slice(0, 120), String(action).slice(0, 120), String(target || '').slice(0, 300), asJson(detail).slice(0, 8000)],
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAuditRoutes(app, { db, adminAuth }) {
|
||||
const client = typeof db?.execute === 'function' ? db : db?.db;
|
||||
app.get('/api/admin/audit', adminAuth, async c => {
|
||||
const format = c.req.query('format');
|
||||
const limit = Math.max(1, Math.min(format ? 10000 : 200, Number(c.req.query('limit')) || (format ? 10000 : 50)));
|
||||
const offset = format ? 0 : Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||
const filters = [];
|
||||
const args = [];
|
||||
for (const key of ['actor', 'action']) {
|
||||
const value = String(c.req.query(key) || '').trim().slice(0, 120);
|
||||
if (value) { filters.push(`${key} LIKE ?`); args.push(`%${value.replace(/[\\%_]/g, '\\$&')}%`); }
|
||||
}
|
||||
const search = String(c.req.query('q') || '').trim().slice(0, 160);
|
||||
if (search) { filters.push('(actor LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ?)'); args.push(...Array(4).fill(`%${search.replace(/[\\%_]/g, '\\$&')}%`)); }
|
||||
const from = Number(c.req.query('from'));
|
||||
const to = Number(c.req.query('to'));
|
||||
if (Number.isFinite(from) && from > 0) { filters.push('ts >= ?'); args.push(Math.floor(from)); }
|
||||
if (Number.isFinite(to) && to > 0) { filters.push('ts <= ?'); args.push(Math.floor(to)); }
|
||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
const [rows, count] = await Promise.all([
|
||||
client.execute({ sql: `SELECT id,ts,actor,action,target,detail FROM audit_log ${where} ORDER BY ts DESC,id DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] }),
|
||||
client.execute({ sql: `SELECT COUNT(*) AS total FROM audit_log ${where}`, args }),
|
||||
]);
|
||||
const events = rows.rows.map(row => ({
|
||||
id: Number(row.id), ts: Number(row.ts), actor: row.actor, action: row.action, target: row.target,
|
||||
detail: (() => { try { return JSON.parse(row.detail || '{}'); } catch { return {}; } })(),
|
||||
}));
|
||||
if (format === 'json') {
|
||||
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.json"');
|
||||
return c.json({ events, total: Number(count.rows[0]?.total) || 0 });
|
||||
}
|
||||
if (format === 'csv') {
|
||||
const quote = value => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
const body = ['id,ts,actor,action,target,detail', ...events.map(row => [row.id, new Date(row.ts * 1000).toISOString(), row.actor, row.action, row.target, JSON.stringify(row.detail)].map(quote).join(','))].join('\n');
|
||||
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.csv"');
|
||||
return c.text(body, 200, { 'Content-Type': 'text/csv; charset=utf-8' });
|
||||
}
|
||||
return c.json({ ok: true, events, total: Number(count.rows[0]?.total) || 0, limit, offset }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
}
|
||||
35
server/audit-log.test.js
Normal file
35
server/audit-log.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'ytp-audit-test-'));
|
||||
process.env.DB_PATH = join(root, 'audit.db');
|
||||
const db = await import('./db.js');
|
||||
const { audit, registerAuditRoutes } = await import('./audit-log.js');
|
||||
const app = new Hono();
|
||||
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.initDb();
|
||||
registerAuditRoutes(app, { db, adminAuth: auth });
|
||||
await audit(db, 'admin', 'notes.save.lyrics', 'video_1', { rev: 2 });
|
||||
await audit(db, 'editor@example.test', 'report.resolve', '42', { batch: true });
|
||||
});
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('admin audit log', () => {
|
||||
test('is authenticated, filtered, paged and exports CSV/JSON', async () => {
|
||||
expect((await app.request('/api/admin/audit')).status).toBe(401);
|
||||
const headers = { 'x-admin': 'yes' };
|
||||
const filtered = await (await app.request('/api/admin/audit?actor=editor&action=resolve', { headers })).json();
|
||||
expect(filtered.total).toBe(1);
|
||||
expect(filtered.events[0]).toMatchObject({ actor: 'editor@example.test', action: 'report.resolve', target: '42', detail: { batch: true } });
|
||||
const csv = await app.request('/api/admin/audit?format=csv', { headers });
|
||||
expect(csv.headers.get('content-type')).toContain('text/csv');
|
||||
expect(await csv.text()).toContain('notes.save.lyrics');
|
||||
const json = await (await app.request('/api/admin/audit?format=json', { headers })).json();
|
||||
expect(json.events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
11
server/db.js
11
server/db.js
@@ -265,6 +265,17 @@ export async function initDb() {
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log (ts DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor_action ON audit_log (actor, action, ts DESC);
|
||||
`);
|
||||
await migrateVideoMeta();
|
||||
// Uploads shared from devices (PWA share target): who sent it, and whether it
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db as defaultDb } from './db.js';
|
||||
import * as catalog from './video-catalog.js';
|
||||
import * as innertube from './innertube.js';
|
||||
import * as searchCache from './search-cache.js';
|
||||
import { audit } from './audit-log.js';
|
||||
|
||||
const MAX_SEEDS = 200, MAX_PLAN = 500, MAX_ERRORS = 50;
|
||||
const clean = value => String(value || '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 200);
|
||||
@@ -191,6 +192,7 @@ export function createDeepSearchService({ db = defaultDb, searchDeep = innertube
|
||||
const options = { resultsPerQuery: Math.max(1, Math.min(200, Number(body.resultsPerQuery) || 50)), relatedDepth: Math.max(0, Math.min(2, Number(body.relatedDepth) || 0)), relatedTop: Math.max(1, Math.min(10, Number(body.relatedTop) || 3)), delayMs: Math.max(1000, Math.min(300000, (Number(body.delaySeconds) || 10) * 1000)), jitterMs: Math.max(0, Math.min(300000, (Number(body.jitterSeconds) || 3) * 1000)), dailyCap: Math.max(1, Math.min(10000, Number(body.dailyCap) || 1000)), channels: (Array.isArray(body.channels) ? body.channels : []).map(clean).filter(Boolean).slice(0, 25), playlists: (Array.isArray(body.playlists) ? body.playlists : []).map(clean).filter(Boolean).slice(0, 25) };
|
||||
const id = randomUUID(), time = now();
|
||||
await db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES(?,'queued',?,?,?,?,?)", args: [id, JSON.stringify(plan), JSON.stringify(options), JSON.stringify({ queriesDone: 0, newVideos: 0, errors: [], log: [] }), time, time] });
|
||||
await audit(db, 'admin', 'deep-search.start', id, { queries: plan.length });
|
||||
if (autoStart) kick(); return c.json({ ok: true, id }, 202);
|
||||
});
|
||||
for (const action of ['pause', 'resume', 'cancel']) app.post(`/api/admin/deep-search/:id/${action}`, adminAuth, async c => {
|
||||
@@ -199,6 +201,7 @@ export function createDeepSearchService({ db = defaultDb, searchDeep = innertube
|
||||
const target = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'cancelled';
|
||||
const marks = allowed.map(() => '?').join(',');
|
||||
const r = await db.execute({ sql: `UPDATE deep_search_jobs SET status=?,updated_at=? WHERE id=? AND status IN (${marks})`, args: [target, now(), id, ...allowed] });
|
||||
if (r.rowsAffected) await audit(db, 'admin', `deep-search.${action}`, id);
|
||||
if (action === 'resume' && r.rowsAffected && autoStart) kick();
|
||||
return c.json({ ok: !!r.rowsAffected, status: r.rowsAffected ? target : undefined }, r.rowsAffected ? 200 : 409);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import { join } from 'node:path';
|
||||
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
||||
import { registerAnalyticsRoutes } from './admin-analytics.js';
|
||||
import { registerTranscriptionRoutes } from './transcriptions.js';
|
||||
import { audit, registerAuditRoutes } from './audit-log.js';
|
||||
|
||||
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
|
||||
// A YouTube id or one of the server's own uploads (see uploads.js).
|
||||
@@ -507,6 +508,7 @@ export function registerNoteRoutes(app, deps) {
|
||||
if (!r.lines.length) return c.json({ ok: false, error: 'this video has no usable captions' }, 404);
|
||||
const data = sanitizeLyrics({ lines: r.lines.map((l) => ({ ...l, kind: 'line' })), tags: [], offset: 0 });
|
||||
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
|
||||
await audit(db, who.by, 'lyrics.save.auto-captions', id, { rev: saved.rev, lines: data.lines.length });
|
||||
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, lang: r.lang, auto: r.auto });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: err.message }, 502);
|
||||
@@ -551,6 +553,7 @@ export function registerNoteRoutes(app, deps) {
|
||||
offset: 0,
|
||||
});
|
||||
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
|
||||
await audit(db, who.by, 'lyrics.save.lrclib', id, { rev: saved.rev, lines: data.lines.length });
|
||||
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: err.message }, 502);
|
||||
@@ -578,6 +581,7 @@ export function registerNoteRoutes(app, deps) {
|
||||
});
|
||||
if (!r.ok) return c.json({ ok: false, error: 'someone else saved a newer version', conflict: true, current: r.current }, 409);
|
||||
if (kind === 'lyrics') autoResolve(id, data);
|
||||
await audit(db, who.by, `notes.save.${kind}`, id, { rev: r.rev, source: who.via });
|
||||
return c.json({ ok: true, rev: r.rev, data });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: err.message }, 500);
|
||||
@@ -658,11 +662,13 @@ export function registerNoteRoutes(app, deps) {
|
||||
const token = newApiToken();
|
||||
const id = randomBytes(6).toString('hex');
|
||||
await db.createApiToken({ id, label, tokenHash: hashToken(token) });
|
||||
await audit(db, 'admin', 'token.create', id, { label });
|
||||
return c.json({ ok: true, id, label, token });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/tokens/:tid', requireAdmin, async (c) => {
|
||||
const ok = await db.deleteApiToken(c.req.param('tid'));
|
||||
if (ok) await audit(db, 'admin', 'token.revoke', c.req.param('tid'));
|
||||
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
|
||||
});
|
||||
|
||||
@@ -673,6 +679,8 @@ export function registerNoteRoutes(app, deps) {
|
||||
await next();
|
||||
};
|
||||
|
||||
registerAuditRoutes(app, { db, adminAuth: requireAdminOrToken });
|
||||
|
||||
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
|
||||
|
||||
registerTranscriptionRoutes(app, {
|
||||
@@ -707,7 +715,11 @@ export function registerNoteRoutes(app, deps) {
|
||||
|
||||
app.post('/api/admin/media/:id/redownload', requireAdminOrToken, async c => {
|
||||
if (!media) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
|
||||
try { return c.json({ ok: true, ...(await media.redownload(c.req.param('id'))) }, 202); }
|
||||
try {
|
||||
const result = await media.redownload(c.req.param('id'));
|
||||
await audit(db, 'admin', 'media.redownload', c.req.param('id'), result);
|
||||
return c.json({ ok: true, ...result }, 202);
|
||||
}
|
||||
catch (err) { return c.json({ ok: false, error: err.message }, 400); }
|
||||
});
|
||||
|
||||
@@ -727,6 +739,7 @@ export function registerNoteRoutes(app, deps) {
|
||||
updatedBy: `admin (rev ${old.rev})`, force: true,
|
||||
});
|
||||
if (kind === 'lyrics') autoResolve(id, restored);
|
||||
await audit(db, 'admin', `notes.restore.${kind}`, id, { fromRev: old.rev, newRev: r.rev });
|
||||
return c.json({ ok: true, rev: r.rev });
|
||||
});
|
||||
|
||||
@@ -763,11 +776,14 @@ export function registerNoteRoutes(app, deps) {
|
||||
try { body = await c.req.json(); } catch { /* status required below */ }
|
||||
if (!Number.isInteger(fid) || !['open', 'resolved'].includes(body.status)) return c.json({ ok: false, error: 'bad request' }, 400);
|
||||
const ok = await db.setFlagStatus(fid, body.status, 'admin');
|
||||
if (ok) await audit(db, 'admin', `report.${body.status}`, String(fid));
|
||||
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
|
||||
});
|
||||
|
||||
app.delete('/api/admin/flags/:fid', requireAdmin, async (c) => {
|
||||
const ok = await db.deleteFlag(Number(c.req.param('fid')));
|
||||
const id = Number(c.req.param('fid'));
|
||||
const ok = await db.deleteFlag(id);
|
||||
if (ok) await audit(db, 'admin', 'report.delete', String(id));
|
||||
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "bun server.js",
|
||||
"dev": "bun --hot server.js",
|
||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js"
|
||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
|
||||
Reference in New Issue
Block a user