Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

View File

@@ -2,13 +2,10 @@
* Common utilities for fs routes
*/
import { createLogger } from "../../lib/logger.js";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger("FS");
const logger = createLogger('FS');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };

View File

@@ -2,33 +2,35 @@
* POST /browse endpoint - Browse directories for file browser UI
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import os from 'os';
import path from 'path';
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createBrowseHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { dirPath } = req.body as { dirPath?: string };
// Default to home directory if no path provided
const targetPath = dirPath ? path.resolve(dirPath) : os.homedir();
// Default to ALLOWED_ROOT_DIRECTORY if set, otherwise home directory
const defaultPath = getAllowedRootDirectory() || os.homedir();
const targetPath = dirPath ? path.resolve(dirPath) : defaultPath;
// Detect available drives on Windows
const detectDrives = async (): Promise<string[]> => {
if (os.platform() !== "win32") {
if (os.platform() !== 'win32') {
return [];
}
const drives: string[] = [];
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (const letter of letters) {
const drivePath = `${letter}:\\`;
try {
await fs.access(drivePath);
await secureFs.access(drivePath);
drives.push(drivePath);
} catch {
// Drive doesn't exist, skip it
@@ -46,21 +48,19 @@ export function createBrowseHandler() {
const drives = await detectDrives();
try {
const stats = await fs.stat(targetPath);
const stats = await secureFs.stat(targetPath);
if (!stats.isDirectory()) {
res
.status(400)
.json({ success: false, error: "Path is not a directory" });
res.status(400).json({ success: false, error: 'Path is not a directory' });
return;
}
// Read directory contents
const entries = await fs.readdir(targetPath, { withFileTypes: true });
const entries = await secureFs.readdir(targetPath, { withFileTypes: true });
// Filter for directories only and add parent directory option
const directories = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => ({
name: entry.name,
path: path.join(targetPath, entry.name),
@@ -76,10 +76,8 @@ export function createBrowseHandler() {
});
} catch (error) {
// Handle permission errors gracefully - still return path info so user can navigate away
const errorMessage =
error instanceof Error ? error.message : "Failed to read directory";
const isPermissionError =
errorMessage.includes("EPERM") || errorMessage.includes("EACCES");
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
if (isPermissionError) {
// Return success with empty directories so user can still navigate to parent
@@ -90,7 +88,7 @@ export function createBrowseHandler() {
directories: [],
drives,
warning:
"Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security",
'Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security',
});
} else {
res.status(400).json({
@@ -100,7 +98,13 @@ export function createBrowseHandler() {
}
}
} catch (error) {
logError(error, "Browse directories failed");
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Browse directories failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /delete-board-background endpoint - Delete board background image
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "../../../lib/automaker-paths.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getBoardDir } from '@automaker/platform';
export function createDeleteBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -16,7 +16,7 @@ export function createDeleteBoardBackgroundHandler() {
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath is required",
error: 'projectPath is required',
});
return;
}
@@ -26,10 +26,10 @@ export function createDeleteBoardBackgroundHandler() {
try {
// Try to remove all background files in the board directory
const files = await fs.readdir(boardDir);
const files = await secureFs.readdir(boardDir);
for (const file of files) {
if (file.startsWith("background")) {
await fs.unlink(path.join(boardDir, file));
if (file.startsWith('background')) {
await secureFs.unlink(path.join(boardDir, file));
}
}
} catch {
@@ -38,7 +38,7 @@ export function createDeleteBoardBackgroundHandler() {
res.json({ success: true });
} catch (error) {
logError(error, "Delete board background failed");
logError(error, 'Delete board background failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,10 @@
* POST /delete endpoint - Delete file
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -13,16 +13,21 @@ export function createDeleteHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
await fs.rm(resolvedPath, { recursive: true });
await secureFs.rm(filePath, { recursive: true });
res.json({ success: true });
} catch (error) {
logError(error, "Delete file failed");
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Delete file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,10 @@
* POST /exists endpoint - Check if file/directory exists
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createExistsHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -13,22 +13,28 @@ export function createExistsHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
// For exists, we check but don't require the path to be pre-allowed
// This allows the UI to validate user-entered paths
const resolvedPath = path.resolve(filePath);
try {
await fs.access(resolvedPath);
await secureFs.access(filePath);
res.json({ success: true, exists: true });
} catch {
} catch (accessError) {
// Check if it's a path not allowed error vs file not existing
if (accessError instanceof PathNotAllowedError) {
throw accessError;
}
res.json({ success: true, exists: false });
}
} catch (error) {
logError(error, "Check exists failed");
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Check exists failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,11 @@
* GET /image endpoint - Serve image files
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
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';
export function createImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -16,7 +17,7 @@ export function createImageHandler() {
};
if (!imagePath) {
res.status(400).json({ success: false, error: "path is required" });
res.status(400).json({ success: false, error: 'path is required' });
return;
}
@@ -24,40 +25,41 @@ export function createImageHandler() {
const fullPath = path.isAbsolute(imagePath)
? imagePath
: projectPath
? path.join(projectPath, imagePath)
: imagePath;
? path.join(projectPath, imagePath)
: imagePath;
// Check if file exists
try {
await fs.access(fullPath);
} catch {
res.status(404).json({ success: false, error: "Image not found" });
await secureFs.access(fullPath);
} catch (accessError) {
if (accessError instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: 'Path not allowed' });
return;
}
res.status(404).json({ success: false, error: 'Image not found' });
return;
}
// Read the file
const buffer = await fs.readFile(fullPath);
const buffer = await secureFs.readFile(fullPath);
// Determine MIME type from extension
const ext = path.extname(fullPath).toLowerCase();
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".bmp": "image/bmp",
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
};
res.setHeader(
"Content-Type",
mimeTypes[ext] || "application/octet-stream"
);
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(buffer);
} catch (error) {
logError(error, "Serve image failed");
logError(error, 'Serve image failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -3,11 +3,11 @@
* Handles symlinks safely to avoid ELOOP errors
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
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';
export function createMkdirHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,7 +15,7 @@ export function createMkdirHandler() {
const { dirPath } = req.body as { dirPath: string };
if (!dirPath) {
res.status(400).json({ success: false, error: "dirPath is required" });
res.status(400).json({ success: false, error: 'dirPath is required' });
return;
}
@@ -23,45 +23,47 @@ export function createMkdirHandler() {
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await fs.lstat(resolvedPath);
const stats = await secureFs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
addAllowedPath(resolvedPath);
res.json({ success: true });
return;
}
// It's a file - can't create directory
res.status(400).json({
success: false,
error: "Path exists and is not a directory",
error: 'Path exists and is not a directory',
});
return;
} catch (statError: any) {
// ENOENT means path doesn't exist - we should create it
if (statError.code !== "ENOENT") {
if (statError.code !== 'ENOENT') {
// Some other error (could be ELOOP in parent path)
throw statError;
}
}
// Path doesn't exist, create it
await fs.mkdir(resolvedPath, { recursive: true });
// Add the new directory to allowed paths for tracking
addAllowedPath(resolvedPath);
await secureFs.mkdir(resolvedPath, { recursive: true });
res.json({ success: true });
} catch (error: any) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
// Handle ELOOP specifically
if (error.code === "ELOOP") {
logError(error, "Create directory failed - symlink loop detected");
if (error.code === 'ELOOP') {
logError(error, 'Create directory failed - symlink loop detected');
res.status(400).json({
success: false,
error: "Cannot create directory: symlink loop detected in path",
error: 'Cannot create directory: symlink loop detected in path',
});
return;
}
logError(error, "Create directory failed");
logError(error, 'Create directory failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,26 +2,21 @@
* POST /read endpoint - Read file
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ["categories.json", "app_spec.txt"];
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt'];
function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
}
function isENOENT(error: unknown): boolean {
return (
error !== null &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
}
export function createReadHandler() {
@@ -30,19 +25,24 @@ export function createReadHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
const content = await fs.readFile(resolvedPath, "utf-8");
const content = await secureFs.readFile(filePath, 'utf-8');
res.json({ success: true, content });
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ""));
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
if (shouldLog) {
logError(error, "Read file failed");
logError(error, 'Read file failed');
}
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -2,10 +2,10 @@
* POST /readdir endpoint - Read directory
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createReaddirHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -13,12 +13,11 @@ export function createReaddirHandler() {
const { dirPath } = req.body as { dirPath: string };
if (!dirPath) {
res.status(400).json({ success: false, error: "dirPath is required" });
res.status(400).json({ success: false, error: 'dirPath is required' });
return;
}
const resolvedPath = validatePath(dirPath);
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
const result = entries.map((entry) => ({
name: entry.name,
@@ -28,7 +27,13 @@ export function createReaddirHandler() {
res.json({ success: true, entries: result });
} catch (error) {
logError(error, "Read directory failed");
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Read directory failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,10 @@
* POST /resolve-directory endpoint - Resolve directory path from directory name
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
export function createResolveDirectoryHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -18,9 +17,7 @@ export function createResolveDirectoryHandler() {
};
if (!directoryName) {
res
.status(400)
.json({ success: false, error: "directoryName is required" });
res.status(400).json({ success: false, error: 'directoryName is required' });
return;
}
@@ -28,9 +25,8 @@ export function createResolveDirectoryHandler() {
if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) {
try {
const resolvedPath = path.resolve(directoryName);
const stats = await fs.stat(resolvedPath);
const stats = await secureFs.stat(resolvedPath);
if (stats.isDirectory()) {
addAllowedPath(resolvedPath);
res.json({
success: true,
path: resolvedPath,
@@ -45,17 +41,11 @@ export function createResolveDirectoryHandler() {
// Search for directory in common locations
const searchPaths: string[] = [
process.cwd(), // Current working directory
process.env.HOME || process.env.USERPROFILE || "", // User home
path.join(
process.env.HOME || process.env.USERPROFILE || "",
"Documents"
),
path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"),
process.env.HOME || process.env.USERPROFILE || '', // User home
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Documents'),
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Desktop'),
// Common project locations
path.join(
process.env.HOME || process.env.USERPROFILE || "",
"Projects"
),
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Projects'),
].filter(Boolean);
// Also check parent of current working directory
@@ -72,7 +62,7 @@ export function createResolveDirectoryHandler() {
for (const searchPath of searchPaths) {
try {
const candidatePath = path.join(searchPath, directoryName);
const stats = await fs.stat(candidatePath);
const stats = await secureFs.stat(candidatePath);
if (stats.isDirectory()) {
// Verify it matches by checking for sample files
@@ -80,15 +70,15 @@ export function createResolveDirectoryHandler() {
let matches = 0;
for (const sampleFile of sampleFiles.slice(0, 5)) {
// Remove directory name prefix from sample file path
const relativeFile = sampleFile.startsWith(directoryName + "/")
const relativeFile = sampleFile.startsWith(directoryName + '/')
? sampleFile.substring(directoryName.length + 1)
: sampleFile.split("/").slice(1).join("/") ||
sampleFile.split("/").pop() ||
: sampleFile.split('/').slice(1).join('/') ||
sampleFile.split('/').pop() ||
sampleFile;
try {
const filePath = path.join(candidatePath, relativeFile);
await fs.access(filePath);
await secureFs.access(filePath);
matches++;
} catch {
// File doesn't exist, continue checking
@@ -102,7 +92,6 @@ export function createResolveDirectoryHandler() {
}
// Found matching directory
addAllowedPath(candidatePath);
res.json({
success: true,
path: candidatePath,
@@ -121,7 +110,7 @@ export function createResolveDirectoryHandler() {
error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`,
});
} catch (error) {
logError(error, "Resolve directory failed");
logError(error, 'Resolve directory failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,11 @@
* POST /save-board-background endpoint - Save board background image
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "../../../lib/automaker-paths.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getBoardDir } from '@automaker/platform';
export function createSaveBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -22,34 +21,31 @@ export function createSaveBoardBackgroundHandler() {
if (!data || !filename || !projectPath) {
res.status(400).json({
success: false,
error: "data, filename, and projectPath are required",
error: 'data, filename, and projectPath are required',
});
return;
}
// Get board directory
const boardDir = getBoardDir(projectPath);
await fs.mkdir(boardDir, { recursive: true });
await secureFs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Use a fixed filename for the board background (overwrite previous)
const ext = path.extname(filename) || ".png";
const ext = path.extname(filename) || '.png';
const uniqueFilename = `background${ext}`;
const filePath = path.join(boardDir, uniqueFilename);
// Write file
await fs.writeFile(filePath, buffer);
// Add board directory to allowed paths
addAllowedPath(boardDir);
await secureFs.writeFile(filePath, buffer);
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {
logError(error, "Save board background failed");
logError(error, 'Save board background failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,11 @@
* POST /save-image endpoint - Save image to .automaker images directory
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import { getImagesDir } from "../../../lib/automaker-paths.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getImagesDir } from '@automaker/platform';
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -22,36 +21,33 @@ export function createSaveImageHandler() {
if (!data || !filename || !projectPath) {
res.status(400).json({
success: false,
error: "data, filename, and projectPath are required",
error: 'data, filename, and projectPath are required',
});
return;
}
// Get images directory
const imagesDir = getImagesDir(projectPath);
await fs.mkdir(imagesDir, { recursive: true });
await secureFs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Generate unique filename with timestamp
const timestamp = Date.now();
const ext = path.extname(filename) || ".png";
const ext = path.extname(filename) || '.png';
const baseName = path.basename(filename, ext);
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
const filePath = path.join(imagesDir, uniqueFilename);
// Write file
await fs.writeFile(filePath, buffer);
// Add automaker directory to allowed paths
addAllowedPath(imagesDir);
await secureFs.writeFile(filePath, buffer);
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {
logError(error, "Save image failed");
logError(error, 'Save image failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,10 @@
* POST /stat endpoint - Get file stats
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createStatHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -13,12 +13,11 @@ export function createStatHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
const stats = await fs.stat(resolvedPath);
const stats = await secureFs.stat(filePath);
res.json({
success: true,
@@ -30,7 +29,13 @@ export function createStatHandler() {
},
});
} catch (error) {
logError(error, "Get file stats failed");
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Get file stats failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /validate-path endpoint - Validate and add path to allowed list
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath, isPathAllowed } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { isPathAllowed } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createValidatePathHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -14,7 +14,7 @@ export function createValidatePathHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
@@ -22,28 +22,23 @@ export function createValidatePathHandler() {
// Check if path exists
try {
const stats = await fs.stat(resolvedPath);
const stats = await secureFs.stat(resolvedPath);
if (!stats.isDirectory()) {
res
.status(400)
.json({ success: false, error: "Path is not a directory" });
res.status(400).json({ success: false, error: 'Path is not a directory' });
return;
}
// Add to allowed paths
addAllowedPath(resolvedPath);
res.json({
success: true,
path: resolvedPath,
isAllowed: isPathAllowed(resolvedPath),
});
} catch {
res.status(400).json({ success: false, error: "Path does not exist" });
res.status(400).json({ success: false, error: 'Path does not exist' });
}
} catch (error) {
logError(error, "Validate path failed");
logError(error, 'Validate path failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /write endpoint - Write file
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import { mkdirSafe } from "../../../lib/fs-utils.js";
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 createWriteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -18,19 +18,23 @@ export function createWriteHandler() {
};
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
// Ensure parent directory exists (symlink-safe)
await mkdirSafe(path.dirname(resolvedPath));
await fs.writeFile(resolvedPath, content, "utf-8");
await mkdirSafe(path.dirname(path.resolve(filePath)));
await secureFs.writeFile(filePath, content, 'utf-8');
res.json({ success: true });
} catch (error) {
logError(error, "Write file failed");
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Write file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};