From 06ac4144577fa59321c70c3608ad2cb08c84bdca Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 7 Oct 2026 19:27:28 +0200 Subject: [PATCH] Add audit log for moderation and admin actions New audit_log table records actor, action, target and a JSON snapshot of the details, so entries stay readable after the target is deleted; the actor is set to NULL when the user is removed. logAudit() is called from upload delete (single and bulk), comment delete, tag removal, role create/delete, role permission changes, role assign/revoke and MOTD updates. Logging failures only warn and never fail the action itself. The admin page gets an audit card behind the new audit.view permission with action filter, click-to-filter by user, readable summaries per action and server-side pagination. Action catalog lives in shared/utils/audit.ts, labels under admin.auditActions (de/en). Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- app/components/AuditLog.vue | 275 ++++ app/pages/admin.vue | 9 + i18n/locales/de.json | 39 +- i18n/locales/en.json | 39 +- server/api/admin/audit.get.ts | 50 + server/api/admin/motd.put.ts | 3 +- server/api/admin/roles.post.ts | 3 +- server/api/admin/roles/[id].delete.ts | 3 +- .../api/admin/roles/[id]/permissions.put.ts | 8 +- server/api/admin/users/[id]/roles.post.ts | 7 +- .../admin/users/[id]/roles/[roleId].delete.ts | 8 +- server/api/comments/[id].delete.ts | 16 +- server/api/uploads/[id].delete.ts | 13 +- server/api/uploads/[id]/tags.put.ts | 3 + server/api/uploads/bulk-delete.post.ts | 3 +- .../0026_sleepy_colonel_america.sql | 13 + server/db/migrations/meta/0026_snapshot.json | 1400 +++++++++++++++++ server/db/migrations/meta/_journal.json | 7 + server/db/schema.ts | 19 +- server/utils/audit.ts | 24 + shared/utils/audit.ts | 15 + shared/utils/permissions.ts | 1 + 22 files changed, 1939 insertions(+), 19 deletions(-) create mode 100644 app/components/AuditLog.vue create mode 100644 server/api/admin/audit.get.ts create mode 100644 server/db/migrations/0026_sleepy_colonel_america.sql create mode 100644 server/db/migrations/meta/0026_snapshot.json create mode 100644 server/utils/audit.ts create mode 100644 shared/utils/audit.ts diff --git a/app/components/AuditLog.vue b/app/components/AuditLog.vue new file mode 100644 index 0000000..cf2a0c0 --- /dev/null +++ b/app/components/AuditLog.vue @@ -0,0 +1,275 @@ + + + diff --git a/app/pages/admin.vue b/app/pages/admin.vue index 255bf03..84b9fc5 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -331,6 +331,15 @@ async function saveMotd() { + + + {{ $t('admin.audit.title') }} + {{ $t('admin.audit.desc') }} + + + + +

{{ errorMessage }}

diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 0afe382..e5e4f93 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -212,7 +212,44 @@ "site": { "manage": "Seiten-Einstellungen verwalten" }, "comment": { "delete": "Kommentare löschen" }, "upload": { "delete": "Uploads löschen" }, - "tag": { "delete": "Tags löschen" } + "tag": { "delete": "Tags löschen" }, + "audit": { "view": "Audit-Log einsehen" } + }, + "audit": { + "title": "Audit-Log", + "desc": "Protokoll aller Moderations- und Admin-Aktionen. Klick auf einen Namen filtert nach diesem Nutzer.", + "filterAction": "Nach Aktion filtern", + "allActions": "Alle Aktionen", + "filterUser": "Nur Aktionen von {name} anzeigen", + "clearUser": "Nutzerfilter entfernen", + "count": "{n} Einträge", + "refresh": "Aktualisieren", + "time": "Zeit", + "actor": "Nutzer", + "action": "Aktion", + "details": "Details", + "deletedUser": "gelöschter Nutzer", + "empty": "Keine Einträge." + }, + "auditActions": { + "delete_upload": "Upload gelöscht", + "bulk_delete_uploads": "Uploads gesammelt gelöscht", + "delete_comment": "Kommentar gelöscht", + "remove_tags": "Tags entfernt", + "create_role": "Rolle angelegt", + "delete_role": "Rolle gelöscht", + "update_role_permissions": "Berechtigungen geändert", + "assign_role": "Rolle zugewiesen", + "remove_role": "Rolle entzogen", + "update_motd": "Ansage geändert" + }, + "auditDetails": { + "deleteUpload": "{name} von {user}", + "bulkDelete": "{n} Uploads", + "deleteComment": "{user}: {content}", + "role": "{role} → {user}", + "noChanges": "keine Änderungen", + "motdCleared": "Ansage entfernt" } }, "palette": { diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 78a0cb8..76ffe6a 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -212,7 +212,44 @@ "site": { "manage": "Manage site settings" }, "comment": { "delete": "Delete comments" }, "upload": { "delete": "Delete uploads" }, - "tag": { "delete": "Delete tags" } + "tag": { "delete": "Delete tags" }, + "audit": { "view": "View audit log" } + }, + "audit": { + "title": "Audit log", + "desc": "Record of all moderation and admin actions. Click a name to filter by that user.", + "filterAction": "Filter by action", + "allActions": "All actions", + "filterUser": "Only show actions by {name}", + "clearUser": "Clear user filter", + "count": "{n} entries", + "refresh": "Refresh", + "time": "Time", + "actor": "User", + "action": "Action", + "details": "Details", + "deletedUser": "deleted user", + "empty": "No entries." + }, + "auditActions": { + "delete_upload": "Upload deleted", + "bulk_delete_uploads": "Uploads bulk-deleted", + "delete_comment": "Comment deleted", + "remove_tags": "Tags removed", + "create_role": "Role created", + "delete_role": "Role deleted", + "update_role_permissions": "Permissions changed", + "assign_role": "Role assigned", + "remove_role": "Role revoked", + "update_motd": "Announcement changed" + }, + "auditDetails": { + "deleteUpload": "{name} by {user}", + "bulkDelete": "{n} uploads", + "deleteComment": "{user}: {content}", + "role": "{role} → {user}", + "noChanges": "no changes", + "motdCleared": "Announcement removed" } }, "palette": { diff --git a/server/api/admin/audit.get.ts b/server/api/admin/audit.get.ts new file mode 100644 index 0000000..39ae32b --- /dev/null +++ b/server/api/admin/audit.get.ts @@ -0,0 +1,50 @@ +import { and, count, desc, eq, type SQL } from 'drizzle-orm' +import { AUDIT_ACTIONS } from '../../../shared/utils/audit' +import { auditLog, users } from '../../db/schema' + +const PER_PAGE = 25 + +export default defineEventHandler(async (event) => { + await requirePermission(event, 'audit.view') + const q = getQuery(event) + const page = Math.max(1, Math.floor(Number(q.page)) || 1) + const action = String(q.action ?? '') + const actorId = Number(q.user) + + const conditions: SQL[] = [] + if ((AUDIT_ACTIONS as readonly string[]).includes(action)) conditions.push(eq(auditLog.action, action)) + if (Number.isInteger(actorId) && actorId > 0) conditions.push(eq(auditLog.userId, actorId)) + const where = conditions.length ? and(...conditions) : undefined + + const db = useDb() + const [totalRow, rows] = await Promise.all([ + db.select({ count: count() }).from(auditLog).where(where), + db + .select({ + id: auditLog.id, + action: auditLog.action, + targetType: auditLog.targetType, + targetId: auditLog.targetId, + details: auditLog.details, + createdAt: auditLog.createdAt, + userId: auditLog.userId, + userName: users.name, + userColor: users.color, + userAvatar: users.avatar, + }) + .from(auditLog) + .leftJoin(users, eq(auditLog.userId, users.id)) + .where(where) + .orderBy(desc(auditLog.id)) + .limit(PER_PAGE) + .offset((page - 1) * PER_PAGE), + ]) + + const total = totalRow[0]?.count ?? 0 + return { + items: rows.map(({ userAvatar, ...r }) => ({ ...r, userAvatarUrl: avatarUrl(userAvatar) })), + total, + perPage: PER_PAGE, + pageCount: Math.max(1, Math.ceil(total / PER_PAGE)), + } +}) diff --git a/server/api/admin/motd.put.ts b/server/api/admin/motd.put.ts index 251274b..c898e61 100644 --- a/server/api/admin/motd.put.ts +++ b/server/api/admin/motd.put.ts @@ -6,7 +6,7 @@ const bodySchema = z.object({ }) export default defineEventHandler(async (event) => { - await requirePermission(event, 'site.manage') + const actor = await requirePermission(event, 'site.manage') const { message } = await readValidatedBody(event, bodySchema.parse) const db = useDb() await db @@ -14,5 +14,6 @@ export default defineEventHandler(async (event) => { .values({ key: 'motd', value: message }) .onDuplicateKeyUpdate({ set: { value: message } }) broadcastToAll({ type: 'motd', message }) + await logAudit(actor.id, 'update_motd', null, { message }) return { message } }) diff --git a/server/api/admin/roles.post.ts b/server/api/admin/roles.post.ts index 12e436a..956cd95 100644 --- a/server/api/admin/roles.post.ts +++ b/server/api/admin/roles.post.ts @@ -5,8 +5,9 @@ const bodySchema = z.object({ }) export default defineEventHandler(async (event) => { - await requirePermission(event, 'roles.manage') + const actor = await requirePermission(event, 'roles.manage') const { name } = await readValidatedBody(event, bodySchema.parse) const id = await ensureRole(name) + await logAudit(actor.id, 'create_role', { type: 'role', id }, { name }) return { id, name } }) diff --git a/server/api/admin/roles/[id].delete.ts b/server/api/admin/roles/[id].delete.ts index 6f1e1bc..8e5e05c 100644 --- a/server/api/admin/roles/[id].delete.ts +++ b/server/api/admin/roles/[id].delete.ts @@ -3,7 +3,7 @@ import { roles } from '../../../db/schema' import { SYSTEM_ROLES } from '../../../../shared/utils/permissions' export default defineEventHandler(async (event) => { - await requirePermission(event, 'roles.manage') + const actor = await requirePermission(event, 'roles.manage') const id = Number(getRouterParam(event, 'id')) if (!Number.isInteger(id) || id <= 0) { throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' }) @@ -17,5 +17,6 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: `Die ${existing[0].name}-Rolle kann nicht gelöscht werden` }) } await db.delete(roles).where(eq(roles.id, id)) + await logAudit(actor.id, 'delete_role', { type: 'role', id }, { name: existing[0].name }) return { ok: true } }) diff --git a/server/api/admin/roles/[id]/permissions.put.ts b/server/api/admin/roles/[id]/permissions.put.ts index 0aff048..b549579 100644 --- a/server/api/admin/roles/[id]/permissions.put.ts +++ b/server/api/admin/roles/[id]/permissions.put.ts @@ -8,7 +8,7 @@ const bodySchema = z.object({ }) export default defineEventHandler(async (event) => { - await requirePermission(event, 'roles.manage') + const actor = await requirePermission(event, 'roles.manage') const id = Number(getRouterParam(event, 'id')) if (!Number.isInteger(id) || id <= 0) { throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' }) @@ -27,6 +27,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Die Admin-Rolle hat immer alle Berechtigungen' }) } + const previous = await getRolePermissions().then(m => m.get(id) ?? []) await db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)) if (keys.length) { const rows = await db.select({ id: permissions.id }).from(permissions).where(inArray(permissions.key, keys)) @@ -34,5 +35,10 @@ export default defineEventHandler(async (event) => { await db.insert(rolePermissions).values(rows.map(r => ({ roleId: id, permissionId: r.id }))) } } + await logAudit(actor.id, 'update_role_permissions', { type: 'role', id }, { + name: role.name, + added: keys.filter(k => !previous.includes(k)), + removed: previous.filter(k => !keys.includes(k)), + }) return { ok: true } }) diff --git a/server/api/admin/users/[id]/roles.post.ts b/server/api/admin/users/[id]/roles.post.ts index 928d46e..704b80d 100644 --- a/server/api/admin/users/[id]/roles.post.ts +++ b/server/api/admin/users/[id]/roles.post.ts @@ -7,7 +7,7 @@ const bodySchema = z.object({ }) export default defineEventHandler(async (event) => { - await requirePermission(event, 'users.manage') + const actor = await requirePermission(event, 'users.manage') const userId = Number(getRouterParam(event, 'id')) if (!Number.isInteger(userId) || userId <= 0) { throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' }) @@ -15,15 +15,16 @@ export default defineEventHandler(async (event) => { const { roleId } = await readValidatedBody(event, bodySchema.parse) const db = useDb() - const [user] = await db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1) + const [user] = await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, userId)).limit(1) if (!user) { throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' }) } - const [role] = await db.select({ id: roles.id }).from(roles).where(eq(roles.id, roleId)).limit(1) + const [role] = await db.select({ id: roles.id, name: roles.name }).from(roles).where(eq(roles.id, roleId)).limit(1) if (!role) { throw createError({ statusCode: 404, statusMessage: 'Rolle nicht gefunden' }) } await assignRole(userId, roleId) + await logAudit(actor.id, 'assign_role', { type: 'user', id: userId }, { userName: user.name, role: role.name }) return { ok: true } }) diff --git a/server/api/admin/users/[id]/roles/[roleId].delete.ts b/server/api/admin/users/[id]/roles/[roleId].delete.ts index ced0b95..b9b4d2f 100644 --- a/server/api/admin/users/[id]/roles/[roleId].delete.ts +++ b/server/api/admin/users/[id]/roles/[roleId].delete.ts @@ -1,5 +1,5 @@ import { and, eq } from 'drizzle-orm' -import { roles, userRoles } from '../../../../../db/schema' +import { roles, userRoles, users } from '../../../../../db/schema' export default defineEventHandler(async (event) => { const admin = await requirePermission(event, 'users.manage') @@ -18,6 +18,10 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Du kannst dir nicht selbst die Admin-Rolle entziehen' }) } - await db.delete(userRoles).where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))) + const [target] = await db.select({ name: users.name }).from(users).where(eq(users.id, userId)).limit(1) + const result = await db.delete(userRoles).where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))) + if (result[0].affectedRows) { + await logAudit(admin.id, 'remove_role', { type: 'user', id: userId }, { userName: target?.name ?? null, role: role.name }) + } return { ok: true } }) diff --git a/server/api/comments/[id].delete.ts b/server/api/comments/[id].delete.ts index 82898b4..9ab1b0e 100644 --- a/server/api/comments/[id].delete.ts +++ b/server/api/comments/[id].delete.ts @@ -1,18 +1,19 @@ import { unlink } from 'node:fs/promises' import { join } from 'node:path' import { eq, sql } from 'drizzle-orm' -import { commentAttachments, comments, uploads } from '../../db/schema' +import { commentAttachments, comments, uploads, users } from '../../db/schema' export default defineEventHandler(async (event) => { - await requirePermission(event, 'comment.delete') + const actor = await requirePermission(event, 'comment.delete') const id = Number(getRouterParam(event, 'id')) if (!Number.isInteger(id) || id <= 0) { throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' }) } const db = useDb() const comment = await db - .select({ uploadId: comments.uploadId }) + .select({ uploadId: comments.uploadId, content: comments.content, userId: comments.userId, userName: users.name }) .from(comments) + .innerJoin(users, eq(comments.userId, users.id)) .where(eq(comments.id, id)) .limit(1) .then(r => r[0]) @@ -30,5 +31,14 @@ export default defineEventHandler(async (event) => { await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {}) } } + if (comment) { + await logAudit(actor.id, 'delete_comment', { type: 'comment', id }, { + uploadId: comment.uploadId, + authorId: comment.userId, + authorName: comment.userName, + content: comment.content.slice(0, 500), + attachments: files.length, + }) + } return { ok: true } }) diff --git a/server/api/uploads/[id].delete.ts b/server/api/uploads/[id].delete.ts index 76b283b..f4d80ba 100644 --- a/server/api/uploads/[id].delete.ts +++ b/server/api/uploads/[id].delete.ts @@ -1,10 +1,10 @@ import { unlink } from 'node:fs/promises' import { join } from 'node:path' import { eq } from 'drizzle-orm' -import { commentAttachments, comments, uploads } from '../../db/schema' +import { commentAttachments, comments, uploads, users } from '../../db/schema' export default defineEventHandler(async (event) => { - await requirePermission(event, 'upload.delete') + const actor = await requirePermission(event, 'upload.delete') const id = getRouterParam(event, 'id') ?? '' if (!/^[A-Za-z0-9]{1,16}$/.test(id)) { throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' }) @@ -12,8 +12,9 @@ export default defineEventHandler(async (event) => { const db = useDb() const [upload] = await db - .select({ fileName: uploads.fileName, previewImage: uploads.previewImage }) + .select({ fileName: uploads.fileName, previewImage: uploads.previewImage, type: uploads.type, originalName: uploads.originalName, userId: uploads.userId, userName: users.name }) .from(uploads) + .innerJoin(users, eq(uploads.userId, users.id)) .where(eq(uploads.id, id)) .limit(1) if (!upload) { @@ -40,5 +41,11 @@ export default defineEventHandler(async (event) => { } } broadcastToAll({ type: 'upload-deleted', ids: [id] }) + await logAudit(actor.id, 'delete_upload', { type: 'upload', id }, { + uploadType: upload.type, + originalName: upload.originalName, + uploaderId: upload.userId, + uploaderName: upload.userName, + }) return { ok: true } }) diff --git a/server/api/uploads/[id]/tags.put.ts b/server/api/uploads/[id]/tags.put.ts index fa99e5f..b34d376 100644 --- a/server/api/uploads/[id]/tags.put.ts +++ b/server/api/uploads/[id]/tags.put.ts @@ -60,5 +60,8 @@ export default defineEventHandler(async (event) => { await db.insert(uploadTags).values(tagRows.map(t => ({ uploadId: id, tagId: t.id }))) } + if (removed.length) { + await logAudit(user.id, 'remove_tags', { type: 'upload', id }, { removed, isUploader: upload.userId === user.id }) + } return { tags: tagRows.map(t => t.name).sort() } }) diff --git a/server/api/uploads/bulk-delete.post.ts b/server/api/uploads/bulk-delete.post.ts index 5c6631b..0796f9c 100644 --- a/server/api/uploads/bulk-delete.post.ts +++ b/server/api/uploads/bulk-delete.post.ts @@ -6,7 +6,7 @@ import { commentAttachments, comments, uploads } from '../../db/schema' const ID_RE = /^[A-Za-z0-9]{1,16}$/ export default defineEventHandler(async (event) => { - await requirePermission(event, 'upload.delete') + const actor = await requirePermission(event, 'upload.delete') const body = await readBody<{ ids?: unknown }>(event) const raw = Array.isArray(body?.ids) ? body.ids : [] const ids = [...new Set(raw.filter((x): x is string => typeof x === 'string' && ID_RE.test(x)))].slice(0, 200) @@ -45,5 +45,6 @@ export default defineEventHandler(async (event) => { } } broadcastToAll({ type: 'upload-deleted', ids: found }) + await logAudit(actor.id, 'bulk_delete_uploads', null, { ids: found, count: found.length }) return { ok: true, deleted: found.length } }) diff --git a/server/db/migrations/0026_sleepy_colonel_america.sql b/server/db/migrations/0026_sleepy_colonel_america.sql new file mode 100644 index 0000000..717c923 --- /dev/null +++ b/server/db/migrations/0026_sleepy_colonel_america.sql @@ -0,0 +1,13 @@ +CREATE TABLE `audit_log` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `user_id` bigint unsigned, + `action` varchar(50) NOT NULL, + `target_type` varchar(32), + `target_id` varchar(64), + `details` json, + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `audit_log_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `audit_log` ADD CONSTRAINT `audit_log_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `audit_log_action_idx` ON `audit_log` (`action`); \ No newline at end of file diff --git a/server/db/migrations/meta/0026_snapshot.json b/server/db/migrations/meta/0026_snapshot.json new file mode 100644 index 0000000..776b7f2 --- /dev/null +++ b/server/db/migrations/meta/0026_snapshot.json @@ -0,0 +1,1400 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "c3498a7c-1f9f-4022-97b9-2762fcb0a664", + "prevId": "3df9ffa9-7ef2-4961-b58f-337a559f789a", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + "action" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_log_user_id_users_id_fk": { + "name": "audit_log_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_log_id": { + "name": "audit_log_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comment_attachments": { + "name": "comment_attachments", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "comment_id": { + "name": "comment_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_attachments_comment_id_comments_id_fk": { + "name": "comment_attachments_comment_id_comments_id_fk", + "tableFrom": "comment_attachments", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_attachments_id": { + "name": "comment_attachments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comment_reactions": { + "name": "comment_reactions", + "columns": { + "comment_id": { + "name": "comment_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_reactions_comment_id_user_id_emoji_pk": { + "name": "comment_reactions_comment_id_user_id_emoji_pk", + "columns": [ + "comment_id", + "user_id", + "emoji" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "upload_id": { + "name": "upload_id", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "comments_upload_id_uploads_id_fk": { + "name": "comments_upload_id_uploads_id_fk", + "tableFrom": "comments", + "tableTo": "uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upload_id": { + "name": "upload_id", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_upload_id_uploads_id_fk": { + "name": "favorites_upload_id_uploads_id_fk", + "tableFrom": "favorites", + "tableTo": "uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "favorites_user_id_upload_id_pk": { + "name": "favorites_user_id_upload_id_pk", + "columns": [ + "user_id", + "upload_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "link_previews": { + "name": "link_previews", + "columns": { + "url_hash": { + "name": "url_hash", + "type": "char(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "link_previews_url_hash": { + "name": "link_previews_url_hash", + "columns": [ + "url_hash" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upload_id": { + "name": "upload_id", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_actor_id_users_id_fk": { + "name": "notifications_actor_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_upload_id_uploads_id_fk": { + "name": "notifications_upload_id_uploads_id_fk", + "tableFrom": "notifications", + "tableTo": "uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_comment_id_comments_id_fk": { + "name": "notifications_comment_id_comments_id_fk", + "tableFrom": "notifications", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "permissions": { + "name": "permissions", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "permissions_id": { + "name": "permissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "permissions_key_unique": { + "name": "permissions_key_unique", + "columns": [ + "key" + ] + } + }, + "checkConstraint": {} + }, + "role_permissions": { + "name": "role_permissions", + "columns": { + "role_id": { + "name": "role_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_id": { + "name": "permission_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tags_id": { + "name": "tags_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "thread_subscriptions": { + "name": "thread_subscriptions", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "thread_subscriptions_upload_id_uploads_id_fk": { + "name": "thread_subscriptions_upload_id_uploads_id_fk", + "tableFrom": "thread_subscriptions", + "tableTo": "uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_subscriptions_user_id_users_id_fk": { + "name": "thread_subscriptions_user_id_users_id_fk", + "tableFrom": "thread_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_subscriptions_upload_id_user_id_pk": { + "name": "thread_subscriptions_upload_id_user_id_pk", + "columns": [ + "upload_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "upload_reactions": { + "name": "upload_reactions", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "upload_reactions_upload_id_uploads_id_fk": { + "name": "upload_reactions_upload_id_uploads_id_fk", + "tableFrom": "upload_reactions", + "tableTo": "uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_reactions_user_id_users_id_fk": { + "name": "upload_reactions_user_id_users_id_fk", + "tableFrom": "upload_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "upload_reactions_upload_id_user_id_emoji_pk": { + "name": "upload_reactions_upload_id_user_id_emoji_pk", + "columns": [ + "upload_id", + "user_id", + "emoji" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "upload_tags": { + "name": "upload_tags", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "upload_tags_upload_id_uploads_id_fk": { + "name": "upload_tags_upload_id_uploads_id_fk", + "tableFrom": "upload_tags", + "tableTo": "uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "upload_tags_tag_id_tags_id_fk": { + "name": "upload_tags_tag_id_tags_id_fk", + "tableFrom": "upload_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "upload_tags_upload_id_tag_id_pk": { + "name": "upload_tags_upload_id_tag_id_pk", + "columns": [ + "upload_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "uploads": { + "name": "uploads", + "columns": { + "id": { + "name": "id", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('image','video','audio','youtube','twitch','link')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preview_image": { + "name": "preview_image", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preview_title": { + "name": "preview_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_meta": { + "name": "audio_meta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_count": { + "name": "comment_count", + "type": "int unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "uploads_user_id_users_id_fk": { + "name": "uploads_user_id_users_id_fk", + "tableFrom": "uploads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "uploads_id": { + "name": "uploads_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "card_effect": { + "name": "card_effect", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "card_effect_color": { + "name": "card_effect_color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "card_vignette": { + "name": "card_vignette", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "card_border_effect": { + "name": "card_border_effect", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "card_border_effect_color": { + "name": "card_border_effect_color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_name_unique": { + "name": "users_name_unique", + "columns": [ + "name" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index 7e387ac..30c01a3 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1791040007768, "tag": "0025_rare_william_stryker", "breakpoints": true + }, + { + "idx": 26, + "version": "5", + "when": 1791391067859, + "tag": "0026_sleepy_colonel_america", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index 174ed2b..8b0d3db 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,5 +1,5 @@ import { relations } from 'drizzle-orm' -import { bigint, boolean, char, int, json, mysqlEnum, mysqlTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/mysql-core' +import { bigint, boolean, char, index, int, json, mysqlEnum, mysqlTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/mysql-core' export const users = mysqlTable('users', { id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), @@ -138,6 +138,19 @@ export const uploadTags = mysqlTable('upload_tags', { tagId: bigint('tag_id', { mode: 'number', unsigned: true }).notNull().references(() => tags.id, { onDelete: 'cascade' }), }, t => [primaryKey({ columns: [t.uploadId, t.tagId] })]) +// Protokoll von Moderations- und Admin-Aktionen. Der Akteur bleibt beim +// Löschen des Nutzers als NULL erhalten, Details als JSON-Snapshot, damit +// Einträge auch nach dem Löschen des Ziels lesbar bleiben +export const auditLog = mysqlTable('audit_log', { + id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), + userId: bigint('user_id', { mode: 'number', unsigned: true }).references(() => users.id, { onDelete: 'set null' }), + action: varchar('action', { length: 50 }).notNull(), + targetType: varchar('target_type', { length: 32 }), + targetId: varchar('target_id', { length: 64 }), + details: json('details'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, t => [index('audit_log_action_idx').on(t.action)]) + // Generischer Key-Value-Store für Seiten-Einstellungen (z. B. MOTD) export const settings = mysqlTable('settings', { key: varchar('key', { length: 100 }).primaryKey(), @@ -212,6 +225,10 @@ export const threadSubscriptionsRelations = relations(threadSubscriptions, ({ on user: one(users, { fields: [threadSubscriptions.userId], references: [users.id] }), })) +export const auditLogRelations = relations(auditLog, ({ one }) => ({ + user: one(users, { fields: [auditLog.userId], references: [users.id] }), +})) + export const notificationsRelations = relations(notifications, ({ one }) => ({ user: one(users, { fields: [notifications.userId], references: [users.id] }), actor: one(users, { fields: [notifications.actorId], references: [users.id] }), diff --git a/server/utils/audit.ts b/server/utils/audit.ts new file mode 100644 index 0000000..b530842 --- /dev/null +++ b/server/utils/audit.ts @@ -0,0 +1,24 @@ +import type { AuditAction } from '../../shared/utils/audit' +import { auditLog } from '../db/schema' + +// Fehler beim Protokollieren dürfen die eigentliche Aktion nicht scheitern +// lassen — die ist zu diesem Zeitpunkt bereits ausgeführt +export async function logAudit( + userId: number, + action: AuditAction, + target: { type: string, id: string | number } | null, + details?: Record, +) { + try { + await useDb().insert(auditLog).values({ + userId, + action, + targetType: target?.type ?? null, + targetId: target ? String(target.id) : null, + details: details ?? null, + }) + } + catch (err) { + console.warn('[audit] Eintrag fehlgeschlagen:', err instanceof Error ? err.message : err) + } +} diff --git a/shared/utils/audit.ts b/shared/utils/audit.ts new file mode 100644 index 0000000..277ab4b --- /dev/null +++ b/shared/utils/audit.ts @@ -0,0 +1,15 @@ +// Katalog der protokollierten Aktionen — Labels im Admin über admin.auditActions. +export const AUDIT_ACTIONS = [ + 'delete_upload', + 'bulk_delete_uploads', + 'delete_comment', + 'remove_tags', + 'create_role', + 'delete_role', + 'update_role_permissions', + 'assign_role', + 'remove_role', + 'update_motd', +] as const + +export type AuditAction = (typeof AUDIT_ACTIONS)[number] diff --git a/shared/utils/permissions.ts b/shared/utils/permissions.ts index 640df86..c9d89cf 100644 --- a/shared/utils/permissions.ts +++ b/shared/utils/permissions.ts @@ -6,6 +6,7 @@ export const PERMISSIONS = [ { key: 'comment.delete', label: 'Kommentare löschen' }, { key: 'upload.delete', label: 'Uploads löschen' }, { key: 'tag.delete', label: 'Tags löschen' }, + { key: 'audit.view', label: 'Audit-Log einsehen' }, ] as const export type PermissionKey = (typeof PERMISSIONS)[number]['key']