refactor(canvas-host): share static file resolver

This commit is contained in:
Peter Steinberger
2026-02-14 14:16:49 +00:00
parent 2004ce919a
commit 1a4fb35030
3 changed files with 54 additions and 92 deletions
+2 -46
View File
@@ -2,8 +2,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { SafeOpenError, openFileWithinRoot, type SafeOpenResult } from "../infra/fs-safe.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
import { resolveFileWithinRoot } from "./file-resolver.js";
export const A2UI_PATH = "/__openclaw__/a2ui"; export const A2UI_PATH = "/__openclaw__/a2ui";
@@ -57,50 +57,6 @@ async function resolveA2uiRootReal(): Promise<string | null> {
return resolvingA2uiRoot; return resolvingA2uiRoot;
} }
function normalizeUrlPath(rawPath: string): string {
const decoded = decodeURIComponent(rawPath || "/");
const normalized = path.posix.normalize(decoded);
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
async function resolveA2uiFile(rootReal: string, urlPath: string): Promise<SafeOpenResult | null> {
const normalized = normalizeUrlPath(urlPath);
const rel = normalized.replace(/^\/+/, "");
if (rel.split("/").some((p) => p === "..")) {
return null;
}
const tryOpen = async (relative: string) => {
try {
return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative });
} catch (err) {
if (err instanceof SafeOpenError) {
return null;
}
throw err;
}
};
if (normalized.endsWith("/")) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
const candidate = path.join(rootReal, rel);
try {
const st = await fs.lstat(candidate);
if (st.isSymbolicLink()) {
return null;
}
if (st.isDirectory()) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
} catch {
// ignore
}
return await tryOpen(rel);
}
export function injectCanvasLiveReload(html: string): string { export function injectCanvasLiveReload(html: string): string {
const snippet = ` const snippet = `
<script> <script>
@@ -192,7 +148,7 @@ export async function handleA2uiHttpRequest(
} }
const rel = url.pathname.slice(basePath.length); const rel = url.pathname.slice(basePath.length);
const result = await resolveA2uiFile(a2uiRootReal, rel || "/"); const result = await resolveFileWithinRoot(a2uiRootReal, rel || "/");
if (!result) { if (!result) {
res.statusCode = 404; res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
+50
View File
@@ -0,0 +1,50 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SafeOpenError, openFileWithinRoot, type SafeOpenResult } from "../infra/fs-safe.js";
export function normalizeUrlPath(rawPath: string): string {
const decoded = decodeURIComponent(rawPath || "/");
const normalized = path.posix.normalize(decoded);
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
export async function resolveFileWithinRoot(
rootReal: string,
urlPath: string,
): Promise<SafeOpenResult | null> {
const normalized = normalizeUrlPath(urlPath);
const rel = normalized.replace(/^\/+/, "");
if (rel.split("/").some((p) => p === "..")) {
return null;
}
const tryOpen = async (relative: string) => {
try {
return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative });
} catch (err) {
if (err instanceof SafeOpenError) {
return null;
}
throw err;
}
};
if (normalized.endsWith("/")) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
const candidate = path.join(rootReal, rel);
try {
const st = await fs.lstat(candidate);
if (st.isSymbolicLink()) {
return null;
}
if (st.isDirectory()) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
} catch {
// ignore
}
return await tryOpen(rel);
}
+2 -46
View File
@@ -9,7 +9,6 @@ import { type WebSocket, WebSocketServer } from "ws";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { isTruthyEnvValue } from "../infra/env.js"; import { isTruthyEnvValue } from "../infra/env.js";
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
import { ensureDir, resolveUserPath } from "../utils.js"; import { ensureDir, resolveUserPath } from "../utils.js";
import { import {
@@ -18,6 +17,7 @@ import {
handleA2uiHttpRequest, handleA2uiHttpRequest,
injectCanvasLiveReload, injectCanvasLiveReload,
} from "./a2ui.js"; } from "./a2ui.js";
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
export type CanvasHostOpts = { export type CanvasHostOpts = {
runtime: RuntimeEnv; runtime: RuntimeEnv;
@@ -149,50 +149,6 @@ function defaultIndexHTML() {
`; `;
} }
function normalizeUrlPath(rawPath: string): string {
const decoded = decodeURIComponent(rawPath || "/");
const normalized = path.posix.normalize(decoded);
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
async function resolveFilePath(rootReal: string, urlPath: string) {
const normalized = normalizeUrlPath(urlPath);
const rel = normalized.replace(/^\/+/, "");
if (rel.split("/").some((p) => p === "..")) {
return null;
}
const tryOpen = async (relative: string) => {
try {
return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative });
} catch (err) {
if (err instanceof SafeOpenError) {
return null;
}
throw err;
}
};
if (normalized.endsWith("/")) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
const candidate = path.join(rootReal, rel);
try {
const st = await fs.lstat(candidate);
if (st.isSymbolicLink()) {
return null;
}
if (st.isDirectory()) {
return await tryOpen(path.posix.join(rel, "index.html"));
}
} catch {
// ignore
}
return await tryOpen(rel);
}
function isDisabledByEnv() { function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CANVAS_HOST)) { if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CANVAS_HOST)) {
return true; return true;
@@ -372,7 +328,7 @@ export async function createCanvasHostHandler(
return true; return true;
} }
const opened = await resolveFilePath(rootReal, urlPath); const opened = await resolveFileWithinRoot(rootReal, urlPath);
if (!opened) { if (!opened) {
if (urlPath === "/" || urlPath.endsWith("/")) { if (urlPath === "/" || urlPath.endsWith("/")) {
res.statusCode = 404; res.statusCode = 404;