mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user