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