import type { FastifyInstance } from "fastify" import { sql } from "drizzle-orm" 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; list(ctx: UserContext): Promise<{ rooms: any[]; invitations: any[] }>; memberRequest(ctx: UserContext, bindingId: string, action: string, input?: any): Promise<{ found: boolean; result?: any }>; accept(ctx: UserContext, id: string, decline?: boolean): Promise<{ found: boolean; result?: any }>; settings(ctx: UserContext, input?: any): Promise; } export type CollaborationRouteOptions = { service: CollaborationService; federation?: CollaborationFederation; apiUrl: string } function body(req: any) { if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) fail("Ungültige Anfrage") return req.body } export async function collaborationRoutes(server: FastifyInstance, options: CollaborationRouteOptions) { const { service, federation } = options const context = (req: any) => service.userContext(req.user?.user_id, Number(req.user?.tenant_id)) server.addHook("onSend", async (req, reply) => { reply.header("Cache-Control", "no-store") }) server.get("/collaboration", async (req) => { const ctx = await context(req) const remote = federation ? await federation.list(ctx) : { rooms: [], invitations: [] } return { rooms: [...await service.listRooms(ctx.principal), ...remote.rooms], invitations: [...await service.invitations(ctx.principal), ...remote.invitations], managedRooms: await service.managedRooms(ctx), projects: await service.projects(ctx), canManage: ctx.manage, contact: federation?.contact(ctx.principal) || `${options.apiUrl}/federation/actors/${ctx.principal.id}` } }) 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) => { 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. await service.manager(service.db, ctx, await service.roomRow(service.db, req.params.id)) let target: Principal if (input.kind === "guest") { if (!service.options.publicUrl) fail("Die öffentliche FEDEO-Adresse ist noch nicht konfiguriert", 503) target = await service.guest(ctx, input) } else if (input.kind === "user") target = (await service.userContext(uuid(input.userId), ctx.tenantId)).principal else if (input.kind === "contact") { const address = text(input.contact, 1000) if (federation) target = await federation.resolveContact(ctx, address) else { const prefix = `${options.apiUrl}/federation/actors/` if (!address.startsWith(prefix)) fail("Föderation ist noch nicht konfiguriert", 503) const [principal] = await rows(service.db, sql`SELECT * FROM collaboration_principals WHERE id=${uuid(address.slice(prefix.length))} AND kind='user'`) if (!principal) fail("Kontakt nicht gefunden", 404) target = await service.assertPrincipal(principal) } } 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) const url = target.kind === "guest" ? `${service.options.publicUrl}/guest#invite=${invitation.token}` : null let mailSent = false if (url && input.sendEmail === true) { const delivered = await service.options.sendMail(target.email!, "Einladung zur FEDEO-Zusammenarbeit", `

Du wurdest in einen FEDEO-Projekt-Unterraum eingeladen.

Einladung öffnen

Zum Anmelden erhältst du einen separaten Code an diese E-Mail-Adresse.

`) mailSent = delivered.success } return { id: invitation.id, expiresAt: invitation.expiresAt, url, mailSent } }) server.post("/collaboration/invitations/:id/accept", async (req: any) => { const ctx = await context(req) const remote = await federation?.accept(ctx, uuid(req.params.id)) return remote?.found ? remote.result : service.accept(ctx.principal, req.params.id) }) server.post("/collaboration/invitations/:id/decline", async (req: any) => { const ctx = await context(req) const remote = await federation?.accept(ctx, uuid(req.params.id), true) return remote?.found ? remote.result : service.decline(ctx.principal, req.params.id) }) server.delete("/collaboration/rooms/:id/members/:memberId", async (req: any) => service.revoke(await context(req), req.params.id, req.params.memberId)) server.patch("/collaboration/rooms/:id/members/:memberId", async (req: any) => service.updateMember(await context(req), req.params.id, req.params.memberId, body(req))) server.delete("/collaboration/rooms/:id/invitations/:invitationId", async (req: any) => service.revoke(await context(req), req.params.id, req.params.invitationId, true)) server.get("/collaboration/federation", async (req) => { const ctx = await context(req) if (!ctx.manage) fail("Keine Berechtigung", 403) return federation ? federation.settings(ctx) : { enabled: false, partners: [] } }) server.post("/collaboration/federation", async (req) => { const ctx = await context(req) if (!ctx.manage) fail("Keine Berechtigung", 403) if (!federation) fail("Föderation ist noch nicht konfiguriert", 503) return federation.settings(ctx, body(req)) }) const request = async (req: any, action: string) => { const ctx = await context(req), id = uuid(req.params.id) const input = req.method === "GET" ? req.query : body(req) const remote = await federation?.memberRequest(ctx, id, action, input) if (remote?.found) return remote.result if (action === "room") return service.room(ctx.principal, id) if (action === "messages") return service.messages(ctx.principal, id, input) if (action === "send") return service.send(ctx.principal, id, input) if (action === "read") return service.markRead(ctx.principal, id, input.seq) return { members: await service.members(ctx.principal, id) } } server.get("/collaboration/rooms/:id", async (req) => request(req, "room")) server.get("/collaboration/rooms/:id/messages", async (req) => request(req, "messages")) server.post("/collaboration/rooms/:id/messages", async (req) => request(req, "send")) server.post("/collaboration/rooms/:id/read", async (req) => request(req, "read")) server.get("/collaboration/rooms/:id/members", async (req) => request(req, "members")) } export async function collaborationGuestRoutes(server: FastifyInstance, { service }: CollaborationRouteOptions) { server.addHook("onSend", async (req, reply) => { reply.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer") }) const token = (req: any) => String(req.headers.authorization || "").replace(/^Bearer /, "") const principal = (req: any) => service.guestSession(token(req)) server.post("/guest/api/auth/request", { bodyLimit: 4096 }, async (req) => service.requestGuestCode(body(req), req.ip)) server.post("/guest/api/auth/verify", { bodyLimit: 4096 }, async (req) => service.verifyGuestCode(body(req), req.ip)) server.post("/guest/api/logout", async (req) => service.logoutGuest(token(req))) server.get("/guest/api/rooms", async (req) => ({ rooms: await service.listRooms(await principal(req)) })) server.post("/guest/api/invitation", async (req) => service.guestInvitation(await principal(req), body(req).token)) server.post("/guest/api/invitations/:id/accept", async (req: any) => service.accept(await principal(req), uuid(req.params.id), body(req).token)) server.get("/guest/api/rooms/:id", async (req: any) => service.room(await principal(req), req.params.id)) server.get("/guest/api/rooms/:id/messages", async (req: any) => service.messages(await principal(req), req.params.id, req.query)) server.get("/guest/api/rooms/:id/members", async (req: any) => ({ members: await service.members(await principal(req), req.params.id) })) server.post("/guest/api/rooms/:id/messages", async (req: any) => service.send(await principal(req), req.params.id, body(req))) server.post("/guest/api/rooms/:id/read", async (req: any) => service.markRead(await principal(req), req.params.id, body(req).seq)) }