Feature: File Editor (#789)

* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component
This commit is contained in:
gsxdsm
2026-02-20 16:06:44 -08:00
committed by GitHub
parent 0a5540c9a2
commit 0e020f7e4a
36 changed files with 5513 additions and 11 deletions

View File

@@ -20,6 +20,9 @@ import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
import { createCopyHandler } from './routes/copy.js';
import { createMoveHandler } from './routes/move.js';
import { createDownloadHandler } from './routes/download.js';
export function createFsRoutes(_events: EventEmitter): Router {
const router = Router();
@@ -39,6 +42,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/browse-project-files', createBrowseProjectFilesHandler());
router.post('/copy', createCopyHandler());
router.post('/move', createMoveHandler());
router.post('/download', createDownloadHandler());
return router;
}

View File

@@ -0,0 +1,99 @@
/**
* POST /copy endpoint - Copy file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
/**
* Recursively copy a directory and its contents
*/
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
await mkdirSafe(dest);
const entries = await secureFs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(srcPath, destPath);
} else {
await secureFs.copyFile(srcPath, destPath);
}
}
}
export function createCopyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent copying a folder into itself or its own descendant (infinite recursion)
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot copy a folder into itself or one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first to avoid merging
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Check if source is a directory
const stats = await secureFs.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destinationPath);
} else {
await secureFs.copyFile(sourcePath, destinationPath);
}
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Copy file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,142 @@
/**
* POST /download endpoint - Download a file, or GET /download for streaming
* For folders, creates a zip archive on the fly
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
import { createReadStream } from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { tmpdir } from 'os';
const execFileAsync = promisify(execFile);
/**
* Get total size of a directory recursively
*/
async function getDirectorySize(dirPath: string): Promise<number> {
let totalSize = 0;
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await getDirectorySize(entryPath);
} else {
const stats = await secureFs.stat(entryPath);
totalSize += Number(stats.size);
}
}
return totalSize;
}
export function createDownloadHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const stats = await secureFs.stat(filePath);
const fileName = path.basename(filePath);
if (stats.isDirectory()) {
// For directories, create a zip archive
const dirSize = await getDirectorySize(filePath);
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
if (dirSize > MAX_DIR_SIZE) {
res.status(413).json({
success: false,
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
size: dirSize,
});
return;
}
// Create a temporary zip file
const zipFileName = `${fileName}.zip`;
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
try {
// Use system zip command (available on macOS and Linux)
// Use execFile to avoid shell injection via user-provided paths
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
cwd: path.dirname(filePath),
maxBuffer: 50 * 1024 * 1024,
});
const zipStats = await secureFs.stat(tmpZipPath);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
res.setHeader('Content-Length', zipStats.size.toString());
res.setHeader('X-Directory-Size', dirSize.toString());
const stream = createReadStream(tmpZipPath);
stream.pipe(res);
stream.on('end', async () => {
// Cleanup temp file
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
});
stream.on('error', async (err) => {
logError(err, 'Download stream error');
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
} catch (zipError) {
// Cleanup on zip failure
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore
}
throw zipError;
}
} else {
// For individual files, stream directly
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Length', stats.size.toString());
const stream = createReadStream(filePath);
stream.pipe(res);
stream.on('error', (err) => {
logError(err, 'Download stream error');
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Download failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* POST /move endpoint - Move (rename) file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
export function createMoveHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent moving to same location or into its own descendant
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc) {
// No-op: source and destination are the same
res.json({ success: true });
return;
}
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot move a folder into one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Use rename for the move operation
await secureFs.rename(sourcePath, destinationPath);
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Move file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}