feat(webui): branding upload + public asset routes
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user