Skip to content

Commit 461ed9a

Browse files
committed
fix(studio): reject invalid media uploads
1 parent 82a70a0 commit 461ed9a

5 files changed

Lines changed: 128 additions & 1 deletion

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import { validateUploadedMedia } from "./mediaValidation.js";
3+
4+
describe("validateUploadedMedia", () => {
5+
it("passes through non-media files", () => {
6+
expect(
7+
validateUploadedMedia("/tmp/test.svg", () => ({ status: 0, stdout: "", stderr: "" })),
8+
).toEqual({
9+
ok: true,
10+
});
11+
});
12+
13+
it("accepts video files with a video stream", () => {
14+
expect(
15+
validateUploadedMedia("/tmp/test.mp4", () => ({
16+
status: 0,
17+
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
18+
stderr: "",
19+
})),
20+
).toEqual({ ok: true });
21+
});
22+
23+
it("rejects video files with no supported video stream", () => {
24+
expect(
25+
validateUploadedMedia("/tmp/test.mp4", () => ({
26+
status: 0,
27+
stdout: JSON.stringify({ streams: [] }),
28+
stderr: "",
29+
})),
30+
).toEqual({ ok: false, reason: "no supported video stream found" });
31+
});
32+
33+
it("accepts audio files with an audio stream", () => {
34+
expect(
35+
validateUploadedMedia("/tmp/test.wav", () => ({
36+
status: 0,
37+
stdout: JSON.stringify({ streams: [{ codec_type: "audio" }] }),
38+
stderr: "",
39+
})),
40+
).toEqual({ ok: true });
41+
});
42+
43+
it("does not block upload when ffprobe is unavailable", () => {
44+
expect(
45+
validateUploadedMedia("/tmp/test.mp4", () => ({
46+
status: null,
47+
stdout: "",
48+
stderr: "",
49+
error: { code: "ENOENT" } as NodeJS.ErrnoException,
50+
})),
51+
).toEqual({ ok: true });
52+
});
53+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { spawnSync } from "node:child_process";
2+
3+
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
4+
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
5+
6+
type FfprobeRunner = (
7+
command: string,
8+
args: string[],
9+
) => {
10+
status: number | null;
11+
stdout: string | Buffer;
12+
stderr: string | Buffer;
13+
error?: NodeJS.ErrnoException;
14+
};
15+
16+
export function validateUploadedMedia(
17+
filePath: string,
18+
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
19+
): { ok: true } | { ok: false; reason: string } {
20+
const isVideo = VIDEO_EXT.test(filePath);
21+
const isAudio = AUDIO_EXT.test(filePath);
22+
if (!isVideo && !isAudio) {
23+
return { ok: true };
24+
}
25+
26+
const result = runner("ffprobe", [
27+
"-v",
28+
"error",
29+
"-show_entries",
30+
"stream=codec_type",
31+
"-of",
32+
"json",
33+
filePath,
34+
]);
35+
36+
if (result.error?.code === "ENOENT") {
37+
return { ok: true };
38+
}
39+
if (result.status !== 0) {
40+
return { ok: false, reason: "ffprobe failed to read the media file" };
41+
}
42+
43+
try {
44+
const parsed = JSON.parse(String(result.stdout || "{}")) as {
45+
streams?: Array<{ codec_type?: string }>;
46+
};
47+
const streams = parsed.streams ?? [];
48+
const hasVideo = streams.some((stream) => stream.codec_type === "video");
49+
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
50+
51+
if (isVideo && !hasVideo) {
52+
return { ok: false, reason: "no supported video stream found" };
53+
}
54+
if (isAudio && !hasAudio) {
55+
return { ok: false, reason: "no supported audio stream found" };
56+
}
57+
return { ok: true };
58+
} catch {
59+
return { ok: false, reason: "ffprobe returned unreadable media metadata" };
60+
}
61+
}

packages/core/src/studio-api/routes/files.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "node:fs";
1414
import { resolve, dirname, join } from "node:path";
1515
import type { StudioApiAdapter } from "../types.js";
16+
import { validateUploadedMedia } from "../helpers/mediaValidation.js";
1617
import { isSafePath } from "../helpers/safePath.js";
1718
import { removeElementFromHtml } from "../helpers/sourceMutation.js";
1819

@@ -301,6 +302,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
301302
const formData = await c.req.formData();
302303
const uploaded: string[] = [];
303304
const skipped: string[] = [];
305+
const invalid: Array<{ name: string; reason: string }> = [];
304306

305307
for (const [, value] of formData.entries()) {
306308
if (!(value instanceof File)) continue;
@@ -338,10 +340,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
338340

339341
const buffer = Buffer.from(await value.arrayBuffer());
340342
writeFileSync(finalPath, buffer);
343+
const validation = validateUploadedMedia(finalPath);
344+
if (!validation.ok) {
345+
unlinkSync(finalPath);
346+
invalid.push({ name: finalName, reason: validation.reason });
347+
continue;
348+
}
341349
uploaded.push(subDir ? join(subDir, finalName) : finalName);
342350
}
343351

344-
return c.json({ ok: true, files: uploaded, skipped }, 201);
352+
return c.json({ ok: true, files: uploaded, skipped, invalid }, 201);
345353
},
346354
);
347355
}

packages/studio/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,10 @@ export function StudioApp() {
936936
if (data.skipped?.length) {
937937
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
938938
}
939+
if (data.invalid?.length) {
940+
const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
941+
showToast(`Unsupported media skipped: ${names}`);
942+
}
939943
await refreshFileTree();
940944
setRefreshKey((k) => k + 1);
941945
return Array.isArray(data.files) ? data.files : [];

packages/studio/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
9999

100100
return {
101101
listProjects() {
102+
if (!existsSync(dataDir)) return [];
102103
const sessionsDir = resolve(dataDir, "../sessions");
103104
const sessionMap = new Map<string, { sessionId: string; title: string }>();
104105
if (existsSync(sessionsDir)) {

0 commit comments

Comments
 (0)