fix(GRO-769): proxy logo uploads through API server to fix mixed content #325
@@ -67,3 +67,22 @@ export async function deleteObject(key: string): Promise<void> {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
const client = getS3Client();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-h-screen">
|
||||
<main className="flex-1 min-h-screen overflow-x-hidden">
|
||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-800">
|
||||
@@ -340,7 +340,7 @@ export function CustomerPortal() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 md:p-8 max-w-6xl">
|
||||
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||
|
||||
Reference in New Issue
Block a user