diff --git a/.gitignore b/.gitignore index 63d95f429..3d923fbce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ /db.sqlite-wal /db.sqlite-* +/audit.sqlite + /*.sqlite /daemonise.json diff --git a/pages/admin/audit-log/[username]/[id].vue b/pages/admin/audit-log/[username]/[id].vue index 58e79d526..7f6f4a733 100644 --- a/pages/admin/audit-log/[username]/[id].vue +++ b/pages/admin/audit-log/[username]/[id].vue @@ -65,7 +65,7 @@ {{ logEntry.event.split('/')[0] }}/{{ logEntry.event.split('/')[1] }} -
{{ JSON.stringify(JSON.parse(logEntry.payload), null, 4) }}
+
{{ JSON.stringify(logEntry.payload, null, 4) }}
diff --git a/server/audit.ts b/server/audit.ts index 4b51b254b..6b7f85b94 100644 --- a/server/audit.ts +++ b/server/audit.ts @@ -1,26 +1,94 @@ +import zlib from 'node:zlib'; +import { promisify } from 'util'; + import * as Sentry from '@sentry/node'; import SQL from 'sql-template-strings'; +import * as sqlite from 'sqlite'; +import sqlite3 from 'sqlite3'; import { ulid } from 'ulid'; -import type { Database } from './db.ts'; import type { UserRow } from './express/user.ts'; +import { rootDir } from '~/server/paths.ts'; + interface Payload { userId?: string; [key: string]: unknown; } -export default async ( - req: { db: Database; user?: Pick | null; rawUser?: Pick | undefined }, +let connection: sqlite.Database | null = null; +const connect = async (): Promise => { + if (!connection) { + connection = await sqlite.open({ + filename: `${rootDir}/audit.sqlite`, + driver: sqlite3.Database, + }); + + // we don't want to replicate the migration setup just for this, so a little workaround here. + // it's already executed on live db, so only creating the table on dev + if (process.env.NODE_ENV === 'development') { + await connection.exec(` + CREATE TABLE IF NOT EXISTS audit_log + ( + id TEXT NOT NULL PRIMARY KEY, + userId TEXT, + username TEXT, + aboutUserId TEXT NULL, + event TEXT NOT NULL, + payload BLOB NULL + ); + CREATE INDEX IF NOT EXISTS "audit_log_userId" ON "audit_log" ("userId"); + CREATE INDEX IF NOT EXISTS "audit_log_username" ON "audit_log" ("username"); + CREATE INDEX IF NOT EXISTS "audit_log_aboutUserId" ON "audit_log" ("aboutUserId"); + `); + } + } + + return connection; +}; + +export const closeAuditLogConnection = async () => { + if (connection) { + await connection.close(); + connection = null; + } +}; + +const gzip = promisify(zlib.gzip); +const gunzip = promisify(zlib.gunzip); + +export const auditLog = async ( + req: { user?: Pick | null; rawUser?: Pick | undefined }, event: string, payload: Payload | null = null, ): Promise => { + const db = await connect(); + try { + const compressedPayload = payload + ? await gzip(Buffer.from(JSON.stringify(payload)) as Uint8Array) + : null; + const user = req.user || req.rawUser || { id: null, username: null }; - await req.db.get(SQL`INSERT INTO audit_log (id, userId, aboutUserId, username, event, payload) VALUES ( - ${ulid()}, ${user.id}, ${payload?.userId || null}, ${user.username}, ${event}, ${payload ? JSON.stringify(payload) : null} + await db.get(SQL`INSERT INTO audit_log (id, userId, aboutUserId, username, event, payload) VALUES ( + ${ulid()}, ${user.id}, ${payload?.userId || null}, ${user.username}, ${event}, ${compressedPayload} )`); } catch (error) { Sentry.captureException(error); } }; + +export const fetchAuditLog = async (username: string, userId: string, aboutUserId: string) => { + const db = await connect(); + + const entries = await db.all(SQL` + SELECT * FROM audit_log + WHERE username = ${username} OR userId = ${userId} OR aboutUserId = ${aboutUserId} + ORDER BY id DESC + `); + + return await Promise.all(entries.map(async (entry) => { + const payload = entry.payload ? JSON.parse((await gunzip(entry.payload)).toString()) : null; + return { ...entry, payload }; + })); +}; diff --git a/server/auditMigration.ts b/server/auditMigration.ts new file mode 100644 index 000000000..de019b7be --- /dev/null +++ b/server/auditMigration.ts @@ -0,0 +1,91 @@ +// pnpm run-file server/auditMigration.ts + +import zlib from 'node:zlib'; +import { promisify } from 'util'; + +import SQL from 'sql-template-strings'; +import * as sqlite from 'sqlite'; +import sqlite3 from 'sqlite3'; + +import dbConnection from './db.ts'; + +import { rootDir } from '~/server/paths.ts'; + +const gzip = promisify(zlib.gzip); + +const connectAudit = async (): Promise => { + const connection = await sqlite.open({ + filename: `${rootDir}/audit.sqlite`, + driver: sqlite3.Database, + }); + + await connection.exec(` + CREATE TABLE IF NOT EXISTS audit_log + ( + id TEXT NOT NULL PRIMARY KEY, + userId TEXT, + username TEXT, + aboutUserId TEXT NULL, + event TEXT NOT NULL, + payload BLOB NULL + ); + CREATE INDEX IF NOT EXISTS "audit_log_userId" ON "audit_log" ("userId"); + CREATE INDEX IF NOT EXISTS "audit_log_username" ON "audit_log" ("username"); + CREATE INDEX IF NOT EXISTS "audit_log_aboutUserId" ON "audit_log" ("aboutUserId"); + `); + + return connection; +}; + +async function migrate(): Promise { + const oldDb = await dbConnection(); + const auditDb = await connectAudit(); + + const { count } = await oldDb.get(SQL`SELECT COUNT(*) as count FROM audit_log`); + let processed = 0; + + const startedAt = new Date(); + + while (true) { + const entries = await oldDb.all(SQL` + SELECT * + FROM audit_log + ORDER BY id DESC + LIMIT 10000 + `); + + const progress = Math.round(processed / count * 1000) / 1000; + const elapsedMinutes = Math.round((new Date().getTime() - startedAt.getTime()) / 1000 / 60 * 100) / 100; + console.log(`[Done: ${processed}/${count} (${100 * progress}%)] [Elapsed: ${elapsedMinutes} min] Processing ${entries.length} entries…`); + + if (entries.length === 0) { + break; + } + + for (const entry of entries) { + const compressedPayload = entry.payload + ? await gzip(Buffer.from(entry.payload) as Uint8Array) + : null; + + try { + await auditDb.get(SQL`INSERT INTO audit_log (id, userId, aboutUserId, username, event, payload) VALUES ( + ${entry.id}, ${entry.userId}, ${entry.aboutUserId}, ${entry.username}, ${entry.event}, ${compressedPayload} + )`); + } catch (error) { + // likely unique constraint issue, because we can't be atomic across databases. report and ignore. + console.error(error); + } + + await oldDb.get(SQL`DELETE FROM audit_log WHERE id = ${entry.id}`); + } + + processed += entries.length; + } + + await oldDb.get(SQL`VACUUM`); + + await oldDb.close(); + await auditDb.close(); +} + +await migrate(); diff --git a/server/express/admin.ts b/server/express/admin.ts index 8bde25e50..c67a4bf40 100644 --- a/server/express/admin.ts +++ b/server/express/admin.ts @@ -12,7 +12,7 @@ import allLocales from '../../locale/locales.ts'; import type { LocaleDescription } from '../../locale/locales.ts'; import buildLocaleList from '../../src/buildLocaleList.ts'; import { buildDict, now, shuffle, handleErrorAsync, filterObjectKeys } from '../../src/helpers.ts'; -import auditLog from '../audit.ts'; +import { auditLog, fetchAuditLog } from '../audit.ts'; import avatar from '../avatar.ts'; import { archiveBan, liftBan } from '../ban.ts'; import type { Database } from '../db.ts'; @@ -714,11 +714,7 @@ router.get('/admin/audit-log/:username/:id', handleErrorAsync(async (req, res) = return res.status(401).json({ error: 'Unauthorised' }); } - return res.json(await req.db.all(SQL` - SELECT * FROM audit_log - WHERE username = ${req.params.username} OR userId = ${req.params.id} OR aboutUserId = ${req.params.id} - ORDER BY id DESC - `)); + return res.json(await fetchAuditLog(req.params.username, req.params.id, req.params.id)); })); router.get('/admin/authenticators/:id', handleErrorAsync(async (req, res) => { diff --git a/server/express/census.ts b/server/express/census.ts index 6c06ebea5..93575e0f8 100644 --- a/server/express/census.ts +++ b/server/express/census.ts @@ -9,7 +9,7 @@ import type { Aggregate } from '../../locale/config.ts'; import { groupBy, handleErrorAsync } from '../../src/helpers.ts'; import { intersection, difference } from '../../src/sets.ts'; import { buildChart } from '../../src/stats.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; interface CensusRow { id: string; diff --git a/server/express/images.ts b/server/express/images.ts index 809aa602b..d72b50443 100644 --- a/server/express/images.ts +++ b/server/express/images.ts @@ -11,7 +11,7 @@ import SQL from 'sql-template-strings'; import { ulid } from 'ulid'; import { handleErrorAsync } from '../../src/helpers.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import { awsConfig, awsParams } from '../aws.ts'; import { rootDir } from '../paths.ts'; diff --git a/server/express/inclusive.ts b/server/express/inclusive.ts index c7f34dd31..c8307ac59 100644 --- a/server/express/inclusive.ts +++ b/server/express/inclusive.ts @@ -5,7 +5,7 @@ import { ulid } from 'ulid'; import { handleErrorAsync, sortClearedLinkedText } from '../../src/helpers.ts'; import type { User } from '../../src/user.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import type { Database } from '../db.ts'; interface InclusiveRow { diff --git a/server/express/mfa.ts b/server/express/mfa.ts index 3e2652d40..e82e0fb07 100644 --- a/server/express/mfa.ts +++ b/server/express/mfa.ts @@ -3,7 +3,7 @@ import speakeasy from 'speakeasy'; import { longtimeCookieSetting } from '../../src/cookieSettings.ts'; import { handleErrorAsync } from '../../src/helpers.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import type { Database } from '../db.ts'; import { diff --git a/server/express/names.ts b/server/express/names.ts index 3e25eef56..bf15f9a60 100644 --- a/server/express/names.ts +++ b/server/express/names.ts @@ -4,7 +4,7 @@ import SQL from 'sql-template-strings'; import { ulid } from 'ulid'; import { handleErrorAsync } from '../../src/helpers.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import type { Database } from '../db.ts'; interface NameRow { diff --git a/server/express/nouns.ts b/server/express/nouns.ts index 4b9e36b8b..61be38f18 100644 --- a/server/express/nouns.ts +++ b/server/express/nouns.ts @@ -7,7 +7,7 @@ import { ulid } from 'ulid'; import type { Translations } from '../../locale/translations.ts'; import { clearKey, handleErrorAsync } from '../../src/helpers.ts'; import type { User } from '../../src/user.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import type { Database } from '../db.ts'; import { loadSuml } from '../loader.ts'; import { registerLocaleFont } from '../localeFont.ts'; diff --git a/server/express/profile.ts b/server/express/profile.ts index f4ebf38da..9bc009046 100644 --- a/server/express/profile.ts +++ b/server/express/profile.ts @@ -1,5 +1,5 @@ import fs from 'fs'; -import zlib from 'zlib'; +import zlib from 'node:zlib'; import { S3, NoSuchKey } from '@aws-sdk/client-s3'; import * as Sentry from '@sentry/node'; @@ -29,7 +29,7 @@ import type { } from '../../src/profile.ts'; import { socialProviders } from '../../src/socialProviders.ts'; import { colours, styles } from '../../src/styling.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import avatar from '../avatar.ts'; import { awsConfig, awsParams } from '../aws.ts'; import crypto from '../crypto.ts'; diff --git a/server/express/sources.ts b/server/express/sources.ts index 59a50bebd..ff21452e5 100644 --- a/server/express/sources.ts +++ b/server/express/sources.ts @@ -4,7 +4,7 @@ import SQL from 'sql-template-strings'; import { ulid } from 'ulid'; import { clearKey, handleErrorAsync } from '../../src/helpers.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import type { Database } from '../db.ts'; export interface SourceRow { diff --git a/server/express/subscription.ts b/server/express/subscription.ts index 63fb5037b..cdebb912d 100644 --- a/server/express/subscription.ts +++ b/server/express/subscription.ts @@ -3,7 +3,7 @@ import SQL from 'sql-template-strings'; import { ulid } from 'ulid'; import { handleErrorAsync } from '../../src/helpers.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import { validateEmail } from './user.ts'; diff --git a/server/express/terms.ts b/server/express/terms.ts index da623deba..6c7fce1ad 100644 --- a/server/express/terms.ts +++ b/server/express/terms.ts @@ -5,7 +5,7 @@ import { ulid } from 'ulid'; import { handleErrorAsync, sortClearedLinkedText, clearKey } from '../../src/helpers.ts'; import type { User } from '../../src/user.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import type { Database } from '../db.ts'; interface TermDb { diff --git a/server/express/translations.ts b/server/express/translations.ts index 63101d43e..63b1b136f 100644 --- a/server/express/translations.ts +++ b/server/express/translations.ts @@ -3,7 +3,7 @@ import SQL from 'sql-template-strings'; import { ulid } from 'ulid'; import { findAdmins, handleErrorAsync } from '../../src/helpers.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import { deduplicateEmailPreset } from './user.ts'; diff --git a/server/express/user.ts b/server/express/user.ts index 0fad0b031..e61047af3 100644 --- a/server/express/user.ts +++ b/server/express/user.ts @@ -12,7 +12,7 @@ import { longtimeCookieSetting } from '../../src/cookieSettings.ts'; import { buildDict, makeId, now, handleErrorAsync, obfuscateEmail } from '../../src/helpers.ts'; import type { User } from '../../src/user.ts'; import { usernameRegex, usernameUnsafeRegex } from '../../src/username.ts'; -import auditLog from '../audit.ts'; +import { auditLog } from '../audit.ts'; import avatar from '../avatar.ts'; import { lookupBanArchive } from '../ban.ts'; import { validateCaptcha } from '../captcha.ts'; @@ -106,7 +106,7 @@ export const saveAuthenticator = async ( payload, }); } - await auditLog({ db, user }, 'auth/saved_authenticator', { + await auditLog({ user }, 'auth/saved_authenticator', { type, payloadId: payload.id || undefined, name: payload.name || undefined, @@ -478,7 +478,7 @@ router.post('/user/init', handleErrorAsync(async (req, res) => { if (user) { const { block } = await fetchLoginAttempts(req.db, user.id); if (block) { - await auditLog({ db: req.db, user }, 'auth/login_too_many_attempts'); + await auditLog({ user }, 'auth/login_too_many_attempts'); return res.json({ error: 'user.tooManyAttempts' }); } } @@ -490,12 +490,12 @@ router.post('/user/init', handleErrorAsync(async (req, res) => { }; if (!await validateEmail(payload.email)) { - await auditLog({ db: req.db, user }, 'auth/email_invalid', { email: payload.email }); + await auditLog({ user }, 'auth/email_invalid', { email: payload.email }); return res.json({ error: 'user.account.changeEmail.invalid' }); } if (!user && await lookupBanArchive(req.db, 'email', payload)) { - await auditLog({ db: req.db, user }, 'auth/blocked_archive_ban'); + await auditLog({ user }, 'auth/blocked_archive_ban'); return res.status(401).json({ error: 'Unauthorised' }); } @@ -511,12 +511,12 @@ router.post('/user/init', handleErrorAsync(async (req, res) => { mailer(payload.email, 'confirmCode', { code: payload.code }); - await auditLog({ db: req.db, user }, 'auth/requested_email_code', { email: payload.email }); + await auditLog({ user }, 'auth/requested_email_code', { email: payload.email }); }, async () => { const auth = await findLatestEmailAuthenticator(req.db, payload.email, 'email'); codeKey = auth ? auth.id : null; - await auditLog({ db: req.db, user }, 'auth/requested_email_code_duplicate', { email: payload.email }); + await auditLog({ user }, 'auth/requested_email_code_duplicate', { email: payload.email }); }, ); } diff --git a/server/index.ts b/server/index.ts index 16c28e85d..54e8c0fee 100644 --- a/server/index.ts +++ b/server/index.ts @@ -41,6 +41,7 @@ import translationsRoute from './express/translations.ts'; import userRoute from './express/user.ts'; import { config } from './social.ts'; +import { closeAuditLogConnection } from '~/server/audit.ts'; import useAuthentication from '~/server/utils/useAuthentication.ts'; const MemoryStore = memorystore(session); @@ -132,6 +133,7 @@ router.use(async function (req, res, next) { }; res.on('finish', async () => { await req.db.close(); + await closeAuditLogConnection(); }); res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Headers', 'authorization,content-type'); @@ -171,6 +173,7 @@ router.use((err: Error, req: Request, res: Response, _next: NextFunction) => { console.error(formatError(err, req)); res.status(500).send('Unexpected server error'); req.db.close(); + closeAuditLogConnection(); }); export default useBase('/api', defineExpressHandler(router));