Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/studio-server/src/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 11 additions & 6 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,17 @@ function HookHost({
element,
previewIframeRef,
onApplyScope,
projectId = "proj",
}: {
projectId?: string;
onState: (state: ReturnType<typeof useColorGradingController>) => void;
onSetAttributeLive: (attr: string, value: string | null) => void;
element: DomEditSelection;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
onApplyScope?: ApplyScope;
}) {
const state = useColorGradingController({
projectId: "proj",
projectId,
element,
previewIframeRef,
onSetAttributeLive,
Expand All @@ -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);
Expand All @@ -108,6 +111,7 @@ function renderHook(
element,
previewIframeRef,
onApplyScope,
projectId,
}),
);
});
Expand Down Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions packages/studio/src/utils/projectRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/utils/projectRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions packages/studio/vite.adapter.projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down
9 changes: 6 additions & 3 deletions packages/studio/vite.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`))),
Expand All @@ -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)) {
Expand All @@ -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)) {
Expand Down
Loading