feat(communication): add chat reactions emojis and images
This commit is contained in:
22
backend/db/migrations/0081_chat_media_reactions.sql
Normal file
22
backend/db/migrations/0081_chat_media_reactions.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -547,6 +547,13 @@
|
|||||||
"when": 1791158400000,
|
"when": 1791158400000,
|
||||||
"tag": "0080_staff_time_spans",
|
"tag": "0080_staff_time_spans",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 78,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791244800000,
|
||||||
|
"tag": "0081_chat_media_reactions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
19
backend/db/schema/chat_reactions.ts
Normal file
19
backend/db/schema/chat_reactions.ts
Normal 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`) }))
|
||||||
@@ -50,10 +50,10 @@ export const collaborationMembers = pgTable("collaboration_members", {
|
|||||||
export const collaborationMessages = pgTable("collaboration_messages", {
|
export const collaborationMessages = pgTable("collaboration_messages", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
|
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),
|
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),
|
}, 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),
|
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", {
|
export const collaborationInvitations = pgTable("collaboration_invitations", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
|
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(),
|
targetPrincipalId: uuid("target_principal_id").notNull().references(() => collaborationPrincipals.id), tokenHash: text("token_hash").notNull().unique(),
|
||||||
|
|||||||
@@ -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 { authUsers } from "./auth_users"
|
||||||
import { communicationRooms } from "./communication_rooms"
|
import { communicationRooms } from "./communication_rooms"
|
||||||
@@ -20,6 +20,7 @@ export const communicationMessages = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => authUsers.id),
|
.references(() => authUsers.id),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
|
image: jsonb("image"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
|
|||||||
@@ -106,3 +106,5 @@ export * from "./collaboration";
|
|||||||
|
|
||||||
export * from "./collaboration_federation"
|
export * from "./collaboration_federation"
|
||||||
export * from './staff_time_spans'
|
export * from './staff_time_spans'
|
||||||
|
|
||||||
|
export * from "./chat_reactions"
|
||||||
|
|||||||
90
backend/src/modules/chat-content.ts
Normal file
90
backend/src/modules/chat-content.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { chatContent, imageMetadata } from "../chat-content"
|
||||||
import { generateKeyPairSync, randomUUID } from "node:crypto"
|
import { generateKeyPairSync, randomUUID } from "node:crypto"
|
||||||
import { sql } from "drizzle-orm"
|
import { sql } from "drizzle-orm"
|
||||||
import type { FastifyInstance } from "fastify"
|
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 *`)
|
ON CONFLICT(dedup_key) DO UPDATE SET dedup_key=excluded.dedup_key RETURNING *`)
|
||||||
if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) {
|
if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) {
|
||||||
// JSONB key order differs: compare the actual command values instead of its serialization.
|
// 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
|
return job
|
||||||
}
|
}
|
||||||
@@ -223,6 +226,9 @@ export class FederationService {
|
|||||||
if (action === "room.get") return this.service.room(principal, room.id)
|
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.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 === "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 === "members.get") return { members: await this.service.members(principal, room.id) }
|
||||||
if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq)
|
if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq)
|
||||||
fail("Unbekannte Föderationsaktion")
|
fail("Unbekannte Föderationsaktion")
|
||||||
@@ -300,7 +306,7 @@ export class FederationService {
|
|||||||
}
|
}
|
||||||
async cache(binding: any, messages: any[], snapshot?: any) {
|
async cache(binding: any, messages: any[], snapshot?: any) {
|
||||||
if (!Array.isArray(messages) || messages.length > 50) fail("Ungültige Nachrichtenantwort", 502)
|
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',
|
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() }))
|
own: m.own === true, createdAt: new Date(m.createdAt).toISOString() }))
|
||||||
await this.service.db.transaction(async db => {
|
await this.service.db.transaction(async db => {
|
||||||
@@ -326,8 +332,9 @@ export class FederationService {
|
|||||||
await this.allowed(ctx.tenantId, binding.origin)
|
await this.allowed(ctx.tenantId, binding.origin)
|
||||||
if (action === "send") {
|
if (action === "send") {
|
||||||
if (!binding.snapshot?.canSend) fail("Unterraum ist nicht zum Schreiben freigegeben", 403)
|
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,
|
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,
|
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 })
|
action: 'messages.send', payload: command, bindingId: binding.id })
|
||||||
await this.deliver(job)
|
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)
|
if (currentJob.status === 'failed') fail(currentJob.last_error || "Nachricht konnte nicht gesendet werden", 409)
|
||||||
return { found: true, result: message?.payload || this.pendingMessage(currentJob) }
|
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)
|
const detail = await this.sync(ctx, binding)
|
||||||
binding = await this.binding(ctx, id)
|
binding = await this.binding(ctx, id)
|
||||||
if (action === "room") return { found: true, result: detail }
|
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 } }
|
return { found: true, result: { messages, hasMore: page.length === 50, offline: detail.offline } }
|
||||||
}
|
}
|
||||||
pendingMessage(job: any) {
|
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' }
|
createdAt: job.created_at, own: true, senderName: 'Du', senderKind: 'user', pending: job.status === 'pending', failed: job.status === 'failed' }
|
||||||
}
|
}
|
||||||
async deliver(job: any) {
|
async deliver(job: any) {
|
||||||
@@ -433,12 +454,12 @@ export class FederationService {
|
|||||||
|
|
||||||
export async function federationRoutes(server: FastifyInstance, { federation }: { federation: FederationService }) {
|
export async function federationRoutes(server: FastifyInstance, { federation }: { federation: FederationService }) {
|
||||||
server.removeContentTypeParser('application/json')
|
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
|
req.federationBody = body
|
||||||
try { done(null, JSON.parse(body)) } catch { done(Object.assign(new Error('Ungültiges JSON'), { statusCode: 400 })) }
|
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.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')
|
reply.header('Cache-Control','no-store')
|
||||||
// Duplicate signature-bearing fields are rejected rather than silently combined.
|
// 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())
|
const names = req.raw.rawHeaders.filter((_: string, index: number) => index % 2 === 0).map((name: string) => name.toLowerCase())
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { sql } from "drizzle-orm"
|
|||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
|
||||||
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
|
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">
|
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 const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
|
||||||
export type CollaborationOptions = {
|
export type CollaborationOptions = {
|
||||||
@@ -172,7 +174,7 @@ export class CollaborationService {
|
|||||||
const room = await this.roomRow(this.db, roomId)
|
const room = await this.roomRow(this.db, roomId)
|
||||||
const member = await this.membership(this.db, room, principal)
|
const member = await this.membership(this.db, room, principal)
|
||||||
const after = sequence(query.after), before = sequence(query.before)
|
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
|
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
|
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}
|
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) {
|
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) => {
|
return this.db.transaction(async (db) => {
|
||||||
const room = await this.roomRow(db, roomId, true)
|
const room = await this.roomRow(db, roomId, true)
|
||||||
const member = await this.membership(db, room, principal, 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)
|
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}`)
|
WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.body !== body) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409)
|
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, own: true, senderName: principal.display_name, senderKind: principal.kind }
|
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 [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)
|
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}) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
|
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)
|
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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll
|
|||||||
if (action === "room") return service.room(ctx.principal, id)
|
if (action === "room") return service.room(ctx.principal, id)
|
||||||
if (action === "messages") return service.messages(ctx.principal, id, input)
|
if (action === "messages") return service.messages(ctx.principal, id, input)
|
||||||
if (action === "send") return service.send(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)
|
if (action === "read") return service.markRead(ctx.principal, id, input.seq)
|
||||||
return { members: await service.members(ctx.principal, id) }
|
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.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/messages", async (req) => request(req, "send"))
|
||||||
server.post("/collaboration/rooms/:id/read", async (req) => request(req, "read"))
|
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"))
|
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/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.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.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))
|
server.post("/guest/api/rooms/:id/read", async (req: any) => service.markRead(await principal(req), req.params.id, body(req).seq))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access"
|
import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access"
|
||||||
|
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../modules/chat-content"
|
||||||
|
|
||||||
type ChatUser = {
|
type ChatUser = {
|
||||||
userId: string
|
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 messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
|
||||||
const conditions = [eq(communicationMessages.roomId, roomId)]
|
const conditions = [eq(communicationMessages.roomId, roomId)]
|
||||||
if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId))
|
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))
|
.where(and(...conditions))
|
||||||
.orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id))
|
.orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id))
|
||||||
.limit(Math.min(Math.max(limit, 1), 100))
|
.limit(Math.min(Math.max(limit, 1), 100))
|
||||||
@@ -181,6 +185,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
return orderedRows.map((message) => ({
|
return orderedRows.map((message) => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
body: message.body,
|
body: message.body,
|
||||||
|
image: imageMetadata(message.image),
|
||||||
sender: message.authorUserId,
|
sender: message.authorUserId,
|
||||||
senderDisplayName: displayName(authorById.get(message.authorUserId) || {}),
|
senderDisplayName: displayName(authorById.get(message.authorUserId) || {}),
|
||||||
timestamp: message.createdAt.getTime(),
|
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 }
|
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 tenantId = await requireTenant(req)
|
||||||
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
|
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
|
||||||
const body = String(req.body?.text || "").trim()
|
const { body, image } = await chatContent(req.body)
|
||||||
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 [created] = await server.db.insert(communicationMessages).values({
|
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()
|
}).returning()
|
||||||
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body })
|
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body: body || "📷 Bild" })
|
||||||
return { id: created.id, body: created.body, sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true }
|
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) => {
|
server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => {
|
||||||
|
|||||||
117
backend/tests/chatContent.test.ts
Normal file
117
backend/tests/chatContent.test.ts
Normal 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() }
|
||||||
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import test from "node:test"
|
import test from "node:test"
|
||||||
import { generateKeyPairSync, randomUUID } from "node:crypto"
|
import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto"
|
||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
|
import sharp from "sharp"
|
||||||
import { CollaborationService } from "../src/modules/collaboration/service"
|
import { CollaborationService } from "../src/modules/collaboration/service"
|
||||||
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
|
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
|
||||||
import { instanceUrl, publicAddress, signedHeaders, verifyHeaders, Transport } from "../src/modules/collaboration/transport"
|
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
|
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])
|
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 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" } })
|
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")
|
const live = await b.federation.memberRequest(b.ctx, bindingId, "room")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
|
|||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
import cookie from "@fastify/cookie"
|
import cookie from "@fastify/cookie"
|
||||||
|
import sharp from "sharp"
|
||||||
import authPlugin from "../src/plugins/auth"
|
import authPlugin from "../src/plugins/auth"
|
||||||
import { secrets } from "../src/utils/secrets"
|
import { secrets } from "../src/utils/secrets"
|
||||||
import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration"
|
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)
|
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])
|
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")
|
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({ 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: "/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() }
|
} finally { secrets.JWT_SECRET = previousSecret; await app.close(); await fixture.close() }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 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(`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"))
|
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"))
|
await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8"))
|
||||||
}
|
}
|
||||||
return { db, pool, close: async () => {
|
return { db, pool, close: async () => {
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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
|
### 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.
|
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.
|
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.
|
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.
|
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
|
### 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.
|
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.
|
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
|
```sh
|
||||||
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
|
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 \
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
65
frontend/components/ChatComposer.vue
Normal file
65
frontend/components/ChatComposer.vue
Normal 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>
|
||||||
38
frontend/components/ChatImage.vue
Normal file
38
frontend/components/ChatImage.vue
Normal 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>
|
||||||
19
frontend/components/ChatReactionBar.vue
Normal file
19
frontend/components/ChatReactionBar.vue
Normal 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>
|
||||||
39
frontend/components/ChatReactions.vue
Normal file
39
frontend/components/ChatReactions.vue
Normal 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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<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 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 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)
|
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
||||||
let generation = 0, timer, polling = false, pending = null
|
let generation = 0, timer, polling = false, pending = null
|
||||||
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
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) {
|
function failed(e) {
|
||||||
error.value = messageError(e)
|
error.value = messageError(e)
|
||||||
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
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')
|
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,8 @@ async function read(id, version) {
|
|||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const version = ++generation, id = props.roomId
|
const version = ++generation, id = props.roomId
|
||||||
room.value = null; messages.value = []; members.value = []; draft.value = ''; error.value = ''; pending = null
|
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
|
||||||
loading.value = true
|
loading.value = true; sending.value = false; olderLoading.value = false
|
||||||
try {
|
try {
|
||||||
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
|
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
|
if (version !== generation) return
|
||||||
@@ -75,18 +75,18 @@ async function older() {
|
|||||||
finally { olderLoading.value = false }
|
finally { olderLoading.value = false }
|
||||||
}
|
}
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = draft.value.trim()
|
const text = draft.value.trim(), image = draftImage.value
|
||||||
if (!text || sending.value || !room.value?.canSend) return
|
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
||||||
const version = generation, id = props.roomId
|
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 = ''
|
sending.value = true; error.value = ''
|
||||||
try {
|
try {
|
||||||
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
||||||
if (version !== generation) return
|
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)
|
await scroll(); await read(id, version)
|
||||||
} catch (e) { if (version === generation) failed(e) }
|
} 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' })
|
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
watch(() => props.roomId, load, { immediate: true })
|
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>
|
<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>
|
<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>
|
<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 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'">
|
<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="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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</ChatReactions>
|
||||||
</div>
|
</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">
|
<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" />
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const router = useRouter()
|
|||||||
|
|
||||||
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
|
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
|
||||||
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = 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 requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
|
||||||
const activeRoomKey = ref(requestedRoom())
|
const activeRoomKey = ref(requestedRoom())
|
||||||
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
|
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 isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
|
||||||
const isInternal = computed(() => activeRoom.value?.source === 'internal')
|
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) {
|
function mergeMessages(incoming) {
|
||||||
const byId = new Map(messages.value.map(message => [message.id, message]))
|
const byId = new Map(messages.value.map(message => [message.id, message]))
|
||||||
for (const message of incoming || []) byId.set(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 = '' }
|
finally { if (currentScope(scope)) invitationBusy.value = '' }
|
||||||
}
|
}
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = draft.value.trim(), request = selection()
|
const text = draft.value.trim(), image = draftImage.value, request = selection()
|
||||||
if (!text || sending.value || !isInternal.value || !activeRoom.value?.exists) return
|
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
|
||||||
sending.value = true; draft.value = ''
|
sending.value = true; draft.value = ''
|
||||||
try {
|
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
|
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) {
|
} catch (error) {
|
||||||
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
|
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||||
} finally { if (currentSelection(request)) sending.value = false }
|
} finally { if (currentSelection(request)) sending.value = false }
|
||||||
@@ -371,21 +371,22 @@ onBeforeUnmount(() => {
|
|||||||
<p class="font-medium">Noch keine Nachrichten</p>
|
<p class="font-medium">Noch keine Nachrichten</p>
|
||||||
<p class="text-sm">Beginne die Unterhaltung.</p>
|
<p class="text-sm">Beginne die Unterhaltung.</p>
|
||||||
</div>
|
</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 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 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>
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ChatReactions>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="flex items-end gap-3 border-t border-gray-200 p-4" @submit.prevent="sendMessage">
|
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="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>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
||||||
|
|||||||
@@ -356,3 +356,24 @@ test('returning to chat immediately reflects a disabled or re-enabled project wi
|
|||||||
await settle()
|
await settle()
|
||||||
assert.equal(ui.run('groupedRooms.value.find(group => group.label === "Projekte").rooms[0].key'), 'project_1')
|
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'))
|
||||||
|
})
|
||||||
|
|||||||
91
frontend/tests/chat-media.test.cjs
Normal file
91
frontend/tests/chat-media.test.cjs
Normal 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')
|
||||||
|
})
|
||||||
16
frontend/utils/chat-image.js
Normal file
16
frontend/utils/chat-image.js
Normal 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() }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user