From 29a9ec2242fcd0cd602d432c465e3bc74e408065 Mon Sep 17 00:00:00 2001 From: flfeders Date: Sat, 3 Oct 2026 17:56:07 +0200 Subject: [PATCH] feat(communication): add chat reactions emojis and images --- .../migrations/0081_chat_media_reactions.sql | 22 ++++ backend/db/migrations/meta/_journal.json | 7 ++ backend/db/schema/chat_reactions.ts | 19 +++ backend/db/schema/collaboration.ts | 4 +- backend/db/schema/communication_messages.ts | 3 +- backend/db/schema/index.ts | 2 + backend/src/modules/chat-content.ts | 90 ++++++++++++++ .../src/modules/collaboration/federation.ts | 33 ++++- backend/src/modules/collaboration/service.ts | 41 ++++-- backend/src/routes/collaboration.ts | 9 ++ backend/src/routes/communication.ts | 37 ++++-- backend/tests/chatContent.test.ts | 117 ++++++++++++++++++ backend/tests/collaborationFederation.test.ts | 35 +++++- backend/tests/collaborationRoutes.test.ts | 13 ++ backend/tests/helpers/collaboration-db.ts | 5 +- docs/chat-foederation-konzept.md | 14 ++- frontend/components/ChatComposer.vue | 65 ++++++++++ frontend/components/ChatImage.vue | 38 ++++++ frontend/components/ChatReactionBar.vue | 19 +++ frontend/components/ChatReactions.vue | 39 ++++++ frontend/components/CollaborationRoom.vue | 29 ++--- frontend/pages/communication/chat.vue | 25 ++-- frontend/tests/chat-collaboration.test.cjs | 21 ++++ frontend/tests/chat-media.test.cjs | 91 ++++++++++++++ frontend/utils/chat-image.js | 16 +++ 25 files changed, 737 insertions(+), 57 deletions(-) create mode 100644 backend/db/migrations/0081_chat_media_reactions.sql create mode 100644 backend/db/schema/chat_reactions.ts create mode 100644 backend/src/modules/chat-content.ts create mode 100644 backend/tests/chatContent.test.ts create mode 100644 frontend/components/ChatComposer.vue create mode 100644 frontend/components/ChatImage.vue create mode 100644 frontend/components/ChatReactionBar.vue create mode 100644 frontend/components/ChatReactions.vue create mode 100644 frontend/tests/chat-media.test.cjs create mode 100644 frontend/utils/chat-image.js diff --git a/backend/db/migrations/0081_chat_media_reactions.sql b/backend/db/migrations/0081_chat_media_reactions.sql new file mode 100644 index 00000000..d3cc3b58 --- /dev/null +++ b/backend/db/migrations/0081_chat_media_reactions.sql @@ -0,0 +1,22 @@ +ALTER TABLE communication_messages ADD COLUMN image jsonb; +--> statement-breakpoint +ALTER TABLE collaboration_messages ADD COLUMN image jsonb; +--> statement-breakpoint +ALTER TABLE collaboration_messages DROP CONSTRAINT collaboration_messages_body_check; +--> statement-breakpoint +ALTER TABLE collaboration_messages ADD CONSTRAINT collaboration_messages_body_check + CHECK (length(body) <= 10000 AND (length(body) > 0 OR image IS NOT NULL)); +--> statement-breakpoint +CREATE TABLE communication_message_reactions ( + message_id bigint NOT NULL REFERENCES communication_messages(id) ON DELETE CASCADE, + actor_id uuid NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + emoji text NOT NULL CHECK (length(emoji) BETWEEN 1 AND 16), + PRIMARY KEY (message_id, actor_id, emoji) +); +--> statement-breakpoint +CREATE TABLE collaboration_message_reactions ( + message_id uuid NOT NULL REFERENCES collaboration_messages(id) ON DELETE CASCADE, + actor_id uuid NOT NULL REFERENCES collaboration_principals(id) ON DELETE CASCADE, + emoji text NOT NULL CHECK (length(emoji) BETWEEN 1 AND 16), + PRIMARY KEY (message_id, actor_id, emoji) +); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 4dccf03b..4185dade 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1791158400000, "tag": "0080_staff_time_spans", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1791244800000, + "tag": "0081_chat_media_reactions", + "breakpoints": true } ] } diff --git a/backend/db/schema/chat_reactions.ts b/backend/db/schema/chat_reactions.ts new file mode 100644 index 00000000..d627e29c --- /dev/null +++ b/backend/db/schema/chat_reactions.ts @@ -0,0 +1,19 @@ +import { sql } from "drizzle-orm" +import { bigint, check, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core" +import { authUsers } from "./auth_users" +import { collaborationMessages, collaborationPrincipals } from "./collaboration" +import { communicationMessages } from "./communication_messages" + +export const communicationMessageReactions = pgTable("communication_message_reactions", { + messageId: bigint("message_id", { mode: "number" }).notNull().references(() => communicationMessages.id, { onDelete: "cascade" }), + actorId: uuid("actor_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), + emoji: text("emoji").notNull(), +}, t => ({ pk: primaryKey({ columns: [t.messageId, t.actorId, t.emoji] }), + emoji: check("communication_message_reactions_emoji_check", sql`length(${t.emoji}) BETWEEN 1 AND 16`) })) + +export const collaborationMessageReactions = pgTable("collaboration_message_reactions", { + messageId: uuid("message_id").notNull().references(() => collaborationMessages.id, { onDelete: "cascade" }), + actorId: uuid("actor_id").notNull().references(() => collaborationPrincipals.id, { onDelete: "cascade" }), + emoji: text("emoji").notNull(), +}, t => ({ pk: primaryKey({ columns: [t.messageId, t.actorId, t.emoji] }), + emoji: check("collaboration_message_reactions_emoji_check", sql`length(${t.emoji}) BETWEEN 1 AND 16`) })) diff --git a/backend/db/schema/collaboration.ts b/backend/db/schema/collaboration.ts index 1ffcc9ca..fe89f2f1 100644 --- a/backend/db/schema/collaboration.ts +++ b/backend/db/schema/collaboration.ts @@ -50,10 +50,10 @@ export const collaborationMembers = pgTable("collaboration_members", { export const collaborationMessages = pgTable("collaboration_messages", { id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }), seq: bigint("seq", { mode: "number" }).notNull(), principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id), - clientId: uuid("client_id").notNull(), body: text("body").notNull(), createdAt: time("created_at").notNull().defaultNow(), + clientId: uuid("client_id").notNull(), body: text("body").notNull(), image: jsonb("image"), createdAt: time("created_at").notNull().defaultNow(), }, t => ({ seq: uniqueIndex("collaboration_messages_room_id_seq_key").on(t.roomId, t.seq), command: uniqueIndex("collaboration_messages_room_id_principal_id_client_id_key").on(t.roomId, t.principalId, t.clientId), - body: check("collaboration_messages_body_check", sql`length(${t.body}) BETWEEN 1 AND 10000`) })) + body: check("collaboration_messages_body_check", sql`length(${t.body}) <= 10000 AND (length(${t.body}) > 0 OR ${t.image} IS NOT NULL)`) })) export const collaborationInvitations = pgTable("collaboration_invitations", { id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }), targetPrincipalId: uuid("target_principal_id").notNull().references(() => collaborationPrincipals.id), tokenHash: text("token_hash").notNull().unique(), diff --git a/backend/db/schema/communication_messages.ts b/backend/db/schema/communication_messages.ts index f25548e4..b06f0174 100644 --- a/backend/db/schema/communication_messages.ts +++ b/backend/db/schema/communication_messages.ts @@ -1,4 +1,4 @@ -import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" +import { bigint, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { authUsers } from "./auth_users" import { communicationRooms } from "./communication_rooms" @@ -20,6 +20,7 @@ export const communicationMessages = pgTable( .notNull() .references(() => authUsers.id), body: text("body").notNull(), + image: jsonb("image"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index e5d68c88..9eaaabe9 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -106,3 +106,5 @@ export * from "./collaboration"; export * from "./collaboration_federation" export * from './staff_time_spans' + +export * from "./chat_reactions" diff --git a/backend/src/modules/chat-content.ts b/backend/src/modules/chat-content.ts new file mode 100644 index 00000000..6063df1b --- /dev/null +++ b/backend/src/modules/chat-content.ts @@ -0,0 +1,90 @@ +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) +} diff --git a/backend/src/modules/collaboration/federation.ts b/backend/src/modules/collaboration/federation.ts index 77263aae..01555b79 100644 --- a/backend/src/modules/collaboration/federation.ts +++ b/backend/src/modules/collaboration/federation.ts @@ -1,3 +1,4 @@ +import { chatContent, imageMetadata } from "../chat-content" import { generateKeyPairSync, randomUUID } from "node:crypto" import { sql } from "drizzle-orm" import type { FastifyInstance } from "fastify" @@ -122,7 +123,9 @@ export class FederationService { ON CONFLICT(dedup_key) DO UPDATE SET dedup_key=excluded.dedup_key RETURNING *`) if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) { // JSONB key order differs: compare the actual command values instead of its serialization. - if (job.payload.input?.text !== entry.payload.input?.text || job.payload.actorId !== entry.payload.actorId || job.payload.roomId !== entry.payload.roomId || job.payload.input?.revision !== entry.payload.input?.revision) fail("Auftrags-ID wurde bereits anders verwendet", 409) + if (job.payload.input?.text !== entry.payload.input?.text || job.payload.actorId !== entry.payload.actorId || job.payload.roomId !== entry.payload.roomId || job.payload.input?.revision !== entry.payload.input?.revision + || (job.payload.input?.image?.dataUrl || null) !== (entry.payload.input?.image?.dataUrl || null) + || (job.payload.input?.image?.name || null) !== (entry.payload.input?.image?.name || null)) fail("Auftrags-ID wurde bereits anders verwendet", 409) } return job } @@ -223,6 +226,9 @@ export class FederationService { if (action === "room.get") return this.service.room(principal, room.id) if (action === "messages.get") return this.service.messages(principal, room.id, request.input) if (action === "messages.send") return this.service.send(principal, room.id, request.input) + if (action === "image.get") return this.service.image(principal, room.id, request.input) + if (action === "reactions.get") return this.service.reactions(principal, room.id, request.input) + if (action === "reactions.set") return this.service.react(principal, room.id, request.input) if (action === "members.get") return { members: await this.service.members(principal, room.id) } if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq) fail("Unbekannte Föderationsaktion") @@ -300,7 +306,7 @@ export class FederationService { } async cache(binding: any, messages: any[], snapshot?: any) { if (!Array.isArray(messages) || messages.length > 50) fail("Ungültige Nachrichtenantwort", 502) - const projected = messages.map(m => ({ id: uuid(m.id), clientId: uuid(m.clientId), seq: sequence(m.seq), body: text(m.body, 10000), + const projected = messages.map(m => ({ id: uuid(m.id), clientId: uuid(m.clientId), seq: sequence(m.seq), body: text(m.body, 10000, Boolean(m.image)), image: imageMetadata(m.image), senderName: text(m.senderName, 400), senderKind: ['user','guest','remote'].includes(m.senderKind) ? m.senderKind : 'remote', own: m.own === true, createdAt: new Date(m.createdAt).toISOString() })) await this.service.db.transaction(async db => { @@ -326,8 +332,9 @@ export class FederationService { await this.allowed(ctx.tenantId, binding.origin) if (action === "send") { if (!binding.snapshot?.canSend) fail("Unterraum ist nicht zum Schreiben freigegeben", 403) + const content = await chatContent(input) const command = { actorId: ctx.principal.id, roomId: binding.room_id, - input: { clientId: uuid(input.clientId), text: text(input.text, 10000), revision: Number(binding.revision) } } + input: { clientId: uuid(input.clientId), text: content.body, image: content.image, revision: Number(binding.revision) } } const job = await this.enqueue(this.service.db, { key: `message:${binding.id}:${command.input.clientId}`, ctx, origin: binding.origin, action: 'messages.send', payload: command, bindingId: binding.id }) await this.deliver(job) @@ -336,6 +343,20 @@ export class FederationService { if (currentJob.status === 'failed') fail(currentJob.last_error || "Nachricht konnte nicht gesendet werden", 409) return { found: true, result: message?.payload || this.pendingMessage(currentJob) } } + if (["image", "reactions", "react"].includes(action)) { + const rpc = action === "image" ? "image.get" : action === "react" ? "reactions.set" : "reactions.get" + try { + const result = await this.call(ctx.tenantId, binding.origin, rpc, { actorId: ctx.principal.id, roomId: binding.room_id, + input: { ...input, revision: Number(binding.revision) } }) + return { found: true, result } + } catch (error: any) { + if ([401, 403, 404, 410].includes(error.statusCode)) { + // A missing image/message is not by itself evidence of revoked room access. + await this.sync(ctx, binding) + } + throw error + } + } const detail = await this.sync(ctx, binding) binding = await this.binding(ctx, id) if (action === "room") return { found: true, result: detail } @@ -368,7 +389,7 @@ export class FederationService { return { found: true, result: { messages, hasMore: page.length === 50, offline: detail.offline } } } pendingMessage(job: any) { - return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text, + return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text, image: imageMetadata(job.payload.input.image), createdAt: job.created_at, own: true, senderName: 'Du', senderKind: 'user', pending: job.status === 'pending', failed: job.status === 'failed' } } async deliver(job: any) { @@ -433,12 +454,12 @@ export class FederationService { export async function federationRoutes(server: FastifyInstance, { federation }: { federation: FederationService }) { server.removeContentTypeParser('application/json') - server.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 128 * 1024 }, (req: any, body: string, done) => { + server.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 1024 * 1024 }, (req: any, body: string, done) => { req.federationBody = body try { done(null, JSON.parse(body)) } catch { done(Object.assign(new Error('Ungültiges JSON'), { statusCode: 400 })) } }) server.get('/.well-known/fedeo-federation', async () => federation.discovery()) - server.post('/federation/v1/rpc', { bodyLimit: 128 * 1024 }, async (req: any, reply) => { + server.post('/federation/v1/rpc', { bodyLimit: 1024 * 1024 }, async (req: any, reply) => { reply.header('Cache-Control','no-store') // Duplicate signature-bearing fields are rejected rather than silently combined. const names = req.raw.rawHeaders.filter((_: string, index: number) => index % 2 === 0).map((name: string) => name.toLowerCase()) diff --git a/backend/src/modules/collaboration/service.ts b/backend/src/modules/collaboration/service.ts index 8a100ec8..db96fe97 100644 --- a/backend/src/modules/collaboration/service.ts +++ b/backend/src/modules/collaboration/service.ts @@ -3,6 +3,8 @@ import { sql } from "drizzle-orm" import type { NodePgDatabase } from "drizzle-orm/node-postgres" import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types" +import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content" + type Db = Pick, "execute" | "transaction"> export const rows = async (db: Pick, query: any): Promise => (await db.execute(query)).rows export type CollaborationOptions = { @@ -172,7 +174,7 @@ export class CollaborationService { const room = await this.roomRow(this.db, roomId) const member = await this.membership(this.db, room, principal) const after = sequence(query.after), before = sequence(query.before) - const result = await rows(this.db, sql`SELECT m.id,m.client_id AS "clientId",m.seq::float8 AS seq,m.body,m.created_at AS "createdAt", + const result = await rows(this.db, sql`SELECT m.id,m.client_id AS "clientId",m.seq::float8 AS seq,m.body,m.image - 'dataUrl' AS image,m.created_at AS "createdAt", p.display_name AS "senderName",p.kind AS "senderKind", m.principal_id=${principal.id} AS own FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.room_id=${room.id} AND m.seq>=${member.history_from_seq} @@ -182,22 +184,45 @@ export class CollaborationService { } async send(principal: Principal, roomId: string, input: any) { - const body = text(input.text, 10000), clientId = uuid(input.clientId) + const clientId = uuid(input.clientId) + await this.membership(this.db, await this.roomRow(this.db, roomId), principal, true) + const { body, image } = await chatContent(input) return this.db.transaction(async (db) => { const room = await this.roomRow(db, roomId, true) const member = await this.membership(db, room, principal, true) if (principal.kind === "remote" && sequence(input.revision) !== Number(member.revision)) fail("Teilnahmerechte haben sich geändert", 409) - const [existing] = await rows(db, sql`SELECT id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt" FROM collaboration_messages + const [existing] = await rows(db, sql`SELECT id,client_id AS "clientId",seq::float8 AS seq,body,image,created_at AS "createdAt" FROM collaboration_messages WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`) if (existing) { - if (existing.body !== body) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409) - return { ...existing, own: true, senderName: principal.display_name, senderKind: principal.kind } + if (existing.body !== body || (existing.image?.dataUrl || null) !== (image?.dataUrl || null) || (existing.image?.name || null) !== (image?.name || null)) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409) + return { ...existing, image: imageMetadata(existing.image), own: true, senderName: principal.display_name, senderKind: principal.kind } } const [next] = await rows(db, sql`UPDATE collaboration_rooms SET sequence=sequence+1,updated_at=now() WHERE id=${room.id} RETURNING sequence`) - const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body) - VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body}) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`) + const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image) + VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`) await this.options.changed?.(room.id, db) - return { ...message, own: true, senderName: principal.display_name, senderKind: principal.kind } + return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind } + }) + } + + async image(principal: Principal, roomId: string, input: any) { + const room = await this.roomRow(this.db, roomId) + const member = await this.membership(this.db, room, principal) + return chatImage(this.db, "collaboration", room.id, input?.messageId, Number(member.history_from_seq)) + } + + async reactions(principal: Principal, roomId: string, input: any) { + const room = await this.roomRow(this.db, roomId) + const member = await this.membership(this.db, room, principal) + return chatReactions(this.db, "collaboration", room.id, principal.id, input?.ids, Number(member.history_from_seq)) + } + + async react(principal: Principal, roomId: string, input: any) { + return this.db.transaction(async db => { + const room = await this.roomRow(db, roomId, true) + const member = await this.membership(db, room, principal, true) + if (principal.kind === "remote" && sequence(input?.revision) !== Number(member.revision)) fail("Teilnahmerechte haben sich geändert", 409) + return setChatReaction(db, "collaboration", room.id, principal.id, input, Number(member.history_from_seq)) }) } diff --git a/backend/src/routes/collaboration.ts b/backend/src/routes/collaboration.ts index 9c8f9b3f..53affec6 100644 --- a/backend/src/routes/collaboration.ts +++ b/backend/src/routes/collaboration.ts @@ -111,6 +111,9 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll if (action === "room") return service.room(ctx.principal, id) if (action === "messages") return service.messages(ctx.principal, id, input) if (action === "send") return service.send(ctx.principal, id, input) + if (action === "image") return service.image(ctx.principal, id, input) + if (action === "reactions") return service.reactions(ctx.principal, id, input) + if (action === "react") return service.react(ctx.principal, id, input) if (action === "read") return service.markRead(ctx.principal, id, input.seq) return { members: await service.members(ctx.principal, id) } } @@ -118,6 +121,9 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll server.get("/collaboration/rooms/:id/messages", async (req) => request(req, "messages")) server.post("/collaboration/rooms/:id/messages", async (req) => request(req, "send")) server.post("/collaboration/rooms/:id/read", async (req) => request(req, "read")) + server.get("/collaboration/rooms/:id/image", async (req) => request(req, "image")) + server.get("/collaboration/rooms/:id/reactions", async (req) => request(req, "reactions")) + server.put("/collaboration/rooms/:id/reactions", async (req) => request(req, "react")) server.get("/collaboration/rooms/:id/members", async (req) => request(req, "members")) } @@ -137,5 +143,8 @@ export async function collaborationGuestRoutes(server: FastifyInstance, { servic server.get("/guest/api/rooms/:id/messages", async (req: any) => service.messages(await principal(req), req.params.id, req.query)) server.get("/guest/api/rooms/:id/members", async (req: any) => ({ members: await service.members(await principal(req), req.params.id) })) server.post("/guest/api/rooms/:id/messages", async (req: any) => service.send(await principal(req), req.params.id, body(req))) + server.get("/guest/api/rooms/:id/image", async (req: any) => service.image(await principal(req), req.params.id, req.query)) + server.get("/guest/api/rooms/:id/reactions", async (req: any) => service.reactions(await principal(req), req.params.id, req.query)) + server.put("/guest/api/rooms/:id/reactions", async (req: any) => service.react(await principal(req), req.params.id, body(req))) server.post("/guest/api/rooms/:id/read", async (req: any) => service.markRead(await principal(req), req.params.id, body(req).seq)) } diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index d1119c13..32e1b5d9 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -15,6 +15,7 @@ import { } from "../../db/schema" import { NotificationService, UserDirectory } from "../modules/notification.service" import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access" +import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../modules/chat-content" type ChatUser = { userId: string @@ -160,7 +161,10 @@ export default async function communicationRoutes(server: FastifyInstance) { const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => { const conditions = [eq(communicationMessages.roomId, roomId)] if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId)) - const rows = await server.db.select().from(communicationMessages) + const rows = await server.db.select({ id: communicationMessages.id, body: communicationMessages.body, + authorUserId: communicationMessages.authorUserId, createdAt: communicationMessages.createdAt, + image: sql`${communicationMessages.image} - 'dataUrl'`, + }).from(communicationMessages) .where(and(...conditions)) .orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id)) .limit(Math.min(Math.max(limit, 1), 100)) @@ -181,6 +185,7 @@ export default async function communicationRoutes(server: FastifyInstance) { return orderedRows.map((message) => ({ id: message.id, body: message.body, + image: imageMetadata(message.image), sender: message.authorUserId, senderDisplayName: displayName(authorById.get(message.authorUserId) || {}), timestamp: message.createdAt.getTime(), @@ -466,17 +471,33 @@ export default async function communicationRoutes(server: FastifyInstance) { return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId } }) - server.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => { + server.post("/communication/chat/rooms/:roomKey/messages", async (req: any) => { const tenantId = await requireTenant(req) const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey) - const body = String(req.body?.text || "").trim() - if (!body) return reply.code(400).send({ error: "Nachricht darf nicht leer sein" }) - if (body.length > 10_000) return reply.code(400).send({ error: "Nachricht ist zu lang" }) + const { body, image } = await chatContent(req.body) const [created] = await server.db.insert(communicationMessages).values({ - tenantId, roomId: room.id, authorUserId: req.user.user_id, body, + tenantId, roomId: room.id, authorUserId: req.user.user_id, body, image, }).returning() - await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body }) - return { id: created.id, body: created.body, sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true } + await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body: body || "📷 Bild" }) + return { id: created.id, body: created.body, image: imageMetadata(created.image), sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true } + }) + + server.get("/communication/chat/rooms/:roomKey/image", async (req: any, reply) => { + reply.header("Cache-Control", "no-store") + const tenantId = await requireTenant(req) + const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey) + return chatImage(server.db, "communication", room.id, req.query.messageId) + }) + server.get("/communication/chat/rooms/:roomKey/reactions", async (req: any, reply) => { + reply.header("Cache-Control", "no-store") + const tenantId = await requireTenant(req) + const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey) + return chatReactions(server.db, "communication", room.id, req.user.user_id, req.query.ids) + }) + server.put("/communication/chat/rooms/:roomKey/reactions", async (req: any) => { + const tenantId = await requireTenant(req) + const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey) + return setChatReaction(server.db, "communication", room.id, req.user.user_id, req.body) }) server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => { diff --git a/backend/tests/chatContent.test.ts b/backend/tests/chatContent.test.ts new file mode 100644 index 00000000..9aeb4078 --- /dev/null +++ b/backend/tests/chatContent.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { randomUUID } from "node:crypto" +import sharp from "sharp" +import Fastify from "fastify" +import { chatContent } from "../src/modules/chat-content" +import { CollaborationService } from "../src/modules/collaboration/service" +import communicationRoutes from "../src/routes/communication" +import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db" + +const picture = async () => ({ name: "Baustelle.png", dataUrl: `data:image/png;base64,${(await sharp({ create: { width: 80, height: 40, channels: 3, background: '#206050' } }).png().toBuffer()).toString('base64')}` }) + +test("chat content accepts emoji and image-only messages, strips metadata and rejects unsafe input", async () => { + assert.equal((await chatContent({ text: "Hallo 👋 ❤️" })).body, "Hallo 👋 ❤️") + const normalized = await chatContent({ image: await picture() }) + assert.equal(normalized.body, "") + assert.equal(normalized.image?.width, 80) + const metadata = await sharp(Buffer.from(normalized.image!.dataUrl.split(',')[1], 'base64')).metadata() + assert.equal(metadata.format, 'webp') + assert.equal(metadata.exif, undefined) + for (const input of [{}, { text: ' '.repeat(5) }, { text: 'a'.repeat(10001) }, + { image: { dataUrl: 'https://example.test/tracker.png' } }, + { image: { dataUrl: `data:image/png;base64,${Buffer.from('').toString('base64')}` } }, + { image: { dataUrl: 'data:image/png;base64,broken' } }, + { image: { dataUrl: 'a'.repeat(700001) } }]) await assert.rejects(chatContent(input)) +}) + +test("native chat images and reactions follow tenant, membership, project and message boundaries", { skip: !testDatabaseUrl }, async () => { + const fixture = await collaborationDatabase(), app = Fastify() + const owner = randomUUID(), colleague = randomUUID(), outsider = randomUUID(), room = randomUUID(), otherRoom = randomUUID() + try { + await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null),(2,'B',null)") + for (const [id, tenant] of [[owner, 1], [colleague, 1], [outsider, 2]]) { + await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,$2)", [id, `${id}@test.example`]) + await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES ($1,$2)", [tenant, id]) + } + await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived,chat_enabled) VALUES (1,1,'Project','{}',false,true)") + await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,type,archived,entity_id) + VALUES ($1,1,'project_1','Project','project',false,1),($2,1,'other','Other','room',false,null)`, [room, otherRoom]) + await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2),($1,$3),($4,$2)", [room, owner, colleague, otherRoom]) + app.decorate('db', fixture.db) + app.addHook('preHandler', async (req: any) => { + req.user = { user_id: req.headers['x-user'] || owner, tenant_id: Number(req.headers['x-tenant'] || 1), is_admin: true } + req.hasPermission = () => true + }) + await app.register(communicationRoutes) + const base = '/communication/chat/rooms/project_1' + const sent = await app.inject({ method: 'POST', url: `${base}/messages`, payload: { image: await picture() } }) + assert.equal(sent.statusCode, 200, sent.body) + const message = sent.json() + assert.equal(message.body, '') + assert.equal(message.image.width, 80) + assert.equal(message.image.dataUrl, undefined) + assert.equal((await app.inject(`${base}/messages`)).json().messages[0].image.dataUrl, undefined) + const image = await app.inject(`${base}/image?messageId=${message.id}`) + assert.equal(image.statusCode, 200, image.body) + assert.equal(image.headers['cache-control'], 'no-store') + assert.match(image.json().dataUrl, /^data:image\/webp;base64,/) + const react = (headers = {}, active = true, emoji = '👍') => app.inject({ method: 'PUT', url: `${base}/reactions`, headers, payload: { messageId: message.id, emoji, active } }) + assert.equal((await react()).statusCode, 200) + assert.equal((await react()).json().reactions[message.id][0].count, 1) + assert.equal((await react({ 'x-user': colleague })).json().reactions[message.id][0].count, 2) + const removed = await react({}, false) + assert.deepEqual(removed.json().reactions[message.id], [{ emoji: '👍', count: 1, own: false }]) + assert.equal((await react({}, true, ' + +