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