forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { commentAttachments, comments, uploads } from '../../db/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'upload.delete')
|
||||
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 [upload] = await db
|
||||
.select({ fileName: uploads.fileName })
|
||||
.from(uploads)
|
||||
.where(eq(uploads.id, id))
|
||||
.limit(1)
|
||||
if (!upload) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
|
||||
}
|
||||
|
||||
const attachmentFiles = await db
|
||||
.select({ fileName: commentAttachments.fileName, cover: commentAttachments.cover })
|
||||
.from(commentAttachments)
|
||||
.innerJoin(comments, eq(commentAttachments.commentId, comments.id))
|
||||
.where(eq(comments.uploadId, id))
|
||||
|
||||
await db.delete(uploads).where(eq(uploads.id, id))
|
||||
if (upload.fileName) {
|
||||
await unlink(join(uploadDir(), upload.fileName)).catch(() => {})
|
||||
}
|
||||
for (const f of attachmentFiles) {
|
||||
await unlink(join(uploadDir(), 'attachments', f.fileName)).catch(() => {})
|
||||
if (f.cover) {
|
||||
await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {})
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { and, asc, desc, eq, gt, inArray, lt, or } from 'drizzle-orm'
|
||||
import { commentAttachments, commentReactions, comments, favorites, tags, threadSubscriptions, uploads, uploadReactions, uploadTags, users } from '../../db/schema'
|
||||
import type { CommentAttachment } from '~~/shared/utils/attachments'
|
||||
|
||||
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 db = useDb()
|
||||
|
||||
const uploadRows = await db
|
||||
.select({
|
||||
id: uploads.id,
|
||||
type: uploads.type,
|
||||
fileName: uploads.fileName,
|
||||
externalUrl: uploads.externalUrl,
|
||||
originalName: uploads.originalName,
|
||||
mimeType: uploads.mimeType,
|
||||
previewImage: uploads.previewImage,
|
||||
previewTitle: uploads.previewTitle,
|
||||
audioMeta: uploads.audioMeta,
|
||||
createdAt: uploads.createdAt,
|
||||
userName: users.name,
|
||||
userId: users.id,
|
||||
userColor: users.color,
|
||||
userAvatar: users.avatar,
|
||||
userCardEffect: users.cardEffect,
|
||||
userCardEffectColor: users.cardEffectColor,
|
||||
userCardVignette: users.cardVignette,
|
||||
userCardBorderEffect: users.cardBorderEffect,
|
||||
userCardBorderEffectColor: users.cardBorderEffectColor,
|
||||
})
|
||||
.from(uploads)
|
||||
.innerJoin(users, eq(uploads.userId, users.id))
|
||||
.where(eq(uploads.id, id))
|
||||
.limit(1)
|
||||
const upload = uploadRows[0]
|
||||
if (!upload) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
|
||||
}
|
||||
|
||||
const commentRows = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
content: comments.content,
|
||||
createdAt: comments.createdAt,
|
||||
userName: users.name,
|
||||
userId: users.id,
|
||||
userColor: users.color,
|
||||
userAvatar: users.avatar,
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(users, eq(comments.userId, users.id))
|
||||
.where(eq(comments.uploadId, id))
|
||||
.orderBy(asc(comments.createdAt))
|
||||
|
||||
// Nachbarn in Grid-Reihenfolge (createdAt desc, id desc als Tiebreak)
|
||||
const sessionUser = await getSessionUser(event)
|
||||
const commentIds = commentRows.map(c => c.id)
|
||||
const [reactionRows, uploadReactionRows, favRows, newerRows, olderRows, tagRows, attachmentRows, subscriptionRows] = await Promise.all([
|
||||
commentIds.length
|
||||
? db
|
||||
.select({
|
||||
commentId: commentReactions.commentId,
|
||||
emoji: commentReactions.emoji,
|
||||
userId: commentReactions.userId,
|
||||
userName: users.name,
|
||||
})
|
||||
.from(commentReactions)
|
||||
.innerJoin(users, eq(commentReactions.userId, users.id))
|
||||
.where(inArray(commentReactions.commentId, commentIds))
|
||||
.orderBy(asc(commentReactions.createdAt))
|
||||
: Promise.resolve([]),
|
||||
db
|
||||
.select({
|
||||
emoji: uploadReactions.emoji,
|
||||
userId: uploadReactions.userId,
|
||||
userName: users.name,
|
||||
})
|
||||
.from(uploadReactions)
|
||||
.innerJoin(users, eq(uploadReactions.userId, users.id))
|
||||
.where(eq(uploadReactions.uploadId, id))
|
||||
.orderBy(asc(uploadReactions.createdAt)),
|
||||
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)),
|
||||
db
|
||||
.select({ id: uploads.id })
|
||||
.from(uploads)
|
||||
.where(or(
|
||||
gt(uploads.createdAt, upload.createdAt),
|
||||
and(eq(uploads.createdAt, upload.createdAt), gt(uploads.id, upload.id)),
|
||||
))
|
||||
.orderBy(asc(uploads.createdAt), asc(uploads.id))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: uploads.id })
|
||||
.from(uploads)
|
||||
.where(or(
|
||||
lt(uploads.createdAt, upload.createdAt),
|
||||
and(eq(uploads.createdAt, upload.createdAt), lt(uploads.id, upload.id)),
|
||||
))
|
||||
.orderBy(desc(uploads.createdAt), desc(uploads.id))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ name: tags.name })
|
||||
.from(uploadTags)
|
||||
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
|
||||
.where(eq(uploadTags.uploadId, id))
|
||||
.orderBy(asc(tags.name)),
|
||||
commentIds.length
|
||||
? db
|
||||
.select()
|
||||
.from(commentAttachments)
|
||||
.where(inArray(commentAttachments.commentId, commentIds))
|
||||
.orderBy(asc(commentAttachments.id))
|
||||
: Promise.resolve([]),
|
||||
sessionUser
|
||||
? db.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
|
||||
.where(and(eq(threadSubscriptions.uploadId, id), eq(threadSubscriptions.userId, sessionUser.id))).limit(1)
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
const attachmentsByComment = new Map<number, CommentAttachment[]>()
|
||||
for (const a of attachmentRows) {
|
||||
let list = attachmentsByComment.get(a.commentId)
|
||||
if (!list) {
|
||||
list = []
|
||||
attachmentsByComment.set(a.commentId, list)
|
||||
}
|
||||
list.push({ id: a.id, name: a.originalName, url: `/uploads/attachments/${a.fileName}`, mimeType: a.mimeType, size: a.size, cover: a.cover, meta: a.meta as CommentAttachment['meta'] })
|
||||
}
|
||||
|
||||
const reactionsByComment = new Map<number, { emoji: string, count: number, me: boolean, names: string[] }[]>()
|
||||
for (const r of reactionRows) {
|
||||
let list = reactionsByComment.get(r.commentId)
|
||||
if (!list) {
|
||||
list = []
|
||||
reactionsByComment.set(r.commentId, list)
|
||||
}
|
||||
let entry = list.find(e => e.emoji === r.emoji)
|
||||
if (!entry) {
|
||||
entry = { emoji: r.emoji, count: 0, me: false, names: [] }
|
||||
list.push(entry)
|
||||
}
|
||||
entry.count += 1
|
||||
entry.names.push(r.userName)
|
||||
if (r.userId === sessionUser?.id) entry.me = true
|
||||
}
|
||||
|
||||
const uploadReactionList: { emoji: string, count: number, me: boolean, names: string[] }[] = []
|
||||
for (const r of uploadReactionRows) {
|
||||
let entry = uploadReactionList.find(e => e.emoji === r.emoji)
|
||||
if (!entry) {
|
||||
entry = { emoji: r.emoji, count: 0, me: false, names: [] }
|
||||
uploadReactionList.push(entry)
|
||||
}
|
||||
entry.count += 1
|
||||
entry.names.push(r.userName)
|
||||
if (r.userId === sessionUser?.id) entry.me = true
|
||||
}
|
||||
|
||||
const url = upload.externalUrl ?? `/uploads/${upload.fileName}`
|
||||
const embedId = upload.type === 'youtube' && upload.externalUrl
|
||||
? getYoutubeId(upload.externalUrl)
|
||||
: upload.type === 'twitch' && upload.externalUrl
|
||||
? serializeTwitchTarget(getTwitchTarget(upload.externalUrl))
|
||||
: null
|
||||
return {
|
||||
upload: { ...upload, url, embedId, userAvatarUrl: avatarUrl(upload.userAvatar), tags: tagRows.map(t => t.name) },
|
||||
comments: commentRows.map(c => ({ ...c, userAvatarUrl: avatarUrl(c.userAvatar), reactions: reactionsByComment.get(c.id) ?? [], attachments: attachmentsByComment.get(c.id) ?? [] })),
|
||||
uploadReactions: uploadReactionList,
|
||||
favoritedBy: favRows.map(f => ({ id: f.id, name: f.name, color: f.color, avatarUrl: avatarUrl(f.avatar) })),
|
||||
isFavorited: sessionUser ? favRows.some(f => f.id === sessionUser.id) : false,
|
||||
isSubscribed: subscriptionRows[0]?.enabled ?? false,
|
||||
newerId: newerRows[0]?.id ?? null,
|
||||
olderId: olderRows[0]?.id ?? null,
|
||||
}
|
||||
})
|
||||
@@ -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(),
|
||||
}
|
||||
})
|
||||
@@ -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) })),
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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() }
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { and, desc, eq, gte, inArray } from 'drizzle-orm'
|
||||
import { tags, uploads, uploadTags, users } from '../../db/schema'
|
||||
import { normalizeTagName } from '../../../shared/utils/tags'
|
||||
|
||||
type UploadType = typeof uploads.$inferSelect['type']
|
||||
|
||||
// Leichtgewichtige Liste — Thumbnails und Kommentarzahlen lädt der Client
|
||||
// separat über /api/uploads/meta nach.
|
||||
// Pagination per Dense-Pack-Simulation: Der Client schickt, wie viele
|
||||
// Grid-Zellen auf den Bildschirm passen (cells) und wie breit das Grid ist
|
||||
// (cols). Tiles belegen je nach Modus/Kommentarzahl 1x1, 2x2 oder 3x3 Zellen.
|
||||
// grid-flow-dense platziert jedes Tile in die erste freie Position — was
|
||||
// nicht passt, wandert auf die nächste Seite. So passt jede Seite garantiert
|
||||
// ohne Scrollen in den Viewport, auch bei fragmentierten Zeilenenden.
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = useDb()
|
||||
const q = getQuery(event)
|
||||
const tag = normalizeTagName(String(q.tag ?? ''))
|
||||
const types = String(q.type ?? '')
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => (uploads.type.enumValues as readonly string[]).includes(t)) as UploadType[]
|
||||
const uploaderId = Number(q.uploader)
|
||||
const days = Number(q.days)
|
||||
const { page, cells, cols, uniform } = getGridPageParams(q)
|
||||
|
||||
const conditions = []
|
||||
if (tag) {
|
||||
conditions.push(inArray(
|
||||
uploads.id,
|
||||
db.select({ id: uploadTags.uploadId }).from(uploadTags)
|
||||
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
|
||||
.where(eq(tags.name, tag)),
|
||||
))
|
||||
}
|
||||
if (types.length) conditions.push(inArray(uploads.type, types))
|
||||
if (Number.isInteger(uploaderId) && uploaderId > 0) conditions.push(eq(uploads.userId, uploaderId))
|
||||
if (Number.isFinite(days) && days > 0) {
|
||||
conditions.push(gte(uploads.createdAt, new Date(Date.now() - days * 86400_000)))
|
||||
}
|
||||
|
||||
// Alle Treffer holen (kleine Felder) und clientseitig dieselbe
|
||||
// Span-Logik wie UploadTile anwenden, um Seiten exakt zu füllen
|
||||
const rows = await db
|
||||
.select({
|
||||
id: uploads.id,
|
||||
type: uploads.type,
|
||||
fileName: uploads.fileName,
|
||||
externalUrl: uploads.externalUrl,
|
||||
commentCount: uploads.commentCount,
|
||||
createdAt: uploads.createdAt,
|
||||
userId: users.id,
|
||||
userName: users.name,
|
||||
userColor: users.color,
|
||||
userAvatar: users.avatar,
|
||||
})
|
||||
.from(uploads)
|
||||
.innerJoin(users, eq(uploads.userId, users.id))
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(uploads.createdAt))
|
||||
|
||||
const pages = paginateGrid(rows, r => gridTileDims(r.commentCount, uniform, cols), cells, cols)
|
||||
|
||||
const pageRows = pages[page - 1] ?? []
|
||||
const items = pageRows.map((row) => {
|
||||
const url = row.externalUrl ?? `/uploads/${row.fileName}`
|
||||
return { id: row.id, type: row.type, url, commentCount: row.commentCount, createdAt: row.createdAt, userId: row.userId, userName: row.userName, userColor: row.userColor, userAvatarUrl: avatarUrl(row.userAvatar) }
|
||||
})
|
||||
|
||||
return { items, total: rows.length, pageCount: pages.length }
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { uploads } from '../../db/schema'
|
||||
|
||||
const MAX_SIZE = MAX_UPLOAD_SIZE
|
||||
|
||||
const MIME_EXT: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/avif': '.avif',
|
||||
'video/mp4': '.mp4',
|
||||
'video/webm': '.webm',
|
||||
'video/quicktime': '.mov',
|
||||
'audio/mpeg': '.mp3',
|
||||
'audio/mp3': '.mp3',
|
||||
'audio/flac': '.flac',
|
||||
'audio/x-flac': '.flac',
|
||||
}
|
||||
|
||||
// Fallback, wenn der Browser keinen MIME-Type mitschickt
|
||||
const EXT_MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.flac': 'audio/flac',
|
||||
}
|
||||
|
||||
const urlSchema = z.object({
|
||||
url: z.url().max(2048),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event)
|
||||
const db = useDb()
|
||||
|
||||
if ((getRequestHeader(event, 'content-type') ?? '').includes('application/json')) {
|
||||
const body = await readValidatedBody(event, urlSchema.parse)
|
||||
if (!/^https?:\/\//i.test(body.url)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Nur http(s)-URLs erlaubt' })
|
||||
}
|
||||
const type = classifyUrl(body.url)
|
||||
const id = await newUploadId(db)
|
||||
|
||||
// Direkte Bild-/Video-URLs werden heruntergeladen und lokal gespeichert,
|
||||
// als wäre die Datei direkt hochgeladen worden
|
||||
if (type === 'image' || type === 'video' || type === 'audio') {
|
||||
const media = await downloadExternalMedia(body.url)
|
||||
const urlExt = extname(new URL(body.url).pathname).toLowerCase().replace(/[^a-z0-9.]/g, '').slice(0, 10)
|
||||
const ext = MIME_EXT[media.mimeType] ?? urlExt
|
||||
const fileName = `${id}${ext}`
|
||||
await mkdir(uploadDir(), { recursive: true })
|
||||
await writeFile(join(uploadDir(), fileName), media.data)
|
||||
const audioMeta = media.type === 'audio'
|
||||
? await extractAudioMeta(id, media.data, media.mimeType)
|
||||
: null
|
||||
await db.insert(uploads).values({
|
||||
id,
|
||||
userId: user.id,
|
||||
type: media.type,
|
||||
fileName,
|
||||
originalName: decodeURIComponent(basename(new URL(body.url).pathname) || body.url).slice(0, 255),
|
||||
mimeType: media.mimeType.slice(0, 100),
|
||||
size: media.data.length,
|
||||
previewImage: audioMeta?.cover ?? null,
|
||||
audioMeta: audioMeta?.meta ?? null,
|
||||
})
|
||||
await ensureThreadSubscription(id, user.id)
|
||||
return { id, type: media.type, url: `/uploads/${fileName}` }
|
||||
}
|
||||
|
||||
const twitchTarget = type === 'twitch' ? getTwitchTarget(body.url) : null
|
||||
const [preview, twitchPreview] = await Promise.all([
|
||||
type === 'link' || type === 'twitch' ? fetchLinkPreview(body.url) : null,
|
||||
twitchTarget ? fetchTwitchPreview(twitchTarget) : null,
|
||||
])
|
||||
const remotePreviewImage = twitchPreview?.image ?? preview?.image ?? null
|
||||
const previewImage = remotePreviewImage
|
||||
? (await savePreviewImage(id, remotePreviewImage)) ?? remotePreviewImage
|
||||
: null
|
||||
await db.insert(uploads).values({
|
||||
id,
|
||||
userId: user.id,
|
||||
type,
|
||||
externalUrl: body.url,
|
||||
originalName: body.url.slice(0, 255),
|
||||
previewImage,
|
||||
previewTitle: twitchPreview?.title ?? preview?.title ?? null,
|
||||
})
|
||||
await ensureThreadSubscription(id, user.id)
|
||||
return { id, type, url: body.url }
|
||||
}
|
||||
|
||||
const parts = await readMultipartFormData(event)
|
||||
const file = parts?.find(p => p.name === 'file')
|
||||
|
||||
if (!file?.data?.length) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Keine Datei hochgeladen' })
|
||||
}
|
||||
|
||||
// Manche Formate kommen ohne MIME-Type (z. B. .flac) — dann per Endung klassifizieren
|
||||
const fileExt = extname(file.filename ?? '').toLowerCase()
|
||||
const rawMime = file.type ?? ''
|
||||
const mimeType = rawMime && rawMime !== 'application/octet-stream' ? rawMime : (EXT_MIME[fileExt] ?? rawMime)
|
||||
const type = mimeType.startsWith('image/') ? 'image'
|
||||
: mimeType.startsWith('video/') ? 'video'
|
||||
: mimeType.startsWith('audio/') ? 'audio'
|
||||
: IMAGE_EXT.test(fileExt) ? 'image'
|
||||
: VIDEO_EXT.test(fileExt) ? 'video'
|
||||
: AUDIO_EXT.test(fileExt) ? 'audio'
|
||||
: null
|
||||
if (!type) {
|
||||
throw createError({ statusCode: 415, statusMessage: 'Nur Bilder, Videos und Audiodateien sind erlaubt' })
|
||||
}
|
||||
if (file.data.length > MAX_SIZE) {
|
||||
throw createError({ statusCode: 413, statusMessage: 'Datei ist zu groß (max. 200 MB)' })
|
||||
}
|
||||
|
||||
const ext = MIME_EXT[mimeType]
|
||||
?? fileExt.replace(/[^a-z0-9.]/g, '').slice(0, 10)
|
||||
?? ''
|
||||
const id = await newUploadId(db)
|
||||
const fileName = `${id}${ext}`
|
||||
|
||||
await mkdir(uploadDir(), { recursive: true })
|
||||
await writeFile(join(uploadDir(), fileName), file.data)
|
||||
const audioMeta = type === 'audio'
|
||||
? await extractAudioMeta(id, file.data, mimeType)
|
||||
: null
|
||||
await db.insert(uploads).values({
|
||||
id,
|
||||
userId: user.id,
|
||||
type,
|
||||
fileName,
|
||||
originalName: (file.filename ?? fileName).slice(0, 255),
|
||||
mimeType: (mimeType || 'application/octet-stream').slice(0, 100),
|
||||
size: file.data.length,
|
||||
previewImage: audioMeta?.cover ?? null,
|
||||
audioMeta: audioMeta?.meta ?? null,
|
||||
})
|
||||
|
||||
await ensureThreadSubscription(id, user.id)
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
url: `/uploads/${fileName}`,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { uploads } from '../../db/schema'
|
||||
|
||||
// Nachgeladene Thumbnails pro Upload für das Grid — bewusst ohne Joins,
|
||||
// die Kommentarzahl kommt denormalisiert direkt aus der Upload-Liste
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ids = String(getQuery(event).ids ?? '')
|
||||
.split(',')
|
||||
.filter(id => /^[A-Za-z0-9]{1,16}$/.test(id))
|
||||
.slice(0, 200)
|
||||
if (!ids.length) return []
|
||||
|
||||
const db = useDb()
|
||||
const rows = await db
|
||||
.select({
|
||||
id: uploads.id,
|
||||
type: uploads.type,
|
||||
fileName: uploads.fileName,
|
||||
externalUrl: uploads.externalUrl,
|
||||
previewImage: uploads.previewImage,
|
||||
})
|
||||
.from(uploads)
|
||||
.where(inArray(uploads.id, ids))
|
||||
|
||||
return rows.map((row) => {
|
||||
const url = row.externalUrl ?? `/uploads/${row.fileName}`
|
||||
const embedId = row.type === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null
|
||||
const thumbUrl = row.type === 'youtube' && embedId
|
||||
? youtubeThumbUrl(embedId)
|
||||
: row.type === 'image' ? url : row.type === 'link' || row.type === 'twitch' || row.type === 'audio' ? row.previewImage : null
|
||||
return { id: row.id, thumbUrl }
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user