diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 79af37b..92189ea 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -430,6 +430,8 @@ model SiteSettings { bodyFont String? poweredByVisible Boolean @default(true) customCss String? + ogImageUrl String? + ogDescription String? updatedAt DateTime @updatedAt } diff --git a/packages/api/src/routes/admin/settings.ts b/packages/api/src/routes/admin/settings.ts index aba94b1..7fa04ff 100644 --- a/packages/api/src/routes/admin/settings.ts +++ b/packages/api/src/routes/admin/settings.ts @@ -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 = { + "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 = { + logo: "logoUrl", + favicon: "faviconUrl", + ogImage: "ogImageUrl", +}; + +const MIME_MAP: Record = { + ".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).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) + ); } diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 5b4f342..f415814 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -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 { + 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>${escHtml(name)}`); + out = out.replace(/(/g, ">"); +} + +function escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + 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); }); } diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index 64cc774..b3f4fe3 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -182,7 +182,7 @@ export default function Sidebar() { }} > {logoUrl ? ( - {appName} + {appName} ) : ( appName )} @@ -201,7 +201,7 @@ export default function Sidebar() { }} > {logoUrl ? ( - {appName} + {appName} ) : ( appName.charAt(0) )} diff --git a/packages/web/src/hooks/useBranding.tsx b/packages/web/src/hooks/useBranding.tsx index 3ce1990..145fa48 100644 --- a/packages/web/src/hooks/useBranding.tsx +++ b/packages/web/src/hooks/useBranding.tsx @@ -9,6 +9,8 @@ interface SiteSettings { bodyFont: string | null poweredByVisible: boolean customCss: string | null + ogImageUrl: string | null + ogDescription: string | null } const defaults: SiteSettings = { @@ -20,6 +22,8 @@ const defaults: SiteSettings = { bodyFont: null, poweredByVisible: true, customCss: null, + ogImageUrl: null, + ogDescription: null, } const BrandingContext = createContext(defaults) @@ -59,7 +63,7 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) { link.rel = 'icon' document.head.appendChild(link) } - link.href = settings.faviconUrl + link.href = '/api/v1/branding/favicon' } }, [settings.faviconUrl]) diff --git a/packages/web/src/pages/admin/AdminSettings.tsx b/packages/web/src/pages/admin/AdminSettings.tsx index 52f3e57..b06a65d 100644 --- a/packages/web/src/pages/admin/AdminSettings.tsx +++ b/packages/web/src/pages/admin/AdminSettings.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Link } from 'react-router-dom' import { api } from '../../lib/api' import { useDocumentTitle } from '../../hooks/useDocumentTitle' import { useToast } from '../../hooks/useToast' +import { IconUpload, IconTrash } from '@tabler/icons-react' interface SiteSettings { appName: string @@ -13,6 +14,8 @@ interface SiteSettings { bodyFont: string | null poweredByVisible: boolean customCss: string | null + ogImageUrl: string | null + ogDescription: string | null } const defaults: SiteSettings = { @@ -24,6 +27,137 @@ const defaults: SiteSettings = { bodyFont: null, poweredByVisible: true, customCss: null, + ogImageUrl: null, + ogDescription: null, +} + +interface BrandingUploadProps { + label: string + field: string + current: string | null + accept: string + hint: string + onUploaded: (url: string) => void + onRemoved: () => void +} + +function BrandingUpload({ label, field, current, accept, hint, onUploaded, onRemoved }: BrandingUploadProps) { + const inputRef = useRef(null) + const [uploading, setUploading] = useState(false) + const [removing, setRemoving] = useState(false) + const toast = useToast() + + const serveField = field === 'ogImage' ? 'og-image' : field + const previewUrl = current ? `/api/v1/branding/${serveField}?t=${Date.now()}` : null + + const upload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file || file.size > 5 * 1024 * 1024) { + if (file) toast.error('File too large (max 5MB)') + return + } + setUploading(true) + try { + const form = new FormData() + form.append('file', file) + const res = await fetch(`/api/v1/admin/branding/upload?field=${field}`, { + method: 'POST', + body: form, + credentials: 'include', + }) + if (res.ok) { + const data = await res.json() + onUploaded(data.url) + toast.success(`${label} uploaded`) + } else { + const err = await res.json().catch(() => ({ error: 'Upload failed' })) + toast.error(err.error || 'Upload failed') + } + } catch { + toast.error('Upload failed') + } finally { + setUploading(false) + if (inputRef.current) inputRef.current.value = '' + } + } + + const remove = async () => { + setRemoving(true) + try { + await fetch(`/api/v1/admin/branding/${serveField}`, { + method: 'DELETE', + credentials: 'include', + }) + onRemoved() + toast.success(`${label} removed`) + } catch { + toast.error('Failed to remove') + } finally { + setRemoving(false) + } + } + + return ( +
+ +
+ {previewUrl && ( + {label} + )} +
+ + + {current && ( + + )} + {hint} +
+
+
+ ) } export default function AdminSettings() { @@ -45,7 +179,15 @@ export default function AdminSettings() { setSaving(true) setSaved(false) try { - await api.put('/admin/site-settings', form) + await api.put('/admin/site-settings', { + appName: form.appName, + accentColor: form.accentColor, + headerFont: form.headerFont, + bodyFont: form.bodyFont, + poweredByVisible: form.poweredByVisible, + customCss: form.customCss, + ogDescription: form.ogDescription, + }) setSaved(true) setTimeout(() => setSaved(false), 2000) toast.success('Branding saved') @@ -92,26 +234,51 @@ export default function AdminSettings() { /> -
- - setForm(f => ({ ...f, logoUrl: e.target.value || null }))} - placeholder="https://example.com/logo.svg" - /> -
+ setForm(f => ({ ...f, logoUrl: url }))} + onRemoved={() => setForm(f => ({ ...f, logoUrl: null }))} + /> + + setForm(f => ({ ...f, faviconUrl: url }))} + onRemoved={() => setForm(f => ({ ...f, faviconUrl: null }))} + /> + + setForm(f => ({ ...f, ogImageUrl: url }))} + onRemoved={() => setForm(f => ({ ...f, ogImageUrl: null }))} + />
- - OG description +