diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d843a1d1..eb293024 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,6 +11,7 @@ import { registerStatic } from "./plugins/static.js"; import { registerUpload } from "./plugins/upload.js"; import { apiKeyRoutes } from "./routes/api-keys.js"; import { registerBatchRoutes } from "./routes/batch.js"; +import { brandingRoutes } from "./routes/branding.js"; import { fileRoutes } from "./routes/files.js"; import { registerPipelineRoutes } from "./routes/pipeline.js"; import { registerProgressRoutes } from "./routes/progress.js"; @@ -92,6 +93,9 @@ await apiKeyRoutes(app); // Settings routes await settingsRoutes(app); +// Branding routes (logo upload/serve/delete) +await brandingRoutes(app); + // Teams routes await teamsRoutes(app); diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 9c843770..de15683e 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -580,6 +580,7 @@ const PUBLIC_PATHS = [ "/api/auth/", "/api/v1/download/", "/api/v1/jobs/", + "/api/v1/settings/logo", ]; function isPublicRoute(url: string): boolean { diff --git a/apps/api/src/routes/branding.ts b/apps/api/src/routes/branding.ts new file mode 100644 index 00000000..faf440f7 --- /dev/null +++ b/apps/api/src/routes/branding.ts @@ -0,0 +1,102 @@ +/** + * Branding routes — custom logo upload, serving, and deletion. + * + * POST /api/v1/settings/logo — Upload logo (admin only) + * GET /api/v1/settings/logo — Serve custom logo as PNG (public) + * DELETE /api/v1/settings/logo — Remove custom logo (admin only) + */ + +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { db, schema } from "../db/index.js"; +import { requireAdmin } from "../plugins/auth.js"; + +const BRANDING_DIR = join(process.cwd(), "data", "branding"); +const LOGO_PATH = join(BRANDING_DIR, "logo.png"); +const MAX_LOGO_SIZE = 500 * 1024; // 500 KB + +function upsertSetting(key: string, value: string): void { + const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); + if (existing) { + db.update(schema.settings) + .set({ value, updatedAt: new Date() }) + .where(eq(schema.settings.key, key)) + .run(); + } else { + db.insert(schema.settings).values({ key, value }).run(); + } +} + +export async function brandingRoutes(app: FastifyInstance): Promise { + // POST /api/v1/settings/logo — Upload logo (admin only) + app.post("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = requireAdmin(request, reply); + if (!admin) return; + + const file = await request.file(); + if (!file) { + return reply.status(400).send({ error: "No file uploaded", code: "VALIDATION_ERROR" }); + } + + // Validate mimetype + if (!file.mimetype.startsWith("image/")) { + return reply.status(400).send({ error: "File must be an image", code: "VALIDATION_ERROR" }); + } + + // Read file buffer + const buffer = await file.toBuffer(); + + // Validate size + if (buffer.length > MAX_LOGO_SIZE) { + return reply + .status(400) + .send({ error: "Logo must be 500KB or smaller", code: "VALIDATION_ERROR" }); + } + + // Convert to PNG, resize to max 128x128 + const pngBuffer = await sharp(buffer) + .resize(128, 128, { fit: "inside", withoutEnlargement: true }) + .png() + .toBuffer(); + + // Ensure branding directory exists + mkdirSync(BRANDING_DIR, { recursive: true }); + + // Write file + writeFileSync(LOGO_PATH, pngBuffer); + + // Upsert setting + upsertSetting("customLogo", "true"); + + return reply.send({ ok: true }); + }); + + // GET /api/v1/settings/logo — Serve logo (public, no auth required) + app.get("/api/v1/settings/logo", async (_request: FastifyRequest, reply: FastifyReply) => { + if (!existsSync(LOGO_PATH)) { + return reply.status(404).send({ error: "No custom logo set", code: "NOT_FOUND" }); + } + + const logoBuffer = readFileSync(LOGO_PATH); + return reply.type("image/png").send(logoBuffer); + }); + + // DELETE /api/v1/settings/logo — Remove logo (admin only) + app.delete("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = requireAdmin(request, reply); + if (!admin) return; + + if (existsSync(LOGO_PATH)) { + unlinkSync(LOGO_PATH); + } + + upsertSetting("customLogo", "false"); + + return reply.send({ ok: true }); + }); + + app.log.info("Branding routes registered"); +} diff --git a/tests/integration/branding.test.ts b/tests/integration/branding.test.ts new file mode 100644 index 00000000..3544d45d --- /dev/null +++ b/tests/integration/branding.test.ts @@ -0,0 +1,414 @@ +/** + * Integration tests for the Logo Branding API. + * + * POST /api/v1/settings/logo — Upload logo (admin only) + * GET /api/v1/settings/logo — Serve custom logo as PNG (public) + * DELETE /api/v1/settings/logo — Remove custom logo (admin only) + */ + +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +// Helper: create a small test PNG +async function makeTestPng(width = 10, height = 10): Promise { + return sharp({ + create: { width, height, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } }, + }) + .png() + .toBuffer(); +} + +// Helper: create a test JPEG +async function makeTestJpeg(width = 10, height = 10): Promise { + return sharp({ + create: { width, height, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .jpeg() + .toBuffer(); +} + +// Helper: create a simple SVG buffer +function makeTestSvg(): Buffer { + return Buffer.from( + '', + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// POST /api/v1/settings/logo +// ═══════════════════════════════════════════════════════════════════════════ +describe("POST /api/v1/settings/logo", () => { + it("uploads a PNG logo and stores it", async () => { + const png = await makeTestPng(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: png }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ok: true }); + }); + + it("uploads a JPEG and converts to PNG", async () => { + const jpeg = await makeTestJpeg(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.jpg", contentType: "image/jpeg", content: jpeg }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + expect(res.statusCode).toBe(200); + + // Verify it was converted — GET should return PNG + const getRes = await app.inject({ + method: "GET", + url: "/api/v1/settings/logo", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(getRes.headers["content-type"]).toBe("image/png"); + }); + + it("uploads an SVG and converts to PNG", async () => { + const svg = makeTestSvg(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.svg", contentType: "image/svg+xml", content: svg }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("rejects files over 500KB", async () => { + // Create an image that's just over 500KB but under the multipart limit (10MB) + // A 500x500 uncompressed PNG with 4 channels is ~1MB + const largePng = await sharp({ + create: { + width: 500, + height: 500, + channels: 4, + background: { r: 128, g: 64, b: 32, alpha: 1 }, + }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(); + + // Ensure it's actually over 500KB + expect(largePng.length).toBeGreaterThan(500 * 1024); + + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "big.png", contentType: "image/png", content: largePng }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/500KB/i); + }); + + it("rejects non-image files", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "data.txt", + contentType: "text/plain", + content: Buffer.from("not an image"), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/image/i); + }); + + it("requires admin role", async () => { + // Register a non-admin user + await app.inject({ + method: "POST", + url: "/api/auth/register", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { username: "branduser", password: "Testpass1", role: "user" }, + }); + + // Login as non-admin + const loginRes = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "branduser", password: "Testpass1" }, + }); + + // Clear mustChangePassword + const { token } = JSON.parse(loginRes.body); + await app.inject({ + method: "POST", + url: "/api/auth/change-password", + headers: { authorization: `Bearer ${token}` }, + payload: { currentPassword: "Testpass1", newPassword: "Newpass123" }, + }); + + // Login again with new password + const loginRes2 = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "branduser", password: "Newpass123" }, + }); + const userToken = JSON.parse(loginRes2.body).token; + + const png = await makeTestPng(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: png }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${userToken}`, + "content-type": contentType, + }, + payload: body, + }); + + expect(res.statusCode).toBe(403); + }); + + it("resizes large images to max 128x128", async () => { + const png = await makeTestPng(256, 256); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "big-logo.png", contentType: "image/png", content: png }, + ]); + + await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + // Fetch and check dimensions + const getRes = await app.inject({ + method: "GET", + url: "/api/v1/settings/logo", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + const metadata = await sharp(getRes.rawPayload).metadata(); + expect(metadata.width).toBeLessThanOrEqual(128); + expect(metadata.height).toBeLessThanOrEqual(128); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// GET /api/v1/settings/logo +// ═══════════════════════════════════════════════════════════════════════════ +describe("GET /api/v1/settings/logo", () => { + it("returns 404 when no custom logo is set", async () => { + // First delete any existing logo + await app.inject({ + method: "DELETE", + url: "/api/v1/settings/logo", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/settings/logo", + }); + + expect(res.statusCode).toBe(404); + }); + + it("returns the logo with image/png content-type after upload", async () => { + // Upload a logo first + const png = await makeTestPng(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: png }, + ]); + + await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/settings/logo", + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("image/png"); + // Verify it's valid PNG data + const metadata = await sharp(res.rawPayload).metadata(); + expect(metadata.format).toBe("png"); + }); + + it("works without authentication (public path)", async () => { + // Upload a logo first (as admin) + const png = await makeTestPng(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: png }, + ]); + + await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + // GET without any auth header + const res = await app.inject({ + method: "GET", + url: "/api/v1/settings/logo", + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("image/png"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// DELETE /api/v1/settings/logo +// ═══════════════════════════════════════════════════════════════════════════ +describe("DELETE /api/v1/settings/logo", () => { + it("removes the custom logo", async () => { + // Upload first + const png = await makeTestPng(); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: png }, + ]); + + await app.inject({ + method: "POST", + url: "/api/v1/settings/logo", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload: body, + }); + + // Delete + const delRes = await app.inject({ + method: "DELETE", + url: "/api/v1/settings/logo", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(delRes.statusCode).toBe(200); + expect(JSON.parse(delRes.body)).toEqual({ ok: true }); + + // Verify logo is gone + const getRes = await app.inject({ + method: "GET", + url: "/api/v1/settings/logo", + }); + + expect(getRes.statusCode).toBe(404); + }); + + it("requires admin role", async () => { + // Login as non-admin (created in earlier test) + const loginRes = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "branduser", password: "Newpass123" }, + }); + const userToken = JSON.parse(loginRes.body).token; + + const res = await app.inject({ + method: "DELETE", + url: "/api/v1/settings/logo", + headers: { authorization: `Bearer ${userToken}` }, + }); + + expect(res.statusCode).toBe(403); + }); + + it("returns ok even if no logo exists (idempotent)", async () => { + // Ensure no logo exists + await app.inject({ + method: "DELETE", + url: "/api/v1/settings/logo", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + // Delete again + const res = await app.inject({ + method: "DELETE", + url: "/api/v1/settings/logo", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ok: true }); + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 276cdc50..14867db5 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -33,6 +33,7 @@ import { authMiddleware, authRoutes, ensureDefaultAdmin } from "../../apps/api/s import { registerUpload } from "../../apps/api/src/plugins/upload.js"; import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js"; import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js"; +import { brandingRoutes } from "../../apps/api/src/routes/branding.js"; import { fileRoutes } from "../../apps/api/src/routes/files.js"; import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js"; import { registerProgressRoutes } from "../../apps/api/src/routes/progress.js"; @@ -99,6 +100,9 @@ export async function buildTestApp(): Promise { // Settings routes await settingsRoutes(app); + // Branding routes + await brandingRoutes(app); + // Teams routes await teamsRoutes(app); diff --git a/vitest.config.ts b/vitest.config.ts index 5f03d6e7..a79578b5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -71,6 +71,7 @@ export default defineConfig({ qrcode: path.join(apiNodeModules, "qrcode"), jsqr: path.join(apiNodeModules, "jsqr"), pdfkit: path.join(apiNodeModules, "pdfkit"), + sharp: path.join(apiNodeModules, "sharp"), }, }, });