dev #241

Merged
flfeders merged 9 commits from dev into main 2026-10-07 17:11:45 +00:00
25 changed files with 737 additions and 57 deletions
Showing only changes of commit 29a9ec2242 - Show all commits

View File

@@ -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)
);

View File

@@ -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
}
]
}

View File

@@ -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`) }))

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -106,3 +106,5 @@ export * from "./collaboration";
export * from "./collaboration_federation"
export * from './staff_time_spans'
export * from "./chat_reactions"

View File

@@ -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<NodePgDatabase<any>, "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<string, { emoji: string; count: number; own: boolean }[]> = {}
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)
}

View File

@@ -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())

View File

@@ -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<NodePgDatabase<any>, "execute" | "transaction">
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (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))
})
}

View File

@@ -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))
}

View File

@@ -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) => {

View File

@@ -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('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>').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, '<script>')).statusCode, 400)
assert.equal((await app.inject(`/communication/chat/rooms/other/image?messageId=${message.id}`)).statusCode, 404)
assert.deepEqual((await app.inject(`/communication/chat/rooms/other/reactions?ids=${message.id}`)).json(), { reactions: {} })
for (const headers of [{ 'x-user': outsider }, { 'x-user': outsider, 'x-tenant': '2' }]) {
assert.ok([403, 404].includes((await app.inject({ url: `${base}/image?messageId=${message.id}`, headers })).statusCode))
assert.ok([403, 404].includes((await react(headers)).statusCode))
}
await fixture.pool.query('UPDATE projects SET chat_enabled=false WHERE id=1')
assert.equal((await app.inject(`${base}/image?messageId=${message.id}`)).statusCode, 404)
assert.equal((await app.inject(`${base}/reactions?ids=${message.id}`)).statusCode, 404)
assert.equal((await react()).statusCode, 404)
} finally { await app.close(); await fixture.close() }
})
test("subroom media respects history, guest read-only rights, revocation and idempotent sends", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase()
const service = new CollaborationService(fixture.db, { publicUrl: 'https://example.test', guestPepper: () => 'test', sendMail: async () => ({ success: true }) })
const ownerId = randomUUID()
try {
await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null)")
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@test.example',true)", [ownerId])
await fixture.pool.query('INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)', [ownerId])
const owner = await service.userContext(ownerId, 1)
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Project','{}',false)")
const room = await service.createRoom(owner, { name: 'Media', projectId: 1 })
const guest = await service.guest(owner, { name: 'Guest', email: 'guest@test.example' })
const old = await service.send(owner.principal, room.id, { clientId: randomUUID(), image: await picture() })
const invitation = await service.invite(owner, room.id, guest, {})
await service.accept(guest, invitation.id, invitation.token)
await assert.rejects(service.image(guest, room.id, { messageId: old.id }), /kein Zugriff/)
await assert.rejects(service.react(guest, room.id, { messageId: old.id, emoji: '👍', active: true }), /kein Zugriff/)
const command = { clientId: randomUUID(), text: '', image: await picture() }
const sent = await service.send(guest, room.id, command)
assert.equal((await service.send(guest, room.id, command)).id, sent.id)
await assert.rejects(service.send(guest, room.id, { ...command, image: { ...command.image, name: 'different.png' } }), /Auftrags-ID/)
assert.equal((await service.messages(guest, room.id)).messages[0].image.dataUrl, undefined)
assert.match((await service.image(guest, room.id, { messageId: sent.id })).dataUrl, /^data:image\/webp/)
await service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: true })
await service.react(owner.principal, room.id, { messageId: sent.id, emoji: '❤️', active: true })
assert.deepEqual((await service.reactions(guest, room.id, { ids: `${old.id},${sent.id}` })).reactions[sent.id], [{ emoji: '❤️', count: 2, own: true }])
const member = (await service.management(owner, room.id)).members.find(m => m.principalId === guest.id)
await service.updateMember(owner, room.id, member.id, { role: 'reader', overviewAllowed: false })
await assert.rejects(service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: false }), /nur lesbar/)
await assert.rejects(service.send(guest, room.id, { clientId: randomUUID(), image: await picture() }), /nur lesbar/)
await service.image(guest, room.id, { messageId: sent.id })
await service.updateRoom(owner, room.id, { status: 'archived' })
await assert.rejects(service.react(owner.principal, room.id, { messageId: sent.id, emoji: '👍', active: true }), /archiviert/)
await service.updateRoom(owner, room.id, { status: 'active' })
await service.revoke(owner, room.id, member.id)
await assert.rejects(service.image(guest, room.id, { messageId: sent.id }), /kein Zugriff/)
await assert.rejects(service.reactions(guest, room.id, { ids: sent.id }), /kein Zugriff/)
} finally { await fixture.close() }
})

View File

@@ -1,7 +1,8 @@
import assert from "node:assert/strict"
import test from "node:test"
import { generateKeyPairSync, randomUUID } from "node:crypto"
import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto"
import Fastify from "fastify"
import sharp from "sharp"
import { CollaborationService } from "../src/modules/collaboration/service"
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
import { instanceUrl, publicAddress, signedHeaders, verifyHeaders, Transport } from "../src/modules/collaboration/transport"
@@ -104,6 +105,38 @@ test("two independent instances exchange invitations and messages through signed
const history = (await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages
assert.deepEqual(history.map((m: any) => m.body), ["Unpolled owner message", command.text])
})
await t.test("image-only messages and reactions cross signed routes, including retries and history restrictions", async () => {
const mediaRoom = await a.service.createRoom(a.ctx, { name: "Media" })
const image = { name: "Foto.webp", dataUrl: `data:image/webp;base64,${(await sharp(randomBytes(700 * 500 * 3), { raw: { width: 700, height: 500, channels: 3 } }).webp({ quality: 85 }).toBuffer()).toString('base64')}` }
assert.ok(image.dataUrl.length > 128 * 1024)
const old = await a.service.send(a.ctx.principal, mediaRoom.id, { clientId: randomUUID(), image })
const invitation = await a.service.invite(a.ctx, mediaRoom.id, contact, {})
const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitation.id}`])).rows[0]
await a.federation.deliver(job)
const binding = (await b.federation.list(b.ctx)).invitations.find(i => i.name === "Media")!.id
await b.federation.accept(b.ctx, binding)
await assert.rejects(b.federation.memberRequest(b.ctx, binding, "image", { messageId: old.id }), /kein Zugriff/)
const command = { clientId: randomUUID(), image }
loseReply = true
assert.equal((await b.federation.memberRequest(b.ctx, binding, "send", command)).result.pending, true)
const confirmed = (await b.federation.memberRequest(b.ctx, binding, "send", command)).result
assert.ok(confirmed.seq)
assert.equal(confirmed.body, "")
assert.equal(confirmed.image.dataUrl, undefined)
assert.equal((await a.service.messages(a.ctx.principal, mediaRoom.id)).messages.length, 2)
const photo = (await b.federation.memberRequest(b.ctx, binding, "image", { messageId: confirmed.id })).result
assert.match(photo.dataUrl, /^data:image\/webp;base64,/)
await b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: true })
await a.service.react(a.ctx.principal, mediaRoom.id, { messageId: confirmed.id, emoji: "🎉", active: true })
const reactions = (await b.federation.memberRequest(b.ctx, binding, "reactions", { ids: confirmed.id })).result.reactions
assert.deepEqual(reactions[confirmed.id], [{ emoji: "🎉", count: 2, own: true }])
await b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: false })
assert.deepEqual((await a.service.reactions(a.ctx.principal, mediaRoom.id, { ids: confirmed.id })).reactions[confirmed.id], [{ emoji: "🎉", count: 1, own: true }])
const member = (await a.service.management(a.ctx, mediaRoom.id)).members.find(m => m.principalId === contact.id)
await a.service.revoke(a.ctx, mediaRoom.id, member.id)
await assert.rejects(b.federation.memberRequest(b.ctx, binding, "image", { messageId: confirmed.id }), /kein Zugriff/)
await assert.rejects(b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: true }))
})
await t.test("only explicit overview is live-shared; offline cache omits it", async () => {
await a.service.updateRoom(a.ctx, room.id, { overview: { title: "Freigegeben", description: "Treffpunkt Tor 2" } })
const live = await b.federation.memberRequest(b.ctx, bindingId, "room")

View File

@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
import Fastify from "fastify"
import jwt from "jsonwebtoken"
import cookie from "@fastify/cookie"
import sharp from "sharp"
import authPlugin from "../src/plugins/auth"
import { secrets } from "../src/utils/secrets"
import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration"
@@ -57,7 +58,19 @@ test("HTTP guest sessions never authorize normal APIs or other subrooms", { skip
assert.equal(sent.statusCode, 200, sent.body)
const stored = await fixture.pool.query("SELECT p.kind FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.id=$1", [sent.json().id])
assert.equal(stored.rows[0].kind, "guest")
const image = { name: "Gastbild.png", dataUrl: `data:image/png;base64,${(await sharp({ create: { width: 2, height: 2, channels: 3, background: 'green' } }).png().toBuffer()).toString('base64')}` }
const photo = await app.inject({ method: "POST", url: `/guest/api/rooms/${roomId}/messages`, headers: guestHeaders,
payload: { clientId: randomUUID(), image } })
assert.equal(photo.statusCode, 200, photo.body)
assert.equal(photo.json().image.dataUrl, undefined)
const imagePath = `/guest/api/rooms/${roomId}/image?messageId=${photo.json().id}`
assert.equal((await app.inject({ url: imagePath, headers: guestHeaders })).statusCode, 200)
const reactionPath = `/guest/api/rooms/${roomId}/reactions`
assert.equal((await app.inject({ method: "PUT", url: reactionPath, headers: guestHeaders,
payload: { messageId: photo.json().id, emoji: '👍', active: true } })).statusCode, 200)
assert.equal((await app.inject({ url: `${reactionPath}?ids=${photo.json().id}`, headers: guestHeaders })).json().reactions[photo.json().id][0].own, true)
assert.equal((await app.inject({ method: "POST", url: "/guest/api/logout", headers: guestHeaders })).statusCode, 200)
assert.equal((await app.inject({ url: "/guest/api/rooms", headers: guestHeaders })).statusCode, 401)
assert.equal((await app.inject({ url: imagePath, headers: guestHeaders })).statusCode, 401)
} finally { secrets.JWT_SECRET = previousSecret; await app.close(); await fixture.close() }
})

View File

@@ -33,7 +33,10 @@ export async function collaborationDatabase(migrations: string[] = []) {
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, name text, locked text, locked_by_export_job_id uuid)`)
await pool.query(`CREATE UNIQUE INDEX ON communication_rooms (tenant_id, key)`)
await pool.query(await readFile(new URL("../../db/migrations/0070_native_communication_chat.sql", import.meta.url), "utf8"))
for (const name of migrations) {
const steps = [...migrations]
if (!steps.includes("0075_project_collaboration.sql")) steps.unshift("0075_project_collaboration.sql")
if (!steps.includes("0081_chat_media_reactions.sql")) steps.push("0081_chat_media_reactions.sql")
for (const name of steps) {
await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8"))
}
return { db, pool, close: async () => {

View File

@@ -289,6 +289,10 @@ Unter **Kommunikation → Chat** erscheinen diese Unterräume ebenfalls in der R
Die Chatansicht nutzt die volle verfügbare Fläche. Über das Augensymbol lassen sich beliebige Räume persönlich ausblenden und unter **Ausgeblendete Räume** wieder einblenden. Diese Auswahl wird pro Benutzer und Mandant gespeichert und bleibt auf anderen Geräten erhalten; Teilnahme, Nachrichten und Benachrichtigungseinstellungen werden dadurch nicht geändert. Unter **Projekt → Bearbeiten → Projektchat aktiviert** können Projektverantwortliche den internen Projektchat für alle Teilnehmer deaktivieren. Er verschwindet aus der Chatliste und ist auch über direkte Aufrufe nicht mehr les- oder beschreibbar. Beim erneuten Aktivieren bleibt der bisherige Verlauf erhalten. Bestehende und neue Projekte starten mit aktiviertem Projektchat. Separat freigegebene Unterräume bleiben unabhängig davon verwaltbar.
Der Chat bietet eine Emoji-Auswahl am Eingabefeld und Reaktionen unter jeder Nachricht. Ein Klick auf eine eigene Reaktion entfernt sie wieder; die Anzahl wird für alle Teilnehmer regelmäßig aktualisiert. Pro Nachricht kann ein Bild mit oder ohne Begleittext gesendet werden: über das Bildsymbol, durch Einfügen aus der Zwischenablage oder per Drag-and-drop. JPEG, PNG und WebP bis 10 MB werden im Browser verkleinert; der Server prüft sie erneut und speichert höchstens 1600 × 1600 Pixel beziehungsweise 512 KiB als WebP ohne ursprüngliche Metadaten. Ein Klick auf das Bild vergrößert die Ansicht. Animierte Bilder und andere Dateiformate sind nicht Bestandteil dieser Funktion.
Bilder und Reaktionen funktionieren in internen Chats, lokalen Unterräumen, Gastchats und über Föderation. Bildabrufe und Reaktionen prüfen jeweils die aktuelle Teilnahme und den Historienbeginn. Nur schreibberechtigte Teilnehmer können Bilder senden oder reagieren; archivierte Räume bleiben lesbar. Die Bilder liegen direkt an der Nachricht in der Datenbank und sind nicht über öffentliche Dateilinks erreichbar. Nachrichtenlisten und Föderationscaches enthalten nur Bildmetadaten; das Bild wird bei Bedarf über die authentifizierte Raum-API abgerufen. Bei nicht erreichbarer Partnerinstanz warten gesendete Bilder wie Textnachrichten auf Zustellung; neue Bildabrufe und Reaktionen benötigen eine Verbindung. Beide Instanzen müssen für die zusätzlichen Funktionen aktualisiert sein.
Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird.
Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff.
@@ -301,7 +305,7 @@ Bei einem vorübergehend nicht erreichbaren Eigentümer sind bereits geladene Na
### Einrichtung
1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql`, `0076_collaboration_federation.sql` und `0078_chat_visibility.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben.
1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql`, `0076_collaboration_federation.sql`, `0078_chat_visibility.sql` und `0081_chat_media_reactions.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben.
2. Die vorhandene `API_BASE_URL` wird als Föderationsadresse verwendet, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Gastlinks werden aus deren Ursprung `https://fedeo.example` abgeleitet. Zusätzliche URL-Variablen sind im Standardbetrieb nicht nötig. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. Den vorhandenen `ENCRYPTION_KEY` beibehalten; er muss ein stabiler 64-stelliger Hex-Schlüssel sein.
3. Der Standard ist jetzt `FEDEO_FEDERATION_ENABLED=auto`, auch wenn die Variable nicht gesetzt ist. Mit gültiger HTTPS-API-Adresse auf Port 443 und gültigem Verschlüsselungsschlüssel wird Föderation automatisch bereitgestellt. Lokale HTTP-Entwicklungsumgebungen oder unvollständige Konfigurationen bleiben ohne Föderation startfähig; die Partnerverwaltung nennt den fehlenden Konfigurationswert. `false` schaltet Föderation ausdrücklich ab. `true` erzwingt die Aktivierung und meldet eine ungültige Konfiguration bereits beim Start. Nur bei abweichender Infrastruktur `FEDEO_FEDERATION_URL` oder `FEDEO_PUBLIC_URL` setzen; beide überschreiben die automatisch abgeleiteten Werte. Ein separates Föderationsgateway verändert die Gast-Webadresse nicht automatisch.
4. Der Proxy muss `GET <API-Adresse>/.well-known/fedeo-federation` und `POST <API-Adresse>/federation/v1/rpc` durchreichen und darf JSON-Body oder Signaturheader nicht verändern. Externe Ziele benötigen öffentlich auflösbare HTTPS-Adressen auf Port 443. Private/reservierte IPv4- und IPv6-Ziele sowie Weiterleitungen werden abgewiesen; die geprüfte DNS-Adresse wird für die TLS-Verbindung festgehalten.
@@ -310,10 +314,12 @@ Bei einem vorübergehend nicht erreichbaren Eigentümer sind bereits geladene Na
Der normale Selfhost-Updater übernimmt die Compose-Datei und Migrationen aus dem Zielrelease; ein manuelles Nachtragen der Föderationsvariablen ist bei verwalteten Installationen nicht erforderlich. Die vorhandene `.env` wird bis auf die Release-Version beibehalten. Eine darin oder in der Override-Datei gesetzte Abschaltung bleibt wirksam. Wurde `FEDEO_FEDERATION_ENABLED=false` aus der früheren Beispielkonfiguration übernommen, muss dieser Wert einmal entfernt oder auf `auto` gesetzt werden, wenn Föderation gewünscht ist. Die automatische Bereitstellung gibt keine Partner oder vorhandenen Chats frei; dafür bleiben die gegenseitige Mandantenfreigabe und explizite Einladung erforderlich.
Das feste Signaturprofil verwendet Ed25519 mit HTTP Message Signatures und Content-Digest. Signiert werden Methode, vollständige Zieladresse, Inhaltstyp, Inhaltsdigest und Herkunft; Zeitfenster und gespeicherte Nonces verhindern Replay. Das RPC-Protokoll ist Version 1 und ausschließlich für diese FEDEO-Implementierung bestimmt. Es ersetzt weder Matrix-Kompatibilität noch Ende-zu-Ende-Verschlüsselung.
Das feste Signaturprofil verwendet Ed25519 mit HTTP Message Signatures und Content-Digest. Signiert werden Methode, vollständige Zieladresse, Inhaltstyp, Inhaltsdigest und Herkunft; Zeitfenster und gespeicherte Nonces verhindern Replay. RPC-Anfragen und Antworten sind auf 1 MiB begrenzt. Die zusätzlichen Aktionen heißen `image.get`, `reactions.get` und `reactions.set`; `messages.send` akzeptiert optional ein Bild. Das RPC-Protokoll ist Version 1 und ausschließlich für diese FEDEO-Implementierung bestimmt. Es ersetzt weder Matrix-Kompatibilität noch Ende-zu-Ende-Verschlüsselung.
### Speicherung, Betrieb und Wiederaufnahme
Migration `0081_chat_media_reactions.sql` ergänzt Bildspalten an `communication_messages` und `collaboration_messages` sowie die Tabellen `communication_message_reactions` und `collaboration_message_reactions`. Die Bilder werden mit der bestehenden Datenbanksicherung erfasst.
Die tatsächlichen Tabellen heißen `collaboration_workspaces`, `collaboration_principals`, `collaboration_rooms`, `collaboration_members`, `collaboration_invitations`, `collaboration_messages` sowie die zugehörigen Gast- und Audittabellen. Der Föderationsteil ergänzt `collaboration_federation_identity`, `collaboration_partners`, `collaboration_federation_nonces`, `collaboration_remote_bindings`, `collaboration_remote_messages` und `collaboration_outbox`. Lokale Identitäten werden bei Nutzung erzeugt; es gibt keinen automatischen Versand oder Backfill bestehender Chatinhalte.
Der Worker läuft alle fünf Sekunden, beansprucht Aufträge mit einer zeitlich begrenzten Sperre und wiederholt vorübergehende Fehler mit wachsender Wartezeit. Dauerhafte Ablehnungen oder 20 erfolglose Versuche beenden die Zustellung. Einladung und ausgehender Zustellauftrag werden atomar gespeichert. Nachrichtencache und Synchronisationsposition sind getrennt, damit eine sofort bestätigte eigene Nachricht keine noch nicht abgeholten Nachrichten überspringt. Gastcodes, abgelaufene Sitzungen, Nonces und alte Ratenzähler werden regelmäßig bereinigt. Für Nachrichten, Audit und Zustellhistorie ist noch keine automatische Aufbewahrungsrichtlinie implementiert.
@@ -329,10 +335,10 @@ Reproduzierbar aus `backend/` mit einer ausdrücklich bereitgestellten, entbehrl
```sh
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts
```
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Eine visuelle Browserabnahme war in der Arbeitsumgebung mangels verbundenem Browser nicht möglich.
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Die spätere Erweiterung um Emojis, Bilder und Reaktionen wurde zusätzlich mit einer lokalen Browser-Vorschau der tatsächlichen Vue-Komponenten und simulierten API-Antworten geprüft. Die automatisierten Medientests prüfen unter anderem Bildvalidierung, Bildnachrichten ohne Text, Reaktionszähler, Historiengrenzen, Gastrechte, deaktivierte Projektchats und signierte Zustellung zwischen zwei Instanzen.
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Push-Benachrichtigungen, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.

View File

@@ -0,0 +1,65 @@
<script setup>
import { prepareChatImage } from '~/utils/chat-image'
const props = defineProps({ modelValue: { type: String, default: '' }, image: { type: Object, default: null }, busy: Boolean, disabled: Boolean,
placeholder: { type: String, default: 'Nachricht schreiben …' } })
const emit = defineEmits(['update:modelValue', 'update:image', 'send'])
const input = ref(null), textarea = ref(null), preparing = ref(false), error = ref(''), emojisOpen = ref(false)
const emojis = ['😀', '😊', '😂', '🥰', '😎', '🤔', '😮', '😢', '😅', '🙈', '👍', '👎', '👋', '🙏', '💪', '👏', '❤️', '🎉', '🔥', '✅', '❌', '🚀', '💡', '📅', '📷', '☕', '🎂', '🎁', '🛠️', '🏗️', '🤝', '👌']
let generation = 0
const locked = computed(() => props.disabled || props.busy || preparing.value)
function send(event) {
if (event?.isComposing || locked.value || (!props.modelValue.trim() && !props.image)) return
emojisOpen.value = false; emit('send')
}
async function insertEmoji(emoji) {
if (locked.value) return
const start = textarea.value?.selectionStart ?? props.modelValue.length, end = textarea.value?.selectionEnd ?? start
const value = props.modelValue.slice(0, start) + emoji + props.modelValue.slice(end)
if (value.length > 10000) return
emit('update:modelValue', value); emojisOpen.value = false
await nextTick(); textarea.value?.focus(); textarea.value?.setSelectionRange(start + emoji.length, start + emoji.length)
}
async function attach(file) {
if (!file || locked.value) return
const version = ++generation
preparing.value = true; error.value = ''
try {
const image = await prepareChatImage(file)
if (version === generation && !props.disabled) emit('update:image', image)
} catch (e) { if (version === generation) error.value = e.message || 'Das Bild konnte nicht gelesen werden.' }
finally { if (version === generation) preparing.value = false }
}
function paste(event) {
const image = [...(event.clipboardData?.files || [])].find(file => file.type.startsWith('image/'))
if (image) { event.preventDefault(); void attach(image) }
}
function drop(event) {
if (event.dataTransfer?.files?.length) { event.preventDefault(); void attach(event.dataTransfer.files[0]) }
}
watch(() => props.disabled, disabled => { if (disabled) { ++generation; preparing.value = false; emojisOpen.value = false } })
onBeforeUnmount(() => { ++generation })
</script>
<template>
<form class="shrink-0 space-y-2 border-t border-gray-200 bg-white p-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 sm:p-4" @submit.prevent="send" @dragover.prevent @drop="drop">
<p v-if="error" role="alert" class="text-sm text-red-700">{{ error }}</p>
<div v-if="image" class="flex items-center gap-3 rounded-lg bg-gray-50 p-2 dark:bg-gray-800">
<img :src="image.dataUrl" :alt="image.name" class="size-16 rounded object-contain" />
<span class="min-w-0 flex-1 truncate text-xs">{{ image.name }}</span>
<UButton icon="i-heroicons-x-mark" aria-label="Bild entfernen" color="neutral" variant="ghost" :disabled="locked" @click="emit('update:image', null)" />
</div>
<div v-if="emojisOpen" class="flex max-h-32 flex-wrap gap-1 overflow-y-auto rounded-lg border border-gray-200 p-2" role="group" aria-label="Emoji auswählen">
<button v-for="emoji in emojis" :key="emoji" type="button" :aria-label="`${emoji} einfügen`" class="rounded p-2 text-xl hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-green-600" @click="insertEmoji(emoji)">{{ emoji }}</button>
</div>
<label class="block"><span class="sr-only">Nachricht</span><textarea ref="textarea" :value="modelValue" rows="2" maxlength="10000" :disabled="locked" :placeholder="placeholder"
class="max-h-36 w-full resize-y rounded-lg border border-gray-300 bg-transparent p-2 text-sm dark:border-gray-600" @input="emit('update:modelValue', $event.target.value)" @keydown.enter.exact.prevent="send" @paste="paste" /></label>
<div class="flex items-center gap-1">
<UButton icon="i-heroicons-face-smile" aria-label="Emojis auswählen" title="Emojis" color="neutral" variant="ghost" :aria-expanded="emojisOpen" :disabled="locked" @click="emojisOpen = !emojisOpen" />
<UButton icon="i-heroicons-photo" aria-label="Bild anhängen" title="Bild anhängen (JPEG, PNG, WebP, bis 10 MB)" color="neutral" variant="ghost" :loading="preparing" :disabled="locked" @click="input?.click()" />
<input ref="input" type="file" accept="image/jpeg,image/png,image/webp" class="hidden" aria-label="Bilddatei" :disabled="locked" @change="attach($event.target.files?.[0]); $event.target.value = ''" />
<span class="hidden min-w-0 flex-1 text-xs text-gray-400 sm:block">Enter sendet · Umschalt+Enter für neue Zeile</span>
<UButton type="submit" class="ml-auto" icon="i-heroicons-paper-airplane" :loading="busy" :disabled="locked || (!modelValue.trim() && !image)">Senden</UButton>
</div>
</form>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
const props = defineProps({ api: { type: Function, required: true }, endpoint: { type: String, required: true }, message: { type: Object, required: true } })
const dataUrl = ref(''), error = ref(''), loading = ref(false), expanded = ref(false)
// Reserve dimensions while fetching so a new photo does not move below the composer.
const imageStyle = computed(() => {
const { width, height } = props.message.image
const scale = expanded.value ? 1 : Math.min(1, 320 / width, 256 / height)
return { width: `${Math.round(width * scale)}px`, aspectRatio: `${width} / ${height}`, maxHeight: expanded.value ? '70vh' : undefined }
})
let generation = 0
async function load() {
const version = ++generation
dataUrl.value = ''; error.value = ''; expanded.value = false; loading.value = false
if (props.message.pending || props.message.failed) return
loading.value = true
try {
const result = await props.api(`${props.endpoint}/image?messageId=${encodeURIComponent(props.message.id)}`)
if (version !== generation) return
// Even a partner instance can only supply bounded raster data, never an external tracking URL.
if (typeof result.dataUrl !== 'string' || result.dataUrl.length > 700000 || !/^data:image\/(webp|png|jpeg);base64,[A-Za-z0-9+/]+=*$/.test(result.dataUrl)) throw new Error('Ungültiges Bild')
dataUrl.value = result.dataUrl
} catch { if (version === generation) error.value = 'Bild derzeit nicht verfügbar.' }
finally { if (version === generation) loading.value = false }
}
watch(() => [props.endpoint, props.message.id, props.message.pending, props.message.failed], load, { immediate: true })
onBeforeUnmount(() => { ++generation; dataUrl.value = '' })
</script>
<template>
<div class="my-2 max-w-full" :style="imageStyle">
<button v-if="dataUrl" type="button" class="block max-w-full rounded-lg focus-visible:ring-2" :aria-label="expanded ? 'Bild verkleinern' : 'Bild vergrößern'" :aria-expanded="expanded" @click="expanded = !expanded">
<img :src="dataUrl" :alt="message.image.name" :width="message.image.width" :height="message.image.height" class="h-auto max-w-full rounded-lg object-contain" :class="expanded ? 'max-h-[70vh]' : 'max-h-64'" />
</button>
<p v-else-if="loading" class="text-xs opacity-70" role="status">Bild wird geladen …</p>
<p v-else-if="message.pending || message.failed" class="text-xs opacity-70">📷 {{ message.image.name }} · {{ message.failed ? 'Nicht zugestellt' : 'Wartet auf Zustellung' }}</p>
<button v-else-if="error" type="button" class="text-xs underline" @click="load">{{ error }} Erneut laden</button>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
const props = defineProps({ reactions: { type: Array, default: () => [] }, disabled: Boolean, busy: Boolean })
const emit = defineEmits(['react'])
const open = ref(false)
const emojis = ['👍', '❤️', '😂', '🎉', '😮', '😢', '🙏', '🔥', '✅', '👎']
function react(emoji) { if (!props.disabled && !props.busy) { open.value = false; emit('react', emoji) } }
</script>
<template>
<div class="mt-2 flex max-w-full flex-wrap items-center gap-1 text-gray-700 dark:text-gray-200">
<button v-for="reaction in reactions" :key="reaction.emoji" type="button" class="rounded-full border px-2 py-0.5 text-xs"
:class="reaction.own ? 'border-green-400 bg-green-100 text-green-900' : 'border-gray-200 bg-white text-gray-700'"
:aria-pressed="reaction.own" :aria-label="`${reaction.emoji}: ${reaction.count}${reaction.own ? ', eigene Reaktion entfernen' : ', reagieren'}`" :disabled="disabled || busy" @click="react(reaction.emoji)">{{ reaction.emoji }} {{ reaction.count }}</button>
<button v-if="!disabled" type="button" class="rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-100" aria-label="Reaktion hinzufügen" :aria-expanded="open" :disabled="busy" @click="open = !open">☺ +</button>
<div v-if="open && !disabled" class="flex basis-full flex-wrap gap-1 rounded-lg bg-white p-1" role="group" aria-label="Reaktion auswählen">
<button v-for="emoji in emojis" :key="emoji" type="button" :aria-label="`Mit ${emoji} reagieren`" class="rounded p-1 hover:bg-gray-100" :disabled="busy" @click="react(emoji)">{{ emoji }}</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup>
const props = defineProps({ api: { type: Function, required: true }, endpoint: { type: String, required: true }, messages: { type: Array, required: true }, disabled: Boolean })
const emit = defineEmits(['error'])
const reactions = ref({}), busy = ref(false)
let timer, disposed = false, fetching = false, revision = 0
const ids = computed(() => props.messages.filter(m => !m.pending && !m.failed).map(m => m.id))
async function refresh() {
if (disposed || fetching || busy.value || document.hidden) return
const version = ++revision, endpoint = props.endpoint, list = [...ids.value]
fetching = true
try {
const next = {}
for (let index = 0; index < list.length; index += 100) {
const result = await props.api(`${endpoint}/reactions?ids=${encodeURIComponent(list.slice(index, index + 100).join(','))}`)
Object.assign(next, result.reactions || {})
if (disposed || version !== revision || props.endpoint !== endpoint) return
}
if (!disposed && version === revision) reactions.value = next
} catch { /* Temporary polling errors must not obscure messages. */ }
finally { fetching = false }
}
async function react(messageId, emoji) {
if (disposed || busy.value || props.disabled || !ids.value.includes(messageId)) return
const version = ++revision, endpoint = props.endpoint
const active = !(reactions.value[messageId] || []).some(r => r.emoji === emoji && r.own)
busy.value = true
try {
const result = await props.api(`${endpoint}/reactions`, { method: 'PUT', body: { messageId, emoji, active } })
if (!disposed && version === revision && endpoint === props.endpoint) reactions.value = { ...reactions.value, [messageId]: result.reactions?.[messageId] || [] }
} catch (error) { if (!disposed && version === revision) emit('error', error) }
finally { if (!disposed) busy.value = false }
}
watch(() => props.endpoint, () => { ++revision; reactions.value = {} })
watch(ids, () => { void refresh() }, { immediate: true })
onMounted(() => { timer = setInterval(refresh, 3000) })
onBeforeUnmount(() => { disposed = true; ++revision; clearInterval(timer) })
</script>
<template><slot :reactions="reactions" :react="react" :busy="busy" /></template>

View File

@@ -1,7 +1,7 @@
<script setup>
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
const emit = defineEmits(['changed', 'unauthorized'])
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('')
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
let generation = 0, timer, polling = false, pending = null
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
@@ -20,7 +20,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
function failed(e) {
error.value = messageError(e)
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
room.value = null; messages.value = []; members.value = []
room.value = null; messages.value = []; members.value = []; draftImage.value = null
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
}
}
@@ -32,8 +32,8 @@ async function read(id, version) {
}
async function load() {
const version = ++generation, id = props.roomId
room.value = null; messages.value = []; members.value = []; draft.value = ''; error.value = ''; pending = null
loading.value = true
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
loading.value = true; sending.value = false; olderLoading.value = false
try {
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
if (version !== generation) return
@@ -75,18 +75,18 @@ async function older() {
finally { olderLoading.value = false }
}
async function send() {
const text = draft.value.trim()
if (!text || sending.value || !room.value?.canSend) return
const text = draft.value.trim(), image = draftImage.value
if ((!text && !image) || sending.value || !room.value?.canSend) return
const version = generation, id = props.roomId
if (!pending || pending.text !== text) pending = { text, clientId: crypto.randomUUID() }
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() }
sending.value = true; error.value = ''
try {
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
if (version !== generation) return
merge([message]); draft.value = ''; pending = null
merge([message]); draft.value = ''; draftImage.value = null; pending = null
await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) }
finally { sending.value = false }
finally { if (version === generation) sending.value = false }
}
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
watch(() => props.roomId, load, { immediate: true })
@@ -114,17 +114,18 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
<ChatReactions :key="roomId" v-slot="{ reactions, react, busy }" :api="api" :endpoint="endpoint(roomId)" :messages="messages" :disabled="!room?.canSend || room?.offline" @error="error = messageError($event)">
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] rounded-xl px-4 py-2" :class="message.own ? 'bg-green-700 text-white' : 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'">
<p class="text-xs font-semibold">{{ message.own ? 'Du' : message.senderName }}</p>
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<ChatImage v-if="message.image" :api="api" :endpoint="endpoint(roomId)" :message="message" />
<p v-if="message.body" class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}{{ message.pending ? ' · Wartet auf Zustellung' : message.failed ? ' · Zustellung fehlgeschlagen' : ' · Bestätigt' }}</p>
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" :disabled="!room?.canSend || room?.offline || !!message.pending || !!message.failed" @react="react(message.id, $event)" />
</div>
</div>
</ChatReactions>
</div>
<form v-if="room" class="flex items-end gap-3 border-t border-gray-200 p-4 dark:border-gray-700" @submit.prevent="send">
<label class="min-w-0 flex-1"><span class="sr-only">Nachricht</span><textarea v-model="draft" rows="2" maxlength="10000" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" class="w-full resize-none rounded-lg border border-gray-300 p-2 text-sm dark:border-gray-600" @keydown.enter.exact.prevent="send" /></label>
<UButton type="submit" :loading="sending" :disabled="!room.canSend || !draft.trim()">Senden</UButton>
</form>
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
</section>
</template>

View File

@@ -7,7 +7,7 @@ const router = useRouter()
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref('')
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null)
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
const activeRoomKey = ref(requestedRoom())
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
@@ -49,7 +49,7 @@ const activeRoom = computed(() => visibleRooms.value.find(room => room.key === a
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
const isInternal = computed(() => activeRoom.value?.source === 'internal')
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function mergeMessages(incoming) {
const byId = new Map(messages.value.map(message => [message.id, message]))
for (const message of incoming || []) byId.set(message.id, message)
@@ -208,13 +208,13 @@ async function respondToInvitation(id, accept) {
finally { if (currentScope(scope)) invitationBusy.value = '' }
}
async function sendMessage() {
const text = draft.value.trim(), request = selection()
if (!text || sending.value || !isInternal.value || !activeRoom.value?.exists) return
const text = draft.value.trim(), image = draftImage.value, request = selection()
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
sending.value = true; draft.value = ''
try {
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text } })
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
if (!currentSelection(request)) return
mergeMessages([message]); await markRead(request); await scrollToBottom(request)
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request)
} catch (error) {
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
} finally { if (currentSelection(request)) sending.value = false }
@@ -371,21 +371,22 @@ onBeforeUnmount(() => {
<p class="font-medium">Noch keine Nachrichten</p>
<p class="text-sm">Beginne die Unterhaltung.</p>
</div>
<div v-else class="space-y-4">
<ChatReactions v-else :key="`${session}:${activeRoomKey}`" v-slot="{ reactions, react, busy }" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :messages="messages" @error="error => toast.add({ title: errorMessage(error), color: 'error' })">
<div class="space-y-4">
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
<div class="max-w-[80%] rounded-2xl px-4 py-2" :class="message.own ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-900'">
<div v-if="!message.own" class="mb-1 text-xs font-semibold text-primary-600">{{ message.senderDisplayName }}</div>
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<ChatImage v-if="message.image" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :message="message" />
<p v-if="message.body" class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<div class="mt-1 text-right text-[11px]" :class="message.own ? 'text-primary-100' : 'text-gray-400'">{{ formatMessageTime(message.timestamp) }}</div>
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" @react="react(message.id, $event)" />
</div>
</div>
</div>
</ChatReactions>
</div>
<form class="flex items-end gap-3 border-t border-gray-200 p-4" @submit.prevent="sendMessage">
<UTextarea v-model="draft" class="flex-1" autoresize :rows="1" :maxrows="6" placeholder="Nachricht schreiben …" :disabled="!activeRoom?.exists" @keydown.enter.exact.prevent="sendMessage" />
<UButton type="submit" icon="i-heroicons-paper-airplane" :loading="sending" :disabled="!draft.trim() || !activeRoom?.exists">Senden</UButton>
</form>
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
</main>
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">

View File

@@ -356,3 +356,24 @@ test('returning to chat immediately reflects a disabled or re-enabled project wi
await settle()
assert.equal(ui.run('groupedRooms.value.find(group => group.label === "Projekte").rooms[0].key'), 'project_1')
})
test('native image-only sends retain the draft after an error and clear it after success', async t => {
let fail = true
const ui = setup(fixture((url, options) => {
if (url.endsWith('/messages') && options?.method === 'POST') {
if (fail) throw { status: 503, data: { message: 'Offline' } }
return { id: 2, body: '', image: { name: 'Bild', width: 80, height: 40 }, own: true }
}
}))
t.after(ui.close)
await ui.run('initialize()')
ui.run('draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Bild" }')
await ui.run('sendMessage()')
assert.equal(ui.run('draftImage.value.name'), 'Bild')
fail = false
await ui.run('sendMessage()')
assert.equal(ui.run('messages.value.at(-1).image.name'), 'Bild')
assert.equal(ui.run('draftImage.value'), null)
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
})

View File

@@ -0,0 +1,91 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
const settle = () => new Promise(setImmediate)
const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } }
function setup(component, values, extra = {}) {
const props = vue.reactive(values), events = [], scope = vue.effectScope()
let unmount = () => {}
const source = fs.readFileSync(`${__dirname}/../components/${component}.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/mg, '')
const context = vm.createContext({ ref: vue.ref, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
defineProps: () => props, defineEmits: () => (name, value) => {
events.push({ name, value })
if (name === 'update:modelValue') props.modelValue = value
if (name === 'update:image') props.image = value
}, onMounted: () => {}, onBeforeUnmount: handler => { unmount = handler }, clearInterval: () => {}, document: { hidden: false }, ...extra })
scope.run(() => vm.runInContext(source, context))
return { props, events, run: code => vm.runInContext(code, context), close: () => { unmount(); scope.stop() } }
}
test('composer inserts emoji at selection and sends image-only messages while respecting disabled state', async t => {
const ui = setup('ChatComposer', { modelValue: 'Hallo Welt', image: null, busy: false, disabled: false }, { prepareChatImage: async file => ({ dataUrl: 'data:image/webp;base64,AAAA', name: file.name }) })
t.after(ui.close)
ui.run('textarea.value = { selectionStart: 6, selectionEnd: 10, focus() {}, setSelectionRange() {} }')
await ui.run('insertEmoji("👋")')
assert.equal(ui.props.modelValue, 'Hallo 👋')
ui.props.modelValue = ''
ui.run('send()')
assert.equal(ui.events.some(e => e.name === 'send'), false)
await ui.run('attach({ name: "Bild.png" })')
ui.run('send()')
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
ui.props.disabled = true
ui.run('send()')
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
})
test('a late image conversion cannot attach a draft after the room unmounts', async () => {
const pending = deferred()
const ui = setup('ChatComposer', { modelValue: '', image: null, busy: false, disabled: false }, { prepareChatImage: () => pending.promise })
const attaching = ui.run('attach({ name: "Bild.png" })')
assert.equal(ui.run('preparing.value'), true)
ui.close()
pending.resolve({ dataUrl: 'private image', name: 'Bild.png' })
await attaching
assert.equal(ui.props.image, null)
assert.equal(ui.events.length, 0)
})
test('reaction refresh batches messages and stale poll results cannot overwrite an own reaction', async t => {
const pending = deferred(), calls = []
let delay = false
const ui = setup('ChatReactions', { endpoint: '/room/a', messages: [{ id: 1 }, { id: 2, pending: true }], disabled: false,
api: async (url, options) => {
calls.push({ url, options })
if (options?.method === 'PUT') return { reactions: { 1: [{ emoji: '👍', count: 1, own: true }] } }
if (delay) return pending.promise
return { reactions: {} }
} })
t.after(ui.close)
await settle()
assert.equal(calls[0].url, '/room/a/reactions?ids=1')
delay = true
const polling = ui.run('refresh()')
await ui.run('react(1, "👍")')
pending.resolve({ reactions: {} })
await polling
assert.equal(ui.run('reactions.value[1][0].own'), true)
assert.equal(calls.find(c => c.options?.method === 'PUT').options.body.active, true)
ui.props.disabled = true
const count = calls.length
await ui.run('react(1, "👍")')
assert.equal(calls.length, count)
})
test('image display rejects external URLs and ignores responses after a room change', async t => {
const pending = deferred()
const ui = setup('ChatImage', { endpoint: '/room/a', message: { id: 1 }, api: () => pending.promise })
t.after(ui.close)
ui.props.endpoint = '/room/b'
await vue.nextTick()
pending.resolve({ dataUrl: 'https://tracking.example/image.png' })
await settle()
assert.equal(ui.run('dataUrl.value'), '')
assert.equal(ui.run('error.value'), 'Bild derzeit nicht verfügbar.')
ui.props.api = async () => ({ dataUrl: 'data:image/webp;base64,AAAA' })
await ui.run('load()')
assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA')
})

View File

@@ -0,0 +1,16 @@
export async function prepareChatImage(file) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) throw new Error('Bitte ein JPEG-, PNG- oder WebP-Bild auswählen.')
if (file.size > 10 * 1024 * 1024) throw new Error('Das Originalbild darf höchstens 10 MB groß sein.')
const bitmap = await createImageBitmap(file)
try {
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(bitmap.width * scale)); canvas.height = Math.max(1, Math.round(bitmap.height * scale))
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
for (const quality of [0.85, 0.7, 0.55, 0.4]) {
const dataUrl = canvas.toDataURL('image/webp', quality)
if (dataUrl.length <= 480_000) return { dataUrl, name: (file.name || 'Bild').slice(0, 200) }
}
throw new Error('Dieses Bild ist zu groß. Bitte einen kleineren Ausschnitt verwenden.')
} finally { bitmap.close() }
}