Files
FEDEO/backend/src/routes/collaboration.ts

142 lines
9.9 KiB
TypeScript

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<any[]>;
retryInvitation(ctx: UserContext, roomId: string, invitationId: string): Promise<any>;
contact(principal: Principal): string;
resolveContact(ctx: UserContext, address: string): Promise<Principal>;
invite(ctx: UserContext, roomId: string, target: Principal, invitationId: string): Promise<void>;
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<any>;
}
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",
`<p>Du wurdest in einen FEDEO-Projekt-Unterraum eingeladen.</p><p><a href="${escapeHtml(url)}">Einladung öffnen</a></p><p>Zum Anmelden erhältst du einen separaten Code an diese E-Mail-Adresse.</p>`)
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))
}