branding image uploads for favicon, logo, og image plus server-side og injection
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user