mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
99
apps/server/src/routes/fs/routes/copy.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
142
apps/server/src/routes/fs/routes/download.ts
Normal file
142
apps/server/src/routes/fs/routes/download.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/fs/routes/move.ts
Normal file
79
apps/server/src/routes/fs/routes/move.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user