Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod'
import { settings } from '../../db/schema'
const bodySchema = z.object({
message: z.string().trim().max(500),
})
export default defineEventHandler(async (event) => {
await requirePermission(event, 'site.manage')
const { message } = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
await db
.insert(settings)
.values({ key: 'motd', value: message })
.onDuplicateKeyUpdate({ set: { value: message } })
broadcastToAll({ type: 'motd', message })
return { message }
})
+18
View File
@@ -0,0 +1,18 @@
import { asc, count, eq } from 'drizzle-orm'
import { roles, userRoles } from '../../db/schema'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'admin.access')
const roleList = await useDb()
.select({
id: roles.id,
name: roles.name,
memberCount: count(userRoles.userId),
})
.from(roles)
.leftJoin(userRoles, eq(userRoles.roleId, roles.id))
.groupBy(roles.id)
.orderBy(asc(roles.name))
const permsByRole = await getRolePermissions()
return roleList.map(r => ({ ...r, permissions: permsByRole.get(r.id) ?? [] }))
})
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod'
const bodySchema = z.object({
name: z.string().trim().min(2).max(50).regex(/^[\w-]+$/, 'Nur Buchstaben, Zahlen, - und _'),
})
export default defineEventHandler(async (event) => {
await requirePermission(event, 'roles.manage')
const { name } = await readValidatedBody(event, bodySchema.parse)
const id = await ensureRole(name)
return { id, name }
})
+21
View File
@@ -0,0 +1,21 @@
import { eq } from 'drizzle-orm'
import { roles } from '../../../db/schema'
import { SYSTEM_ROLES } from '../../../../shared/utils/permissions'
export default defineEventHandler(async (event) => {
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' })
}
const db = useDb()
const existing = await db.select({ name: roles.name }).from(roles).where(eq(roles.id, id)).limit(1)
if (!existing[0]) {
throw createError({ statusCode: 404, statusMessage: 'Rolle nicht gefunden' })
}
if ((SYSTEM_ROLES as readonly string[]).includes(existing[0].name)) {
throw createError({ statusCode: 400, statusMessage: `Die ${existing[0].name}-Rolle kann nicht gelöscht werden` })
}
await db.delete(roles).where(eq(roles.id, id))
return { ok: true }
})
@@ -0,0 +1,38 @@
import { eq, inArray } from 'drizzle-orm'
import { z } from 'zod'
import { PERMISSION_KEYS } from '../../../../../shared/utils/permissions'
import { permissions, rolePermissions, roles } from '../../../../db/schema'
const bodySchema = z.object({
permissions: z.array(z.string()).max(50),
})
export default defineEventHandler(async (event) => {
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' })
}
const { permissions: keys } = await readValidatedBody(event, bodySchema.parse)
if (keys.some(k => !PERMISSION_KEYS.includes(k))) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannte Berechtigung' })
}
const db = useDb()
const [role] = await db.select({ name: roles.name }).from(roles).where(eq(roles.id, id)).limit(1)
if (!role) {
throw createError({ statusCode: 404, statusMessage: 'Rolle nicht gefunden' })
}
if (role.name === 'admin') {
throw createError({ statusCode: 400, statusMessage: 'Die Admin-Rolle hat immer alle Berechtigungen' })
}
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))
if (rows.length) {
await db.insert(rolePermissions).values(rows.map(r => ({ roleId: id, permissionId: r.id })))
}
}
return { ok: true }
})
+26
View File
@@ -0,0 +1,26 @@
import { asc, eq } from 'drizzle-orm'
import { roles, userRoles, users } from '../../db/schema'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'admin.access')
const db = useDb()
const allUsers = await db
.select({ id: users.id, name: users.name, email: users.email, createdAt: users.createdAt })
.from(users)
.orderBy(asc(users.name))
const roleRows = await db
.select({ userId: userRoles.userId, roleId: roles.id, name: roles.name })
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
const byUser = new Map<number, { id: number, name: string }[]>()
for (const row of roleRows) {
const list = byUser.get(row.userId) ?? []
list.push({ id: row.roleId, name: row.name })
byUser.set(row.userId, list)
}
return allUsers.map(u => ({ ...u, roles: byUser.get(u.id) ?? [] }))
})
+29
View File
@@ -0,0 +1,29 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { roles, users } from '../../../../db/schema'
const bodySchema = z.object({
roleId: z.number().int().positive(),
})
export default defineEventHandler(async (event) => {
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' })
}
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)
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)
if (!role) {
throw createError({ statusCode: 404, statusMessage: 'Rolle nicht gefunden' })
}
await assignRole(userId, roleId)
return { ok: true }
})
@@ -0,0 +1,23 @@
import { and, eq } from 'drizzle-orm'
import { roles, userRoles } from '../../../../../db/schema'
export default defineEventHandler(async (event) => {
const admin = await requirePermission(event, 'users.manage')
const userId = Number(getRouterParam(event, 'id'))
const roleId = Number(getRouterParam(event, 'roleId'))
if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(roleId) || roleId <= 0) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const db = useDb()
const [role] = await db.select({ name: roles.name }).from(roles).where(eq(roles.id, roleId)).limit(1)
if (!role) {
throw createError({ statusCode: 404, statusMessage: 'Rolle nicht gefunden' })
}
if (role.name === 'admin' && userId === admin.id) {
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)))
return { ok: true }
})
+22
View File
@@ -0,0 +1,22 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { users } from '../../db/schema'
const bodySchema = z.object({
name: z.string().trim().min(1),
password: z.string().min(1),
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const rows = await db.select().from(users).where(eq(users.name, body.name)).limit(1)
const user = rows[0]
if (!user || !verifyPassword(body.password, user.passwordHash)) {
throw createError({ statusCode: 401, statusMessage: 'Name oder Passwort falsch' })
}
await createSession(event, user.id)
return { user: { id: user.id, name: user.name, email: user.email, color: user.color, cardEffect: user.cardEffect, cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette, cardBorderEffect: user.cardBorderEffect, cardBorderEffectColor: user.cardBorderEffectColor, avatarUrl: avatarUrl(user.avatar), roles: await getUserRoles(user.id), permissions: await getUserPermissions(user.id) } }
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
await destroySession(event)
return { ok: true }
})
+6
View File
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const user = await getSessionUser(event)
return {
user: user ? { id: user.id, name: user.name, email: user.email, color: user.color, cardEffect: user.cardEffect, cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette, cardBorderEffect: user.cardBorderEffect, cardBorderEffectColor: user.cardBorderEffectColor, avatarUrl: avatarUrl(user.avatar), roles: user.roles, permissions: user.permissions } : null,
}
})
+51
View File
@@ -0,0 +1,51 @@
import { count, eq } from 'drizzle-orm'
import { z } from 'zod'
import { users } from '../../db/schema'
const bodySchema = z.object({
name: z.string().trim().min(3, 'Mindestens 3 Zeichen').max(50),
password: z.string().min(6, 'Mindestens 6 Zeichen').max(128),
email: z.union([z.literal(''), z.email().max(255)]).optional(),
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const existing = await db
.select({ id: users.id })
.from(users)
.where(eq(users.name, body.name))
.limit(1)
if (existing.length) {
throw createError({ statusCode: 409, statusMessage: 'Name ist bereits vergeben' })
}
try {
const color = randomUserColor()
const [result] = await db.insert(users).values({
name: body.name,
email: body.email || null,
passwordHash: hashPassword(body.password),
color,
})
const userId = Number(result.insertId)
const [{ value: userCount }] = await db.select({ value: count() }).from(users)
let roleNames: string[] = []
if (userCount === 1) {
const adminRoleId = await ensureRole('admin')
await assignRole(userId, adminRoleId)
roleNames = ['admin']
}
await createSession(event, userId)
return { user: { id: userId, name: body.name, email: body.email || null, color, cardEffect: null, cardEffectColor: null, cardVignette: false, cardBorderEffect: null, cardBorderEffectColor: null, avatarUrl: null, roles: roleNames, permissions: await getUserPermissions(userId) } }
}
catch (err: unknown) {
if (typeof err === 'object' && err !== null && (err as { errno?: number }).errno === 1062) {
throw createError({ statusCode: 409, statusMessage: 'Name oder E-Mail ist bereits vergeben' })
}
throw err
}
})
+34
View File
@@ -0,0 +1,34 @@
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { eq, sql } from 'drizzle-orm'
import { commentAttachments, comments, uploads } from '../../db/schema'
export default defineEventHandler(async (event) => {
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 })
.from(comments)
.where(eq(comments.id, id))
.limit(1)
.then(r => r[0])
const files = await db
.select({ fileName: commentAttachments.fileName, cover: commentAttachments.cover })
.from(commentAttachments)
.where(eq(commentAttachments.commentId, id))
await db.delete(comments).where(eq(comments.id, id))
if (comment) {
await db.update(uploads).set({ commentCount: sql`GREATEST(comment_count - 1, 0)` }).where(eq(uploads.id, comment.uploadId))
}
for (const f of files) {
await unlink(join(uploadDir(), 'attachments', f.fileName)).catch(() => {})
if (f.cover) {
await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {})
}
}
return { ok: true }
})
@@ -0,0 +1,69 @@
import { and, eq, sql } from 'drizzle-orm'
import { z } from 'zod'
import { commentReactions, comments } from '../../../db/schema'
const bodySchema = z.object({
emoji: z.string().min(1).max(32),
})
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id) || id <= 0) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const { emoji } = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const [comment] = await db
.select({ id: comments.id, uploadId: comments.uploadId })
.from(comments)
.where(eq(comments.id, id))
.limit(1)
if (!comment) {
throw createError({ statusCode: 404, statusMessage: 'Kommentar nicht gefunden' })
}
const existing = await db
.select({ userId: commentReactions.userId })
.from(commentReactions)
.where(and(
eq(commentReactions.commentId, id),
eq(commentReactions.userId, user.id),
eq(commentReactions.emoji, emoji),
))
.limit(1)
let active: boolean
if (existing.length) {
await db.delete(commentReactions).where(and(
eq(commentReactions.commentId, id),
eq(commentReactions.userId, user.id),
eq(commentReactions.emoji, emoji),
))
active = false
}
else {
await db.insert(commentReactions).values({ commentId: id, userId: user.id, emoji })
active = true
}
const [{ total }] = await db
.select({ total: sql<number>`count(*)` })
.from(commentReactions)
.where(and(eq(commentReactions.commentId, id), eq(commentReactions.emoji, emoji)))
const count = Number(total)
broadcastToAll({
type: 'reaction',
commentId: id,
uploadId: comment.uploadId,
emoji,
actorId: user.id,
actorName: user.name,
active,
count,
})
return { active, count }
})
+47
View File
@@ -0,0 +1,47 @@
import { asc, desc, eq, inArray } from 'drizzle-orm'
import { commentAttachments, comments, uploads, users } from '../../db/schema'
import type { CommentAttachment } from '~~/shared/utils/attachments'
export default defineEventHandler(async () => {
const db = useDb()
const rows = await db
.select({
id: comments.id,
content: comments.content,
createdAt: comments.createdAt,
userName: users.name,
userId: users.id,
userColor: users.color,
userAvatar: users.avatar,
uploadId: uploads.id,
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.innerJoin(uploads, eq(comments.uploadId, uploads.id))
.orderBy(desc(comments.createdAt))
.limit(15)
const commentIds = rows.map(r => r.id)
const attachmentRows = commentIds.length
? await db
.select()
.from(commentAttachments)
.where(inArray(commentAttachments.commentId, commentIds))
.orderBy(asc(commentAttachments.id))
: []
const attachmentsByComment = new Map<number, CommentAttachment[]>()
for (const a of attachmentRows) {
let list = attachmentsByComment.get(a.commentId)
if (!list) {
list = []
attachmentsByComment.set(a.commentId, list)
}
list.push({ id: a.id, name: a.originalName, url: `/uploads/attachments/${a.fileName}`, mimeType: a.mimeType, size: a.size, cover: a.cover, meta: a.meta as CommentAttachment['meta'] })
}
return rows.map(r => ({
...r,
userAvatarUrl: avatarUrl(r.userAvatar),
attachments: attachmentsByComment.get(r.id) ?? [],
}))
})
+16
View File
@@ -0,0 +1,16 @@
import { sql } from 'drizzle-orm'
import { users } from '../db/schema'
export default defineEventHandler(async () => {
try {
const db = useDb()
await db.execute(sql`SELECT 1`)
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(users)
return { status: 'ok', database: 'connected', users: Number(count) }
}
catch {
throw createError({ statusCode: 503, statusMessage: 'Database unavailable' })
}
})
+37
View File
@@ -0,0 +1,37 @@
import { createHash } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { linkPreviews } from '../db/schema'
const CACHE_TTL_MS = 1000 * 60 * 60 * 24
export default defineEventHandler(async (event) => {
const url = String(getQuery(event).url ?? '')
if (!/^https?:\/\//i.test(url) || url.length > 2048) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige URL' })
}
const urlHash = createHash('sha256').update(url).digest('hex')
const db = useDb()
const [cached] = await db
.select()
.from(linkPreviews)
.where(eq(linkPreviews.urlHash, urlHash))
.limit(1)
if (cached && Date.now() - cached.fetchedAt.getTime() < CACHE_TTL_MS) {
return { image: cached.image, title: cached.title }
}
const preview = await fetchLinkPreview(url)
// Vorschaubild lokal speichern — die Remote-URL ist nicht stabil
const image = preview.image
? (await savePreviewImage(`links/${urlHash}`, preview.image)) ?? preview.image
: null
await db
.insert(linkPreviews)
.values({ urlHash, url, image, title: preview.title, fetchedAt: new Date() })
.onDuplicateKeyUpdate({
set: { image, title: preview.title, fetchedAt: new Date() },
})
return { image, title: preview.title }
})
+12
View File
@@ -0,0 +1,12 @@
import { eq } from 'drizzle-orm'
import { settings } from '../db/schema'
export default defineEventHandler(async () => {
const db = useDb()
const [row] = await db
.select({ value: settings.value })
.from(settings)
.where(eq(settings.key, 'motd'))
.limit(1)
return { message: row?.value ?? '' }
})
@@ -0,0 +1,19 @@
import { and, eq } from 'drizzle-orm'
import { notifications, users } from '../../../db/schema'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id) || id <= 0) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
await useDb().transaction(async (tx) => {
await tx.select({ id: users.id }).from(users).where(eq(users.id, user.id)).for('update')
const [notification] = await tx.select({ uploadId: notifications.uploadId }).from(notifications)
.where(and(eq(notifications.id, id), eq(notifications.userId, user.id))).limit(1)
if (!notification) return
await tx.update(notifications).set({ isRead: true })
.where(and(eq(notifications.uploadId, notification.uploadId), eq(notifications.userId, user.id)))
})
return { ok: true }
})
+52
View File
@@ -0,0 +1,52 @@
import { and, count, desc, eq, max } from 'drizzle-orm'
import { comments, notifications, uploads, users } from '../../db/schema'
import { mentionedUserIds } from '../../../shared/utils/mentions'
export default defineEventHandler(async (event) => {
const user = await getSessionUser(event)
if (!user) {
return { notifications: [], unreadCount: 0 }
}
const db = useDb()
const threads = db.select({ id: max(notifications.id).as('thread_notification_id') }).from(notifications)
.where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)))
.groupBy(notifications.uploadId).as('thread_notifications')
const [total] = await db.select({ count: count() }).from(threads)
const rows = await db
.select({
id: notifications.id,
uploadId: notifications.uploadId,
commentId: notifications.commentId,
isRead: notifications.isRead,
createdAt: notifications.createdAt,
actorName: users.name,
actorColor: users.color,
content: comments.content,
uploadType: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
previewImage: uploads.previewImage,
})
.from(notifications)
.innerJoin(threads, eq(notifications.id, threads.id))
.innerJoin(users, eq(notifications.actorId, users.id))
.innerJoin(comments, eq(notifications.commentId, comments.id))
.innerJoin(uploads, eq(notifications.uploadId, uploads.id))
.where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)))
.orderBy(desc(notifications.createdAt), desc(notifications.id))
.limit(30)
const mentionUsers = rows.some(row => row.content.includes('@'))
? await db.select({ id: users.id, name: users.name, color: users.color }).from(users)
: []
return {
notifications: rows.map((row) => {
const url = row.externalUrl ?? `/uploads/${row.fileName}`
const embedId = row.uploadType === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null
const thumbUrl = row.uploadType === 'youtube' && embedId
? youtubeThumbUrl(embedId)
: row.uploadType === 'image' ? url : row.uploadType === 'link' ? row.previewImage : null
return { ...row, url, thumbUrl, isMention: mentionedUserIds(row.content, mentionUsers).has(user.id) }
}),
unreadCount: Number(total?.count ?? 0),
}
})
+11
View File
@@ -0,0 +1,11 @@
import { and, eq } from 'drizzle-orm'
import { notifications } from '../../db/schema'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
await useDb()
.update(notifications)
.set({ isRead: true })
.where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)))
return { ok: true }
})
+18
View File
@@ -0,0 +1,18 @@
import { count, desc, eq, like } from 'drizzle-orm'
import { tags, uploadTags } from '../../db/schema'
export default defineEventHandler(async (event) => {
const q = String(getQuery(event).q ?? '').trim().toLowerCase().slice(0, 64)
const db = useDb()
const rows = await db
.select({ name: tags.name, count: count(uploadTags.uploadId) })
.from(tags)
.leftJoin(uploadTags, eq(uploadTags.tagId, tags.id))
.where(q ? like(tags.name, `${q}%`) : undefined)
.groupBy(tags.id, tags.name)
.orderBy(desc(count(uploadTags.uploadId)), tags.name)
.limit(15)
return rows
})
+40
View File
@@ -0,0 +1,40 @@
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { commentAttachments, comments, uploads } from '../../db/schema'
export default defineEventHandler(async (event) => {
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' })
}
const db = useDb()
const [upload] = await db
.select({ fileName: uploads.fileName })
.from(uploads)
.where(eq(uploads.id, id))
.limit(1)
if (!upload) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const attachmentFiles = await db
.select({ fileName: commentAttachments.fileName, cover: commentAttachments.cover })
.from(commentAttachments)
.innerJoin(comments, eq(commentAttachments.commentId, comments.id))
.where(eq(comments.uploadId, id))
await db.delete(uploads).where(eq(uploads.id, id))
if (upload.fileName) {
await unlink(join(uploadDir(), upload.fileName)).catch(() => {})
}
for (const f of attachmentFiles) {
await unlink(join(uploadDir(), 'attachments', f.fileName)).catch(() => {})
if (f.cover) {
await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {})
}
}
return { ok: true }
})
+183
View File
@@ -0,0 +1,183 @@
import { and, asc, desc, eq, gt, inArray, lt, or } from 'drizzle-orm'
import { commentAttachments, commentReactions, comments, favorites, tags, threadSubscriptions, uploads, uploadReactions, uploadTags, users } from '../../db/schema'
import type { CommentAttachment } from '~~/shared/utils/attachments'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const db = useDb()
const uploadRows = await db
.select({
id: uploads.id,
type: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
originalName: uploads.originalName,
mimeType: uploads.mimeType,
previewImage: uploads.previewImage,
previewTitle: uploads.previewTitle,
audioMeta: uploads.audioMeta,
createdAt: uploads.createdAt,
userName: users.name,
userId: users.id,
userColor: users.color,
userAvatar: users.avatar,
userCardEffect: users.cardEffect,
userCardEffectColor: users.cardEffectColor,
userCardVignette: users.cardVignette,
userCardBorderEffect: users.cardBorderEffect,
userCardBorderEffectColor: users.cardBorderEffectColor,
})
.from(uploads)
.innerJoin(users, eq(uploads.userId, users.id))
.where(eq(uploads.id, id))
.limit(1)
const upload = uploadRows[0]
if (!upload) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const commentRows = await db
.select({
id: comments.id,
content: comments.content,
createdAt: comments.createdAt,
userName: users.name,
userId: users.id,
userColor: users.color,
userAvatar: users.avatar,
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.where(eq(comments.uploadId, id))
.orderBy(asc(comments.createdAt))
// Nachbarn in Grid-Reihenfolge (createdAt desc, id desc als Tiebreak)
const sessionUser = await getSessionUser(event)
const commentIds = commentRows.map(c => c.id)
const [reactionRows, uploadReactionRows, favRows, newerRows, olderRows, tagRows, attachmentRows, subscriptionRows] = await Promise.all([
commentIds.length
? db
.select({
commentId: commentReactions.commentId,
emoji: commentReactions.emoji,
userId: commentReactions.userId,
userName: users.name,
})
.from(commentReactions)
.innerJoin(users, eq(commentReactions.userId, users.id))
.where(inArray(commentReactions.commentId, commentIds))
.orderBy(asc(commentReactions.createdAt))
: Promise.resolve([]),
db
.select({
emoji: uploadReactions.emoji,
userId: uploadReactions.userId,
userName: users.name,
})
.from(uploadReactions)
.innerJoin(users, eq(uploadReactions.userId, users.id))
.where(eq(uploadReactions.uploadId, id))
.orderBy(asc(uploadReactions.createdAt)),
db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar })
.from(favorites)
.innerJoin(users, eq(favorites.userId, users.id))
.where(eq(favorites.uploadId, id))
.orderBy(desc(favorites.createdAt)),
db
.select({ id: uploads.id })
.from(uploads)
.where(or(
gt(uploads.createdAt, upload.createdAt),
and(eq(uploads.createdAt, upload.createdAt), gt(uploads.id, upload.id)),
))
.orderBy(asc(uploads.createdAt), asc(uploads.id))
.limit(1),
db
.select({ id: uploads.id })
.from(uploads)
.where(or(
lt(uploads.createdAt, upload.createdAt),
and(eq(uploads.createdAt, upload.createdAt), lt(uploads.id, upload.id)),
))
.orderBy(desc(uploads.createdAt), desc(uploads.id))
.limit(1),
db
.select({ name: tags.name })
.from(uploadTags)
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
.where(eq(uploadTags.uploadId, id))
.orderBy(asc(tags.name)),
commentIds.length
? db
.select()
.from(commentAttachments)
.where(inArray(commentAttachments.commentId, commentIds))
.orderBy(asc(commentAttachments.id))
: Promise.resolve([]),
sessionUser
? db.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
.where(and(eq(threadSubscriptions.uploadId, id), eq(threadSubscriptions.userId, sessionUser.id))).limit(1)
: Promise.resolve([]),
])
const attachmentsByComment = new Map<number, CommentAttachment[]>()
for (const a of attachmentRows) {
let list = attachmentsByComment.get(a.commentId)
if (!list) {
list = []
attachmentsByComment.set(a.commentId, list)
}
list.push({ id: a.id, name: a.originalName, url: `/uploads/attachments/${a.fileName}`, mimeType: a.mimeType, size: a.size, cover: a.cover, meta: a.meta as CommentAttachment['meta'] })
}
const reactionsByComment = new Map<number, { emoji: string, count: number, me: boolean, names: string[] }[]>()
for (const r of reactionRows) {
let list = reactionsByComment.get(r.commentId)
if (!list) {
list = []
reactionsByComment.set(r.commentId, list)
}
let entry = list.find(e => e.emoji === r.emoji)
if (!entry) {
entry = { emoji: r.emoji, count: 0, me: false, names: [] }
list.push(entry)
}
entry.count += 1
entry.names.push(r.userName)
if (r.userId === sessionUser?.id) entry.me = true
}
const uploadReactionList: { emoji: string, count: number, me: boolean, names: string[] }[] = []
for (const r of uploadReactionRows) {
let entry = uploadReactionList.find(e => e.emoji === r.emoji)
if (!entry) {
entry = { emoji: r.emoji, count: 0, me: false, names: [] }
uploadReactionList.push(entry)
}
entry.count += 1
entry.names.push(r.userName)
if (r.userId === sessionUser?.id) entry.me = true
}
const url = upload.externalUrl ?? `/uploads/${upload.fileName}`
const embedId = upload.type === 'youtube' && upload.externalUrl
? getYoutubeId(upload.externalUrl)
: upload.type === 'twitch' && upload.externalUrl
? serializeTwitchTarget(getTwitchTarget(upload.externalUrl))
: null
return {
upload: { ...upload, url, embedId, userAvatarUrl: avatarUrl(upload.userAvatar), tags: tagRows.map(t => t.name) },
comments: commentRows.map(c => ({ ...c, userAvatarUrl: avatarUrl(c.userAvatar), reactions: reactionsByComment.get(c.id) ?? [], attachments: attachmentsByComment.get(c.id) ?? [] })),
uploadReactions: uploadReactionList,
favoritedBy: favRows.map(f => ({ id: f.id, name: f.name, color: f.color, avatarUrl: avatarUrl(f.avatar) })),
isFavorited: sessionUser ? favRows.some(f => f.id === sessionUser.id) : false,
isSubscribed: subscriptionRows[0]?.enabled ?? false,
newerId: newerRows[0]?.id ?? null,
olderId: olderRows[0]?.id ?? null,
}
})
+130
View File
@@ -0,0 +1,130 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { eq, sql } from 'drizzle-orm'
import { z } from 'zod'
import { commentAttachments, comments, uploads } from '../../../db/schema'
import { isAudioAttachment, MAX_COMMENT_ATTACHMENTS } from '~~/shared/utils/attachments'
const bodySchema = z.object({
content: z.string().trim().min(1).max(2000),
})
const attachmentsDir = () => join(uploadDir(), 'attachments')
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const id = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const db = useDb()
let content: string
let files: { filename: string, type: string | undefined, data: Buffer, voice: boolean }[] = []
if ((getRequestHeader(event, 'content-type') ?? '').includes('multipart/form-data')) {
const parts = await readMultipartFormData(event)
content = (parts?.find(p => p.name === 'content' && !p.filename)?.data.toString('utf8') ?? '').trim()
files = (parts ?? [])
.filter(p => (p.name === 'files' || p.name === 'voice') && p.filename && p.data?.length)
.map(p => ({ filename: p.filename!, type: p.type, data: p.data, voice: p.name === 'voice' }))
if (files.length > MAX_COMMENT_ATTACHMENTS) {
throw createError({ statusCode: 400, statusMessage: `Maximal ${MAX_COMMENT_ATTACHMENTS} Anhänge pro Kommentar` })
}
if (files.some(f => f.data.length > MAX_UPLOAD_SIZE)) {
throw createError({ statusCode: 413, statusMessage: 'Anhang ist zu groß (max. 200 MB)' })
}
if (!content && !files.length) {
throw createError({ statusCode: 400, statusMessage: 'Kommentar darf nicht leer sein' })
}
if (content.length > 2000) {
throw createError({ statusCode: 400, statusMessage: 'Kommentar ist zu lang (max. 2000 Zeichen)' })
}
}
else {
content = (await readValidatedBody(event, bodySchema.parse)).content
}
const exists = await db
.select({ id: uploads.id })
.from(uploads)
.where(eq(uploads.id, id))
.limit(1)
if (!exists.length) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const commentId = await db.transaction(async (tx) => {
const [result] = await tx.insert(comments).values({ uploadId: id, userId: user.id, content })
await ensureThreadSubscription(id, user.id, tx)
return Number(result.insertId)
})
await db.update(uploads).set({ commentCount: sql`comment_count + 1` }).where(eq(uploads.id, id))
const attachments: { id: number, name: string, url: string, mimeType: string | null, size: number, cover: string | null, meta: unknown }[] = []
if (files.length) {
await mkdir(attachmentsDir(), { recursive: true })
for (const [i, file] of files.entries()) {
let data = file.data
let filename = file.filename
let mimeType = (file.type || 'application/octet-stream').slice(0, 100)
if (file.voice) {
const mp3 = await transcodeVoiceToMp3(data)
if (mp3) {
data = mp3
mimeType = 'audio/mpeg'
filename = filename.replace(/\.[^./]+$/, '') + '.mp3'
}
}
const ext = extname(filename).toLowerCase().replace(/[^a-z0-9.]/g, '').slice(0, 10)
const storedName = `${commentId}-${i}${ext}`
await writeFile(join(attachmentsDir(), storedName), data)
const audioMeta = !file.voice && isAudioAttachment({ mimeType, name: filename })
? await extractAudioMeta(`att-${commentId}-${i}`, data, mimeType)
: null
const meta = file.voice ? { voice: true } : (audioMeta?.meta ?? null)
const [row] = await db.insert(commentAttachments).values({
commentId,
fileName: storedName,
originalName: filename.slice(0, 255),
mimeType,
size: data.length,
cover: audioMeta?.cover ?? null,
meta,
})
attachments.push({
id: Number(row.insertId),
name: filename.slice(0, 255),
url: `/uploads/attachments/${storedName}`,
mimeType,
size: data.length,
cover: audioMeta?.cover ?? null,
meta,
})
}
}
const createdAt = new Date()
broadcastToAll({
type: 'comment',
comment: {
id: commentId,
content,
createdAt: createdAt.toISOString(),
userName: user.name,
userId: user.id,
userColor: user.color,
userAvatarUrl: avatarUrl(user.avatar),
uploadId: id,
attachments,
},
})
await notifyComment(user.id, commentId, id, content || '[Anhang]')
return {
id: commentId,
content,
attachments,
userName: user.name,
createdAt: new Date(),
}
})
+51
View File
@@ -0,0 +1,51 @@
import { and, desc, eq } from 'drizzle-orm'
import { favorites, threadSubscriptions, uploads, users } from '../../../db/schema'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const id = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const db = useDb()
const exists = await db
.select({ id: uploads.id })
.from(uploads)
.where(eq(uploads.id, id))
.limit(1)
if (!exists.length) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const existing = await db
.select()
.from(favorites)
.where(and(eq(favorites.uploadId, id), eq(favorites.userId, user.id)))
.limit(1)
if (existing.length) {
await db.delete(favorites).where(and(eq(favorites.uploadId, id), eq(favorites.userId, user.id)))
}
else {
await db.transaction(async (tx) => {
await tx.insert(favorites).values({ uploadId: id, userId: user.id })
await ensureThreadSubscription(id, user.id, tx)
})
}
const favRows = await db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar })
.from(favorites)
.innerJoin(users, eq(favorites.userId, users.id))
.where(eq(favorites.uploadId, id))
.orderBy(desc(favorites.createdAt))
const [subscription] = await db.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
.where(and(eq(threadSubscriptions.uploadId, id), eq(threadSubscriptions.userId, user.id))).limit(1)
return {
isSubscribed: subscription?.enabled ?? false,
favorited: !existing.length,
favoritedBy: favRows.map(f => ({ id: f.id, name: f.name, color: f.color, avatarUrl: avatarUrl(f.avatar) })),
}
})
+70
View File
@@ -0,0 +1,70 @@
import { and, eq, sql } from 'drizzle-orm'
import { z } from 'zod'
import { threadSubscriptions, uploadReactions, uploads } from '../../../db/schema'
const bodySchema = z.object({
emoji: z.string().min(1).max(32),
})
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const uploadId = getRouterParam(event, 'id') ?? ''
const { emoji } = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const [upload] = await db
.select({ id: uploads.id })
.from(uploads)
.where(eq(uploads.id, uploadId))
.limit(1)
if (!upload) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const existing = await db
.select({ userId: uploadReactions.userId })
.from(uploadReactions)
.where(and(
eq(uploadReactions.uploadId, uploadId),
eq(uploadReactions.userId, user.id),
eq(uploadReactions.emoji, emoji),
))
.limit(1)
let active: boolean
if (existing.length) {
await db.delete(uploadReactions).where(and(
eq(uploadReactions.uploadId, uploadId),
eq(uploadReactions.userId, user.id),
eq(uploadReactions.emoji, emoji),
))
active = false
}
else {
await db.transaction(async (tx) => {
await tx.insert(uploadReactions).values({ uploadId, userId: user.id, emoji })
await ensureThreadSubscription(uploadId, user.id, tx)
})
active = true
}
const [result] = await db
.select({ total: sql<number>`count(*)` })
.from(uploadReactions)
.where(and(eq(uploadReactions.uploadId, uploadId), eq(uploadReactions.emoji, emoji)))
const count = Number(result?.total ?? 0)
broadcastToAll({
type: 'upload-reaction',
uploadId,
emoji,
actorId: user.id,
actorName: user.name,
active,
count,
})
const [subscription] = await db.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
.where(and(eq(threadSubscriptions.uploadId, uploadId), eq(threadSubscriptions.userId, user.id))).limit(1)
return { active, count, isSubscribed: subscription?.enabled ?? false }
})
@@ -0,0 +1,23 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { threadSubscriptions, uploads, users } from '../../../db/schema'
const bodySchema = z.object({ enabled: z.boolean() })
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const uploadId = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,8}$/.test(uploadId)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const { enabled } = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const [upload] = await db.select({ id: uploads.id }).from(uploads).where(eq(uploads.id, uploadId)).limit(1)
if (!upload) throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
await db.transaction(async (tx) => {
await tx.select({ id: users.id }).from(users).where(eq(users.id, user.id)).for('update')
await tx.insert(threadSubscriptions).values({ uploadId, userId: user.id, enabled })
.onDuplicateKeyUpdate({ set: { enabled } })
})
return { enabled }
})
+64
View File
@@ -0,0 +1,64 @@
import { inArray, eq } from 'drizzle-orm'
import { z } from 'zod'
import { sql } from 'drizzle-orm'
import { normalizeTagList, MAX_TAGS_PER_UPLOAD } from '../../../../shared/utils/tags'
import { tags, uploads, uploadTags } from '../../../db/schema'
const bodySchema = z.object({
tags: z.array(z.string().max(200)).max(MAX_TAGS_PER_UPLOAD),
})
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const user = await requireUser(event)
const db = useDb()
const upload = await db
.select({ id: uploads.id, userId: uploads.userId })
.from(uploads)
.where(eq(uploads.id, id))
.limit(1)
.then(r => r[0])
if (!upload) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const body = await readValidatedBody(event, bodySchema.parse)
const names = normalizeTagList(body.tags)
// Tags hinzufügen darf jeder Eingeloggte — entfernen nur der Uploader
// oder Rollen mit tag.delete-Recht
const currentRows = await db
.select({ name: tags.name })
.from(uploadTags)
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
.where(eq(uploadTags.uploadId, id))
const currentNames = new Set(currentRows.map(r => r.name))
const removed = currentNames.size ? [...currentNames].filter(n => !names.includes(n)) : []
const canDeleteTags = upload.userId === user.id
|| user.roles.includes('admin')
|| user.permissions.includes('tag.delete')
if (removed.length && !canDeleteTags) {
throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung zum Entfernen von Tags' })
}
// Tags finden oder anlegen
if (names.length) {
await db
.insert(tags)
.values(names.map(name => ({ name })))
.onDuplicateKeyUpdate({ set: { name: sql`name` } })
}
const tagRows = names.length
? await db.select({ id: tags.id, name: tags.name }).from(tags).where(inArray(tags.name, names))
: []
await db.delete(uploadTags).where(eq(uploadTags.uploadId, id))
if (tagRows.length) {
await db.insert(uploadTags).values(tagRows.map(t => ({ uploadId: id, tagId: t.id })))
}
return { tags: tagRows.map(t => t.name).sort() }
})
+71
View File
@@ -0,0 +1,71 @@
import { and, desc, eq, gte, inArray } from 'drizzle-orm'
import { tags, uploads, uploadTags, users } from '../../db/schema'
import { normalizeTagName } from '../../../shared/utils/tags'
type UploadType = typeof uploads.$inferSelect['type']
// Leichtgewichtige Liste — Thumbnails und Kommentarzahlen lädt der Client
// separat über /api/uploads/meta nach.
// Pagination per Dense-Pack-Simulation: Der Client schickt, wie viele
// Grid-Zellen auf den Bildschirm passen (cells) und wie breit das Grid ist
// (cols). Tiles belegen je nach Modus/Kommentarzahl 1x1, 2x2 oder 3x3 Zellen.
// grid-flow-dense platziert jedes Tile in die erste freie Position — was
// nicht passt, wandert auf die nächste Seite. So passt jede Seite garantiert
// ohne Scrollen in den Viewport, auch bei fragmentierten Zeilenenden.
export default defineEventHandler(async (event) => {
const db = useDb()
const q = getQuery(event)
const tag = normalizeTagName(String(q.tag ?? ''))
const types = String(q.type ?? '')
.split(',')
.map(t => t.trim())
.filter(t => (uploads.type.enumValues as readonly string[]).includes(t)) as UploadType[]
const uploaderId = Number(q.uploader)
const days = Number(q.days)
const { page, cells, cols, uniform } = getGridPageParams(q)
const conditions = []
if (tag) {
conditions.push(inArray(
uploads.id,
db.select({ id: uploadTags.uploadId }).from(uploadTags)
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
.where(eq(tags.name, tag)),
))
}
if (types.length) conditions.push(inArray(uploads.type, types))
if (Number.isInteger(uploaderId) && uploaderId > 0) conditions.push(eq(uploads.userId, uploaderId))
if (Number.isFinite(days) && days > 0) {
conditions.push(gte(uploads.createdAt, new Date(Date.now() - days * 86400_000)))
}
// Alle Treffer holen (kleine Felder) und clientseitig dieselbe
// Span-Logik wie UploadTile anwenden, um Seiten exakt zu füllen
const rows = await db
.select({
id: uploads.id,
type: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
commentCount: uploads.commentCount,
createdAt: uploads.createdAt,
userId: users.id,
userName: users.name,
userColor: users.color,
userAvatar: users.avatar,
})
.from(uploads)
.innerJoin(users, eq(uploads.userId, users.id))
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(uploads.createdAt))
const pages = paginateGrid(rows, r => gridTileDims(r.commentCount, uniform, cols), cells, cols)
const pageRows = pages[page - 1] ?? []
const items = pageRows.map((row) => {
const url = row.externalUrl ?? `/uploads/${row.fileName}`
return { id: row.id, type: row.type, url, commentCount: row.commentCount, createdAt: row.createdAt, userId: row.userId, userName: row.userName, userColor: row.userColor, userAvatarUrl: avatarUrl(row.userAvatar) }
})
return { items, total: rows.length, pageCount: pages.length }
})
+158
View File
@@ -0,0 +1,158 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { z } from 'zod'
import { uploads } from '../../db/schema'
const MAX_SIZE = MAX_UPLOAD_SIZE
const MIME_EXT: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/quicktime': '.mov',
'audio/mpeg': '.mp3',
'audio/mp3': '.mp3',
'audio/flac': '.flac',
'audio/x-flac': '.flac',
}
// Fallback, wenn der Browser keinen MIME-Type mitschickt
const EXT_MIME: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
}
const urlSchema = z.object({
url: z.url().max(2048),
})
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const db = useDb()
if ((getRequestHeader(event, 'content-type') ?? '').includes('application/json')) {
const body = await readValidatedBody(event, urlSchema.parse)
if (!/^https?:\/\//i.test(body.url)) {
throw createError({ statusCode: 400, statusMessage: 'Nur http(s)-URLs erlaubt' })
}
const type = classifyUrl(body.url)
const id = await newUploadId(db)
// Direkte Bild-/Video-URLs werden heruntergeladen und lokal gespeichert,
// als wäre die Datei direkt hochgeladen worden
if (type === 'image' || type === 'video' || type === 'audio') {
const media = await downloadExternalMedia(body.url)
const urlExt = extname(new URL(body.url).pathname).toLowerCase().replace(/[^a-z0-9.]/g, '').slice(0, 10)
const ext = MIME_EXT[media.mimeType] ?? urlExt
const fileName = `${id}${ext}`
await mkdir(uploadDir(), { recursive: true })
await writeFile(join(uploadDir(), fileName), media.data)
const audioMeta = media.type === 'audio'
? await extractAudioMeta(id, media.data, media.mimeType)
: null
await db.insert(uploads).values({
id,
userId: user.id,
type: media.type,
fileName,
originalName: decodeURIComponent(basename(new URL(body.url).pathname) || body.url).slice(0, 255),
mimeType: media.mimeType.slice(0, 100),
size: media.data.length,
previewImage: audioMeta?.cover ?? null,
audioMeta: audioMeta?.meta ?? null,
})
await ensureThreadSubscription(id, user.id)
return { id, type: media.type, url: `/uploads/${fileName}` }
}
const twitchTarget = type === 'twitch' ? getTwitchTarget(body.url) : null
const [preview, twitchPreview] = await Promise.all([
type === 'link' || type === 'twitch' ? fetchLinkPreview(body.url) : null,
twitchTarget ? fetchTwitchPreview(twitchTarget) : null,
])
const remotePreviewImage = twitchPreview?.image ?? preview?.image ?? null
const previewImage = remotePreviewImage
? (await savePreviewImage(id, remotePreviewImage)) ?? remotePreviewImage
: null
await db.insert(uploads).values({
id,
userId: user.id,
type,
externalUrl: body.url,
originalName: body.url.slice(0, 255),
previewImage,
previewTitle: twitchPreview?.title ?? preview?.title ?? null,
})
await ensureThreadSubscription(id, user.id)
return { id, type, url: body.url }
}
const parts = await readMultipartFormData(event)
const file = parts?.find(p => p.name === 'file')
if (!file?.data?.length) {
throw createError({ statusCode: 400, statusMessage: 'Keine Datei hochgeladen' })
}
// Manche Formate kommen ohne MIME-Type (z. B. .flac) — dann per Endung klassifizieren
const fileExt = extname(file.filename ?? '').toLowerCase()
const rawMime = file.type ?? ''
const mimeType = rawMime && rawMime !== 'application/octet-stream' ? rawMime : (EXT_MIME[fileExt] ?? rawMime)
const type = mimeType.startsWith('image/') ? 'image'
: mimeType.startsWith('video/') ? 'video'
: mimeType.startsWith('audio/') ? 'audio'
: IMAGE_EXT.test(fileExt) ? 'image'
: VIDEO_EXT.test(fileExt) ? 'video'
: AUDIO_EXT.test(fileExt) ? 'audio'
: null
if (!type) {
throw createError({ statusCode: 415, statusMessage: 'Nur Bilder, Videos und Audiodateien sind erlaubt' })
}
if (file.data.length > MAX_SIZE) {
throw createError({ statusCode: 413, statusMessage: 'Datei ist zu groß (max. 200 MB)' })
}
const ext = MIME_EXT[mimeType]
?? fileExt.replace(/[^a-z0-9.]/g, '').slice(0, 10)
?? ''
const id = await newUploadId(db)
const fileName = `${id}${ext}`
await mkdir(uploadDir(), { recursive: true })
await writeFile(join(uploadDir(), fileName), file.data)
const audioMeta = type === 'audio'
? await extractAudioMeta(id, file.data, mimeType)
: null
await db.insert(uploads).values({
id,
userId: user.id,
type,
fileName,
originalName: (file.filename ?? fileName).slice(0, 255),
mimeType: (mimeType || 'application/octet-stream').slice(0, 100),
size: file.data.length,
previewImage: audioMeta?.cover ?? null,
audioMeta: audioMeta?.meta ?? null,
})
await ensureThreadSubscription(id, user.id)
return {
id,
type,
url: `/uploads/${fileName}`,
}
})
+33
View File
@@ -0,0 +1,33 @@
import { inArray } from 'drizzle-orm'
import { uploads } from '../../db/schema'
// Nachgeladene Thumbnails pro Upload für das Grid — bewusst ohne Joins,
// die Kommentarzahl kommt denormalisiert direkt aus der Upload-Liste
export default defineEventHandler(async (event) => {
const ids = String(getQuery(event).ids ?? '')
.split(',')
.filter(id => /^[A-Za-z0-9]{1,16}$/.test(id))
.slice(0, 200)
if (!ids.length) return []
const db = useDb()
const rows = await db
.select({
id: uploads.id,
type: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
previewImage: uploads.previewImage,
})
.from(uploads)
.where(inArray(uploads.id, ids))
return rows.map((row) => {
const url = row.externalUrl ?? `/uploads/${row.fileName}`
const embedId = row.type === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null
const thumbUrl = row.type === 'youtube' && embedId
? youtubeThumbUrl(embedId)
: row.type === 'image' ? url : row.type === 'link' || row.type === 'twitch' || row.type === 'audio' ? row.previewImage : null
return { id: row.id, thumbUrl }
})
})
+49
View File
@@ -0,0 +1,49 @@
import { count, eq } from 'drizzle-orm'
import { comments, uploads, users } from '../../db/schema'
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const db = useDb()
const [user] = await db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, createdAt: users.createdAt })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
}
const [uploadStats] = await db
.select({ count: count() })
.from(uploads)
.where(eq(uploads.userId, user.id))
const [commentStats] = await db
.select({ count: count() })
.from(comments)
.where(eq(comments.userId, user.id))
return {
user: {
id: user.id,
name: user.name,
color: user.color,
avatarUrl: avatarUrl(user.avatar),
cardEffect: user.cardEffect,
cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette,
cardBorderEffect: user.cardBorderEffect,
cardBorderEffectColor: user.cardBorderEffectColor,
createdAt: user.createdAt,
},
stats: {
uploads: uploadStats?.count ?? 0,
comments: commentStats?.count ?? 0,
},
}
})
+85
View File
@@ -0,0 +1,85 @@
import { asc, count, desc, eq, inArray } from 'drizzle-orm'
import { commentAttachments, comments, uploads, users } from '../../../db/schema'
import type { CommentAttachment } from '~~/shared/utils/attachments'
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const q = getQuery(event)
const page = Math.max(1, Math.floor(Number(q.page)) || 1)
const perPage = Math.min(100, Math.max(1, Math.floor(Number(q.perPage)) || 20))
const db = useDb()
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
}
const [totalRow, rows] = await Promise.all([
db.select({ count: count() }).from(comments).where(eq(comments.userId, userId)),
db
.select({
id: comments.id,
content: comments.content,
createdAt: comments.createdAt,
uploadId: uploads.id,
uploadType: uploads.type,
uploadFileName: uploads.fileName,
uploadExternalUrl: uploads.externalUrl,
uploadPreviewImage: uploads.previewImage,
})
.from(comments)
.innerJoin(uploads, eq(comments.uploadId, uploads.id))
.where(eq(comments.userId, userId))
.orderBy(desc(comments.createdAt))
.limit(perPage)
.offset((page - 1) * perPage),
])
const total = totalRow[0]?.count ?? 0
const commentIds = rows.map(r => r.id)
const attachmentRows = commentIds.length
? await db
.select()
.from(commentAttachments)
.where(inArray(commentAttachments.commentId, commentIds))
.orderBy(asc(commentAttachments.id))
: []
const attachmentsByComment = new Map<number, CommentAttachment[]>()
for (const a of attachmentRows) {
let list = attachmentsByComment.get(a.commentId)
if (!list) {
list = []
attachmentsByComment.set(a.commentId, list)
}
list.push({ id: a.id, name: a.originalName, url: `/uploads/attachments/${a.fileName}`, mimeType: a.mimeType, size: a.size, cover: a.cover, meta: a.meta as CommentAttachment['meta'] })
}
const items = rows.map((row) => {
const url = row.uploadExternalUrl ?? `/uploads/${row.uploadFileName}`
const embedId = row.uploadType === 'youtube' && row.uploadExternalUrl ? getYoutubeId(row.uploadExternalUrl) : null
const uploadThumbUrl = row.uploadType === 'youtube' && embedId
? youtubeThumbUrl(embedId)
: row.uploadType === 'image' ? url : row.uploadType === 'link' || row.uploadType === 'twitch' || row.uploadType === 'audio' ? row.uploadPreviewImage : null
return {
id: row.id,
content: row.content,
createdAt: row.createdAt,
uploadId: row.uploadId,
uploadThumbUrl,
attachments: attachmentsByComment.get(row.id) ?? [],
}
})
return { items, total, pageCount: Math.max(1, Math.ceil(total / perPage)) }
})
+47
View File
@@ -0,0 +1,47 @@
import { count, eq } from 'drizzle-orm'
import { comments, uploads, users } from '../../../db/schema'
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const db = useDb()
const [user] = await db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, createdAt: users.createdAt })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
}
const [uploadStats] = await db
.select({ count: count() })
.from(uploads)
.where(eq(uploads.userId, user.id))
const [commentStats] = await db
.select({ count: count() })
.from(comments)
.where(eq(comments.userId, user.id))
return {
id: user.id,
name: user.name,
color: user.color,
cardEffect: user.cardEffect,
cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette,
cardBorderEffect: user.cardBorderEffect,
cardBorderEffectColor: user.cardBorderEffectColor,
avatarUrl: avatarUrl(user.avatar),
createdAt: user.createdAt,
stats: {
uploads: uploadStats?.count ?? 0,
comments: commentStats?.count ?? 0,
},
}
})
+47
View File
@@ -0,0 +1,47 @@
import { desc, eq } from 'drizzle-orm'
import { uploads, users } from '../../../db/schema'
// Upload-Liste eines Nutzers mit Viewport-Pagination (wie /api/uploads).
// thumbUrl wird hier serverseitig mitgeliefert — kein separater Meta-Call.
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const q = getQuery(event)
const { page, cells, cols, uniform } = getGridPageParams(q)
const db = useDb()
const rows = await db
.select({
id: uploads.id,
type: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
previewImage: uploads.previewImage,
commentCount: uploads.commentCount,
createdAt: uploads.createdAt,
userName: users.name,
userColor: users.color,
userAvatar: users.avatar,
})
.from(uploads)
.innerJoin(users, eq(uploads.userId, users.id))
.where(eq(uploads.userId, userId))
.orderBy(desc(uploads.createdAt))
const pages = paginateGrid(rows, r => gridTileDims(r.commentCount, uniform, cols), cells, cols)
const items = (pages[page - 1] ?? []).map((row) => {
const url = row.externalUrl ?? `/uploads/${row.fileName}`
const embedId = row.type === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null
const thumbUrl = row.type === 'youtube' && embedId
? youtubeThumbUrl(embedId)
: row.type === 'image' ? url : row.type === 'link' || row.type === 'twitch' || row.type === 'audio' ? row.previewImage : null
return { ...row, url, thumbUrl, userAvatarUrl: avatarUrl(row.userAvatar) }
})
return { items, total: rows.length, pageCount: pages.length }
})
+44
View File
@@ -0,0 +1,44 @@
import { asc, count } from 'drizzle-orm'
import { comments, uploads, users } from '../../db/schema'
export default defineEventHandler(async () => {
const db = useDb()
const rows = await db
.select({
id: users.id,
name: users.name,
color: users.color,
avatar: users.avatar,
cardEffect: users.cardEffect,
cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette,
cardBorderEffect: users.cardBorderEffect,
cardBorderEffectColor: users.cardBorderEffectColor,
createdAt: users.createdAt,
})
.from(users)
.orderBy(asc(users.id))
const [uploadStats, commentStats] = await Promise.all([
db.select({ userId: uploads.userId, count: count() }).from(uploads).groupBy(uploads.userId),
db.select({ userId: comments.userId, count: count() }).from(comments).groupBy(comments.userId),
])
const uploadCount = new Map(uploadStats.map(s => [s.userId, s.count]))
const commentCount = new Map(commentStats.map(s => [s.userId, s.count]))
return rows.map(u => ({
id: u.id,
name: u.name,
color: u.color,
avatarUrl: avatarUrl(u.avatar),
cardEffect: u.cardEffect,
cardEffectColor: u.cardEffectColor, cardVignette: u.cardVignette,
cardBorderEffect: u.cardBorderEffect,
cardBorderEffectColor: u.cardBorderEffectColor,
createdAt: u.createdAt,
stats: {
uploads: uploadCount.get(u.id) ?? 0,
comments: commentCount.get(u.id) ?? 0,
},
}))
})
+125
View File
@@ -0,0 +1,125 @@
import { randomUUID } from 'node:crypto'
import { mkdir, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { isCardBorderEffect } from '../../../shared/utils/cardBorderEffects'
import { isCardEffect } from '../../../shared/utils/cardEffects'
import { users } from '../../db/schema'
const AVATAR_MIME_EXT: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
}
const MAX_AVATAR_SIZE = 5 * 1024 * 1024 // 5 MB
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const parts = await readMultipartFormData(event)
const colorField = parts?.find(p => p.name === 'color')?.data.toString('utf8').trim()
const cardEffectField = parts?.find(p => p.name === 'cardEffect')?.data.toString('utf8').trim()
const cardEffectColorField = parts?.find(p => p.name === 'cardEffectColor')?.data.toString('utf8').trim()
const cardVignetteField = parts?.find(p => p.name === 'cardVignette')?.data.toString('utf8').trim()
const cardBorderEffectField = parts?.find(p => p.name === 'cardBorderEffect')?.data.toString('utf8').trim()
const cardBorderEffectColorField = parts?.find(p => p.name === 'cardBorderEffectColor')?.data.toString('utf8').trim()
const avatarFile = parts?.find(p => p.name === 'avatar')
let color: string | null | undefined
if (colorField !== undefined) {
if (colorField === '') {
color = null
}
else if (/^#[0-9a-fA-F]{6}$/.test(colorField)) {
color = colorField.toLowerCase()
}
else {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Farbe' })
}
}
let cardEffect: string | null | undefined
if (cardEffectField !== undefined) {
if (cardEffectField === '') {
cardEffect = null
}
else if (isCardEffect(cardEffectField)) {
cardEffect = cardEffectField
}
else {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Effekt' })
}
}
let cardBorderEffect: string | null | undefined
if (cardBorderEffectField !== undefined) {
if (cardBorderEffectField === '') {
cardBorderEffect = null
}
else if (isCardBorderEffect(cardBorderEffectField)) {
cardBorderEffect = cardBorderEffectField
}
else {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Border-Effekt' })
}
}
const parseOptionalColor = (field: string | undefined): string | null | undefined => {
if (field === undefined) return undefined
if (field === '') return null
if (/^#[0-9a-fA-F]{6}$/.test(field)) return field.toLowerCase()
throw createError({ statusCode: 400, statusMessage: 'Ungültige Effekt-Farbe' })
}
const cardEffectColor = parseOptionalColor(cardEffectColorField)
const cardBorderEffectColor = parseOptionalColor(cardBorderEffectColorField)
const cardVignette = cardVignetteField === undefined ? undefined : cardVignetteField === '1'
let avatarName: string | undefined
if (avatarFile?.data?.length && avatarFile.type) {
const ext = AVATAR_MIME_EXT[avatarFile.type]
if (!ext) {
throw createError({ statusCode: 415, statusMessage: 'Nur Bilder sind als Avatar erlaubt' })
}
if (avatarFile.data.length > MAX_AVATAR_SIZE) {
throw createError({ statusCode: 413, statusMessage: 'Avatar ist zu groß (max. 5 MB)' })
}
avatarName = `${randomUUID()}${ext}`
await mkdir(avatarDir(), { recursive: true })
await writeFile(join(avatarDir(), avatarName), avatarFile.data)
}
const db = useDb()
await db.update(users).set({
...(color !== undefined && { color }),
...(cardEffect !== undefined && { cardEffect }),
...(cardEffectColor !== undefined && { cardEffectColor }),
...(cardVignette !== undefined && { cardVignette }),
...(cardBorderEffect !== undefined && { cardBorderEffect }),
...(cardBorderEffectColor !== undefined && { cardBorderEffectColor }),
...(avatarName && { avatar: avatarName }),
}).where(eq(users.id, user.id))
if (avatarName && user.avatar) {
await unlink(join(avatarDir(), user.avatar)).catch(() => {})
}
const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1)
return {
user: {
id: updated!.id,
name: updated!.name,
email: updated!.email,
color: updated!.color,
cardEffect: updated!.cardEffect,
cardEffectColor: updated!.cardEffectColor,
cardVignette: updated!.cardVignette,
cardBorderEffect: updated!.cardBorderEffect,
cardBorderEffectColor: updated!.cardBorderEffectColor,
avatarUrl: avatarUrl(updated!.avatar),
roles: user.roles,
},
}
})
+7
View File
@@ -0,0 +1,7 @@
import { asc } from 'drizzle-orm'
import { users } from '../../db/schema'
export default defineEventHandler(() => useDb()
.select({ id: users.id, name: users.name, color: users.color })
.from(users)
.orderBy(asc(users.name)))