diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts
index 753cb0b8d6..8b5f779dd9 100644
--- a/packages/studio-server/src/routes/preview.test.ts
+++ b/packages/studio-server/src/routes/preview.test.ts
@@ -116,6 +116,34 @@ describe("registerPreviewRoutes", () => {
expect(authored).not.toContain('');
});
+ it("encodes the project name in , so a '#' or '\"' in it keeps assets in its own folder", async () => {
+ const projectDir = createProjectDir();
+ writeFileSync(join(projectDir, "scene.html"), "");
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+ const baseOf = async (path: string) =>
+ //.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 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"), '
');
+ const html = await (await app.request("http://localhost/projects/Take%20%232/preview")).text();
+ expect(html).toContain('');
+ });
+
it("serves the mark the runtime keys preview-only work on, ahead of the runtime script", async () => {
const projectDir = createProjectDir();
const app = new Hono();
diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts
index 7fd3d460cd..1d20ced7fb 100644
--- a/packages/studio-server/src/routes/preview.ts
+++ b/packages/studio-server/src/routes/preview.ts
@@ -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, ``);
+
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 for relative asset resolution
- const baseHref = `/api/projects/${project.id}/preview/`;
- if (!hasBaseElement(bundled)) {
- bundled = bundled.replace(//i, ``);
- }
+ 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,
diff --git a/packages/studio/src/components/editor/useColorGradingController.test.ts b/packages/studio/src/components/editor/useColorGradingController.test.ts
index d86796dbde..61baabe6fd 100644
--- a/packages/studio/src/components/editor/useColorGradingController.test.ts
+++ b/packages/studio/src/components/editor/useColorGradingController.test.ts
@@ -71,7 +71,9 @@ function HookHost({
element,
previewIframeRef,
onApplyScope,
+ projectId = "proj",
}: {
+ projectId?: string;
onState: (state: ReturnType) => 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,
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", () => {
diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts
index 3d69c99250..b1a30f2975 100644
--- a/packages/studio/src/components/editor/useColorGradingController.ts
+++ b/packages/studio/src/components/editor/useColorGradingController.ts
@@ -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();
diff --git a/packages/studio/src/utils/projectRouting.test.ts b/packages/studio/src/utils/projectRouting.test.ts
index 9f022719e3..4f62929c90 100644
--- a/packages/studio/src/utils/projectRouting.test.ts
+++ b/packages/studio/src/utils/projectRouting.test.ts
@@ -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"));
diff --git a/packages/studio/src/utils/projectRouting.ts b/packages/studio/src/utils/projectRouting.ts
index 6291d665c8..1b991beecc 100644
--- a/packages/studio/src/utils/projectRouting.ts
+++ b/packages/studio/src/utils/projectRouting.ts
@@ -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)
diff --git a/packages/studio/vite.adapter.projects.test.ts b/packages/studio/vite.adapter.projects.test.ts
index 2f844da539..ecf3574344 100644
--- a/packages/studio/vite.adapter.projects.test.ts
+++ b/packages/studio/vite.adapter.projects.test.ts
@@ -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" }));
diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts
index b99a69970e..939f33aec7 100644
--- a/packages/studio/vite.adapter.ts
+++ b/packages/studio/vite.adapter.ts
@@ -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)) {