diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5a33390801..f2a9313e6d 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 072b4829f3..0523ab4e8f 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 9547a7f617..9803b8731a 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 3b24e2ec98..77805259b4 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 0000000000..32d56496e3 --- /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 6f6c303602..3e4d4a5323 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 0bc8b4fd36..f8bfa6325a 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 d71d7ef526..6285e87c60 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 feb947c42d..47e23350f3 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 4b0209e559..f11a040f59 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 15d1348803..704ea828d5 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 aa8270dbf0..0ca716a507 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 47a9e61a17..173c9e4d4d 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 d0baaa595e..5f27139cc4 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 f88f3f2613..3b3691e518 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 243af89015..e7fce63c47 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 7d2549b507..09994a3edc 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 b425ddb0d2..7b77d62d4a 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 df5de796de..464a05ed62 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 adc078de69..f78126c6ab 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 1ac24bd14e..532adff463 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 399fb75957..a2e234f215 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 7d4f7b1579..3d3a821c13 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 cd47b9adcf..505067f035 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 94ce3964d4..50ca3794ee 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 0a55cb7d55..7a1732d8c1 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 4406381845..7255b4c50c 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 1f1e0badc3..9f022719e3 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(["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(); + 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 faed3790d1..6291d665c8 100644 --- a/packages/studio/src/utils/projectRouting.ts +++ b/packages/studio/src/utils/projectRouting.ts @@ -5,6 +5,19 @@ 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("/") && + !value.includes("\\") && + !Array.from(value).some((char) => char.charCodeAt(0) < 32) + ); +} + function decodeHashProjectId(value: string): string { try { return decodeURIComponent(value); @@ -28,6 +41,7 @@ function normalizeHashParams( } export function encodeProjectId(projectId: string): string { + if (!isValidProjectId(projectId)) throw new Error("Invalid project ID"); return encodeURIComponent(projectId); } @@ -47,9 +61,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 567bbffe39..ea526da58c 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 c055cb8709..7125221b88 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 489bf8ea0b..8531768a55 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 e50970f8b0..9a853fad46 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 0000000000..bcb9b6f651 --- /dev/null +++ b/packages/studio/vite.adapter.projects.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +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"; + +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(["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(); + 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 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" })); + expect(adapter.resolveProject("alias")).toBeNull(); + }); +}); diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index d34346251e..13017fc6ca 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)) ); } @@ -183,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`))), @@ -201,15 +205,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,