Files
BRETT/server/utils/uploads.ts
T
FliesentischKingandDevin cd04135463 Locally stored thumbnails for video and YouTube uploads
- Server-side video thumbnails: ffmpeg-static grabs a frame at ~1s,
  scales it to 480px and stores it under /uploads/thumbs/ at upload
  time (direct files, remote URLs and comment attachments)
- YouTube thumbnails are downloaded once at upload time instead of
  being hotlinked from i.ytimg.com on every render; remote URL kept
  as fallback for legacy rows
- thumbUrl now covers video/audio/youtube/twitch in grid meta,
  profile uploads and notifications
- Generated thumbnail files are removed on single/bulk delete

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 03:06:01 +02:00

254 lines
9.0 KiB
TypeScript

import { spawn } from 'node:child_process'
import { randomInt } from 'node:crypto'
import { access, 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<typeof useDb>) {
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<DownloadedMedia> {
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<string, string> = {
'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<string | null> {
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<string, string> = {
'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 }
}
}
// Video-Vorschaubild: Frame bei ~1s per ffmpeg als JPG in thumbs/ ablegen.
// Bei sehr kurzen Videos liefert -ss 1 kein Frame — dann ohne Seek nochmal.
export async function extractVideoThumb(key: string, videoPath: string): Promise<string | null> {
if (!ffmpegPath || !/^[A-Za-z0-9/_-]+$/.test(key)) return null
const outPath = join(uploadDir(), 'thumbs', `${key}.jpg`)
const run = (seek: boolean) => new Promise<void>((resolve, reject) => {
const proc = spawn(ffmpegPath!, [
'-hide_banner', '-loglevel', 'error', '-y',
...(seek ? ['-ss', '1'] : []),
'-i', videoPath,
'-frames:v', '1',
'-vf', 'scale=480:-2',
'-q:v', '5',
outPath,
])
proc.on('error', reject)
proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`))))
})
const exists = () => access(outPath).then(() => true, () => false)
try {
await mkdir(dirname(outPath), { recursive: true })
await run(true)
if (!(await exists())) await run(false)
return (await exists()) ? `/uploads/thumbs/${key}.jpg` : null
}
catch {
return 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<Buffer | null> {
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<void>((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(() => {})
}
}