UNPKG

@kadi.build/local-remote-file-manager-ability

Version:

Local & Remote File Management System with S3-compatible container registry, HTTP server provider, file streaming, and comprehensive testing suite

856 lines (712 loc) 26.4 kB
import { promises as fs } from 'fs'; import fsSync from 'fs'; import path from 'path'; import crypto from 'crypto'; class LocalProvider { constructor(config) { this.config = config || {}; this.localRoot = this.config.localRoot || process.cwd(); this.maxFileSize = this.config.maxFileSize || 1073741824; // 1GB this.chunkSize = this.config.chunkSize || 8388608; // 8MB this.allowSymlinks = this.config.allowSymlinks || false; this.restrictToBasePath = this.config.restrictToBasePath !== false; // Default true this.maxPathLength = this.config.maxPathLength || 255; } // ============================================================================ // PATH VALIDATION AND UTILITIES // ============================================================================ normalizePath(inputPath) { if (!inputPath || inputPath === '/') { return this.localRoot; } // Handle absolute paths if (path.isAbsolute(inputPath)) { const normalizedPath = path.normalize(inputPath); // Security check: ensure absolute path is within base path if restriction is enabled if (this.restrictToBasePath) { const resolvedLocalRoot = path.resolve(this.localRoot); if (!normalizedPath.startsWith(resolvedLocalRoot)) { throw new Error(`Path '${inputPath}' is outside the allowed base path '${this.localRoot}'`); } } return normalizedPath; } // Handle relative paths - resolve them relative to localRoot const resolvedLocalRoot = path.resolve(this.localRoot); const normalizedPath = path.resolve(resolvedLocalRoot, inputPath); // Security check: ensure resolved path is within base path if restriction is enabled if (this.restrictToBasePath) { // Use path.relative to check if the path goes outside the base const relativePath = path.relative(resolvedLocalRoot, normalizedPath); // If relative path starts with '..' or is absolute, it's outside the base path if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { throw new Error(`Path '${inputPath}' is outside the allowed base path '${this.localRoot}'`); } } return normalizedPath; } validatePath(inputPath) { if (!inputPath) { throw new Error('Path cannot be empty'); } if (inputPath.length > this.maxPathLength) { throw new Error(`Path length exceeds maximum of ${this.maxPathLength} characters`); } // Check for invalid characters (Windows + Unix) if (/[<>:"|?*\x00-\x1f]/.test(inputPath)) { throw new Error(`Path contains invalid characters: ${inputPath}`); } return true; } async ensureDirectory(dirPath) { try { await fs.mkdir(dirPath, { recursive: true }); } catch (error) { if (error.code !== 'EEXIST') { throw new Error(`Failed to create directory '${dirPath}': ${error.message}`); } } } // ============================================================================ // CONNECTION AND VALIDATION // ============================================================================ async testConnection() { try { // Test read access to local root const stats = await fs.stat(this.localRoot); if (!stats.isDirectory()) { throw new Error(`Local root '${this.localRoot}' is not a directory`); } // Test write access by creating a temporary file const testFile = path.join(this.localRoot, '.local-provider-test'); await fs.writeFile(testFile, 'test'); await fs.unlink(testFile); // Get system information const totalSize = await this.getDirectorySize(this.localRoot); return { provider: 'local', localRoot: this.localRoot, accessible: true, writable: true, totalSize: totalSize, freeSpace: await this.getFreeSpace() }; } catch (error) { throw new Error(`Local provider connection test failed: ${error.message}`); } } validateConfig() { const errors = []; const warnings = []; if (!this.localRoot) { errors.push('Local root directory is required'); } if (this.maxFileSize <= 0) { errors.push('Max file size must be positive'); } if (this.chunkSize <= 0) { errors.push('Chunk size must be positive'); } if (this.chunkSize > this.maxFileSize) { warnings.push('Chunk size is larger than max file size'); } if (this.allowSymlinks) { warnings.push('Allowing symlinks may pose security risks'); } return { isValid: errors.length === 0, errors, warnings }; } // ============================================================================ // FILE OPERATIONS (CRUD) // ============================================================================ async uploadFile(sourcePath, targetPath) { this.validatePath(sourcePath); this.validatePath(targetPath); const resolvedSourcePath = this.normalizePath(sourcePath); const resolvedTargetPath = this.normalizePath(targetPath); console.log(`📤 Uploading file from ${resolvedSourcePath} to ${resolvedTargetPath}`); try { // Check if source file exists const sourceStats = await fs.stat(resolvedSourcePath); if (!sourceStats.isFile()) { throw new Error(`Source '${sourcePath}' is not a file`); } // Check file size limit if (sourceStats.size > this.maxFileSize) { throw new Error(`File size ${this.formatBytes(sourceStats.size)} exceeds maximum of ${this.formatBytes(this.maxFileSize)}`); } // Ensure target directory exists const targetDir = path.dirname(resolvedTargetPath); await this.ensureDirectory(targetDir); // Copy file (this is our "upload" for local operations) await fs.copyFile(resolvedSourcePath, resolvedTargetPath); // Verify the copy const targetStats = await fs.stat(resolvedTargetPath); console.log(`✅ Upload completed: ${path.basename(targetPath)} (${this.formatBytes(targetStats.size)})`); return { name: path.basename(targetPath), path: targetPath, size: targetStats.size, modifiedTime: targetStats.mtime.toISOString(), hash: await this.calculateChecksum(resolvedTargetPath) }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Source file not found: ${sourcePath}`); } throw new Error(`Upload failed: ${error.message}`); } } async downloadFile(sourcePath, targetPath) { this.validatePath(sourcePath); this.validatePath(targetPath); const resolvedSourcePath = this.normalizePath(sourcePath); const resolvedTargetPath = this.normalizePath(targetPath); console.log(`📥 Downloading file from ${resolvedSourcePath} to ${resolvedTargetPath}`); try { // Check if source file exists const sourceStats = await fs.stat(resolvedSourcePath); if (!sourceStats.isFile()) { throw new Error(`Source '${sourcePath}' is not a file`); } // Ensure target directory exists const targetDir = path.dirname(resolvedTargetPath); await this.ensureDirectory(targetDir); // Copy file (this is our "download" for local operations) await fs.copyFile(resolvedSourcePath, resolvedTargetPath); console.log(`✅ Download completed: ${path.basename(targetPath)}`); return { path: targetPath, size: sourceStats.size, sourcePath: sourcePath }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Source file not found: ${sourcePath}`); } throw new Error(`Download failed: ${error.message}`); } } async getFile(filePath) { this.validatePath(filePath); const resolvedPath = this.normalizePath(filePath); try { const stats = await fs.stat(resolvedPath); if (!stats.isFile()) { throw new Error(`Path '${filePath}' is not a file`); } return { name: path.basename(filePath), path: filePath, size: stats.size, modifiedTime: stats.mtime.toISOString(), createdTime: stats.birthtime.toISOString(), isDirectory: false, isFile: true, hash: await this.calculateChecksum(resolvedPath), permissions: stats.mode }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`File not found: ${filePath}`); } throw error; } } async listFiles(directoryPath = '/', options = {}) { this.validatePath(directoryPath); const resolvedPath = this.normalizePath(directoryPath); const { recursive = false, includeHidden = false, fileTypesOnly = true } = options; try { const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { throw new Error(`Path '${directoryPath}' is not a directory`); } const files = []; await this.collectFiles(resolvedPath, directoryPath, files, recursive, includeHidden, fileTypesOnly); return files; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Directory not found: ${directoryPath}`); } throw error; } } async collectFiles(resolvedPath, relativePath, files, recursive, includeHidden, fileTypesOnly) { const entries = await fs.readdir(resolvedPath, { withFileTypes: true }); for (const entry of entries) { // Skip hidden files if not included if (!includeHidden && entry.name.startsWith('.')) { continue; } const fullPath = path.join(resolvedPath, entry.name); const relativeFilePath = path.join(relativePath, entry.name); try { const stats = await fs.stat(fullPath); if (entry.isFile() || (!fileTypesOnly && !entry.isDirectory())) { files.push({ name: entry.name, path: relativeFilePath.replace(/\\/g, '/'), // Normalize path separators size: stats.size, modifiedTime: stats.mtime.toISOString(), createdTime: stats.birthtime.toISOString(), isDirectory: false, isFile: true, permissions: stats.mode }); } if (recursive && entry.isDirectory()) { await this.collectFiles(fullPath, relativeFilePath, files, recursive, includeHidden, fileTypesOnly); } } catch (error) { // Skip files we can't read console.warn(`⚠️ Could not read ${relativeFilePath}: ${error.message}`); } } } async deleteFile(filePath) { this.validatePath(filePath); const resolvedPath = this.normalizePath(filePath); try { // Verify it's a file before deleting const stats = await fs.stat(resolvedPath); if (!stats.isFile()) { throw new Error(`Path '${filePath}' is not a file`); } await fs.unlink(resolvedPath); return { deleted: true, path: filePath }; } catch (error) { if (this.isFileNotFoundError(error)) { // File already doesn't exist, consider it successfully deleted return { deleted: true, path: filePath }; } throw new Error(`Delete failed: ${error.message}`); } } async renameFile(oldPath, newName) { this.validatePath(oldPath); this.validatePath(newName); const resolvedOldPath = this.normalizePath(oldPath); const directory = path.dirname(resolvedOldPath); const resolvedNewPath = path.join(directory, newName); try { // Check if source file exists const stats = await fs.stat(resolvedOldPath); if (!stats.isFile()) { throw new Error(`Path '${oldPath}' is not a file`); } await fs.rename(resolvedOldPath, resolvedNewPath); const newRelativePath = path.join(path.dirname(oldPath), newName); return { name: newName, oldPath: oldPath, newPath: newRelativePath }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`File not found: ${oldPath}`); } throw new Error(`Rename failed: ${error.message}`); } } async copyFile(sourcePath, targetPath) { this.validatePath(sourcePath); this.validatePath(targetPath); const resolvedSourcePath = this.normalizePath(sourcePath); const resolvedTargetPath = this.normalizePath(targetPath); try { // Check if source file exists const stats = await fs.stat(resolvedSourcePath); if (!stats.isFile()) { throw new Error(`Source '${sourcePath}' is not a file`); } // Ensure target directory exists const targetDir = path.dirname(resolvedTargetPath); await this.ensureDirectory(targetDir); await fs.copyFile(resolvedSourcePath, resolvedTargetPath); return { name: path.basename(targetPath), sourcePath: sourcePath, targetPath: targetPath, size: stats.size }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Source file not found: ${sourcePath}`); } throw new Error(`Copy failed: ${error.message}`); } } async moveFile(sourcePath, targetPath) { this.validatePath(sourcePath); this.validatePath(targetPath); const resolvedSourcePath = this.normalizePath(sourcePath); const resolvedTargetPath = this.normalizePath(targetPath); try { // Check if source file exists const stats = await fs.stat(resolvedSourcePath); if (!stats.isFile()) { throw new Error(`Source '${sourcePath}' is not a file`); } // Ensure target directory exists const targetDir = path.dirname(resolvedTargetPath); await this.ensureDirectory(targetDir); await fs.rename(resolvedSourcePath, resolvedTargetPath); return { name: path.basename(targetPath), oldPath: sourcePath, newPath: targetPath, size: stats.size }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Source file not found: ${sourcePath}`); } throw new Error(`Move failed: ${error.message}`); } } // ============================================================================ // FOLDER OPERATIONS (CRUD) // ============================================================================ async createFolder(folderPath) { this.validatePath(folderPath); const resolvedPath = this.normalizePath(folderPath); try { await fs.mkdir(resolvedPath, { recursive: true }); return { name: path.basename(folderPath), path: folderPath, created: true }; } catch (error) { if (error.code === 'EEXIST') { // Folder already exists return { name: path.basename(folderPath), path: folderPath, created: false, message: 'Folder already exists' }; } throw new Error(`Create folder failed: ${error.message}`); } } async getFolder(folderPath) { this.validatePath(folderPath); const resolvedPath = this.normalizePath(folderPath); try { const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { throw new Error(`Path '${folderPath}' is not a directory`); } // Count items in directory const entries = await fs.readdir(resolvedPath); const itemCount = entries.length; return { name: path.basename(folderPath) || 'Root', path: folderPath, itemCount: itemCount, modifiedTime: stats.mtime.toISOString(), createdTime: stats.birthtime.toISOString(), isDirectory: true, isFile: false, permissions: stats.mode }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Folder not found: ${folderPath}`); } throw error; } } async listFolders(directoryPath = '/') { this.validatePath(directoryPath); const resolvedPath = this.normalizePath(directoryPath); try { const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { throw new Error(`Path '${directoryPath}' is not a directory`); } const entries = await fs.readdir(resolvedPath, { withFileTypes: true }); const folders = []; for (const entry of entries) { if (entry.isDirectory()) { try { const fullPath = path.join(resolvedPath, entry.name); const folderStats = await fs.stat(fullPath); const subEntries = await fs.readdir(fullPath); folders.push({ name: entry.name, path: path.join(directoryPath, entry.name).replace(/\\/g, '/'), itemCount: subEntries.length, modifiedTime: folderStats.mtime.toISOString(), createdTime: folderStats.birthtime.toISOString(), isDirectory: true, isFile: false, permissions: folderStats.mode }); } catch (error) { // Skip folders we can't read console.warn(`⚠️ Could not read folder ${entry.name}: ${error.message}`); } } } return folders; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Directory not found: ${directoryPath}`); } throw error; } } async deleteFolder(folderPath, recursive = false) { this.validatePath(folderPath); const resolvedPath = this.normalizePath(folderPath); try { const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { throw new Error(`Path '${folderPath}' is not a directory`); } if (recursive) { await fs.rm(resolvedPath, { recursive: true, force: true }); } else { await fs.rmdir(resolvedPath); } return { deleted: true, path: folderPath, recursive: recursive }; } catch (error) { if (this.isFileNotFoundError(error)) { // Folder already doesn't exist return { deleted: true, path: folderPath, recursive: recursive }; } throw new Error(`Delete folder failed: ${error.message}`); } } async renameFolder(oldPath, newName) { this.validatePath(oldPath); this.validatePath(newName); const resolvedOldPath = this.normalizePath(oldPath); const directory = path.dirname(resolvedOldPath); const resolvedNewPath = path.join(directory, newName); try { // Check if source folder exists const stats = await fs.stat(resolvedOldPath); if (!stats.isDirectory()) { throw new Error(`Path '${oldPath}' is not a directory`); } await fs.rename(resolvedOldPath, resolvedNewPath); const newRelativePath = path.join(path.dirname(oldPath), newName); return { name: newName, oldPath: oldPath, newPath: newRelativePath }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Folder not found: ${oldPath}`); } throw new Error(`Rename folder failed: ${error.message}`); } } async copyFolder(sourcePath, targetPath) { this.validatePath(sourcePath); this.validatePath(targetPath); const resolvedSourcePath = this.normalizePath(sourcePath); const resolvedTargetPath = this.normalizePath(targetPath); try { // Check if source folder exists const stats = await fs.stat(resolvedSourcePath); if (!stats.isDirectory()) { throw new Error(`Source '${sourcePath}' is not a directory`); } // Create target directory await fs.mkdir(resolvedTargetPath, { recursive: true }); // Recursively copy contents await this.copyFolderRecursive(resolvedSourcePath, resolvedTargetPath); return { name: path.basename(targetPath), sourcePath: sourcePath, targetPath: targetPath }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Source folder not found: ${sourcePath}`); } throw new Error(`Copy folder failed: ${error.message}`); } } async copyFolderRecursive(sourceDir, targetDir) { const entries = await fs.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); if (entry.isDirectory()) { await fs.mkdir(targetPath, { recursive: true }); await this.copyFolderRecursive(sourcePath, targetPath); } else { await fs.copyFile(sourcePath, targetPath); } } } async moveFolder(sourcePath, targetPath) { this.validatePath(sourcePath); this.validatePath(targetPath); const resolvedSourcePath = this.normalizePath(sourcePath); const resolvedTargetPath = this.normalizePath(targetPath); try { // Check if source folder exists const stats = await fs.stat(resolvedSourcePath); if (!stats.isDirectory()) { throw new Error(`Source '${sourcePath}' is not a directory`); } // Ensure target parent directory exists const targetParent = path.dirname(resolvedTargetPath); await this.ensureDirectory(targetParent); await fs.rename(resolvedSourcePath, resolvedTargetPath); return { name: path.basename(targetPath), oldPath: sourcePath, newPath: targetPath }; } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Source folder not found: ${sourcePath}`); } throw new Error(`Move folder failed: ${error.message}`); } } // ============================================================================ // SEARCH OPERATIONS // ============================================================================ async searchFiles(query, options = {}) { const { directory = '/', recursive = true, caseSensitive = false, fileTypesOnly = true, limit = 100 } = options; this.validatePath(directory); const resolvedDir = this.normalizePath(directory); try { const results = []; await this.searchInDirectory(resolvedDir, directory, query, results, recursive, caseSensitive, fileTypesOnly); // Apply limit return results.slice(0, limit); } catch (error) { if (this.isFileNotFoundError(error)) { throw new Error(`Search directory not found: ${directory}`); } throw new Error(`Search failed: ${error.message}`); } } async searchInDirectory(resolvedDir, relativeDir, query, results, recursive, caseSensitive, fileTypesOnly) { const entries = await fs.readdir(resolvedDir, { withFileTypes: true }); const searchQuery = caseSensitive ? query : query.toLowerCase(); for (const entry of entries) { const fullPath = path.join(resolvedDir, entry.name); const relativePath = path.join(relativeDir, entry.name); const fileName = caseSensitive ? entry.name : entry.name.toLowerCase(); try { if (fileName.includes(searchQuery)) { const stats = await fs.stat(fullPath); if (entry.isFile() || (!fileTypesOnly && !entry.isDirectory())) { results.push({ name: entry.name, path: relativePath.replace(/\\/g, '/'), size: stats.size, modifiedTime: stats.mtime.toISOString(), isDirectory: entry.isDirectory(), isFile: entry.isFile() }); } } if (recursive && entry.isDirectory()) { await this.searchInDirectory(fullPath, relativePath, query, results, recursive, caseSensitive, fileTypesOnly); } } catch (error) { // Skip files/folders we can't access console.warn(`⚠️ Could not search in ${relativePath}: ${error.message}`); } } } // ============================================================================ // UTILITY METHODS // ============================================================================ async calculateChecksum(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fsSync.createReadStream(filePath); stream.on('error', reject); stream.on('data', chunk => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); }); } async getDirectorySize(dirPath) { let totalSize = 0; try { const files = await this.listFiles(path.relative(this.localRoot, dirPath), { recursive: true }); totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); } catch (error) { // If we can't read the directory, return 0 totalSize = 0; } return totalSize; } async getFreeSpace() { try { const stats = await fs.statfs ? fs.statfs(this.localRoot) : null; if (stats) { return stats.bavail * stats.bsize; } } catch (error) { // Fallback - return a large number if we can't determine free space } return Number.MAX_SAFE_INTEGER; } formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } // ============================================================================ // ERROR HANDLING HELPERS // ============================================================================ isFileNotFoundError(error) { return error.code === 'ENOENT' || error.message.includes('no such file') || error.message.includes('not found'); } isPermissionError(error) { return error.code === 'EACCES' || error.code === 'EPERM' || error.message.includes('permission denied'); } isDirectoryNotEmptyError(error) { return error.code === 'ENOTEMPTY' || error.message.includes('directory not empty'); } } export { LocalProvider };