Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
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, 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 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: audioMeta?.cover ?? null,
meta,
})
attachments.push({
id: Number(row.insertId),
name: filename.slice(0, 255),
url: `/uploads/attachments/${storedName}`,
mimeType,
size: data.length,
cover: 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(),
}
})
+51
View File
@@ -0,0 +1,51 @@
import { and, desc, eq } from 'drizzle-orm'
import { favorites, threadSubscriptions, uploads, users } from '../../../db/schema'
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()
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 existing = await db
.select()
.from(favorites)
.where(and(eq(favorites.uploadId, id), eq(favorites.userId, user.id)))
.limit(1)
if (existing.length) {
await db.delete(favorites).where(and(eq(favorites.uploadId, id), eq(favorites.userId, user.id)))
}
else {
await db.transaction(async (tx) => {
await tx.insert(favorites).values({ uploadId: id, userId: user.id })
await ensureThreadSubscription(id, user.id, tx)
})
}
const favRows = await db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar })
.from(favorites)
.innerJoin(users, eq(favorites.userId, users.id))
.where(eq(favorites.uploadId, id))
.orderBy(desc(favorites.createdAt))
const [subscription] = await db.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
.where(and(eq(threadSubscriptions.uploadId, id), eq(threadSubscriptions.userId, user.id))).limit(1)
return {
isSubscribed: subscription?.enabled ?? false,
favorited: !existing.length,
favoritedBy: favRows.map(f => ({ id: f.id, name: f.name, color: f.color, avatarUrl: avatarUrl(f.avatar) })),
}
})
+70
View File
@@ -0,0 +1,70 @@
import { and, eq, sql } from 'drizzle-orm'
import { z } from 'zod'
import { threadSubscriptions, uploadReactions, uploads } from '../../../db/schema'
const bodySchema = z.object({
emoji: z.string().min(1).max(32),
})
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const uploadId = getRouterParam(event, 'id') ?? ''
const { emoji } = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const [upload] = await db
.select({ id: uploads.id })
.from(uploads)
.where(eq(uploads.id, uploadId))
.limit(1)
if (!upload) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const existing = await db
.select({ userId: uploadReactions.userId })
.from(uploadReactions)
.where(and(
eq(uploadReactions.uploadId, uploadId),
eq(uploadReactions.userId, user.id),
eq(uploadReactions.emoji, emoji),
))
.limit(1)
let active: boolean
if (existing.length) {
await db.delete(uploadReactions).where(and(
eq(uploadReactions.uploadId, uploadId),
eq(uploadReactions.userId, user.id),
eq(uploadReactions.emoji, emoji),
))
active = false
}
else {
await db.transaction(async (tx) => {
await tx.insert(uploadReactions).values({ uploadId, userId: user.id, emoji })
await ensureThreadSubscription(uploadId, user.id, tx)
})
active = true
}
const [result] = await db
.select({ total: sql<number>`count(*)` })
.from(uploadReactions)
.where(and(eq(uploadReactions.uploadId, uploadId), eq(uploadReactions.emoji, emoji)))
const count = Number(result?.total ?? 0)
broadcastToAll({
type: 'upload-reaction',
uploadId,
emoji,
actorId: user.id,
actorName: user.name,
active,
count,
})
const [subscription] = await db.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
.where(and(eq(threadSubscriptions.uploadId, uploadId), eq(threadSubscriptions.userId, user.id))).limit(1)
return { active, count, isSubscribed: subscription?.enabled ?? false }
})
@@ -0,0 +1,23 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { threadSubscriptions, uploads, users } from '../../../db/schema'
const bodySchema = z.object({ enabled: z.boolean() })
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const uploadId = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,8}$/.test(uploadId)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const { enabled } = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const [upload] = await db.select({ id: uploads.id }).from(uploads).where(eq(uploads.id, uploadId)).limit(1)
if (!upload) throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
await db.transaction(async (tx) => {
await tx.select({ id: users.id }).from(users).where(eq(users.id, user.id)).for('update')
await tx.insert(threadSubscriptions).values({ uploadId, userId: user.id, enabled })
.onDuplicateKeyUpdate({ set: { enabled } })
})
return { enabled }
})
+64
View File
@@ -0,0 +1,64 @@
import { inArray, eq } from 'drizzle-orm'
import { z } from 'zod'
import { sql } from 'drizzle-orm'
import { normalizeTagList, MAX_TAGS_PER_UPLOAD } from '../../../../shared/utils/tags'
import { tags, uploads, uploadTags } from '../../../db/schema'
const bodySchema = z.object({
tags: z.array(z.string().max(200)).max(MAX_TAGS_PER_UPLOAD),
})
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const user = await requireUser(event)
const db = useDb()
const upload = await db
.select({ id: uploads.id, userId: uploads.userId })
.from(uploads)
.where(eq(uploads.id, id))
.limit(1)
.then(r => r[0])
if (!upload) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const body = await readValidatedBody(event, bodySchema.parse)
const names = normalizeTagList(body.tags)
// Tags hinzufügen darf jeder Eingeloggte — entfernen nur der Uploader
// oder Rollen mit tag.delete-Recht
const currentRows = await db
.select({ name: tags.name })
.from(uploadTags)
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
.where(eq(uploadTags.uploadId, id))
const currentNames = new Set(currentRows.map(r => r.name))
const removed = currentNames.size ? [...currentNames].filter(n => !names.includes(n)) : []
const canDeleteTags = upload.userId === user.id
|| user.roles.includes('admin')
|| user.permissions.includes('tag.delete')
if (removed.length && !canDeleteTags) {
throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung zum Entfernen von Tags' })
}
// Tags finden oder anlegen
if (names.length) {
await db
.insert(tags)
.values(names.map(name => ({ name })))
.onDuplicateKeyUpdate({ set: { name: sql`name` } })
}
const tagRows = names.length
? await db.select({ id: tags.id, name: tags.name }).from(tags).where(inArray(tags.name, names))
: []
await db.delete(uploadTags).where(eq(uploadTags.uploadId, id))
if (tagRows.length) {
await db.insert(uploadTags).values(tagRows.map(t => ({ uploadId: id, tagId: t.id })))
}
return { tags: tagRows.map(t => t.name).sort() }
})