branding image uploads for favicon, logo, og image plus server-side og injection

This commit is contained in:
2026-03-21 23:25:16 +02:00
parent 624cfe8192
commit cdb9e5d8ee
6 changed files with 489 additions and 25 deletions

View File

@@ -1,21 +1,66 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
import { existsSync, mkdirSync } from "node:fs";
import { writeFile, readFile, unlink, realpath } from "node:fs/promises";
import { resolve, extname, sep } from "node:path";
import { randomBytes } from "node:crypto";
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const UPLOAD_DIR = resolve(process.cwd(), "uploads", "branding");
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED_MIME = new Set([
"image/jpeg", "image/png", "image/webp", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon",
]);
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".svg", ".ico"]);
const MAGIC_BYTES: Record<string, number[][]> = {
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
"image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]],
"image/x-icon": [[0x00, 0x00, 0x01, 0x00]],
"image/vnd.microsoft.icon": [[0x00, 0x00, 0x01, 0x00]],
};
function validateMagicBytes(buf: Buffer, mime: string): boolean {
// SVG is text-based, skip magic byte check
if (mime === "image/svg+xml") return true;
const sigs = MAGIC_BYTES[mime];
if (!sigs) return false;
return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte));
}
const FIELD_MAP: Record<string, string> = {
logo: "logoUrl",
favicon: "faviconUrl",
ogImage: "ogImageUrl",
};
const MIME_MAP: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
};
const updateSchema = z.object({
appName: z.string().min(1).max(100).optional(),
logoUrl: z.string().url().max(500).nullable().optional(),
faviconUrl: z.string().url().max(500).nullable().optional(),
logoUrl: z.string().max(500).nullable().optional(),
faviconUrl: z.string().max(500).nullable().optional(),
accentColor: z.string().regex(HEX_COLOR).optional(),
headerFont: z.string().max(100).nullable().optional(),
bodyFont: z.string().max(100).nullable().optional(),
poweredByVisible: z.boolean().optional(),
customCss: z.string().max(10000).nullable().optional(),
ogDescription: z.string().max(500).nullable().optional(),
});
export default async function settingsRoutes(app: FastifyInstance) {
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
app.get(
"/site-settings",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
@@ -56,4 +101,165 @@ export default async function settingsRoutes(app: FastifyInstance) {
reply.send(settings);
}
);
// branding image upload
app.post(
"/admin/branding/upload",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const data = await req.file();
if (!data) { reply.status(400).send({ error: "No file" }); return; }
const field = (req.query as Record<string, string>).field;
if (!field || !FIELD_MAP[field]) {
reply.status(400).send({ error: "Invalid field" });
return;
}
const ext = extname(data.filename).toLowerCase();
if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) {
reply.status(400).send({ error: "Only jpg, png, webp, svg, ico images are allowed" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of data.file) {
size += chunk.length;
if (size > MAX_SIZE) {
reply.status(400).send({ error: "File too large (max 5MB)" });
return;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (!validateMagicBytes(buffer, data.mimetype)) {
reply.status(400).send({ error: "File content does not match declared type" });
return;
}
// delete old file if exists
const settings = await prisma.siteSettings.findUnique({ where: { id: "default" } });
const dbField = FIELD_MAP[field] as keyof typeof settings;
const oldPath = settings?.[dbField] as string | null | undefined;
if (oldPath) {
try {
const fullOld = resolve(UPLOAD_DIR, "..", oldPath);
const realOld = await realpath(fullOld);
const realUpload = await realpath(UPLOAD_DIR);
if (realOld.startsWith(realUpload + sep)) {
await unlink(realOld).catch(() => {});
}
} catch {}
}
const storedName = `${field}-${randomBytes(12).toString("hex")}${ext}`;
const filePath = resolve(UPLOAD_DIR, storedName);
if (!filePath.startsWith(UPLOAD_DIR)) {
reply.status(400).send({ error: "Invalid file path" });
return;
}
await writeFile(filePath, buffer);
const storedPath = `branding/${storedName}`;
await prisma.siteSettings.upsert({
where: { id: "default" },
create: { id: "default", [FIELD_MAP[field]]: storedPath },
update: { [FIELD_MAP[field]]: storedPath },
});
const serveField = field === "ogImage" ? "og-image" : field;
reply.send({ url: `/api/v1/branding/${serveField}` });
}
);
// delete branding image
app.delete<{ Params: { field: string } }>(
"/admin/branding/:field",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const rawField = req.params.field;
const field = rawField === "og-image" ? "ogImage" : rawField;
if (!FIELD_MAP[field]) {
reply.status(400).send({ error: "Invalid field" });
return;
}
const settings = await prisma.siteSettings.findUnique({ where: { id: "default" } });
const dbField = FIELD_MAP[field] as keyof typeof settings;
const oldPath = settings?.[dbField] as string | null | undefined;
if (oldPath) {
try {
const fullOld = resolve(UPLOAD_DIR, "..", oldPath);
const realOld = await realpath(fullOld);
const realUpload = await realpath(UPLOAD_DIR);
if (realOld.startsWith(realUpload + sep)) {
await unlink(realOld).catch(() => {});
}
} catch {}
}
await prisma.siteSettings.upsert({
where: { id: "default" },
create: { id: "default", [FIELD_MAP[field]]: null },
update: { [FIELD_MAP[field]]: null },
});
reply.status(204).send();
}
);
// serve branding images
const serveBranding = async (fieldKey: string, req: any, reply: any) => {
const settings = await prisma.siteSettings.findUnique({ where: { id: "default" } });
const dbField = FIELD_MAP[fieldKey] as keyof typeof settings;
const storedPath = settings?.[dbField] as string | null | undefined;
if (!storedPath) {
reply.status(404).send({ error: "Not set" });
return;
}
const filePath = resolve(UPLOAD_DIR, "..", storedPath);
let realFile: string;
try {
realFile = await realpath(filePath);
} catch {
reply.status(404).send({ error: "File missing" });
return;
}
const realUpload = await realpath(UPLOAD_DIR);
if (!realFile.startsWith(realUpload + sep)) {
reply.status(404).send({ error: "File missing" });
return;
}
const ext = extname(storedPath).toLowerCase();
const buf = await readFile(realFile);
reply
.header("Content-Type", MIME_MAP[ext] || "application/octet-stream")
.header("Content-Length", buf.length)
.header("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
.header("X-Content-Type-Options", "nosniff")
.send(buf);
};
app.get(
"/branding/logo",
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
async (req, reply) => serveBranding("logo", req, reply)
);
app.get(
"/branding/favicon",
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
async (req, reply) => serveBranding("favicon", req, reply)
);
app.get(
"/branding/og-image",
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
async (req, reply) => serveBranding("ogImage", req, reply)
);
}

View File

@@ -6,6 +6,7 @@ import fastifyStatic from "@fastify/static";
import multipart from "@fastify/multipart";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createHmac } from "node:crypto";
import { config } from "./config.js";
@@ -54,6 +55,75 @@ import adminTeamRoutes from "./routes/admin/team.js";
import adminPluginRoutes from "./routes/admin/plugins.js";
import pluginApiRoutes from "./routes/plugins-api.js";
const DEFAULT_OG_DESC = "Self-hosted feedback board. Anonymous by default, no email required. Vote on what matters, comment with markdown, track status changes.";
const DEFAULT_TWITTER_DESC = "Self-hosted feedback board. Anonymous by default, no email required.";
interface CachedSettings {
appName: string;
ogDescription: string | null;
ogImageUrl: string | null;
faviconUrl: string | null;
fetchedAt: number;
}
let settingsCache: CachedSettings | null = null;
const CACHE_TTL = 60_000;
async function getCachedSettings(): Promise<CachedSettings> {
if (settingsCache && Date.now() - settingsCache.fetchedAt < CACHE_TTL) {
return settingsCache;
}
const s = await prisma.siteSettings.findUnique({ where: { id: "default" } });
settingsCache = {
appName: s?.appName ?? "Echoboard",
ogDescription: s?.ogDescription ?? null,
ogImageUrl: s?.ogImageUrl ?? null,
faviconUrl: s?.faviconUrl ?? null,
fetchedAt: Date.now(),
};
return settingsCache;
}
function injectMeta(html: string, s: CachedSettings): string {
const name = s.appName;
const ogDesc = s.ogDescription || DEFAULT_OG_DESC;
const twitterDesc = s.ogDescription || DEFAULT_TWITTER_DESC;
const ogImg = s.ogImageUrl ? "/api/v1/branding/og-image" : "/icon-512.png";
const favicon = s.faviconUrl ? "/api/v1/branding/favicon" : "/favicon.ico";
const twitterCard = s.ogImageUrl ? "summary_large_image" : "summary";
let out = html;
out = out.replace(/<title>[^<]*<\/title>/, `<title>${escHtml(name)}</title>`);
out = out.replace(/(<meta\s+property="og:title"\s+content=")[^"]*"/, `$1${escAttr(name)}"`);
out = out.replace(/(<meta\s+property="og:description"\s+content=")[^"]*"/, `$1${escAttr(ogDesc)}"`);
out = out.replace(/(<meta\s+property="og:image"\s+content=")[^"]*"/, `$1${escAttr(ogImg)}"`);
out = out.replace(/(<meta\s+property="og:site_name"\s+content=")[^"]*"/, `$1${escAttr(name)}"`);
out = out.replace(/(<meta\s+name="twitter:card"\s+content=")[^"]*"/, `$1${escAttr(twitterCard)}"`);
out = out.replace(/(<meta\s+name="twitter:title"\s+content=")[^"]*"/, `$1${escAttr(name)}"`);
out = out.replace(/(<meta\s+name="twitter:description"\s+content=")[^"]*"/, `$1${escAttr(twitterDesc)}"`);
out = out.replace(/(<meta\s+name="twitter:image"\s+content=")[^"]*"/, `$1${escAttr(ogImg)}"`);
out = out.replace(/(<link\s+rel="icon"\s+href=")[^"]*("\s+sizes="any")/, `$1${escAttr(favicon)}$2`);
out = out.replace(/(<meta\s+name="description"\s+content=")[^"]*"/, `$1${escAttr(ogDesc)}"`);
// update og:image dimensions if custom image
if (s.ogImageUrl) {
out = out.replace(/(<meta\s+property="og:image:width"\s+content=")[^"]*"/, `$11200"`);
out = out.replace(/(<meta\s+property="og:image:height"\s+content=")[^"]*"/, `$1630"`);
out = out.replace(/(<meta\s+property="og:image:alt"\s+content=")[^"]*"/, `$1${escAttr(name)}"`);
out = out.replace(/(<meta\s+name="twitter:image:alt"\s+content=")[^"]*"/, `$1${escAttr(name)}"`);
}
return out;
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export async function createServer() {
const app = Fastify({
logger: {
@@ -172,13 +242,28 @@ export async function createServer() {
const webDistAlt = resolve(process.cwd(), "../web/dist");
const staticRoot = existsSync(webDist) ? webDist : existsSync(webDistAlt) ? webDistAlt : null;
if (process.env.NODE_ENV === "production" && staticRoot) {
// read index.html once at startup for meta injection
const indexPath = resolve(staticRoot, "index.html");
let indexHtml = "";
if (existsSync(indexPath)) {
indexHtml = await readFile(indexPath, "utf-8");
}
await app.register(fastifyStatic, {
root: staticRoot,
wildcard: false,
});
app.setNotFoundHandler((_req, reply) => {
reply.sendFile("index.html");
app.setNotFoundHandler(async (_req, reply) => {
if (!indexHtml) {
reply.sendFile("index.html");
return;
}
const s = await getCachedSettings();
const html = injectMeta(indexHtml, s);
reply
.header("Content-Type", "text/html; charset=utf-8")
.send(html);
});
}