diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 2d562e1..84309d5 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -20,6 +20,11 @@ type MatrixRoomEvent = { content?: { body?: string msgtype?: string + url?: string + info?: { + mimetype?: string + size?: number + } } } @@ -59,6 +64,13 @@ type MatrixTenantRoomOptions = { inviteUserIds?: string[] } +type MatrixAttachmentInput = { + buffer: Buffer + filename: string + mimeType: string + size: number +} + type MatrixCachedValue = { exists: true cachedUntil: number @@ -404,6 +416,41 @@ export function matrixService(server: FastifyInstance) { }) } + 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}` @@ -1118,12 +1165,16 @@ export function matrixService(server: FastifyInstance) { name: room.name, matrixUserId: session.matrixUserId, messages: response.chunk - .filter((event) => event.type === "m.room.message" && event.content?.msgtype === "m.text") + .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, })) @@ -1210,6 +1261,103 @@ export function matrixService(server: FastifyInstance) { } } + 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, @@ -1442,6 +1590,17 @@ export function matrixService(server: FastifyInstance) { text ) + const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) => + sendTenantRoomAttachment( + userId, + tenantId, + { + key: "allgemein", + name: "Allgemeiner Chat", + }, + attachment + ) + return { getStatus, matrixUserIdForUser, @@ -1456,11 +1615,14 @@ export function matrixService(server: FastifyInstance) { getTenantRoomMessages, getTenantRoomMembers, sendTenantRoomMessage, + sendTenantRoomAttachment, + getMediaContent, createElementRoomSession, createLiveKitRoomSession, syncTenantRoomMembers, getGeneralRoomMessages, getGeneralRoomMembers, sendGeneralRoomMessage, + sendGeneralRoomAttachment, } } diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index b96136e..ccbecea 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" +import multipart from "@fastify/multipart" import { and, eq, inArray, ne } from "drizzle-orm" import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema" import { matrixService } from "../modules/matrix.service" @@ -24,6 +25,10 @@ type ChatRecipient = { } export default async function communicationRoutes(server: FastifyInstance) { + await server.register(multipart, { + limits: { fileSize: 25 * 1024 * 1024 }, + }) + const matrix = matrixService(server) const notifications = new NotificationService(server, getUserDirectory) const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => { @@ -226,6 +231,24 @@ export default async function communicationRoutes(server: FastifyInstance) { return { read: ids.length } } + const uploadedAttachmentFromRequest = async (req: any) => { + const data = await req.file() + if (!data?.file) { + throw Object.assign( + new Error("Keine Datei hochgeladen"), + { statusCode: 400 } + ) + } + + const buffer = await data.toBuffer() + return { + buffer, + filename: data.filename || "Anhang", + mimeType: data.mimetype || "application/octet-stream", + size: buffer.length, + } + } + const callModeFromRequest = (req: any): "audio" | "video" => { const body = (req.body || {}) as { mode?: string } return body.mode === "audio" ? "audio" : "video" @@ -336,6 +359,23 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + server.get("/communication/matrix/media", async (req, reply) => { + try { + const query = req.query as { uri?: string; name?: string } + if (!query.uri) return reply.code(400).send({ error: "Matrix-Media-URI fehlt" }) + + const media = await matrix.getMediaContent(req.user.user_id, req.user.tenant_id, query.uri) + reply.header("Content-Type", media.contentType) + if (media.contentLength) reply.header("Content-Length", media.contentLength) + if (query.name) { + reply.header("Content-Disposition", `inline; filename="${query.name.replace(/"/g, "")}"`) + } + return reply.send(media.buffer) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix media failed") + } + }) + server.get("/communication/matrix/project-rooms", async (req, reply) => { try { if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) @@ -611,6 +651,18 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + server.post("/communication/matrix/rooms/general/attachments", async (req, reply) => { + try { + const attachment = await uploadedAttachmentFromRequest(req) + const message = await matrix.sendGeneralRoomAttachment(req.user.user_id, req.user.tenant_id, attachment) + const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") + await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`) + return message + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix attachment send failed") + } + }) + server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => { try { const params = req.params as { roomKey: string } @@ -721,4 +773,21 @@ export default async function communicationRoutes(server: FastifyInstance) { return handleMatrixError(req, reply, err, "Matrix message send failed") } }) + + server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => { + try { + const attachment = await uploadedAttachmentFromRequest(req) + const message = await matrix.sendTenantRoomAttachment( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req), + attachment + ) + const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) + await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`) + return message + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix attachment send failed") + } + }) } diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index 5f87da8..b9b0878 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -16,6 +16,7 @@ const matrixMessages = ref([]) const matrixMembers = ref([]) const matrixMessageDraft = ref("") const matrixMessagesViewport = ref(null) +const matrixAttachmentInput = ref(null) const roomCreateOpen = ref(false) const collapsedRoomGroups = ref({}) const matrixCallOpen = ref(false) @@ -46,6 +47,7 @@ const roomMembersSyncing = ref(false) const matrixMessagesLoading = ref(false) const matrixMembersLoading = ref(false) const matrixMessageSending = ref(false) +const matrixAttachmentUploading = ref(false) const matrixAutoRefreshActive = ref(false) const lastUpdated = ref(null) let matrixRefreshInterval = null @@ -856,6 +858,67 @@ const sendMatrixMessage = async () => { } } +const openAttachmentPicker = () => { + if (!canUseMatrixChat.value || !activeRoom.value?.exists || matrixAttachmentUploading.value) return + matrixAttachmentInput.value?.click() +} + +const uploadMatrixAttachment = async (event) => { + const file = event.target.files?.[0] + event.target.value = "" + + if (!file || !canUseMatrixChat.value || !activeRoom.value?.exists) return + + const optimisticId = `attachment-${Date.now()}` + const optimisticMessage = { + id: optimisticId, + sender: identity.value?.matrixUserId || "Du", + senderDisplayName: identity.value?.displayName || "Du", + body: file.name, + attachment: { + fileName: file.name, + mimeType: file.type || "application/octet-stream", + size: file.size, + isImage: file.type?.startsWith("image/"), + }, + timestamp: Date.now(), + own: true, + pending: true, + failed: false + } + + matrixMessages.value = [ + ...matrixMessages.value, + optimisticMessage + ] + await scrollMessagesToBottom() + + const formData = new FormData() + formData.append("file", file) + + matrixAttachmentUploading.value = true + try { + const message = await $api(`${activeRoomEndpoint.value}/attachments`, { + method: "POST", + body: formData + }) + + matrixMessages.value = matrixMessages.value.map((item) => + item.id === optimisticId ? message : item + ) + } catch (error) { + matrixMessages.value = matrixMessages.value.map((item) => + item.id === optimisticId ? { ...item, pending: false, failed: true } : item + ) + toast.add({ + title: "Anhang konnte nicht gesendet werden", + color: "error" + }) + } finally { + matrixAttachmentUploading.value = false + } +} + const startMatrixAutoRefresh = () => { if (matrixRefreshInterval) return @@ -885,6 +948,25 @@ const formatMessageTime = (timestamp) => { }).format(new Date(timestamp)) } +const formatAttachmentSize = (size) => { + const bytes = Number(size || 0) + if (!bytes) return "" + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +const matrixMediaProxyUrl = (attachment) => { + if (!attachment?.url) return "" + + const params = new URLSearchParams({ + uri: attachment.url, + name: attachment.fileName || "Anhang" + }) + + return `/api/communication/matrix/media?${params.toString()}` +} + const formatLastUpdated = computed(() => { if (!lastUpdated.value) return "Noch nicht aktualisiert" @@ -1316,6 +1398,45 @@ onBeforeUnmount(() => {

{{ message.body }}

+
+ + + + + {{ message.attachment.fileName || message.body }} + + + {{ formatAttachmentSize(message.attachment.size) }} + + +
+ + + {{ message.attachment.fileName || message.body }} + + + {{ formatAttachmentSize(message.attachment.size) }} + +
+
@@ -1324,18 +1445,33 @@ onBeforeUnmount(() => { class="flex shrink-0 gap-2 border-t border-default bg-default p-3" @submit.prevent="sendMatrixMessage" > + + Senden