mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
703 lines
22 KiB
TypeScript
703 lines
22 KiB
TypeScript
/**
|
|
* File system routes
|
|
* Provides REST API equivalents for Electron IPC file operations
|
|
*/
|
|
|
|
import { Router, type Request, type Response } from "express";
|
|
import fs from "fs/promises";
|
|
import os from "os";
|
|
import path from "path";
|
|
import os from "os";
|
|
import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js";
|
|
import type { EventEmitter } from "../lib/events.js";
|
|
|
|
export function createFsRoutes(_events: EventEmitter): Router {
|
|
const router = Router();
|
|
|
|
// Read file
|
|
router.post("/read", async (req: Request, res: Response) => {
|
|
try {
|
|
const { filePath } = req.body as { filePath: string };
|
|
|
|
if (!filePath) {
|
|
res.status(400).json({ success: false, error: "filePath is required" });
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = validatePath(filePath);
|
|
const content = await fs.readFile(resolvedPath, "utf-8");
|
|
|
|
res.json({ success: true, content });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Write file
|
|
router.post("/write", async (req: Request, res: Response) => {
|
|
try {
|
|
const { filePath, content } = req.body as {
|
|
filePath: string;
|
|
content: string;
|
|
};
|
|
|
|
if (!filePath) {
|
|
res.status(400).json({ success: false, error: "filePath is required" });
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = validatePath(filePath);
|
|
|
|
// Ensure parent directory exists
|
|
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
await fs.writeFile(resolvedPath, content, "utf-8");
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Create directory
|
|
router.post("/mkdir", async (req: Request, res: Response) => {
|
|
try {
|
|
const { dirPath } = req.body as { dirPath: string };
|
|
|
|
if (!dirPath) {
|
|
res.status(400).json({ success: false, error: "dirPath is required" });
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = validatePath(dirPath);
|
|
await fs.mkdir(resolvedPath, { recursive: true });
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Read directory
|
|
router.post("/readdir", async (req: Request, res: Response) => {
|
|
try {
|
|
const { dirPath } = req.body as { dirPath: string };
|
|
|
|
if (!dirPath) {
|
|
res.status(400).json({ success: false, error: "dirPath is required" });
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = validatePath(dirPath);
|
|
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
|
|
const result = entries.map((entry) => ({
|
|
name: entry.name,
|
|
isDirectory: entry.isDirectory(),
|
|
isFile: entry.isFile(),
|
|
}));
|
|
|
|
res.json({ success: true, entries: result });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Check if file/directory exists
|
|
router.post("/exists", async (req: Request, res: Response) => {
|
|
try {
|
|
const { filePath } = req.body as { filePath: string };
|
|
|
|
if (!filePath) {
|
|
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);
|
|
res.json({ success: true, exists: true });
|
|
} catch {
|
|
res.json({ success: true, exists: false });
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Get file stats
|
|
router.post("/stat", async (req: Request, res: Response) => {
|
|
try {
|
|
const { filePath } = req.body as { filePath: string };
|
|
|
|
if (!filePath) {
|
|
res.status(400).json({ success: false, error: "filePath is required" });
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = validatePath(filePath);
|
|
const stats = await fs.stat(resolvedPath);
|
|
|
|
res.json({
|
|
success: true,
|
|
stats: {
|
|
isDirectory: stats.isDirectory(),
|
|
isFile: stats.isFile(),
|
|
size: stats.size,
|
|
mtime: stats.mtime,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Delete file
|
|
router.post("/delete", async (req: Request, res: Response) => {
|
|
try {
|
|
const { filePath } = req.body as { filePath: string };
|
|
|
|
if (!filePath) {
|
|
res.status(400).json({ success: false, error: "filePath is required" });
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = validatePath(filePath);
|
|
await fs.rm(resolvedPath, { recursive: true });
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Validate and add path to allowed list
|
|
// This is the web equivalent of dialog:openDirectory
|
|
router.post("/validate-path", async (req: Request, res: Response) => {
|
|
try {
|
|
const { filePath } = req.body as { filePath: string };
|
|
|
|
if (!filePath) {
|
|
res.status(400).json({ success: false, error: "filePath is required" });
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = path.resolve(filePath);
|
|
|
|
// Check if path exists
|
|
try {
|
|
const stats = await fs.stat(resolvedPath);
|
|
|
|
if (!stats.isDirectory()) {
|
|
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" });
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Resolve directory path from directory name and file structure
|
|
// Used when browser file picker only provides directory name (not full path)
|
|
router.post("/resolve-directory", async (req: Request, res: Response) => {
|
|
try {
|
|
const { directoryName, sampleFiles, fileCount } = req.body as {
|
|
directoryName: string;
|
|
sampleFiles?: string[];
|
|
fileCount?: number;
|
|
};
|
|
|
|
if (!directoryName) {
|
|
res.status(400).json({ success: false, error: "directoryName is required" });
|
|
return;
|
|
}
|
|
|
|
// If directoryName looks like an absolute path, try validating it directly
|
|
if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) {
|
|
try {
|
|
const resolvedPath = path.resolve(directoryName);
|
|
const stats = await fs.stat(resolvedPath);
|
|
if (stats.isDirectory()) {
|
|
addAllowedPath(resolvedPath);
|
|
return res.json({
|
|
success: true,
|
|
path: resolvedPath,
|
|
});
|
|
}
|
|
} catch {
|
|
// Not a valid absolute path, continue to search
|
|
}
|
|
}
|
|
|
|
// 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"),
|
|
// Common project locations
|
|
path.join(process.env.HOME || process.env.USERPROFILE || "", "Projects"),
|
|
].filter(Boolean);
|
|
|
|
// Also check parent of current working directory
|
|
try {
|
|
const parentDir = path.dirname(process.cwd());
|
|
if (!searchPaths.includes(parentDir)) {
|
|
searchPaths.push(parentDir);
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
|
|
// Search for directory matching the name and file structure
|
|
for (const searchPath of searchPaths) {
|
|
try {
|
|
const candidatePath = path.join(searchPath, directoryName);
|
|
const stats = await fs.stat(candidatePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Verify it matches by checking for sample files
|
|
if (sampleFiles && sampleFiles.length > 0) {
|
|
let matches = 0;
|
|
for (const sampleFile of sampleFiles.slice(0, 5)) {
|
|
// Remove directory name prefix from sample file path
|
|
const relativeFile = sampleFile.startsWith(directoryName + "/")
|
|
? sampleFile.substring(directoryName.length + 1)
|
|
: sampleFile.split("/").slice(1).join("/") || sampleFile.split("/").pop() || sampleFile;
|
|
|
|
try {
|
|
const filePath = path.join(candidatePath, relativeFile);
|
|
await fs.access(filePath);
|
|
matches++;
|
|
} catch {
|
|
// File doesn't exist, continue checking
|
|
}
|
|
}
|
|
|
|
// If at least one file matches, consider it a match
|
|
if (matches === 0 && sampleFiles.length > 0) {
|
|
continue; // Try next candidate
|
|
}
|
|
}
|
|
|
|
// Found matching directory
|
|
addAllowedPath(candidatePath);
|
|
return res.json({
|
|
success: true,
|
|
path: candidatePath,
|
|
});
|
|
}
|
|
} catch {
|
|
// Directory doesn't exist at this location, continue searching
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Directory not found
|
|
res.status(404).json({
|
|
success: false,
|
|
error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Save image to .automaker/images directory
|
|
router.post("/save-image", async (req: Request, res: Response) => {
|
|
try {
|
|
const { data, filename, mimeType, projectPath } = req.body as {
|
|
data: string;
|
|
filename: string;
|
|
mimeType: string;
|
|
projectPath: string;
|
|
};
|
|
|
|
if (!data || !filename || !projectPath) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: "data, filename, and projectPath are required",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Create .automaker/images directory if it doesn't exist
|
|
const imagesDir = path.join(projectPath, ".automaker", "images");
|
|
await fs.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");
|
|
|
|
// Generate unique filename with timestamp
|
|
const timestamp = Date.now();
|
|
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 project path to allowed paths if not already
|
|
addAllowedPath(projectPath);
|
|
|
|
res.json({ success: true, path: filePath });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Browse directories - for file browser UI
|
|
router.post("/browse", async (req: Request, res: Response) => {
|
|
try {
|
|
const { dirPath } = req.body as { dirPath?: string };
|
|
|
|
// Default to home directory if no path provided
|
|
const targetPath = dirPath ? path.resolve(dirPath) : os.homedir();
|
|
|
|
// Detect available drives on Windows
|
|
const detectDrives = async (): Promise<string[]> => {
|
|
if (os.platform() !== "win32") {
|
|
return [];
|
|
}
|
|
|
|
const drives: string[] = [];
|
|
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
|
|
for (const letter of letters) {
|
|
const drivePath = `${letter}:\\`;
|
|
try {
|
|
await fs.access(drivePath);
|
|
drives.push(drivePath);
|
|
} catch {
|
|
// Drive doesn't exist, skip it
|
|
}
|
|
}
|
|
|
|
return drives;
|
|
};
|
|
|
|
try {
|
|
const stats = await fs.stat(targetPath);
|
|
|
|
if (!stats.isDirectory()) {
|
|
res.status(400).json({ success: false, error: "Path is not a directory" });
|
|
return;
|
|
}
|
|
|
|
// Read directory contents
|
|
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
|
|
|
// Filter for directories only and add parent directory option
|
|
const directories = entries
|
|
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
.map((entry) => ({
|
|
name: entry.name,
|
|
path: path.join(targetPath, entry.name),
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// Get parent directory
|
|
const parentPath = path.dirname(targetPath);
|
|
const hasParent = parentPath !== targetPath;
|
|
|
|
// Get available drives
|
|
const drives = await detectDrives();
|
|
|
|
res.json({
|
|
success: true,
|
|
currentPath: targetPath,
|
|
parentPath: hasParent ? parentPath : null,
|
|
directories,
|
|
drives,
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to read directory",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Serve image files
|
|
router.get("/image", async (req: Request, res: Response) => {
|
|
try {
|
|
const { path: imagePath, projectPath } = req.query as {
|
|
path?: string;
|
|
projectPath?: string;
|
|
};
|
|
|
|
if (!imagePath) {
|
|
res.status(400).json({ success: false, error: "path is required" });
|
|
return;
|
|
}
|
|
|
|
// Resolve full path
|
|
const fullPath = path.isAbsolute(imagePath)
|
|
? imagePath
|
|
: projectPath
|
|
? 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" });
|
|
return;
|
|
}
|
|
|
|
// Read the file
|
|
const buffer = await fs.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",
|
|
};
|
|
|
|
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
|
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
res.send(buffer);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Save board background image to .automaker/board directory
|
|
router.post("/save-board-background", async (req: Request, res: Response) => {
|
|
try {
|
|
const { data, filename, mimeType, projectPath } = req.body as {
|
|
data: string;
|
|
filename: string;
|
|
mimeType: string;
|
|
projectPath: string;
|
|
};
|
|
|
|
if (!data || !filename || !projectPath) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: "data, filename, and projectPath are required",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Create .automaker/board directory if it doesn't exist
|
|
const boardDir = path.join(projectPath, ".automaker", "board");
|
|
await fs.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");
|
|
|
|
// Use a fixed filename for the board background (overwrite previous)
|
|
const ext = path.extname(filename) || ".png";
|
|
const uniqueFilename = `background${ext}`;
|
|
const filePath = path.join(boardDir, uniqueFilename);
|
|
|
|
// Write file
|
|
await fs.writeFile(filePath, buffer);
|
|
|
|
// Add project path to allowed paths if not already
|
|
addAllowedPath(projectPath);
|
|
|
|
// Return the relative path for storage
|
|
const relativePath = `.automaker/board/${uniqueFilename}`;
|
|
res.json({ success: true, path: relativePath });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Delete board background image
|
|
router.post("/delete-board-background", async (req: Request, res: Response) => {
|
|
try {
|
|
const { projectPath } = req.body as { projectPath: string };
|
|
|
|
if (!projectPath) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: "projectPath is required",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const boardDir = path.join(projectPath, ".automaker", "board");
|
|
|
|
try {
|
|
// Try to remove all files in the board directory
|
|
const files = await fs.readdir(boardDir);
|
|
for (const file of files) {
|
|
if (file.startsWith("background")) {
|
|
await fs.unlink(path.join(boardDir, file));
|
|
}
|
|
}
|
|
} catch {
|
|
// Directory may not exist, that's fine
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
// Browse directories for file picker
|
|
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows
|
|
router.post("/browse", async (req: Request, res: Response) => {
|
|
try {
|
|
const { dirPath } = req.body as { dirPath?: string };
|
|
const homeDir = os.homedir();
|
|
|
|
// Detect available drives on Windows
|
|
const detectDrives = async (): Promise<string[]> => {
|
|
if (os.platform() !== "win32") {
|
|
return [];
|
|
}
|
|
|
|
const drives: string[] = [];
|
|
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
|
|
for (const letter of letters) {
|
|
const drivePath = `${letter}:\\`;
|
|
try {
|
|
await fs.access(drivePath);
|
|
drives.push(drivePath);
|
|
} catch {
|
|
// Drive doesn't exist, skip it
|
|
}
|
|
}
|
|
|
|
return drives;
|
|
};
|
|
|
|
// Check if a path is safe to browse
|
|
const isSafePath = (targetPath: string): boolean => {
|
|
const resolved = path.resolve(targetPath);
|
|
const normalizedHome = path.resolve(homeDir);
|
|
|
|
// Allow browsing within home directory
|
|
if (resolved === normalizedHome || resolved.startsWith(normalizedHome + path.sep)) {
|
|
return true;
|
|
}
|
|
|
|
// Allow browsing already-allowed paths
|
|
if (isPathAllowed(resolved)) {
|
|
return true;
|
|
}
|
|
|
|
// On Windows, allow drive roots for initial navigation
|
|
if (os.platform() === "win32") {
|
|
const driveRootMatch = /^[A-Z]:\\$/i.test(resolved);
|
|
if (driveRootMatch) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// On Unix, allow root for initial navigation (but only list, not read files)
|
|
if (os.platform() !== "win32" && resolved === "/") {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Default to home directory if no path provided
|
|
const targetPath = dirPath ? path.resolve(dirPath) : homeDir;
|
|
|
|
// Security check: validate the path is safe to browse
|
|
if (!isSafePath(targetPath)) {
|
|
res.status(403).json({
|
|
success: false,
|
|
error: "Access denied: browsing is restricted to your home directory and allowed project paths",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stats = await fs.stat(targetPath);
|
|
|
|
if (!stats.isDirectory()) {
|
|
res.status(400).json({ success: false, error: "Path is not a directory" });
|
|
return;
|
|
}
|
|
|
|
// Read directory contents
|
|
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
|
|
|
// Filter for directories only and exclude hidden directories
|
|
const directories = entries
|
|
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
.map((entry) => ({
|
|
name: entry.name,
|
|
path: path.join(targetPath, entry.name),
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// Get parent directory (only if parent is also safe to browse)
|
|
const parentPath = path.dirname(targetPath);
|
|
const hasParent = parentPath !== targetPath && isSafePath(parentPath);
|
|
|
|
// Get available drives on Windows
|
|
const drives = await detectDrives();
|
|
|
|
res.json({
|
|
success: true,
|
|
currentPath: targetPath,
|
|
parentPath: hasParent ? parentPath : null,
|
|
directories,
|
|
drives,
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to read directory",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
res.status(500).json({ success: false, error: message });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|