diff --git a/.env.example b/.env.example index 75eb8ac5..9645c2b7 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,11 @@ SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password M2M_API_KEY=change-this-m2m-key API_BASE_URL=https://app.example.com/backend +# Collaboration: public browser URL and canonical HTTPS API address (including reverse-proxy prefix). +FEDEO_PUBLIC_URL=https://app.example.com +FEDEO_FEDERATION_URL=https://app.example.com/backend +# Requires mutual tenant partner approval under Zusammenarbeit. +FEDEO_FEDERATION_ENABLED=false GOCARDLESS_BASE_URL=https://api.gocardless.com GOCARDLESS_SECRET_ID=replace-this GOCARDLESS_SECRET_KEY=replace-this diff --git a/backend/db/migrations/0076_collaboration_federation.sql b/backend/db/migrations/0076_collaboration_federation.sql new file mode 100644 index 00000000..1438e7ec --- /dev/null +++ b/backend/db/migrations/0076_collaboration_federation.sql @@ -0,0 +1,67 @@ +CREATE TABLE collaboration_federation_identity ( + singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), + instance_id uuid NOT NULL UNIQUE, + key_id uuid NOT NULL, + public_key text NOT NULL, + private_key_cipher jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE collaboration_partners ( + tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + origin text NOT NULL, + instance_id uuid NOT NULL, + key_id uuid NOT NULL, + public_key text NOT NULL, + active boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(tenant_id,origin) +); +CREATE TABLE collaboration_federation_nonces ( + origin text NOT NULL, + nonce uuid NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY(origin,nonce) +); +CREATE TABLE collaboration_remote_bindings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + principal_id uuid NOT NULL REFERENCES collaboration_principals(id) ON DELETE CASCADE, + origin text NOT NULL, + room_id uuid NOT NULL, + invitation_id uuid NOT NULL, + status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','active','revoked','declined')), + invitation jsonb NOT NULL, + snapshot jsonb, + revision bigint NOT NULL DEFAULT 0, + last_read_seq bigint NOT NULL DEFAULT 0, + sync_seq bigint NOT NULL DEFAULT 0, + sync_at timestamptz, + last_error text, + UNIQUE(principal_id,origin,room_id) +); +CREATE TABLE collaboration_remote_messages ( + binding_id uuid NOT NULL REFERENCES collaboration_remote_bindings(id) ON DELETE CASCADE, + id uuid NOT NULL, + seq bigint NOT NULL, + payload jsonb NOT NULL, + PRIMARY KEY(binding_id,id), + UNIQUE(binding_id,seq) +); +CREATE TABLE collaboration_outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + dedup_key text NOT NULL UNIQUE, + tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + principal_id uuid NOT NULL REFERENCES collaboration_principals(id), + origin text NOT NULL, + binding_id uuid REFERENCES collaboration_remote_bindings(id) ON DELETE CASCADE, + room_id uuid REFERENCES collaboration_rooms(id) ON DELETE CASCADE, + action text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','sent','failed')), + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + lease_until timestamptz, + last_error text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX collaboration_outbox_pending ON collaboration_outbox(next_attempt_at) WHERE status='pending'; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index ff645c3d..6a2bd3c1 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -512,6 +512,13 @@ "when": 1790726400000, "tag": "0075_project_collaboration", "breakpoints": true + }, + { + "idx": 73, + "version": "7", + "when": 1790726400001, + "tag": "0076_collaboration_federation", + "breakpoints": true } ] } diff --git a/backend/db/schema/collaboration_federation.ts b/backend/db/schema/collaboration_federation.ts new file mode 100644 index 00000000..7ede65bb --- /dev/null +++ b/backend/db/schema/collaboration_federation.ts @@ -0,0 +1,44 @@ +import { sql } from "drizzle-orm" +import { pgTable, uuid, bigint, text, timestamp, boolean, integer, jsonb, index, uniqueIndex, primaryKey, check } from "drizzle-orm/pg-core" +import { tenants } from "./tenants" +import { collaborationPrincipals, collaborationRooms } from "./collaboration" + +const time = (name: string) => timestamp(name, { withTimezone: true }) +export const collaborationFederationIdentity = pgTable("collaboration_federation_identity", { + singleton: boolean("singleton").primaryKey().default(true), instanceId: uuid("instance_id").notNull().unique(), + keyId: uuid("key_id").notNull(), publicKey: text("public_key").notNull(), privateKeyCipher: jsonb("private_key_cipher").notNull(), + createdAt: time("created_at").notNull().defaultNow(), +}, t => ({ singleton: check("collaboration_federation_identity_singleton_check", sql`${t.singleton}`) })) +export const collaborationPartners = pgTable("collaboration_partners", { + tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }), + origin: text("origin").notNull(), instanceId: uuid("instance_id").notNull(), keyId: uuid("key_id").notNull(), publicKey: text("public_key").notNull(), + active: boolean("active").notNull().default(true), updatedAt: time("updated_at").notNull().defaultNow(), +}, t => ({ pk: primaryKey({ columns: [t.tenantId, t.origin] }) })) +export const collaborationFederationNonces = pgTable("collaboration_federation_nonces", { + origin: text("origin").notNull(), nonce: uuid("nonce").notNull(), expiresAt: time("expires_at").notNull(), +}, t => ({ pk: primaryKey({ columns: [t.origin, t.nonce] }) })) +export const collaborationRemoteBindings = pgTable("collaboration_remote_bindings", { + id: uuid("id").primaryKey().defaultRandom(), + tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }), + principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id, { onDelete: "cascade" }), + origin: text("origin").notNull(), roomId: uuid("room_id").notNull(), invitationId: uuid("invitation_id").notNull(), + status: text("status").notNull().default("pending"), invitation: jsonb("invitation").notNull(), snapshot: jsonb("snapshot"), + revision: bigint("revision", { mode: "number" }).notNull().default(0), lastReadSeq: bigint("last_read_seq", { mode: "number" }).notNull().default(0), + syncSeq: bigint("sync_seq", { mode: "number" }).notNull().default(0), + syncAt: time("sync_at"), lastError: text("last_error"), +}, t => ({ identity: uniqueIndex("collaboration_remote_bindings_principal_id_origin_room_id_key").on(t.principalId, t.origin, t.roomId), + status: check("collaboration_remote_bindings_status_check", sql`${t.status} IN ('pending','active','revoked','declined')`) })) +export const collaborationRemoteMessages = pgTable("collaboration_remote_messages", { + bindingId: uuid("binding_id").notNull().references(() => collaborationRemoteBindings.id, { onDelete: "cascade" }), + id: uuid("id").notNull(), seq: bigint("seq", { mode: "number" }).notNull(), payload: jsonb("payload").notNull(), +}, t => ({ pk: primaryKey({ columns: [t.bindingId, t.id] }), seq: uniqueIndex("collaboration_remote_messages_binding_id_seq_key").on(t.bindingId, t.seq) })) +export const collaborationOutbox = pgTable("collaboration_outbox", { + id: uuid("id").primaryKey().defaultRandom(), dedupKey: text("dedup_key").notNull().unique(), + tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }), + principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id), origin: text("origin").notNull(), + bindingId: uuid("binding_id").references(() => collaborationRemoteBindings.id, { onDelete: "cascade" }), + roomId: uuid("room_id").references(() => collaborationRooms.id, { onDelete: "cascade" }), action: text("action").notNull(), payload: jsonb("payload").notNull(), + status: text("status").notNull().default("pending"), attempts: integer("attempts").notNull().default(0), + nextAttemptAt: time("next_attempt_at").notNull().defaultNow(), leaseUntil: time("lease_until"), lastError: text("last_error"), createdAt: time("created_at").notNull().defaultNow(), +}, t => ({ pending: index("collaboration_outbox_pending").on(t.nextAttemptAt).where(sql`${t.status}='pending'`), + status: check("collaboration_outbox_status_check", sql`${t.status} IN ('pending','sent','failed')`) })) diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 0a2c10d1..399dbae2 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -102,3 +102,5 @@ export * from "./public_links" export * from "./wikipages" export * from "./scanner_inbox" export * from "./collaboration"; + +export * from "./collaboration_federation" diff --git a/backend/src/index.ts b/backend/src/index.ts index 78cd0e08..8865d0c5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -40,6 +40,7 @@ import documentImportRoutes from "./routes/documentImports"; import scannerInboxGateway from "./routes/scannerInboxGateway"; import documentInboxRoutes from "./routes/documentInbox"; import { collaborationRoutes, collaborationGuestRoutes } from "./routes/collaboration"; +import { federationRoutes } from "./modules/collaboration/federation"; import { collaborationRuntime } from "./modules/collaboration/runtime"; //Public Links @@ -116,6 +117,7 @@ async function main() { await app.register(authRoutes); await app.register(healthRoutes); await app.register(collaborationGuestRoutes, collaboration); + await app.register(federationRoutes, collaboration); await app.register(helpdeskInboundRoutes); diff --git a/backend/src/modules/collaboration/federation.ts b/backend/src/modules/collaboration/federation.ts new file mode 100644 index 00000000..86e18641 --- /dev/null +++ b/backend/src/modules/collaboration/federation.ts @@ -0,0 +1,448 @@ +import { generateKeyPairSync, randomUUID } from "node:crypto" +import { sql } from "drizzle-orm" +import type { FastifyInstance } from "fastify" +import { CollaborationService, rows } from "./service" +import { fail, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types" +import { FederationKey, Headers, instanceUrl, safeTransport, signedHeaders, Transport, validatePublicKey, verifyHeaders } from "./transport" + +type Options = { + apiUrl: string; enabled: boolean; + seal: (value: string) => any; unseal: (value: any) => string; + transport?: Transport; +} + +export class FederationService { + readonly origin: string + readonly transport: Transport + private keyPromise?: Promise + private syncing = new Map>() + private working = false + constructor(readonly service: CollaborationService, readonly options: Options) { + this.origin = options.apiUrl.replace(/\/$/, "") + this.transport = options.transport || safeTransport + } + enabled() { + if (!this.options.enabled) fail("Der Betreiber muss die Föderation zuerst aktivieren", 503) + instanceUrl(this.origin) + } + async keys() { + this.enabled() + if (!this.keyPromise) this.keyPromise = (async () => { + let [identity] = await rows(this.service.db, sql`SELECT * FROM collaboration_federation_identity WHERE singleton`) + if (!identity) { + const pair = generateKeyPairSync("ed25519") + const pub = pair.publicKey.export({ format: "pem", type: "spki" }).toString() + const secret = this.options.seal(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString()) + await this.service.db.execute(sql`INSERT INTO collaboration_federation_identity(instance_id,key_id,public_key,private_key_cipher) + VALUES (${randomUUID()},${randomUUID()},${pub},${JSON.stringify(secret)}::jsonb) ON CONFLICT DO NOTHING`) + ;[identity] = await rows(this.service.db, sql`SELECT * FROM collaboration_federation_identity WHERE singleton`) + } + return { instanceId: identity.instance_id, keyId: identity.key_id, publicKey: identity.public_key, privateKey: this.options.unseal(identity.private_key_cipher) } + })().catch(error => { this.keyPromise = undefined; throw error }) + return this.keyPromise + } + async discovery() { + const keys = await this.keys() + return { version: 1, origin: this.origin, instanceId: keys.instanceId, keyId: keys.keyId, publicKey: keys.publicKey } + } + contact(principal: Principal) { return `${this.origin}/federation/actors/${principal.id}` } + async allowed(tenantId: number, origin: string, db: any = this.service.db) { + const [partner] = await rows(db, sql`SELECT * FROM collaboration_partners WHERE tenant_id=${tenantId} AND origin=${origin} AND active`) + if (!partner) fail("Partnerinstanz ist für diesen Mandanten nicht freigegeben", 403) + return partner + } + async settings(ctx: UserContext, input?: any) { + if (!ctx.manage || !(await this.service.currentUser(ctx.userId, ctx.tenantId)).manage) fail("Keine Berechtigung", 403) + if (input) { + this.enabled() + const origin = instanceUrl(text(input.origin, 1000)) + if (origin === this.origin) fail("Diese Instanz benötigt keine Partnerfreigabe") + if (typeof input.active !== "boolean") fail("Ungültiger Partnerstatus") + if (!input.active) { + await this.service.db.execute(sql`UPDATE collaboration_partners SET active=false,updated_at=now() WHERE tenant_id=${ctx.tenantId} AND origin=${origin}`) + } else { + await this.service.rateLimit(`partner-discovery:${ctx.tenantId}`, 10, 600) + const data = await this.transport(`${origin}/.well-known/fedeo-federation`, "GET", {}) + if (data.version !== 1 || data.origin !== origin) fail("Inkompatible Instanzadresse oder Protokollversion") + const instanceId = uuid(data.instanceId), keyId = uuid(data.keyId), publicKey = validatePublicKey(data.publicKey) + const existing = await rows(this.service.db, sql`SELECT instance_id,key_id,public_key FROM collaboration_partners WHERE origin=${origin}`) + if (existing.some(p => p.instance_id !== instanceId || p.key_id !== keyId || p.public_key !== publicKey)) fail("Instanzschlüssel hat sich geändert. Eine erneute Vertrauensprüfung durch den Betreiber ist erforderlich.", 409) + await this.service.db.execute(sql`INSERT INTO collaboration_partners(tenant_id,origin,instance_id,key_id,public_key) + VALUES (${ctx.tenantId},${origin},${instanceId},${keyId},${publicKey}) ON CONFLICT(tenant_id,origin) DO UPDATE SET active=true,updated_at=now()`) + } + } + return { enabled: this.options.enabled, origin: this.origin, partners: (await rows(this.service.db, + sql`SELECT origin,active,public_key FROM collaboration_partners WHERE tenant_id=${ctx.tenantId} ORDER BY origin`)).map(p => ({ origin: p.origin, active: p.active, fingerprint: hash(p.public_key) })) } + } + async call(tenantId: number, origin: string, action: string, payload: any) { + this.enabled() + await this.allowed(tenantId, origin) + const body = JSON.stringify({ version: 1, action, ...payload }) + const target = `${origin}/federation/v1/rpc` + const result = await this.transport(target, "POST", signedHeaders(await this.keys(), this.origin, target, body), body) + if (result?.version !== 1 || !("data" in result)) fail("Inkompatible Föderationsantwort", 502) + return result.data + } + async authenticate(body: string, headers: Headers) { + this.enabled() + const origin = instanceUrl(headers["x-fedeo-origin"] || "") + const partners = await rows(this.service.db, sql`SELECT DISTINCT key_id,public_key FROM collaboration_partners WHERE origin=${origin} AND active`) + if (partners.length !== 1) fail("Unbekannte oder gesperrte Partnerinstanz", 401) + const verified = verifyHeaders({ keyId: partners[0].key_id, publicKey: partners[0].public_key }, `${this.origin}/federation/v1/rpc`, body, headers) + const nonce = uuid(verified.nonce) + const [accepted] = await rows(this.service.db, sql`INSERT INTO collaboration_federation_nonces(origin,nonce,expires_at) + VALUES (${origin},${nonce},now()+interval '10 minutes') ON CONFLICT DO NOTHING RETURNING nonce`) + if (!accepted) fail("Signatur wurde bereits verwendet", 401) + await this.service.rateLimit(`federation:${origin}`, 600, 60) + return origin + } + async resolveContact(ctx: UserContext, address: string): Promise { + const localPrefix = `${this.origin}/federation/actors/` + if (address.startsWith(localPrefix)) { + const actorId = uuid(address.slice(localPrefix.length)) + const [principal] = await rows(this.service.db, sql`SELECT * FROM collaboration_principals WHERE id=${actorId} AND kind='user'`) + if (!principal) fail("Kontakt nicht gefunden", 404) + return this.service.assertPrincipal(principal) + } + const match = /^(https:\/\/.+)\/federation\/actors\/([0-9a-f-]{36})$/i.exec(address) + if (!match) fail("Ungültige persönliche Kontaktadresse") + const origin = instanceUrl(match[1]), actorId = uuid(match[2]) + const actor = await this.call(ctx.tenantId, origin, "actor.lookup", { actorId }) + if (uuid(actor.id) !== actorId) fail("Kontaktidentität stimmt nicht überein", 502) + const name = text(actor.name), organisation = text(actor.organisation) + const [principal] = await rows(this.service.db, sql`INSERT INTO collaboration_principals(kind,remote_origin,remote_actor_id,display_name) + VALUES ('remote',${origin},${actorId},${`${name} · ${organisation}`}) ON CONFLICT(remote_origin,remote_actor_id) WHERE kind='remote' + DO UPDATE SET display_name=excluded.display_name RETURNING *`) + return principal + } + async enqueue(db: any, entry: { key: string; ctx: UserContext; origin: string; action: string; payload: any; roomId?: string; bindingId?: string }) { + const [job] = await rows(db, sql`INSERT INTO collaboration_outbox(dedup_key,tenant_id,principal_id,origin,action,payload,room_id,binding_id) + VALUES (${entry.key},${entry.ctx.tenantId},${entry.ctx.principal.id},${entry.origin},${entry.action},${JSON.stringify(entry.payload)}::jsonb,${entry.roomId || null},${entry.bindingId || null}) + ON CONFLICT(dedup_key) DO UPDATE SET dedup_key=excluded.dedup_key RETURNING *`) + if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) { + // JSONB key order differs: compare the actual command values instead of its serialization. + if (job.payload.input?.text !== entry.payload.input?.text || job.payload.actorId !== entry.payload.actorId || job.payload.roomId !== entry.payload.roomId || job.payload.input?.revision !== entry.payload.input?.revision) fail("Auftrags-ID wurde bereits anders verwendet", 409) + } + return job + } + async invite(ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any = this.service.db) { + this.enabled() + await this.allowed(ctx.tenantId, target.remote_origin!, db) + await this.enqueue(db, { key: `invite:${invitationId}`, ctx, origin: target.remote_origin!, roomId, + action: "invitation.deliver", payload: { actorId: ctx.principal.id, targetActorId: target.remote_actor_id, invitationId } }) + } + async deliveries(ctx: UserContext, roomId: string) { + await this.service.manager(this.service.db, ctx, await this.service.roomRow(this.service.db, roomId)) + return rows(this.service.db, sql`SELECT payload->>'invitationId' AS id,status,last_error AS error FROM collaboration_outbox + WHERE tenant_id=${ctx.tenantId} AND room_id=${roomId} AND action='invitation.deliver' ORDER BY created_at DESC LIMIT 100`) + } + async retryInvitation(ctx: UserContext, roomId: string, invitationId: string) { + await this.service.manager(this.service.db, ctx, await this.service.roomRow(this.service.db, roomId)) + const [job] = await rows(this.service.db, sql`UPDATE collaboration_outbox o SET status='pending',attempts=0,next_attempt_at=now(),last_error=null + FROM collaboration_invitations i WHERE i.id=${uuid(invitationId)} AND i.room_id=${roomId} AND i.status='pending' AND i.expires_at>now() + AND o.dedup_key='invite:' || i.id::text AND o.tenant_id=${ctx.tenantId} AND o.room_id=${roomId} AND o.status='failed' RETURNING o.*`) + if (!job) fail("Einladung kann nicht erneut zugestellt werden", 409) + await this.deliver(job) + return { queued: true } + } + async changed(roomId: string, db: any) { + if (!this.options.enabled) return + const [room] = await rows(db, sql`SELECT r.tenant_id,r.created_by,p.id AS actor_id FROM collaboration_rooms r + JOIN collaboration_principals p ON p.tenant_id=r.tenant_id AND p.user_id=r.created_by AND p.kind='user' WHERE r.id=${roomId}`) + if (!room) return + const members = await rows(db, sql`SELECT p.* FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id + WHERE m.room_id=${roomId} AND p.kind='remote'`) + for (const p of members) await db.execute(sql`INSERT INTO collaboration_outbox(dedup_key,tenant_id,principal_id,origin,action,payload,room_id) + VALUES (${`change:${randomUUID()}`},${room.tenant_id},${room.actor_id},${p.remote_origin},'room.changed', + ${JSON.stringify({ roomId, targetActorId: p.remote_actor_id })}::jsonb,${roomId})`) + const withdrawn = await rows(db, sql`SELECT i.id,p.remote_origin,p.remote_actor_id FROM collaboration_invitations i + JOIN collaboration_principals p ON p.id=i.target_principal_id WHERE i.room_id=${roomId} AND i.status='revoked' AND p.kind='remote'`) + for (const i of withdrawn) await db.execute(sql`INSERT INTO collaboration_outbox(dedup_key,tenant_id,principal_id,origin,action,payload,room_id) + VALUES (${`withdraw:${i.id}`},${room.tenant_id},${room.actor_id},${i.remote_origin},'invitation.withdrawn', + ${JSON.stringify({ invitationId: i.id, targetActorId: i.remote_actor_id })}::jsonb,${roomId}) ON CONFLICT(dedup_key) DO NOTHING`) + } + async rpc(origin: string, request: any) { + if (!request || request.version !== 1 || typeof request.action !== "string") fail("Protokollversion nicht unterstützt") + const action = request.action + if (["actor.lookup", "invitation.deliver", "invitation.withdrawn", "room.changed"].includes(action)) { + const actorId = uuid(action === "actor.lookup" ? request.actorId : request.targetActorId) + const [principal] = await rows(this.service.db, sql`SELECT * FROM collaboration_principals WHERE id=${actorId} AND kind='user'`) + if (!principal) fail("Kontakt nicht gefunden", 404) + await this.allowed(Number(principal.tenant_id), origin) + await this.service.assertPrincipal(principal) + if (action === "actor.lookup") { + const [tenant] = await rows(this.service.db, sql`SELECT name FROM tenants WHERE id=${principal.tenant_id}`) + return { id: principal.id, name: principal.display_name, organisation: tenant.name } + } + if (action === "room.changed") { + await this.service.db.execute(sql`UPDATE collaboration_remote_bindings SET sync_at=null + WHERE principal_id=${principal.id} AND origin=${origin} AND room_id=${uuid(request.roomId)} AND status='active'`) + return { received: true } + } + if (action === 'invitation.withdrawn') { + const [binding] = await rows(this.service.db, sql`SELECT * FROM collaboration_remote_bindings WHERE principal_id=${principal.id} + AND origin=${origin} AND invitation_id=${uuid(request.invitationId)} AND status='pending'`) + if (binding) await this.revokeBinding(binding) + return { received: true } + } + const offer = request.offer + if (!offer || !["participant", "reader"].includes(offer.role) || typeof offer.overviewAllowed !== "boolean") fail("Ungültige Einladung") + const invitationId = uuid(request.invitationId), roomId = uuid(offer.roomId) + const snapshot = { name: text(offer.name), topic: text(offer.topic, 1000, true), ownerName: text(offer.ownerName), + role: offer.role, overviewAllowed: offer.overviewAllowed, expiresAt: new Date(offer.expiresAt).toISOString(), + createdAt: new Date(offer.createdAt).toISOString(), generation: sequence(offer.generation) } + if (!snapshot.generation) fail("Einladungsgeneration fehlt") + if (new Date(snapshot.expiresAt).getTime() <= Date.now()) fail("Einladung ist abgelaufen", 410) + await this.service.db.transaction(async db => { + const [updated] = await rows(db, sql`INSERT INTO collaboration_remote_bindings(tenant_id,principal_id,origin,room_id,invitation_id,invitation) + VALUES (${principal.tenant_id},${principal.id},${origin},${roomId},${invitationId},${JSON.stringify(snapshot)}::jsonb) + ON CONFLICT(principal_id,origin,room_id) DO UPDATE SET invitation_id=excluded.invitation_id,invitation=excluded.invitation, + status='pending',snapshot=null,revision=0,last_read_seq=0,sync_seq=0 + WHERE collaboration_remote_bindings.invitation_id<>excluded.invitation_id + AND coalesce((collaboration_remote_bindings.invitation->>'generation')::bigint,0)<${snapshot.generation} RETURNING id`) + if (updated) { + await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${updated.id}`) + await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${updated.id} AND status='pending'`) + } + }) + return { received: true } + } + const actorId = uuid(request.actorId) + const [principal] = await rows(this.service.db, sql`SELECT * FROM collaboration_principals WHERE kind='remote' AND remote_origin=${origin} AND remote_actor_id=${actorId}`) + if (!principal) fail("Teilnehmer nicht eingeladen", 403) + if (action === "invitation.accept" || action === "invitation.decline") { + const [invite] = await rows(this.service.db, sql`SELECT i.*,r.tenant_id FROM collaboration_invitations i JOIN collaboration_rooms r ON r.id=i.room_id WHERE i.id=${uuid(request.invitationId)} AND i.target_principal_id=${principal.id}`) + if (!invite) fail("Einladung nicht gefunden", 404) + await this.allowed(Number(invite.tenant_id), origin) + if (action === "invitation.decline") return this.service.decline(principal, invite.id) + return this.service.accept(principal, invite.id) + } + const room = await this.service.roomRow(this.service.db, uuid(request.roomId)) + await this.allowed(Number(room.tenant_id), origin) + if (action === "room.get") return this.service.room(principal, room.id) + if (action === "messages.get") return this.service.messages(principal, room.id, request.input) + if (action === "messages.send") return this.service.send(principal, room.id, request.input) + if (action === "members.get") return { members: await this.service.members(principal, room.id) } + if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq) + fail("Unbekannte Föderationsaktion") + } + async list(ctx: UserContext) { + if (!this.options.enabled) return { rooms: [], invitations: [] } + const bindings = await rows(this.service.db, sql`SELECT b.* FROM collaboration_remote_bindings b JOIN collaboration_partners p ON p.tenant_id=b.tenant_id AND p.origin=b.origin AND p.active + WHERE b.principal_id=${ctx.principal.id} AND b.tenant_id=${ctx.tenantId} AND b.status IN ('active','pending')`) + const result: { rooms: any[]; invitations: any[] } = { rooms: [], invitations: [] } + for (const b of bindings) { + if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true }) + if (b.status === "active") { + const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`) + result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic, + ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count }) + } + } + return result + } + async binding(ctx: UserContext, bindingId: string) { + const [binding] = await rows(this.service.db, sql`SELECT * FROM collaboration_remote_bindings WHERE id=${uuid(bindingId)} AND principal_id=${ctx.principal.id} AND tenant_id=${ctx.tenantId}`) + return binding + } + async accept(ctx: UserContext, id: string, decline = false) { + const binding = await this.binding(ctx, id) + if (!binding) return { found: false } + this.enabled() + if (!['pending', 'active'].includes(binding.status)) fail("Einladung ist nicht mehr gültig", 410) + const result = await this.call(ctx.tenantId, binding.origin, decline ? "invitation.decline" : "invitation.accept", { actorId: ctx.principal.id, invitationId: binding.invitation_id }) + if (!decline && uuid(result.roomId) !== binding.room_id) fail("Falscher Unterraum in Föderationsantwort", 502) + await this.service.db.transaction(async db => { + const [updated] = await rows(db, sql`UPDATE collaboration_remote_bindings SET status=${decline ? "declined" : "active"},sync_at=null + WHERE id=${binding.id} AND invitation_id=${binding.invitation_id} AND status IN ('pending','active') RETURNING id`) + if (!updated) fail("Einladung ist nicht mehr aktuell", 410) + if (!decline && binding.status === 'pending') { + await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${binding.id}`) + await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${binding.id} AND status='pending'`) + } + }) + if (!decline) await this.sync(ctx, { ...binding, status: 'active' }) + return { found: true, result: decline ? { declined: true } : { roomId: binding.id } } + } + async revokeBinding(binding: any) { + await this.service.db.transaction(async db => { + const [revoked] = await rows(db, sql`UPDATE collaboration_remote_bindings SET status='revoked',snapshot=null WHERE id=${binding.id} AND invitation_id=${binding.invitation_id} RETURNING id`) + if (!revoked) return + await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${binding.id}`) + await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Zugang entzogen' WHERE binding_id=${binding.id} AND status='pending'`) + }) + } + async sync(ctx: UserContext, binding: any) { + if (this.syncing.has(binding.id)) return this.syncing.get(binding.id) + const operation = this.syncOnce(ctx, binding).finally(() => this.syncing.delete(binding.id)) + this.syncing.set(binding.id, operation) + return operation + } + async syncOnce(ctx: UserContext, binding: any) { + try { + const detail = await this.call(ctx.tenantId, binding.origin, "room.get", { actorId: ctx.principal.id, roomId: binding.room_id }) + if (uuid(detail.id) !== binding.room_id || !['active', 'archived'].includes(detail.status) || typeof detail.canSend !== 'boolean') fail("Ungültige Unterraumantwort", 502) + const snapshot = { id: binding.id, name: text(detail.name), topic: text(detail.topic, 1000, true), status: detail.status, + canSend: detail.canSend, historyFromSeq: sequence(detail.historyFromSeq), revision: sequence(detail.revision), + lastReadSeq: sequence(detail.lastReadSeq), overview: null } + const page = await this.call(ctx.tenantId, binding.origin, "messages.get", { actorId: ctx.principal.id, roomId: binding.room_id, input: { after: Math.max(Number(binding.sync_seq || 0), snapshot.historyFromSeq - 1) } }) + await this.cache(binding, page.messages, snapshot) + return { ...snapshot, overview: detail.overview ? overview(detail.overview) : null, offline: false } + } catch (error: any) { + if ([401,403,404,410].includes(error.statusCode)) { await this.revokeBinding(binding); throw error } + const current = await this.binding(ctx, binding.id) + if (!current || current.status !== 'active' || current.invitation_id !== binding.invitation_id) fail('Zugang ist nicht mehr aktiv', 410) + await this.service.db.execute(sql`UPDATE collaboration_remote_bindings SET last_error='Partnerinstanz nicht erreichbar',sync_at=now() WHERE id=${binding.id} AND invitation_id=${binding.invitation_id}`) + if (current.snapshot) return { ...current.snapshot, id: binding.id, overview: null, offline: true } + throw error + } + } + async cache(binding: any, messages: any[], snapshot?: any) { + if (!Array.isArray(messages) || messages.length > 50) fail("Ungültige Nachrichtenantwort", 502) + const projected = messages.map(m => ({ id: uuid(m.id), clientId: uuid(m.clientId), seq: sequence(m.seq), body: text(m.body, 10000), + senderName: text(m.senderName, 400), senderKind: ['user','guest','remote'].includes(m.senderKind) ? m.senderKind : 'remote', + own: m.own === true, createdAt: new Date(m.createdAt).toISOString() })) + await this.service.db.transaction(async db => { + const [current] = await rows(db, sql`SELECT * FROM collaboration_remote_bindings WHERE id=${binding.id} FOR UPDATE`) + if (current.status !== 'active' || current.invitation_id !== binding.invitation_id) fail("Zugang ist nicht mehr aktiv", 410) + if (snapshot && Number(current.revision) > snapshot.revision) fail('Teilnahmerechte haben sich geändert', 409) + if (snapshot) { + await db.execute(sql`UPDATE collaboration_remote_bindings SET snapshot=${JSON.stringify(snapshot)}::jsonb,revision=${snapshot.revision}, + sync_at=now(),last_error=null,sync_seq=GREATEST(sync_seq,${Math.max(0, ...projected.map(m => m.seq))}),last_read_seq=GREATEST(last_read_seq,${snapshot.lastReadSeq}) WHERE id=${binding.id}`) + await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${binding.id} AND seq<${snapshot.historyFromSeq}`) + } + const from = snapshot?.historyFromSeq || current.snapshot?.historyFromSeq || 0 + for (const message of projected.filter(m => m.seq >= from)) await db.execute(sql`INSERT INTO collaboration_remote_messages(binding_id,id,seq,payload) + VALUES (${binding.id},${message.id},${message.seq},${JSON.stringify(message)}::jsonb) + ON CONFLICT(binding_id,id) DO UPDATE SET payload=excluded.payload`) + }) + } + async memberRequest(ctx: UserContext, id: string, action: string, input: any = {}) { + let binding = await this.binding(ctx, id) + if (!binding) return { found: false } + this.enabled() + if (binding.status !== 'active') fail("Unterraum nicht freigegeben", 404) + await this.allowed(ctx.tenantId, binding.origin) + if (action === "send") { + if (!binding.snapshot?.canSend) fail("Unterraum ist nicht zum Schreiben freigegeben", 403) + const command = { actorId: ctx.principal.id, roomId: binding.room_id, + input: { clientId: uuid(input.clientId), text: text(input.text, 10000), revision: Number(binding.revision) } } + const job = await this.enqueue(this.service.db, { key: `message:${binding.id}:${command.input.clientId}`, ctx, origin: binding.origin, + action: 'messages.send', payload: command, bindingId: binding.id }) + await this.deliver(job) + const [message] = await rows(this.service.db, sql`SELECT payload FROM collaboration_remote_messages WHERE binding_id=${binding.id} AND payload->>'clientId'=${command.input.clientId}`) + const [currentJob] = await rows(this.service.db, sql`SELECT * FROM collaboration_outbox WHERE id=${job.id}`) + if (currentJob.status === 'failed') fail(currentJob.last_error || "Nachricht konnte nicht gesendet werden", 409) + return { found: true, result: message?.payload || this.pendingMessage(currentJob) } + } + const detail = await this.sync(ctx, binding) + binding = await this.binding(ctx, id) + if (action === "room") return { found: true, result: detail } + if (action === "members") { + if (detail.offline) return { found: true, result: { members: [] } } + const data = await this.call(ctx.tenantId, binding.origin, 'members.get', { actorId: ctx.principal.id, roomId: binding.room_id }) + if (!Array.isArray(data.members) || data.members.length > 500) fail("Ungültige Teilnehmerliste", 502) + return { found: true, result: { members: data.members.map((m: any) => ({ id: uuid(m.id), name: text(m.name,400), kind: text(m.kind,10), own: m.own === true })) } } + } + if (action === "read") { + const seq = sequence(input.seq) + if (!detail.offline) await this.call(ctx.tenantId, binding.origin, 'read', { actorId: ctx.principal.id, roomId: binding.room_id, input: { seq } }) + const [max] = await rows(this.service.db, sql`SELECT max(seq) AS seq FROM collaboration_remote_messages WHERE binding_id=${binding.id}`) + await this.service.db.execute(sql`UPDATE collaboration_remote_bindings SET last_read_seq=GREATEST(last_read_seq,${Math.min(seq,Number(max.seq || 0))}) WHERE id=${binding.id}`) + return { found: true, result: { read: true } } + } + const before = sequence(input.before), after = sequence(input.after) + if (before && !detail.offline) { + const page = await this.call(ctx.tenantId, binding.origin, 'messages.get', { actorId: ctx.principal.id, roomId: binding.room_id, input: { before } }) + await this.cache(binding, page.messages) + } + const page = await rows(this.service.db, sql`SELECT payload FROM collaboration_remote_messages WHERE binding_id=${binding.id} + ${before ? sql`AND seq<${before}` : sql``} ${after ? sql`AND seq>${after}` : sql``} + ORDER BY seq ${after ? sql`ASC` : sql`DESC`} LIMIT 50`) + const messages = (after ? page : page.reverse()).map(r => r.payload) + if (!before) { + const pending = await rows(this.service.db, sql`SELECT * FROM collaboration_outbox WHERE binding_id=${binding.id} AND status IN ('pending','failed') AND (payload->'input'->>'revision')::bigint=${binding.revision} ORDER BY created_at LIMIT 100`) + messages.push(...pending.map(job => this.pendingMessage(job))) + } + return { found: true, result: { messages, hasMore: page.length === 50, offline: detail.offline } } + } + pendingMessage(job: any) { + return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text, + createdAt: job.created_at, own: true, senderName: 'Du', senderKind: 'user', pending: job.status === 'pending', failed: job.status === 'failed' } + } + async deliver(job: any) { + const [claimed] = await rows(this.service.db, sql`UPDATE collaboration_outbox SET lease_until=now()+interval '30 seconds',attempts=attempts+1 + WHERE id=${job.id} AND status='pending' AND (lease_until IS NULL OR lease_until= 400 && error.statusCode < 500 && error.statusCode !== 429) || claimed.attempts >= 20 + await this.service.db.execute(sql`UPDATE collaboration_outbox SET status=${terminal ? 'failed' : 'pending'},lease_until=null, + last_error=${String(error.message || 'Zustellung fehlgeschlagen').slice(0,300)}, + next_attempt_at=now()+${Math.min(3600, 2 ** Math.min(claimed.attempts,12)) + Math.floor(Math.random()*5)}*interval '1 second' WHERE id=${claimed.id}`) + } + } + async tick() { + if (this.working) return + this.working = true + try { + if (this.options.enabled) { + const jobs = await rows(this.service.db, sql`SELECT * FROM collaboration_outbox WHERE status='pending' AND next_attempt_at<=now() + AND (lease_until IS NULL OR lease_until this.deliver(job))) + const bindings = await rows(this.service.db, sql`SELECT b.*,p.user_id FROM collaboration_remote_bindings b + JOIN collaboration_principals p ON p.id=b.principal_id + WHERE b.status='active' AND (b.sync_at IS NULL OR b.sync_at { + try { await this.sync(await this.service.userContext(b.user_id, Number(b.tenant_id)), b) } + catch (error: any) { if ([401,403,404,410].includes(error.statusCode)) await this.revokeBinding(b) } + })) + } + await this.service.db.execute(sql`DELETE FROM collaboration_federation_nonces WHERE expires_at { + req.federationBody = body + try { done(null, JSON.parse(body)) } catch { done(Object.assign(new Error('Ungültiges JSON'), { statusCode: 400 })) } + }) + server.get('/.well-known/fedeo-federation', async () => federation.discovery()) + server.post('/federation/v1/rpc', { bodyLimit: 128 * 1024 }, async (req: any, reply) => { + reply.header('Cache-Control','no-store') + // Duplicate signature-bearing fields are rejected rather than silently combined. + const names = req.raw.rawHeaders.filter((_: string, index: number) => index % 2 === 0).map((name: string) => name.toLowerCase()) + for (const header of ['signature','signature-input','content-digest','content-type','x-fedeo-origin']) if (names.filter((name: string) => name === header).length !== 1) fail('Mehrdeutige Signaturfelder',401) + const origin = await federation.authenticate(req.federationBody, req.headers) + return { version: 1, data: await federation.rpc(origin, req.body) } + }) +} diff --git a/backend/src/modules/collaboration/runtime.ts b/backend/src/modules/collaboration/runtime.ts index 8f0d0fe7..0f8d12b6 100644 --- a/backend/src/modules/collaboration/runtime.ts +++ b/backend/src/modules/collaboration/runtime.ts @@ -2,6 +2,8 @@ import type { FastifyInstance } from "fastify" import { secrets } from "../../utils/secrets" import { sendMail } from "../../utils/mailer" import { CollaborationService } from "./service" +import { FederationService } from "./federation" +import { encrypt, decrypt } from "../../utils/crypt" export function collaborationRuntime(server: FastifyInstance) { const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "") @@ -11,5 +13,15 @@ export function collaborationRuntime(server: FastifyInstance) { guestPepper: () => secrets.ENCRYPTION_KEY || "", sendMail, }) - return { service, apiUrl } + const federation = new FederationService(service, { apiUrl, enabled: process.env.FEDEO_FEDERATION_ENABLED === "true", seal: encrypt, unseal: decrypt }) + service.options.changed = (roomId, db) => federation.changed(roomId, db) + service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db) + let timer: ReturnType + server.addHook("onReady", async () => { + if (federation.options.enabled) await federation.keys() + timer = setInterval(() => federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed")), 5000) + timer.unref() + }) + server.addHook("onClose", async () => { clearInterval(timer) }) + return { service, federation, apiUrl } } diff --git a/backend/src/modules/collaboration/service.ts b/backend/src/modules/collaboration/service.ts index f934de14..8a100ec8 100644 --- a/backend/src/modules/collaboration/service.ts +++ b/backend/src/modules/collaboration/service.ts @@ -10,6 +10,7 @@ export type CollaborationOptions = { guestPepper: () => string; sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>; changed?: (roomId: string, db: any) => Promise; + invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise; } /** Owner-side authority. All membership changes and sends serialize on the room row. */ @@ -184,7 +185,8 @@ export class CollaborationService { const body = text(input.text, 10000), clientId = uuid(input.clientId) return this.db.transaction(async (db) => { const room = await this.roomRow(db, roomId, true) - 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) const [existing] = await rows(db, sql`SELECT id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt" FROM collaboration_messages WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`) if (existing) { @@ -271,6 +273,10 @@ export class CollaborationService { VALUES (${room.id},${target.id},${hash(token)},${role},${input.overviewAllowed === true},${ctx.userId}, LEAST(now()+interval '7 days',${accessExpires}::timestamptz),${accessExpires}) RETURNING id,expires_at AS "expiresAt"`) await this.audit(db, room, ctx.principal, "invitation.created", invite.id) + if (target.kind === "remote") { + if (!this.options.invited) fail("Föderation ist nicht konfiguriert", 503) + await this.options.invited(ctx, room.id, target, invite.id, db) + } return invite }) return { ...result, token } diff --git a/backend/src/modules/collaboration/transport.ts b/backend/src/modules/collaboration/transport.ts new file mode 100644 index 00000000..e7de3580 --- /dev/null +++ b/backend/src/modules/collaboration/transport.ts @@ -0,0 +1,105 @@ +import { createHash, createPublicKey, sign, verify, randomUUID } from "node:crypto" +import { lookup } from "node:dns/promises" +import { BlockList, isIP } from "node:net" +import https from "node:https" +import { fail } from "./types" + +const covered = '("@method" "@target-uri" "content-type" "content-digest" "x-fedeo-origin")' +export type FederationKey = { keyId: string; publicKey: string; privateKey?: string } +export type Headers = Record +export type Transport = (url: string, method: "GET" | "POST", headers: Headers, body?: string) => Promise + +export function instanceUrl(value: string) { + let url: URL + try { url = new URL(value) } catch { fail("Ungültige Instanzadresse") } + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash + || (url.port && url.port !== "443") || !/^\/(?:[A-Za-z0-9_-]+\/?)*$/.test(url.pathname)) { + fail("Die Instanz benötigt eine öffentliche HTTPS-Adresse auf Port 443") + } + return url.href.replace(/\/$/, "") +} + +export const digest = (body: string) => `sha-256=:${createHash("sha256").update(body, "utf8").digest("base64")}:` +export function signatureBase(method: string, target: string, headers: Headers, params: string) { + return `"@method": ${method}\n"@target-uri": ${target}\n"content-type": ${headers["content-type"]}\n"content-digest": ${headers["content-digest"]}\n"x-fedeo-origin": ${headers["x-fedeo-origin"]}\n"@signature-params": ${params}` +} + +/** Deliberately narrow RFC 9421 application profile; no algorithm negotiation or remote key URLs. */ +export function signedHeaders(key: FederationKey, origin: string, target: string, body: string, now = Math.floor(Date.now() / 1000), nonce = randomUUID()): Headers { + const params = `${covered};created=${now};expires=${now + 120};nonce="${nonce}";keyid="${key.keyId}";alg="ed25519"` + const headers = { "content-type": "application/json", "content-digest": digest(body), "x-fedeo-origin": origin } + const signature = sign(null, Buffer.from(signatureBase("POST", target, headers, params)), key.privateKey!).toString("base64") + return { ...headers, "signature-input": `fedeo=${params}`, signature: `fedeo=:${signature}:` } +} + +export function verifyHeaders(key: FederationKey, target: string, body: string, headers: Headers, now = Math.floor(Date.now() / 1000)) { + const raw = headers["signature-input"] || "" + const suffix = raw.slice(`fedeo=${covered}`.length) + const match = /^;created=(\d+);expires=(\d+);nonce="([0-9a-f-]{36})";keyid="([0-9a-f-]{36})";alg="ed25519"$/.exec(suffix) + if (!raw.startsWith(`fedeo=${covered}`) || !match || match[4] !== key.keyId) fail("Ungültiges Signaturprofil", 401) + const created = Number(match[1]), expires = Number(match[2]) + if (created > now + 30 || created < now - 300 || expires < now || expires > created + 300 || expires <= created) fail("Signatur ist abgelaufen", 401) + if (headers["content-type"] !== "application/json" || headers["content-digest"] !== digest(body)) fail("Ungültiger Inhaltsdigest", 401) + const signature = /^fedeo=:([A-Za-z0-9+/]+={0,2}):$/.exec(headers.signature || "") + if (!signature) fail("Ungültige Signatur", 401) + const bytes = Buffer.from(signature[1], "base64") + if (bytes.length !== 64 || !verify(null, Buffer.from(signatureBase("POST", target, headers, raw.slice(6))), key.publicKey, bytes)) fail("Ungültige Signatur", 401) + return { nonce: match[3], expires } +} + +const privateV4 = new BlockList() +for (const [address, bits] of [["0.0.0.0",8],["10.0.0.0",8],["100.64.0.0",10],["127.0.0.0",8],["169.254.0.0",16],["172.16.0.0",12], + ["192.0.0.0",24],["192.0.2.0",24],["192.168.0.0",16],["198.18.0.0",15],["198.51.100.0",24],["203.0.113.0",24],["224.0.0.0",3]] as [string,number][]) privateV4.addSubnet(address,bits) +const publicV6 = new BlockList(); publicV6.addSubnet("2000::",3,"ipv6") +const specialV6 = new BlockList() +for (const [address, bits] of [["2001::",23],["2001:db8::",32],["2002::",16],["3fff::",20]] as [string,number][]) specialV6.addSubnet(address,bits,"ipv6") +export function publicAddress(address: string) { + return isIP(address) === 4 ? !privateV4.check(address) : isIP(address) === 6 && publicV6.check(address,"ipv6") && !specialV6.check(address,"ipv6") +} + +export function validatePublicKey(pem: unknown) { + if (typeof pem !== "string" || pem.length > 2000) fail("Ungültiger Instanzschlüssel") + try { if (createPublicKey(pem).asymmetricKeyType !== "ed25519") fail("Ed25519-Schlüssel erforderlich") } + catch { fail("Ungültiger Instanzschlüssel") } + return pem +} + +export const safeTransport: Transport = async (target, method, headers, body) => { + const url = new URL(target) + if (url.protocol !== "https:" || url.username || url.password || (url.port && url.port !== "443")) fail("Unzulässiges Föderationsziel") + let timer: ReturnType + const addresses = await Promise.race([ + lookup(url.hostname, { all: true }), + new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error("DNS-Zeitüberschreitung"), { statusCode: 503 })), 3000) }), + ]).finally(() => clearTimeout(timer)) + if (!addresses.length || addresses.some(item => !publicAddress(item.address))) fail("Föderation darf keine privaten oder reservierten Netzwerkadressen aufrufen", 400) + const address = addresses[0] + return new Promise((resolve, reject) => { + // Pin the checked address on this connection; preserve hostname for TLS verification and SNI. + const request = https.request(url, { + method, headers: { ...headers, ...(body ? { "content-length": Buffer.byteLength(body).toString() } : {}) }, + agent: false, lookup: ((_hostname: string, options: any, callback: any) => { + if (options?.all) callback(null, [address]); else callback(null, address.address, address.family) + }) as any, + }, response => { + const chunks: Buffer[] = []; let length = 0 + response.on("data", chunk => { + length += chunk.length + if (length > 1024 * 1024) request.destroy(new Error("Föderationsantwort zu groß")) + else chunks.push(chunk) + }) + response.on("end", () => { + const status = response.statusCode || 502 + // Redirects are intentionally not followed, including during discovery. + if (status < 200 || status >= 300) { + reject(Object.assign(new Error(status >= 500 ? "Partnerinstanz ist nicht erreichbar" : "Partnerinstanz hat die Anfrage abgelehnt"), { statusCode: status })); return + } + try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))) } catch { reject(Object.assign(new Error("Ungültige Föderationsantwort"), { statusCode: 502 })) } + }) + }) + const deadline = setTimeout(() => request.destroy(new Error("Föderations-Zeitüberschreitung")), 6000) + request.on("error", error => { clearTimeout(deadline); reject(Object.assign(error, { statusCode: 503 })) }) + request.on("close", () => clearTimeout(deadline)) + request.end(body) + }) +} diff --git a/backend/src/routes/collaboration.ts b/backend/src/routes/collaboration.ts index 073e496d..9c8f9b3f 100644 --- a/backend/src/routes/collaboration.ts +++ b/backend/src/routes/collaboration.ts @@ -4,6 +4,8 @@ import { CollaborationService, rows } from "../modules/collaboration/service" import { escapeHtml, fail, Principal, text, UserContext, uuid } from "../modules/collaboration/types" export type CollaborationFederation = { + deliveries(ctx: UserContext, roomId: string): Promise; + retryInvitation(ctx: UserContext, roomId: string, invitationId: string): Promise; contact(principal: Principal): string; resolveContact(ctx: UserContext, address: string): Promise; invite(ctx: UserContext, roomId: string, target: Principal, invitationId: string): Promise; @@ -35,7 +37,16 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll server.get("/collaboration/contacts", async (req) => ({ contacts: await service.localContacts(await context(req)) })) server.post("/collaboration/rooms", async (req) => service.createRoom(await context(req), body(req))) server.patch("/collaboration/rooms/:id", async (req: any) => service.updateRoom(await context(req), req.params.id, body(req))) - server.get("/collaboration/rooms/:id/management", async (req: any) => service.management(await context(req), req.params.id)) + server.get("/collaboration/rooms/:id/management", async (req: any) => { + const ctx = await context(req) + const result = await service.management(ctx, req.params.id) + const deliveries = federation ? await federation.deliveries(ctx, req.params.id) : [] + return { ...result, invitations: result.invitations.map(i => ({ ...i, delivery: deliveries.find(d => d.id === i.id) || null })) } + }) + server.post("/collaboration/rooms/:id/invitations/:invitationId/retry", async (req: any) => { + if (!federation) fail("Föderation ist nicht konfiguriert", 503) + return federation.retryInvitation(await context(req), req.params.id, req.params.invitationId) + }) server.post("/collaboration/rooms/:id/invitations", async (req: any) => { const ctx = await context(req), input = body(req) // Check ownership before resolving or creating any recipient identity. @@ -59,7 +70,6 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll } else fail("Ungültige Teilnehmerart") await service.rateLimit(`invite:${ctx.tenantId}:${ctx.userId}`, 30, 600) const invitation = await service.invite(ctx, req.params.id, target, input) - if (target.kind === "remote") await federation!.invite(ctx, req.params.id, target, invitation.id) const url = target.kind === "guest" ? `${service.options.publicUrl}/guest#invite=${invitation.token}` : null let mailSent = false if (url && input.sendEmail === true) { diff --git a/backend/tests/collaborationFederation.test.ts b/backend/tests/collaborationFederation.test.ts new file mode 100644 index 00000000..9249072c --- /dev/null +++ b/backend/tests/collaborationFederation.test.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { generateKeyPairSync, randomUUID } from "node:crypto" +import Fastify from "fastify" +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" +import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db" + +test("federation signatures bind body, origin, destination, key and short validity", () => { + const keys = generateKeyPairSync("ed25519") + const key = { keyId: randomUUID(), publicKey: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), privateKey: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString() } + const target = "https://b.example/backend/federation/v1/rpc", body = '{"text":"Grüße"}' + const headers = signedHeaders(key, "https://a.example/backend", target, body, 1000) + assert.ok(verifyHeaders(key, target, body, headers, 1001).nonce) + assert.throws(() => verifyHeaders(key, target, body + " ", headers, 1001), /digest/) + assert.throws(() => verifyHeaders(key, target + "?other", body, headers, 1001), /Signatur/) + assert.throws(() => verifyHeaders(key, target, body, { ...headers, "x-fedeo-origin": "https://other.example" }, 1001), /Signatur/) + assert.throws(() => verifyHeaders({ ...key, keyId: randomUUID() }, target, body, headers, 1001), /Signaturprofil/) + assert.throws(() => verifyHeaders(key, target, body, headers, 1121), /abgelaufen/) + assert.throws(() => verifyHeaders(key, target, body, headers, 960), /abgelaufen/) + assert.throws(() => verifyHeaders(key, target, body, { ...headers, "signature-input": headers["signature-input"].replace("ed25519", "hmac-sha256") }, 1001), /Signaturprofil/) +}) + +test("federation destinations reject non-HTTPS and non-public IPv4/IPv6", () => { + for (const value of ["http://example.com", "https://user:secret@example.com", "https://example.com:8443", "https://example.com/?x=1", "https://example.com/#frag"]) assert.throws(() => instanceUrl(value)) + assert.equal(instanceUrl("https://EXAMPLE.com/backend/"), "https://example.com/backend") + for (const value of ["127.0.0.1", "10.1.1.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.1.1", "192.0.2.1", "224.1.1.1", "::1", "::ffff:127.0.0.1", "fc00::1", "fe80::1", "2001:db8::1", "2002:7f00:1::", "not-ip"]) assert.equal(publicAddress(value), false, value) + assert.equal(publicAddress("8.8.8.8"), true) + assert.equal(publicAddress("2606:4700:4700::1111"), true) +}) + +test("two independent instances exchange invitations and messages through signed HTTP routes", { skip: !testDatabaseUrl }, async t => { + const servers = new Map>() + let offline = false, loseReply = false + const transport: Transport = async (url, method, headers, body) => { + if (offline) throw Object.assign(new Error("Simulated outage"), { statusCode: 503 }) + const prefix = [...servers.keys()].find(origin => url.startsWith(origin + "/"))! + const response = await servers.get(prefix)!.inject({ method, url: url.slice(prefix.length), headers, ...(body ? { payload: body } : {}) }) + if (response.statusCode >= 400) throw Object.assign(new Error(response.json().message), { statusCode: response.statusCode }) + if (loseReply && body && JSON.parse(body).action === "messages.send") { loseReply = false; throw Object.assign(new Error("Lost acknowledgement"), { statusCode: 503 }) } + return response.json() + } + const make = async (name: string) => { + const fixture = await collaborationDatabase(["0075_project_collaboration.sql", "0076_collaboration_federation.sql"]) + const userId = randomUUID() + await fixture.pool.query("INSERT INTO tenants VALUES (1,$1,null),(2,'Other context',null)", [name]) + await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,$2,true)", [userId, `${name}@example.test`]) + await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1)", [userId]) + await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Secret project','{}',false)") + const service = new CollaborationService(fixture.db, { publicUrl: `https://${name}.example`, guestPepper: () => "a".repeat(64), sendMail: async () => ({ success: true }) }) + const federation = new FederationService(service, { apiUrl: `https://${name}.example/backend`, enabled: true, transport, seal: value => ({ testKey: value }), unseal: value => value.testKey }) + service.options.changed = (id, db) => federation.changed(id, db) + service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db) + const app = Fastify() + await app.register(federationRoutes, { federation }) + await app.ready() + servers.set(federation.origin, app) + return { ...fixture, service, federation, app, ctx: await service.userContext(userId, 1), other: await service.userContext(userId, 2) } + } + const a = await make("alpha"), b = await make("beta") + try { + await a.federation.settings(a.ctx, { origin: b.federation.origin, active: true }) + await b.federation.settings(b.ctx, { origin: a.federation.origin, active: true }) + const contact = await a.federation.resolveContact(a.ctx, b.federation.contact(b.ctx.principal)) + const room = await a.service.createRoom(a.ctx, { projectId: 1, name: "Gerüstbau" }) + const sibling = await a.service.createRoom(a.ctx, { projectId: 1, name: "Bauherr" }) + await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Private history" }) + const invitation = await a.service.invite(a.ctx, room.id, contact, { overviewAllowed: true }) + await a.federation.tick() + const offers = await b.federation.list(b.ctx) + assert.equal(offers.invitations.length, 1) + const bindingId = offers.invitations[0].id + await t.test("named principal and owner tenant are checked independently of instance trust", async () => { + assert.equal((await b.federation.list(b.other)).invitations.length, 0) + assert.equal((await b.federation.accept(b.other, bindingId)).found, false) + await assert.rejects(a.federation.resolveContact(a.ctx, b.federation.contact(b.other.principal)), /freigegeben/) + await assert.rejects(b.federation.call(1, a.federation.origin, "invitation.accept", { actorId: b.other.principal.id, invitationId: invitation.id }), /nicht eingeladen/) + const accepted = await b.federation.accept(b.ctx, bindingId) + assert.equal(accepted.result.roomId, bindingId) + await assert.rejects(b.federation.call(1, a.federation.origin, "room.get", { actorId: b.ctx.principal.id, roomId: sibling.id }), /kein Zugriff/) + assert.deepEqual((await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages, []) + }) + await t.test("replay and destination substitution are rejected by actual HTTP route", async () => { + const body = JSON.stringify({ version: 1, action: "room.get", actorId: b.ctx.principal.id, roomId: room.id }) + const headers = signedHeaders(await b.federation.keys(), b.federation.origin, `${a.federation.origin}/federation/v1/rpc`, body) + assert.equal((await a.app.inject({ method: "POST", url: "/federation/v1/rpc", headers, payload: body })).statusCode, 200) + assert.equal((await a.app.inject({ method: "POST", url: "/federation/v1/rpc", headers, payload: body })).statusCode, 401) + assert.equal((await b.app.inject({ method: "POST", url: "/federation/v1/rpc", headers, payload: body })).statusCode, 401) + }) + await t.test("lost acknowledgement, retries and concurrent delivery create exactly one message", async () => { + await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Unpolled owner message" }) + const command = { clientId: randomUUID(), text: "Hallo von Beta" } + loseReply = true + const pending = await b.federation.memberRequest(b.ctx, bindingId, "send", command) + assert.equal(pending.result.pending, true) + const jobs = (await b.pool.query("SELECT * FROM collaboration_outbox WHERE binding_id=$1", [bindingId])).rows + await Promise.all([b.federation.deliver(jobs[0]), b.federation.deliver(jobs[0])]) + const retry = await b.federation.memberRequest(b.ctx, bindingId, "send", command) + assert.equal(retry.result.body, command.text) + assert.equal(retry.result.seq, 3) + assert.equal((await a.service.messages(a.ctx.principal, room.id)).messages.length, 3) + await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "send", { ...command, text: "Changed" }), /Auftrags-ID/) + 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("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") + assert.equal(live.result.overview.title, "Freigegeben") + assert.equal("projectId" in live.result, false) + offline = true + const cached = await b.federation.memberRequest(b.ctx, bindingId, "room") + assert.equal(cached.result.offline, true) + assert.equal(cached.result.overview, null) + const pending = await b.federation.memberRequest(b.ctx, bindingId, "send", { clientId: randomUUID(), text: "Offline" }) + assert.equal(pending.result.pending, true) + offline = false + const job = (await b.pool.query("SELECT * FROM collaboration_outbox WHERE status='pending' AND binding_id=$1", [bindingId])).rows[0] + await b.federation.deliver(job) + assert.equal((await a.service.messages(a.ctx.principal, room.id)).messages.at(-1).body, "Offline") + }) + await t.test("new invitations supersede stale active bindings; old notifications cannot revoke them", async () => { + const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id) + await a.service.revoke(a.ctx, room.id, member.id) + const next = await a.service.invite(a.ctx, room.id, contact, {}) + const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${next.id}`])).rows[0] + await a.federation.deliver(job) + assert.equal((await b.federation.list(b.ctx)).invitations.length, 1) + const stale = { ...(await b.federation.binding(b.ctx, bindingId)), invitation_id: invitation.id } + await b.federation.revokeBinding(stale) + await b.federation.accept(b.ctx, bindingId) + assert.deepEqual((await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages, []) + }) + await t.test("withdrawal removes a pending offer and delivery failures can be retried", async () => { + const offer = await a.service.invite(a.ctx, sibling.id, contact, {}) + const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${offer.id}`])).rows[0] + await a.pool.query("UPDATE collaboration_outbox SET status='failed' WHERE id=$1", [job.id]) + assert.equal((await a.federation.deliveries(a.ctx, sibling.id))[0].status, "failed") + await a.federation.retryInvitation(a.ctx, sibling.id, offer.id) + assert.equal((await b.federation.list(b.ctx)).invitations.length, 1) + await a.service.revoke(a.ctx, sibling.id, offer.id, true) + const withdrawal = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`withdraw:${offer.id}`])).rows[0] + await a.federation.deliver(withdrawal) + assert.equal((await b.federation.list(b.ctx)).invitations.length, 0) + await assert.rejects(a.federation.retryInvitation(a.ctx, sibling.id, offer.id), /nicht erneut/) + }) + await t.test("rights changes fence queued commands and revocation purges cached content", async () => { + const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id) + offline = true + await b.federation.memberRequest(b.ctx, bindingId, "send", { clientId: randomUUID(), text: "Never deliver" }) + offline = false + await a.service.revoke(a.ctx, room.id, member.id) + const oldJob = (await b.pool.query("SELECT * FROM collaboration_outbox WHERE status='pending' AND binding_id=$1", [bindingId])).rows[0] + await b.federation.deliver(oldJob) + assert.equal((await b.pool.query("SELECT status FROM collaboration_outbox WHERE id=$1", [oldJob.id])).rows[0].status, "failed") + await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "messages"), /kein Zugriff/) + assert.equal((await b.pool.query("SELECT count(*) FROM collaboration_remote_messages WHERE binding_id=$1", [bindingId])).rows[0].count, "0") + await assert.rejects(b.federation.call(1, a.federation.origin, "invitation.accept", { actorId: b.ctx.principal.id, invitationId: invitation.id }), /kein Zugriff/) + const next = await a.service.invite(a.ctx, room.id, contact, {}) + const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${next.id}`])).rows[0] + await a.federation.deliver(job) + await b.federation.accept(b.ctx, bindingId) + assert.deepEqual((await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages, []) + await assert.rejects(b.federation.call(1, a.federation.origin, "messages.send", oldJob.payload), /geändert/) + b.federation.options.enabled = false + await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "room"), /aktivieren/) + b.federation.options.enabled = true + await a.federation.settings(a.ctx, { origin: b.federation.origin, active: false }) + await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "room"), /Partnerinstanz/) + }) + } finally { + await a.app.close(); await b.app.close() + await a.close(); await b.close() + } +}) diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index cdfe8f62..020086b7 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -123,6 +123,9 @@ services: SEAFILE_ADMIN_PASSWORD: ${SEAFILE_ADMIN_PASSWORD} M2M_API_KEY: ${M2M_API_KEY} API_BASE_URL: ${API_BASE_URL} + FEDEO_PUBLIC_URL: ${FEDEO_PUBLIC_URL:-} + FEDEO_FEDERATION_URL: ${FEDEO_FEDERATION_URL:-} + FEDEO_FEDERATION_ENABLED: ${FEDEO_FEDERATION_ENABLED:-false} GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL} GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID} GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY} diff --git a/frontend/components/CollaborationRoom.vue b/frontend/components/CollaborationRoom.vue index ae87ac1e..effbb3bd 100644 --- a/frontend/components/CollaborationRoom.vue +++ b/frontend/components/CollaborationRoom.vue @@ -7,9 +7,14 @@ let generation = 0, timer, polling = false, pending = null const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}` const merge = (incoming) => { const byId = new Map(messages.value.map(m => [m.id, m])) - incoming.forEach(m => byId.set(m.id, m)) - messages.value = [...byId.values()].sort((a, b) => a.seq - b.seq) + incoming.forEach(m => { + if (m.own && m.seq) for (const [id, old] of byId) if (old.own && old.clientId === m.clientId && !old.seq) byId.delete(id) + if (!m.seq && [...byId.values()].some(old => old.own && old.seq && old.clientId === m.clientId)) return + byId.set(m.id, m) + }) + messages.value = [...byId.values()].sort((a, b) => (a.seq || Number.MAX_SAFE_INTEGER) - (b.seq || Number.MAX_SAFE_INTEGER)) } +const lastSequence = () => Math.max(0, ...messages.value.map(m => m.seq || 0)) const messageError = e => e?.data?.message || e?.data?.error || 'Unterraum konnte nicht geladen werden.' const scroll = async () => { await nextTick(); if (viewport.value) viewport.value.scrollTop = viewport.value.scrollHeight } function failed(e) { @@ -21,7 +26,8 @@ function failed(e) { } async function read(id, version) { if (version !== generation || document.hidden || !messages.value.length) return - await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: messages.value.at(-1).seq } }) + if (!lastSequence()) return + await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: lastSequence() } }) if (version === generation) emit('changed') } async function load() { @@ -41,10 +47,12 @@ async function poll() { const version = generation, id = props.roomId polling = true try { - const after = messages.value.at(-1)?.seq || 0 + const after = lastSequence() const [detail, result] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, `/messages?after=${after}`))]) if (version !== generation) return + const rightsChanged = room.value?.revision !== detail.revision room.value = detail + messages.value = messages.value.filter(m => m.seq ? m.seq >= detail.historyFromSeq : !rightsChanged) const nearBottom = !viewport.value || viewport.value.scrollHeight - viewport.value.scrollTop - viewport.value.clientHeight < 90 if (result.messages.length) { merge(result.messages) @@ -94,6 +102,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })

{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}

+

Die Partnerinstanz ist gerade nicht erreichbar. Du siehst bereits geladene Nachrichten. Neue Nachrichten warten auf Zustellung.

Freigegebene Projektinformationen

{{ room.overview.title }}

@@ -109,7 +118,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })

{{ message.own ? 'Du' : message.senderName }}

{{ message.body }}

-

{{ time(message.createdAt) }}

+

{{ time(message.createdAt) }}{{ message.pending ? ' · Wartet auf Zustellung' : message.failed ? ' · Zustellung fehlgeschlagen' : ' · Bestätigt' }}

diff --git a/frontend/pages/communication/collaboration.vue b/frontend/pages/communication/collaboration.vue index 591ff794..4cee6b6d 100644 --- a/frontend/pages/communication/collaboration.vue +++ b/frontend/pages/communication/collaboration.vue @@ -2,7 +2,7 @@ const { $api } = useNuxtApp() const route = useRoute() const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' }) -const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), creating = ref(false), showCreate = ref(false), showManagement = ref(false) +const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false) const management = ref({ members: [], invitations: [] }), inviteResult = ref(null) const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') }) const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true }) @@ -92,7 +92,8 @@ onBeforeUnmount(() => clearInterval(timer))

Freigegebene Partnerinstanzen

Beide Firmen geben die jeweils andere Instanz frei. Einzelne Personen erhalten anschließend eine eigene Einladung.

-
Partner freigeben
+

Die Föderation muss zuerst vom Betreiber aktiviert werden.

+
Partner freigeben
{{ partner.origin }} · {{ partner.active ? 'Freigegeben' : 'Gesperrt' }}{{ partner.active ? 'Sperren' : 'Freigeben' }}
@@ -144,7 +145,7 @@ onBeforeUnmount(() => clearInterval(timer))
-

Offene Einladungen

{{ item.name }} · bis {{ new Date(item.expiresAt).toLocaleDateString('de-DE') }}Widerrufen
+

Offene Einladungen

{{ item.name }} · bis {{ new Date(item.expiresAt).toLocaleDateString('de-DE') }}{{ item.delivery.status === 'sent' ? 'Bei Partnerinstanz zugestellt' : item.delivery.status === 'failed' ? 'Zustellung fehlgeschlagen' : 'Wartet auf Zustellung' }}Erneut zustellenWiderrufen

Unterraum und freigegebene Informationen

diff --git a/frontend/pages/guest.vue b/frontend/pages/guest.vue index 75b754d0..db0f04d0 100644 --- a/frontend/pages/guest.vue +++ b/frontend/pages/guest.vue @@ -8,6 +8,7 @@ const api = async (path, options = {}) => $fetch(path, { baseURL: config.public.apiBase, credentials: 'omit', ...options, headers: { ...options.headers, ...(token.value ? { Authorization: `Bearer ${token.value}` } : {}) } }) +const loginUrl = computed(() => `${import.meta.client ? window.location.origin : ''}/guest?workspace=${workspaceId.value}`) const message = e => e?.data?.message || e?.data?.error || 'Die Aktion konnte nicht ausgeführt werden.' function expire() { token.value = ''; rooms.value = []; selected.value = ''; invitation.value = null; sessionStorage.removeItem('fedeo-guest-session') } async function run(fn) { busy.value = true; error.value = ''; try { await fn() } catch (e) { error.value = message(e) } finally { busy.value = false } } @@ -38,6 +39,7 @@ async function accept() { await run(async () => { const result = await api(`/guest/api/invitations/${invitation.value.id}/accept`, { method: 'POST', body: { token: invitationToken.value } }) invitation.value = null; invitationToken.value = ''; sessionStorage.removeItem('fedeo-guest-invite') + history.replaceState(null, '', `/guest?workspace=${workspaceId.value}`) await refresh(); selected.value = result.roomId }) } @@ -47,8 +49,8 @@ onMounted(async () => { const fragment = new URLSearchParams(window.location.hash.slice(1)) invitationToken.value = fragment.get('invite') || sessionStorage.getItem('fedeo-guest-invite') || '' if (invitationToken.value) sessionStorage.setItem('fedeo-guest-invite', invitationToken.value) - history.replaceState(null, '', window.location.pathname) - workspaceId.value = localStorage.getItem('fedeo-guest-workspace') || '' + workspaceId.value = new URLSearchParams(window.location.search).get('workspace') || localStorage.getItem('fedeo-guest-workspace') || '' + history.replaceState(null, '', workspaceId.value ? `/guest?workspace=${encodeURIComponent(workspaceId.value)}` : '/guest') token.value = sessionStorage.getItem('fedeo-guest-session') || '' if (token.value) { await run(async () => { @@ -84,6 +86,7 @@ onBeforeUnmount(() => clearInterval(timer))

Dein Zugriff gilt für diesen Unterraum. Du siehst die dort eingeladenen Teilnehmer und Nachrichten ab deinem Beitritt.

Einladung annehmen +

Für spätere Anmeldungen kannst du diese Gastanmeldung als Lesezeichen speichern.