import sharp from "sharp" import { sql } from "drizzle-orm" import type { NodePgDatabase } from "drizzle-orm/node-postgres" import { fail, text, uuid } from "./collaboration/types" export const reactionEmojis = ["👍", "❤️", "😂", "🎉", "😮", "😢", "🙏", "🔥", "✅", "👎"] export type ChatImage = { dataUrl: string; name: string; width: number; height: number } type Db = Pick, "execute"> type Source = "communication" | "collaboration" /** Only re-encoded raster images are stored; neither URLs nor original metadata are retained. */ export async function chatContent(input: any): Promise<{ body: string; image: ChatImage | null }> { const body = text(input?.text, 10000, true) let image: ChatImage | null = null if (input?.image != null) { const value = input.image if (typeof value.dataUrl !== "string" || value.dataUrl.length > 700_000) fail("Das Bild ist zu groß (maximal 512 KB nach Verkleinerung)") const match = /^data:image\/(?:jpeg|png|webp);base64,([A-Za-z0-9+/]+={0,2})$/.exec(value.dataUrl) if (!match) fail("Nur JPEG-, PNG- und WebP-Bilder sind erlaubt") try { const buffer = Buffer.from(match[1], "base64") if (buffer.length > 512 * 1024) fail("Das Bild ist zu groß") const decoder = sharp(buffer, { limitInputPixels: 16_000_000, failOn: "warning" }) const metadata = await decoder.metadata() if (!["jpeg", "png", "webp"].includes(metadata.format || "") || (metadata.pages || 1) > 1) fail("Bitte ein einzelnes JPEG-, PNG- oder WebP-Bild verwenden") const result = await decoder.rotate().resize({ width: 1600, height: 1600, fit: "inside", withoutEnlargement: true }) .webp({ quality: 82 }).toBuffer({ resolveWithObject: true }) if (result.data.length > 512 * 1024) fail("Das Bild ist zu groß. Bitte eine kleinere Datei wählen") image = { dataUrl: `data:image/webp;base64,${result.data.toString("base64")}`, name: text(value.name || "Bild", 200), width: result.info.width, height: result.info.height } } catch (error: any) { if (error.statusCode) throw error fail("Das Bild konnte nicht gelesen werden") } } if (!body && !image) fail("Nachricht darf nicht leer sein") return { body, image } } export function imageMetadata(value: any) { if (value == null) return null if (!Number.isInteger(value.width) || !Number.isInteger(value.height) || value.width < 1 || value.height < 1 || value.width > 1600 || value.height > 1600) fail("Ungültige Bildabmessungen") return { name: text(value.name, 200), width: value.width, height: value.height } } function messageId(source: Source, value: unknown) { if (source === "collaboration") return uuid(value) const id = Number(value) if (!Number.isSafeInteger(id) || id < 1) fail("Ungültige Nachricht") return id } const tables = (source: Source) => ({ messages: sql.identifier(`${source}_messages`), reactions: sql.identifier(`${source}_message_reactions`) }) const history = (source: Source, from: number) => source === "collaboration" ? sql`AND m.seq>=${from}` : sql`` /** Callers must authorize room membership first, including the current history boundary. */ export async function chatImage(db: Db, source: Source, roomId: string, id: unknown, from = 0) { const { messages } = tables(source) const result = await db.execute(sql`SELECT m.image FROM ${messages} m WHERE m.room_id=${roomId} AND m.id=${messageId(source, id)} ${history(source, from)}`) const image = result.rows[0]?.image as ChatImage | null if (!image) fail("Bild nicht gefunden oder kein Zugriff", 404) return { dataUrl: image.dataUrl } } export async function chatReactions(db: Db, source: Source, roomId: string, actorId: string, value: unknown, from = 0) { const ids = typeof value === "string" ? value.split(",").filter(Boolean) : value if (!Array.isArray(ids) || ids.length > 100) fail("Ungültige Nachrichtenliste") const unique = [...new Set(ids.map(id => messageId(source, id)))] const reactions: Record = {} if (!unique.length) return { reactions } const { messages, reactions: table } = tables(source) const result = await db.execute(sql`SELECT r.message_id AS id,r.emoji,count(*)::int AS count,bool_or(r.actor_id=${actorId}) AS own FROM ${table} r JOIN ${messages} m ON m.id=r.message_id WHERE m.room_id=${roomId} AND m.id IN (${sql.join(unique.map(id => sql`${id}`), sql`,`)}) ${history(source, from)} GROUP BY r.message_id,r.emoji ORDER BY r.emoji`) for (const row of result.rows as any[]) (reactions[row.id] ||= []).push({ emoji: row.emoji, count: row.count, own: row.own }) return { reactions } } export async function setChatReaction(db: Db, source: Source, roomId: string, actorId: string, input: any, from = 0) { const id = messageId(source, input?.messageId) if (!reactionEmojis.includes(input?.emoji) || typeof input?.active !== "boolean") fail("Ungültige Reaktion") const { messages, reactions } = tables(source) const result = await db.execute(sql`SELECT m.id FROM ${messages} m WHERE m.room_id=${roomId} AND m.id=${id} ${history(source, from)}`) if (!result.rows.length) fail("Nachricht nicht gefunden oder kein Zugriff", 404) if (input.active) await db.execute(sql`INSERT INTO ${reactions}(message_id,actor_id,emoji) VALUES (${id},${actorId},${input.emoji}) ON CONFLICT DO NOTHING`) else await db.execute(sql`DELETE FROM ${reactions} WHERE message_id=${id} AND actor_id=${actorId} AND emoji=${input.emoji}`) return chatReactions(db, source, roomId, actorId, [id], from) }