KI-AGENT: Erste Matrix-Backendintegration ergänzen
This commit is contained in:
@@ -28,3 +28,7 @@ MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
|
|||||||
MATRIX_DEV_TURN_PORT=3478
|
MATRIX_DEV_TURN_PORT=3478
|
||||||
MATRIX_DEV_TURN_MIN_PORT=49160
|
MATRIX_DEV_TURN_MIN_PORT=49160
|
||||||
MATRIX_DEV_TURN_MAX_PORT=49200
|
MATRIX_DEV_TURN_MAX_PORT=49200
|
||||||
|
|
||||||
|
# Backend-Integration gegen den lokalen Matrix-Stack
|
||||||
|
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||||
|
MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-aut
|
|||||||
import wikiRoutes from "./routes/wiki";
|
import wikiRoutes from "./routes/wiki";
|
||||||
import portalContractRoutes from "./routes/portal/contracts";
|
import portalContractRoutes from "./routes/portal/contracts";
|
||||||
import mcpRoutes from "./routes/mcp";
|
import mcpRoutes from "./routes/mcp";
|
||||||
|
import communicationRoutes from "./routes/communication";
|
||||||
|
|
||||||
//Public Links
|
//Public Links
|
||||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||||
@@ -150,6 +151,7 @@ async function main() {
|
|||||||
await subApp.register(wikiRoutes);
|
await subApp.register(wikiRoutes);
|
||||||
await subApp.register(portalContractRoutes);
|
await subApp.register(portalContractRoutes);
|
||||||
await subApp.register(mcpRoutes);
|
await subApp.register(mcpRoutes);
|
||||||
|
await subApp.register(communicationRoutes);
|
||||||
|
|
||||||
},{prefix: "/api"})
|
},{prefix: "/api"})
|
||||||
|
|
||||||
|
|||||||
201
backend/src/modules/matrix.service.ts
Normal file
201
backend/src/modules/matrix.service.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { createHmac, randomBytes } from "node:crypto"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { authProfiles, authUsers } 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(/\/+$/, "")
|
||||||
|
|
||||||
|
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 ||
|
||||||
|
""
|
||||||
|
|
||||||
|
const matrixLocalpartForUser = (userId: string) =>
|
||||||
|
`u_${userId.toLowerCase()}`
|
||||||
|
|
||||||
|
const matrixUserIdForUser = (userId: string) =>
|
||||||
|
`@${matrixLocalpartForUser(userId)}:${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 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 || matrixUserIdForUser(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getStatus = async () => {
|
||||||
|
const configured = Boolean(homeserverUrl() && serverName())
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
homeserverUrl: homeserverUrl(),
|
||||||
|
serverName: serverName(),
|
||||||
|
reachable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versions = await requestJson<{ versions: string[] }>(
|
||||||
|
`${homeserverUrl()}/_matrix/client/versions`
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
homeserverUrl: homeserverUrl(),
|
||||||
|
serverName: serverName(),
|
||||||
|
reachable: true,
|
||||||
|
versions: versions.versions,
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
homeserverUrl: homeserverUrl(),
|
||||||
|
serverName: serverName(),
|
||||||
|
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 = matrixLocalpartForUser(userId)
|
||||||
|
const matrixUserId = matrixUserIdForUser(userId)
|
||||||
|
const password = randomBytes(32).toString("base64url")
|
||||||
|
const displayName = await getCurrentUserDisplayName(userId, tenantId)
|
||||||
|
|
||||||
|
const nonceResponse = await requestJson<{ nonce: string }>(
|
||||||
|
`${homeserverUrl()}/_synapse/admin/v1/register`
|
||||||
|
)
|
||||||
|
|
||||||
|
const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
nonce: nonceResponse.nonce,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
admin: false,
|
||||||
|
mac,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getStatus,
|
||||||
|
matrixUserIdForUser,
|
||||||
|
getCurrentUserDisplayName,
|
||||||
|
provisionCurrentUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/routes/communication.ts
Normal file
30
backend/src/routes/communication.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { matrixService } from "../modules/matrix.service"
|
||||||
|
|
||||||
|
export default async function communicationRoutes(server: FastifyInstance) {
|
||||||
|
const matrix = matrixService(server)
|
||||||
|
|
||||||
|
server.get("/communication/matrix/status", async () => {
|
||||||
|
return matrix.getStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/me", async (req) => {
|
||||||
|
const userId = req.user.user_id
|
||||||
|
|
||||||
|
return {
|
||||||
|
matrixUserId: matrix.matrixUserIdForUser(userId),
|
||||||
|
displayName: await matrix.getCurrentUserDisplayName(userId, req.user.tenant_id),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/me/provision", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id)
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply
|
||||||
|
.code(err.statusCode || 500)
|
||||||
|
.send({ error: err.message || "Matrix provisioning failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ export let secrets = {
|
|||||||
DOKUBOX_IMAP_PASSWORD: string
|
DOKUBOX_IMAP_PASSWORD: string
|
||||||
OPENAI_API_KEY: string
|
OPENAI_API_KEY: string
|
||||||
STIRLING_API_KEY: string
|
STIRLING_API_KEY: string
|
||||||
|
MATRIX_HOMESERVER_URL?: string
|
||||||
|
MATRIX_SERVER_NAME?: string
|
||||||
|
MATRIX_REGISTRATION_SHARED_SECRET?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSecrets () {
|
export async function loadSecrets () {
|
||||||
@@ -58,4 +61,3 @@ export async function loadSecrets () {
|
|||||||
console.log("✅ Secrets aus Infisical geladen");
|
console.log("✅ Secrets aus Infisical geladen");
|
||||||
console.log(Object.keys(secrets).length + " Stück")
|
console.log(Object.keys(secrets).length + " Stück")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,3 +172,13 @@ docker compose --profile matrix-dev exec matrix-dev-synapse \
|
|||||||
Anschließend Element Web unter `http://localhost:8080` öffnen und mit dem lokalen Matrix-Nutzer anmelden.
|
Anschließend Element Web unter `http://localhost:8080` öffnen und mit dem lokalen Matrix-Nutzer anmelden.
|
||||||
|
|
||||||
Wenn FEDEO selbst parallel lokal laufen soll, starte die FEDEO-Dienste separat wie gewohnt. Der lokale Matrix-Stack ist absichtlich über direkte Ports erreichbar, damit er unabhängig von DNS, TLS und Traefik getestet werden kann.
|
Wenn FEDEO selbst parallel lokal laufen soll, starte die FEDEO-Dienste separat wie gewohnt. Der lokale Matrix-Stack ist absichtlich über direkte Ports erreichbar, damit er unabhängig von DNS, TLS und Traefik getestet werden kann.
|
||||||
|
|
||||||
|
## Erste FEDEO-Backend-Integration
|
||||||
|
|
||||||
|
Das Backend stellt geschützte Matrix-Endpunkte unter `/api/communication/matrix/*` bereit:
|
||||||
|
|
||||||
|
- `GET /api/communication/matrix/status`: prüft Konfiguration und Erreichbarkeit des Matrix-Homeservers
|
||||||
|
- `GET /api/communication/matrix/me`: zeigt die aus dem FEDEO-Nutzer abgeleitete Matrix-ID
|
||||||
|
- `POST /api/communication/matrix/me/provision`: legt den Matrix-Account für den angemeldeten FEDEO-Nutzer per Synapse-Shared-Secret-Registrierung an
|
||||||
|
|
||||||
|
Für lokale Provisionierung muss `MATRIX_REGISTRATION_SHARED_SECRET` aus `matrix/dev/synapse/homeserver.yaml` in der Backend-Umgebung gesetzt werden. Die lokale Synapse-Konfiguration ist absichtlich nicht versioniert, weil sie Secrets enthält.
|
||||||
|
|||||||
Reference in New Issue
Block a user