promote: dev → uat (GRO-769 S3 mixed content fix) #326
@@ -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 { 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 } from "../lib/s3.js";
|
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } 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();
|
||||||
@@ -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
|
* POST /api/admin/settings/logo/confirm
|
||||||
* Called after the client has successfully uploaded to the presigned URL.
|
* Called after the client has successfully uploaded to the presigned URL.
|
||||||
|
|||||||
@@ -158,46 +158,28 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get presigned upload URL
|
// Upload directly through the API server to avoid mixed-content issues
|
||||||
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
|
// 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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: formData,
|
||||||
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
|
|
||||||
});
|
});
|
||||||
if (!uploadRes.ok) {
|
if (!uploadRes.ok) {
|
||||||
const err = await uploadRes.json().catch(() => null);
|
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
|
// Fetch the presigned GET URL for display
|
||||||
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
|
|
||||||
const logoRes = await fetch("/api/admin/settings/logo");
|
const logoRes = await fetch("/api/admin/settings/logo");
|
||||||
if (logoRes.ok) {
|
if (logoRes.ok) {
|
||||||
const logoData = await logoRes.json();
|
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 {
|
} 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." });
|
setMessage({ type: "success", text: "Logo uploaded." });
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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 className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-stone-800">
|
<h1 className="text-lg font-semibold text-stone-800">
|
||||||
@@ -340,7 +340,7 @@ export function CustomerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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()}
|
{renderSection()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||||
|
|||||||
Reference in New Issue
Block a user