feat(communication): add chat reactions emojis and images

This commit is contained in:
2026-10-03 17:56:07 +02:00
parent 59538f46b7
commit 29a9ec2242
25 changed files with 737 additions and 57 deletions

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import sharp from "sharp"
import Fastify from "fastify"
import { chatContent } from "../src/modules/chat-content"
import { CollaborationService } from "../src/modules/collaboration/service"
import communicationRoutes from "../src/routes/communication"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
const picture = async () => ({ name: "Baustelle.png", dataUrl: `data:image/png;base64,${(await sharp({ create: { width: 80, height: 40, channels: 3, background: '#206050' } }).png().toBuffer()).toString('base64')}` })
test("chat content accepts emoji and image-only messages, strips metadata and rejects unsafe input", async () => {
assert.equal((await chatContent({ text: "Hallo 👋 ❤️" })).body, "Hallo 👋 ❤️")
const normalized = await chatContent({ image: await picture() })
assert.equal(normalized.body, "")
assert.equal(normalized.image?.width, 80)
const metadata = await sharp(Buffer.from(normalized.image!.dataUrl.split(',')[1], 'base64')).metadata()
assert.equal(metadata.format, 'webp')
assert.equal(metadata.exif, undefined)
for (const input of [{}, { text: ' '.repeat(5) }, { text: 'a'.repeat(10001) },
{ image: { dataUrl: 'https://example.test/tracker.png' } },
{ image: { dataUrl: `data:image/png;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>').toString('base64')}` } },
{ image: { dataUrl: 'data:image/png;base64,broken' } },
{ image: { dataUrl: 'a'.repeat(700001) } }]) await assert.rejects(chatContent(input))
})
test("native chat images and reactions follow tenant, membership, project and message boundaries", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase(), app = Fastify()
const owner = randomUUID(), colleague = randomUUID(), outsider = randomUUID(), room = randomUUID(), otherRoom = randomUUID()
try {
await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null),(2,'B',null)")
for (const [id, tenant] of [[owner, 1], [colleague, 1], [outsider, 2]]) {
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,$2)", [id, `${id}@test.example`])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES ($1,$2)", [tenant, id])
}
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived,chat_enabled) VALUES (1,1,'Project','{}',false,true)")
await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,type,archived,entity_id)
VALUES ($1,1,'project_1','Project','project',false,1),($2,1,'other','Other','room',false,null)`, [room, otherRoom])
await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2),($1,$3),($4,$2)", [room, owner, colleague, otherRoom])
app.decorate('db', fixture.db)
app.addHook('preHandler', async (req: any) => {
req.user = { user_id: req.headers['x-user'] || owner, tenant_id: Number(req.headers['x-tenant'] || 1), is_admin: true }
req.hasPermission = () => true
})
await app.register(communicationRoutes)
const base = '/communication/chat/rooms/project_1'
const sent = await app.inject({ method: 'POST', url: `${base}/messages`, payload: { image: await picture() } })
assert.equal(sent.statusCode, 200, sent.body)
const message = sent.json()
assert.equal(message.body, '')
assert.equal(message.image.width, 80)
assert.equal(message.image.dataUrl, undefined)
assert.equal((await app.inject(`${base}/messages`)).json().messages[0].image.dataUrl, undefined)
const image = await app.inject(`${base}/image?messageId=${message.id}`)
assert.equal(image.statusCode, 200, image.body)
assert.equal(image.headers['cache-control'], 'no-store')
assert.match(image.json().dataUrl, /^data:image\/webp;base64,/)
const react = (headers = {}, active = true, emoji = '👍') => app.inject({ method: 'PUT', url: `${base}/reactions`, headers, payload: { messageId: message.id, emoji, active } })
assert.equal((await react()).statusCode, 200)
assert.equal((await react()).json().reactions[message.id][0].count, 1)
assert.equal((await react({ 'x-user': colleague })).json().reactions[message.id][0].count, 2)
const removed = await react({}, false)
assert.deepEqual(removed.json().reactions[message.id], [{ emoji: '👍', count: 1, own: false }])
assert.equal((await react({}, true, '<script>')).statusCode, 400)
assert.equal((await app.inject(`/communication/chat/rooms/other/image?messageId=${message.id}`)).statusCode, 404)
assert.deepEqual((await app.inject(`/communication/chat/rooms/other/reactions?ids=${message.id}`)).json(), { reactions: {} })
for (const headers of [{ 'x-user': outsider }, { 'x-user': outsider, 'x-tenant': '2' }]) {
assert.ok([403, 404].includes((await app.inject({ url: `${base}/image?messageId=${message.id}`, headers })).statusCode))
assert.ok([403, 404].includes((await react(headers)).statusCode))
}
await fixture.pool.query('UPDATE projects SET chat_enabled=false WHERE id=1')
assert.equal((await app.inject(`${base}/image?messageId=${message.id}`)).statusCode, 404)
assert.equal((await app.inject(`${base}/reactions?ids=${message.id}`)).statusCode, 404)
assert.equal((await react()).statusCode, 404)
} finally { await app.close(); await fixture.close() }
})
test("subroom media respects history, guest read-only rights, revocation and idempotent sends", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase()
const service = new CollaborationService(fixture.db, { publicUrl: 'https://example.test', guestPepper: () => 'test', sendMail: async () => ({ success: true }) })
const ownerId = randomUUID()
try {
await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null)")
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@test.example',true)", [ownerId])
await fixture.pool.query('INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)', [ownerId])
const owner = await service.userContext(ownerId, 1)
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Project','{}',false)")
const room = await service.createRoom(owner, { name: 'Media', projectId: 1 })
const guest = await service.guest(owner, { name: 'Guest', email: 'guest@test.example' })
const old = await service.send(owner.principal, room.id, { clientId: randomUUID(), image: await picture() })
const invitation = await service.invite(owner, room.id, guest, {})
await service.accept(guest, invitation.id, invitation.token)
await assert.rejects(service.image(guest, room.id, { messageId: old.id }), /kein Zugriff/)
await assert.rejects(service.react(guest, room.id, { messageId: old.id, emoji: '👍', active: true }), /kein Zugriff/)
const command = { clientId: randomUUID(), text: '', image: await picture() }
const sent = await service.send(guest, room.id, command)
assert.equal((await service.send(guest, room.id, command)).id, sent.id)
await assert.rejects(service.send(guest, room.id, { ...command, image: { ...command.image, name: 'different.png' } }), /Auftrags-ID/)
assert.equal((await service.messages(guest, room.id)).messages[0].image.dataUrl, undefined)
assert.match((await service.image(guest, room.id, { messageId: sent.id })).dataUrl, /^data:image\/webp/)
await service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: true })
await service.react(owner.principal, room.id, { messageId: sent.id, emoji: '❤️', active: true })
assert.deepEqual((await service.reactions(guest, room.id, { ids: `${old.id},${sent.id}` })).reactions[sent.id], [{ emoji: '❤️', count: 2, own: true }])
const member = (await service.management(owner, room.id)).members.find(m => m.principalId === guest.id)
await service.updateMember(owner, room.id, member.id, { role: 'reader', overviewAllowed: false })
await assert.rejects(service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: false }), /nur lesbar/)
await assert.rejects(service.send(guest, room.id, { clientId: randomUUID(), image: await picture() }), /nur lesbar/)
await service.image(guest, room.id, { messageId: sent.id })
await service.updateRoom(owner, room.id, { status: 'archived' })
await assert.rejects(service.react(owner.principal, room.id, { messageId: sent.id, emoji: '👍', active: true }), /archiviert/)
await service.updateRoom(owner, room.id, { status: 'active' })
await service.revoke(owner, room.id, member.id)
await assert.rejects(service.image(guest, room.id, { messageId: sent.id }), /kein Zugriff/)
await assert.rejects(service.reactions(guest, room.id, { ids: sent.id }), /kein Zugriff/)
} finally { await fixture.close() }
})

View File

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

View File

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

View File

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