980 lines
36 KiB
TypeScript
980 lines
36 KiB
TypeScript
import { Router, type Request, type Response, type NextFunction } from 'express';
|
|
import express from 'express';
|
|
import { existsSync, readFileSync, writeFileSync, statSync, readdirSync, renameSync, mkdirSync, unlinkSync } from 'fs';
|
|
import { join, dirname, basename } from 'path';
|
|
import {
|
|
USER_SUBDIRS,
|
|
type UserSubdir,
|
|
ensureUserFolder,
|
|
resolveUserSubdir,
|
|
userRoot,
|
|
readUserAgentsMd,
|
|
writeUserAgentsMd,
|
|
deleteUserAgentsMd,
|
|
} from '../user-folder/paths.js';
|
|
import { logger } from '../logger.js';
|
|
import { compileScript } from '../user-folder/script-compiler.js';
|
|
import { parseScript, serializeScript } from '../user-folder/frontmatter.js';
|
|
import { runUserScript } from '../user-folder/script-runner.js';
|
|
import type { RecordedAction } from '../engine/browser-recorder.js';
|
|
import { recorder } from '../engine/browser-recorder.js';
|
|
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
|
import { loadSessionStateForUser } from '../user-folder/session-loader.js';
|
|
import {
|
|
deletePet,
|
|
getPet,
|
|
importPetZip,
|
|
listPets,
|
|
PetConflictError,
|
|
PetValidationError,
|
|
readPetSettings,
|
|
resolvePetAsset,
|
|
slugifyPetId,
|
|
writePetSettings,
|
|
} from '../user-folder/pets.js';
|
|
import type { NotesService } from '../notes/notes-service.js';
|
|
|
|
interface Deps {
|
|
userFolderRoot: string;
|
|
sessRepo?: BrowserSessionRepo;
|
|
masterKeyPath?: string;
|
|
authActive?: boolean; // default true; when false, fall back to synthetic 'local' user
|
|
notesService?: NotesService;
|
|
}
|
|
|
|
interface AuthedUser { id: string; role: string; }
|
|
|
|
function getUser(req: Request): AuthedUser | null {
|
|
return (req.user as AuthedUser | undefined) ?? null;
|
|
}
|
|
|
|
const MAX_FILE_BYTES = 1024 * 1024; // 1 MB
|
|
|
|
function isUserSubdir(s: string): s is UserSubdir {
|
|
return (USER_SUBDIRS as readonly string[]).includes(s);
|
|
}
|
|
|
|
// Subdirs that users may write to / delete from. 'trash' is system-managed.
|
|
// 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers
|
|
// then delegate immediately to NotesService rather than the generic file writer.
|
|
const WRITABLE_SUBDIRS = ['browser-macros', 'recordings', 'notes'] as const;
|
|
type WritableSubdir = typeof WRITABLE_SUBDIRS[number];
|
|
function isWritableSubdir(s: string): s is WritableSubdir {
|
|
return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
|
|
}
|
|
|
|
/**
|
|
* Write file atomically via tmp + rename.
|
|
* The tmp file is created in the same directory as the target to ensure
|
|
* rename is an atomic single-filesystem move.
|
|
*/
|
|
function writeAtomic(path: string, content: string): void {
|
|
const dir = dirname(path);
|
|
mkdirSync(dir, { recursive: true });
|
|
const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
let renamed = false;
|
|
try {
|
|
writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
|
|
renameSync(tmp, path);
|
|
renamed = true;
|
|
} finally {
|
|
if (!renamed) {
|
|
try { unlinkSync(tmp); } catch { /* tmp may not exist if writeFileSync threw */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a Date as YYYYMMDD-HHMMSS in UTC (used for trash prefix).
|
|
*/
|
|
function utcTimestamp(d: Date): string {
|
|
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
|
|
return (
|
|
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
|
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
|
);
|
|
}
|
|
|
|
export function createUserFolderApi(deps: Deps): Router {
|
|
const { userFolderRoot } = deps;
|
|
const r = Router();
|
|
|
|
// ── Auth gate ────────────────────────────────────────────────────────────
|
|
const authActive = deps.authActive ?? true;
|
|
r.use((req: Request, res: Response, next) => {
|
|
if (!authActive && !getUser(req)) {
|
|
// Local-dev / no-auth mode: inject a synthetic 'local' user so handlers
|
|
// can operate against data/users/local/. Real OAuth deployments are
|
|
// unaffected because authActive=true and Passport populates req.user.
|
|
(req as any).user = { id: 'local', role: 'user' };
|
|
}
|
|
if (!getUser(req)) {
|
|
res.status(401).json({ error: 'Unauthenticated' });
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
// ── Pets: Codex Pets-compatible user imports ────────────────────────────
|
|
r.get('/pets', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
try {
|
|
res.json({
|
|
pets: listPets(userFolderRoot, u.id),
|
|
settings: readPetSettings(userFolderRoot, u.id),
|
|
});
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] pets list failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to list pets' });
|
|
}
|
|
});
|
|
|
|
r.post('/pets/import', express.raw({ limit: '12mb', type: '*/*' }), (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
const rawPetId = typeof req.query['petId'] === 'string'
|
|
? req.query['petId']
|
|
: typeof req.query['filename'] === 'string'
|
|
? req.query['filename']
|
|
: null;
|
|
const overwrite = req.query['overwrite'] === 'true';
|
|
try {
|
|
const detail = importPetZip(userFolderRoot, u.id, body, {
|
|
preferredId: rawPetId ? slugifyPetId(rawPetId) : null,
|
|
overwrite,
|
|
});
|
|
res.json({ ok: true, pet: detail });
|
|
} catch (err) {
|
|
if (err instanceof PetConflictError) {
|
|
res.status(409).json({ error: err.message, petId: err.petId });
|
|
return;
|
|
}
|
|
if (err instanceof PetValidationError || err instanceof SyntaxError) {
|
|
res.status(400).json({ error: (err as Error).message });
|
|
return;
|
|
}
|
|
logger.error(`[user-folder-api] pet import failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to import pet' });
|
|
}
|
|
});
|
|
|
|
r.get('/pets/settings', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
try {
|
|
res.json({ settings: readPetSettings(userFolderRoot, u.id) });
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] pet settings read failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to read pet settings' });
|
|
}
|
|
});
|
|
|
|
r.put('/pets/settings', express.json({ limit: '32kb' }), (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
try {
|
|
const settings = writePetSettings(userFolderRoot, u.id, req.body);
|
|
res.json({ ok: true, settings });
|
|
} catch (err) {
|
|
if (err instanceof PetValidationError) {
|
|
res.status(400).json({ error: err.message });
|
|
return;
|
|
}
|
|
logger.error(`[user-folder-api] pet settings write failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to write pet settings' });
|
|
}
|
|
});
|
|
|
|
r.get('/pets/:petId/assets/:file', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const asset = resolvePetAsset(userFolderRoot, u.id, req.params.petId, req.params.file);
|
|
if (!asset) {
|
|
res.status(404).json({ error: 'Asset not found' });
|
|
return;
|
|
}
|
|
res.setHeader('Content-Type', asset.contentType);
|
|
res.sendFile(asset.path);
|
|
});
|
|
|
|
r.get('/pets/:petId', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
try {
|
|
const pet = getPet(userFolderRoot, u.id, req.params.petId);
|
|
if (!pet) {
|
|
res.status(404).json({ error: 'Pet not found' });
|
|
return;
|
|
}
|
|
res.json({ pet });
|
|
} catch (err) {
|
|
if (err instanceof PetValidationError) {
|
|
res.status(400).json({ error: err.message });
|
|
return;
|
|
}
|
|
logger.error(`[user-folder-api] pet read failed user=${u.id} pet=${req.params.petId} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to read pet' });
|
|
}
|
|
});
|
|
|
|
r.delete('/pets/:petId', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
try {
|
|
const deleted = deletePet(userFolderRoot, u.id, req.params.petId);
|
|
if (!deleted) {
|
|
res.status(404).json({ error: 'Pet not found' });
|
|
return;
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] pet delete failed user=${u.id} pet=${req.params.petId} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to delete pet' });
|
|
}
|
|
});
|
|
|
|
// ── GET /folder/list?subdir=scripts ──────────────────────────────────────
|
|
r.get('/folder/list', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const subdir = req.query['subdir'] as string | undefined;
|
|
|
|
if (!subdir || !isUserSubdir(subdir)) {
|
|
res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
const dirPath = join(userRoot(userFolderRoot, u.id), subdir);
|
|
|
|
try {
|
|
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
const files = entries
|
|
.filter(e => e.isFile() && !e.name.startsWith('.'))
|
|
.map(e => {
|
|
const stat = statSync(join(dirPath, e.name));
|
|
return {
|
|
name: e.name,
|
|
size: stat.size,
|
|
mtime: stat.mtime.toISOString(),
|
|
};
|
|
});
|
|
res.json({ files });
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] list failed user=${u.id} subdir=${subdir} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to list folder' });
|
|
}
|
|
});
|
|
|
|
// ── GET /folder/file?subdir=scripts&path=foo.js ──────────────────────────
|
|
r.get('/folder/file', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const subdir = req.query['subdir'] as string | undefined;
|
|
const relPath = req.query['path'] as string | undefined;
|
|
|
|
if (!subdir || !isUserSubdir(subdir)) {
|
|
res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
|
|
return;
|
|
}
|
|
if (!relPath) {
|
|
res.status(400).json({ error: 'path query parameter is required' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
let fullPath: string;
|
|
try {
|
|
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(fullPath)) {
|
|
res.status(404).json({ error: 'File not found' });
|
|
return;
|
|
}
|
|
|
|
let stat: ReturnType<typeof statSync>;
|
|
try {
|
|
stat = statSync(fullPath);
|
|
} catch {
|
|
res.status(404).json({ error: 'File not found' });
|
|
return;
|
|
}
|
|
|
|
if (!stat.isFile()) {
|
|
res.status(404).json({ error: 'Not a file' });
|
|
return;
|
|
}
|
|
|
|
if (stat.size > MAX_FILE_BYTES) {
|
|
res.status(413).json({ error: 'File exceeds 1 MB limit' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(fullPath, 'utf-8');
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
res.send(content);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] read failed user=${u.id} path=${relPath} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to read file' });
|
|
}
|
|
});
|
|
|
|
// ── PUT /folder/file?subdir=scripts&path=foo.js ──────────────────────────
|
|
r.put('/folder/file', express.text({ limit: '1mb', type: '*/*' }), async (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const subdir = req.query['subdir'] as string | undefined;
|
|
const relPath = req.query['path'] as string | undefined;
|
|
|
|
if (!subdir || !isWritableSubdir(subdir)) {
|
|
res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
|
|
return;
|
|
}
|
|
if (!relPath) {
|
|
res.status(400).json({ error: 'path query parameter is required' });
|
|
return;
|
|
}
|
|
|
|
// ── notes subdir: delegate entirely to NotesService ──────────────────
|
|
if (subdir === 'notes') {
|
|
if (!deps.notesService) {
|
|
res.status(500).json({ error: 'notesService is not configured; cannot write notes' });
|
|
return;
|
|
}
|
|
// Validate path: must be exactly 2 segments (<folder>/<file.md>)
|
|
const segments = relPath.split('/').filter(s => s.length > 0);
|
|
if (segments.length !== 2) {
|
|
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
|
|
return;
|
|
}
|
|
const [folder, fileName] = segments as [string, string];
|
|
const content = typeof req.body === 'string' ? req.body : '';
|
|
try {
|
|
deps.notesService.writeNote({ ownerId: u.id, folder, fileName, content });
|
|
res.json({ ok: true, indexed: true });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
|
|
res.status(400).json({ error: msg });
|
|
return;
|
|
}
|
|
logger.error(`[user-folder-api] notes write failed user=${u.id} path=${relPath} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to write note' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
let fullPath: string;
|
|
try {
|
|
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
|
|
return;
|
|
}
|
|
|
|
const content = typeof req.body === 'string' ? req.body : '';
|
|
|
|
try {
|
|
writeAtomic(fullPath, content);
|
|
const stat = statSync(fullPath);
|
|
res.json({ ok: true, size: stat.size, mtime: stat.mtime.toISOString() });
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] write failed user=${u.id} path=${relPath} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to write file' });
|
|
}
|
|
});
|
|
|
|
// ── DELETE /folder/file?subdir=scripts&path=foo.js ───────────────────────
|
|
r.delete('/folder/file', async (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const subdir = req.query['subdir'] as string | undefined;
|
|
const relPath = req.query['path'] as string | undefined;
|
|
|
|
if (!subdir || !isWritableSubdir(subdir)) {
|
|
res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
|
|
return;
|
|
}
|
|
if (!relPath) {
|
|
res.status(400).json({ error: 'path query parameter is required' });
|
|
return;
|
|
}
|
|
|
|
// ── notes subdir: delegate entirely to NotesService ──────────────────
|
|
if (subdir === 'notes') {
|
|
if (!deps.notesService) {
|
|
res.status(500).json({ error: 'notesService is not configured; cannot delete notes' });
|
|
return;
|
|
}
|
|
const segments = relPath.split('/').filter(s => s.length > 0);
|
|
if (segments.length !== 2) {
|
|
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
|
|
return;
|
|
}
|
|
const [folder, fileName] = segments as [string, string];
|
|
try {
|
|
deps.notesService.deleteNote({ ownerId: u.id, folder, fileName });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
|
|
res.status(400).json({ error: msg });
|
|
return;
|
|
}
|
|
logger.error(`[user-folder-api] notes delete failed user=${u.id} path=${relPath} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to delete note' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
let fullPath: string;
|
|
try {
|
|
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(fullPath)) {
|
|
res.status(404).json({ error: 'File not found' });
|
|
return;
|
|
}
|
|
|
|
// Extract the base filename for the trash name
|
|
const originalName = relPath.split('/').pop()!;
|
|
const ts = utcTimestamp(new Date());
|
|
const suffix = Math.random().toString(16).slice(2, 6);
|
|
const trashedAs = `${ts}-${suffix}-${originalName}`;
|
|
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
|
|
const trashPath = join(trashDir, trashedAs);
|
|
|
|
try {
|
|
renameSync(fullPath, trashPath);
|
|
res.json({ ok: true, trashedAs });
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] delete/trash failed user=${u.id} path=${relPath} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to move file to trash' });
|
|
}
|
|
});
|
|
|
|
// ── POST /browser-macros/compile ──────────────────────────────────────────────
|
|
r.post('/browser-macros/compile', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
|
|
const {
|
|
recordingName,
|
|
scriptName,
|
|
description,
|
|
sessionProfileId,
|
|
paramHints,
|
|
} = req.body as {
|
|
recordingName?: unknown;
|
|
scriptName?: unknown;
|
|
description?: unknown;
|
|
sessionProfileId?: unknown;
|
|
paramHints?: unknown;
|
|
};
|
|
|
|
if (typeof recordingName !== 'string' || !recordingName.trim()) {
|
|
res.status(400).json({ error: 'recordingName is required' });
|
|
return;
|
|
}
|
|
if (typeof scriptName !== 'string' || !scriptName.trim()) {
|
|
res.status(400).json({ error: 'scriptName is required' });
|
|
return;
|
|
}
|
|
if (typeof description !== 'string') {
|
|
res.status(400).json({ error: 'description is required' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
// Resolve recording path
|
|
let recordingPath: string;
|
|
try {
|
|
recordingPath = resolveUserSubdir(userFolderRoot, u.id, 'recordings', `${recordingName}.json`);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid recordingName' });
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(recordingPath)) {
|
|
res.status(404).json({ error: `Recording not found: ${recordingName}.json` });
|
|
return;
|
|
}
|
|
|
|
// Parse recording JSON
|
|
let recording: { recordTo?: unknown; capturedAt?: unknown; actions?: unknown };
|
|
try {
|
|
recording = JSON.parse(readFileSync(recordingPath, 'utf-8')) as typeof recording;
|
|
} catch {
|
|
res.status(400).json({ error: 'Recording is not valid JSON' });
|
|
return;
|
|
}
|
|
|
|
// Validate shape
|
|
if (!recording || typeof recording !== 'object' || !Array.isArray(recording.actions)) {
|
|
res.status(400).json({ error: 'Recording is missing required fields (expected { recordTo, capturedAt, actions })' });
|
|
return;
|
|
}
|
|
|
|
// Conflict policy: check if script already exists
|
|
const scriptFileName = scriptName.endsWith('.js') ? scriptName : `${scriptName}.js`;
|
|
let scriptPath: string;
|
|
try {
|
|
scriptPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid scriptName' });
|
|
return;
|
|
}
|
|
|
|
if (existsSync(scriptPath) && req.query['overwrite'] !== 'true') {
|
|
res.status(409).json({ error: 'Script already exists; pass ?overwrite=true to replace' });
|
|
return;
|
|
}
|
|
|
|
// Validate paramHints shape
|
|
if (paramHints !== undefined) {
|
|
if (!Array.isArray(paramHints)) {
|
|
res.status(400).json({ error: 'paramHints must be an array' });
|
|
return;
|
|
}
|
|
for (let i = 0; i < paramHints.length; i++) {
|
|
const hint = paramHints[i];
|
|
if (!hint || typeof hint !== 'object' ||
|
|
typeof hint.name !== 'string' || !hint.name ||
|
|
typeof hint.valueToReplace !== 'string' ||
|
|
!['string', 'number', 'boolean'].includes((hint as any).type)) {
|
|
res.status(400).json({
|
|
error: `paramHints[${i}] must be { name: string, valueToReplace: string, type: 'string' | 'number' | 'boolean' }`,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compile
|
|
let compiled: ReturnType<typeof compileScript>;
|
|
try {
|
|
compiled = compileScript({
|
|
recording: recording.actions as RecordedAction[],
|
|
description,
|
|
sessionProfileId: typeof sessionProfileId === 'number' ? sessionProfileId : undefined,
|
|
paramHints: Array.isArray(paramHints)
|
|
? (paramHints as { name: string; valueToReplace: string; type: 'string' | 'number' | 'boolean' }[])
|
|
: undefined,
|
|
recordingSource: `${recordingName}.json`,
|
|
});
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] compile failed user=${u.id} recording=${recordingName} err=${err}`);
|
|
res.status(500).json({ error: `Compile failed: ${(err as Error).message}` });
|
|
return;
|
|
}
|
|
|
|
// Stamp timestamps and re-serialize
|
|
const now = new Date().toISOString();
|
|
const meta = { ...compiled.meta, createdAt: now, updatedAt: now };
|
|
const { body } = parseScript(compiled.source);
|
|
const source = serializeScript({ frontmatter: meta, body });
|
|
|
|
// Write atomically
|
|
try {
|
|
writeAtomic(scriptPath, source);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] write failed user=${u.id} script=${scriptName} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to write script file' });
|
|
return;
|
|
}
|
|
|
|
const size = statSync(scriptPath).size;
|
|
res.json({ ok: true, scriptName: scriptFileName, source, size });
|
|
});
|
|
|
|
// ── POST /scripts/:name/run ───────────────────────────────────────────────────
|
|
// body.kind: 'script' | 'browser-macro' — determines which subdir to load from.
|
|
// If omitted, both are tried (scripts/ first, then browser-macros/).
|
|
r.post('/scripts/:name/run', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const rawName = req.params['name'] ?? '';
|
|
const scriptFileName = rawName.endsWith('.js') ? rawName : `${rawName}.js`;
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
const { params, timeoutMs } = ((req.body as Record<string, unknown>) ?? {}) as {
|
|
params?: Record<string, unknown>;
|
|
timeoutMs?: number;
|
|
};
|
|
|
|
// Plain-Node scripts/ were retired (2026-06) — only browser-macros run here.
|
|
let scriptPath: string | null = null;
|
|
const resolvedRuntime = 'playwright' as const;
|
|
try {
|
|
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
|
|
if (existsSync(candidate)) scriptPath = candidate;
|
|
} catch { /* invalid path */ }
|
|
if (scriptPath === null) {
|
|
res.status(404).json({ error: `Script not found: ${scriptFileName}` });
|
|
return;
|
|
}
|
|
|
|
// Clamp timeoutMs to 5 minutes max (prevent malicious long-running requests)
|
|
const requestedTimeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 60_000;
|
|
const cappedTimeout = Math.min(requestedTimeout, 300_000);
|
|
|
|
// Load session storageState if it's a playwright-runtime script with sessionProfileId
|
|
let storageState: object | undefined;
|
|
if (resolvedRuntime === 'playwright') {
|
|
try {
|
|
const source = readFileSync(scriptPath, 'utf-8');
|
|
const parsed = parseScript(source);
|
|
const sessionProfileId = parsed.frontmatter.sessionProfileId;
|
|
|
|
if (sessionProfileId !== undefined) {
|
|
if (!deps.sessRepo || !deps.masterKeyPath) {
|
|
res.status(500).json({
|
|
error: 'Session profile required but session repository is not configured',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const sessionResult = await loadSessionStateForUser(
|
|
{ sessRepo: deps.sessRepo, masterKeyPath: deps.masterKeyPath },
|
|
u.id,
|
|
sessionProfileId,
|
|
);
|
|
|
|
if (!sessionResult.ok) {
|
|
res.status(500).json({ error: sessionResult.error.message });
|
|
return;
|
|
}
|
|
|
|
storageState = sessionResult.storageState;
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({ error: `Failed to parse script: ${(err as Error).message}` });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Run
|
|
const startMs = Date.now();
|
|
try {
|
|
const scriptResult = await runUserScript({
|
|
scriptPath,
|
|
params: params ?? {},
|
|
runtime: resolvedRuntime,
|
|
storageState,
|
|
timeoutMs: cappedTimeout,
|
|
});
|
|
const durationMs = Date.now() - startMs;
|
|
res.json({ result: scriptResult.result, logs: scriptResult.logs, durationMs });
|
|
} catch (err) {
|
|
const durationMs = Date.now() - startMs;
|
|
const message = (err as Error).message;
|
|
res.status(500).json({ error: message, durationMs });
|
|
}
|
|
});
|
|
|
|
// ── GET /browser-macros/:name/diff ───────────────────────────────────────────
|
|
// Returns { current: string|null, candidate: string, candidateMtime: string }
|
|
// or 404 if no .next.js exists.
|
|
r.get('/browser-macros/:name/diff', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const rawName = req.params['name'] ?? '';
|
|
// Normalize: strip any trailing .js suffix to get the bare name
|
|
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
let candidatePath: string;
|
|
let currentPath: string;
|
|
try {
|
|
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
|
|
currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid script name' });
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(candidatePath)) {
|
|
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
|
|
return;
|
|
}
|
|
|
|
let candidate: string;
|
|
let candidateMtime: string;
|
|
try {
|
|
candidate = readFileSync(candidatePath, 'utf-8');
|
|
candidateMtime = statSync(candidatePath).mtime.toISOString();
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] diff read candidate failed user=${u.id} name=${baseName} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to read candidate file' });
|
|
return;
|
|
}
|
|
|
|
// current may not exist (orphaned .next.js)
|
|
let current: string | null = null;
|
|
if (existsSync(currentPath)) {
|
|
try {
|
|
current = readFileSync(currentPath, 'utf-8');
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] diff read current failed user=${u.id} name=${baseName} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to read current file' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
res.json({ current, candidate, candidateMtime });
|
|
});
|
|
|
|
// ── POST /browser-macros/:name/accept ────────────────────────────────────────
|
|
// Atomically archives browser-macros/{name}.js to trash, then renames .next.js into place.
|
|
// NOTE: Not fully atomic — a crash between step 1 and step 2 would leave no
|
|
// browser-macros/{name}.js. Acceptable given the complexity of a copy-then-rename
|
|
// alternative. The .next.js is always preserved or moved to trash.
|
|
r.post('/browser-macros/:name/accept', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const rawName = req.params['name'] ?? '';
|
|
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
let candidatePath: string;
|
|
let currentPath: string;
|
|
try {
|
|
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
|
|
currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid script name' });
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(candidatePath)) {
|
|
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
|
|
return;
|
|
}
|
|
|
|
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
|
|
const ts = utcTimestamp(new Date());
|
|
const suffix = Math.random().toString(16).slice(2, 6);
|
|
|
|
let archivedAs: string | null = null;
|
|
|
|
try {
|
|
// Step 1: Archive the existing script to trash (if it exists)
|
|
if (existsSync(currentPath)) {
|
|
archivedAs = `${ts}-${suffix}-${baseName}.js`;
|
|
const trashPath = join(trashDir, archivedAs);
|
|
renameSync(currentPath, trashPath);
|
|
}
|
|
|
|
// Step 2: Rename .next.js into the canonical script location
|
|
renameSync(candidatePath, currentPath);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] accept failed user=${u.id} name=${baseName} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to accept patch' });
|
|
return;
|
|
}
|
|
|
|
logger.info(`[user-folder-api] accept user=${u.id} name=${baseName} archivedAs=${archivedAs ?? 'none'}`);
|
|
res.json({ ok: true, accepted: `${baseName}.js`, archivedAs });
|
|
});
|
|
|
|
// ── POST /browser-macros/:name/reject ────────────────────────────────────────
|
|
// Moves browser-macros/{name}.next.js to trash; the original .js is untouched.
|
|
r.post('/browser-macros/:name/reject', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const rawName = req.params['name'] ?? '';
|
|
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
|
|
|
|
try {
|
|
ensureUserFolder(userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to ensure user folder' });
|
|
return;
|
|
}
|
|
|
|
let candidatePath: string;
|
|
try {
|
|
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
|
|
} catch {
|
|
res.status(400).json({ error: 'Invalid script name' });
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(candidatePath)) {
|
|
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
|
|
return;
|
|
}
|
|
|
|
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
|
|
const ts = utcTimestamp(new Date());
|
|
const suffix = Math.random().toString(16).slice(2, 6);
|
|
const trashedAs = `${ts}-${suffix}-${baseName}.next.js`;
|
|
const trashPath = join(trashDir, trashedAs);
|
|
|
|
try {
|
|
renameSync(candidatePath, trashPath);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] reject failed user=${u.id} name=${baseName} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to reject patch' });
|
|
return;
|
|
}
|
|
|
|
logger.info(`[user-folder-api] reject user=${u.id} name=${baseName} trashedAs=${trashedAs}`);
|
|
res.json({ ok: true, rejected: `${baseName}.next.js`, trashedAs });
|
|
});
|
|
|
|
// ── POST /recordings/flush?taskId=<id> ───────────────────────────────────
|
|
// Flush the in-memory recording buffer for a given taskId to disk.
|
|
// Returns { ok: true, recordingName, path } or 404 if no buffer exists.
|
|
r.post('/recordings/flush', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const taskId = req.query['taskId'] as string | undefined;
|
|
|
|
if (!taskId) {
|
|
res.status(400).json({ error: 'taskId query parameter is required' });
|
|
return;
|
|
}
|
|
|
|
let absPath: string | null;
|
|
try {
|
|
absPath = recorder.flush(taskId, userFolderRoot, u.id);
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] recordings/flush failed user=${u.id} taskId=${taskId} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to flush recording' });
|
|
return;
|
|
}
|
|
|
|
if (absPath === null) {
|
|
res.status(404).json({ error: 'no active recording for this task' });
|
|
return;
|
|
}
|
|
|
|
// e.g. absPath = "/data/users/user-a/recordings/my-rec.json"
|
|
// recordingName = "my-rec" (basename without .json)
|
|
const fileBasename = basename(absPath);
|
|
const recordingName = fileBasename.endsWith('.json')
|
|
? fileBasename.slice(0, -5)
|
|
: fileBasename;
|
|
const relPath = `recordings/${fileBasename}`;
|
|
|
|
logger.info(`[user-folder-api] recordings/flush user=${u.id} taskId=${taskId} recordingName=${recordingName}`);
|
|
res.json({ ok: true, recordingName, path: relPath });
|
|
});
|
|
|
|
// ── GET /agents-md ───────────────────────────────────────────────────────
|
|
r.get('/agents-md', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
try {
|
|
const content = readUserAgentsMd(userFolderRoot, u.id);
|
|
if (content === null) {
|
|
res.json({ exists: false, content: '' });
|
|
return;
|
|
}
|
|
res.json({ exists: true, content });
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] read AGENTS.md failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to read AGENTS.md' });
|
|
}
|
|
});
|
|
|
|
// ── PUT /agents-md ───────────────────────────────────────────────────────
|
|
r.put('/agents-md', express.text({ type: '*/*', limit: '256kb' }), (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
const body = (req.body as unknown) as string;
|
|
if (typeof body !== 'string') {
|
|
res.status(400).json({ error: 'body must be text/plain' });
|
|
return;
|
|
}
|
|
try {
|
|
writeUserAgentsMd(userFolderRoot, u.id, body);
|
|
res.json({ ok: true, bytes: Buffer.byteLength(body, 'utf-8') });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes('exceeds')) {
|
|
res.status(413).json({ error: msg });
|
|
return;
|
|
}
|
|
logger.error(`[user-folder-api] write AGENTS.md failed user=${u.id} err=${msg}`);
|
|
res.status(500).json({ error: 'Failed to write AGENTS.md' });
|
|
}
|
|
});
|
|
|
|
// ── DELETE /agents-md ────────────────────────────────────────────────────
|
|
r.delete('/agents-md', (req: Request, res: Response) => {
|
|
const u = getUser(req)!;
|
|
try {
|
|
const existed = deleteUserAgentsMd(userFolderRoot, u.id);
|
|
res.json({ ok: true, existed });
|
|
} catch (err) {
|
|
logger.error(`[user-folder-api] delete AGENTS.md failed user=${u.id} err=${err}`);
|
|
res.status(500).json({ error: 'Failed to delete AGENTS.md' });
|
|
}
|
|
});
|
|
|
|
// ── Router-level error middleware ─────────────────────────────────────────
|
|
// Catches errors from route handlers (e.g. express.text body-too-large).
|
|
r.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
|
if (err && err.type === 'entity.too.large') {
|
|
res.status(413).json({ error: 'Request body exceeds 1 MB limit' });
|
|
return;
|
|
}
|
|
res.status(err?.status ?? 500).json({ error: err?.message ?? 'Internal error' });
|
|
});
|
|
|
|
return r;
|
|
}
|