import type { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, statSync, renameSync, readdirSync, } from "node:fs"; import { resolve, dirname, join } from "node:path"; import type { StudioApiAdapter } from "../types.js"; import { isAudioFile } from "../helpers/mime.js"; import { generateWaveformCache } from "../helpers/waveform.js"; import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { isSafePath } from "../helpers/safePath.js"; import { removeElementFromHtml, patchElementInHtml, probeElementInSource, type PatchOperation, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; // ── Shared helpers ────────────────────────────────────────────────────────── /** * Resolve the project and file path from the request, validating safety. * Returns null (and sends an error response) if anything is invalid. */ interface RouteContext { req: { param: (name: string) => string; path: string; query: (name: string) => string | undefined; }; json: (data: unknown, status?: number) => Response; } /** Resolve project + safe absolute path for any project-scoped route. */ async function resolveProjectPath( c: RouteContext, adapter: StudioApiAdapter, pathPrefix: (projectId: string) => string, opts?: { mustExist?: boolean }, ) { const id = c.req.param("id"); const project = await adapter.resolveProject(id); if (!project) { return { error: c.json({ error: "not found" }, 404) } as const; } const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), "")); if (filePath.includes("\0")) { return { error: c.json({ error: "forbidden" }, 403) } as const; } const absPath = resolve(project.dir, filePath); if (!isSafePath(project.dir, absPath)) { return { error: c.json({ error: "forbidden" }, 403) } as const; } if (opts?.mustExist && !existsSync(absPath)) { return { error: c.json({ error: "not found" }, 404) } as const; } return { project, filePath, absPath } as const; } function resolveProjectFile( c: RouteContext, adapter: StudioApiAdapter, opts?: { mustExist?: boolean }, ) { return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts); } function resolveFileMutationContext(c: RouteContext, adapter: StudioApiAdapter, operation: string) { return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`); } type MutationTarget = { id?: string | null; selector?: string; selectorIndex?: number }; /** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */ function writeIfChanged( c: RouteContext, absPath: string, original: string, next: string, ): Response { if (next === original) { return c.json({ ok: true, changed: false, content: original }); } writeFileSync(absPath, next, "utf-8"); return c.json({ ok: true, changed: true, content: next }); } /** * Parse the request body and validate that `target` is present. * Returns `{ error }` if missing, or `{ target, body }` for the full parsed body. */ async function parseMutationBody( c: RouteContext & { req: { json(): Promise } }, ): Promise<{ error: Response } | { target: MutationTarget; body: T }> { const body = (await (c.req as { json(): Promise }).json().catch(() => null)) as T | null; if (!body?.target) { return { error: c.json({ error: "target required" }, 400) }; } return { target: body.target, body }; } /** Ensure the parent directory of a path exists. */ function ensureDir(filePath: string) { const dir = dirname(filePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } /** * Generate a copy name: foo.html → foo (copy).html → foo (copy 2).html */ function generateCopyPath(projectDir: string, originalPath: string): string { const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : ""; const base = ext ? originalPath.slice(0, -ext.length) : originalPath; // If already a copy, increment the number const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/); const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base; let num = copyMatch ? (copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2) : 1; let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`; while (existsSync(resolve(projectDir, candidate))) { num++; candidate = `${cleanBase} (copy ${num})${ext}`; } return candidate; } /** * Walk a directory recursively and return all file paths matching a filter. */ function walkFiles(dir: string, filter: (name: string) => boolean): string[] { const results: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders") continue; results.push(...walkFiles(full, filter)); } else if (filter(entry.name)) { results.push(full); } } return results; } /** * After a rename, update all references to the old path in project files. * Scans HTML, CSS, JS, and JSON files for the old filename/path and replaces. */ function updateReferences(projectDir: string, oldPath: string, newPath: string): number { const textFiles = walkFiles(projectDir, (name) => /\.(html|css|js|jsx|ts|tsx|json|mjs|cjs|md|mdx)$/i.test(name), ); let updatedCount = 0; for (const file of textFiles) { const content = readFileSync(file, "utf-8"); // Only replace full relative paths — never bare filenames, which can // corrupt unrelated content (e.g. "logo.png" inside "my-logo.png"). if (!content.includes(oldPath)) continue; const updated = content.split(oldPath).join(newPath); if (updated !== content) { writeFileSync(file, updated, "utf-8"); updatedCount++; } } return updatedCount; } // ── GSAP script extraction ────────────────────────────────────────────────── /** * Parse an HTML string with linkedom, locate the inline `