// src/utils/nextcloud.ts import { promises as fs } from 'fs'; import path from 'path'; import crypto from 'crypto'; interface NextcloudConfig { endpoint: string; username: string; password: string; uploadPath: string; publicBaseUrl: string; } interface UploadResult { success: boolean; url?: string; filename?: string; error?: string; size?: number; } interface FileValidation { valid: boolean; error?: string; sanitizedName?: string; } export class NextcloudUploader { private config: NextcloudConfig; private allowedTypes: Set; private maxFileSize: number; // in bytes constructor() { this.config = { endpoint: process.env.NEXTCLOUD_ENDPOINT || '', username: process.env.NEXTCLOUD_USERNAME || '', password: process.env.NEXTCLOUD_PASSWORD || '', uploadPath: process.env.NEXTCLOUD_UPLOAD_PATH || '/kb-media', publicBaseUrl: process.env.NEXTCLOUD_PUBLIC_URL || '' }; // Allowed file types for knowledge base this.allowedTypes = new Set([ // Images 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', // Videos 'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov', // Documents 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // Text files 'text/plain', 'text/csv', 'application/json', // Archives (for tool downloads) 'application/zip', 'application/x-tar', 'application/gzip' ]); this.maxFileSize = 50 * 1024 * 1024; // 50MB } /** * Check if Nextcloud upload is properly configured */ isConfigured(): boolean { return !!(this.config.endpoint && this.config.username && this.config.password && this.config.publicBaseUrl); } /** * Validate file before upload */ private validateFile(file: File): FileValidation { // Check file size if (file.size > this.maxFileSize) { return { valid: false, error: `File too large (max ${Math.round(this.maxFileSize / 1024 / 1024)}MB)` }; } // Check file type if (!this.allowedTypes.has(file.type)) { return { valid: false, error: `File type not allowed: ${file.type}` }; } // Sanitize filename const sanitizedName = this.sanitizeFilename(file.name); if (!sanitizedName) { return { valid: false, error: 'Invalid filename' }; } return { valid: true, sanitizedName }; } /** * Sanitize filename for safe storage */ private sanitizeFilename(filename: string): string { // Remove or replace unsafe characters const sanitized = filename .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore .replace(/_{2,}/g, '_') // Replace multiple underscores with single .replace(/^_|_$/g, '') // Remove leading/trailing underscores .toLowerCase(); // Ensure reasonable length if (sanitized.length > 100) { const ext = path.extname(sanitized); const base = path.basename(sanitized, ext).substring(0, 90); return base + ext; } return sanitized; } /** * Generate unique filename to prevent conflicts */ private generateUniqueFilename(originalName: string): string { const timestamp = Date.now(); const randomId = crypto.randomBytes(4).toString('hex'); const ext = path.extname(originalName); const base = path.basename(originalName, ext); return `${timestamp}_${randomId}_${base}${ext}`; } /** * Upload file to Nextcloud */ async uploadFile(file: File, category: string = 'general'): Promise { try { if (!this.isConfigured()) { return { success: false, error: 'Nextcloud not configured' }; } // Validate file const validation = this.validateFile(file); if (!validation.valid) { return { success: false, error: validation.error }; } // Generate unique filename const uniqueFilename = this.generateUniqueFilename(validation.sanitizedName!); // Create category-based path const categoryPath = this.sanitizeFilename(category); const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`; // **FIX: Ensure directory exists before upload** const dirPath = `${this.config.uploadPath}/${categoryPath}`; await this.ensureDirectoryExists(dirPath); // Convert file to buffer const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // Upload to Nextcloud via WebDAV const uploadUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`; const response = await fetch(uploadUrl, { method: 'PUT', headers: { 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, 'Content-Type': file.type, 'Content-Length': buffer.length.toString() }, body: buffer }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } // Generate public URL const publicUrl = await this.createPublicLink(remotePath); return { success: true, url: publicUrl, filename: uniqueFilename, size: file.size }; } catch (error) { console.error('Nextcloud upload error:', error); return { success: false, error: error instanceof Error ? error.message : 'Upload failed' }; } } private async ensureDirectoryExists(dirPath: string): Promise { try { // Split path and create each directory level const parts = dirPath.split('/').filter(part => part); let currentPath = ''; for (const part of parts) { currentPath += '/' + part; const mkcolUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${currentPath}`; const response = await fetch(mkcolUrl, { method: 'MKCOL', headers: { 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}` } }); // 201 = created, 405 = already exists, both are fine if (response.status !== 201 && response.status !== 405) { console.warn(`Directory creation failed: ${response.status} for ${currentPath}`); } } } catch (error) { console.warn('Failed to ensure directory exists:', error); // Don't fail upload for directory creation issues } } /** * Create a public share link for the uploaded file */ private async createPublicLink(remotePath: string): Promise { try { // Use Nextcloud's share API to create public link const shareUrl = `${this.config.endpoint}/ocs/v2.php/apps/files_sharing/api/v1/shares`; const formData = new FormData(); formData.append('path', remotePath); formData.append('shareType', '3'); // Public link formData.append('permissions', '1'); // Read only const response = await fetch(shareUrl, { method: 'POST', headers: { 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, 'OCS-APIRequest': 'true' }, body: formData }); if (!response.ok) { throw new Error('Failed to create public link'); } const text = await response.text(); // Parse XML response to extract share URL const urlMatch = text.match(/(.*?)<\/url>/); if (urlMatch) { return urlMatch[1]; } // Fallback to direct URL construction return `${this.config.publicBaseUrl}${remotePath}`; } catch (error) { console.warn('Failed to create public link, using direct URL:', error); // Fallback to direct URL return `${this.config.publicBaseUrl}${remotePath}`; } } /** * Delete file from Nextcloud */ async deleteFile(remotePath: string): Promise<{ success: boolean; error?: string }> { try { if (!this.isConfigured()) { return { success: false, error: 'Nextcloud not configured' }; } const deleteUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`; const response = await fetch(deleteUrl, { method: 'DELETE', headers: { 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}` } }); if (response.ok || response.status === 404) { return { success: true }; } throw new Error(`Delete failed: ${response.status} ${response.statusText}`); } catch (error) { console.error('Nextcloud delete error:', error); return { success: false, error: error instanceof Error ? error.message : 'Delete failed' }; } } /** * Check Nextcloud connectivity and authentication */ async testConnection(): Promise<{ success: boolean; error?: string; details?: any }> { try { if (!this.isConfigured()) { return { success: false, error: 'Nextcloud not configured', details: { hasEndpoint: !!this.config.endpoint, hasUsername: !!this.config.username, hasPassword: !!this.config.password, hasPublicUrl: !!this.config.publicBaseUrl } }; } // Test with a simple WebDAV request const testUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}/`; const response = await fetch(testUrl, { method: 'PROPFIND', headers: { 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, 'Depth': '0' } }); if (response.ok) { return { success: true, details: { endpoint: this.config.endpoint, username: this.config.username, uploadPath: this.config.uploadPath } }; } throw new Error(`Connection failed: ${response.status} ${response.statusText}`); } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Connection test failed' }; } } /** * Get file information from Nextcloud */ async getFileInfo(remotePath: string): Promise<{ success: boolean; info?: any; error?: string }> { try { const propfindUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`; const response = await fetch(propfindUrl, { method: 'PROPFIND', headers: { 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, 'Depth': '0' } }); if (response.ok) { const text = await response.text(); // Parse basic file info from WebDAV response return { success: true, info: { path: remotePath, exists: true, response: text.substring(0, 200) + '...' // Truncated for safety } }; } if (response.status === 404) { return { success: true, info: { path: remotePath, exists: false } }; } throw new Error(`Failed to get file info: ${response.status}`); } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to get file info' }; } } } // Convenience functions for easy usage export async function uploadToNextcloud(file: File, category: string = 'general'): Promise { const uploader = new NextcloudUploader(); return await uploader.uploadFile(file, category); } export async function testNextcloudConnection(): Promise<{ success: boolean; error?: string; details?: any }> { const uploader = new NextcloudUploader(); return await uploader.testConnection(); } export function isNextcloudConfigured(): boolean { const uploader = new NextcloudUploader(); return uploader.isConfigured(); }