Files
FEDEO/backend/src/modules/collaboration/service.ts

465 lines
31 KiB
TypeScript

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"
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (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<void>;
invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise<void>;
}
/** 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<UserContext> {
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
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.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 body = text(input.text, 10000), clientId = uuid(input.clientId)
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,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) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409)
return { ...existing, 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)
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body}) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
await this.options.changed?.(room.id, db)
return { ...message, own: true, senderName: principal.display_name, senderKind: principal.kind }
})
}
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<Principal> {
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)
}
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", `<p>Dein Anmeldecode lautet <strong>${code}</strong>.</p><p>Er ist zehn Minuten gültig. Gib ihn nur in der FEDEO-Gastanmeldung ein.</p>`)
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<Principal> {
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
}
}