This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/apps/api/src/routes/settings.ts
T
Test User 1bc8a94a29 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 <noreply@paperclip.ing>
2026-04-17 17:07:52 +00:00

250 lines
7.5 KiB
TypeScript

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, putObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
// GET /api/admin/settings — return current business settings
settingsRouter.get("/", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) {
// Auto-create default settings if none exist
const [created] = await db.insert(businessSettings).values({}).returning();
return c.json(created);
}
return c.json(row);
});
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
const updateSettingsSchema = z.object({
businessName: z.string().min(1).max(200).optional(),
primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(),
accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(),
});
// PATCH /api/admin/settings — update business settings
settingsRouter.patch(
"/",
requireSuperUser(),
zValidator("json", updateSettingsSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
// Get or create the settings row
const rows = await db.select().from(businessSettings).limit(1);
let settingsId: string;
if (rows[0]) {
settingsId = rows[0].id;
} else {
const [inserted] = await db.insert(businessSettings).values({}).returning();
if (!inserted) throw new Error("Failed to create default settings");
settingsId = inserted.id;
}
const [updated] = await db
.update(businessSettings)
.set({ ...body, updatedAt: new Date() })
.where(eq(businessSettings.id, settingsId))
.returning();
return c.json(updated);
}
);
// ─── Logo routes ──────────────────────────────────────────────────────────────
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]);
const MAX_LOGO_SIZE = 512 * 1024; // 512 KB
const logoUploadUrlSchema = z.object({
contentType: z.string().refine((v) => ALLOWED_LOGO_TYPES.has(v), {
message: "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
}),
fileSizeBytes: z.number().int().positive().max(MAX_LOGO_SIZE, {
message: "File must not exceed 512 KB",
}),
});
const logoConfirmSchema = z.object({
key: z.string().min(1),
});
/**
* POST /api/admin/settings/logo/upload-url
* Returns a presigned S3 PUT URL and the object key for logo upload.
*/
settingsRouter.post(
"/logo/upload-url",
zValidator("json", logoUploadUrlSchema),
async (c) => {
const db = getDb();
const { contentType, fileSizeBytes } = c.req.valid("json");
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}`;
const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes);
return c.json({ uploadUrl, key });
}
);
/**
* 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.
* Records the object key in the DB and clears legacy base64 fields.
*/
settingsRouter.post(
"/logo/confirm",
zValidator("json", logoConfirmSchema),
async (c) => {
const db = getDb();
const { key } = c.req.valid("json");
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;
// Validate key prefix
if (!key.startsWith(`logos/${settingsId}/`)) {
return c.json({ error: "Invalid key" }, 400);
}
// Delete previous S3 object if any
if (rows[0].logoKey) {
await deleteObject(rows[0].logoKey);
}
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 });
}
);
/**
* GET /api/admin/settings/logo
* Returns a presigned GET URL for the logo.
*/
settingsRouter.get("/logo", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const url = await getPresignedGetUrl(row.logoKey);
return c.json({ url, logoKey: row.logoKey });
});
/**
* DELETE /api/admin/settings/logo
* Removes the logo from S3 and clears the DB record.
*/
settingsRouter.delete("/logo", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
await deleteObject(row.logoKey);
await db
.update(businessSettings)
.set({ logoKey: null, updatedAt: new Date() })
.where(eq(businessSettings.id, row.id));
return c.json({ ok: true });
});