forked from BRETT/BRETT
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { AUDIT_ACTIONS } from '~~/shared/utils/audit'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableEmpty,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number
|
||||
action: string
|
||||
targetType: string | null
|
||||
targetId: string | null
|
||||
details: Record<string, unknown> | null
|
||||
createdAt: string
|
||||
userId: number | null
|
||||
userName: string | null
|
||||
userColor: string | null
|
||||
userAvatarUrl: string | null
|
||||
}
|
||||
|
||||
interface AuditList {
|
||||
items: AuditEntry[]
|
||||
total: number
|
||||
perPage: number
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const timeAgo = useTimeAgo()
|
||||
|
||||
const page = ref(1)
|
||||
const action = ref('all')
|
||||
const actor = ref<{ id: number, name: string } | null>(null)
|
||||
|
||||
const query = computed(() => ({
|
||||
page: page.value,
|
||||
...(action.value !== 'all' && { action: action.value }),
|
||||
...(actor.value && { user: actor.value.id }),
|
||||
}))
|
||||
|
||||
const { data, pending, refresh } = await useFetch<AuditList>('/api/admin/audit', {
|
||||
query,
|
||||
server: false,
|
||||
})
|
||||
|
||||
watch([action, actor], () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
function filterByActor(entry: AuditEntry) {
|
||||
if (entry.userId === null || !entry.userName) return
|
||||
actor.value = { id: entry.userId, name: entry.userName }
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
}
|
||||
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : '')
|
||||
const list = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
|
||||
const permLabel = (key: string) => t(`admin.perms.${key}`)
|
||||
|
||||
// Lesbare Zusammenfassung pro Aktion aus dem Details-Snapshot
|
||||
function summary(entry: AuditEntry): string {
|
||||
const d = entry.details ?? {}
|
||||
switch (entry.action) {
|
||||
case 'delete_upload':
|
||||
return t('admin.auditDetails.deleteUpload', { name: str(d.originalName) || entry.targetId, user: str(d.uploaderName) || '?' })
|
||||
case 'bulk_delete_uploads':
|
||||
return t('admin.auditDetails.bulkDelete', { n: Number(d.count) || list(d.ids).length })
|
||||
case 'delete_comment':
|
||||
return t('admin.auditDetails.deleteComment', { user: str(d.authorName) || '?', content: str(d.content).slice(0, 120) })
|
||||
case 'remove_tags':
|
||||
return list(d.removed).join(', ')
|
||||
case 'create_role':
|
||||
case 'delete_role':
|
||||
return str(d.name)
|
||||
case 'update_role_permissions': {
|
||||
const added = list(d.added).map(k => `+ ${permLabel(k)}`)
|
||||
const removed = list(d.removed).map(k => `− ${permLabel(k)}`)
|
||||
return [str(d.name), [...added, ...removed].join(', ') || t('admin.auditDetails.noChanges')].join(': ')
|
||||
}
|
||||
case 'assign_role':
|
||||
case 'remove_role':
|
||||
return t('admin.auditDetails.role', { role: str(d.role), user: str(d.userName) || `#${entry.targetId}` })
|
||||
case 'update_motd':
|
||||
return str(d.message) || t('admin.auditDetails.motdCleared')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Verlinkbares Ziel — gelöschte Uploads führen ins Leere und bleiben Text
|
||||
function targetLink(entry: AuditEntry): string | null {
|
||||
const d = entry.details ?? {}
|
||||
if (entry.action === 'delete_comment' && str(d.uploadId)) return `/t/${str(d.uploadId)}`
|
||||
if (entry.action === 'remove_tags' && entry.targetId) return `/t/${entry.targetId}`
|
||||
if (entry.targetType === 'user' && entry.targetId) return `/user/${entry.targetId}`
|
||||
return null
|
||||
}
|
||||
|
||||
const bulkIds = (entry: AuditEntry) => (entry.action === 'bulk_delete_uploads' ? list(entry.details?.ids) : [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Select v-model="action">
|
||||
<SelectTrigger size="sm" class="w-56" :aria-label="$t('admin.audit.filterAction')">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">
|
||||
{{ $t('admin.audit.allActions') }}
|
||||
</SelectItem>
|
||||
<SelectItem v-for="a in AUDIT_ACTIONS" :key="a" :value="a">
|
||||
{{ $t(`admin.auditActions.${a}`) }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Badge v-if="actor" variant="secondary" class="gap-1">
|
||||
{{ actor.name }}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer opacity-60 hover:opacity-100"
|
||||
:aria-label="$t('admin.audit.clearUser')"
|
||||
@click="actor = null"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
<span v-if="data" class="text-sm text-muted-foreground">{{ $t('admin.audit.count', { n: data.total }) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="ml-auto text-muted-foreground"
|
||||
:disabled="pending"
|
||||
:aria-label="$t('admin.audit.refresh')"
|
||||
@click="refresh()"
|
||||
>
|
||||
<Icon name="ph:arrow-clockwise-bold" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-32">
|
||||
{{ $t('admin.audit.time') }}
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
{{ $t('admin.audit.actor') }}
|
||||
</TableHead>
|
||||
<TableHead class="w-48">
|
||||
{{ $t('admin.audit.action') }}
|
||||
</TableHead>
|
||||
<TableHead>{{ $t('admin.audit.details') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="pending && !data">
|
||||
<TableRow v-for="i in 5" :key="i">
|
||||
<TableCell v-for="j in 4" :key="j">
|
||||
<Skeleton class="h-4 w-full rounded-sm" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else-if="data?.items.length">
|
||||
<TableRow v-for="entry in data.items" :key="entry.id">
|
||||
<TableCell class="text-xs text-muted-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span>{{ timeAgo(entry.createdAt) }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ formatDate(entry.createdAt) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
v-if="entry.userName"
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-2 text-sm font-medium hover:underline"
|
||||
:style="entry.userColor ? { color: entry.userColor } : undefined"
|
||||
:title="$t('admin.audit.filterUser', { name: entry.userName })"
|
||||
@click="filterByActor(entry)"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="entry.userAvatarUrl"
|
||||
:src="entry.userAvatarUrl"
|
||||
:alt="entry.userName"
|
||||
width="20"
|
||||
height="20"
|
||||
fit="cover"
|
||||
class="size-5 shrink-0 rounded-sm"
|
||||
/>
|
||||
{{ entry.userName }}
|
||||
</button>
|
||||
<span v-else class="text-sm text-muted-foreground italic">{{ $t('admin.audit.deletedUser') }}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{{ $t(`admin.auditActions.${entry.action}`) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="max-w-md text-sm whitespace-normal break-words">
|
||||
<NuxtLink v-if="targetLink(entry)" :to="targetLink(entry)!" class="hover:underline">
|
||||
{{ summary(entry) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ summary(entry) }}</span>
|
||||
<span v-if="bulkIds(entry).length" class="mt-0.5 block font-mono text-xs text-muted-foreground">
|
||||
{{ bulkIds(entry).join(' ') }}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<TableEmpty v-else :colspan="4">
|
||||
{{ $t('admin.audit.empty') }}
|
||||
</TableEmpty>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="data && data.pageCount > 1"
|
||||
v-model:page="page"
|
||||
:items-per-page="data.perPage"
|
||||
:total="data.total"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
>
|
||||
<PaginationContent v-slot="{ items }">
|
||||
<PaginationPrevious />
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<PaginationItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
:is-active="item.value === page"
|
||||
>
|
||||
{{ item.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else />
|
||||
</template>
|
||||
<PaginationNext />
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
@@ -331,6 +331,15 @@ async function saveMotd() {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card v-if="can('audit.view')" class="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('admin.audit.title') }}</CardTitle>
|
||||
<CardDescription>{{ $t('admin.audit.desc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditLog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p v-if="errorMessage" class="text-sm text-destructive lg:col-span-2">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
+38
-1
@@ -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": {
|
||||
|
||||
+38
-1
@@ -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": {
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+18
-1
@@ -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] }),
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Katalog der protokollierten Aktionen — Labels im Admin über admin.auditActions.<key>
|
||||
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]
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user