diff --git a/Dockerfile b/Dockerfile index 262571c..aaeb087 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ COPY --from=client-builder /app/client/public/fonts ./public/fonts RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data +RUN chown -R node:node /app +USER node + # Umgebung setzen ENV NODE_ENV=production ENV PORT=3000 diff --git a/client/package-lock.json b/client/package-lock.json index 9a80671..5dc9bc0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.6.2", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.6.2", + "version": "2.7.0", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 03ab09f..fbabe99 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -168,6 +168,8 @@ export const adminApi = { listInvites: () => apiClient.get('/admin/invites').then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), + auditLog: (params?: { limit?: number; offset?: number }) => + apiClient.get('/admin/audit-log', { params }).then(r => r.data), } export const addonsApi = { diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx new file mode 100644 index 0000000..f36d69e --- /dev/null +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { RefreshCw, ClipboardList } from 'lucide-react' + +interface AuditEntry { + id: number + created_at: string + user_id: number | null + username: string | null + user_email: string | null + action: string + resource: string | null + details: Record | null + ip: string | null +} + +export default function AuditLogPanel(): React.ReactElement { + const { t, locale } = useTranslation() + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [offset, setOffset] = useState(0) + const [loading, setLoading] = useState(true) + const limit = 100 + + const loadFirstPage = useCallback(async () => { + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: 0 }) as { + entries: AuditEntry[] + total: number + } + setEntries(data.entries || []) + setTotal(data.total ?? 0) + setOffset(0) + } catch { + setEntries([]) + setTotal(0) + setOffset(0) + } finally { + setLoading(false) + } + }, []) + + const loadMore = useCallback(async () => { + const nextOffset = offset + limit + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: nextOffset }) as { + entries: AuditEntry[] + total: number + } + setEntries((prev) => [...prev, ...(data.entries || [])]) + setTotal(data.total ?? 0) + setOffset(nextOffset) + } catch { + /* keep existing */ + } finally { + setLoading(false) + } + }, [offset]) + + useEffect(() => { + loadFirstPage() + }, [loadFirstPage]) + + const fmtTime = (iso: string) => { + try { + return new Date(iso).toLocaleString(locale, { + dateStyle: 'short', + timeStyle: 'medium', + }) + } catch { + return iso + } + } + + const fmtDetails = (d: Record | null) => { + if (!d || Object.keys(d).length === 0) return '—' + try { + return JSON.stringify(d) + } catch { + return '—' + } + } + + const userLabel = (e: AuditEntry) => { + if (e.username) return e.username + if (e.user_email) return e.user_email + if (e.user_id != null) return `#${e.user_id}` + return '—' + } + + return ( +
+
+
+

+ + {t('admin.tabs.audit')} +

+

{t('admin.audit.subtitle')}

+
+ +
+ +

+ {t('admin.audit.showing', { count: entries.length, total })} +

+ + {loading && entries.length === 0 ? ( +
{t('common.loading')}
+ ) : entries.length === 0 ? ( +
{t('admin.audit.empty')}
+ ) : ( +
+ + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + ))} + +
{t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
{fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
+
+ )} + + {entries.length < total && ( + + )} +
+ ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 9dd8573..08820dd 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -320,6 +320,7 @@ const ar: Record = { 'admin.tabs.users': 'المستخدمون', 'admin.tabs.categories': 'الفئات', 'admin.tabs.backup': 'النسخ الاحتياطي', + 'admin.tabs.audit': 'سجل التدقيق', 'admin.tabs.settings': 'الإعدادات', 'admin.tabs.config': 'الإعدادات', 'admin.tabs.templates': 'قوالب التعبئة', @@ -463,6 +464,18 @@ const ar: Record = { 'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.', // GitHub + 'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).', + 'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.', + 'admin.audit.refresh': 'تحديث', + 'admin.audit.loadMore': 'تحميل المزيد', + 'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}', + 'admin.audit.col.time': 'الوقت', + 'admin.audit.col.user': 'المستخدم', + 'admin.audit.col.action': 'الإجراء', + 'admin.audit.col.resource': 'المورد', + 'admin.audit.col.ip': 'عنوان IP', + 'admin.audit.col.details': 'التفاصيل', + 'admin.github.title': 'سجل الإصدارات', 'admin.github.subtitle': 'آخر التحديثات من {repo}', 'admin.github.latest': 'الأحدث', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 274e376..1e5422d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -315,6 +315,7 @@ const de: Record = { 'admin.tabs.users': 'Benutzer', 'admin.tabs.categories': 'Kategorien', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Audit-Protokoll', 'admin.stats.users': 'Benutzer', 'admin.stats.trips': 'Reisen', 'admin.stats.places': 'Orte', @@ -418,8 +419,6 @@ const de: Record = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.', - 'admin.addons.catalog.memories.name': 'Erinnerungen', - 'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise', 'admin.addons.catalog.packing.name': 'Packliste', 'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise', 'admin.addons.catalog.budget.name': 'Budget', @@ -459,6 +458,19 @@ const de: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).', + 'admin.audit.empty': 'Noch keine Audit-Einträge.', + 'admin.audit.refresh': 'Aktualisieren', + 'admin.audit.loadMore': 'Mehr laden', + 'admin.audit.showing': '{count} geladen · {total} gesamt', + 'admin.audit.col.time': 'Zeit', + 'admin.audit.col.user': 'Benutzer', + 'admin.audit.col.action': 'Aktion', + 'admin.audit.col.resource': 'Ressource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', + 'admin.github.title': 'Update-Verlauf', 'admin.github.subtitle': 'Neueste Updates von {repo}', 'admin.github.latest': 'Aktuell', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index fac08a9..b4f5a05 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -315,6 +315,7 @@ const en: Record = { 'admin.tabs.users': 'Users', 'admin.tabs.categories': 'Categories', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Audit log', 'admin.stats.users': 'Users', 'admin.stats.trips': 'Trips', 'admin.stats.places': 'Places', @@ -418,8 +419,6 @@ const en: Record = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.', - 'admin.addons.catalog.memories.name': 'Memories', - 'admin.addons.catalog.memories.description': 'Shared photo albums for each trip', 'admin.addons.catalog.packing.name': 'Packing', 'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip', 'admin.addons.catalog.budget.name': 'Budget', @@ -459,6 +458,18 @@ const en: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).', + 'admin.audit.empty': 'No audit entries yet.', + 'admin.audit.refresh': 'Refresh', + 'admin.audit.loadMore': 'Load more', + 'admin.audit.showing': '{count} loaded · {total} total', + 'admin.audit.col.time': 'Time', + 'admin.audit.col.user': 'User', + 'admin.audit.col.action': 'Action', + 'admin.audit.col.resource': 'Resource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', 'admin.github.title': 'Release History', 'admin.github.subtitle': 'Latest updates from {repo}', 'admin.github.latest': 'Latest', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index d76b83a..778a98c 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -313,6 +313,7 @@ const es: Record = { 'admin.tabs.users': 'Usuarios', 'admin.tabs.categories': 'Categorías', 'admin.tabs.backup': 'Copia de seguridad', + 'admin.tabs.audit': 'Registro de auditoría', 'admin.stats.users': 'Usuarios', 'admin.stats.trips': 'Viajes', 'admin.stats.places': 'Lugares', @@ -437,6 +438,19 @@ const es: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).', + 'admin.audit.empty': 'Aún no hay entradas de auditoría.', + 'admin.audit.refresh': 'Actualizar', + 'admin.audit.loadMore': 'Cargar más', + 'admin.audit.showing': '{count} cargados · {total} en total', + 'admin.audit.col.time': 'Fecha y hora', + 'admin.audit.col.user': 'Usuario', + 'admin.audit.col.action': 'Acción', + 'admin.audit.col.resource': 'Recurso', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Detalles', + 'admin.github.title': 'Historial de versiones', 'admin.github.subtitle': 'Últimas novedades de {repo}', 'admin.github.latest': 'Última', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cf63d18..cfe0255 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -315,6 +315,7 @@ const nl: Record = { 'admin.tabs.users': 'Gebruikers', 'admin.tabs.categories': 'Categorieën', 'admin.tabs.backup': 'Back-up', + 'admin.tabs.audit': 'Auditlog', 'admin.stats.users': 'Gebruikers', 'admin.stats.trips': 'Reizen', 'admin.stats.places': 'Plaatsen', @@ -456,6 +457,19 @@ const nl: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Beveiligingsgevoelige en beheerdersgebeurtenissen (back-ups, gebruikers, MFA, instellingen).', + 'admin.audit.empty': 'Nog geen auditregistraties.', + 'admin.audit.refresh': 'Vernieuwen', + 'admin.audit.loadMore': 'Meer laden', + 'admin.audit.showing': '{count} geladen · {total} totaal', + 'admin.audit.col.time': 'Tijd', + 'admin.audit.col.user': 'Gebruiker', + 'admin.audit.col.action': 'Actie', + 'admin.audit.col.resource': 'Bron', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', + 'admin.github.title': 'Release-geschiedenis', 'admin.github.subtitle': 'Laatste updates van {repo}', 'admin.github.latest': 'Nieuwste', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index e0f4f35..5698ebe 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -315,6 +315,7 @@ const ru: Record = { 'admin.tabs.users': 'Пользователи', 'admin.tabs.categories': 'Категории', 'admin.tabs.backup': 'Резервная копия', + 'admin.tabs.audit': 'Журнал аудита', 'admin.stats.users': 'Пользователи', 'admin.stats.trips': 'Поездки', 'admin.stats.places': 'Места', @@ -456,6 +457,19 @@ const ru: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'События, связанные с безопасностью и администрированием (резервные копии, пользователи, MFA, настройки).', + 'admin.audit.empty': 'Записей аудита пока нет.', + 'admin.audit.refresh': 'Обновить', + 'admin.audit.loadMore': 'Загрузить ещё', + 'admin.audit.showing': 'Загружено: {count} · всего {total}', + 'admin.audit.col.time': 'Время', + 'admin.audit.col.user': 'Пользователь', + 'admin.audit.col.action': 'Действие', + 'admin.audit.col.resource': 'Объект', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Подробности', + 'admin.github.title': 'История релизов', 'admin.github.subtitle': 'Последние обновления из {repo}', 'admin.github.latest': 'Последний', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 66610d3..02cd7d1 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -315,6 +315,7 @@ const zh: Record = { 'admin.tabs.users': '用户', 'admin.tabs.categories': '分类', 'admin.tabs.backup': '备份', + 'admin.tabs.audit': '审计日志', 'admin.stats.users': '用户', 'admin.stats.trips': '旅行', 'admin.stats.places': '地点', @@ -456,6 +457,19 @@ const zh: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': '安全与管理员操作记录(备份、用户、MFA、设置)。', + 'admin.audit.empty': '暂无审计记录。', + 'admin.audit.refresh': '刷新', + 'admin.audit.loadMore': '加载更多', + 'admin.audit.showing': '已加载 {count} 条 · 共 {total} 条', + 'admin.audit.col.time': '时间', + 'admin.audit.col.user': '用户', + 'admin.audit.col.action': '操作', + 'admin.audit.col.resource': '资源', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': '详情', + 'admin.github.title': '版本历史', 'admin.github.subtitle': '{repo} 的最新更新', 'admin.github.latest': '最新', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 06c944d..2fe5c2d 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -13,6 +13,7 @@ import BackupPanel from '../components/Admin/BackupPanel' import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' import PackingTemplateManager from '../components/Admin/PackingTemplateManager' +import AuditLogPanel from '../components/Admin/AuditLogPanel' import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' @@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement { { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, + { id: 'audit', label: t('admin.tabs.audit') }, { id: 'github', label: t('admin.tabs.github') }, ] @@ -978,6 +980,8 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'backup' && } + {activeTab === 'audit' && } + {activeTab === 'github' && } diff --git a/docker-compose.yml b/docker-compose.yml index 91a97e9..300cc97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,23 @@ services: + init-permissions: + image: alpine:3.20 + container_name: trek-init-permissions + user: "0:0" + command: > + sh -c "mkdir -p /app/data /app/uploads && + chown -R 1000:1000 /app/data /app/uploads && + chmod -R u+rwX /app/data /app/uploads" + volumes: + - ./data:/app/data + - ./uploads:/app/uploads + restart: "no" + app: image: mauriceboe/trek:latest container_name: trek + depends_on: + init-permissions: + condition: service_completed_successfully ports: - "3000:3000" environment: diff --git a/server/.env.example b/server/.env.example index 188bf55..4e2e99e 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,3 +1,4 @@ PORT=3001 JWT_SECRET=your-super-secret-jwt-key-change-in-production NODE_ENV=development +DEBUG=false diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 3f468d7..963cff2 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -379,6 +379,21 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {} try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {} }, + () => { + // Audit log + db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 425a914..c3ebe23 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -380,6 +380,17 @@ function createTables(db: Database.Database): void { UNIQUE(assignment_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); `); } diff --git a/server/src/index.ts b/server/src/index.ts index 4906f44..a485c13 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import path from 'path'; import fs from 'fs'; const app = express(); +const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true'; // Trust first proxy (nginx/Docker) for correct req.ip if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) { @@ -79,6 +80,34 @@ if (shouldForceHttps) { app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true })); +if (DEBUG) { + app.use((req: Request, res: Response, next: NextFunction) => { + const startedAt = Date.now(); + const requestId = Math.random().toString(36).slice(2, 10); + const redact = (value: unknown): unknown => { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(redact); + const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']); + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v); + } + return out; + }; + + const safeQuery = redact(req.query); + const safeBody = redact(req.body); + console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`); + + res.on('finish', () => { + const elapsedMs = Date.now() - startedAt; + console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`); + }); + + next(); + }); +} + // Avatars are public (shown on login, sharing screens) app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers'))); @@ -196,6 +225,7 @@ const PORT = process.env.PORT || 3001; const server = app.listen(PORT, () => { console.log(`TREK API running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); + console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`); if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.'); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index eea850f..1ed4b99 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import { db } from '../db/database'; import { authenticate, adminOnly } from '../middleware/auth'; import { AuthRequest, User, Addon } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; const router = express.Router(); @@ -63,6 +64,14 @@ router.post('/users', (req: Request, res: Response) => { 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(result.lastInsertRowid); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_create', + resource: String(result.lastInsertRowid), + ip: getClientIp(req), + details: { username: username.trim(), email: email.trim(), role: role || 'user' }, + }); res.status(201).json({ user }); }); @@ -101,6 +110,19 @@ router.put('/users/:id', (req: Request, res: Response) => { 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id); + const authReq = req as AuthRequest; + const changed: string[] = []; + if (username) changed.push('username'); + if (email) changed.push('email'); + if (role) changed.push('role'); + if (password) changed.push('password'); + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_update', + resource: String(req.params.id), + ip: getClientIp(req), + details: { fields: changed }, + }); res.json({ user: updated }); }); @@ -114,6 +136,12 @@ router.delete('/users/:id', (req: Request, res: Response) => { if (!user) return res.status(404).json({ error: 'User not found' }); db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_delete', + resource: String(req.params.id), + ip: getClientIp(req), + }); res.json({ success: true }); }); @@ -126,6 +154,48 @@ router.get('/stats', (_req: Request, res: Response) => { res.json({ totalUsers, totalTrips, totalPlaces, totalFiles }); }); +router.get('/audit-log', (req: Request, res: Response) => { + const limitRaw = parseInt(String(req.query.limit || '100'), 10); + const offsetRaw = parseInt(String(req.query.offset || '0'), 10); + const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500); + const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0); + type Row = { + id: number; + created_at: string; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; + }; + const rows = db.prepare(` + SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip + FROM audit_log a + LEFT JOIN users u ON u.id = a.user_id + ORDER BY a.id DESC + LIMIT ? OFFSET ? + `).all(limit, offset) as Row[]; + const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c; + res.json({ + entries: rows.map((r) => { + let details: Record | null = null; + if (r.details) { + try { + details = JSON.parse(r.details) as Record; + } catch { + details = { _parse_error: true }; + } + } + return { ...r, details }; + }), + total, + limit, + offset, + }); +}); + router.get('/oidc', (_req: Request, res: Response) => { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || ''; const secret = get('oidc_client_secret'); @@ -146,16 +216,25 @@ router.put('/oidc', (req: Request, res: Response) => { if (client_secret !== undefined) set('oidc_client_secret', client_secret); set('oidc_display_name', display_name); set('oidc_only', oidc_only ? 'true' : 'false'); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.oidc_update', + ip: getClientIp(req), + details: { oidc_only: !!oidc_only, issuer_set: !!issuer }, + }); res.json({ success: true }); }); -router.post('/save-demo-baseline', (_req: Request, res: Response) => { +router.post('/save-demo-baseline', (req: Request, res: Response) => { if (process.env.DEMO_MODE !== 'true') { return res.status(404).json({ error: 'Not found' }); } try { const { saveBaseline } = require('../demo/demo-reset'); saveBaseline(); + const authReq = req as AuthRequest; + writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' }); } catch (err: unknown) { console.error(err); @@ -212,7 +291,7 @@ router.get('/version-check', async (_req: Request, res: Response) => { } }); -router.post('/update', async (_req: Request, res: Response) => { +router.post('/update', async (req: Request, res: Response) => { const rootDir = path.resolve(__dirname, '../../..'); const serverDir = path.resolve(__dirname, '../..'); const clientDir = path.join(rootDir, 'client'); @@ -235,6 +314,13 @@ router.post('/update', async (_req: Request, res: Response) => { const { version: newVersion } = require('../../package.json'); steps.push({ step: 'version', version: newVersion }); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.system_update', + resource: newVersion, + ip: getClientIp(req), + }); res.json({ success: true, steps, restarting: true }); setTimeout(() => { @@ -271,24 +357,39 @@ router.post('/invites', (req: Request, res: Response) => { ? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString() : null; - db.prepare( + const ins = db.prepare( 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)' ).run(token, uses, expiresAt, authReq.user.id); + const inviteId = Number(ins.lastInsertRowid); const invite = db.prepare(` SELECT i.*, u.username as created_by_name FROM invite_tokens i JOIN users u ON i.created_by = u.id - WHERE i.id = last_insert_rowid() - `).get(); + WHERE i.id = ? + `).get(inviteId); + writeAudit({ + userId: authReq.user.id, + action: 'admin.invite_create', + resource: String(inviteId), + ip: getClientIp(req), + details: { max_uses: uses, expires_in_days: expires_in_days ?? null }, + }); res.status(201).json({ invite }); }); -router.delete('/invites/:id', (_req: Request, res: Response) => { - const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id); +router.delete('/invites/:id', (req: Request, res: Response) => { + const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id); if (!invite) return res.status(404).json({ error: 'Invite not found' }); - db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id); + db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.invite_delete', + resource: String(req.params.id), + ip: getClientIp(req), + }); res.json({ success: true }); }); @@ -302,6 +403,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => { router.put('/bag-tracking', (req: Request, res: Response) => { const { enabled } = req.body; db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false'); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.bag_tracking', + ip: getClientIp(req), + details: { enabled: !!enabled }, + }); res.json({ enabled: !!enabled }); }); @@ -348,10 +456,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => { res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) }); }); -router.delete('/packing-templates/:id', (_req: Request, res: Response) => { - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id); +router.delete('/packing-templates/:id', (req: Request, res: Response) => { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); if (!template) return res.status(404).json({ error: 'Template not found' }); - db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id); + db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id); + const authReq = req as AuthRequest; + const t = template as { name?: string }; + writeAudit({ + userId: authReq.user.id, + action: 'admin.packing_template_delete', + resource: String(req.params.id), + ip: getClientIp(req), + details: { name: t.name }, + }); res.json({ success: true }); }); @@ -419,6 +536,14 @@ router.put('/addons/:id', (req: Request, res: Response) => { if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon; + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.addon_update', + resource: String(req.params.id), + ip: getClientIp(req), + details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined }, + }); res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 23807c8..5229b08 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -13,6 +13,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; import { AuthRequest, User } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; authenticator.options = { window: 1 }; @@ -543,6 +544,15 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); } } + writeAudit({ + userId: authReq.user.id, + action: 'settings.app_update', + ip: getClientIp(req), + details: { + allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined, + allowed_file_types_changed: allowed_file_types !== undefined, + }, + }); res.json({ success: true }); }); @@ -698,6 +708,7 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { authReq.user.id ); mfaSetupPending.delete(authReq.user.id); + writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); res.json({ success: true, mfa_enabled: true }); }); @@ -727,6 +738,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re authReq.user.id ); mfaSetupPending.delete(authReq.user.id); + writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); res.json({ success: true, mfa_enabled: false }); }); diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index 832b355..de47375 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -7,6 +7,10 @@ import fs from 'fs'; import { authenticate, adminOnly } from '../middleware/auth'; import * as scheduler from '../scheduler'; import { db, closeDb, reinitialize } from '../db/database'; +import { AuthRequest } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; + +type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string }; const router = express.Router(); @@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re }); const stat = fs.statSync(outputPath); + const authReq = _req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.create', + resource: filename, + ip: getClientIp(_req), + details: { size: stat.size }, + }); res.json({ success: true, backup: { @@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => { res.download(filePath, filename); }); -async function restoreFromZip(zipPath: string, res: Response) { +async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) { const extractDir = path.join(dataDir, `restore-${Date.now()}`); try { await fs.createReadStream(zipPath) @@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) { fs.rmSync(extractDir, { recursive: true, force: true }); + if (audit) { + writeAudit({ + userId: audit.userId, + action: audit.source, + resource: audit.label, + ip: audit.ip, + }); + } res.json({ success: true }); } catch (err: unknown) { console.error('Restore error:', err); @@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => { if (!fs.existsSync(zipPath)) { return res.status(404).json({ error: 'Backup not found' }); } - await restoreFromZip(zipPath, res); + const authReq = req as AuthRequest; + await restoreFromZip(zipPath, res, { + userId: authReq.user.id, + ip: getClientIp(req), + source: 'backup.restore', + label: filename, + }); }); const uploadTmp = multer({ @@ -206,7 +232,14 @@ const uploadTmp = multer({ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const zipPath = req.file.path; - await restoreFromZip(zipPath, res); + const authReq = req as AuthRequest; + const origName = req.file.originalname || 'upload.zip'; + await restoreFromZip(zipPath, res, { + userId: authReq.user.id, + ip: getClientIp(req), + source: 'backup.upload_restore', + label: origName, + }); if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); }); @@ -255,6 +288,13 @@ router.put('/auto-settings', (req: Request, res: Response) => { const settings = parseAutoBackupBody((req.body || {}) as Record); scheduler.saveSettings(settings); scheduler.start(); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.auto_settings', + ip: getClientIp(req), + details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days }, + }); res.json({ settings }); } catch (err: unknown) { console.error('[backup] PUT auto-settings:', err); @@ -279,6 +319,13 @@ router.delete('/:filename', (req: Request, res: Response) => { } fs.unlinkSync(filePath); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.delete', + resource: filename, + ip: getClientIp(req), + }); res.json({ success: true }); }); diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts new file mode 100644 index 0000000..ed78ad5 --- /dev/null +++ b/server/src/services/auditLog.ts @@ -0,0 +1,30 @@ +import { Request } from 'express'; +import { db } from '../db/database'; + +export function getClientIp(req: Request): string | null { + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string') { + const first = xff.split(',')[0]?.trim(); + return first || null; + } + if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null; + return req.socket?.remoteAddress || null; +} + +/** Best-effort; never throws — failures are logged only. */ +export function writeAudit(entry: { + userId: number | null; + action: string; + resource?: string | null; + details?: Record; + ip?: string | null; +}): void { + try { + const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null; + db.prepare( + `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)` + ).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null); + } catch (e) { + console.error('[audit] write failed:', e instanceof Error ? e.message : e); + } +}