Files
ytplayer/server/audit-log.js
2026-10-14 01:54:08 +08:00

54 lines
3.3 KiB
JavaScript

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' });
});
}