forked from BRETT/BRETT
- 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>
134 lines
4.9 KiB
TypeScript
134 lines
4.9 KiB
TypeScript
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { extname, join } from 'node:path'
|
|
import { eq, sql } from 'drizzle-orm'
|
|
import { z } from 'zod'
|
|
import { commentAttachments, comments, uploads } from '../../../db/schema'
|
|
import { isAudioAttachment, isVideoAttachment, MAX_COMMENT_ATTACHMENTS } from '~~/shared/utils/attachments'
|
|
|
|
const bodySchema = z.object({
|
|
content: z.string().trim().min(1).max(2000),
|
|
})
|
|
|
|
const attachmentsDir = () => join(uploadDir(), 'attachments')
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event)
|
|
const id = getRouterParam(event, 'id') ?? ''
|
|
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
|
}
|
|
const db = useDb()
|
|
|
|
let content: string
|
|
let files: { filename: string, type: string | undefined, data: Buffer, voice: boolean }[] = []
|
|
|
|
if ((getRequestHeader(event, 'content-type') ?? '').includes('multipart/form-data')) {
|
|
const parts = await readMultipartFormData(event)
|
|
content = (parts?.find(p => p.name === 'content' && !p.filename)?.data.toString('utf8') ?? '').trim()
|
|
files = (parts ?? [])
|
|
.filter(p => (p.name === 'files' || p.name === 'voice') && p.filename && p.data?.length)
|
|
.map(p => ({ filename: p.filename!, type: p.type, data: p.data, voice: p.name === 'voice' }))
|
|
if (files.length > MAX_COMMENT_ATTACHMENTS) {
|
|
throw createError({ statusCode: 400, statusMessage: `Maximal ${MAX_COMMENT_ATTACHMENTS} Anhänge pro Kommentar` })
|
|
}
|
|
if (files.some(f => f.data.length > MAX_UPLOAD_SIZE)) {
|
|
throw createError({ statusCode: 413, statusMessage: 'Anhang ist zu groß (max. 200 MB)' })
|
|
}
|
|
if (!content && !files.length) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Kommentar darf nicht leer sein' })
|
|
}
|
|
if (content.length > 2000) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Kommentar ist zu lang (max. 2000 Zeichen)' })
|
|
}
|
|
}
|
|
else {
|
|
content = (await readValidatedBody(event, bodySchema.parse)).content
|
|
}
|
|
|
|
const exists = await db
|
|
.select({ id: uploads.id })
|
|
.from(uploads)
|
|
.where(eq(uploads.id, id))
|
|
.limit(1)
|
|
if (!exists.length) {
|
|
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
|
|
}
|
|
|
|
const commentId = await db.transaction(async (tx) => {
|
|
const [result] = await tx.insert(comments).values({ uploadId: id, userId: user.id, content })
|
|
await ensureThreadSubscription(id, user.id, tx)
|
|
return Number(result.insertId)
|
|
})
|
|
await db.update(uploads).set({ commentCount: sql`comment_count + 1` }).where(eq(uploads.id, id))
|
|
|
|
const attachments: { id: number, name: string, url: string, mimeType: string | null, size: number, cover: string | null, meta: unknown }[] = []
|
|
if (files.length) {
|
|
await mkdir(attachmentsDir(), { recursive: true })
|
|
for (const [i, file] of files.entries()) {
|
|
let data = file.data
|
|
let filename = file.filename
|
|
let mimeType = (file.type || 'application/octet-stream').slice(0, 100)
|
|
if (file.voice) {
|
|
const mp3 = await transcodeVoiceToMp3(data)
|
|
if (mp3) {
|
|
data = mp3
|
|
mimeType = 'audio/mpeg'
|
|
filename = filename.replace(/\.[^./]+$/, '') + '.mp3'
|
|
}
|
|
}
|
|
const ext = extname(filename).toLowerCase().replace(/[^a-z0-9.]/g, '').slice(0, 10)
|
|
const storedName = `${commentId}-${i}${ext}`
|
|
await writeFile(join(attachmentsDir(), storedName), data)
|
|
const audioMeta = !file.voice && isAudioAttachment({ mimeType, name: filename })
|
|
? await extractAudioMeta(`att-${commentId}-${i}`, data, mimeType)
|
|
: null
|
|
const videoThumb = !file.voice && isVideoAttachment({ mimeType, name: filename })
|
|
? await extractVideoThumb(`att-${commentId}-${i}`, join(attachmentsDir(), storedName))
|
|
: null
|
|
const meta = file.voice ? { voice: true } : (audioMeta?.meta ?? null)
|
|
const [row] = await db.insert(commentAttachments).values({
|
|
commentId,
|
|
fileName: storedName,
|
|
originalName: filename.slice(0, 255),
|
|
mimeType,
|
|
size: data.length,
|
|
cover: videoThumb ?? audioMeta?.cover ?? null,
|
|
meta,
|
|
})
|
|
attachments.push({
|
|
id: Number(row.insertId),
|
|
name: filename.slice(0, 255),
|
|
url: `/uploads/attachments/${storedName}`,
|
|
mimeType,
|
|
size: data.length,
|
|
cover: videoThumb ?? audioMeta?.cover ?? null,
|
|
meta,
|
|
})
|
|
}
|
|
}
|
|
|
|
const createdAt = new Date()
|
|
broadcastToAll({
|
|
type: 'comment',
|
|
comment: {
|
|
id: commentId,
|
|
content,
|
|
createdAt: createdAt.toISOString(),
|
|
userName: user.name,
|
|
userId: user.id,
|
|
userColor: user.color,
|
|
userAvatarUrl: avatarUrl(user.avatar),
|
|
uploadId: id,
|
|
attachments,
|
|
},
|
|
})
|
|
await notifyComment(user.id, commentId, id, content || '[Anhang]')
|
|
return {
|
|
id: commentId,
|
|
content,
|
|
attachments,
|
|
userName: user.name,
|
|
createdAt: new Date(),
|
|
}
|
|
})
|