From 77971a1ac9dee3eb64621270f9ce82b4b53da96d Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:13:44 +0000 Subject: [PATCH] fix(GRO-769): proxy logo uploads through API server to fix mixed content (#325) * fix(GRO-766): prevent horizontal overflow on portal mobile pages - Add overflow-x-hidden to main content area in CustomerPortal - Add w-full overflow-hidden to content wrapper div - Add flex-wrap to BillingPayments tab button row Co-Authored-By: Paperclip * fix(GRO-769): proxy logo uploads through API server to fix mixed content The pre-signed URL flow used an internal HTTP endpoint for S3 uploads, which browsers blocked as mixed content on HTTPS pages. Instead of generating a pre-signed URL that the browser uploads to directly, the new /logo/upload endpoint receives the file via multipart POST and streams it to S3 from the API server using the internal endpoint. This resolves the mixed content error that was blocking logo uploads on dev.groombook.dev. Co-Authored-By: Paperclip --------- Co-authored-by: Test User Co-authored-by: Paperclip --- apps/api/src/lib/s3.ts | 19 +++++ apps/api/src/routes/settings.ts | 73 ++++++++++++++++++- apps/web/src/pages/Settings.tsx | 42 +++-------- apps/web/src/portal/CustomerPortal.tsx | 4 +- .../src/portal/sections/BillingPayments.tsx | 2 +- 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/apps/api/src/lib/s3.ts b/apps/api/src/lib/s3.ts index c242ff9..b0793c5 100644 --- a/apps/api/src/lib/s3.ts +++ b/apps/api/src/lib/s3.ts @@ -67,3 +67,22 @@ export async function deleteObject(key: string): Promise { }) ); } + +/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */ +export async function putObject( + key: string, + body: Buffer | Uint8Array | string, + contentType: string, + contentLength: number +): Promise { + const client = getS3Client(); + await client.send( + new PutObjectCommand({ + Bucket: getBucket(), + Key: key, + Body: body, + ContentType: contentType, + ContentLength: contentLength, + }) + ); +} diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index ec1f8fa..fe06b80 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 } from "../lib/s3.js"; +import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } from "../lib/s3.js"; import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); @@ -100,6 +100,77 @@ settingsRouter.post( } ); +/** + * POST /api/admin/settings/logo/upload + * Proxy upload through the API server to avoid mixed-content issues with + * pre-signed URLs that use the internal HTTP endpoint. The file is uploaded + * directly to S3 from the server using the internal endpoint. + */ +settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => { + const db = getDb(); + + // Parse multipart form data (file field) + const body = await c.req.parseBody({ all: true }); + const file = body["file"]; + + if (!file || !(file instanceof File)) { + return c.json({ error: "No file provided" }, 400); + } + + const contentType = file.type; + if (!ALLOWED_LOGO_TYPES.has(contentType)) { + return c.json( + { + error: + "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp", + }, + 400 + ); + } + + const fileSizeBytes = file.size; + if (fileSizeBytes > MAX_LOGO_SIZE) { + return c.json({ error: "File must not exceed 512 KB" }, 400); + } + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + const ext = contentType.split("/")[1] ?? "png"; + const key = `logos/${settingsId}/${Date.now()}.${ext}`; + + // Read file into buffer and upload directly to S3 (bypasses pre-signed URL) + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await putObject(key, buffer, contentType, fileSizeBytes); + + // Delete previous S3 object if any + if (rows[0].logoKey) { + await deleteObject(rows[0].logoKey); + } + + // Update database with new logo key + const [updated] = await db + .update(businessSettings) + .set({ + logoKey: key, + logoBase64: null, + logoMimeType: null, + updatedAt: new Date(), + }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + if (!updated) { + return c.json({ error: "Settings not found" }, 404); + } + + return c.json({ ok: true, logoKey: updated.logoKey }); +}); + /** * POST /api/admin/settings/logo/confirm * Called after the client has successfully uploaded to the presigned URL. diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 8d70d06..c1f01ce 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -158,46 +158,28 @@ export function SettingsPage() { } try { - // Step 1: Get presigned upload URL - const uploadRes = await fetch("/api/admin/settings/logo/upload-url", { + // Upload directly through the API server to avoid mixed-content issues + // with pre-signed URLs that use the internal HTTP endpoint + const formData = new FormData(); + formData.append("file", file); + + const uploadRes = await fetch("/api/admin/settings/logo/upload", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }), + body: formData, }); if (!uploadRes.ok) { const err = await uploadRes.json().catch(() => null); - throw new Error(err?.error ?? "Failed to get upload URL"); + throw new Error(err?.error ?? "Failed to upload logo"); } - const { uploadUrl, key } = await uploadRes.json(); + const { logoKey } = await uploadRes.json(); - // Step 2: PUT the file directly to S3 - const putRes = await fetch(uploadUrl, { - method: "PUT", - headers: { "Content-Type": file.type }, - body: file, - }); - if (!putRes.ok) { - throw new Error("Failed to upload logo to storage"); - } - - // Step 3: Confirm the upload - const confirmRes = await fetch("/api/admin/settings/logo/confirm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }); - if (!confirmRes.ok) { - const err = await confirmRes.json().catch(() => null); - throw new Error(err?.error ?? "Failed to confirm logo upload"); - } - - // Step 4: Fetch the presigned GET URL for display + // 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: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); + setForm((f) => ({ ...f, logoKey, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); } else { - setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null })); + setForm((f) => ({ ...f, logoKey, logoUrl: null, logoBase64: null, logoMimeType: null })); } setMessage({ type: "success", text: "Logo uploaded." }); refresh(); diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 89bc750..a542cc0 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -326,7 +326,7 @@ export function CustomerPortal() { )} {/* Main Content */} -
+

@@ -340,7 +340,7 @@ export function CustomerPortal() {

-
+
{renderSection()}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index d47bea4..e4d2902 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) { )} -
+
{([ { id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard },