From 74516fd7c858f32b6f349670448856bee9644f9d Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 11 Aug 2026 09:23:28 +0800 Subject: [PATCH] feat(webui): branding upload + public asset routes --- .../system/branding/asset/[asset]/route.ts | 49 ++++++++++ src/app/api/system/branding/route.ts | 98 +++++++++++++++++++ src/app/api/system/branding/routes.test.ts | 90 +++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 src/app/api/system/branding/asset/[asset]/route.ts create mode 100644 src/app/api/system/branding/route.ts create mode 100644 src/app/api/system/branding/routes.test.ts diff --git a/src/app/api/system/branding/asset/[asset]/route.ts b/src/app/api/system/branding/asset/[asset]/route.ts new file mode 100644 index 0000000..5775475 --- /dev/null +++ b/src/app/api/system/branding/asset/[asset]/route.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; +import { type NextRequest, NextResponse } from "next/server"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { brandingDir, getSystemConfig } from "@/lib/server/systemConfig"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CONTENT_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + svg: "image/svg+xml", + ico: "image/x-icon", +}; + +/** Public branding assets (logo/favicon). Filenames come from the config + * file, never from the URL, so there is no path traversal surface. */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ asset: string }> } +) { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: { "Cache-Control": "no-store" } } + ); + } + const { asset } = await params; + if (asset !== "logo" && asset !== "favicon") { + return new NextResponse("Not found.", { status: 404 }); + } + const file = + asset === "logo" + ? getSystemConfig().branding.logoFile + : getSystemConfig().branding.faviconFile; + const ext = file?.split(".").pop() ?? ""; + const contentType = CONTENT_TYPES[ext]; + const target = file ? path.join(brandingDir(), file) : null; + if (!file || !contentType || !target || !fs.existsSync(target)) { + return new NextResponse("Not found.", { status: 404 }); + } + return new NextResponse(new Uint8Array(fs.readFileSync(target)), { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=60", + }, + }); +} diff --git a/src/app/api/system/branding/route.ts b/src/app/api/system/branding/route.ts new file mode 100644 index 0000000..e4be3b9 --- /dev/null +++ b/src/app/api/system/branding/route.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import path from "node:path"; +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import { + brandingDir, + getSystemConfig, + saveSystemConfig, +} from "@/lib/server/systemConfig"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_BYTES = 512 * 1024; +const KINDS = ["logo", "favicon"] as const; +type Kind = (typeof KINDS)[number]; +const MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/svg+xml": "svg", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", +}; + +function parseKind(value: unknown): Kind { + if (value === "logo" || value === "favicon") return value; + throw new Error("kind must be 'logo' or 'favicon'."); +} + +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + const form = await request.formData(); + const kind = parseKind(form.get("kind")); + const file = form.get("file"); + if (!(file instanceof File)) throw new Error("Missing upload file."); + const ext = MIME_TO_EXT[file.type]; + if (!ext || (kind === "logo" && ext === "ico")) { + throw new Error("Unsupported image type (use PNG, JPEG or SVG)."); + } + const bytes = Buffer.from(await file.arrayBuffer()); + if (bytes.length === 0) throw new Error("Upload is empty."); + if (bytes.length > MAX_BYTES) { + throw new Error("Image must be 512KB or smaller."); + } + const dir = brandingDir(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + for (const stale of fs.readdirSync(dir)) { + if (stale.startsWith(`${kind}.`)) fs.rmSync(path.join(dir, stale), { force: true }); + } + const name = `${kind}.${ext}`; + fs.writeFileSync(path.join(dir, name), bytes, { mode: 0o600 }); + const config = getSystemConfig(); + config.branding[kind === "logo" ? "logoFile" : "faviconFile"] = name; + saveSystemConfig(config); + const version = fs.statSync(path.join(dir, name)).mtimeMs; + return NextResponse.json({ file: name, version }, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} + +export async function DELETE(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + const kind = parseKind(new URL(request.url).searchParams.get("kind")); + const dir = brandingDir(); + if (fs.existsSync(dir)) { + for (const stale of fs.readdirSync(dir)) { + if (stale.startsWith(`${kind}.`)) fs.rmSync(path.join(dir, stale), { force: true }); + } + } + const config = getSystemConfig(); + config.branding[kind === "logo" ? "logoFile" : "faviconFile"] = null; + saveSystemConfig(config); + return NextResponse.json({ ok: true }, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/branding/routes.test.ts b/src/app/api/system/branding/routes.test.ts new file mode 100644 index 0000000..39fd5ed --- /dev/null +++ b/src/app/api/system/branding/routes.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-brand-route-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const branding = await import("./route"); +const asset = await import("./asset/[asset]/route"); +const { getSystemConfig, brandingDir } = await import("@/lib/server/systemConfig"); + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64" +); + +function upload(kind: string, body: Buffer | string, type: string) { + const form = new FormData(); + form.set("kind", kind); + form.set( + "file", + new File([typeof body === "string" ? body : new Uint8Array(body)], `test.${type.split("/")[1]}`, { type }) + ); + return branding.POST( + new Request("http://localhost/api/system/branding", { + method: "POST", + body: form, + }) as never + ); +} + +describe("branding routes", () => { + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("uploads a logo, serves it publicly, and reports a version", async () => { + const response = await upload("logo", PNG, "image/png"); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.file).toBe("logo.png"); + expect(body.version).toBeGreaterThan(0); + expect(getSystemConfig().branding.logoFile).toBe("logo.png"); + expect(fs.existsSync(path.join(brandingDir(), "logo.png"))).toBe(true); + + const served = await asset.GET( + new Request("http://localhost/api/system/branding/asset/logo") as never, + { params: Promise.resolve({ asset: "logo" }) } + ); + expect(served.status).toBe(200); + expect(served.headers.get("Content-Type")).toBe("image/png"); + expect(Buffer.from(await served.arrayBuffer())).toEqual(PNG); + }); + + it("rejects wrong mime and oversize uploads", async () => { + expect((await upload("logo", "plain text", "text/plain")).status).toBe(400); + const big = Buffer.alloc(600 * 1024, 1); + expect((await upload("logo", big, "image/png")).status).toBe(400); + }); + + it("404s for unknown asset names and missing files", async () => { + const bad = await asset.GET( + new Request("http://localhost/api/system/branding/asset/nope") as never, + { params: Promise.resolve({ asset: "nope" }) } + ); + expect(bad.status).toBe(404); + const missing = await asset.GET( + new Request("http://localhost/api/system/branding/asset/favicon") as never, + { params: Promise.resolve({ asset: "favicon" }) } + ); + expect(missing.status).toBe(404); + }); + + it("DELETE restores the default", async () => { + const response = await branding.DELETE( + new Request("http://localhost/api/system/branding?kind=logo", { + method: "DELETE", + }) as never + ); + expect(response.status).toBe(200); + expect(getSystemConfig().branding.logoFile).toBeNull(); + expect(fs.existsSync(path.join(brandingDir(), "logo.png"))).toBe(false); + }); +});