forensic-pathways/src/utils/nextcloud.ts
2025-07-23 22:33:37 +02:00

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();
}