Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/api/middleware/auditLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { info, error as logError } from '../../logger.js';
import { getConfig } from '../../modules/config.js';
import { maskSensitiveFields } from '../utils/configAllowlist.js';
import { broadcastAuditEntry } from '../ws/auditStream.js';

/** HTTP methods considered mutating */
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
Expand Down Expand Up @@ -104,7 +105,8 @@ function insertAuditEntry(pool, entry) {
try {
const result = pool.query(
`INSERT INTO audit_logs (guild_id, user_id, action, target_type, target_id, details, ip_address)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, guild_id, user_id, action, target_type, target_id, details, ip_address, created_at`,
[
guildId || 'global',
userId,
Expand All @@ -118,8 +120,26 @@ function insertAuditEntry(pool, entry) {

if (result && typeof result.then === 'function') {
result
.then(() => {
.then((insertResult) => {
info('Audit log entry created', { action, guildId, userId });
// Broadcast to real-time audit log WebSocket clients
const row = insertResult?.rows?.[0];
const broadcastEntry = {
guild_id: guildId || 'global',
user_id: userId,
action,
target_type: targetType || null,
target_id: targetId || null,
details: details || null,
ip_address: ipAddress || null,
created_at: new Date().toISOString(),
...(row || {}),
};
try {
broadcastAuditEntry(broadcastEntry);
} catch {
// Non-critical — streaming failure must not affect audit integrity
}
})
.catch((err) => {
logError('Failed to insert audit log entry', { error: err.message, action, guildId });
Expand Down
203 changes: 165 additions & 38 deletions src/api/routes/auditLog.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Audit Log API Routes
* Paginated, filterable audit log retrieval for dashboard consumption.
* Paginated, filterable audit log retrieval and export for dashboard consumption.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/123
* @see https://github.com/VolvoxLLC/volvox-bot/issues/136
*/

import { Router } from 'express';
Expand All @@ -15,6 +15,9 @@ const router = Router();
/** Rate limiter for audit log endpoints — 30 req/min per IP. */
const auditRateLimit = rateLimit({ windowMs: 60 * 1000, max: 30 });

/** Rate limiter for export endpoints — 10 req/min per IP (exports are heavier). */
const exportRateLimit = rateLimit({ windowMs: 60 * 1000, max: 10 });

/**
* Helper to get the database pool from app.locals.
*
Expand All @@ -39,6 +42,96 @@ function toFilterString(value) {
return trimmed.length > 0 ? trimmed : null;
}

/**
* Build WHERE conditions and params from query filters.
*
* @param {string} guildId
* @param {import('express').Request['query']} query
* @returns {{ conditions: string[], params: unknown[], paramIndex: number }}
*/
function buildFilters(guildId, query) {
const conditions = ['guild_id = $1'];
const params = [guildId];
let paramIndex = 2;

const actionFilter = toFilterString(query.action);
if (actionFilter) {
conditions.push(`action = $${paramIndex}`);
params.push(actionFilter);
paramIndex++;
}

const userIdFilter = toFilterString(query.userId);
if (userIdFilter) {
conditions.push(`user_id = $${paramIndex}`);
params.push(userIdFilter);
paramIndex++;
}

// Guard against array query params — Express can pass string|string[]|undefined
if (typeof query.startDate === 'string') {
const start = new Date(query.startDate);
if (!Number.isNaN(start.getTime())) {
conditions.push(`created_at >= $${paramIndex}`);
params.push(start.toISOString());
paramIndex++;
}
}

if (typeof query.endDate === 'string') {
const end = new Date(query.endDate);
if (!Number.isNaN(end.getTime())) {
conditions.push(`created_at <= $${paramIndex}`);
params.push(end.toISOString());
paramIndex++;
}
}

return { conditions, params, paramIndex };
}

/**
* Escape a value for CSV output.
* Wraps in double quotes and escapes internal double quotes.
*
* @param {unknown} value
* @returns {string}
*/
export function escapeCsvValue(value) {
if (value === null || value === undefined) return '';
const str = typeof value === 'object' ? JSON.stringify(value) : String(value);
// RFC 4180: also check for \r (CRLF) to properly handle Windows line endings
if (str.includes(',') || str.includes('\n') || str.includes('\r') || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}

/**
* Convert an array of audit log rows to CSV string.
*
* @param {Object[]} rows
* @returns {string}
*/
export function rowsToCsv(rows) {
const headers = [
'id',
'guild_id',
'user_id',
'action',
'target_type',
'target_id',
'details',
'ip_address',
'created_at',
];
const lines = [headers.join(',')];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(','));
}
return lines.join('\n');
}

// ─── GET /:id/audit-log ──────────────────────────────────────────────────────

/**
Expand All @@ -61,42 +154,7 @@ router.get('/:id/audit-log', auditRateLimit, requireGuildAdmin, validateGuild, a
const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);

try {
const conditions = ['guild_id = $1'];
const params = [guildId];
let paramIndex = 2;

const actionFilter = toFilterString(req.query.action);
if (actionFilter) {
conditions.push(`action = $${paramIndex}`);
params.push(actionFilter);
paramIndex++;
}

const userIdFilter = toFilterString(req.query.userId);
if (userIdFilter) {
conditions.push(`user_id = $${paramIndex}`);
params.push(userIdFilter);
paramIndex++;
}

if (req.query.startDate) {
const start = new Date(req.query.startDate);
if (!Number.isNaN(start.getTime())) {
conditions.push(`created_at >= $${paramIndex}`);
params.push(start.toISOString());
paramIndex++;
}
}

if (req.query.endDate) {
const end = new Date(req.query.endDate);
if (!Number.isNaN(end.getTime())) {
conditions.push(`created_at <= $${paramIndex}`);
params.push(end.toISOString());
paramIndex++;
}
}

const { conditions, params, paramIndex } = buildFilters(guildId, req.query);
const whereClause = conditions.join(' AND ');

const [countResult, entriesResult] = await Promise.all([
Expand All @@ -123,4 +181,73 @@ router.get('/:id/audit-log', auditRateLimit, requireGuildAdmin, validateGuild, a
}
});

// ─── GET /:id/audit-log/export ───────────────────────────────────────────────

/**
* GET /:id/audit-log/export — Export full filtered audit log as CSV or JSON.
*
* Query params:
* format — 'csv' or 'json' (default 'json')
* action — Filter by action type
* userId — Filter by admin user ID
* startDate — ISO timestamp lower bound
* endDate — ISO timestamp upper bound
* limit — Max rows to export (default 1000, max 10000)
*/
router.get(
'/:id/audit-log/export',
exportRateLimit,
requireGuildAdmin,
validateGuild,
async (req, res) => {
const { id: guildId } = req.params;
const pool = getDbPool(req);
if (!pool) return res.status(503).json({ error: 'Database not available' });

const format = toFilterString(req.query.format) || 'json';
if (format !== 'csv' && format !== 'json') {
return res.status(400).json({ error: 'Invalid format. Use "csv" or "json".' });
}

// Allow larger exports — up to 10k rows
const limit = Math.min(10000, Math.max(1, Number.parseInt(req.query.limit, 10) || 1000));

try {
const { conditions, params, paramIndex } = buildFilters(guildId, req.query);
const whereClause = conditions.join(' AND ');

const result = await pool.query(
`SELECT id, guild_id, user_id, action, target_type, target_id, details, ip_address, created_at
FROM audit_logs
WHERE ${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex}`,
[...params, limit],
);

const rows = result.rows;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `audit-log-${guildId}-${timestamp}`;

if (format === 'csv') {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
res.send(rowsToCsv(rows));
} else {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
res.json({
guildId,
exportedAt: new Date().toISOString(),
count: rows.length,
entries: rows,
});
}
} catch (err) {
logError('Failed to export audit log', { guildId, error: err.message });
res.status(500).json({ error: 'Failed to export audit log' });
}
},
);

export default router;
12 changes: 12 additions & 0 deletions src/api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { rateLimit } from './middleware/rateLimit.js';
import { stopAuthCleanup } from './routes/auth.js';
import { swaggerSpec } from './swagger.js';
import { stopGuildCacheCleanup } from './utils/discordApi.js';
import { setupAuditStream, stopAuditStream } from './ws/auditStream.js';
import { setupLogStream, stopLogStream } from './ws/logStream.js';

/** @type {import('node:http').Server | null} */
Expand Down Expand Up @@ -132,6 +133,14 @@ export async function startServer(client, dbPool, options = {}) {
}
}

// Attach audit log real-time WebSocket stream
try {
setupAuditStream(server);
} catch (err) {
error('Failed to setup audit log WebSocket stream', { error: err.message });
// Non-fatal — HTTP server still works without audit WS streaming
}

resolve(server);
});
server.once('error', (err) => {
Expand All @@ -151,6 +160,9 @@ export async function stopServer() {
// Stop WebSocket log stream before closing HTTP server
await stopLogStream();

// Stop audit log WebSocket stream
await stopAuditStream();

stopAuthCleanup();
stopGuildCacheCleanup();

Expand Down
Loading
Loading