Merge pull request #352 from groombook/fix/gro-867-logo-proxy

fix(GRO-867): proxy logo download through API server — eliminate mixed content
This commit was merged in pull request #352.
This commit is contained in:
the-dogfather-cto[bot]
2026-04-22 02:48:54 +00:00
committed by GitHub
3 changed files with 34 additions and 26 deletions
+19
View File
@@ -68,6 +68,25 @@ export async function deleteObject(key: string): Promise<void> {
); );
} }
/** 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 chunks: Uint8Array[] = [];
// response.Body is a Readable stream; collect chunks into a buffer
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const contentType = response.ContentType ?? "application/octet-stream";
return { body, contentType };
}
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */ /** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
export async function putObject( export async function putObject(
key: string, key: string,
+11 -4
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db"; import { eq, getDb, businessSettings } from "@groombook/db";
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } from "../lib/s3.js"; import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js"; import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono(); export const settingsRouter = new Hono();
@@ -215,7 +215,8 @@ settingsRouter.post(
/** /**
* GET /api/admin/settings/logo * 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) => { settingsRouter.get("/logo", async (c) => {
const db = getDb(); const db = getDb();
@@ -224,8 +225,14 @@ settingsRouter.get("/logo", async (c) => {
if (!row) return c.json({ error: "Settings not found" }, 404); if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const url = await getPresignedGetUrl(row.logoKey); const { body, contentType } = await getObject(row.logoKey);
return c.json({ url, logoKey: row.logoKey }); return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
}); });
/** /**
+4 -22
View File
@@ -89,24 +89,14 @@ export function SettingsPage() {
fetch("/api/admin/settings") fetch("/api/admin/settings")
.then((r) => r.json()) .then((r) => r.json())
.then(async (data) => { .then(async (data) => {
let logoUrl: string | null = null; // The logo is now proxied through the API server so the browser
if (data.logoKey) { // never receives an S3 URL — use the proxy path directly as the src.
try {
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
logoUrl = logoData.url;
}
} catch {
// ignore
}
}
setForm({ setForm({
businessName: data.businessName ?? "GroomBook", businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f", primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355", accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null, logoKey: data.logoKey ?? null,
logoUrl, logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
logoBase64: data.logoBase64 ?? null, logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null, logoMimeType: data.logoMimeType ?? null,
}); });
@@ -172,15 +162,7 @@ export function SettingsPage() {
throw new Error(err?.error ?? "Failed to upload logo"); throw new Error(err?.error ?? "Failed to upload logo");
} }
const { logoKey } = await uploadRes.json(); const { logoKey } = await uploadRes.json();
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
// 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 }));
}
setMessage({ type: "success", text: "Logo uploaded." }); setMessage({ type: "success", text: "Logo uploaded." });
refresh(); refresh();
} catch (err: unknown) { } catch (err: unknown) {