434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
// 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<string>;
|
|
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<UploadResult> {
|
|
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<void> {
|
|
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<string> {
|
|
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>(.*?)<\/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<UploadResult> {
|
|
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();
|
|
} |