import { randomBytes, randomInt, randomUUID, createHmac, timingSafeEqual } from "node:crypto" import { sql } from "drizzle-orm" import type { NodePgDatabase } from "drizzle-orm/node-postgres" import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types" import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content" import type { CollaborationNotifications } from "./notifications" type Db = Pick, "execute" | "transaction"> export const rows = async (db: Pick, query: any): Promise => (await db.execute(query)).rows export type CollaborationOptions = { publicUrl: string; 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; notifications?: CollaborationNotifications; } /** Owner-side authority. All membership changes and sends serialize on the room row. */ export class CollaborationService { constructor(readonly db: Db, readonly options: CollaborationOptions) {} async currentUser(userId: string, tenantId: number, db: Db = this.db) { uuid(userId) if (!Number.isSafeInteger(tenantId) || tenantId < 1) fail("Kein aktiver Mandant", 403) const [user] = await rows(db, sql` SELECT u.id, u.email, COALESCE(NULLIF(p.full_name,''),u.email) AS name, (u.is_admin OR EXISTS (SELECT 1 FROM auth_user_roles ur JOIN auth_roles r ON r.id=ur.role_id AND (r.tenant_id IS NULL OR r.tenant_id=${tenantId}) JOIN auth_role_permissions rp ON rp.role_id=r.id WHERE ur.user_id=u.id AND ur.tenant_id=${tenantId} AND rp.permission='organisation.projects.write')) AS manage FROM auth_users u JOIN auth_tenant_users tu ON tu.user_id=u.id AND tu.tenant_id=${tenantId} JOIN tenants t ON t.id=tu.tenant_id LEFT JOIN auth_profiles p ON p.user_id=u.id AND p.tenant_id=tu.tenant_id WHERE u.id=${userId} AND (p.id IS NULL OR p.active) AND t.locked IS DISTINCT FROM 'maintenance_tenant' LIMIT 1`) if (!user) fail("Keine aktive Mandantenmitgliedschaft", 403) return user } async userContext(userId: string, tenantId: number): Promise { const user = await this.currentUser(userId, tenantId) const [principal] = await rows(this.db, sql` INSERT INTO collaboration_principals(kind,tenant_id,user_id,display_name,email) VALUES ('user',${tenantId},${userId},${user.name},${user.email.toLowerCase()}) ON CONFLICT (tenant_id,user_id) WHERE kind='user' DO UPDATE SET display_name=excluded.display_name,email=excluded.email RETURNING *`) if (principal.disabled) fail("Zusammenarbeit ist für diesen Benutzer gesperrt", 403) return { userId, tenantId, principal, manage: Boolean(user.manage) } } async workspace(tenantId: number, db: Db = this.db) { const [workspace] = await rows(db, sql`INSERT INTO collaboration_workspaces(tenant_id) VALUES (${tenantId}) ON CONFLICT (tenant_id) DO UPDATE SET tenant_id=excluded.tenant_id RETURNING *`) return workspace } async projects(ctx: UserContext) { if (!ctx.manage) return [] return rows(this.db, sql`SELECT id,name FROM projects WHERE tenant=${ctx.tenantId} AND NOT archived ORDER BY name`) } async localContacts(ctx: UserContext) { if (!ctx.manage) fail("Keine Berechtigung zur Teilnehmerverwaltung", 403) return rows(this.db, sql`SELECT u.id, COALESCE(NULLIF(p.full_name,''),u.email) AS name FROM auth_tenant_users tu JOIN auth_users u ON u.id=tu.user_id LEFT JOIN auth_profiles p ON p.user_id=u.id AND p.tenant_id=tu.tenant_id WHERE tu.tenant_id=${ctx.tenantId} AND (p.id IS NULL OR p.active) ORDER BY name`) } async assertPrincipal(principal: Principal, db: Db = this.db) { const [current] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${principal.id} AND NOT disabled`) if (!current) fail("Teilnehmer ist nicht verfügbar", 403) if (current.kind === "user") await this.currentUser(current.user_id, Number(current.tenant_id), db) if (current.kind === "guest") { const [allowed] = await rows(db, sql`SELECT 1 FROM collaboration_workspaces w JOIN tenants t ON t.id=w.tenant_id WHERE w.tenant_id=${current.tenant_id} AND w.guests_enabled AND t.locked IS DISTINCT FROM 'maintenance_tenant'`) if (!allowed) fail("Gastzugang ist gesperrt", 403) } return current as Principal } async roomRow(db: Db, roomId: string, lock = false) { const [room] = await rows(db, sql`SELECT r.*, p.archived AS project_archived, t.locked AS tenant_locked FROM collaboration_rooms r JOIN tenants t ON t.id=r.tenant_id LEFT JOIN projects p ON p.id=r.project_id AND p.tenant=r.tenant_id WHERE r.id=${uuid(roomId)} ${lock ? sql`FOR UPDATE OF r` : sql``}`) if (!room) fail("Unterraum nicht gefunden", 404) return room } assertOpen(room: any, write = false) { if (room.status === "blocked" || room.tenant_locked === "maintenance_tenant") fail("Unterraum ist gesperrt", 403) if (write && (room.status !== "active" || room.project_archived)) fail("Unterraum ist archiviert", 409) } async membership(db: Db, room: any, principal: Principal, write = false) { this.assertOpen(room, write) await this.assertPrincipal(principal, db) const [member] = await rows(db, sql`SELECT * FROM collaboration_members WHERE room_id=${room.id} AND principal_id=${principal.id} AND status='active' AND (expires_at IS NULL OR expires_at>now())`) if (!member) fail("Unterraum nicht gefunden oder kein Zugriff", 404) if (write && member.role !== "participant") fail("Dieser Unterraum ist für dich nur lesbar", 403) return member } async manager(db: Db, ctx: UserContext, room: any) { const user = await this.currentUser(ctx.userId, ctx.tenantId, db) if (Number(room.tenant_id) !== ctx.tenantId || !user.manage) fail("Keine Berechtigung zur Unterraumverwaltung", 403) } async audit(db: Db, room: any, actor: Principal, action: string, subjectId?: string) { await db.execute(sql`INSERT INTO collaboration_audit(tenant_id,room_id,actor_id,action,subject_id) VALUES (${room.tenant_id},${room.id},${actor.id},${action},${subjectId || null})`) } async createRoom(ctx: UserContext, input: any) { const name = text(input.name), topic = text(input.topic, 1000, true) const projectId = input.projectId == null ? null : Number(input.projectId) if (projectId !== null && (!Number.isSafeInteger(projectId) || projectId < 1)) fail("Ungültiges Projekt") if (!ctx.manage) fail("Keine Berechtigung zum Anlegen von Unterräumen", 403) return this.db.transaction(async (db) => { const user = await this.currentUser(ctx.userId, ctx.tenantId, db) if (!user.manage) fail("Keine Berechtigung", 403) if (projectId !== null) { const [project] = await rows(db, sql`SELECT id FROM projects WHERE id=${projectId} AND tenant=${ctx.tenantId} AND NOT archived FOR SHARE`) if (!project) fail("Projekt nicht verfügbar", 404) } await this.workspace(ctx.tenantId, db) const [room] = await rows(db, sql`INSERT INTO collaboration_rooms(tenant_id,project_id,name,topic,created_by) VALUES (${ctx.tenantId},${projectId},${name},${topic},${ctx.userId}) RETURNING *`) await db.execute(sql`INSERT INTO collaboration_members(room_id,principal_id,history_from_seq,overview_allowed) VALUES (${room.id},${ctx.principal.id},1,true)`) await this.audit(db, room, ctx.principal, "room.created") return { id: room.id, name: room.name } }) } async listRooms(principal: Principal) { await this.assertPrincipal(principal) return rows(this.db, sql`SELECT r.id,r.name,r.topic,r.status,r.project_id IS NOT NULL AS "isProjectRoom", (r.status='active' AND NOT coalesce(p.archived,false) AND m.role='participant') AS "canSend", t.name AS "ownerName", m.overview_allowed AS "overviewAllowed", (SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread ,coalesce((SELECT max(cm.created_at) FROM collaboration_messages cm WHERE cm.room_id=r.id AND cm.seq>=m.history_from_seq), r.created_at) AS "lastActivityAt" FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id LEFT JOIN projects p ON p.id=r.project_id WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now()) AND r.status<>'blocked' AND t.locked IS DISTINCT FROM 'maintenance_tenant' ORDER BY r.updated_at DESC`) } async managedRooms(ctx: UserContext, projectId?: number) { if (!ctx.manage) return [] return rows(this.db, sql`SELECT r.id,r.name,r.topic,r.status,r.project_id AS "projectId",r.shared_overview AS "overview", EXISTS(SELECT 1 FROM collaboration_members m WHERE m.room_id=r.id AND m.principal_id=${ctx.principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())) AS "isMember" FROM collaboration_rooms r WHERE r.tenant_id=${ctx.tenantId} ${projectId ? sql`AND r.project_id=${projectId}` : sql``} ORDER BY r.created_at DESC`) } async room(principal: Principal, roomId: string) { const room = await this.roomRow(this.db, roomId) const member = await this.membership(this.db, room, principal) return { id: room.id, name: room.name, topic: room.topic, status: room.project_archived ? "archived" : room.status, canSend: room.status === "active" && !room.project_archived && member.role === "participant", overview: member.overview_allowed ? room.shared_overview : null, historyFromSeq: Number(member.history_from_seq), lastReadSeq: Number(member.last_read_seq), revision: Number(member.revision), } } async messages(principal: Principal, roomId: string, query: any = {}) { const room = await this.roomRow(this.db, roomId) const member = await this.membership(this.db, room, principal) const after = sequence(query.after), before = sequence(query.before) const result = await rows(this.db, sql`SELECT m.id,m.client_id AS "clientId",m.seq::float8 AS seq,m.body,m.image - 'dataUrl' AS image,m.created_at AS "createdAt", p.display_name AS "senderName",p.kind AS "senderKind", m.principal_id=${principal.id} AS own FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.room_id=${room.id} AND m.seq>=${member.history_from_seq} ${after ? sql`AND m.seq>${after}` : sql``} ${before ? sql`AND m.seq<${before}` : sql``} ORDER BY m.seq ${after ? sql`ASC` : sql`DESC`} LIMIT 50`) return { messages: after ? result : result.reverse(), hasMore: result.length === 50 } } async send(principal: Principal, roomId: string, input: any) { const clientId = uuid(input.clientId) await this.membership(this.db, await this.roomRow(this.db, roomId), principal, true) const { body, image } = await chatContent(input) return this.db.transaction(async (db) => { const room = await this.roomRow(db, roomId, 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,image,created_at AS "createdAt" FROM collaboration_messages WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`) if (existing) { if (existing.body !== body || (existing.image?.dataUrl || null) !== (image?.dataUrl || null) || (existing.image?.name || null) !== (image?.name || null)) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409) return { ...existing, image: imageMetadata(existing.image), own: true, senderName: principal.display_name, senderKind: principal.kind } } const [next] = await rows(db, sql`UPDATE collaboration_rooms SET sequence=sequence+1,updated_at=now() WHERE id=${room.id} RETURNING sequence`) const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image) VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`) await this.options.changed?.(room.id, db) await this.options.notifications?.localMessage(db, room, principal, message) return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind } }) } async image(principal: Principal, roomId: string, input: any) { const room = await this.roomRow(this.db, roomId) const member = await this.membership(this.db, room, principal) return chatImage(this.db, "collaboration", room.id, input?.messageId, Number(member.history_from_seq)) } async reactions(principal: Principal, roomId: string, input: any) { const room = await this.roomRow(this.db, roomId) const member = await this.membership(this.db, room, principal) return chatReactions(this.db, "collaboration", room.id, principal.id, input?.ids, Number(member.history_from_seq)) } async react(principal: Principal, roomId: string, input: any) { return this.db.transaction(async db => { const room = await this.roomRow(db, roomId, 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) return setChatReaction(db, "collaboration", room.id, principal.id, input, Number(member.history_from_seq)) }) } async markRead(principal: Principal, roomId: string, value: unknown) { const requested = sequence(value) return this.db.transaction(async (db) => { const room = await this.roomRow(db, roomId, true) await this.membership(db, room, principal) await db.execute(sql`UPDATE collaboration_members SET last_read_seq=GREATEST(last_read_seq,${Math.min(requested, Number(room.sequence))}) WHERE room_id=${room.id} AND principal_id=${principal.id}`) return { read: true } }) } async members(principal: Principal, roomId: string) { const room = await this.roomRow(this.db, roomId) await this.membership(this.db, room, principal) return rows(this.db, sql`SELECT p.id,p.display_name AS name,p.kind,m.role,p.id=${principal.id} AS own FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.room_id=${room.id} AND m.status='active' AND NOT p.disabled AND (m.expires_at IS NULL OR m.expires_at>now()) ORDER BY p.display_name`) } async updateRoom(ctx: UserContext, roomId: string, input: any) { if (Object.keys(input).some((key) => !["name", "topic", "status", "overview"].includes(key))) fail("Unbekanntes Unterraumfeld") return this.db.transaction(async (db) => { const room = await this.roomRow(db, roomId, true) await this.manager(db, ctx, room) const status = input.status ?? room.status if (!["active", "archived", "blocked"].includes(status)) fail("Ungültiger Status") await db.execute(sql`UPDATE collaboration_rooms SET name=${input.name === undefined ? room.name : text(input.name)}, topic=${input.topic === undefined ? room.topic : text(input.topic, 1000, true)},status=${status}, shared_overview=${JSON.stringify(input.overview === undefined ? room.shared_overview : overview(input.overview))}::jsonb,updated_at=now() WHERE id=${room.id}`) if (status !== "active") await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' WHERE room_id=${room.id} AND status='pending'`) await this.audit(db, room, ctx.principal, "room.updated") await this.options.changed?.(room.id, db) return { updated: true } }) } async guest(ctx: UserContext, input: any): Promise { if (!ctx.manage) fail("Keine Berechtigung", 403) const workspace = await this.workspace(ctx.tenantId) if (!workspace.guests_enabled) fail("Gastzugang ist deaktiviert", 403) const address = email(input.email), name = text(input.name) const [principal] = await rows(this.db, sql`INSERT INTO collaboration_principals(kind,tenant_id,email,display_name) VALUES ('guest',${ctx.tenantId},${address},${name}) ON CONFLICT (tenant_id,email) WHERE kind='guest' DO UPDATE SET email=excluded.email RETURNING *`) if (principal.disabled) fail("Gastzugang ist gesperrt", 403) return principal } async invite(ctx: UserContext, roomId: string, target: Principal, input: any) { const role = input.role || "participant" if (!["participant", "reader"].includes(role)) fail("Ungültige Rolle") if (input.overviewAllowed !== undefined && typeof input.overviewAllowed !== "boolean") fail("Ungültige Freigabe") const accessExpires = futureDate(input.expiresAt) || (target.kind === "guest" ? new Date(Date.now() + 30 * 86400000) : null) const token = randomBytes(32).toString("base64url") const result = await this.db.transaction(async (db) => { const room = await this.roomRow(db, roomId, true) await this.manager(db, ctx, room) this.assertOpen(room, true) await this.assertPrincipal(target, db) if (target.kind === "guest" && (Number(target.tenant_id) !== ctx.tenantId || !room.project_id)) { fail("Gäste können nur in Projekt-Unterräume eingeladen werden") } const [active] = await rows(db, sql`SELECT id FROM collaboration_members WHERE room_id=${room.id} AND principal_id=${target.id} AND status='active' AND (expires_at IS NULL OR expires_at>now())`) if (active) fail("Diese Person nimmt bereits teil", 409) await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' WHERE room_id=${room.id} AND target_principal_id=${target.id} AND status='pending'`) const [invite] = await rows(db, sql`INSERT INTO collaboration_invitations(room_id,target_principal_id,token_hash,role,overview_allowed,invited_by,expires_at,access_expires_at) 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) } await this.options.notifications?.localInvitation(db, room, target, invite.id) return invite }) return { ...result, token } } async invitations(principal: Principal) { await this.assertPrincipal(principal) return rows(this.db, sql`SELECT i.id,r.name,r.topic,i.role,i.overview_allowed AS "overviewAllowed", i.expires_at AS "expiresAt",t.name AS "ownerName" FROM collaboration_invitations i JOIN collaboration_rooms r ON r.id=i.room_id JOIN tenants t ON t.id=r.tenant_id LEFT JOIN projects p ON p.id=r.project_id WHERE i.target_principal_id=${principal.id} AND i.status='pending' AND i.expires_at>now() AND r.status='active' AND NOT coalesce(p.archived,false) AND t.locked IS DISTINCT FROM 'maintenance_tenant' ORDER BY i.created_at DESC`) } async accept(principal: Principal, invitationId: string, token?: string) { const [hint] = await rows(this.db, sql`SELECT room_id FROM collaboration_invitations WHERE id=${uuid(invitationId)}`) if (!hint) fail("Einladung nicht verfügbar", 404) return this.db.transaction(async (db) => { const room = await this.roomRow(db, hint.room_id, true) this.assertOpen(room, true) await this.assertPrincipal(principal, db) const [invite] = await rows(db, sql`SELECT * FROM collaboration_invitations WHERE id=${invitationId} FOR UPDATE`) if (invite.target_principal_id !== principal.id || (principal.kind === "guest" && invite.token_hash !== hash(text(token, 100)))) fail("Einladung nicht verfügbar", 404) if (invite.status === "accepted") { await this.membership(db, room, principal) return { roomId: room.id } } if (invite.status !== "pending" || new Date(invite.expires_at).getTime() <= Date.now() || (invite.access_expires_at && new Date(invite.access_expires_at).getTime() <= Date.now())) fail("Einladung ist abgelaufen oder widerrufen", 410) const inviter = await this.currentUser(invite.invited_by, Number(room.tenant_id), db) if (!inviter.manage) fail("Einladung ist nicht mehr gültig", 410) await db.execute(sql`INSERT INTO collaboration_members(room_id,principal_id,role,overview_allowed,history_from_seq,expires_at) VALUES (${room.id},${principal.id},${invite.role},${invite.overview_allowed},${Number(room.sequence) + 1},${invite.access_expires_at}) ON CONFLICT (room_id,principal_id) DO UPDATE SET status='active',role=excluded.role,overview_allowed=excluded.overview_allowed, history_from_seq=excluded.history_from_seq,expires_at=excluded.expires_at,revision=collaboration_members.revision+1,revoked_at=null,joined_at=now()`) await db.execute(sql`UPDATE collaboration_invitations SET status='accepted',accepted_at=now() WHERE id=${invite.id}`) await this.audit(db, room, principal, "invitation.accepted", invite.id) await this.options.changed?.(room.id, db) return { roomId: room.id } }) } async decline(principal: Principal, invitationId: string) { await this.assertPrincipal(principal) await this.db.execute(sql`UPDATE collaboration_invitations SET status='declined' WHERE id=${uuid(invitationId)} AND target_principal_id=${principal.id} AND status='pending'`) return { declined: true } } async management(ctx: UserContext, roomId: string) { const room = await this.roomRow(this.db, roomId) await this.manager(this.db, ctx, room) return { members: await rows(this.db, sql`SELECT m.id,m.principal_id AS "principalId",p.display_name AS name,p.kind,p.email, m.status,m.role,m.overview_allowed AS "overviewAllowed",m.expires_at AS "expiresAt" FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.room_id=${room.id} ORDER BY m.joined_at`), invitations: await rows(this.db, sql`SELECT i.id,p.display_name AS name,p.kind,i.status,i.expires_at AS "expiresAt" FROM collaboration_invitations i JOIN collaboration_principals p ON p.id=i.target_principal_id WHERE i.room_id=${room.id} ORDER BY i.created_at DESC LIMIT 100`), } } async revoke(ctx: UserContext, roomId: string, subjectId: string, invitation = false) { return this.db.transaction(async (db) => { const room = await this.roomRow(db, roomId, true) await this.manager(db, ctx, room) if (invitation) { await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' WHERE id=${uuid(subjectId)} AND room_id=${room.id} AND status='pending'`) } else { const [member] = await rows(db, sql`UPDATE collaboration_members SET status='revoked',revision=revision+1,revoked_at=now() WHERE id=${uuid(subjectId)} AND room_id=${room.id} RETURNING principal_id`) if (member) await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' WHERE room_id=${room.id} AND target_principal_id=${member.principal_id} AND status='pending'`) } await this.audit(db, room, ctx.principal, invitation ? "invitation.revoked" : "member.revoked", subjectId) await this.options.changed?.(room.id, db) return { revoked: true } }) } async updateMember(ctx: UserContext, roomId: string, memberId: string, input: any) { if (Object.keys(input).some(key => !["role", "overviewAllowed", "expiresAt"].includes(key))) fail("Unbekanntes Berechtigungsfeld") if (!["participant", "reader"].includes(input.role) || typeof input.overviewAllowed !== "boolean") fail("Ungültige Berechtigung") return this.db.transaction(async db => { const room = await this.roomRow(db, roomId, true) await this.manager(db, ctx, room) const [member] = await rows(db, sql`SELECT * FROM collaboration_members WHERE id=${uuid(memberId)} AND room_id=${room.id} AND status='active' AND (expires_at IS NULL OR expires_at>now())`) if (!member) fail("Teilnahme ist nicht mehr aktiv. Bitte erneut einladen.", 410) await db.execute(sql`UPDATE collaboration_members SET role=${input.role},overview_allowed=${input.overviewAllowed}, expires_at=${input.expiresAt === undefined ? member.expires_at : futureDate(input.expiresAt)},revision=revision+1 WHERE id=${member.id}`) await this.audit(db, room, ctx.principal, "member.updated", member.id) await this.options.changed?.(room.id, db) return { updated: true } }) } async rateLimit(key: string, limit: number, seconds: number) { const [counter] = await rows(this.db, sql`INSERT INTO collaboration_rate_limits(key,hits,resets_at) VALUES (${hash(key)},1,now()+${seconds}*interval '1 second') ON CONFLICT (key) DO UPDATE SET hits=CASE WHEN collaboration_rate_limits.resets_at<=now() THEN 1 ELSE collaboration_rate_limits.hits+1 END, resets_at=CASE WHEN collaboration_rate_limits.resets_at<=now() THEN excluded.resets_at ELSE collaboration_rate_limits.resets_at END RETURNING hits`) if (counter.hits > limit) fail("Zu viele Anfragen. Bitte später erneut versuchen.", 429) } codeHash(id: string, code: string) { const pepper = this.options.guestPepper() if (pepper.length < 32) fail("Gastanmeldung ist noch nicht konfiguriert", 503) return createHmac("sha256", pepper).update(`fedeo-guest-code-v1:${id}:${code}`).digest("hex") } async requestGuestCode(input: any, ip: string) { await this.rateLimit(`guest-request:${ip}`, 10, 600) let guest: Principal | undefined if (input.invitationToken) { const [row] = await rows(this.db, sql`SELECT p.*,i.id AS invitation_id FROM collaboration_invitations i JOIN collaboration_principals p ON p.id=i.target_principal_id JOIN collaboration_rooms r ON r.id=i.room_id LEFT JOIN projects project ON project.id=r.project_id WHERE i.token_hash=${hash(text(input.invitationToken, 100))} AND i.status='pending' AND i.expires_at>now() AND p.kind='guest' AND NOT p.disabled AND r.status='active' AND NOT coalesce(project.archived,false)`) guest = row } else { const address = email(input.email) const [row] = await rows(this.db, sql`SELECT p.* FROM collaboration_principals p JOIN collaboration_workspaces w ON w.tenant_id=p.tenant_id WHERE w.public_id=${uuid(input.workspaceId)} AND w.guests_enabled AND p.kind='guest' AND p.email=${address} AND NOT p.disabled AND EXISTS(SELECT 1 FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id WHERE m.principal_id=p.id AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now()) AND r.status<>'blocked')`) guest = row } const challengeId = randomUUID() if (!guest) return { challengeId } await this.assertPrincipal(guest) await this.rateLimit(`guest-mail:${guest.id}`, 3, 600) const code = String(randomInt(10000000, 100000000)) await this.db.execute(sql`INSERT INTO collaboration_guest_challenges(id,principal_id,code_hash,expires_at) VALUES (${challengeId},${guest.id},${this.codeHash(challengeId, code)},now()+interval '10 minutes')`) const delivery = await this.options.sendMail(guest.email!, "Dein FEDEO-Anmeldecode", `

Dein Anmeldecode lautet ${code}.

Er ist zehn Minuten gültig. Gib ihn nur in der FEDEO-Gastanmeldung ein.

`) if (!delivery.success) { await this.db.execute(sql`DELETE FROM collaboration_guest_challenges WHERE id=${challengeId}`) fail("Anmeldecode konnte nicht zugestellt werden", 503) } return { challengeId } } async verifyGuestCode(input: any, ip: string) { await this.rateLimit(`guest-verify:${ip}`, 30, 600) const id = uuid(input.challengeId), code = text(input.code, 32) const result = await this.db.transaction(async (db) => { const [challenge] = await rows(db, sql`SELECT * FROM collaboration_guest_challenges WHERE id=${id} FOR UPDATE`) if (!challenge || challenge.consumed_at || challenge.attempts >= 5 || new Date(challenge.expires_at).getTime() <= Date.now()) return null await db.execute(sql`UPDATE collaboration_guest_challenges SET attempts=attempts+1 WHERE id=${id}`) if (!timingSafeEqual(Buffer.from(challenge.code_hash, "hex"), Buffer.from(this.codeHash(id, code), "hex"))) return null const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${challenge.principal_id}`) await this.assertPrincipal(principal, db) await db.execute(sql`UPDATE collaboration_guest_challenges SET consumed_at=now() WHERE id=${id}`) const token = randomBytes(32).toString("base64url") await db.execute(sql`INSERT INTO collaboration_guest_sessions(token_hash,principal_id,expires_at) VALUES (${hash(token)},${principal.id},now()+interval '12 hours')`) const workspace = await this.workspace(Number(principal.tenant_id), db) return { token, workspaceId: workspace.public_id, name: principal.display_name } }) if (!result) fail("Anmeldecode ist ungültig oder abgelaufen", 401) return result } async guestSession(token: string): Promise { if (!/^[A-Za-z0-9_-]{43}$/.test(token || "")) fail("Gastanmeldung erforderlich", 401) const [principal] = await rows(this.db, sql`SELECT p.* FROM collaboration_guest_sessions s JOIN collaboration_principals p ON p.id=s.principal_id WHERE s.token_hash=${hash(token)} AND s.expires_at>now() AND p.kind='guest'`) if (!principal) fail("Gastanmeldung erforderlich", 401) return this.assertPrincipal(principal) } async logoutGuest(token: string) { await this.db.execute(sql`DELETE FROM collaboration_guest_sessions WHERE token_hash=${hash(token)}`) return { loggedOut: true } } async guestInvitation(principal: Principal, token: string) { const [invite] = await rows(this.db, sql`SELECT i.id,r.name,r.topic,i.role,i.overview_allowed AS "overviewAllowed",i.expires_at AS "expiresAt",i.status FROM collaboration_invitations i JOIN collaboration_rooms r ON r.id=i.room_id WHERE i.token_hash=${hash(text(token, 100))} AND i.target_principal_id=${principal.id}`) if (!invite) fail("Einladung nicht verfügbar", 404) return invite } }