feat(settings): migrate logo storage from base64-in-DB to S3

- Add logoKey column to businessSettings schema
- Add Drizzle migration 0022_logo_key.sql
- Add POST /api/admin/settings/logo/upload-url (presigned PUT URL)
- Add POST /api/admin/settings/logo/confirm (record key, clear base64)
- Add GET /api/admin/settings/logo (presigned GET URL)
- Add DELETE /api/admin/settings/logo (remove S3 object, clear DB)
- Update PATCH /api/admin/settings to reject logoBase64/logoMimeType
- Update GET /api/branding to return logoUrl (presigned) with legacy base64 compat
- Update BrandingContext to include logoUrl field
- Update Settings page to use presigned upload flow (no base64 in PATCH body)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
groombook-engineer[bot]
2026-04-02 12:17:57 +00:00
parent 74571d9f2b
commit fa5ddc0792
+63 -14
View File
@@ -57,7 +57,7 @@ export function SettingsPage() {
.catch(() => setLoaded(true));
}, []);
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -72,15 +72,53 @@ export function SettingsPage() {
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Strip the data:...;base64, prefix
const base64 = result.split(",")[1] ?? null;
setForm((f) => ({ ...f, logoBase64: base64, logoMimeType: file.type as SettingsForm["logoMimeType"] }));
setMessage(null);
};
reader.readAsDataURL(file);
try {
// Step 1: Get presigned upload URL
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => null);
throw new Error(err?.error ?? "Failed to get upload URL");
}
const { uploadUrl, key } = 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
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 }));
} else {
setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null }));
}
setMessage({ type: "success", text: "Logo uploaded." });
refresh();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Logo upload failed" });
}
};
const handleSave = async () => {
@@ -107,9 +145,7 @@ export function SettingsPage() {
if (!loaded) return <p>Loading settings...</p>;
const logoSrc = form.logoBase64 && form.logoMimeType
? `data:${form.logoMimeType};base64,${form.logoBase64}`
: null;
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
return (
<div style={{ maxWidth: 600 }}>
@@ -182,7 +218,20 @@ export function SettingsPage() {
/>
{logoSrc && (
<button
onClick={() => setForm((f) => ({ ...f, logoBase64: null, logoMimeType: null }))}
onClick={async () => {
try {
const res = await fetch("/api/admin/settings/logo", { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "Failed to delete logo");
}
setForm((f) => ({ ...f, logoKey: null, logoUrl: null, logoBase64: null, logoMimeType: null }));
setMessage({ type: "success", text: "Logo removed." });
refresh();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Delete failed" });
}
}}
style={{
marginLeft: 8,
padding: "0.4rem 0.75rem",