testjsdj / exocore-web /src /file-manager.ts
Twan07's picture
Upload 13 files
6b825ee verified
raw
history blame
33.4 kB
import fs from 'fs';
import path from 'path';
import archiver, { ArchiverError } from 'archiver';
import multer, { Multer } from 'multer';
import extract from 'extract-zip';
import yauzl, { Options as YauzlOptions, ZipFile as YauzlZipFile, Entry as YauzlEntry } from 'yauzl';
import { Request, Response, Application, RequestHandler } from 'express';
const configJsonPath: string = path.resolve(__dirname, '../config.json');
const UPLOAD_TEMP_DIR: string = path.resolve(__dirname, '../upload_temp');
let currentProjectPathFromConfig: string = '';
let currentBaseDir: string = '';
async function pathExists(p: string): Promise<boolean> {
try {
await fs.promises.access(p, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
async function ensureDirExists(dirPath: string): Promise<void> {
if (dirPath && !(await pathExists(dirPath))) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
}
ensureDirExists(UPLOAD_TEMP_DIR).catch(err => {
console.error("FATAL: Failed to create UPLOAD_TEMP_DIR on startup:", err);
});
interface AppConfig {
project?: string;
[key: string]: any;
}
function updatePathsFromConfig(): boolean {
const oldRawProjectPath = currentProjectPathFromConfig;
const oldBaseDir = currentBaseDir;
const wasPreviouslyValid = oldBaseDir !== '';
let newRawProjectPathCandidate: string = '';
let newResolvedBaseDirCandidate: string = '';
let isNowValidConfig: boolean = false;
try {
if (!fs.existsSync(configJsonPath)) {
newRawProjectPathCandidate = '';
newResolvedBaseDirCandidate = '';
isNowValidConfig = false;
} else {
const rawConfigData: string = fs.readFileSync(configJsonPath, 'utf-8');
if (rawConfigData.trim() === '') {
newRawProjectPathCandidate = '';
newResolvedBaseDirCandidate = '';
isNowValidConfig = false;
} else {
const parsedConfig: AppConfig = JSON.parse(rawConfigData);
if (parsedConfig && typeof parsedConfig.project === 'string') {
newRawProjectPathCandidate = parsedConfig.project;
const trimmedProjectPath: string = parsedConfig.project.trim();
if (trimmedProjectPath !== '') {
const configJsonDir: string = path.dirname(configJsonPath);
newResolvedBaseDirCandidate = path.resolve(configJsonDir, trimmedProjectPath);
isNowValidConfig = true;
} else {
newResolvedBaseDirCandidate = '';
isNowValidConfig = false;
}
} else {
newRawProjectPathCandidate = '';
newResolvedBaseDirCandidate = '';
isNowValidConfig = false;
}
}
}
} catch (error: any) {
const errMsg = error instanceof Error ? error.message : String(error);
console.error(`[ConfigMonitor] ERROR reading/parsing ${configJsonPath}: ${errMsg}`);
newRawProjectPathCandidate = '';
newResolvedBaseDirCandidate = '';
isNowValidConfig = false;
}
currentProjectPathFromConfig = newRawProjectPathCandidate;
currentBaseDir = isNowValidConfig ? newResolvedBaseDirCandidate : '';
if (isNowValidConfig) {
if (!wasPreviouslyValid) {
console.log(`[ConfigMonitor] PROJECT LOADED: Project path "${currentProjectPathFromConfig.trim()}" configured. Base directory: ${currentBaseDir}`);
} else if (currentBaseDir !== oldBaseDir) {
console.log(`[ConfigMonitor] PROJECT CHANGED: Project path updated to "${currentProjectPathFromConfig.trim()}". New base directory: ${currentBaseDir}`);
}
} else {
if (wasPreviouslyValid) {
console.warn(`[ConfigMonitor] PROJECT UNCONFIGURED: Project path "${oldRawProjectPath}" (was base: "${oldBaseDir}") is now invalid, removed, or config error. Waiting for configuration.`);
}
}
return isNowValidConfig;
}
function startConfigMonitor(): void {
console.log(`[ConfigMonitor] Initializing: Monitoring ${configJsonPath} for project configuration.`);
const initiallyValid = updatePathsFromConfig();
if (!initiallyValid) {
console.warn(`[ConfigMonitor] Initial Status: No valid project path found in ${configJsonPath}. Waiting for configuration.`);
}
setInterval(() => {
updatePathsFromConfig();
}, 1000);
}
startConfigMonitor();
const upload: Multer = multer({ dest: UPLOAD_TEMP_DIR });
function runMulterMiddleware(req: Request, res: Response, multerMiddleware: RequestHandler): Promise<void> {
return new Promise<void>((resolve, reject) => {
multerMiddleware(req, res, (err: any) => {
if (err) {
return reject(err);
}
resolve();
});
});
}
async function safePathResolve(relativePathInput: string | undefined | null, checkExists: boolean = false): Promise<string> {
if (!currentBaseDir) {
console.error("[safePathResolve] Error: Attempted to operate while project base directory is not configured.");
throw new Error('Project base directory is not configured. Please check config.json and ensure the "project" key is set to a valid path.');
}
const activeBaseDir: string = path.resolve(currentBaseDir);
if (relativePathInput === '') {
await ensureDirExists(activeBaseDir);
if (checkExists && !(await pathExists(activeBaseDir))) {
throw new Error(`Base project directory not found: ${activeBaseDir}`);
}
return activeBaseDir;
}
if (typeof relativePathInput !== 'string' || relativePathInput.trim() === '') {
throw new Error('Invalid path provided: path is empty or not a string (and not an intentional empty string for base directory).');
}
const trimmedRelativePath: string = relativePathInput.trim();
const resolvedPath: string = path.resolve(activeBaseDir, trimmedRelativePath);
if (!resolvedPath.startsWith(activeBaseDir + path.sep) && resolvedPath !== activeBaseDir) {
console.error(`[safePathResolve] Unsafe path attempt: Resolved "${resolvedPath}" is outside base "${activeBaseDir}" from relative "${trimmedRelativePath}"`);
throw new Error('Unsafe path: Access denied due to path traversal attempt.');
}
if (checkExists && !(await pathExists(resolvedPath))) {
const displayPath: string = path.relative(activeBaseDir, resolvedPath) || trimmedRelativePath;
console.warn(`[safePathResolve] Path not found: "${displayPath}" (resolved from "${trimmedRelativePath}" within base "${activeBaseDir}")`);
throw new Error(`Path not found: ${displayPath}`);
}
return resolvedPath;
}
interface FileManagerRouteParams {
req: Request;
res: Response;
app?: Application;
}
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "all";
interface FileManagerExpressRouteModule {
method: HttpMethod;
path: string;
install: (params: FileManagerRouteParams) => Promise<void> | void;
}
export const modules: FileManagerExpressRouteModule[] = [
{
method: 'post',
path: '/file/list', // <--- Here it is
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const dirToList: string = await safePathResolve('');
const itemNames: string[] = await fs.promises.readdir(dirToList);
const items = await Promise.all(itemNames.map(async (name) => {
const fullPath: string = path.join(dirToList, name);
const stat: fs.Stats = await fs.promises.stat(fullPath);
return { name, isDir: stat.isDirectory() };
}));
res.json(items);
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/list:', errMsg, e);
if (errMsg.includes('Project base directory is not configured')) {
res.status(503).send(errMsg);
} else if (e instanceof Error && e.message.startsWith('Path not found')) {
res.status(404).send("Root project directory not found or accessible.");
} else {
res.status(500).send(errMsg);
}
}
},
},
{
method: 'post',
path: '/file/list',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const dirToList: string = await safePathResolve('');
const itemNames: string[] = await fs.promises.readdir(dirToList);
const items = await Promise.all(itemNames.map(async (name) => {
const fullPath: string = path.join(dirToList, name);
const stat: fs.Stats = await fs.promises.stat(fullPath);
return { name, isDir: stat.isDirectory() };
}));
res.json(items);
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/list:', errMsg, e);
if (errMsg.includes('Project base directory is not configured')) {
res.status(503).send(errMsg);
} else if (e instanceof Error && e.message.startsWith('Path not found')) {
res.status(404).send("Root project directory not found or accessible.");
} else {
res.status(500).send(errMsg);
}
}
},
},
{
method: 'post',
path: '/file/open',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const filePath: string = await safePathResolve(req.body.file, true);
const content: string = await fs.promises.readFile(filePath, 'utf8');
res.send(content);
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/open:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
else if (e instanceof Error && e.message.startsWith('Path not found')) statusCode = 404;
res.status(statusCode).send(errMsg);
}
},
},
{
method: 'post',
path: '/file/save',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const fileRelativePath: string = req.body.file;
const content: string = req.body.content;
if (typeof content !== 'string') {
res.status(400).send('Invalid content for file save.');
return;
}
const filePath: string = await safePathResolve(fileRelativePath, false);
const dirForFile: string = path.dirname(filePath);
await ensureDirExists(dirForFile);
await fs.promises.writeFile(filePath, content, 'utf8');
res.send('File saved');
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/save:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
res.status(statusCode).send(errMsg);
}
},
},
{
method: 'post',
path: '/file/upload',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
if (!currentBaseDir) {
throw new Error('Project base directory is not configured. Cannot accept uploads.');
}
await runMulterMiddleware(req, res, upload.single('file'));
if (!req.file) {
res.status(400).send('No file uploaded.');
return;
}
const uploadedFile: Express.Multer.File = req.file as Express.Multer.File;
const relativeTargetSubfolder: string = typeof req.body.path === 'string' && req.body.path.trim() !== ''
? req.body.path.trim()
: '';
const targetDirectory: string = await safePathResolve(relativeTargetSubfolder, false);
try {
const targetStat: fs.Stats = await fs.promises.stat(targetDirectory);
if (!targetStat.isDirectory()) {
await fs.promises.unlink(uploadedFile.path);
res.status(400).send('Target path exists but is not a directory.');
return;
}
} catch (statError: any) {
if (statError && typeof statError === 'object' && 'code' in statError && statError.code === 'ENOENT') {
await ensureDirExists(targetDirectory);
} else {
await fs.promises.unlink(uploadedFile.path);
throw statError;
}
}
const finalDestination: string = path.join(targetDirectory, uploadedFile.originalname);
if (!finalDestination.startsWith(targetDirectory + path.sep) && finalDestination !== targetDirectory) {
await fs.promises.unlink(uploadedFile.path);
throw new Error("Upload failed: constructed final path is outside target directory.");
}
await fs.promises.rename(uploadedFile.path, finalDestination);
const displayPath: string = path.relative(path.resolve(currentBaseDir), finalDestination).split(path.sep).join('/') || uploadedFile.originalname;
res.send('File uploaded to ' + displayPath);
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/upload handler:', errMsg, e);
const currentReqFile = req.file as Express.Multer.File | undefined;
if (currentReqFile && currentReqFile.path && await pathExists(currentReqFile.path)) {
await fs.promises.unlink(currentReqFile.path).catch(unlinkErr => console.error("Failed to cleanup temp upload file:", (unlinkErr as Error).message));
}
if (!res.headersSent) {
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof multer.MulterError) statusCode = 400;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
res.status(statusCode).send(errMsg);
}
}
},
},
{
method: 'post',
path: '/file/download',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const filePath: string = await safePathResolve(req.body.file, true);
const stat: fs.Stats = await fs.promises.stat(filePath);
if (!stat.isFile()) {
res.status(400).send('Specified path is not a file.');
return;
}
res.download(filePath);
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/download:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
else if (e instanceof Error && e.message.startsWith('Path not found')) statusCode = 404;
res.status(statusCode).send(errMsg);
}
},
},
{
method: 'post',
path: '/file/download-zip',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const folderRelativePath: string = typeof req.body.folder === 'string' ? req.body.folder.trim() : '';
const folderToZip: string = await safePathResolve(folderRelativePath, true);
const stat: fs.Stats = await fs.promises.stat(folderToZip);
if (!stat.isDirectory()) {
res.status(400).send('Specified path is not a directory.');
return;
}
const zipName: string = (path.basename(folderToZip) || 'archive') + '.zip';
res.setHeader('Content-Disposition', `attachment; filename="${zipName}"`);
res.setHeader('Content-Type', 'application/zip');
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('warning', (err: ArchiverError) => {
console.warn('[Archiver Warning]', err.code, err.message);
});
archive.on('error', (err: Error) => {
console.error('[Archiver Error]', err.message, err);
if (!res.headersSent) {
res.status(500).send({ error: 'Failed to create zip file: ' + err.message });
} else if (res.writable && !res.writableEnded) {
res.end();
}
});
archive.pipe(res);
archive.directory(folderToZip, false);
await archive.finalize();
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/download-zip setup:', errMsg, e);
if (!res.headersSent) {
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
else if (e instanceof Error && e.message.startsWith('Path not found')) statusCode = 404;
res.status(statusCode).send(errMsg);
}
}
},
},
{
method: 'post',
path: '/file/unzip',
install: async ({ req, res }: FileManagerRouteParams) => {
const {
zipFilePath: zipFileRelativePath,
destinationPath: destinationRelativePathInput,
overwrite
}: { zipFilePath: string; destinationPath?: string; overwrite?: boolean } = req.body;
try {
if (typeof zipFileRelativePath !== 'string' || zipFileRelativePath.trim() === '') {
res.status(400).send('zipFilePath is required.'); return;
}
const absoluteZipFilePath: string = await safePathResolve(zipFileRelativePath.trim(), true);
if (!(await fs.promises.stat(absoluteZipFilePath)).isFile()) {
res.status(400).send(`Specified zipFilePath '${zipFileRelativePath}' is not a file.`); return;
}
if (!absoluteZipFilePath.toLowerCase().endsWith('.zip')) {
console.warn(`[Unzip] Warning: File '${zipFileRelativePath}' may not be a .zip file based on extension. Attempting unzip.`);
}
let absoluteDestinationDir: string;
const resolvedCurrentBaseDir: string = path.resolve(currentBaseDir);
if (typeof destinationRelativePathInput === 'string' && destinationRelativePathInput.trim() !== '') {
absoluteDestinationDir = await safePathResolve(destinationRelativePathInput.trim(), false);
} else {
absoluteDestinationDir = path.dirname(absoluteZipFilePath);
if (!absoluteDestinationDir.startsWith(resolvedCurrentBaseDir + path.sep) && absoluteDestinationDir !== resolvedCurrentBaseDir) {
console.error(`[Unzip] Default destination dir "${absoluteDestinationDir}" is outside base "${resolvedCurrentBaseDir}"`);
throw new Error('Default extraction path is outside the allowed project directory.');
}
}
try {
const destStat: fs.Stats = await fs.promises.stat(absoluteDestinationDir);
if (!destStat.isDirectory()) {
res.status(400).send(`Destination path '${path.relative(resolvedCurrentBaseDir, absoluteDestinationDir).split(path.sep).join('/') || '.'}' exists and is not a directory.`); return;
}
} catch (statError: any) {
if (statError && typeof statError === 'object' && 'code' in statError && statError.code === 'ENOENT') {
} else {
throw statError;
}
}
await ensureDirExists(absoluteDestinationDir);
if (!overwrite) {
const conflictingEntries: string[] = [];
const openZipForRead = (filePath: string, options: YauzlOptions): Promise<YauzlZipFile> =>
new Promise<YauzlZipFile>((resolveMain, rejectMain) => {
yauzl.open(filePath, options, (err: Error | null, zipfile?: YauzlZipFile) => {
if (err || !zipfile) rejectMain(err || new Error("Failed to open zipfile. Path: " + filePath));
else resolveMain(zipfile);
});
});
let zipfile: YauzlZipFile | null = null;
try {
zipfile = await openZipForRead(absoluteZipFilePath, { lazyEntries: true });
const currentZipfile: YauzlZipFile = zipfile;
await new Promise<void>((resolveLoop, rejectLoop) => {
currentZipfile.on('error', rejectLoop);
currentZipfile.on('end', resolveLoop);
currentZipfile.on('entry', async (entry: YauzlEntry) => {
const targetPath: string = path.resolve(absoluteDestinationDir, entry.fileName);
if (!targetPath.startsWith(absoluteDestinationDir)) {
console.warn(`[Unzip] Skipped potentially unsafe entry during conflict check: ${entry.fileName} (resolves outside destination)`);
currentZipfile.readEntry();
return;
}
if (await pathExists(targetPath)) {
const existingStat: fs.Stats = await fs.promises.stat(targetPath);
const entryIsDirectory: boolean = entry.fileName.endsWith('/');
if (existingStat.isDirectory() !== entryIsDirectory) {
conflictingEntries.push(`${entry.fileName} (type mismatch: existing is ${existingStat.isDirectory() ? 'dir' : 'file'}, zip entry is ${entryIsDirectory ? 'dir' : 'file'})`);
} else if (!entryIsDirectory) {
conflictingEntries.push(entry.fileName);
}
}
currentZipfile.readEntry();
});
currentZipfile.readEntry();
});
} finally {
zipfile?.close();
}
if (conflictingEntries.length > 0) {
res.status(409).send({
message: "Extraction would overwrite or conflict with existing files/directories. Please use 'overwrite: true' in your request to proceed.",
conflicts: conflictingEntries
});
return;
}
}
await extract(absoluteZipFilePath, { dir: absoluteDestinationDir });
const displayDestPath: string = path.relative(resolvedCurrentBaseDir, absoluteDestinationDir).split(path.sep).join('/') || '.';
res.send(`Successfully unzipped '${path.basename(zipFileRelativePath)}' to '${displayDestPath}'`);
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
let statusCode: number = 500;
let clientMsg: string = `Failed to unzip: ${errMsg}`;
if (errMsg.includes('Project base directory is not configured')) { statusCode = 503; clientMsg = errMsg; }
else if (e instanceof Error) {
if (e.message.startsWith('Unsafe path')) { statusCode = 403; clientMsg = errMsg; }
else if (e.message.startsWith('Path not found')) { statusCode = 404; clientMsg = errMsg; }
else if (e.message.includes('is not a directory')) { statusCode = 400; clientMsg = errMsg; }
else if (errMsg.toLowerCase().includes("invalid") && (errMsg.toLowerCase().includes("zip") || errMsg.toLowerCase().includes("archive"))) {
statusCode = 400; clientMsg = `Failed to unzip '${zipFileRelativePath || 'file'}': May be corrupted or not a valid ZIP.`;
} else if (errMsg.includes("yauzl") && (errMsg.includes("Не найден указанный файл") || errMsg.includes("end of central directory record signature not found") || errMsg.includes("Failed to open zipfile"))) {
statusCode = 400; clientMsg = `Failed to read '${zipFileRelativePath || 'file'}': Invalid or corrupted ZIP file.`;
} else if (e.message.includes('EEXIST') || e.message.includes('ENOTDIR') || (e.message.includes('entry') && e.message.includes('isDirectory') && e.message.includes('false but is a directory'))) {
statusCode = 409;
clientMsg = `Failed to unzip: A conflict occurred during extraction (e.g., a file exists where a directory was expected, or vice-versa). Original error: ${errMsg}`;
}
}
console.error(`Error in /file/unzip (HTTP ${statusCode}):`, errMsg, e);
if (!res.headersSent) res.status(statusCode).send(clientMsg);
}
},
},
{
method: 'post',
path: '/file/create',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const fileRelativePath: string = req.body.file;
if (typeof fileRelativePath !== 'string' || fileRelativePath.trim() === '') {
res.status(400).send('File path is required.'); return;
}
const filePath: string = await safePathResolve(fileRelativePath.trim(), false);
if (await pathExists(filePath)) {
res.status(400).send('File or folder already exists at the target path.'); return;
}
await ensureDirExists(path.dirname(filePath));
await fs.promises.writeFile(filePath, '');
res.send('File created: ' + (path.relative(path.resolve(currentBaseDir), filePath).split(path.sep).join('/') || path.basename(filePath)));
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/create:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
res.status(statusCode).send(errMsg);
}
},
},
{
method: 'post',
path: '/file/create-folder',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const folderRelativePath: string = req.body.folder;
if (typeof folderRelativePath !== 'string' || folderRelativePath.trim() === '') {
res.status(400).send('Folder path is required.'); return;
}
const folderPath: string = await safePathResolve(folderRelativePath.trim(), false);
if (await pathExists(folderPath)) {
res.status(400).send('File or folder already exists at the target path.'); return;
}
await fs.promises.mkdir(folderPath, { recursive: true });
res.send('Folder created: ' + (path.relative(path.resolve(currentBaseDir), folderPath).split(path.sep).join('/') || path.basename(folderPath)));
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/create-folder:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
res.status(statusCode).send(errMsg);
}
},
},
{
method: 'post',
path: '/file/open-folder',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const relativeFolderPath: string = typeof req.body.folder === 'string' ? req.body.folder.trim() : '';
const folderPath: string = await safePathResolve(relativeFolderPath, true);
if (!(await fs.promises.stat(folderPath)).isDirectory()) {
res.status(400).send(`Specified path '${relativeFolderPath}' is not a directory.`); return;
}
const itemNames: string[] = await fs.promises.readdir(folderPath);
const items = await Promise.all(itemNames.map(async (name) => {
const fullPath: string = path.join(folderPath, name);
const itemStat: fs.Stats = await fs.promises.stat(fullPath);
return { name, isDir: itemStat.isDirectory(), size: itemStat.size, lastModified: itemStat.mtimeMs };
}));
const resolvedCurrentBaseDir: string = path.resolve(currentBaseDir);
const currentDisplayPath: string = path.relative(resolvedCurrentBaseDir, folderPath).split(path.sep).join('/');
res.json({
currentPath: folderPath === resolvedCurrentBaseDir ? '' : currentDisplayPath,
items,
});
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/open-folder:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
else if (e instanceof Error && e.message.startsWith('Path not found')) statusCode = 404;
res.status(statusCode).send(errMsg);
}
},
},
{
method: 'post',
path: '/file/rename',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const { oldPath: oldRelativePath, newPath: newRelativePath }: { oldPath: string; newPath: string } = req.body;
if (typeof oldRelativePath !== 'string' || !oldRelativePath.trim() ||
typeof newRelativePath !== 'string' || !newRelativePath.trim()) {
res.status(400).send('Old and new paths are required and cannot be empty.'); return;
}
const trimmedOldRelativePath: string = oldRelativePath.trim();
const trimmedNewRelativePath: string = newRelativePath.trim();
if (trimmedOldRelativePath === trimmedNewRelativePath) {
res.status(400).send('Old and new paths cannot be the same.'); return;
}
const oldFullPath: string = await safePathResolve(trimmedOldRelativePath, true);
const newName: string = path.basename(trimmedNewRelativePath);
if (!newName || newName === '.' || newName === '..') {
res.status(400).send('New name is invalid.'); return;
}
const newParentDirRelative: string = path.dirname(trimmedNewRelativePath);
const newParentDirAbsolute: string = await safePathResolve(newParentDirRelative, false);
await ensureDirExists(newParentDirAbsolute);
const newFullPath: string = path.join(newParentDirAbsolute, newName);
const resolvedCurrentBaseDir: string = path.resolve(currentBaseDir);
if (!newFullPath.startsWith(resolvedCurrentBaseDir + path.sep) && newFullPath !== resolvedCurrentBaseDir) {
throw new Error('Unsafe new path: Target is outside the allowed directory.');
}
if (oldFullPath === newFullPath) {
res.status(400).send('Resolved old and new paths are identical.'); return;
}
if (await pathExists(newFullPath)) {
res.status(400).send(`Item at new path '${trimmedNewRelativePath}' already exists.`); return;
}
await fs.promises.rename(oldFullPath, newFullPath);
res.send('Renamed successfully to ' + (path.relative(resolvedCurrentBaseDir, newFullPath).split(path.sep).join('/') || newName));
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/rename:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
else if (e instanceof Error && e.message.startsWith('Path not found')) statusCode = 404;
res.status(statusCode).send(errMsg);
}
},
},
{
method: 'post',
path: '/file/delete',
install: async ({ req, res }: FileManagerRouteParams) => {
try {
const itemRelativePath: string = req.body.path;
if (typeof itemRelativePath !== 'string' || !itemRelativePath.trim()) {
res.status(400).send('Path for deletion is required.'); return;
}
const trimmedItemRelativePath: string = itemRelativePath.trim();
if (trimmedItemRelativePath === '.' || trimmedItemRelativePath === '/') {
res.status(400).send('Invalid path for deletion. Cannot delete root.'); return;
}
const itemFullPath: string = await safePathResolve(trimmedItemRelativePath, true);
const resolvedCurrentBaseDir: string = path.resolve(currentBaseDir);
if (itemFullPath === resolvedCurrentBaseDir) {
res.status(400).send('Cannot delete the root project directory itself.'); return;
}
const stat: fs.Stats = await fs.promises.stat(itemFullPath);
const displayPath: string = path.relative(resolvedCurrentBaseDir, itemFullPath).split(path.sep).join('/') || trimmedItemRelativePath;
if (stat.isDirectory()) {
await fs.promises.rm(itemFullPath, { recursive: true, force: true });
} else {
await fs.promises.unlink(itemFullPath);
}
res.send(`${stat.isDirectory() ? 'Folder' : 'File'} deleted: ${displayPath}`);
} catch (e: any) {
const errMsg: string = e instanceof Error ? e.message : String(e);
console.error('Error in /file/delete:', errMsg, e);
let statusCode: number = 500;
if (errMsg.includes('Project base directory is not configured')) statusCode = 503;
else if (e instanceof Error && e.message.startsWith('Unsafe path')) statusCode = 403;
else if (e instanceof Error && e.message.startsWith('Path not found')) statusCode = 404;
res.status(statusCode).send(errMsg);
}
},
},
];