feat(webui): branding upload + public asset routes

This commit is contained in:
m4
2026-08-11 09:23:28 +08:00
parent e8f09f240e
commit 74516fd7c8
3 changed files with 237 additions and 0 deletions
@@ -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<string, string> = {
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",
},
});
}
+98
View File
@@ -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<string, string> = {
"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);
}
}
@@ -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);
});
});