From 14acadb5d936d26365ed0ebc7fc6ede707225b30 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 01:36:36 -0700 Subject: [PATCH 1/3] fix(studio): contain project IDs across client and server routes --- packages/studio/src/App.tsx | 8 ++- .../src/captions/hooks/useCaptionSync.ts | 5 +- .../studio/src/components/MediaPreview.tsx | 3 +- .../src/components/editor/domEditingLayers.ts | 26 +------- .../components/editor/probeSourceElement.ts | 29 +++++++++ .../components/editor/propertyPanelFill.tsx | 3 +- .../components/feedback/projectProvenance.ts | 5 +- .../studio/src/components/nle/NLEContext.tsx | 3 +- .../src/components/nle/useCompositionStack.ts | 15 +++-- .../components/renders/RenderQueueItem.tsx | 3 +- .../src/components/renders/useRenderQueue.ts | 5 +- .../src/components/sidebar/AssetsTab.tsx | 3 +- .../components/sidebar/CompositionsTab.tsx | 3 +- .../src/components/storyboard/FramePoster.tsx | 3 +- packages/studio/src/hooks/useAskAgentModal.ts | 3 +- .../studio/src/hooks/useCaptionDetection.ts | 3 +- .../src/hooks/useCompositionContentLoader.ts | 4 +- .../studio/src/hooks/useDomEditCommits.ts | 9 +-- .../src/hooks/useElementLifecycleOps.ts | 6 +- packages/studio/src/hooks/useFileTree.ts | 9 +-- packages/studio/src/hooks/useGroupCommits.ts | 3 +- packages/studio/src/hooks/useLintModal.ts | 3 +- .../studio/src/hooks/useRenderClipContent.ts | 11 ++-- .../studio/src/hooks/useTimelineDeleteOps.ts | 6 +- .../studio/src/player/components/Player.tsx | 10 ++-- .../src/player/components/thumbnailUtils.ts | 8 ++- packages/studio/src/utils/blockInstaller.ts | 3 +- .../studio/src/utils/projectRouting.test.ts | 9 +++ packages/studio/src/utils/projectRouting.ts | 18 +++++- .../src/utils/razorSplitTransaction.test.ts | 6 +- .../studio/src/utils/studioFileHistory.ts | 3 +- packages/studio/src/utils/studioHelpers.ts | 5 +- .../utils/timelineCompositionInsert.test.ts | 6 +- packages/studio/vite.adapter.projects.test.ts | 60 +++++++++++++++++++ packages/studio/vite.adapter.ts | 19 ++++-- 35 files changed, 231 insertions(+), 87 deletions(-) create mode 100644 packages/studio/src/components/editor/probeSourceElement.ts create mode 100644 packages/studio/vite.adapter.projects.test.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5a333908013..f2a9313e6de 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "./utils/projectRouting"; import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react"; import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; @@ -326,9 +327,10 @@ export function StudioApp() { const renderClipContent = useRenderClipContent({ projectIdRef: fileManager.projectIdRef, compIdToSrc, - activePreviewUrl: activeCompPath - ? `/api/projects/${projectId}/preview/comp/${activeCompPath}` - : null, + activePreviewUrl: + activeCompPath && projectId + ? buildProjectApiPath(projectId, `/preview/comp/${activeCompPath}`) + : null, effectiveTimelineDuration, }); const compositionDimensions = useCompositionDimensions(); diff --git a/packages/studio/src/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index 072b4829f39..0523ab4e8f3 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { useCallback, useRef } from "react"; import { useCaptionStore } from "../store"; import { useMountEffect } from "../../hooks/useMountEffect"; @@ -92,7 +93,7 @@ export function useCaptionSync(projectId: string | null) { const seqAtSave = editSeqRef.current; const overrides = buildOverrides(state.model); - fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, { + fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), { method: "PUT", headers: { "Content-Type": "text/plain", ...studioWriteHeaders() }, body: JSON.stringify(overrides, null, 2), @@ -171,7 +172,7 @@ export function useCaptionSync(projectId: string | null) { let data: { content?: string }; try { const res = await fetch( - `/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, + buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), ); if (!res.ok) return; // no overrides file yet — normal data = await res.json(); diff --git a/packages/studio/src/components/MediaPreview.tsx b/packages/studio/src/components/MediaPreview.tsx index 9547a7f6174..9803b8731a9 100644 --- a/packages/studio/src/components/MediaPreview.tsx +++ b/packages/studio/src/components/MediaPreview.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useState } from "react"; import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes"; @@ -28,7 +29,7 @@ function MediaErrorPanel({ name, filePath }: { name: string; filePath: string }) } export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) { - const serveUrl = `/api/projects/${projectId}/preview/${filePath}`; + const serveUrl = buildProjectApiPath(projectId, `/preview/${filePath}`); const name = filePath.split("/").pop() ?? filePath; // Keyed by path so switching to another file clears a previous failure. const [failedPath, setFailedPath] = useState(null); diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 3b24e2ec985..77805259b41 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -1,3 +1,4 @@ +import { probeSourceElement } from "./probeSourceElement"; import type { PatchOperation } from "../../utils/sourcePatcher"; import { resolveEditingAffordances, @@ -281,31 +282,6 @@ export function resolveDomEditCapabilities(args: { ).capabilities; } -async function probeSourceElement( - projectId: string, - sourceFile: string, - target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number }, -): Promise { - try { - const response = await fetch( - `/api/projects/${projectId}/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target }), - }, - ); - if (!response.ok) return true; - const data = await response.json(); - if (data && typeof data === "object" && "exists" in data && data.exists === false) { - return false; - } - return true; - } catch { - return true; - } -} - // fallow-ignore-next-line complexity export async function resolveDomEditSelection( startEl: HTMLElement | null, diff --git a/packages/studio/src/components/editor/probeSourceElement.ts b/packages/studio/src/components/editor/probeSourceElement.ts new file mode 100644 index 00000000000..32d56496e3f --- /dev/null +++ b/packages/studio/src/components/editor/probeSourceElement.ts @@ -0,0 +1,29 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; + +export async function probeSourceElement( + projectId: string, + sourceFile: string, + target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number }, +): Promise { + try { + const response = await fetch( + buildProjectApiPath( + projectId, + `/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`, + ), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target }), + }, + ); + if (!response.ok) return true; + const data = await response.json(); + if (data && typeof data === "object" && "exists" in data && data.exists === false) { + return false; + } + return true; + } catch { + return true; + } +} diff --git a/packages/studio/src/components/editor/propertyPanelFill.tsx b/packages/studio/src/components/editor/propertyPanelFill.tsx index 6f6c303602f..3e4d4a53230 100644 --- a/packages/studio/src/components/editor/propertyPanelFill.tsx +++ b/packages/studio/src/components/editor/propertyPanelFill.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { useMemo, useRef, useState } from "react"; import { Plus, RotateCcw, X } from "../../icons/SystemIcons"; import { @@ -158,7 +159,7 @@ export function ImageFillField({ {selectedAsset && (
{selectedAsset.split("/").pop() diff --git a/packages/studio/src/components/feedback/projectProvenance.ts b/packages/studio/src/components/feedback/projectProvenance.ts index 0bc8b4fd369..f8bfa6325ab 100644 --- a/packages/studio/src/components/feedback/projectProvenance.ts +++ b/packages/studio/src/components/feedback/projectProvenance.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; // --------------------------------------------------------------------------- // How this project came to exist, and roughly what shape it is. // @@ -53,7 +54,9 @@ export async function captureProjectProvenance( if (!scaffolded) return; try { - const res = await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(CONFIG_FILE)}`); + const res = await fetch( + buildProjectApiPath(projectId, `/files/${encodeURIComponent(CONFIG_FILE)}`), + ); if (!res.ok) return; // The route answers with an envelope, not the file: {filename, content, // version}. The config is the `content` string inside it. diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index d71d7ef5265..6285e87c60e 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react"; import { useTimelinePlayer, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; @@ -185,7 +186,7 @@ export function NLEProvider({ setCompositionSourceMap(emptyMap); onCompIdToSrcChangeRef.current?.(emptyMap); - fetch(`/api/projects/${projectId}/files/index.html`, { + fetch(buildProjectApiPath(projectId, `/files/index.html`), { signal: controller.signal, }) .then((r) => { diff --git a/packages/studio/src/components/nle/useCompositionStack.ts b/packages/studio/src/components/nle/useCompositionStack.ts index feb947c42d0..47e23350f32 100644 --- a/packages/studio/src/components/nle/useCompositionStack.ts +++ b/packages/studio/src/components/nle/useCompositionStack.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; // Composition drill-down stack management for NLEContext/EditorShell import { useState, useCallback, useRef, useEffect } from "react"; import { usePlayerStore } from "../../player"; @@ -29,7 +30,7 @@ export function useCompositionStack({ { id: "master", label: "Master", - previewUrl: `/api/projects/${projectId}/preview`, + previewUrl: buildProjectApiPath(projectId, `/preview`), }, ]); @@ -89,7 +90,10 @@ export function useCompositionStack({ .split("/") .pop() ?.replace(/\.html$/, "") || resolvedPath; - const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(resolvedPath)}`; + const previewUrl = buildProjectApiPath( + projectId, + `/preview/comp/${encodePreviewPath(resolvedPath)}`, + ); return [...prev, { id: resolvedPath, label, previewUrl }]; }); }, @@ -103,7 +107,7 @@ export function useCompositionStack({ const master: CompositionLevel = { id: "master", label: "Master", - previewUrl: `/api/projects/${projectId}/preview`, + previewUrl: buildProjectApiPath(projectId, `/preview`), }; if (activeCompositionPath === "index.html") { usePlayerStore.getState().setElements([]); @@ -116,7 +120,10 @@ export function useCompositionStack({ // panel highlighted the row, so the canvas and timeline stayed on // index.html and edits landed in the root file. const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, ""); - const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`; + const previewUrl = buildProjectApiPath( + projectId, + `/preview/comp/${encodePreviewPath(activeCompositionPath)}`, + ); usePlayerStore.getState().setElements([]); updateCompositionStack((prev) => { if (prev[prev.length - 1]?.id === activeCompositionPath) return prev; diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index 4b0209e5591..f11a040f59d 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { memo, useCallback, useState } from "react"; import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; import { Button } from "../ui/Button"; @@ -36,7 +37,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({ const [confirmingDelete, setConfirmingDelete] = useState(false); // Direct file URL — serves from disk, survives server restarts - const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`; + const fileSrc = buildProjectApiPath(projectId, `/renders/file/${job.filename}`); const handleOpen = useCallback(() => { window.open(fileSrc, "_blank"); diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 15d1348803e..704ea828d56 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { CanvasResolution } from "@hyperframes/parsers"; import { trackStudioRenderStart } from "../../telemetry/events"; @@ -125,7 +126,7 @@ export function useRenderQueue( const loadRenders = useCallback(async () => { if (!projectId) return; try { - const res = await fetch(`/api/projects/${projectId}/renders`); + const res = await fetch(buildProjectApiPath(projectId, `/renders`)); if (!res.ok) { setLoadError(`Couldn't load render history (server error ${res.status}).`); return; @@ -261,7 +262,7 @@ export function useRenderQueue( } let res: Response; try { - res = await fetch(`/api/projects/${projectId}/render`, { + res = await fetch(buildProjectApiPath(projectId, `/render`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index aa8270dbf00..0ca716a5070 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; // fallow-ignore-file code-duplication import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react"; import { SearchInput } from "../ui/SearchInput"; @@ -216,7 +217,7 @@ export const AssetsTab = memo(function AssetsTab({ useEffect(() => { if (manifest404Ref.current.has(projectId)) return; let cancelled = false; - fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`) + fetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`)) .then((r) => { if (!r.ok) { manifest404Ref.current.add(projectId); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index 47a9e61a176..173c9e4d4d6 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail"; import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers"; @@ -173,7 +174,7 @@ function CompCard({ setLivePreviewLoaded(false); }; const name = comp.replace(/^compositions\//, "").replace(/\.html$/, ""); - const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`; + const previewUrl = buildProjectApiPath(projectId, `/preview/comp/${comp}`); const thumbnailUrl = buildCompositionThumbnailUrl({ previewUrl, seekTime: THUMBNAIL_SEEK_TIME_SECONDS, diff --git a/packages/studio/src/components/storyboard/FramePoster.tsx b/packages/studio/src/components/storyboard/FramePoster.tsx index d0baaa595ee..5f27139cc4b 100644 --- a/packages/studio/src/components/storyboard/FramePoster.tsx +++ b/packages/studio/src/components/storyboard/FramePoster.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { useEffect, useState } from "react"; import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail"; @@ -51,7 +52,7 @@ export function FramePoster({ ); } let url = buildCompositionThumbnailUrl({ - previewUrl: `/api/projects/${projectId}/preview/comp/${src}`, + previewUrl: buildProjectApiPath(projectId, `/preview/comp/${src}`), seekTime: seconds, duration: 0, origin: window.location.origin, diff --git a/packages/studio/src/hooks/useAskAgentModal.ts b/packages/studio/src/hooks/useAskAgentModal.ts index f88f3f26133..3b3691e518d 100644 --- a/packages/studio/src/hooks/useAskAgentModal.ts +++ b/packages/studio/src/hooks/useAskAgentModal.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useState, useCallback, useRef, useEffect } from "react"; import { copyTextToClipboard } from "../utils/clipboard"; import { readTagSnippetByTarget } from "../utils/sourcePatcher"; @@ -53,7 +54,7 @@ export function useAskAgentModal({ const targetPath = selection.sourceFile || activeCompPath || "index.html"; try { const response = await fetch( - `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, + buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`), ); if (!response.ok) return; diff --git a/packages/studio/src/hooks/useCaptionDetection.ts b/packages/studio/src/hooks/useCaptionDetection.ts index 243af890159..e7fce63c47f 100644 --- a/packages/studio/src/hooks/useCaptionDetection.ts +++ b/packages/studio/src/hooks/useCaptionDetection.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useEffect, useRef } from "react"; import { useCaptionStore } from "../captions/store"; import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol"; @@ -107,7 +108,7 @@ export function useCaptionDetection({ activating = true; const srcPath = captionSrcPath; - fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`) + fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(srcPath)}`)) .then((r) => r.json()) .then((data: { content?: string }) => { if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return; diff --git a/packages/studio/src/hooks/useCompositionContentLoader.ts b/packages/studio/src/hooks/useCompositionContentLoader.ts index 7d2549b507d..09994a3edc2 100644 --- a/packages/studio/src/hooks/useCompositionContentLoader.ts +++ b/packages/studio/src/hooks/useCompositionContentLoader.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useCallback } from "react"; /** @@ -20,9 +21,10 @@ export function useCompositionContentLoader({ }) { return useCallback( (comp: string) => { + if (!projectId) return; setActiveCompPath(comp.endsWith(".html") ? comp : null); setEditingFile({ path: comp, content: null }); - fetch(`/api/projects/${projectId}/files/${comp}`) + fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(comp)}`)) .then(async (r) => { if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`); return r.json(); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index b425ddb0d2b..7b77d62d4a7 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useCallback, useRef } from "react"; import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation"; import { FONT_EXT } from "../utils/mediaTypes"; @@ -127,11 +128,11 @@ export function useDomEditCommits({ FONT_EXT.test(path) && fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(), ); - if (!asset) return null; + if (!asset || !projectId) return null; return { family: fontFamilyFromAssetPath(asset), path: asset, - url: `/api/projects/${projectId}/preview/${asset}`, + url: buildProjectApiPath(projectId, `/preview/${asset}`), }; }, [fileTree, projectId, importedFontAssetsRef], @@ -161,7 +162,7 @@ export function useDomEditCommits({ }; const readResponse = await fetch( - `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, + buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`), ); if (!readResponse.ok) { throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`); @@ -219,7 +220,7 @@ export function useDomEditCommits({ domEditSaveTimestampRef.current = Date.now(); const patchResponse = await fetch( - `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, + buildProjectApiPath(pid, `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`), { method: "POST", headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index df5de796de0..464a05ed625 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useCallback } from "react"; import { usePlayerStore } from "../player"; import { @@ -152,7 +153,10 @@ export function useElementLifecycleOps({ // selection runs to hundreds of members — the file ended up correct, but // only after long enough that Delete looked like it had done nothing. const removeResponse = await fetch( - `/api/projects/${pid}/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`, + buildProjectApiPath( + pid, + `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`, + ), { method: "POST", headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, diff --git a/packages/studio/src/hooks/useFileTree.ts b/packages/studio/src/hooks/useFileTree.ts index adc078de696..f78126c6ab6 100644 --- a/packages/studio/src/hooks/useFileTree.ts +++ b/packages/studio/src/hooks/useFileTree.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useState, useCallback, useEffect, useMemo } from "react"; import { FONT_EXT } from "../utils/mediaTypes"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; @@ -22,7 +23,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) { } let cancelled = false; setFileTreeLoaded(false); - fetch(`/api/projects/${projectId}`) + fetch(buildProjectApiPath(projectId)) .then((r) => r.json()) .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => { if (cancelled) return; @@ -47,7 +48,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) { const refreshFileTree = useCallback(async () => { const pid = projectIdRef.current; if (!pid) return; - const res = await fetch(`/api/projects/${pid}`); + const res = await fetch(buildProjectApiPath(pid)); const data = await res.json(); if (data.files) setFileTree(data.files); }, [projectIdRef]); @@ -62,12 +63,12 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) { const fontAssets = useMemo( () => - assets + (projectId ? assets : []) .filter((asset) => FONT_EXT.test(asset)) .map((asset) => ({ family: fontFamilyFromAssetPath(asset), path: asset, - url: `/api/projects/${projectId}/preview/${asset}`, + url: projectId ? buildProjectApiPath(projectId, `/preview/${asset}`) : "", })), [assets, projectId], ); diff --git a/packages/studio/src/hooks/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts index 1ac24bd14e8..532adff4639 100644 --- a/packages/studio/src/hooks/useGroupCommits.ts +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useCallback } from "react"; import { readProjectFileContent, @@ -73,7 +74,7 @@ async function commitStructuralMutation( deps.domEditSaveTimestampRef.current = Date.now(); const mutateResponse = await fetch( - `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`, + buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`), { method: "POST", headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, diff --git a/packages/studio/src/hooks/useLintModal.ts b/packages/studio/src/hooks/useLintModal.ts index 399fb759571..a2e234f215c 100644 --- a/packages/studio/src/hooks/useLintModal.ts +++ b/packages/studio/src/hooks/useLintModal.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import type { LintFinding } from "../components/LintModal"; import { usePlayerStore } from "../player"; @@ -35,7 +36,7 @@ export function useLintModal(projectId: string | null, refreshKey?: number) { if (!projectId) return; if (!opts?.background) setLinting(true); try { - const res = await fetch(`/api/projects/${projectId}/lint`); + const res = await fetch(buildProjectApiPath(projectId, `/lint`)); const data = await res.json(); const parsed = ((data.findings ?? []) as RawFinding[]).map(parseFinding); if (opts?.background) { diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index 7d4f7b15795..3d3a821c138 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; import { useCallback, type ReactNode } from "react"; import { createElement } from "react"; import { CompositionThumbnail, VideoThumbnail } from "../player"; @@ -16,7 +17,7 @@ export function normalizeCompositionSrc( ): string { try { const parsed = new URL(compSrc, origin); - const previewPrefix = `/api/projects/${projectId}/preview/`; + const previewPrefix = buildProjectApiPath(projectId, `/preview/`); if (parsed.pathname.startsWith(previewPrefix)) { return parsed.pathname.slice(previewPrefix.length); } @@ -35,7 +36,7 @@ function resolvePreviewRelative( if (!src) return null; try { const parsed = new URL(src, origin); - const base = new URL(`/api/projects/${pid}/preview/`, origin).pathname; + const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname; return parsed.pathname.startsWith(base) ? decodeURIComponent(parsed.pathname.slice(base.length)) : null; @@ -77,7 +78,7 @@ function renderAudioClip( // returns the DECODED path, so it must be re-encoded here. const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null; const waveformUrl = encodedRelative - ? `/api/projects/${pid}/waveform/${encodedRelative}` + ? buildProjectApiPath(pid, `/waveform/${encodedRelative}`) : undefined; const { start, end } = trimFractions(el); return createElement(AudioWaveform, { @@ -146,7 +147,7 @@ export function useRenderClipContent({ // instead of capturing the master at a time when the comp is fading in. if (compSrc) { return createElement(CompositionThumbnail, { - previewUrl: `/api/projects/${pid}/preview/comp/${encodePreviewPath(compSrc)}`, + previewUrl: buildProjectApiPath(pid, `/preview/comp/${encodePreviewPath(compSrc)}`), label: "", labelColor: style.label, @@ -225,7 +226,7 @@ export function useRenderClipContent({ if (htmlPreviewEligible) { return createElement(CompositionThumbnail, { - previewUrl: `/api/projects/${pid}/preview`, + previewUrl: buildProjectApiPath(pid, `/preview`), label: "", labelColor: style.label, diff --git a/packages/studio/src/hooks/useTimelineDeleteOps.ts b/packages/studio/src/hooks/useTimelineDeleteOps.ts index cd47b9adcf9..505067f0352 100644 --- a/packages/studio/src/hooks/useTimelineDeleteOps.ts +++ b/packages/studio/src/hooks/useTimelineDeleteOps.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../utils/projectRouting"; // Timeline clip deletion: the marquee/multi path and the single-clip wrapper // the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep // it under the studio 600-line cap, following useTimelineAssetDropOps. @@ -76,7 +77,10 @@ export function useTimelineDeleteOps({ } const removeResponse = await fetch( - `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, + buildProjectApiPath( + pid, + `/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, + ), { method: "POST", headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 94ce3964d4d..50ca3794eeb 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; import { forwardRef, useEffect, useRef, useState } from "react"; import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness"; import { useMountEffect } from "../../hooks/useMountEffect"; @@ -158,6 +159,10 @@ export const Player = forwardRef( const container = containerRef.current; if (!container) return; + const previewSource = + directUrl || (projectId ? buildProjectApiPath(projectId, "/preview") : null); + if (!previewSource) return; + let canceled = false; let cleanup: (() => void) | undefined; @@ -167,10 +172,7 @@ export const Player = forwardRef( // Create the web component imperatively to avoid JSX custom-element typing. const player = document.createElement("hyperframes-player") as HyperframesPlayerElement; - const srcUrl = new URL( - directUrl || `/api/projects/${projectId}/preview`, - window.location.origin, - ); + const srcUrl = new URL(previewSource, window.location.origin); applyPreviewVariablesToUrl(srcUrl); const src = srcUrl.pathname + srcUrl.search; const retryPreview = () => { diff --git a/packages/studio/src/player/components/thumbnailUtils.ts b/packages/studio/src/player/components/thumbnailUtils.ts index 0a55cb7d550..7a1732d8c10 100644 --- a/packages/studio/src/player/components/thumbnailUtils.ts +++ b/packages/studio/src/player/components/thumbnailUtils.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; /** Rendered height of a timeline-clip thumbnail strip, in CSS px. */ export const THUMBNAIL_CLIP_HEIGHT = 66; @@ -113,7 +114,7 @@ export function resolveMediaPreviewUrl( return src; } if (!studioOrigin || parsed.origin !== studioOrigin) return src; - const previewPath = new URL(`/api/projects/${projectId}/preview/`, studioOrigin).pathname; + const previewPath = new URL(buildProjectApiPath(projectId, `/preview/`), studioOrigin).pathname; if (parsed.pathname.startsWith(previewPath)) return src; if (parsed.pathname.startsWith("/api/")) return src; try { @@ -128,5 +129,8 @@ export function resolveMediaPreviewUrl( suffix = `${parsed.search}${parsed.hash}`; } - return `/api/projects/${projectId}/preview/${encodePreviewPath(relativePath.replace(/^\/+/, ""))}${suffix}`; + return buildProjectApiPath( + projectId, + `/preview/${encodePreviewPath(relativePath.replace(/^\/+/, ""))}${suffix}`, + ); } diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index 44063818456..7255b4c50c2 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "./projectRouting"; import type { RegistryItem } from "@hyperframes/core/registry"; import type { TimelineElement } from "../player"; import { @@ -64,7 +65,7 @@ async function installRegistryItem({ block: RegistryItem; compositionFile: string; } | null> { - const response = await fetch(`/api/projects/${projectId}/registry/install`, { + const response = await fetch(buildProjectApiPath(projectId, `/registry/install`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ blockName }), diff --git a/packages/studio/src/utils/projectRouting.test.ts b/packages/studio/src/utils/projectRouting.test.ts index 1f1e0badc30..e55b3c20769 100644 --- a/packages/studio/src/utils/projectRouting.test.ts +++ b/packages/studio/src/utils/projectRouting.test.ts @@ -9,6 +9,15 @@ import { } from "./projectRouting"; describe("project routing utilities", () => { + it.each([".", "..", "../sessions", "a/b", "a\\b", "a\u0000b", "a\nb"])( + "rejects unsafe decoded project IDs: %s", + (id) => { + expect(parseProjectIdFromHash(`#project/${encodeURIComponent(id)}`)).toBeNull(); + expect(() => buildProjectApiPath(id, "/files/index.html")).toThrow("Invalid project ID"); + expect(() => buildProjectHash(id)).toThrow("Invalid project 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 faed3790d17..fcf3541bc53 100644 --- a/packages/studio/src/utils/projectRouting.ts +++ b/packages/studio/src/utils/projectRouting.ts @@ -5,6 +5,18 @@ export interface ProjectHashRoute { params: URLSearchParams; } +/** 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("/") && + !value.includes("\\") && + !Array.from(value).some((char) => char.charCodeAt(0) < 32) + ); +} + function decodeHashProjectId(value: string): string { try { return decodeURIComponent(value); @@ -28,6 +40,7 @@ function normalizeHashParams( } export function encodeProjectId(projectId: string): string { + if (!isValidProjectId(projectId)) throw new Error("Invalid project ID"); return encodeURIComponent(projectId); } @@ -47,9 +60,12 @@ export function parseProjectHashRoute(hash: string): ProjectHashRoute | null { const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route; if (!encodedProjectId || encodedProjectId.includes("/")) return null; + const projectId = decodeHashProjectId(encodedProjectId); + if (!isValidProjectId(projectId)) return null; + const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : ""; return { - projectId: decodeHashProjectId(encodedProjectId), + projectId, params: new URLSearchParams(rawParams), }; } diff --git a/packages/studio/src/utils/razorSplitTransaction.test.ts b/packages/studio/src/utils/razorSplitTransaction.test.ts index 567bbffe39f..ea526da58c8 100644 --- a/packages/studio/src/utils/razorSplitTransaction.test.ts +++ b/packages/studio/src/utils/razorSplitTransaction.test.ts @@ -103,7 +103,7 @@ describe("runAtomicCutTransaction", () => { const synchronize = vi.fn(); const result = await runAtomicCutTransaction({ - projectId: "launch/demo", + projectId: "launch#demo", intents: buildAtomicCutIntents([element()], 2, "index.html"), label: "Split timeline clip", writeProjectFile, @@ -114,8 +114,8 @@ describe("runAtomicCutTransaction", () => { expect(requests.filter((request) => request.url.includes("split-batch"))).toHaveLength(1); expect(requests.map((request) => request.url)).toEqual([ - "/api/projects/launch%2Fdemo/files/index.html", - "/api/projects/launch%2Fdemo/file-mutations/split-batch", + "/api/projects/launch%23demo/files/index.html", + "/api/projects/launch%23demo/file-mutations/split-batch", ]); const splitRequest = requests.find((request) => request.url.includes("split-batch")); const writeToken = new Headers(splitRequest?.headers).get("X-Hyperframes-Write-Token"); diff --git a/packages/studio/src/utils/studioFileHistory.ts b/packages/studio/src/utils/studioFileHistory.ts index c055cb8709e..7125221b888 100644 --- a/packages/studio/src/utils/studioFileHistory.ts +++ b/packages/studio/src/utils/studioFileHistory.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "./projectRouting"; import type { MutableRefObject } from "react"; import type { EditHistoryKind } from "./editHistory"; import { serializeStudioFileMutations } from "./studioFileMutationCoordinator"; @@ -52,7 +53,7 @@ interface SaveProjectFilesWithHistoryInput { } export async function readProjectFileContent(pid: string, path: string): Promise { - const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`); + const response = await fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent(path)}`)); if (!response.ok) { throw await createStudioSaveHttpError(response, `Failed to read ${path}`); } diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 489bf8ea0b4..8531768a556 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -1,3 +1,4 @@ +import { buildProjectApiPath } from "./projectRouting"; import { isTypingTarget } from "./typingTarget"; import type { TimelineElement } from "../player/store/playerStore"; import type { DomEditSelection } from "../components/editor/domEditing"; @@ -306,7 +307,7 @@ export async function resolveDroppedAssetDuration( const media = document.createElement(kind === "video" ? "video" : "audio"); media.preload = "metadata"; - media.src = `/api/projects/${projectId}/preview/${assetPath}`; + media.src = buildProjectApiPath(projectId, `/preview/${assetPath}`); const duration = await new Promise((resolve) => { const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000); @@ -343,7 +344,7 @@ export async function resolveDroppedAssetDimensions( kind: TimelineAssetKind, ): Promise<{ width: number; height: number } | null> { if (kind === "audio") return null; - const src = `/api/projects/${projectId}/preview/${assetPath}`; + const src = buildProjectApiPath(projectId, `/preview/${assetPath}`); if (kind === "image") { return new Promise((resolve) => { diff --git a/packages/studio/src/utils/timelineCompositionInsert.test.ts b/packages/studio/src/utils/timelineCompositionInsert.test.ts index e50970f8b05..9a853fad468 100644 --- a/packages/studio/src/utils/timelineCompositionInsert.test.ts +++ b/packages/studio/src/utils/timelineCompositionInsert.test.ts @@ -33,7 +33,7 @@ describe("commitTimelineCompositionInsertion", () => { const refresh = vi.fn(); await commitTimelineCompositionInsertion({ - projectId: "launch/demo", + projectId: "launch#demo", targetPath: "index.html", sourcePath: "headline.html", start: 4, @@ -48,8 +48,8 @@ describe("commitTimelineCompositionInsertion", () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - "/api/projects/launch%2Fdemo/files/index.html", - "/api/projects/launch%2Fdemo/file-mutations/insert-composition/index.html", + "/api/projects/launch%23demo/files/index.html", + "/api/projects/launch%23demo/file-mutations/insert-composition/index.html", ]); expect(recordEdit).toHaveBeenCalledOnce(); expect(writeFile).not.toHaveBeenCalled(); diff --git a/packages/studio/vite.adapter.projects.test.ts b/packages/studio/vite.adapter.projects.test.ts new file mode 100644 index 00000000000..ea9cc0c2d0e --- /dev/null +++ b/packages/studio/vite.adapter.projects.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createStudioApi } from "@hyperframes/studio-server"; +import type { ViteDevServer } from "vite"; +import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "hf-project-routing-")); + roots.push(root); + const data = join(root, "data"); + const sessions = join(root, "sessions"); + mkdirSync(data); + mkdirSync(sessions); + // Project resolution does not call Vite's module loader. + const adapter = createViteAdapter( + data, + {} as ViteDevServer, + createProjectSignatureCache({ compute: () => "test" }), + ); + const app = createStudioApi(adapter); + return { root, data, sessions, adapter, app }; +} + +describe("Vite project resolution boundary", () => { + it.each(["..%2Fsessions", "a%2Fb", "a%5Cb", "%2E%2E%2Fsessions", "a%00b"])( + "rejects a router-decoded unsafe ID: %s", + async (id) => { + const { app } = fixture(); + expect((await app.request(`http://localhost/projects/${id}`)).status).toBe(404); + }, + ); + + it("preserves valid names, session aliases, and explicitly listed symlink projects", async () => { + const { data, sessions, root, adapter, app } = fixture(); + const id = "..Mañana #1? 50%"; + mkdirSync(join(data, id)); + expect((await app.request(`http://localhost/projects/${encodeURIComponent(id)}`)).status).toBe( + 200, + ); + writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: id, title: "Title" })); + expect(adapter.resolveProject("alias")?.id).toBe(id); + const linked = join(root, "linked"); + mkdirSync(linked); + symlinkSync(linked, join(data, "shortcut"), "junction"); + expect(adapter.resolveProject("shortcut")?.dir).toBe(realpathSync(linked)); + }); + + it("rejects traversal in a session's project mapping", () => { + const { sessions, adapter } = fixture(); + writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: "../sessions" })); + expect(adapter.resolveProject("alias")).toBeNull(); + }); +}); diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index d34346251e1..dc54bf70f5a 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -1,3 +1,4 @@ +import { isValidProjectId } from "./src/utils/projectRouting"; // Vite adapter that wires the shared Studio API to the local filesystem and build tools. import { @@ -10,7 +11,7 @@ import { copyFileSync, unlinkSync, } from "node:fs"; -import { join, relative, resolve, isAbsolute, dirname } from "node:path"; +import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path"; import type { ViteDevServer } from "vite"; import { type ResolvedProject, @@ -30,7 +31,9 @@ function isPathWithin(parentDir: string, childPath: string): boolean { const childRelativePath = relative(resolve(parentDir), resolve(childPath)); return ( childRelativePath === "" || - (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)) + (childRelativePath !== ".." && + !childRelativePath.startsWith(`..${sep}`) && + !isAbsolute(childRelativePath)) ); } @@ -201,15 +204,19 @@ export function createViteAdapter( // fallow-ignore-next-line complexity resolveProject(id: string) { - let projectDir = join(dataDir, id); + if (!isValidProjectId(id)) return null; + let projectDir = resolve(dataDir, id); + if (!isPathWithin(dataDir, projectDir)) return null; if (!existsSync(projectDir)) { const sessionsDir = resolve(dataDir, "../sessions"); - const sessionFile = join(sessionsDir, `${id}.json`); + const sessionFile = resolve(sessionsDir, `${id}.json`); + if (!isPathWithin(sessionsDir, sessionFile)) return null; if (existsSync(sessionFile)) { try { const session = JSON.parse(readFileSync(sessionFile, "utf-8")); - if (session.projectId) { - projectDir = join(dataDir, session.projectId); + if (typeof session.projectId === "string" && isValidProjectId(session.projectId)) { + projectDir = resolve(dataDir, session.projectId); + if (!isPathWithin(dataDir, projectDir)) return null; if (existsSync(projectDir)) { return { id: session.projectId, From 69c1ec08b46e8a1d9291ee3e46f8f87d8cc60423 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 01:51:02 -0700 Subject: [PATCH 2/3] test(studio): use a portable project directory fixture --- packages/studio/vite.adapter.projects.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/vite.adapter.projects.test.ts b/packages/studio/vite.adapter.projects.test.ts index ea9cc0c2d0e..8f0af5e7ee6 100644 --- a/packages/studio/vite.adapter.projects.test.ts +++ b/packages/studio/vite.adapter.projects.test.ts @@ -39,7 +39,7 @@ describe("Vite project resolution boundary", () => { it("preserves valid names, session aliases, and explicitly listed symlink projects", async () => { const { data, sessions, root, adapter, app } = fixture(); - const id = "..Mañana #1? 50%"; + const id = "..Mañana #1 50%"; mkdirSync(join(data, id)); expect((await app.request(`http://localhost/projects/${encodeURIComponent(id)}`)).status).toBe( 200, From dfe08c5b7c433c3abc71014bd516b317407638be Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 01:58:45 -0700 Subject: [PATCH 3/3] fix(studio): reject drive-relative IDs and filter project discovery --- .../studio/src/utils/projectRouting.test.ts | 2 +- packages/studio/src/utils/projectRouting.ts | 1 + packages/studio/vite.adapter.projects.test.ts | 24 +++++++++++++++++-- packages/studio/vite.adapter.ts | 1 + 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/utils/projectRouting.test.ts b/packages/studio/src/utils/projectRouting.test.ts index e55b3c20769..9f022719e33 100644 --- a/packages/studio/src/utils/projectRouting.test.ts +++ b/packages/studio/src/utils/projectRouting.test.ts @@ -9,7 +9,7 @@ import { } from "./projectRouting"; describe("project routing utilities", () => { - it.each([".", "..", "../sessions", "a/b", "a\\b", "a\u0000b", "a\nb"])( + it.each(["C:", "C:demo", ".", "..", "../sessions", "a/b", "a\\b", "a\u0000b", "a\nb"])( "rejects unsafe decoded project IDs: %s", (id) => { expect(parseProjectIdFromHash(`#project/${encodeURIComponent(id)}`)).toBeNull(); diff --git a/packages/studio/src/utils/projectRouting.ts b/packages/studio/src/utils/projectRouting.ts index fcf3541bc53..6291d665c80 100644 --- a/packages/studio/src/utils/projectRouting.ts +++ b/packages/studio/src/utils/projectRouting.ts @@ -11,6 +11,7 @@ export function isValidProjectId(value: string): boolean { value.length > 0 && value !== "." && value !== ".." && + !value.includes(":") && !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 8f0af5e7ee6..bcb9b6f6510 100644 --- a/packages/studio/vite.adapter.projects.test.ts +++ b/packages/studio/vite.adapter.projects.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, win32 } from "node:path"; +import { isValidProjectId } from "./src/utils/projectRouting"; import { createStudioApi } from "@hyperframes/studio-server"; import type { ViteDevServer } from "vite"; import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter"; @@ -29,7 +30,7 @@ function fixture() { } describe("Vite project resolution boundary", () => { - it.each(["..%2Fsessions", "a%2Fb", "a%5Cb", "%2E%2E%2Fsessions", "a%00b"])( + it.each(["C%3A", "C%3Ademo", "..%2Fsessions", "a%2Fb", "a%5Cb", "%2E%2E%2Fsessions", "a%00b"])( "rejects a router-decoded unsafe ID: %s", async (id) => { const { app } = fixture(); @@ -52,6 +53,25 @@ describe("Vite project resolution boundary", () => { expect(adapter.resolveProject("shortcut")?.dir).toBe(realpathSync(linked)); }); + it("rejects drive-relative IDs that Windows resolves as root or sibling aliases", () => { + expect(win32.resolve("C:\\hf\\data", "C:")).toBe("C:\\hf\\data"); + expect(win32.resolve("C:\\hf\\data", "C:demo")).toBe("C:\\hf\\data\\demo"); + expect(isValidProjectId("C:")).toBe(false); + expect(isValidProjectId("C:demo")).toBe(false); + }); + + it.skipIf(process.platform === "win32")( + "does not discover POSIX directory names outside the portable ID contract", + async () => { + const { data, adapter } = fixture(); + for (const id of ["valid", "bad\\name", "bad\nname", "C:demo"]) { + mkdirSync(join(data, id)); + writeFileSync(join(data, id, "index.html"), ""); + } + expect((await adapter.listProjects()).map((project) => project.id)).toEqual(["valid"]); + }, + ); + 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 dc54bf70f5a..13017fc6ca3 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -186,6 +186,7 @@ export function createViteAdapter( return readdirSync(dataDir, { withFileTypes: true }) .filter( (d) => + isValidProjectId(d.name) && (d.isDirectory() || d.isSymbolicLink()) && (existsSync(join(dataDir, d.name, "index.html")) || existsSync(join(dataDir, d.name, `${d.name}.html`))),