forked from BRETT/BRETT
224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
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<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 }
|
|
}
|
|
}
|
|
|
|
// 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(() => {})
|
|
}
|
|
}
|