forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { H3Event } from 'h3'
|
||||
import { sessions, users } from '../db/schema'
|
||||
|
||||
const SESSION_COOKIE = 'session'
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 Tage
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const hash = scryptSync(password, salt, 64).toString('hex')
|
||||
return `${salt}:${hash}`
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string) {
|
||||
const [salt, hash] = stored.split(':')
|
||||
if (!salt || !hash) return false
|
||||
const candidate = scryptSync(password, salt, 64)
|
||||
const expected = Buffer.from(hash, 'hex')
|
||||
return candidate.length === expected.length && timingSafeEqual(candidate, expected)
|
||||
}
|
||||
|
||||
export async function createSession(event: H3Event, userId: number) {
|
||||
const token = randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
|
||||
await useDb().insert(sessions).values({ id: token, userId, expiresAt })
|
||||
setCookie(event, SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSessionUser(event: H3Event) {
|
||||
const token = getCookie(event, SESSION_COOKIE)
|
||||
if (!token) return null
|
||||
const db = useDb()
|
||||
const rows = await db
|
||||
.select({ user: users, session: sessions })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.where(eq(sessions.id, token))
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
if (row.session.expiresAt < new Date()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, token))
|
||||
deleteCookie(event, SESSION_COOKIE, { path: '/' })
|
||||
return null
|
||||
}
|
||||
return { ...row.user, roles: await getUserRoles(row.user.id), permissions: await getUserPermissions(row.user.id) }
|
||||
}
|
||||
|
||||
export async function requireUser(event: H3Event) {
|
||||
const user = await getSessionUser(event)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Nicht angemeldet' })
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function requirePermission(event: H3Event, permission: string) {
|
||||
const user = await requireUser(event)
|
||||
if (!user.roles.includes('admin') && !user.permissions.includes(permission)) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Kein Zugriff' })
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function destroySession(event: H3Event) {
|
||||
const token = getCookie(event, SESSION_COOKIE)
|
||||
if (token) {
|
||||
await useDb().delete(sessions).where(eq(sessions.id, token))
|
||||
}
|
||||
deleteCookie(event, SESSION_COOKIE, { path: '/' })
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2'
|
||||
import { drizzle } from 'drizzle-orm/mysql2'
|
||||
import mysql from 'mysql2/promise'
|
||||
import * as schema from '../db/schema'
|
||||
|
||||
let db: MySql2Database<typeof schema> | undefined
|
||||
|
||||
export function useDb() {
|
||||
if (!db) {
|
||||
const { databaseUrl } = useRuntimeConfig()
|
||||
if (!databaseUrl) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'DATABASE_URL is not configured' })
|
||||
}
|
||||
db = drizzle(mysql.createPool(databaseUrl), { schema, mode: 'default' })
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Gemeinsame Viewport-Pagination für Upload-Grids (Mainseite, Profilseite).
|
||||
// Der Client schickt cells (Zeilen x Spalten, die ohne Scrollen passen), cols
|
||||
// und den Modus — der Server simuliert grid-auto-flow: dense exakt nach.
|
||||
|
||||
export function getGridPageParams(q: Record<string, unknown>) {
|
||||
const page = Math.max(1, Math.floor(Number(q.page)) || 1)
|
||||
const cells = Math.min(400, Math.max(1, Math.floor(Number(q.cells)) || 48))
|
||||
const cols = Math.min(12, Math.max(1, Math.floor(Number(q.cols)) || 12))
|
||||
const uniform = q.mode === 'uniform'
|
||||
return { page, cells, cols, uniform }
|
||||
}
|
||||
|
||||
// Gleiche Maße wie UploadTile: uniform = 1x1, >=7 Kommentare = 3x3
|
||||
// (ab sm/cols>=6) bzw. 2x2, >=1 Kommentar = 2x2, sonst 1x1
|
||||
export function gridTileDims(commentCount: number, uniform: boolean, cols: number): [h: number, w: number] {
|
||||
return uniform ? [1, 1] : commentCount >= 7 ? (cols >= 6 ? [3, 3] : [2, 2]) : commentCount >= 1 ? [2, 2] : [1, 1]
|
||||
}
|
||||
|
||||
// Simuliert grid-auto-flow: dense — jedes Tile geht in die erste freie
|
||||
// Rechteck-Position (zeilenweise von oben links). Passt es nirgends in die
|
||||
// maxRows-Zeilen, wird es auf die nächste Seite verschoben (Reihenfolge der
|
||||
// übrigen Items bleibt erhalten). Kleinere Tiles danach füllen noch Lücken.
|
||||
export function paginateGrid<T>(items: T[], dims: (item: T) => [h: number, w: number], cells: number, cols: number): T[][] {
|
||||
const maxRows = Math.max(1, Math.floor(cells / cols))
|
||||
|
||||
const pack = (input: T[]) => {
|
||||
const occupied: boolean[][] = []
|
||||
const placed: T[] = []
|
||||
const deferred: T[] = []
|
||||
const fits = (r: number, c: number, h: number, w: number) => {
|
||||
if (r + h > maxRows || c + w > cols) return false
|
||||
for (let dr = 0; dr < h; dr++)
|
||||
for (let dc = 0; dc < w; dc++)
|
||||
if (occupied[r + dr]?.[c + dc]) return false
|
||||
return true
|
||||
}
|
||||
for (const item of input) {
|
||||
const [h, w] = dims(item)
|
||||
let done = false
|
||||
for (let r = 0; r + h <= maxRows && !done; r++) {
|
||||
for (let c = 0; c + w <= cols; c++) {
|
||||
if (fits(r, c, h, w)) {
|
||||
for (let dr = 0; dr < h; dr++) {
|
||||
const line = (occupied[r + dr] ??= new Array(cols).fill(false))
|
||||
for (let dc = 0; dc < w; dc++) line[c + dc] = true
|
||||
}
|
||||
placed.push(item)
|
||||
done = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!done) deferred.push(item)
|
||||
}
|
||||
return { placed, deferred }
|
||||
}
|
||||
|
||||
const pages: T[][] = []
|
||||
let rest = items
|
||||
while (rest.length) {
|
||||
const { placed, deferred } = pack(rest)
|
||||
if (!placed.length) {
|
||||
// Sicherheitsnetz: kann bei cols>=1 nie leer sein (1x1 passt immer)
|
||||
pages.push(rest)
|
||||
break
|
||||
}
|
||||
pages.push(placed)
|
||||
rest = deferred
|
||||
}
|
||||
return pages
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
const MAX_HTML_BYTES = 512 * 1024
|
||||
|
||||
function metaContent(html: string, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const before = new RegExp(`<meta[^>]+(?:property|name)=["']${escaped}["'][^>]+content=["']([^"']+)["']`, 'i')
|
||||
const after = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${escaped}["']`, 'i')
|
||||
const match = before.exec(html) ?? after.exec(html)
|
||||
if (match?.[1]?.trim()) return match[1].trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface LinkPreview {
|
||||
image: string | null
|
||||
title: string | null
|
||||
}
|
||||
|
||||
const BLOCKED_HOST = /^(localhost|127\.|0\.0\.0\.0|::1|\[::1\]|169\.254\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|.*\.(local|internal|lan)$)/i
|
||||
|
||||
export function isBlockedHost(hostname: string): boolean {
|
||||
return BLOCKED_HOST.test(hostname)
|
||||
}
|
||||
|
||||
export async function fetchLinkPreview(url: string): Promise<LinkPreview> {
|
||||
try {
|
||||
const hostname = new URL(url).hostname
|
||||
if (isBlockedHost(hostname)) return { image: null, title: null }
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'user-agent': 'Mozilla/5.0 (compatible; 4Brett-LinkPreview/1.0)',
|
||||
'accept': 'text/html,application/xhtml+xml',
|
||||
},
|
||||
})
|
||||
if (!res.ok) return { image: null, title: null }
|
||||
const contentType = res.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
||||
return { image: null, title: null }
|
||||
}
|
||||
const html = (await res.text()).slice(0, MAX_HTML_BYTES)
|
||||
const base = res.url || url
|
||||
|
||||
const image = metaContent(html, ['og:image', 'og:image:url', 'og:image:secure_url', 'twitter:image', 'twitter:image:src'])
|
||||
const title = metaContent(html, ['og:title', 'twitter:title'])
|
||||
?? /<title[^>]*>([^<]+)<\/title>/i.exec(html)?.[1]?.trim()
|
||||
?? null
|
||||
|
||||
return {
|
||||
image: image ? new URL(image, base).href.slice(0, 2048) : null,
|
||||
title: title?.slice(0, 500) ?? null,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return { image: null, title: null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Peer } from 'crossws'
|
||||
import { and, countDistinct, desc, eq, ne, sql } from 'drizzle-orm'
|
||||
import { mentionedUserIds } from '../../shared/utils/mentions'
|
||||
import { notifications, sessions, threadSubscriptions, uploads, users } from '../db/schema'
|
||||
|
||||
const peersByUser = new Map<number, Set<Peer>>()
|
||||
const allPeers = new Set<Peer>()
|
||||
|
||||
export function registerGlobalPeer(peer: Peer) {
|
||||
allPeers.add(peer)
|
||||
}
|
||||
|
||||
export function unregisterGlobalPeer(peer: Peer) {
|
||||
allPeers.delete(peer)
|
||||
}
|
||||
|
||||
export function broadcastToAll(payload: unknown) {
|
||||
const message = JSON.stringify(payload)
|
||||
for (const peer of allPeers) {
|
||||
try {
|
||||
peer.send(message)
|
||||
}
|
||||
catch {
|
||||
allPeers.delete(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPeer(userId: number, peer: Peer) {
|
||||
let set = peersByUser.get(userId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
peersByUser.set(userId, set)
|
||||
}
|
||||
set.add(peer)
|
||||
}
|
||||
|
||||
export function unregisterPeer(userId: number | undefined, peer: Peer) {
|
||||
if (userId === undefined) return
|
||||
const set = peersByUser.get(userId)
|
||||
if (!set) return
|
||||
set.delete(peer)
|
||||
if (!set.size) peersByUser.delete(userId)
|
||||
}
|
||||
|
||||
export function pushToUser(userId: number, payload: unknown) {
|
||||
const set = peersByUser.get(userId)
|
||||
if (!set) return
|
||||
const message = JSON.stringify(payload)
|
||||
for (const peer of set) {
|
||||
try {
|
||||
peer.send(message)
|
||||
}
|
||||
catch {
|
||||
set.delete(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserIdFromCookieHeader(cookieHeader: string | undefined) {
|
||||
const token = /(?:^|;\s*)session=([^;]+)/.exec(cookieHeader ?? '')?.[1]
|
||||
if (!token) return null
|
||||
const rows = await useDb()
|
||||
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, token))
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
if (!row || row.expiresAt < new Date()) return null
|
||||
return row.userId
|
||||
}
|
||||
|
||||
export async function notifyComment(actorId: number, commentId: number, uploadId: string, content: string) {
|
||||
const db = useDb()
|
||||
const [upload] = await db
|
||||
.select({ userId: uploads.userId, type: uploads.type, fileName: uploads.fileName, externalUrl: uploads.externalUrl, previewImage: uploads.previewImage })
|
||||
.from(uploads)
|
||||
.where(eq(uploads.id, uploadId))
|
||||
.limit(1)
|
||||
if (!upload) return
|
||||
|
||||
const mentionUsers = content.includes('@')
|
||||
? await db.select({ id: users.id, name: users.name, color: users.color }).from(users)
|
||||
: []
|
||||
const mentioned = mentionedUserIds(content, mentionUsers)
|
||||
const subscriptions = await db.select({ userId: threadSubscriptions.userId, enabled: threadSubscriptions.enabled })
|
||||
.from(threadSubscriptions).where(eq(threadSubscriptions.uploadId, uploadId))
|
||||
const recipients = new Set([...subscriptions.filter(s => s.enabled).map(s => s.userId), ...mentioned])
|
||||
for (const subscription of subscriptions) {
|
||||
if (!subscription.enabled) recipients.delete(subscription.userId)
|
||||
}
|
||||
recipients.delete(actorId)
|
||||
if (!recipients.size) return
|
||||
|
||||
const [actor] = await db
|
||||
.select({ name: users.name, color: users.color })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1)
|
||||
|
||||
const url = upload.externalUrl ?? `/uploads/${upload.fileName}`
|
||||
const embedId = upload.type === 'youtube' && upload.externalUrl ? getYoutubeId(upload.externalUrl) : null
|
||||
const thumbUrl = upload.type === 'youtube' && embedId
|
||||
? youtubeThumbUrl(embedId)
|
||||
: upload.type === 'image' ? url : upload.type === 'link' || upload.type === 'twitch' ? upload.previewImage : null
|
||||
|
||||
for (const userId of recipients) {
|
||||
const id = await db.transaction(async (tx) => {
|
||||
await tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).for('update')
|
||||
const [subscription] = await tx.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
|
||||
.where(and(eq(threadSubscriptions.uploadId, uploadId), eq(threadSubscriptions.userId, userId))).limit(1).for('update')
|
||||
if (subscription && !subscription.enabled) return null
|
||||
const thread = and(eq(notifications.userId, userId), eq(notifications.uploadId, uploadId))
|
||||
const [existing] = await tx.select({ id: notifications.id, commentId: notifications.commentId })
|
||||
.from(notifications).where(thread).orderBy(desc(notifications.id)).limit(1).for('update')
|
||||
if (existing) {
|
||||
if (existing.commentId > commentId) return null
|
||||
await tx.update(notifications)
|
||||
.set({ actorId, commentId, isRead: false, createdAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(eq(notifications.id, existing.id))
|
||||
await tx.update(notifications).set({ isRead: true }).where(and(thread, ne(notifications.id, existing.id)))
|
||||
return existing.id
|
||||
}
|
||||
const [result] = await tx.insert(notifications).values({ userId, actorId, uploadId, commentId })
|
||||
return Number(result.insertId)
|
||||
})
|
||||
if (id === null) continue
|
||||
const [unread] = await db.select({ count: countDistinct(notifications.uploadId) }).from(notifications)
|
||||
.where(and(eq(notifications.userId, userId), eq(notifications.isRead, false)))
|
||||
pushToUser(userId, {
|
||||
type: 'notification',
|
||||
unreadCount: Number(unread?.count ?? 0),
|
||||
notification: {
|
||||
id,
|
||||
actorName: actor?.name ?? '?',
|
||||
actorColor: actor?.color ?? null,
|
||||
uploadId,
|
||||
commentId,
|
||||
content: content.slice(0, 140),
|
||||
isMention: mentioned.has(userId),
|
||||
isRead: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
uploadType: upload.type,
|
||||
url,
|
||||
thumbUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { permissions, rolePermissions, userRoles } from '../db/schema'
|
||||
|
||||
export async function getUserPermissions(userId: number) {
|
||||
const rows = await useDb()
|
||||
.select({ key: permissions.key })
|
||||
.from(rolePermissions)
|
||||
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
|
||||
.innerJoin(userRoles, eq(rolePermissions.roleId, userRoles.roleId))
|
||||
.where(eq(userRoles.userId, userId))
|
||||
return [...new Set(rows.map(r => r.key))]
|
||||
}
|
||||
|
||||
export async function getRolePermissions() {
|
||||
const rows = await useDb()
|
||||
.select({ roleId: rolePermissions.roleId, key: permissions.key })
|
||||
.from(rolePermissions)
|
||||
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
|
||||
const byRole = new Map<number, string[]>()
|
||||
for (const row of rows) {
|
||||
const list = byRole.get(row.roleId) ?? []
|
||||
list.push(row.key)
|
||||
byRole.set(row.roleId, list)
|
||||
}
|
||||
return byRole
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { roles, userRoles } from '../db/schema'
|
||||
|
||||
export async function getUserRoles(userId: number) {
|
||||
const rows = await useDb()
|
||||
.select({ name: roles.name })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId))
|
||||
return rows.map(r => r.name)
|
||||
}
|
||||
|
||||
export async function ensureRole(name: string) {
|
||||
const db = useDb()
|
||||
const existing = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, name)).limit(1)
|
||||
if (existing[0]) return existing[0].id
|
||||
const [result] = await db.insert(roles).values({ name })
|
||||
return Number(result.insertId)
|
||||
}
|
||||
|
||||
export async function assignRole(userId: number, roleId: number) {
|
||||
try {
|
||||
await useDb().insert(userRoles).values({ userId, roleId })
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as { errno?: number }).errno !== 1062) throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { threadSubscriptions } from '../db/schema'
|
||||
|
||||
export function ensureThreadSubscription(uploadId: string, userId: number, db: Pick<ReturnType<typeof useDb>, 'insert'> = useDb()) {
|
||||
return db.insert(threadSubscriptions).values({ uploadId, userId })
|
||||
.onDuplicateKeyUpdate({ set: { userId: sql`${threadSubscriptions.userId}` } })
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TwitchTarget } from '../../../shared/utils/twitch'
|
||||
|
||||
// Öffentliche Client-ID der Twitch-Web-App (gql.twitch.tv wird von twitch.tv selbst genutzt)
|
||||
const TWITCH_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
|
||||
|
||||
function gqlStr(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
interface TwitchPreview {
|
||||
image: string | null
|
||||
title: string | null
|
||||
}
|
||||
|
||||
// Echtes Twitch-Thumbnail/-Titel via öffentlicher GQL-API — og:image liefert
|
||||
// bei Twitch nur das generische Logo
|
||||
export async function fetchTwitchPreview(target: TwitchTarget): Promise<TwitchPreview> {
|
||||
try {
|
||||
const query = target.kind === 'video'
|
||||
? `{ video(id: "${gqlStr(target.id)}") { previewThumbnailURL(height: 360, width: 640) title } }`
|
||||
: target.kind === 'clip'
|
||||
? `{ clip(slug: "${gqlStr(target.id)}") { thumbnailURL title } }`
|
||||
: `{ user(login: "${gqlStr(target.id.toLowerCase())}") { displayName broadcastSettings { title } profileImageURL(width: 300) stream { previewImageURL(width: 640, height: 360) } } }`
|
||||
|
||||
const res = await fetch('https://gql.twitch.tv/gql', {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'client-id': TWITCH_CLIENT_ID,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
})
|
||||
if (!res.ok) return { image: null, title: null }
|
||||
const data = (await res.json())?.data
|
||||
if (!data) return { image: null, title: null }
|
||||
|
||||
if (target.kind === 'video') {
|
||||
return { image: data.video?.previewThumbnailURL ?? null, title: data.video?.title ?? null }
|
||||
}
|
||||
if (target.kind === 'clip') {
|
||||
return { image: data.clip?.thumbnailURL ?? null, title: data.clip?.title ?? null }
|
||||
}
|
||||
const u = data.user
|
||||
return {
|
||||
image: u?.stream?.previewImageURL ?? u?.profileImageURL ?? null,
|
||||
title: u?.broadcastSettings?.title ?? u?.displayName ?? null,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return { image: null, title: null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { randomInt } from 'node:crypto'
|
||||
|
||||
export function randomUserColor() {
|
||||
const h = randomInt(360)
|
||||
const s = 60 + randomInt(30)
|
||||
const l = 50 + randomInt(15)
|
||||
const a = s * Math.min(l, 100 - l) / 100
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12
|
||||
const v = l / 100 - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
|
||||
return Math.round(Math.min(1, Math.max(0, v)) * 255)
|
||||
}
|
||||
return `#${[f(0), f(8), f(4)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export function avatarUrl(avatar: string | null) {
|
||||
return avatar ? `/uploads/avatars/${avatar}` : null
|
||||
}
|
||||
Reference in New Issue
Block a user