diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 766c15b147..0757c659be 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -83,6 +83,36 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after"); }); + it("preserves binary bodies byte-for-byte on PUT and POST", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + // Bytes that are invalid UTF-8 (e.g. font/image data): a UTF-8 text decode + // would replace them with U+FFFD and corrupt the file. + const binary = Uint8Array.from([0x4f, 0x54, 0x54, 0x4f, 0x00, 0x98, 0xff, 0xfe, 0x80, 0x01]); + + const created = await app.request("http://localhost/projects/demo/files/assets/font.otf", { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: binary, + }); + expect(created.status).toBe(201); + expect(new Uint8Array(readFileSync(join(projectDir, "assets/font.otf")))).toEqual(binary); + + const reversed = Uint8Array.from([...binary].reverse()); + const overwritten = await app.request( + "http://localhost/projects/demo/files/assets/font.otf", + { + method: "PUT", + headers: { "Content-Type": "application/octet-stream" }, + body: reversed, + }, + ); + expect(overwritten.status).toBe(200); + expect(new Uint8Array(readFileSync(join(projectDir, "assets/font.otf")))).toEqual(reversed); + }); + it("backs up the previous file content before delete", async () => { const projectDir = createProjectDir(); writeFileSync(join(projectDir, "index.html"), "before delete"); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index cd8b8f72d4..56accc9051 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -1604,10 +1604,13 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if ("error" in res) return res.error; ensureDir(res.absPath); - const body = await c.req.text(); + // Read raw bytes: c.req.text() decodes the body as UTF-8, which corrupts + // binary uploads (fonts, images) by replacing non-UTF-8 bytes. Writing the + // raw buffer is byte-exact for text bodies too. + const body = Buffer.from(await c.req.arrayBuffer()); const backup = snapshotBeforeWrite(res.project.dir, res.absPath); if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`); - writeFileSync(res.absPath, body, "utf-8"); + writeFileSync(res.absPath, body); return c.json({ ok: true, @@ -1627,8 +1630,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { } ensureDir(res.absPath); - const body = await c.req.text().catch(() => ""); - writeFileSync(res.absPath, body, "utf-8"); + const body = await c.req + .arrayBuffer() + .then((b) => Buffer.from(b)) + .catch(() => Buffer.alloc(0)); + writeFileSync(res.absPath, body); return c.json({ ok: true, path: res.filePath }, 201); });