142 lines
9.9 KiB
TypeScript
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))
|
|
}
|