5f01df819e
The PATCH handler returned the full businessSettings row via .returning(), echoing the encrypted googleMapsApiKey ciphertext back to the caller. Wrap the return in the existing redactSettings() helper (after a !updated guard) so redaction is applied symmetrically with the GET projection (GRO-2294). - src/routes/settings.ts: guard + redactSettings(updated) on PATCH return - src/__tests__/settings.test.ts: assert PATCH omits googleMapsApiKey (existing-row and auto-create-then-update branches) - UAT_PLAYBOOK.md §13 TC-API-13.2: assert PATCH response omits the secret Co-Authored-By: Paperclip <noreply@paperclip.ing>
270 lines
8.3 KiB
TypeScript
270 lines
8.3 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, deleteObject, putObject, getObject } from "../lib/s3.js";
|
|
import { requireSuperUser } from "../middleware/rbac.js";
|
|
|
|
export const settingsRouter = new Hono();
|
|
|
|
type BusinessSettingsRow = typeof businessSettings.$inferSelect;
|
|
|
|
// Strip the encrypted googleMapsApiKey ciphertext from settings responses
|
|
// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is
|
|
// only written via the dedicated provider-config endpoint.
|
|
function redactSettings(row: BusinessSettingsRow) {
|
|
const rest: Partial<BusinessSettingsRow> = { ...row };
|
|
delete rest.googleMapsApiKey;
|
|
return rest;
|
|
}
|
|
|
|
// 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();
|
|
if (!created) throw new Error("Failed to create default settings");
|
|
return c.json(redactSettings(created));
|
|
}
|
|
return c.json(redactSettings(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();
|
|
|
|
if (!updated) throw new Error("Failed to update settings");
|
|
return c.json(redactSettings(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
|
|
* 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) => {
|
|
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 { body, contentType } = await getObject(row.logoKey);
|
|
return new Response(Buffer.from(body), {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": "public, max-age=86400",
|
|
},
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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 });
|
|
});
|