88 lines
2.7 KiB
TypeScript
88 lines
2.7 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
|
|
export const CANVAS_CAPABILITY_PATH_PREFIX = "/__openclaw__/cap";
|
|
export const CANVAS_CAPABILITY_QUERY_PARAM = "oc_cap";
|
|
export const CANVAS_CAPABILITY_TTL_MS = 10 * 60_000;
|
|
|
|
export type NormalizedCanvasScopedUrl = {
|
|
pathname: string;
|
|
capability?: string;
|
|
rewrittenUrl?: string;
|
|
scopedPath: boolean;
|
|
malformedScopedPath: boolean;
|
|
};
|
|
|
|
function normalizeCapability(raw: string | null | undefined): string | undefined {
|
|
const trimmed = raw?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
export function mintCanvasCapabilityToken(): string {
|
|
return randomBytes(18).toString("base64url");
|
|
}
|
|
|
|
export function buildCanvasScopedHostUrl(baseUrl: string, capability: string): string | undefined {
|
|
const normalizedCapability = normalizeCapability(capability);
|
|
if (!normalizedCapability) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const url = new URL(baseUrl);
|
|
const trimmedPath = url.pathname.replace(/\/+$/, "");
|
|
const prefix = `${CANVAS_CAPABILITY_PATH_PREFIX}/${encodeURIComponent(normalizedCapability)}`;
|
|
url.pathname = `${trimmedPath}${prefix}`;
|
|
url.search = "";
|
|
url.hash = "";
|
|
return url.toString().replace(/\/$/, "");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function normalizeCanvasScopedUrl(rawUrl: string): NormalizedCanvasScopedUrl {
|
|
const url = new URL(rawUrl, "http://localhost");
|
|
const prefix = `${CANVAS_CAPABILITY_PATH_PREFIX}/`;
|
|
let scopedPath = false;
|
|
let malformedScopedPath = false;
|
|
let capabilityFromPath: string | undefined;
|
|
let rewrittenUrl: string | undefined;
|
|
|
|
if (url.pathname.startsWith(prefix)) {
|
|
scopedPath = true;
|
|
const remainder = url.pathname.slice(prefix.length);
|
|
const slashIndex = remainder.indexOf("/");
|
|
if (slashIndex <= 0) {
|
|
malformedScopedPath = true;
|
|
} else {
|
|
const encodedCapability = remainder.slice(0, slashIndex);
|
|
const canonicalPath = remainder.slice(slashIndex) || "/";
|
|
let decoded: string | undefined;
|
|
try {
|
|
decoded = decodeURIComponent(encodedCapability);
|
|
} catch {
|
|
malformedScopedPath = true;
|
|
}
|
|
capabilityFromPath = normalizeCapability(decoded);
|
|
if (!capabilityFromPath || !canonicalPath.startsWith("/")) {
|
|
malformedScopedPath = true;
|
|
} else {
|
|
url.pathname = canonicalPath;
|
|
if (!url.searchParams.has(CANVAS_CAPABILITY_QUERY_PARAM)) {
|
|
url.searchParams.set(CANVAS_CAPABILITY_QUERY_PARAM, capabilityFromPath);
|
|
}
|
|
rewrittenUrl = `${url.pathname}${url.search}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const capability =
|
|
capabilityFromPath ?? normalizeCapability(url.searchParams.get(CANVAS_CAPABILITY_QUERY_PARAM));
|
|
return {
|
|
pathname: url.pathname,
|
|
capability,
|
|
rewrittenUrl,
|
|
scopedPath,
|
|
malformedScopedPath,
|
|
};
|
|
}
|