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

1629 lines
55 KiB
TypeScript

import { createHash, createHmac, randomBytes } from "node:crypto"
import { existsSync, readFileSync } from "node:fs"
import { resolve } from "node:path"
import { FastifyInstance } from "fastify"
import { authProfiles, authTenantUsers, authUsers, communicationRooms, tenants } from "../../db/schema"
import { and, eq } from "drizzle-orm"
import { secrets } from "../utils/secrets"
import jwt from "jsonwebtoken"
type MatrixErrorResponse = {
errcode?: string
error?: string
}
type MatrixRoomEvent = {
event_id: string
sender: string
origin_server_ts: number
type: string
content?: {
body?: string
msgtype?: string
url?: string
info?: {
mimetype?: string
size?: number
}
}
}
type MatrixJoinedMembersResponse = {
joined: Record<string, {
display_name?: string
avatar_url?: string
}>
}
type MatrixUserSession = {
accessToken: string
matrixUserId: string
validUntilMs: number
}
type MatrixLoginTokenResponse = {
login_token: string
expires_in_ms: number
}
type LiveKitGrant = {
roomJoin: boolean
room: string
canPublish: boolean
canSubscribe: boolean
}
type MatrixTenantRoomOptions = {
key?: string
name?: string
topic?: string
type?: string
entityType?: string | null
entityId?: number | null
entityUuid?: string | null
inviteUserIds?: string[]
}
type MatrixAttachmentInput = {
buffer: Buffer
filename: string
mimeType: string
size: number
}
type MatrixCachedValue<T = any> = {
exists: true
cachedUntil: number
value: T
}
const matrixUserSessionCache = new Map<string, MatrixUserSession>()
const matrixJoinedRoomCache = new Map<string, number>()
const matrixProvisionedUserCache = new Map<string, number>()
const matrixTenantSpaceCache = new Map<string, MatrixCachedValue>()
const matrixTenantRoomCache = new Map<string, MatrixCachedValue>()
let matrixServiceSessionCache: MatrixUserSession | null = null
const defaultTenantRooms: Required<Pick<MatrixTenantRoomOptions, "key" | "name" | "type">>[] = [
{
key: "allgemein",
name: "Allgemeiner Chat",
type: "general",
},
]
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 rtcHost = () =>
process.env.MATRIX_RTC_HOST ||
secrets.MATRIX_RTC_HOST ||
"call.fedeo.de"
const rtcJwtUrl = () =>
process.env.MATRIX_RTC_JWT_URL ||
secrets.MATRIX_RTC_JWT_URL ||
(process.env.NODE_ENV === "production"
? `https://${rtcHost()}/livekit/jwt`
: `http://localhost:${process.env.MATRIX_DEV_RTC_JWT_PORT || "8081"}`)
const livekitUrl = () =>
process.env.MATRIX_LIVEKIT_URL ||
secrets.MATRIX_LIVEKIT_URL ||
(process.env.NODE_ENV === "production"
? `wss://${rtcHost()}/livekit/sfu`
: `ws://localhost:${process.env.MATRIX_DEV_LIVEKIT_PORT || "7880"}`)
const livekitKey = () =>
process.env.LIVEKIT_KEY ||
secrets.LIVEKIT_KEY ||
(process.env.NODE_ENV === "production" ? "" : "devkey")
const livekitSecret = () =>
process.env.LIVEKIT_SECRET ||
secrets.LIVEKIT_SECRET ||
(process.env.NODE_ENV === "production" ? "" : "devsecret-local-matrix-stack-32-chars")
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 normalizeTenantRoomOptions = (options: MatrixTenantRoomOptions = {}) => {
const fallbackName = options.key || options.name || "Allgemeiner Chat"
const key = normalizeMatrixAliasSeed(options.key || fallbackName)
const name = (options.name || fallbackName).trim() || "Allgemeiner Chat"
const topic = options.topic?.trim()
return {
key,
name,
topic,
type: options.type || "room",
entityType: options.entityType || null,
entityId: options.entityId || null,
entityUuid: options.entityUuid || null,
inviteUserIds: options.inviteUserIds || [],
}
}
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 }
)
}
if (matrixServiceSessionCache && matrixServiceSessionCache.validUntilMs > Date.now() + 60_000) {
return matrixServiceSessionCache
}
const username = serviceUserLocalpart()
const password = serviceUserPassword()
try {
await registerWithSharedSecret(username, password, true)
} catch (err: any) {
if (err.errcode !== "M_USER_IN_USE") {
throw err
}
}
const login = await loginMatrixUser(username, password)
matrixServiceSessionCache = {
accessToken: login.access_token,
matrixUserId: login.user_id,
validUntilMs: Date.now() + 30 * 60 * 1000,
}
return matrixServiceSessionCache
}
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 <T>(url: string, init?: RequestInit): Promise<T> => {
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 <T>(path: string, accessToken: string, init?: RequestInit): Promise<T> => {
return requestJson<T>(`${homeserverUrl()}${path}`, {
...init,
headers: {
...(init?.headers || {}),
Authorization: `Bearer ${accessToken}`,
},
})
}
const mxcToMediaPath = (mxcUri: string) => {
const match = mxcUri.match(/^mxc:\/\/([^/]+)\/(.+)$/)
if (!match) {
throw Object.assign(
new Error("Ungültige Matrix-Media-URI"),
{ statusCode: 400 }
)
}
return `/_matrix/media/v3/download/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}`
}
const matrixMediaUrl = (mxcUri: string) => `${homeserverUrl()}${mxcToMediaPath(mxcUri)}`
const attachmentFromEvent = (event: MatrixRoomEvent) => {
const msgtype = event.content?.msgtype || "m.text"
if (!["m.file", "m.image"].includes(msgtype) || !event.content?.url) {
return null
}
const mimeType = event.content.info?.mimetype || "application/octet-stream"
return {
type: msgtype === "m.image" ? "image" : "file",
url: event.content.url,
fileName: event.content.body || "Anhang",
mimeType,
size: event.content.info?.size || 0,
previewUrl: msgtype === "m.image" ? matrixMediaUrl(event.content.url) : null,
downloadUrl: matrixMediaUrl(event.content.url),
isImage: msgtype === "m.image" || mimeType.startsWith("image/"),
}
}
const createAccessTokenForUser = async (userId: string, tenantId: number | null) => {
const matrixUserId = await matrixUserIdForUser(userId, tenantId)
const cacheKey = `${tenantId || "global"}:${userId}`
const cachedSession = matrixUserSessionCache.get(cacheKey)
if (cachedSession && cachedSession.validUntilMs > Date.now() + 60_000) {
return cachedSession
}
await provisionCurrentUser(userId, tenantId)
const serviceLogin = await ensureServiceAccessToken()
const validUntilMs = Date.now() + 30 * 60 * 1000
const login = await requestMatrixJson<{ access_token: string }>(
`/_synapse/admin/v1/users/${encodeURIComponent(matrixUserId)}/login`,
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ valid_until_ms: validUntilMs }),
}
)
const session = {
accessToken: login.access_token,
matrixUserId,
validUntilMs,
}
matrixUserSessionCache.set(cacheKey, session)
return session
}
const getStatus = async () => {
const configured = Boolean(homeserverUrl() && serverName())
if (!configured) {
return {
configured: false,
homeserverUrl: homeserverUrl(),
serverName: serverName(),
provisioningConfigured: Boolean(registrationSharedSecret()),
reachable: false,
calls: {
provider: "matrixrtc-livekit",
configured: Boolean(rtcJwtUrl() && livekitUrl()),
rtcHost: rtcHost(),
rtcJwtUrl: rtcJwtUrl(),
livekitUrl: livekitUrl(),
},
}
}
try {
const versions = await requestJson<{ versions: string[] }>(
`${homeserverUrl()}/_matrix/client/versions`
)
return {
configured: true,
homeserverUrl: homeserverUrl(),
serverName: serverName(),
provisioningConfigured: Boolean(registrationSharedSecret()),
reachable: true,
calls: {
provider: "matrixrtc-livekit",
configured: Boolean(rtcJwtUrl() && livekitUrl()),
rtcHost: rtcHost(),
rtcJwtUrl: rtcJwtUrl(),
livekitUrl: livekitUrl(),
},
versions: versions.versions,
}
} catch (err: any) {
return {
configured: true,
homeserverUrl: homeserverUrl(),
serverName: serverName(),
provisioningConfigured: Boolean(registrationSharedSecret()),
reachable: false,
calls: {
provider: "matrixrtc-livekit",
configured: Boolean(rtcJwtUrl() && livekitUrl()),
rtcHost: rtcHost(),
rtcJwtUrl: rtcJwtUrl(),
livekitUrl: livekitUrl(),
},
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 displayName = await getCurrentUserDisplayName(userId, tenantId)
const cacheKey = `${tenantId || "global"}:${userId}`
const cachedUntil = matrixProvisionedUserCache.get(cacheKey)
if (cachedUntil && cachedUntil > Date.now()) {
return {
matrixUserId,
localpart: username,
displayName,
created: false,
alreadyExisted: true,
}
}
const password = randomBytes(32).toString("base64url")
try {
await registerWithSharedSecret(username, password, false)
matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000)
return {
matrixUserId,
localpart: username,
displayName,
created: true,
alreadyExisted: false,
}
} catch (err: any) {
if (err.errcode === "M_USER_IN_USE") {
matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000)
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 cacheKey = String(tenant.id)
const cachedSpace = matrixTenantSpaceCache.get(cacheKey)
if (cachedSpace?.exists && cachedSpace.cachedUntil > Date.now()) {
return cachedSpace.value
}
const existing = await getTenantSpaceStatus(tenant.id)
const userAccount = await provisionCurrentUser(userId, tenant.id)
if (existing.exists) {
const value = {
...existing,
created: false,
alreadyExisted: true,
invitedUserId: userAccount.matrixUserId,
}
matrixTenantSpaceCache.set(cacheKey, {
exists: true,
cachedUntil: Date.now() + 30 * 60 * 1000,
value,
})
return value
}
const serviceLogin = await ensureServiceAccessToken()
const aliasLocalpart = tenantSpaceAliasLocalpart(tenant)
const createdRoom = await requestMatrixJson<{ room_id: string }>(
"/_matrix/client/v3/createRoom",
serviceLogin.accessToken,
{
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",
},
},
],
}),
}
)
const value = {
tenantId: tenant.id,
tenantName: tenant.name,
alias: tenantSpaceAlias(tenant),
exists: true,
created: true,
alreadyExisted: false,
roomId: createdRoom.room_id,
invitedUserId: userAccount.matrixUserId,
serviceUserId: serviceLogin.matrixUserId,
}
matrixTenantSpaceCache.set(cacheKey, {
exists: true,
cachedUntil: Date.now() + 30 * 60 * 1000,
value,
})
return value
}
const roomMetadataToApi = (
tenant: { id: number, name?: string | null, short?: string | null },
room: typeof communicationRooms.$inferSelect
) => ({
id: room.id,
tenantId: tenant.id,
tenantName: tenant.name,
key: room.key,
name: room.name,
topic: room.topic,
type: room.type,
entityType: room.entityType,
entityId: room.entityId,
entityUuid: room.entityUuid,
alias: room.matrixAlias || tenantRoomAlias(tenant, room.key),
exists: Boolean(room.matrixRoomId),
roomId: room.matrixRoomId,
parentSpaceRoomId: room.parentSpaceRoomId,
servers: [],
archived: room.archived,
})
const findTenantRoomMetadata = async (tenantId: number, key: string) => {
const [room] = await server.db
.select()
.from(communicationRooms)
.where(and(
eq(communicationRooms.tenantId, tenantId),
eq(communicationRooms.key, key)
))
.limit(1)
return room
}
const ensureTenantRoomMetadata = async (
tenant: { id: number, name?: string | null, short?: string | null },
options: MatrixTenantRoomOptions
) => {
const normalizedOptions = normalizeTenantRoomOptions(options)
const existing = await findTenantRoomMetadata(tenant.id, normalizedOptions.key)
if (existing) {
const shouldUpdate =
(options.name !== undefined && existing.name !== normalizedOptions.name) ||
(options.topic !== undefined && existing.topic !== normalizedOptions.topic) ||
(options.type !== undefined && existing.type !== normalizedOptions.type) ||
(options.entityType !== undefined && existing.entityType !== normalizedOptions.entityType) ||
(options.entityId !== undefined && existing.entityId !== normalizedOptions.entityId) ||
(options.entityUuid !== undefined && existing.entityUuid !== normalizedOptions.entityUuid)
if (!shouldUpdate) return existing
const [updated] = await server.db
.update(communicationRooms)
.set({
name: options.name !== undefined ? normalizedOptions.name : existing.name,
topic: options.topic !== undefined ? normalizedOptions.topic : existing.topic,
type: options.type !== undefined ? normalizedOptions.type : existing.type,
entityType: options.entityType !== undefined ? normalizedOptions.entityType : existing.entityType,
entityId: options.entityId !== undefined ? normalizedOptions.entityId : existing.entityId,
entityUuid: options.entityUuid !== undefined ? normalizedOptions.entityUuid : existing.entityUuid,
updatedAt: new Date(),
})
.where(eq(communicationRooms.id, existing.id))
.returning()
return updated
}
const [created] = await server.db
.insert(communicationRooms)
.values({
tenantId: tenant.id,
key: normalizedOptions.key,
name: normalizedOptions.name,
topic: normalizedOptions.topic,
type: normalizedOptions.type,
entityType: normalizedOptions.entityType,
entityId: normalizedOptions.entityId,
entityUuid: normalizedOptions.entityUuid,
matrixAlias: tenantRoomAlias(tenant, normalizedOptions.key),
})
.returning()
return created
}
const markTenantRoomProvisioned = async (
metadataId: string,
values: {
matrixRoomId: string
matrixAlias: string
parentSpaceRoomId?: string | null
}
) => {
const [updated] = await server.db
.update(communicationRooms)
.set({
matrixRoomId: values.matrixRoomId,
matrixAlias: values.matrixAlias,
parentSpaceRoomId: values.parentSpaceRoomId || null,
updatedAt: new Date(),
})
.where(eq(communicationRooms.id, metadataId))
.returning()
return updated
}
const getTenantRoomStatus = async (
tenantId: number | null,
roomKey: string,
roomName?: string
) => {
const tenant = await getCurrentTenant(tenantId)
const normalizedOptions = normalizeTenantRoomOptions({ key: roomKey, name: roomName })
const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions)
const alias = metadata.matrixAlias || tenantRoomAlias(tenant, normalizedOptions.key)
try {
const directoryEntry = await requestJson<{
room_id: string
servers: string[]
}>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`)
const roomMetadata = metadata.matrixRoomId === directoryEntry.room_id
? metadata
: await markTenantRoomProvisioned(metadata.id, {
matrixRoomId: directoryEntry.room_id,
matrixAlias: alias,
parentSpaceRoomId: metadata.parentSpaceRoomId,
})
return {
tenantId: tenant.id,
tenantName: tenant.name,
id: roomMetadata.id,
key: roomMetadata.key,
name: roomMetadata.name,
topic: roomMetadata.topic,
type: roomMetadata.type,
entityType: roomMetadata.entityType,
entityId: roomMetadata.entityId,
entityUuid: roomMetadata.entityUuid,
alias,
exists: true,
roomId: directoryEntry.room_id,
parentSpaceRoomId: roomMetadata.parentSpaceRoomId,
servers: directoryEntry.servers,
}
} catch (err: any) {
if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") {
return {
tenantId: tenant.id,
tenantName: tenant.name,
id: metadata.id,
key: metadata.key,
name: metadata.name,
topic: metadata.topic,
type: metadata.type,
entityType: metadata.entityType,
entityId: metadata.entityId,
entityUuid: metadata.entityUuid,
alias,
exists: false,
roomId: metadata.matrixRoomId,
parentSpaceRoomId: metadata.parentSpaceRoomId,
servers: [],
}
}
throw err
}
}
const provisionTenantRoom = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {}
) => {
const tenant = await getCurrentTenant(tenantId)
const normalizedOptions = normalizeTenantRoomOptions(options)
const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions)
const key = normalizedOptions.key
const name = normalizedOptions.name
const topic = (normalizedOptions.topic || `Allgemeiner Kommunikationsraum für ${tenant.name}`).trim()
const cacheKey = `${tenant.id}:${key}`
const cachedRoom = matrixTenantRoomCache.get(cacheKey)
if (cachedRoom?.exists && cachedRoom.cachedUntil > Date.now()) {
return cachedRoom.value
}
const existing = await getTenantRoomStatus(tenant.id, key, name)
const userAccount = await provisionCurrentUser(userId, tenant.id)
const invitedMatrixUserIds = await matrixUserIdsForInvitees(userId, tenant.id, normalizedOptions.inviteUserIds || [])
const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id)
if (existing.exists) {
await markTenantRoomProvisioned(metadata.id, {
matrixRoomId: existing.roomId,
matrixAlias: existing.alias,
parentSpaceRoomId: existing.parentSpaceRoomId,
})
const value = {
...existing,
created: false,
alreadyExisted: true,
parentSpaceRoomId: tenantSpace.roomId,
invitedUserId: userAccount.matrixUserId,
}
await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds)
matrixTenantRoomCache.set(cacheKey, {
exists: true,
cachedUntil: Date.now() + 30 * 60 * 1000,
value,
})
return value
}
const serviceLogin = await ensureServiceAccessToken()
const createdRoom = await requestMatrixJson<{ room_id: string }>(
"/_matrix/client/v3/createRoom",
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
topic,
preset: "private_chat",
visibility: "private",
room_alias_name: tenantRoomAliasLocalpart(tenant, key),
invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])),
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.accessToken,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
via: [serverName()],
suggested: true,
order: "10",
}),
}
)
const value = {
id: metadata.id,
tenantId: tenant.id,
tenantName: tenant.name,
key,
name,
topic,
type: metadata.type,
entityType: metadata.entityType,
entityId: metadata.entityId,
entityUuid: metadata.entityUuid,
alias: tenantRoomAlias(tenant, key),
exists: true,
created: true,
alreadyExisted: false,
roomId: createdRoom.room_id,
parentSpaceRoomId: tenantSpace.roomId,
invitedUserId: userAccount.matrixUserId,
serviceUserId: serviceLogin.matrixUserId,
}
await markTenantRoomProvisioned(metadata.id, {
matrixRoomId: value.roomId,
matrixAlias: value.alias,
parentSpaceRoomId: value.parentSpaceRoomId,
})
matrixTenantRoomCache.set(cacheKey, {
exists: true,
cachedUntil: Date.now() + 30 * 60 * 1000,
value,
})
return value
}
const matrixUserIdsForInvitees = async (
currentUserId: string,
tenantId: number,
inviteUserIds: string[]
) => {
const uniqueUserIds = Array.from(new Set(inviteUserIds.filter((id) => id && id !== currentUserId)))
return await Promise.all(uniqueUserIds.map(async (inviteUserId) => {
const account = await provisionCurrentUser(inviteUserId, tenantId)
return account.matrixUserId
}))
}
const inviteUsersToRoom = async (roomId: string | null, matrixUserIds: string[]) => {
if (!roomId || !matrixUserIds.length) return
const serviceLogin = await ensureServiceAccessToken()
for (const matrixUserId of matrixUserIds) {
try {
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`,
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: matrixUserId }),
}
)
} catch (err: any) {
if (err.statusCode === 403 || err.statusCode === 400) continue
throw err
}
}
}
const ensureCurrentUserJoinedRoom = async (
userId: string,
tenantId: number | null,
room: { roomId: string, alias: string }
) => {
const session = await createAccessTokenForUser(userId, tenantId)
const joinCacheKey = `${session.matrixUserId}:${room.roomId || room.alias}`
const joinedUntil = matrixJoinedRoomCache.get(joinCacheKey)
if (joinedUntil && joinedUntil > Date.now()) {
return session
}
await requestMatrixJson(
`/_matrix/client/v3/join/${encodeURIComponent(room.roomId || room.alias)}`,
session.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
}
)
matrixJoinedRoomCache.set(joinCacheKey, Date.now() + 30 * 60 * 1000)
return session
}
const listTenantRooms = async (tenantId: number | null) => {
const tenant = await getCurrentTenant(tenantId)
for (const room of defaultTenantRooms) {
await ensureTenantRoomMetadata(tenant, room)
}
const rooms = await server.db
.select()
.from(communicationRooms)
.where(and(
eq(communicationRooms.tenantId, tenant.id),
eq(communicationRooms.archived, false)
))
return {
tenantId: tenant.id,
tenantName: tenant.name,
rooms: rooms.map((room) => roomMetadataToApi(tenant, room)).sort((a, b) =>
String(a.name || a.key).localeCompare(String(b.name || b.key), "de")
),
}
}
const getTenantRoomMessages = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {}
) => {
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const response = await requestMatrixJson<{
chunk: MatrixRoomEvent[]
start?: string
end?: string
}>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/messages?dir=b&limit=50`,
session.accessToken
)
const members = await requestMatrixJson<MatrixJoinedMembersResponse>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`,
session.accessToken
)
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
matrixUserId: session.matrixUserId,
messages: response.chunk
.filter((event) =>
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
)
.map((event) => ({
id: event.event_id,
sender: event.sender,
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
body: event.content?.body || "",
attachment: attachmentFromEvent(event),
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
}))
.reverse(),
}
}
const getTenantRoomMembers = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {}
) => {
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const members = await requestMatrixJson<MatrixJoinedMembersResponse>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`,
session.accessToken
)
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
members: Object.entries(members.joined).map(([matrixUserId, member]) => ({
matrixUserId,
displayName: member.display_name || matrixUserId,
avatarUrl: member.avatar_url || null,
own: matrixUserId === session.matrixUserId,
})),
}
}
const sendTenantRoomMessage = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
text: string
) => {
const message = text.trim()
if (!message) {
throw Object.assign(
new Error("Message text is required"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
const response = await requestMatrixJson<{ event_id: string }>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
session.accessToken,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
msgtype: "m.text",
body: message,
}),
}
)
return {
id: response.event_id,
sender: session.matrixUserId,
senderDisplayName: await getCurrentUserDisplayName(userId, tenantId),
body: message,
timestamp: Date.now(),
own: true,
roomId: room.roomId,
alias: room.alias,
key: room.key,
}
}
const sendTenantRoomAttachment = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
attachment: MatrixAttachmentInput
) => {
if (!attachment.buffer?.length) {
throw Object.assign(
new Error("Attachment file is required"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const upload = await requestMatrixJson<{ content_uri: string }>(
`/_matrix/media/v3/upload?filename=${encodeURIComponent(attachment.filename)}`,
session.accessToken,
{
method: "POST",
headers: { "Content-Type": attachment.mimeType || "application/octet-stream" },
body: attachment.buffer as any,
}
)
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
const isImage = (attachment.mimeType || "").startsWith("image/")
const messageContent = {
msgtype: isImage ? "m.image" : "m.file",
body: attachment.filename,
url: upload.content_uri,
info: {
mimetype: attachment.mimeType || "application/octet-stream",
size: attachment.size,
},
}
const response = await requestMatrixJson<{ event_id: string }>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
session.accessToken,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(messageContent),
}
)
return {
id: response.event_id,
sender: session.matrixUserId,
senderDisplayName: await getCurrentUserDisplayName(userId, tenantId),
body: attachment.filename,
attachment: {
type: isImage ? "image" : "file",
url: upload.content_uri,
fileName: attachment.filename,
mimeType: attachment.mimeType || "application/octet-stream",
size: attachment.size,
previewUrl: isImage ? matrixMediaUrl(upload.content_uri) : null,
downloadUrl: matrixMediaUrl(upload.content_uri),
isImage,
},
timestamp: Date.now(),
own: true,
roomId: room.roomId,
alias: room.alias,
key: room.key,
}
}
const getMediaContent = async (
userId: string,
tenantId: number | null,
mxcUri: string
) => {
const session = await createAccessTokenForUser(userId, tenantId)
const response = await fetch(matrixMediaUrl(mxcUri), {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
})
if (!response.ok) {
throw Object.assign(
new Error(`Matrix media request failed with ${response.status}`),
{ statusCode: response.status }
)
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get("content-type") || "application/octet-stream",
contentLength: response.headers.get("content-length"),
}
}
const createElementRoomSession = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {}
) => {
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const token = await requestMatrixJson<MatrixLoginTokenResponse>(
"/_matrix/client/v1/login/get_token",
session.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
}
)
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
matrixUserId: session.matrixUserId,
loginToken: token.login_token,
expiresInMs: token.expires_in_ms,
homeserverUrl: homeserverUrl(),
serverName: serverName(),
}
}
const createLiveKitRoomSession = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {}
) => {
if (!livekitKey() || !livekitSecret()) {
throw Object.assign(
new Error("LIVEKIT_KEY and LIVEKIT_SECRET are not configured"),
{ statusCode: 503 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const displayName = await getCurrentUserDisplayName(userId, tenantId)
const liveKitRoomName = `fedeo-${tenantId || "global"}-${room.key}`.replace(/[^a-zA-Z0-9_-]/g, "_")
const now = Math.floor(Date.now() / 1000)
const expiresInSeconds = 60 * 60
const video: LiveKitGrant = {
roomJoin: true,
room: liveKitRoomName,
canPublish: true,
canSubscribe: true,
}
const token = jwt.sign(
{
sub: session.matrixUserId,
name: displayName,
video,
nbf: now - 10,
exp: now + expiresInSeconds,
},
livekitSecret(),
{
algorithm: "HS256",
issuer: livekitKey(),
}
)
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
matrixUserId: session.matrixUserId,
displayName,
liveKitUrl: livekitUrl(),
liveKitRoomName,
liveKitToken: token,
expiresInMs: expiresInSeconds * 1000,
}
}
const listTenantCommunicationUsers = async (tenantId: number | null) => {
const tenant = await getCurrentTenant(tenantId)
const rows = await server.db
.select({
userId: authTenantUsers.user_id,
email: authUsers.email,
profileActive: authProfiles.active,
firstName: authProfiles.first_name,
lastName: authProfiles.last_name,
fullName: authProfiles.full_name,
})
.from(authTenantUsers)
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, tenant.id)
))
.where(eq(authTenantUsers.tenant_id, tenant.id))
return rows
.filter((row) => row.profileActive !== false)
.map((row) => ({
userId: row.userId,
email: row.email,
displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email,
}))
}
const inviteMatrixUserToRoom = async (
room: { roomId: string },
matrixUserId: string,
reason?: string
) => {
const serviceLogin = await ensureServiceAccessToken()
try {
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/invite`,
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: matrixUserId,
reason,
}),
}
)
return "invited"
} catch (err: any) {
if (
err.errcode === "M_FORBIDDEN" ||
err.errcode === "M_BAD_STATE" ||
err.errcode === "M_UNKNOWN"
) {
return "already_available"
}
throw err
}
}
const syncTenantRoomMembers = async (
requestingUserId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {}
) => {
const room = await provisionTenantRoom(requestingUserId, tenantId, options)
const users = await listTenantCommunicationUsers(tenantId)
const results = []
for (const user of users) {
try {
const account = await provisionCurrentUser(user.userId, tenantId)
await inviteMatrixUserToRoom(
{ roomId: room.roomId },
account.matrixUserId,
`FEDEO-Raumsynchronisation: ${room.name}`
)
await ensureCurrentUserJoinedRoom(user.userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
results.push({
userId: user.userId,
email: user.email,
displayName: user.displayName,
matrixUserId: account.matrixUserId,
status: "joined",
ok: true,
})
} catch (err: any) {
results.push({
userId: user.userId,
email: user.email,
displayName: user.displayName,
status: "failed",
ok: false,
error: err.message || "Synchronisation fehlgeschlagen",
})
}
}
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
total: results.length,
joined: results.filter((item) => item.status === "joined").length,
invited: results.filter((item) => item.status === "invited").length,
alreadyAvailable: results.filter((item) => item.status === "already_available").length,
failed: results.filter((item) => !item.ok).length,
results,
}
}
const getGeneralRoomMessages = (userId: string, tenantId: number | null) =>
getTenantRoomMessages(userId, tenantId, {
key: "allgemein",
name: "Allgemeiner Chat",
})
const getGeneralRoomMembers = (userId: string, tenantId: number | null) =>
getTenantRoomMembers(userId, tenantId, {
key: "allgemein",
name: "Allgemeiner Chat",
})
const sendGeneralRoomMessage = (userId: string, tenantId: number | null, text: string) =>
sendTenantRoomMessage(
userId,
tenantId,
{
key: "allgemein",
name: "Allgemeiner Chat",
},
text
)
const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) =>
sendTenantRoomAttachment(
userId,
tenantId,
{
key: "allgemein",
name: "Allgemeiner Chat",
},
attachment
)
return {
getStatus,
matrixUserIdForUser,
getCurrentUserDisplayName,
provisionCurrentUser,
getTenantSpaceStatus,
provisionCurrentTenantSpace,
listTenantRooms,
getTenantRoomStatus,
provisionTenantRoom,
createAccessTokenForUser,
getTenantRoomMessages,
getTenantRoomMembers,
sendTenantRoomMessage,
sendTenantRoomAttachment,
getMediaContent,
createElementRoomSession,
createLiveKitRoomSession,
syncTenantRoomMembers,
getGeneralRoomMessages,
getGeneralRoomMembers,
sendGeneralRoomMessage,
sendGeneralRoomAttachment,
}
}