import { createHash, createHmac, randomBytes } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { resolve } from "node:path" import { FastifyInstance } from "fastify" import { authProfiles, authUsers, tenants } from "../../db/schema" import { and, eq } from "drizzle-orm" import { secrets } from "../utils/secrets" type MatrixErrorResponse = { errcode?: string error?: string } const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") const readLocalDevRegistrationSharedSecret = () => { if (process.env.NODE_ENV === "production") return "" const candidates = [ resolve(process.cwd(), "../matrix/dev/synapse/homeserver.yaml"), resolve(process.cwd(), "matrix/dev/synapse/homeserver.yaml"), ] for (const candidate of candidates) { if (!existsSync(candidate)) continue const content = readFileSync(candidate, "utf8") const match = content.match(/^registration_shared_secret:\s*["']?(.+?)["']?\s*$/m) if (match?.[1]) { return match[1] } } return "" } const normalizeMatrixLocalpartSeed = (value: string) => { const normalized = value .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/ä/g, "a") .replace(/ö/g, "o") .replace(/ü/g, "u") .replace(/ß/g, "ss") .replace(/[^a-z0-9._=-]+/g, "_") .replace(/_+/g, "_") .replace(/^[._=-]+|[._=-]+$/g, "") return normalized || "user" } const normalizeMatrixAliasSeed = (value: string) => normalizeMatrixLocalpartSeed(value) .replace(/[.=]/g, "_") .replace(/_+/g, "_") export function matrixService(server: FastifyInstance) { const homeserverUrl = () => trimTrailingSlash( process.env.MATRIX_HOMESERVER_URL || secrets.MATRIX_HOMESERVER_URL || "http://localhost:8008" ) const serverName = () => process.env.MATRIX_SERVER_NAME || secrets.MATRIX_SERVER_NAME || "localhost" const registrationSharedSecret = () => process.env.MATRIX_REGISTRATION_SHARED_SECRET || secrets.MATRIX_REGISTRATION_SHARED_SECRET || readLocalDevRegistrationSharedSecret() || "" const serviceUserLocalpart = () => process.env.MATRIX_SERVICE_USER_LOCALPART || secrets.MATRIX_SERVICE_USER_LOCALPART || "fedeo_service" const serviceUserPassword = () => createHmac("sha256", registrationSharedSecret()) .update(`${serverName()}:fedeo-service-user`) .digest("base64url") const getUserIdentitySeed = async (userId: string, tenantId: number | null) => { const [user] = await server.db .select({ email: authUsers.email }) .from(authUsers) .where(eq(authUsers.id, userId)) .limit(1) if (user?.email) { return user.email.split("@")[0] || user.email } if (tenantId) { const displayName = await getCurrentUserDisplayName(userId, tenantId) if (displayName && !displayName.startsWith("@")) { return displayName } } return "user" } const matrixLocalpartForUser = async (userId: string, tenantId: number | null) => { const seed = normalizeMatrixLocalpartSeed(await getUserIdentitySeed(userId, tenantId)) const hash = createHash("sha256").update(userId).digest("hex").slice(0, 8) return `${seed}_${hash}` } const matrixUserIdForUser = async (userId: string, tenantId: number | null) => `@${await matrixLocalpartForUser(userId, tenantId)}:${serverName()}` const tenantSpaceAliasLocalpart = (tenant: { id: number, short?: string | null, name?: string | null }) => { const seed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) return `fedeo_${seed}_${tenant.id}` } const tenantSpaceAlias = (tenant: { id: number, short?: string | null, name?: string | null }) => `#${tenantSpaceAliasLocalpart(tenant)}:${serverName()}` const tenantRoomAliasLocalpart = ( tenant: { id: number, short?: string | null, name?: string | null }, roomKey: string ) => { const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) const roomSeed = normalizeMatrixAliasSeed(roomKey) return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}` } const tenantRoomAlias = ( tenant: { id: number, short?: string | null, name?: string | null }, roomKey: string ) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${serverName()}` const buildSharedSecretMac = ( nonce: string, username: string, password: string, admin: boolean ) => { const hmac = createHmac("sha1", registrationSharedSecret()) hmac.update(nonce) hmac.update("\0") hmac.update(username) hmac.update("\0") hmac.update(password) hmac.update("\0") hmac.update(admin ? "admin" : "notadmin") return hmac.digest("hex") } const registerWithSharedSecret = async ( username: string, password: string, admin: boolean ) => { const nonceResponse = await requestJson<{ nonce: string }>( `${homeserverUrl()}/_synapse/admin/v1/register` ) const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, admin) return requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nonce: nonceResponse.nonce, username, password, admin, mac, }), }) } const loginMatrixUser = async (username: string, password: string) => { return requestJson<{ access_token: string user_id: string }>(`${homeserverUrl()}/_matrix/client/v3/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "m.login.password", identifier: { type: "m.id.user", user: username, }, password, }), }) } const ensureServiceAccessToken = async () => { if (!registrationSharedSecret()) { throw Object.assign( new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), { statusCode: 503 } ) } const username = serviceUserLocalpart() const password = serviceUserPassword() try { await registerWithSharedSecret(username, password, true) } catch (err: any) { if (err.errcode !== "M_USER_IN_USE") { throw err } } return loginMatrixUser(username, password) } const getCurrentUserDisplayName = async (userId: string, tenantId: number | null) => { if (tenantId) { const [profile] = await server.db .select({ firstName: authProfiles.first_name, lastName: authProfiles.last_name, }) .from(authProfiles) .where(and( eq(authProfiles.user_id, userId), eq(authProfiles.tenant_id, tenantId) )) .limit(1) const profileName = [profile?.firstName, profile?.lastName] .filter(Boolean) .join(" ") .trim() if (profileName) return profileName } const [user] = await server.db .select({ email: authUsers.email }) .from(authUsers) .where(eq(authUsers.id, userId)) .limit(1) return user?.email || await matrixUserIdForUser(userId, tenantId) } const requestJson = async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init) const text = await response.text() const body = text ? JSON.parse(text) : {} if (!response.ok) { const error = body as MatrixErrorResponse throw Object.assign( new Error(error.error || `Matrix request failed with ${response.status}`), { statusCode: response.status, errcode: error.errcode, body, } ) } return body as T } const requestMatrixJson = async (path: string, accessToken: string, init?: RequestInit): Promise => { return requestJson(`${homeserverUrl()}${path}`, { ...init, headers: { ...(init?.headers || {}), Authorization: `Bearer ${accessToken}`, }, }) } const getStatus = async () => { const configured = Boolean(homeserverUrl() && serverName()) if (!configured) { return { configured: false, homeserverUrl: homeserverUrl(), serverName: serverName(), provisioningConfigured: Boolean(registrationSharedSecret()), reachable: false, } } try { const versions = await requestJson<{ versions: string[] }>( `${homeserverUrl()}/_matrix/client/versions` ) return { configured: true, homeserverUrl: homeserverUrl(), serverName: serverName(), provisioningConfigured: Boolean(registrationSharedSecret()), reachable: true, versions: versions.versions, } } catch (err: any) { return { configured: true, homeserverUrl: homeserverUrl(), serverName: serverName(), provisioningConfigured: Boolean(registrationSharedSecret()), reachable: false, error: err.message, } } } const provisionCurrentUser = async (userId: string, tenantId: number | null) => { if (!registrationSharedSecret()) { throw Object.assign( new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), { statusCode: 503 } ) } const username = await matrixLocalpartForUser(userId, tenantId) const matrixUserId = await matrixUserIdForUser(userId, tenantId) const password = randomBytes(32).toString("base64url") const displayName = await getCurrentUserDisplayName(userId, tenantId) try { await registerWithSharedSecret(username, password, false) return { matrixUserId, localpart: username, displayName, created: true, alreadyExisted: false, } } catch (err: any) { if (err.errcode === "M_USER_IN_USE") { return { matrixUserId, localpart: username, displayName, created: false, alreadyExisted: true, } } throw err } } const getCurrentTenant = async (tenantId: number | null) => { if (!tenantId) { throw Object.assign( new Error("No active tenant selected"), { statusCode: 400 } ) } const [tenant] = await server.db .select({ id: tenants.id, name: tenants.name, short: tenants.short, }) .from(tenants) .where(eq(tenants.id, tenantId)) .limit(1) if (!tenant) { throw Object.assign( new Error("Tenant not found"), { statusCode: 404 } ) } return tenant } const getTenantSpaceStatus = async (tenantId: number | null) => { const tenant = await getCurrentTenant(tenantId) const alias = tenantSpaceAlias(tenant) try { const directoryEntry = await requestJson<{ room_id: string servers: string[] }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) return { tenantId: tenant.id, tenantName: tenant.name, alias, exists: true, roomId: directoryEntry.room_id, servers: directoryEntry.servers, } } catch (err: any) { if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { return { tenantId: tenant.id, tenantName: tenant.name, alias, exists: false, roomId: null, servers: [], } } throw err } } const provisionCurrentTenantSpace = async (userId: string, tenantId: number | null) => { const tenant = await getCurrentTenant(tenantId) const existing = await getTenantSpaceStatus(tenant.id) const userAccount = await provisionCurrentUser(userId, tenant.id) if (existing.exists) { return { ...existing, created: false, alreadyExisted: true, invitedUserId: userAccount.matrixUserId, } } const serviceLogin = await ensureServiceAccessToken() const aliasLocalpart = tenantSpaceAliasLocalpart(tenant) const createdRoom = await requestMatrixJson<{ room_id: string }>( "/_matrix/client/v3/createRoom", serviceLogin.access_token, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ creation_content: { type: "m.space", }, name: `FEDEO · ${tenant.name}`, topic: `Kommunikationsbereich für ${tenant.name}`, preset: "private_chat", visibility: "private", room_alias_name: aliasLocalpart, invite: [userAccount.matrixUserId], initial_state: [ { type: "m.room.history_visibility", state_key: "", content: { history_visibility: "invited", }, }, ], }), } ) return { tenantId: tenant.id, tenantName: tenant.name, alias: tenantSpaceAlias(tenant), exists: true, created: true, alreadyExisted: false, roomId: createdRoom.room_id, invitedUserId: userAccount.matrixUserId, serviceUserId: serviceLogin.user_id, } } const getTenantRoomStatus = async ( tenantId: number | null, roomKey: string, roomName: string ) => { const tenant = await getCurrentTenant(tenantId) const alias = tenantRoomAlias(tenant, roomKey) try { const directoryEntry = await requestJson<{ room_id: string servers: string[] }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) return { tenantId: tenant.id, tenantName: tenant.name, key: roomKey, name: roomName, alias, exists: true, roomId: directoryEntry.room_id, servers: directoryEntry.servers, } } catch (err: any) { if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { return { tenantId: tenant.id, tenantName: tenant.name, key: roomKey, name: roomName, alias, exists: false, roomId: null, servers: [], } } throw err } } const provisionTenantRoom = async ( userId: string, tenantId: number | null, options: { key?: string name?: string topic?: string } = {} ) => { const tenant = await getCurrentTenant(tenantId) const key = normalizeMatrixAliasSeed(options.key || options.name || "allgemein") const name = (options.name || "Allgemeiner Chat").trim() || "Allgemeiner Chat" const topic = (options.topic || `Allgemeiner Kommunikationsraum für ${tenant.name}`).trim() const existing = await getTenantRoomStatus(tenant.id, key, name) const userAccount = await provisionCurrentUser(userId, tenant.id) const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id) if (existing.exists) { return { ...existing, created: false, alreadyExisted: true, parentSpaceRoomId: tenantSpace.roomId, invitedUserId: userAccount.matrixUserId, } } const serviceLogin = await ensureServiceAccessToken() const createdRoom = await requestMatrixJson<{ room_id: string }>( "/_matrix/client/v3/createRoom", serviceLogin.access_token, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, topic, preset: "private_chat", visibility: "private", room_alias_name: tenantRoomAliasLocalpart(tenant, key), invite: [userAccount.matrixUserId], initial_state: [ { type: "m.room.history_visibility", state_key: "", content: { history_visibility: "invited", }, }, { type: "m.space.parent", state_key: tenantSpace.roomId, content: { via: [serverName()], canonical: true, }, }, ], }), } ) await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(tenantSpace.roomId)}/state/m.space.child/${encodeURIComponent(createdRoom.room_id)}`, serviceLogin.access_token, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ via: [serverName()], suggested: true, order: "10", }), } ) return { tenantId: tenant.id, tenantName: tenant.name, key, name, alias: tenantRoomAlias(tenant, key), exists: true, created: true, alreadyExisted: false, roomId: createdRoom.room_id, parentSpaceRoomId: tenantSpace.roomId, invitedUserId: userAccount.matrixUserId, serviceUserId: serviceLogin.user_id, } } return { getStatus, matrixUserIdForUser, getCurrentUserDisplayName, provisionCurrentUser, getTenantSpaceStatus, provisionCurrentTenantSpace, getTenantRoomStatus, provisionTenantRoom, } }