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

621 lines
20 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, 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 <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 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,
}
}