fix(studio): open projects named with a colon and keep preview assets for names with # (#4829)

* fix(studio): open projects whose folder name has a colon

* fix(studio): open projects named with a colon or '#' in Studio and its preview

* fix(studio): refuse a Windows stream id reached through a session alias

One adapter rule, isServableProjectId, now guards the requested id, a
session's projectId and the folder listing. The bundler-failure preview
places its <base> inside an attributed <head>, and the media metadata
cache keys on the encoded URL so ids with ':' can't collide.
This commit is contained in:
Miguel Ángel
2026-09-30 23:21:53 -07:00
committed by GitHub
parent ed20d67b16
commit a369708fa5
8 changed files with 120 additions and 13 deletions
@@ -116,6 +116,34 @@ describe("registerPreviewRoutes", () => {
expect(authored).not.toContain('<base href="/api/projects/demo/preview/">');
});
it("encodes the project name in <base>, so a '#' or '\"' in it keeps assets in its own folder", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "scene.html"), "<template><section></section></template>");
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const baseOf = async (path: string) =>
/<base href="([^"]*)">/.exec(
await (await app.request(`http://localhost${path}`)).text(),
)?.[1];
const take = "/api/projects/Take%20%232/preview/";
expect(await baseOf("/projects/Take%20%232/preview")).toBe(take);
expect(await baseOf("/projects/Take%20%232/preview/comp/scene.html")).toBe(take);
expect(new URL("logo.png", `http://localhost${take}`).pathname).toBe(`${take}logo.png`);
expect(await baseOf("/projects/a%22b/preview")).toBe("/api/projects/a%22b/preview/");
});
it("keeps the encoded <base> when the bundler fails and the page is read from disk", async () => {
const projectDir = createProjectDir();
const app = new Hono();
const bundle = async () => {
throw new Error("bundler unavailable");
};
registerPreviewRoutes(app, createAdapter(projectDir, { bundle }));
writeFileSync(join(projectDir, "index.html"), '<html><head data-theme="dark"></head></html>');
const html = await (await app.request("http://localhost/projects/Take%20%232/preview")).text();
expect(html).toContain('<base href="/api/projects/Take%20%232/preview/">');
});
it("serves the mark the runtime keys preview-only work on, ahead of the runtime script", async () => {
const projectDir = createProjectDir();
const app = new Hono();
+11 -6
View File
@@ -344,6 +344,14 @@ export const PREVIEW_BUNDLE_OPTIONS = {
sceneParts: true,
} as const satisfies BundleOptions;
const previewBaseHref = (projectId: string) =>
`/api/projects/${encodeURIComponent(projectId)}/preview/`;
const withPreviewBase = (html: string, projectId: string) =>
hasBaseElement(html)
? html
: injectTagsAtHeadStart(html, `<base href="${previewBaseHref(projectId)}">`);
export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void {
const previewCacheHeaders = (etag: string) => ({
"Cache-Control": "private, no-cache",
@@ -406,10 +414,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
}
// Inject <base> for relative asset resolution
const baseHref = `/api/projects/${project.id}/preview/`;
if (!hasBaseElement(bundled)) {
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}
bundled = withPreviewBase(bundled, project.id);
// Also covers elements the adapter injected; ids already present are kept.
bundled = injectStudioPreviewAugmentations(
@@ -438,7 +443,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
// not the pre-request snapshot that may have been saved over.
const fallback = resolveProjectMainHtml(project.dir, project.id);
if (fallback) {
const fallbackHtml = ensureHfIds(fallback.html);
const fallbackHtml = withPreviewBase(ensureHfIds(fallback.html), project.id);
let fallbackAugmented = injectStudioPreviewAugmentations(
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
adapter,
@@ -549,7 +554,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
const stamped = pinSubCompHfIds(compFile, compPath);
if (stamped === null) return c.text("not found", 404); // file removed between stat and read
const baseHref = `/api/projects/${project.id}/preview/`;
const baseHref = previewBaseHref(project.id);
let html = buildSubCompositionHtml(
project.dir,
compPath,
@@ -71,7 +71,9 @@ function HookHost({
element,
previewIframeRef,
onApplyScope,
projectId = "proj",
}: {
projectId?: string;
onState: (state: ReturnType<typeof useColorGradingController>) => void;
onSetAttributeLive: (attr: string, value: string | null) => void;
element: DomEditSelection;
@@ -79,7 +81,7 @@ function HookHost({
onApplyScope?: ApplyScope;
}) {
const state = useColorGradingController({
projectId: "proj",
projectId,
element,
previewIframeRef,
onSetAttributeLive,
@@ -94,6 +96,7 @@ function renderHook(
initialElement: DomEditSelection = makeElement(),
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
onApplyScope?: ApplyScope,
projectId?: string,
) {
const host = document.createElement("div");
document.body.append(host);
@@ -108,6 +111,7 @@ function renderHook(
element,
previewIframeRef,
onApplyScope,
projectId,
}),
);
});
@@ -763,6 +767,37 @@ describe("useColorGradingController", () => {
act(() => second.root.unmount());
vi.unstubAllGlobals();
});
it("keeps media metadata apart for 'p:a' + 'b.mp4' and 'p' + 'a:b.mp4'", async () => {
const video = (src: string) => {
const el = document.createElement("video");
el.setAttribute("src", src);
return el;
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ metadata: { kind: "video", color: { dynamicRange: "hdr" } } }),
} as unknown as Response);
vi.stubGlobal("fetch", fetchMock);
for (const [projectId, src] of [
["p:a", "b.mp4"],
["p", "a:b.mp4"],
]) {
const { root } = renderHook(
vi.fn(),
makeElement({ element: video(src) }),
undefined,
undefined,
projectId,
);
await act(async () => {
await Promise.resolve();
});
act(() => root.unmount());
}
expect(fetchMock).toHaveBeenCalledTimes(2);
vi.unstubAllGlobals();
});
});
describe("useColorGradingController after the preview iframe is replaced", () => {
@@ -284,13 +284,13 @@ export function useColorGradingController({
useEffect(() => {
setMediaMetadata(null);
if (!selectedAssetPath) return;
const cacheKey = `${projectId}:${selectedAssetPath}`;
const cacheKey = mediaMetadataUrl(projectId, selectedAssetPath);
if (MEDIA_METADATA_CACHE.has(cacheKey)) {
setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
return;
}
const controller = new AbortController();
fetch(mediaMetadataUrl(projectId, selectedAssetPath), { signal: controller.signal })
fetch(cacheKey, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) return { ok: false as const };
const data: MediaMetadataResponse | null = await response.json();
@@ -18,6 +18,15 @@ describe("project routing utilities", () => {
},
);
it("opens a folder whose name has a colon, encoded once both ways", () => {
const id = "Customer story: Northwind";
expect(encodeProjectId(id)).toBe("Customer%20story%3A%20Northwind");
expect(buildProjectApiPath(id, "/preview")).toBe(
"/api/projects/Customer%20story%3A%20Northwind/preview",
);
expect(parseProjectIdFromHash(buildProjectHash(id))).toBe(id);
});
it("decodes project ids from hash routes before building capture URLs", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
+3 -1
View File
@@ -5,13 +5,15 @@ export interface ProjectHashRoute {
params: URLSearchParams;
}
const WINDOWS_DRIVE_PREFIX = /^[a-z]:/i;
/** Project names are single path segments, including when received from a hash or server. */
export function isValidProjectId(value: string): boolean {
return (
value.length > 0 &&
value !== "." &&
value !== ".." &&
!value.includes(":") &&
!WINDOWS_DRIVE_PREFIX.test(value) &&
!value.includes("/") &&
!value.includes("\\") &&
!Array.from(value).some((char) => char.charCodeAt(0) < 32)
@@ -171,6 +171,31 @@ describe("Vite project resolution boundary", () => {
},
);
it("opens a folder named with a colon, which Windows would read as a file stream and refuses", () => {
const { data, sessions, adapter } = fixture();
const id = "Customer story: Northwind";
mkdirSync(join(data, "demo"));
if (process.platform !== "win32") {
mkdirSync(join(data, id));
expect(adapter.resolveProject(id)?.id).toBe(id);
}
writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: id }));
writeFileSync(
join(sessions, "stream.json"),
JSON.stringify({ projectId: "demo::$INDEX_ALLOCATION" }),
);
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { value: "win32" });
try {
expect(adapter.resolveProject(id)).toBeNull();
expect(adapter.resolveProject("demo::$INDEX_ALLOCATION")).toBeNull();
expect(adapter.resolveProject("alias")).toBeNull();
expect(adapter.resolveProject("stream")).toBeNull();
} finally {
Object.defineProperty(process, "platform", platform);
}
});
it("rejects traversal in a session's project mapping", () => {
const { sessions, adapter } = fixture();
writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: "../sessions" }));
+6 -3
View File
@@ -102,6 +102,9 @@ export function createProjectSignatureCache({
};
}
const isServableProjectId = (id: string) =>
isValidProjectId(id) && !(process.platform === "win32" && id.includes(":"));
export function createViteAdapter(
dataDir: string,
server: ViteDevServer,
@@ -208,7 +211,7 @@ export function createViteAdapter(
return readdirSync(dataDir, { withFileTypes: true })
.filter(
(d) =>
isValidProjectId(d.name) &&
isServableProjectId(d.name) &&
(d.isDirectory() || d.isSymbolicLink()) &&
(existsSync(join(dataDir, d.name, "index.html")) ||
existsSync(join(dataDir, d.name, `${d.name}.html`))),
@@ -233,7 +236,7 @@ export function createViteAdapter(
// fallow-ignore-next-line complexity
resolveProject(id: string) {
if (!isValidProjectId(id)) return null;
if (!isServableProjectId(id)) return null;
let projectDir = resolve(dataDir, id);
if (!isPathWithin(dataDir, projectDir)) return null;
if (!existsSync(projectDir)) {
@@ -243,7 +246,7 @@ export function createViteAdapter(
if (existsSync(sessionFile)) {
try {
const session = JSON.parse(readFileSync(sessionFile, "utf-8"));
if (typeof session.projectId === "string" && isValidProjectId(session.projectId)) {
if (typeof session.projectId === "string" && isServableProjectId(session.projectId)) {
projectDir = resolve(dataDir, session.projectId);
if (!isPathWithin(dataDir, projectDir)) return null;
if (existsSync(projectDir)) {