import { spawn } from 'node:child_process' import { randomInt } from 'node:crypto' import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { eq } from 'drizzle-orm' import ffmpegPath from 'ffmpeg-static' import { uploads } from '../db/schema' export const uploadDir = () => join(process.cwd(), 'public', 'uploads') export const avatarDir = () => join(uploadDir(), 'avatars') const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' export function generateUploadId() { return Array.from({ length: 8 }, () => ID_ALPHABET[randomInt(ID_ALPHABET.length)]).join('') } export async function newUploadId(db: ReturnType) { for (let i = 0; i < 10; i++) { const id = generateUploadId() const rows = await db.select({ id: uploads.id }).from(uploads).where(eq(uploads.id, id)).limit(1) if (!rows.length) return id } throw createError({ statusCode: 500, statusMessage: 'ID konnte nicht generiert werden' }) } export const IMAGE_EXT = /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i export const VIDEO_EXT = /\.(m4v|mov|mp4|ogg|ogv|webm)$/i export const AUDIO_EXT = /\.(flac|mp3)$/i export type UploadType = 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link' export function classifyUrl(url: string): UploadType { if (getYoutubeId(url)) return 'youtube' if (getTwitchTarget(url)) return 'twitch' const path = new URL(url).pathname if (IMAGE_EXT.test(path)) return 'image' if (VIDEO_EXT.test(path)) return 'video' if (AUDIO_EXT.test(path)) return 'audio' return 'link' } export const MAX_UPLOAD_SIZE = 200 * 1024 * 1024 // 200 MB export interface DownloadedMedia { data: Buffer mimeType: string type: 'image' | 'video' | 'audio' } // Direkte Medien-URLs werden lokal gespeichert wie ein echter Upload — // mit SSRF-Schutz (interne Hosts blockiert, auch nach Redirects), Timeout // und Größenlimit, das beim Streamen erzwungen wird export async function downloadExternalMedia(url: string): Promise { if (isBlockedHost(new URL(url).hostname)) { throw createError({ statusCode: 400, statusMessage: 'Diese URL ist nicht erlaubt' }) } let res: Response try { res = await fetch(url, { signal: AbortSignal.timeout(60_000), redirect: 'follow', headers: { 'user-agent': 'Mozilla/5.0 (compatible; 4Brett-MediaDownload/1.0)', 'accept': 'image/*,video/*,audio/*', }, }) } catch { throw createError({ statusCode: 502, statusMessage: 'Datei konnte nicht geladen werden' }) } if (isBlockedHost(new URL(res.url).hostname)) { throw createError({ statusCode: 400, statusMessage: 'Diese URL ist nicht erlaubt' }) } if (!res.ok || !res.body) { throw createError({ statusCode: 502, statusMessage: `Download fehlgeschlagen (${res.status})` }) } const mimeType = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase() const type = mimeType.startsWith('image/') ? 'image' as const : mimeType.startsWith('video/') ? 'video' as const : mimeType.startsWith('audio/') ? 'audio' as const : null if (!type) { throw createError({ statusCode: 415, statusMessage: 'URL liefert keine Bild-, Video- oder Audiodatei' }) } const contentLength = Number(res.headers.get('content-length') ?? 0) if (contentLength > MAX_UPLOAD_SIZE) { throw createError({ statusCode: 413, statusMessage: 'Datei ist zu groß (max. 200 MB)' }) } const chunks: Buffer[] = [] let total = 0 const reader = res.body.getReader() for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength if (total > MAX_UPLOAD_SIZE) { await reader.cancel() throw createError({ statusCode: 413, statusMessage: 'Datei ist zu groß (max. 200 MB)' }) } chunks.push(Buffer.from(value)) } return { data: Buffer.concat(chunks), mimeType, type } } const PREVIEW_MIME_EXT: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/avif': '.avif', } // Link-/Twitch-Vorschaubilder werden zur Upload-Zeit lokal gespeichert — // entfernte URLs sind veränderlich (z. B. Twitch-Live-Previews zeigen sonst // nach Stream-Ende nur noch einen Platzhalter) export async function savePreviewImage(key: string, url: string): Promise { try { const media = await downloadExternalMedia(url) const ext = media.type === 'image' ? PREVIEW_MIME_EXT[media.mimeType] : null if (!ext || !/^[A-Za-z0-9/_-]+$/.test(key)) return null const filePath = join(uploadDir(), 'thumbs', `${key}${ext}`) await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, media.data) return `/uploads/thumbs/${key}${ext}` } catch { return null } } const COVER_MIME_EXT: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', } export interface AudioMetaInfo { title: string | null artist: string | null album: string | null codec: string | null bitrate: number | null sampleRate: number | null bitsPerSample: number | null lossless: boolean } // Eingebettete Audio-Metadaten auslesen: Cover (ID3v2 APIC / FLAC PICTURE) // wird lokal als Vorschaubild gespeichert, Text-/Qualitätsmetadaten gehen // als JSON in die DB export async function extractAudioMeta(uploadId: string, data: Buffer, mimeType: string): Promise<{ cover: string | null, meta: AudioMetaInfo | null }> { try { const { parseBuffer } = await import('music-metadata') // MIME-Hint kann falsch sein (per Endung abgeleitet) — im Fehlerfall // ohne Hint parsen lassen, dann sniffed music-metadata die Magic Bytes const parsed = await parseBuffer(data, { mimeType }, { duration: false }) .catch(() => parseBuffer(data, undefined, { duration: false })) const pic = parsed.common.picture?.[0] const ext = pic ? COVER_MIME_EXT[pic.format] ?? null : null let cover: string | null = null if (pic && ext) { const filePath = join(uploadDir(), 'thumbs', 'covers', `${uploadId}${ext}`) await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, pic.data) cover = `/uploads/thumbs/covers/${uploadId}${ext}` } const fmt = parsed.format const meta: AudioMetaInfo = { title: parsed.common.title ?? null, artist: parsed.common.artist ?? parsed.common.artists?.[0] ?? null, album: parsed.common.album ?? null, codec: fmt.codec ?? fmt.container ?? null, bitrate: fmt.bitrate ? Math.round(fmt.bitrate) : null, sampleRate: fmt.sampleRate ?? null, bitsPerSample: fmt.bitsPerSample ?? null, lossless: fmt.lossless ?? false, } const hasAny = meta.title || meta.artist || meta.album || meta.codec || meta.bitrate return { cover, meta: hasAny ? meta : null } } catch { return { cover: null, meta: null } } } // Sprachnachrichten kommen als WebM/Opus o.ä. rein — MediaRecorder kann selbst // kein MP3 encodieren, daher serverseitig per ffmpeg nach MP3 transkodieren. export async function transcodeVoiceToMp3(data: Buffer): Promise { if (!ffmpegPath) return null const base = join(tmpdir(), `voice-${Date.now()}-${Math.random().toString(36).slice(2)}`) const inPath = `${base}.webm` const outPath = `${base}.mp3` try { await writeFile(inPath, data) await new Promise((resolve, reject) => { const proc = spawn(ffmpegPath, [ '-hide_banner', '-loglevel', 'error', '-y', '-i', inPath, '-codec:a', 'libmp3lame', '-q:a', '4', outPath, ]) proc.on('error', reject) proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)))) }) return await readFile(outPath) } catch { return null } finally { await unlink(inPath).catch(() => {}) await unlink(outPath).catch(() => {}) } }