1bc8a94a29
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>
250 lines
7.5 KiB
TypeScript
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 });
|
|
});
|