update nextcloud for file support

This commit is contained in:
Matthias Hochmeister
2026-03-13 13:46:08 +01:00
parent e36de3199a
commit e26d77ef35
10 changed files with 600 additions and 24 deletions

View File

@@ -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",

View File

@@ -167,6 +167,117 @@ class NextcloudController {
}
}
async uploadFile(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);

View File

@@ -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;

View File

@@ -138,6 +138,7 @@ interface NextcloudChatMessage {
timestamp: number;
messageType: string;
systemMessage: string;
messageParameters: Record<string, any>;
}
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
@@ -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<void> {
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<any> {
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<any> {
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 };

51
backend/src/types/multer.d.ts vendored Normal file
View File

@@ -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;
}[];
}
}

View File

@@ -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<string, any>): boolean {
if (!params) return false;
return Object.keys(params).some((k) => k === 'file' || k.startsWith('file'));
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
hour: '2-digit',
@@ -25,6 +31,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ 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 (
<Box
sx={{
@@ -50,9 +62,23 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
{message.actorDisplayName}
</Typography>
)}
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{message.message}
</Typography>
{isFileMessage ? (
<>
{textBeforeFile && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mb: 0.5 }}>
{textBeforeFile}
</Typography>
)}
<FileMessageContent
messageParameters={message.messageParameters!}
isOwnMessage={isOwnMessage}
/>
</>
) : (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{message.message}
</Typography>
)}
<Typography
variant="caption"
sx={{

View File

@@ -4,8 +4,11 @@ import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import LinearProgress from '@mui/material/LinearProgress';
import Tooltip from '@mui/material/Tooltip';
import SendIcon from '@mui/icons-material/Send';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nextcloudApi } from '../../services/nextcloud';
import { useChat } from '../../contexts/ChatContext';
@@ -23,6 +26,9 @@ const ChatMessageView: React.FC = () => {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<NextcloudMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const lastMsgIdRef = useRef<number>(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<HTMLInputElement>) => {
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 (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
@@ -139,7 +178,20 @@ const ChatMessageView: React.FC = () => {
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
<Box
sx={{
flex: 1,
overflow: 'auto',
py: 1,
outline: isDragOver ? '2px dashed' : 'none',
outlineColor: 'primary.main',
outlineOffset: '-4px',
transition: 'outline 0.1s',
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
<CircularProgress size={24} />
@@ -156,28 +208,49 @@ const ChatMessageView: React.FC = () => {
<div ref={messagesEndRef} />
</Box>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider', display: 'flex', gap: 0.5 }}>
<TextField
size="small"
fullWidth
placeholder="Nachricht..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
multiline
maxRows={3}
/>
<IconButton
color="primary"
onClick={handleSend}
disabled={!input.trim() || sendMutation.isPending}
>
<SendIcon />
</IconButton>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
{uploadProgress !== null && (
<LinearProgress variant="determinate" value={uploadProgress} sx={{ mb: 0.5, borderRadius: 1 }} />
)}
<Box sx={{ display: 'flex', gap: 0.5 }}>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
<Tooltip title="Datei anhängen">
<span>
<IconButton
size="small"
onClick={() => fileInputRef.current?.click()}
disabled={uploadProgress !== null || !selectedRoomToken}
>
<AttachFileIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<TextField
size="small"
fullWidth
placeholder="Nachricht..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
multiline
maxRows={3}
/>
<IconButton
color="primary"
onClick={handleSend}
disabled={!input.trim() || sendMutation.isPending}
>
<SendIcon />
</IconButton>
</Box>
</Box>
</Box>
);
};
export default ChatMessageView;

View File

@@ -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<string, any>;
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<string, any>): 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<FileMessageContentProps> = ({ 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 (
<Box key={idx} sx={{ mt: 0.5 }}>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer" download={file.name}>
<Box
component="img"
src={previewUrl}
alt={file.name}
sx={{
maxWidth: '100%',
maxHeight: 200,
borderRadius: 1,
display: 'block',
cursor: 'pointer',
'&:hover': { opacity: 0.9 },
}}
/>
</a>
<Typography variant="caption" sx={{ opacity: 0.7, fontSize: '0.7rem' }}>
{file.name}
</Typography>
</Box>
);
}
return (
<Box
key={idx}
sx={{
mt: 0.5,
display: 'flex',
alignItems: 'center',
gap: 1,
p: 0.75,
borderRadius: 1,
bgcolor: isOwnMessage
? 'rgba(255,255,255,0.15)'
: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)',
}}
>
<InsertDriveFileIcon fontSize="small" sx={{ opacity: 0.8, flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
noWrap
title={file.name}
sx={{ fontWeight: 500, lineHeight: 1.2 }}
>
{file.name}
</Typography>
{file.size && (
<Typography variant="caption" sx={{ opacity: 0.7 }}>
{formatFileSize(file.size)}
</Typography>
)}
</Box>
<Tooltip title="Herunterladen">
<IconButton
size="small"
component="a"
href={downloadUrl}
download={file.name}
target="_blank"
rel="noopener noreferrer"
sx={{ flexShrink: 0, color: 'inherit' }}
>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
);
})}
</>
);
};
export default FileMessageContent;

View File

@@ -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<void> {
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}`;
},
};

View File

@@ -38,6 +38,7 @@ export interface NextcloudMessage {
timestamp: number;
messageType: string;
systemMessage: string;
messageParameters?: Record<string, any>;
}
export interface NextcloudRoomListData {