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, isVideoAttachment, 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 videoThumb = !file.voice && isVideoAttachment({ mimeType, name: filename }) ? await extractVideoThumb(`att-${commentId}-${i}`, join(attachmentsDir(), storedName)) : 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: videoThumb ?? audioMeta?.cover ?? null, meta, }) attachments.push({ id: Number(row.insertId), name: filename.slice(0, 255), url: `/uploads/attachments/${storedName}`, mimeType, size: data.length, cover: videoThumb ?? 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(), } })