Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/core/src/studio-api/routes/files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFileRoutes } from "./files";
import type { StudioApiAdapter } from "../types";

const tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-files-test-"));
tempDirs.push(projectDir);
writeFileSync(join(projectDir, "index.html"), "<html><body>Preview</body></html>");
return projectDir;
}

function createAdapter(projectDir: string): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
};
}

describe("registerFileRoutes", () => {
it("returns empty content for missing files when caller marks the read optional", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await app.request(
"http://localhost/projects/demo/files/missing-file.txt?optional=1",
);
const payload = (await response.json()) as { filename?: string; content?: string };

expect(response.status).toBe(200);
expect(payload.filename).toBe("missing-file.txt");
expect(payload.content).toBe("");
});

it("still returns 404 for other missing files", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await app.request("http://localhost/projects/demo/files/missing-file.txt");

expect(response.status).toBe(404);
});
});
15 changes: 13 additions & 2 deletions packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import { removeElementFromHtml } from "../helpers/sourceMutation.js";
* Returns null (and sends an error response) if anything is invalid.
*/
interface RouteContext {
req: { param: (name: string) => string; path: string };
req: {
param: (name: string) => string;
path: string;
query: (name: string) => string | undefined;
};
json: (data: unknown, status?: number) => Response;
}

Expand Down Expand Up @@ -135,9 +139,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// ── Read ──

api.get("/projects/:id/files/*", async (c) => {
const res = await resolveProjectFile(c, adapter, { mustExist: true });
const res = await resolveProjectFile(c, adapter);
if ("error" in res) return res.error;

if (!existsSync(res.absPath)) {
if (c.req.query("optional") === "1") {
return c.json({ filename: res.filePath, content: "" });
}
return c.json({ error: "not found" }, 404);
}

const content = readFileSync(res.absPath, "utf-8");
return c.json({ filename: res.filePath, content });
});
Expand Down
5 changes: 3 additions & 2 deletions packages/studio/src/hooks/useFileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,11 @@
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
if (response.status === 404) return "";
const response = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(path)}?optional=1`,
);
if (!response.ok) throw new Error(`Failed to read ${path}`);
const data = (await response.json()) as { content?: string };

Check warning

Code scanning / CodeQL

Client-side request forgery Medium

The
URL
of this request depends on a
user-provided value
.
return typeof data.content === "string" ? data.content : "";
}, []);

Expand Down
Loading