Files
hyperframes/packages/studio-server/src/routes/waveform.ts
Miguel Ángel fed45bac7c fix: serve projects and assets whose names contain @ % # & or ? (#4415)
* fix(studio-server): serve projects whose names contain @ % # & or ?

Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F
encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms
404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL.

* fix(studio-server): thumbnails and waveforms stay inside the project for any name

The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it,
and neither route checked that the decoded path stays inside the project. Thumbnails now use the
composition guard and an encoded URL; waveforms use the read-only asset guard.

* fix(cli): play and present serve assets whose names contain @ % # & or ?

Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share
studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the
five studio-server routes, so one helper owns how a request path becomes a file path.

* fix(studio-server): a thumbnail request for the project folder answers 404

An empty thumbnail path resolved to the project directory and crashed reading it (500).

* fix(studio-server): read the thumbnail's composition with one open

Checking the path with stat and then reading it let the file change in between (CodeQL
js/file-system-race). One open now serves the type check, the mtime and the read; a missing file
still thumbnails from the preview as before, anything that is not a file answers 404.
2026-09-24 10:38:56 -04:00

57 lines
2.0 KiB
TypeScript

import { existsSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import {
decodeAudioPeaks,
buildWaveformCacheKey,
writeWaveformCache,
isWaveformCacheDirectory,
} from "../helpers/waveform.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/waveform/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
const audioPath = resolve(project.dir, assetPath);
if (!isWithinProjectRoot(project.dir, audioPath)) {
return c.json({ error: "file not found" }, 404);
}
const stats = statSync(audioPath, { throwIfNoEntry: false });
if (!stats) return c.json({ error: "file not found" }, 404);
const cacheDir = join(project.dir, ".waveform-cache");
// Keyed on the file's size and mtime as well as its name, so re-encoding an
// asset in place invalidates its peaks instead of drawing the old ones.
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath, stats));
try {
if (isWaveformCacheDirectory(cacheDir) && existsSync(cachePath)) {
const peaks = JSON.parse(readFileSync(cachePath, "utf-8")) as number[];
return c.json({ peaks });
}
} catch {
// corrupt or inaccessible cache — regenerate
}
let peaks: number[];
try {
peaks = await decodeAudioPeaks(audioPath);
} catch {
return c.json({ error: "failed to decode audio" }, 500);
}
try {
writeWaveformCache(cachePath, peaks);
} catch {
// cache write failure is non-fatal
}
return c.json({ peaks });
});
}