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 = { '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 = { '.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 const videoThumb = media.type === 'video' ? await extractVideoThumb(id, join(uploadDir(), fileName)) : 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: videoThumb ?? 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 embedId = type === 'youtube' ? getYoutubeId(body.url) : null const remotePreviewImage = twitchPreview?.image ?? preview?.image ?? (embedId ? youtubeThumbUrl(embedId) : 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 const videoThumb = type === 'video' ? await extractVideoThumb(id, join(uploadDir(), fileName)) : 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: videoThumb ?? audioMeta?.cover ?? null, audioMeta: audioMeta?.meta ?? null, }) await ensureThreadSubscription(id, user.id) return { id, type, url: `/uploads/${fileName}`, } })