diff --git a/backend/package.json b/backend/package.json index 6968c6d..9db6b78 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,11 +24,13 @@ "jose": "^6.2.1", "jsonwebtoken": "^9.0.3", "pg": "^8.18.0", + "multer": "^1.4.5-lts.2", "winston": "^3.19.0", "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.19", + "@types/multer": "^1.4.12", "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts index 7263f6c..4acfecf 100644 --- a/backend/src/controllers/nextcloud.controller.ts +++ b/backend/src/controllers/nextcloud.controller.ts @@ -167,6 +167,117 @@ class NextcloudController { } } + async uploadFile(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + const token = req.params.token as string; + if (!token) { + res.status(400).json({ success: false, message: 'Room token fehlt' }); + return; + } + if (!req.file) { + res.status(400).json({ success: false, message: 'Keine Datei übermittelt' }); + return; + } + await nextcloudService.uploadFileToTalk( + token, + req.file.buffer, + req.file.originalname, + req.file.mimetype, + credentials.loginName, + credentials.appPassword, + ); + res.status(200).json({ success: true, data: null }); + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(200).json({ success: true, data: { connected: false } }); + return; + } + logger.error('uploadFile error', { error }); + res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' }); + } + } + + async downloadFile(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + const filePath = req.query.path as string; + if (!filePath) { + res.status(400).json({ success: false, message: 'Dateipfad fehlt' }); + return; + } + const response = await nextcloudService.downloadFile( + filePath, + credentials.loginName, + credentials.appPassword, + ); + const contentType = response.headers['content-type'] ?? 'application/octet-stream'; + const contentDisposition = response.headers['content-disposition'] ?? `attachment; filename="${req.params.fileId}"`; + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', contentDisposition); + if (response.headers['content-length']) { + res.setHeader('Content-Length', response.headers['content-length']); + } + response.data.pipe(res); + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + logger.error('downloadFile error', { error }); + res.status(500).json({ success: false, message: 'Datei konnte nicht heruntergeladen werden' }); + } + } + + async getFilePreview(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + const fileId = parseInt(req.params.fileId as string, 10); + if (isNaN(fileId)) { + res.status(400).json({ success: false, message: 'Ungültige Datei-ID' }); + return; + } + const w = parseInt((req.query.w as string) ?? '400', 10) || 400; + const h = parseInt((req.query.h as string) ?? '400', 10) || 400; + const response = await nextcloudService.getFilePreview( + fileId, + Math.min(w, 1200), + Math.min(h, 1200), + credentials.loginName, + credentials.appPassword, + ); + const contentType = response.headers['content-type'] ?? 'image/jpeg'; + res.setHeader('Content-Type', contentType); + if (response.headers['content-length']) { + res.setHeader('Content-Length', response.headers['content-length']); + } + res.setHeader('Cache-Control', 'private, max-age=300'); + response.data.pipe(res); + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + logger.error('getFilePreview error', { error }); + res.status(500).json({ success: false, message: 'Vorschau konnte nicht geladen werden' }); + } + } + async markRoomAsRead(req: Request, res: Response): Promise { try { const credentials = await userService.getNextcloudCredentials(req.user!.id); diff --git a/backend/src/routes/nextcloud.routes.ts b/backend/src/routes/nextcloud.routes.ts index cfaf2c6..d1499fb 100644 --- a/backend/src/routes/nextcloud.routes.ts +++ b/backend/src/routes/nextcloud.routes.ts @@ -1,8 +1,10 @@ import { Router } from 'express'; +import multer from 'multer'; import nextcloudController from '../controllers/nextcloud.controller'; import { authenticate } from '../middleware/auth.middleware'; const router = Router(); +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } }); router.get('/', authenticate, nextcloudController.getConversations.bind(nextcloudController)); router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(nextcloudController)); @@ -12,6 +14,10 @@ router.delete('/connect', authenticate, nextcloudController.disconnect.bind(next router.get('/rooms', authenticate, nextcloudController.getRooms.bind(nextcloudController)); router.get('/rooms/:token/messages', authenticate, nextcloudController.getMessages.bind(nextcloudController)); router.post('/rooms/:token/messages', authenticate, nextcloudController.sendMessage.bind(nextcloudController)); +router.post('/rooms/:token/files', authenticate, upload.single('file'), nextcloudController.uploadFile.bind(nextcloudController)); router.post('/rooms/:token/read', authenticate, nextcloudController.markRoomAsRead.bind(nextcloudController)); +router.get('/files/:fileId/download', authenticate, nextcloudController.downloadFile.bind(nextcloudController)); +router.get('/files/:fileId/preview', authenticate, nextcloudController.getFilePreview.bind(nextcloudController)); + export default router; diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index 1f7cffa..ad63505 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -138,6 +138,7 @@ interface NextcloudChatMessage { timestamp: number; messageType: string; systemMessage: string; + messageParameters: Record; } async function getAllConversations(loginName: string, appPassword: string): Promise { @@ -253,6 +254,7 @@ async function getMessages(token: string, loginName: string, appPassword: string timestamp: m.timestamp, messageType: m.messageType ?? '', systemMessage: m.systemMessage ?? '', + messageParameters: m.messageParameters ?? {}, })); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { @@ -412,5 +414,151 @@ async function getConversations(loginName: string, appPassword: string): Promise } } +async function uploadFileToTalk( + token: string, + fileBuffer: Buffer, + filename: string, + mimetype: string, + loginName: string, + appPassword: string, +): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl || !isValidServiceUrl(baseUrl)) { + throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); + } + + const authHeader = `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`; + + // Add timestamp to avoid filename conflicts + const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : ''; + const base = filename.includes('.') ? filename.slice(0, filename.lastIndexOf('.')) : filename; + const safeName = `${base}_${Date.now()}${ext}`; + const remotePath = `/Talk/${safeName}`; + + try { + // Step 1: Upload via WebDAV + await httpClient.put( + `${baseUrl}/remote.php/dav/files/${encodeURIComponent(loginName)}${remotePath}`, + fileBuffer, + { + headers: { + 'Authorization': authHeader, + 'Content-Type': mimetype, + }, + }, + ); + + // Step 2: Share file to room + await httpClient.post( + `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}/share`, + { path: remotePath, shareType: 10 }, + { + headers: { + 'Authorization': authHeader, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }, + ); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud authentication invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('NextcloudService.uploadFileToTalk failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.uploadFileToTalk failed', { error }); + throw new Error('Failed to upload file to Nextcloud Talk'); + } +} + +async function downloadFile( + filePath: string, + loginName: string, + appPassword: string, +): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl || !isValidServiceUrl(baseUrl)) { + throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); + } + + const authHeader = `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`; + + try { + const response = await httpClient.get( + `${baseUrl}/remote.php/dav/files/${encodeURIComponent(loginName)}/${filePath.replace(/^\//, '')}`, + { + headers: { 'Authorization': authHeader }, + responseType: 'stream', + }, + ); + return response; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud authentication invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('NextcloudService.downloadFile failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.downloadFile failed', { error }); + throw new Error('Failed to download file from Nextcloud'); + } +} + +async function getFilePreview( + fileId: number, + width: number, + height: number, + loginName: string, + appPassword: string, +): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl || !isValidServiceUrl(baseUrl)) { + throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); + } + + const authHeader = `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`; + + try { + const response = await httpClient.get( + `${baseUrl}/index.php/core/preview`, + { + params: { fileId, x: width, y: height, a: 1 }, + headers: { 'Authorization': authHeader }, + responseType: 'stream', + }, + ); + return response; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud authentication invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('NextcloudService.getFilePreview failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.getFilePreview failed', { error }); + throw new Error('Failed to get file preview from Nextcloud'); + } +} + export type { NextcloudChatMessage, GetMessagesOptions }; -export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead }; +export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead, uploadFileToTalk, downloadFile, getFilePreview }; diff --git a/backend/src/types/multer.d.ts b/backend/src/types/multer.d.ts new file mode 100644 index 0000000..c50e73e --- /dev/null +++ b/backend/src/types/multer.d.ts @@ -0,0 +1,51 @@ +// Minimal type declaration for multer — replaced by @types/multer after npm install +declare module 'multer' { + import { RequestHandler } from 'express'; + + interface StorageEngine {} + + interface Options { + storage?: StorageEngine; + limits?: { + fileSize?: number; + files?: number; + fields?: number; + }; + } + + interface Multer { + single(fieldname: string): RequestHandler; + array(fieldname: string, maxCount?: number): RequestHandler; + none(): RequestHandler; + } + + interface MulterStatic { + (options?: Options): Multer; + memoryStorage(): StorageEngine; + diskStorage(options: any): StorageEngine; + } + + const multer: MulterStatic; + export default multer; +} + +declare namespace Express { + interface Request { + file?: { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + buffer: Buffer; + }; + files?: { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + buffer: Buffer; + }[]; + } +} diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index c7991e8..779927a 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -3,12 +3,18 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Paper from '@mui/material/Paper'; import type { NextcloudMessage } from '../../types/nextcloud.types'; +import FileMessageContent from './FileMessageContent'; interface ChatMessageProps { message: NextcloudMessage; isOwnMessage: boolean; } +function hasFileParams(params?: Record): boolean { + if (!params) return false; + return Object.keys(params).some((k) => k === 'file' || k.startsWith('file')); +} + const ChatMessage: React.FC = ({ message, isOwnMessage }) => { const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', @@ -25,6 +31,12 @@ const ChatMessage: React.FC = ({ message, isOwnMessage }) => { ); } + const isFileMessage = + hasFileParams(message.messageParameters) && message.message.includes('{file}'); + + // For file messages, extract any surrounding text + const textBeforeFile = isFileMessage ? message.message.split('{file}')[0].trim() : null; + return ( = ({ message, isOwnMessage }) => { {message.actorDisplayName} )} - - {message.message} - + {isFileMessage ? ( + <> + {textBeforeFile && ( + + {textBeforeFile} + + )} + + + ) : ( + + {message.message} + + )} { const [input, setInput] = useState(''); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); const lastMsgIdRef = useRef(0); // Initial fetch + long-poll loop for near-instant message delivery @@ -118,6 +124,39 @@ const ChatMessageView: React.FC = () => { } }; + const handleUploadFile = async (file: File) => { + if (!selectedRoomToken) return; + if (file.size > 50 * 1024 * 1024) { + return; + } + setUploadProgress(0); + try { + await nextcloudApi.uploadFile(selectedRoomToken, file, setUploadProgress); + } finally { + setUploadProgress(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleUploadFile(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => setIsDragOver(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleUploadFile(file); + }; + if (!selectedRoomToken) { return ( @@ -139,7 +178,20 @@ const ChatMessageView: React.FC = () => { - + {isLoading ? ( @@ -156,28 +208,49 @@ const ChatMessageView: React.FC = () => {
- - setInput(e.target.value)} - onKeyDown={handleKeyDown} - multiline - maxRows={3} - /> - - - + + {uploadProgress !== null && ( + + )} + + + + + fileInputRef.current?.click()} + disabled={uploadProgress !== null || !selectedRoomToken} + > + + + + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + multiline + maxRows={3} + /> + + + + ); }; export default ChatMessageView; - diff --git a/frontend/src/components/chat/FileMessageContent.tsx b/frontend/src/components/chat/FileMessageContent.tsx new file mode 100644 index 0000000..83f42fa --- /dev/null +++ b/frontend/src/components/chat/FileMessageContent.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import DownloadIcon from '@mui/icons-material/Download'; + +interface FileParam { + name: string; + size?: number; + mimetype?: string; + id: number | string; + path?: string; + previewAvailable?: string; +} + +interface FileMessageContentProps { + messageParameters: Record; + isOwnMessage: boolean; +} + +function formatFileSize(bytes?: number): string { + 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`; +} + +function extractFileParams(messageParameters: Record): FileParam[] { + const files: FileParam[] = []; + for (const key of Object.keys(messageParameters)) { + if (key === 'file' || key.startsWith('file')) { + const val = messageParameters[key]; + if (val && typeof val === 'object' && val.id) { + files.push(val as FileParam); + } + } + } + return files; +} + +const FileMessageContent: React.FC = ({ messageParameters, isOwnMessage }) => { + const files = extractFileParams(messageParameters); + if (files.length === 0) return null; + + return ( + <> + {files.map((file, idx) => { + const downloadUrl = `/api/nextcloud/talk/files/${file.id}/download${file.path ? `?path=${encodeURIComponent(file.path)}` : ''}`; + const previewUrl = `/api/nextcloud/talk/files/${file.id}/preview?w=400&h=400`; + const isImage = file.mimetype?.startsWith('image/') && file.previewAvailable === 'yes'; + + if (isImage) { + return ( + + + + + + {file.name} + + + ); + } + + return ( + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', + }} + > + + + + {file.name} + + {file.size && ( + + {formatFileSize(file.size)} + + )} + + + + + + + + ); + })} + + ); +}; + +export default FileMessageContent; diff --git a/frontend/src/services/nextcloud.ts b/frontend/src/services/nextcloud.ts index 5297265..5594fd2 100644 --- a/frontend/src/services/nextcloud.ts +++ b/frontend/src/services/nextcloud.ts @@ -68,4 +68,32 @@ export const nextcloudApi = { .post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/read`) .then(() => undefined); }, + + uploadFile( + token: string, + file: File, + onProgress?: (pct: number) => void, + ): Promise { + const formData = new FormData(); + formData.append('file', file); + return api + .post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: onProgress + ? (e) => { + if (e.total) onProgress(Math.round((e.loaded / e.total) * 100)); + } + : undefined, + }) + .then(() => undefined); + }, + + getFileDownloadUrl(fileId: number | string, filePath?: string): string { + const base = `/api/nextcloud/talk/files/${fileId}/download`; + return filePath ? `${base}?path=${encodeURIComponent(filePath)}` : base; + }, + + getFilePreviewUrl(fileId: number | string, w = 400, h = 400): string { + return `/api/nextcloud/talk/files/${fileId}/preview?w=${w}&h=${h}`; + }, }; diff --git a/frontend/src/types/nextcloud.types.ts b/frontend/src/types/nextcloud.types.ts index 7e2c6d7..32f4a91 100644 --- a/frontend/src/types/nextcloud.types.ts +++ b/frontend/src/types/nextcloud.types.ts @@ -38,6 +38,7 @@ export interface NextcloudMessage { timestamp: number; messageType: string; systemMessage: string; + messageParameters?: Record; } export interface NextcloudRoomListData {