branding image uploads for favicon, logo, og image plus server-side og injection
This commit is contained in:
@@ -430,6 +430,8 @@ model SiteSettings {
|
|||||||
bodyFont String?
|
bodyFont String?
|
||||||
poweredByVisible Boolean @default(true)
|
poweredByVisible Boolean @default(true)
|
||||||
customCss String?
|
customCss String?
|
||||||
|
ogImageUrl String?
|
||||||
|
ogDescription String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,66 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "../../lib/prisma.js";
|
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 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({
|
const updateSchema = z.object({
|
||||||
appName: z.string().min(1).max(100).optional(),
|
appName: z.string().min(1).max(100).optional(),
|
||||||
logoUrl: z.string().url().max(500).nullable().optional(),
|
logoUrl: z.string().max(500).nullable().optional(),
|
||||||
faviconUrl: z.string().url().max(500).nullable().optional(),
|
faviconUrl: z.string().max(500).nullable().optional(),
|
||||||
accentColor: z.string().regex(HEX_COLOR).optional(),
|
accentColor: z.string().regex(HEX_COLOR).optional(),
|
||||||
headerFont: z.string().max(100).nullable().optional(),
|
headerFont: z.string().max(100).nullable().optional(),
|
||||||
bodyFont: z.string().max(100).nullable().optional(),
|
bodyFont: z.string().max(100).nullable().optional(),
|
||||||
poweredByVisible: z.boolean().optional(),
|
poweredByVisible: z.boolean().optional(),
|
||||||
customCss: z.string().max(10000).nullable().optional(),
|
customCss: z.string().max(10000).nullable().optional(),
|
||||||
|
ogDescription: z.string().max(500).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function settingsRoutes(app: FastifyInstance) {
|
export default async function settingsRoutes(app: FastifyInstance) {
|
||||||
|
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/site-settings",
|
"/site-settings",
|
||||||
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||||
@@ -56,4 +101,165 @@ export default async function settingsRoutes(app: FastifyInstance) {
|
|||||||
reply.send(settings);
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import fastifyStatic from "@fastify/static";
|
|||||||
import multipart from "@fastify/multipart";
|
import multipart from "@fastify/multipart";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
@@ -54,6 +55,75 @@ import adminTeamRoutes from "./routes/admin/team.js";
|
|||||||
import adminPluginRoutes from "./routes/admin/plugins.js";
|
import adminPluginRoutes from "./routes/admin/plugins.js";
|
||||||
import pluginApiRoutes from "./routes/plugins-api.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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
export async function createServer() {
|
export async function createServer() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -172,13 +242,28 @@ export async function createServer() {
|
|||||||
const webDistAlt = resolve(process.cwd(), "../web/dist");
|
const webDistAlt = resolve(process.cwd(), "../web/dist");
|
||||||
const staticRoot = existsSync(webDist) ? webDist : existsSync(webDistAlt) ? webDistAlt : null;
|
const staticRoot = existsSync(webDist) ? webDist : existsSync(webDistAlt) ? webDistAlt : null;
|
||||||
if (process.env.NODE_ENV === "production" && staticRoot) {
|
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, {
|
await app.register(fastifyStatic, {
|
||||||
root: staticRoot,
|
root: staticRoot,
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setNotFoundHandler((_req, reply) => {
|
app.setNotFoundHandler(async (_req, reply) => {
|
||||||
reply.sendFile("index.html");
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export default function Sidebar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt={appName} style={{ height: 24, objectFit: 'contain' }} />
|
<img src="/api/v1/branding/logo" alt={appName} style={{ height: 24, objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
appName
|
appName
|
||||||
)}
|
)}
|
||||||
@@ -201,7 +201,7 @@ export default function Sidebar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt={appName} style={{ height: 20, objectFit: 'contain' }} />
|
<img src="/api/v1/branding/logo" alt={appName} style={{ height: 20, objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
appName.charAt(0)
|
appName.charAt(0)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface SiteSettings {
|
|||||||
bodyFont: string | null
|
bodyFont: string | null
|
||||||
poweredByVisible: boolean
|
poweredByVisible: boolean
|
||||||
customCss: string | null
|
customCss: string | null
|
||||||
|
ogImageUrl: string | null
|
||||||
|
ogDescription: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: SiteSettings = {
|
const defaults: SiteSettings = {
|
||||||
@@ -20,6 +22,8 @@ const defaults: SiteSettings = {
|
|||||||
bodyFont: null,
|
bodyFont: null,
|
||||||
poweredByVisible: true,
|
poweredByVisible: true,
|
||||||
customCss: null,
|
customCss: null,
|
||||||
|
ogImageUrl: null,
|
||||||
|
ogDescription: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const BrandingContext = createContext<SiteSettings>(defaults)
|
const BrandingContext = createContext<SiteSettings>(defaults)
|
||||||
@@ -59,7 +63,7 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) {
|
|||||||
link.rel = 'icon'
|
link.rel = 'icon'
|
||||||
document.head.appendChild(link)
|
document.head.appendChild(link)
|
||||||
}
|
}
|
||||||
link.href = settings.faviconUrl
|
link.href = '/api/v1/branding/favicon'
|
||||||
}
|
}
|
||||||
}, [settings.faviconUrl])
|
}, [settings.faviconUrl])
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
import { useToast } from '../../hooks/useToast'
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
import { IconUpload, IconTrash } from '@tabler/icons-react'
|
||||||
|
|
||||||
interface SiteSettings {
|
interface SiteSettings {
|
||||||
appName: string
|
appName: string
|
||||||
@@ -13,6 +14,8 @@ interface SiteSettings {
|
|||||||
bodyFont: string | null
|
bodyFont: string | null
|
||||||
poweredByVisible: boolean
|
poweredByVisible: boolean
|
||||||
customCss: string | null
|
customCss: string | null
|
||||||
|
ogImageUrl: string | null
|
||||||
|
ogDescription: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: SiteSettings = {
|
const defaults: SiteSettings = {
|
||||||
@@ -24,6 +27,137 @@ const defaults: SiteSettings = {
|
|||||||
bodyFont: null,
|
bodyFont: null,
|
||||||
poweredByVisible: true,
|
poweredByVisible: true,
|
||||||
customCss: null,
|
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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>{label}</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{previewUrl && (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={label}
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={upload}
|
||||||
|
className="sr-only"
|
||||||
|
id={`branding-upload-${field}`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`branding-upload-${field}`}
|
||||||
|
className="btn btn-secondary inline-flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
cursor: uploading ? 'wait' : 'pointer',
|
||||||
|
opacity: uploading ? 0.6 : 1,
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
padding: '6px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconUpload size={14} stroke={2} />
|
||||||
|
{uploading ? 'Uploading...' : current ? 'Replace' : 'Upload'}
|
||||||
|
</label>
|
||||||
|
{current && (
|
||||||
|
<button
|
||||||
|
onClick={remove}
|
||||||
|
disabled={removing}
|
||||||
|
className="action-btn inline-flex items-center gap-1"
|
||||||
|
style={{
|
||||||
|
color: 'var(--error)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
opacity: removing ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconTrash size={12} stroke={2} /> Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{hint}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
@@ -45,7 +179,15 @@ export default function AdminSettings() {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
try {
|
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)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
toast.success('Branding saved')
|
toast.success('Branding saved')
|
||||||
@@ -92,26 +234,51 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<BrandingUpload
|
||||||
<label htmlFor="settings-logo-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Logo URL</label>
|
label="Logo"
|
||||||
<input
|
field="logo"
|
||||||
id="settings-logo-url"
|
current={form.logoUrl}
|
||||||
className="input"
|
accept="image/jpeg,image/png,image/webp,image/svg+xml"
|
||||||
value={form.logoUrl || ''}
|
hint="JPG, PNG, WebP or SVG. Max 5MB."
|
||||||
onChange={e => setForm(f => ({ ...f, logoUrl: e.target.value || null }))}
|
onUploaded={(url) => setForm(f => ({ ...f, logoUrl: url }))}
|
||||||
placeholder="https://example.com/logo.svg"
|
onRemoved={() => setForm(f => ({ ...f, logoUrl: null }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
<BrandingUpload
|
||||||
|
label="Favicon"
|
||||||
|
field="favicon"
|
||||||
|
current={form.faviconUrl}
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/svg+xml,image/x-icon,image/vnd.microsoft.icon"
|
||||||
|
hint="ICO, PNG, SVG or WebP. Max 5MB."
|
||||||
|
onUploaded={(url) => setForm(f => ({ ...f, faviconUrl: url }))}
|
||||||
|
onRemoved={() => setForm(f => ({ ...f, faviconUrl: null }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BrandingUpload
|
||||||
|
label="OG image"
|
||||||
|
field="ogImage"
|
||||||
|
current={form.ogImageUrl}
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
hint="Recommended 1200x630px for best preview on social media. Max 5MB."
|
||||||
|
onUploaded={(url) => setForm(f => ({ ...f, ogImageUrl: url }))}
|
||||||
|
onRemoved={() => setForm(f => ({ ...f, ogImageUrl: null }))}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="settings-favicon-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Favicon URL</label>
|
<label htmlFor="settings-og-description" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>OG description</label>
|
||||||
<input
|
<textarea
|
||||||
id="settings-favicon-url"
|
id="settings-og-description"
|
||||||
className="input"
|
className="input"
|
||||||
value={form.faviconUrl || ''}
|
rows={3}
|
||||||
onChange={e => setForm(f => ({ ...f, faviconUrl: e.target.value || null }))}
|
value={form.ogDescription || ''}
|
||||||
placeholder="https://example.com/favicon.ico"
|
onChange={e => setForm(f => ({ ...f, ogDescription: e.target.value || null }))}
|
||||||
|
placeholder="Shown when your site is shared on social media, messaging apps, etc."
|
||||||
|
style={{ resize: 'vertical', fontSize: 'var(--text-sm)' }}
|
||||||
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2, display: 'block' }}>
|
||||||
|
Shown when your site is shared on social media, messaging apps, etc.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user