feat(admin): Logo und Favicon per Bild-Upload austauschbar

Neue Gruppe "Logo und Favicon" unter Admin > Inhalte mit drei Bildfeldern:
Logo (Kopfzeile), optionales Logo fuer dunkle Flaechen (Mobilmenue,
Fusszeile, Admin-Leiste; faellt sonst auf das normale Logo zurueck) und
Favicon. Leer bleibt alles wie bisher (gezeichnetes Waben-Zeichen bzw.
Standard-Icon).

- lib/brand.ts liest die Pfade und prueft sie erneut gegen das
  Upload-Muster, bevor sie in ein src-Attribut gelangen.
- Statische app/icon.svg und app/favicon.ico ersetzt durch dynamische
  app/icon.tsx (512 px PNG) und app/apple-icon.tsx (180 px auf Creme);
  lib/brand-icon.ts rendert Upload oder Standard-Icon per sharp und liest
  Dateien nur ueber resolveUploadPath. Cache-Control no-cache, damit ein
  neues Favicon ohne Tage Verzoegerung erscheint.
- /favicon.ico per Rewrite auf /icon, Manifest nutzt /icon.
- Hochgeladenes Logo mit fester Hoehe und begrenzter Breite (Kopfzeile
  mobil 56 px, ab sm 112 px), damit breite Wortmarken nichts sprengen.

Uploads bleiben auf Rasterbilder beschraenkt (kein SVG, XSS-Schutz).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 01:02:18 +02:00
1 parent ae2726b0ee
commit 4f442ec06d
19 files changed
+217 -77

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import { readFile } from "node:fs/promises";
import sharp from "sharp";
import { isValidUploadPath, resolveUploadPath } from "@/lib/uploads";
/**
* Standard-Icon (Waben-Rahmen mit drei Tannen auf Waldgruen) — frueher als
* statische app/icon.svg ausgeliefert. Jetzt Quelle fuer app/icon.tsx und
* app/apple-icon.tsx, solange im Admin kein eigenes Favicon hochgeladen ist.
*/
const DEFAULT_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#24352B" />
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" stroke-width="4" />
<g fill="#F4EFE5">
<g transform="translate(26,42) scale(0.62)">
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
</g>
<g transform="translate(40,30) scale(0.82)">
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
</g>
<g transform="translate(56,42) scale(0.62)">
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
</g>
</g>
</svg>`;
async function readUploadedIcon(faviconPath: string | null): Promise<Buffer | null> {
if (!faviconPath || !isValidUploadPath(faviconPath)) return null;
// resolveUploadPath haelt den Zugriff innerhalb von UPLOAD_DIR.
const file = resolveUploadPath(faviconPath.replace(/^\/uploads\//, "").split("/"));
if (!file) return null;
try {
return await readFile(file);
} catch {
// Datei fehlt (z.B. Upload-Volume nicht persistent): bewusst kein Fehler,
// der Browser bekaeme sonst gar kein Icon — das Standard-Icon springt ein.
// Das Dashboard meldet fehlende Upload-Dateien ohnehin gesondert.
return null;
}
}
/**
* Quadratisches PNG in `size` Pixeln. Nicht quadratische Uploads werden
* eingepasst (nicht beschnitten), der Rand bleibt transparent — ausser
* `background` ist gesetzt (Apple-Touch-Icon: iOS zeigt Transparenz schwarz).
*/
export async function renderBrandIcon(
faviconPath: string | null,
size: number,
background?: string
): Promise<Buffer> {
const uploaded = await readUploadedIcon(faviconPath);
const input = uploaded ?? Buffer.from(DEFAULT_ICON_SVG);
const image = sharp(input, { density: 384 }).resize(size, size, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
});
return (background ? image.flatten({ background }) : image).png().toBuffer();
}
+35
View File
@@ -0,0 +1,35 @@
import { getContentValues } from "@/lib/content";
import { isValidUploadPath } from "@/lib/uploads";
export interface BrandAssets {
/** Logo fuer helle Flaechen (Kopfzeile); null = gezeichnetes Waben-Zeichen. */
logo: string | null;
/** Logo fuer dunkle Flaechen (Mobilmenue, Fusszeile, Admin-Leiste); faellt auf `logo` zurueck. */
logoInverted: string | null;
/** Quadratisches Favicon-Bild; null = Standard-Icon (siehe lib/brand-icon.ts). */
favicon: string | null;
}
export const BRAND_CONTENT_KEYS = ["brand.logo", "brand.logoInverted", "brand.favicon"] as const;
/**
* Hochgeladene Marken-Bilder aus den Inhalten (Admin > Inhalte > Logo und
* Favicon). Jeder Wert wird hier noch einmal gegen das Upload-Pfadmuster
* geprueft, obwohl updateSiteContentAction das beim Speichern schon tut:
* der Pfad landet ungefiltert in einem src-Attribut auf jeder Seite, ein
* irgendwie in die Datenbank geratener fremder Wert (z.B. eine externe URL)
* soll dort nie ankommen.
*/
export async function getBrandAssets(): Promise<BrandAssets> {
// Nicht lokalisierte Felder — die Locale spielt fuer den Wert keine Rolle.
const values = await getContentValues([...BRAND_CONTENT_KEYS], "de");
const pick = (value: string | undefined): string | null =>
value && isValidUploadPath(value) ? value : null;
const logo = pick(values["brand.logo"]);
return {
logo,
logoInverted: pick(values["brand.logoInverted"]) ?? logo,
favicon: pick(values["brand.favicon"]),
};
}
+9 -1
View File
@@ -18,7 +18,7 @@ export type ContentFieldType =
export interface ContentFieldDef {
key: string;
group: "company" | "shipping" | "home" | "pages" | "legal" | "social";
group: "brand" | "company" | "shipping" | "home" | "pages" | "legal" | "social";
labelDe: string;
labelEn: string;
type: ContentFieldType;
@@ -54,6 +54,14 @@ function joinSections(sections: LegalSectionContent[]): string {
}
export const CONTENT_REGISTRY: ContentFieldDef[] = [
// --- Logo und Favicon (nicht lokalisiert). Leer = das gezeichnete
// Waben-Zeichen (components/logomark.tsx) bzw. das Standard-Icon
// (lib/brand-icon.ts). Nur Rasterbilder: der Upload nimmt bewusst kein SVG
// an (ein SVG kann Skripte enthalten). Gelesen ueber lib/brand.ts.
{ key: "brand.logo", group: "brand", labelDe: "Logo (PNG mit transparentem Hintergrund, erscheint neben dem Firmennamen)", labelEn: "Logo (PNG with transparent background, shown next to the company name)", type: "image", localized: false, getDefault: () => "" },
{ key: "brand.logoInverted", group: "brand", labelDe: "Logo für dunkle Flächen (optional: Mobilmenü, Fußzeile, Admin-Leiste; leer = normales Logo)", labelEn: "Logo for dark backgrounds (optional: mobile menu, footer, admin sidebar; empty = regular logo)", type: "image", localized: false, getDefault: () => "" },
{ key: "brand.favicon", group: "brand", labelDe: "Favicon (quadratisch, mindestens 512 × 512 px; leer = Standard-Wabe)", labelEn: "Favicon (square, at least 512 × 512 px; empty = default honeycomb)", type: "image", localized: false, getDefault: () => "" },
// --- Firmendaten (nicht lokalisiert) ---
{ key: "company.name", group: "company", labelDe: "Firmenname", labelEn: "Company name", type: "text", localized: false, getDefault: () => siteConfig.name },
{ key: "company.claim", group: "company", labelDe: "Slogan", labelEn: "Tagline", type: "text", localized: false, getDefault: () => siteConfig.claim },