Files
BRETT/server/api/uploads/index.post.ts
T
2026-10-03 20:07:49 +02:00

159 lines
5.4 KiB
TypeScript

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}`,
}
})