From 4c46cec4e37572e944d60a9d9f3681e1697afb3f Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 21 Apr 2026 22:07:21 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(GRO-867):=20proxy=20logo=20download=20t?= =?UTF-8?q?hrough=20API=20server=20=E2=80=94=20eliminate=20mixed=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All logo S3 interactions are now server-proxied: - GET /api/admin/settings/logo streams image bytes directly instead of returning a presigned S3 URL to the browser - Upload already went through POST /api/admin/settings/logo/upload - Frontend uses relative /api/admin/settings/logo path as img src, never a raw S3 URL - Appends cache-buster query param (?t=Date.now()) after upload so the browser fetches the fresh image instead of serving a stale cache Co-Authored-By: Paperclip --- apps/api/src/lib/s3.ts | 14 ++++++++++++++ apps/api/src/routes/settings.ts | 11 +++++++---- apps/web/src/pages/Settings.tsx | 26 ++++---------------------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/apps/api/src/lib/s3.ts b/apps/api/src/lib/s3.ts index b0793c5..622ad68 100644 --- a/apps/api/src/lib/s3.ts +++ b/apps/api/src/lib/s3.ts @@ -68,6 +68,20 @@ export async function deleteObject(key: string): Promise { ); } +/** Read an object from S3 and return its body buffer and content type. */ +export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> { + const client = getS3Client(); + const response = await client.send( + new GetObjectCommand({ + Bucket: getBucket(), + Key: key, + }) + ); + const body = await response.Body!.transformToBuffer(); + const contentType = response.ContentType ?? "application/octet-stream"; + return { body, contentType }; +} + /** Upload an object directly to S3 (server-side only, not a pre-signed URL). */ export async function putObject( key: string, diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index fe06b80..129e5e0 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, businessSettings } from "@groombook/db"; -import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } from "../lib/s3.js"; +import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject, getObject } from "../lib/s3.js"; import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); @@ -215,7 +215,8 @@ settingsRouter.post( /** * GET /api/admin/settings/logo - * Returns a presigned GET URL for the logo. + * Proxies the logo from S3 so the browser never sees an S3 URL. + * Returns the image bytes with proper Content-Type. */ settingsRouter.get("/logo", async (c) => { const db = getDb(); @@ -224,8 +225,10 @@ settingsRouter.get("/logo", async (c) => { if (!row) return c.json({ error: "Settings not found" }, 404); if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); - const url = await getPresignedGetUrl(row.logoKey); - return c.json({ url, logoKey: row.logoKey }); + const { body, contentType } = await getObject(row.logoKey); + c.header("Content-Type", contentType); + c.header("Cache-Control", "public, max-age=86400"); + return c.body(body); }); /** diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index c1f01ce..c22e1f2 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -89,24 +89,14 @@ export function SettingsPage() { fetch("/api/admin/settings") .then((r) => r.json()) .then(async (data) => { - let logoUrl: string | null = null; - if (data.logoKey) { - try { - const logoRes = await fetch("/api/admin/settings/logo"); - if (logoRes.ok) { - const logoData = await logoRes.json(); - logoUrl = logoData.url; - } - } catch { - // ignore - } - } + // The logo is now proxied through the API server so the browser + // never receives an S3 URL — use the proxy path directly as the src. setForm({ businessName: data.businessName ?? "GroomBook", primaryColor: data.primaryColor ?? "#4f8a6f", accentColor: data.accentColor ?? "#8b7355", logoKey: data.logoKey ?? null, - logoUrl, + logoUrl: data.logoKey ? "/api/admin/settings/logo" : null, logoBase64: data.logoBase64 ?? null, logoMimeType: data.logoMimeType ?? null, }); @@ -172,15 +162,7 @@ export function SettingsPage() { throw new Error(err?.error ?? "Failed to upload logo"); } const { logoKey } = await uploadRes.json(); - - // Fetch the presigned GET URL for display - const logoRes = await fetch("/api/admin/settings/logo"); - if (logoRes.ok) { - const logoData = await logoRes.json(); - setForm((f) => ({ ...f, logoKey, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); - } else { - setForm((f) => ({ ...f, logoKey, logoUrl: null, logoBase64: null, logoMimeType: null })); - } + setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null })); setMessage({ type: "success", text: "Logo uploaded." }); refresh(); } catch (err: unknown) { From f74e0344958acc1d111235636d9c02ae6dd1305d Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 21 Apr 2026 22:16:08 +0000 Subject: [PATCH 2/4] fix(GRO-867): replace transformToBuffer with async iteration over S3 stream transformToBuffer() does not exist on StreamingBlobPayloadOutputTypes in the AWS SDK v3 client. Use for-await-of over the async iterable body to collect chunks and Buffer.concat instead. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/lib/s3.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/lib/s3.ts b/apps/api/src/lib/s3.ts index 622ad68..5067101 100644 --- a/apps/api/src/lib/s3.ts +++ b/apps/api/src/lib/s3.ts @@ -77,7 +77,12 @@ export async function getObject(key: string): Promise<{ body: Buffer; contentTyp Key: key, }) ); - const body = await response.Body!.transformToBuffer(); + const chunks: Uint8Array[] = []; + // response.Body is a Readable stream; collect chunks into a buffer + for await (const chunk of response.Body as AsyncIterable) { + chunks.push(chunk); + } + const body = Buffer.concat(chunks); const contentType = response.ContentType ?? "application/octet-stream"; return { body, contentType }; } From 1dfcdcc2cb80fc3917b6e4eb7eaf1c1b7e0f8b46 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 21 Apr 2026 22:19:26 +0000 Subject: [PATCH 3/4] fix(GRO-867): c.body does not accept Buffer in Hono 4.x c.body() signature only accepts string | ArrayBuffer | ReadableStream | Uint8Array in Hono 4.x, not Node.js Buffer. Return a plain Response directly instead. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/routes/settings.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 129e5e0..3453582 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -226,9 +226,13 @@ settingsRouter.get("/logo", async (c) => { if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); const { body, contentType } = await getObject(row.logoKey); - c.header("Content-Type", contentType); - c.header("Cache-Control", "public, max-age=86400"); - return c.body(body); + return new Response(Buffer.from(body), { + status: 200, + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + }, + }); }); /** From c811b58c62f434782846e38d94ad07040f8ac3b0 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 21 Apr 2026 22:20:55 +0000 Subject: [PATCH 4/4] fix(GRO-867): remove unused getPresignedGetUrl import from settings.ts ESLint @typescript-eslint/no-unused-vars flagged the import. The logo proxy no longer uses pre-signed GET URLs. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/routes/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 3453582..3b931db 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, businessSettings } from "@groombook/db"; -import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject, getObject } from "../lib/s3.js"; +import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js"; import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono();