From 5c5f2dd3de786d78d4777206a6174e88c79e1267 Mon Sep 17 00:00:00 2001 From: soraefir Date: Mon, 8 Jun 2026 22:11:17 +0200 Subject: [PATCH] fix favicon --- modules/server/containers/apps/favicon.nix | 130 +++++---------------- 1 file changed, 30 insertions(+), 100 deletions(-) diff --git a/modules/server/containers/apps/favicon.nix b/modules/server/containers/apps/favicon.nix index 78b19e7..482fa3a 100644 --- a/modules/server/containers/apps/favicon.nix +++ b/modules/server/containers/apps/favicon.nix @@ -5,7 +5,6 @@ let palette = serverCfg.colorScheme.palette or { }; port = 8080; assetSize = 64; - cacheControl = containerCfg.extra.cacheControl or "public, max-age=86400"; priority = toString (containerCfg.extra.priority or 2147482647); logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg); logoSvgMount = "/assets/${logoSvgFileName}"; @@ -64,46 +63,29 @@ let + "|apple-touch-icon(-precomposed)?\\.png" + "|android-chrome-[0-9]+x[0-9]+\\.png" + "|mstile-[0-9]+x[0-9]+\\.png)$"; - configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON { - inherit cacheControl; - inherit borderRadius; - domain = serverCfg.domain; - mappings = hostMappings; - default = defaultProfile; - }); pythonEnv = pkgs.python3.withPackages (ps: with ps; [ cairosvg pillow ]); serverScript = pkgs.writeText "favicon-server.py" '' - import hashlib from io import BytesIO - import json - import os import re from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - from pathlib import Path import cairosvg from PIL import Image, ImageDraw - CONFIG_PATH = os.environ.get("FAVICON_CONFIG", "/config/config.json") - LOGO_PATH = os.environ.get("FAVICON_LOGO", "/assets/logo.svg") - CACHE_DIR = Path(os.environ.get("FAVICON_CACHE_DIR", "/cache")) - LISTEN_HOST = os.environ.get("FAVICON_LISTEN_HOST", "0.0.0.0") - LISTEN_PORT = int(os.environ.get("FAVICON_PORT", "8080")) - ASSET_SIZE = int(os.environ.get("FAVICON_ASSET_SIZE", "${toString assetSize}")) - DEFAULT_CACHE_CONTROL = "public, max-age=86400" + LOGO_PATH = ${builtins.toJSON logoSvgMount} + LISTEN_HOST = "0.0.0.0" + LISTEN_PORT = ${toString port} + ASSET_SIZE = ${toString assetSize} + CACHE_CONTROL = "no-store" - with open(CONFIG_PATH, "r", encoding="utf-8") as fh: - APP_CONFIG = json.load(fh) with open(LOGO_PATH, "rb") as fh: LOGO_BYTES = fh.read() - MAPPINGS = APP_CONFIG.get("mappings", {}) - DEFAULT_PROFILE = APP_CONFIG.get("default") - APP_DOMAIN = (APP_CONFIG.get("domain", "") or "").strip().lower() - CACHE_CONTROL = APP_CONFIG.get("cacheControl", DEFAULT_CACHE_CONTROL) - LOGO_HASH = hashlib.sha256(LOGO_BYTES).hexdigest() + MAPPINGS = ${builtins.toJSON hostMappings} + DEFAULT_PROFILE = ${if defaultProfile == null then "None" else builtins.toJSON defaultProfile} + APP_DOMAIN = (${builtins.toJSON serverCfg.domain} or "").strip().lower() DEFAULT_COLORS = {"bg": "#111827", "fg": "#f8fafc"} def _request_host(headers): @@ -138,30 +120,15 @@ let return candidate, profile return None, DEFAULT_PROFILE - def _replace_svg_color(svg, attribute, color): - if attribute not in {"fill", "stroke"}: - return svg - - svg = re.sub( - rf'({attribute}\\s*=\\s*")(?!none\\b)[^"]*(")', - rf"\\1{color}\\2", - svg, - flags=re.IGNORECASE, - ) - svg = re.sub( - rf"({attribute}\\s*=\\s*')(?!none\\b)[^']*(')", - rf"\\1{color}\\2", - svg, - flags=re.IGNORECASE, - ) + def _replace_logo_fill(svg, color): return re.sub( - rf"({attribute}\\s*:\\s*)(?!none\\b)[^;\"']+", - rf"\\1{color}", + r"fill:\\s*#3193f5\\b", + f"fill:{color}", svg, flags=re.IGNORECASE, ) - border_radius = str(APP_CONFIG.get("borderRadius", "8")).strip() + border_radius = str(${builtins.toJSON borderRadius}).strip() if not border_radius.endswith("px"): border_radius = f"{border_radius}px" border_radius_px = max(0, int(float(border_radius[:-2]))) @@ -173,34 +140,17 @@ let "fg": profile.get("fg") or profile.get("foreground") or DEFAULT_COLORS["fg"], } - def _asset_path(host, colors): - payload = json.dumps( - { - "asset_size": ASSET_SIZE, - "bg": colors["bg"], - "border_radius": border_radius, - "fg": colors["fg"], - "host": host, - "logo_hash": LOGO_HASH, - }, - sort_keys=True, - separators=(",", ":"), - ) - safe_host = re.sub(r"[^a-z0-9.-]+", "_", host or "default") - digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] - return CACHE_DIR / f"{safe_host}-{digest}.ico" - - def _render_icon(colors, target): + def _render_icon(colors): svg = LOGO_BYTES.decode("utf-8") - for attribute in ("fill", "stroke"): - svg = _replace_svg_color(svg, attribute, colors["fg"]) + svg = _replace_logo_fill(svg, colors["fg"]) + print(f"favicon-render fg={colors['fg']} bg={colors['bg']}") logo_png = cairosvg.svg2png( bytestring=svg.encode("utf-8"), output_width=ASSET_SIZE, output_height=ASSET_SIZE, ) - tmp_target = target.with_suffix(".tmp") + output = BytesIO() with Image.new("RGBA", (ASSET_SIZE, ASSET_SIZE), (0, 0, 0, 0)) as canvas: ImageDraw.Draw(canvas).rounded_rectangle( (0, 0, ASSET_SIZE, ASSET_SIZE), @@ -209,8 +159,8 @@ let ) with Image.open(BytesIO(logo_png)) as logo: canvas.alpha_composite(logo.convert("RGBA")) - canvas.save(tmp_target, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)]) - os.replace(tmp_target, target) + canvas.save(output, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)]) + return output.getvalue() class Handler(BaseHTTPRequestHandler): server_version = "favicon-router/1.0" @@ -218,31 +168,19 @@ let def _serve(self, include_body): host = _request_host(self.headers) matched_host, profile = _profile_for_host(host) + print(f"favicon-request host={host!r} matched={matched_host!r} include_body={include_body}") if not profile: + print("favicon-request no-profile") self.send_error(404, "No favicon mapping for host") return colors = _colors(profile) - asset_path = _asset_path(host, colors) - if not asset_path.exists(): - CACHE_DIR.mkdir(parents=True, exist_ok=True) - _render_icon(colors, asset_path) - etag = f'"{asset_path.stem.rsplit("-", 1)[-1]}"' - if self.headers.get("If-None-Match") == etag: - self.send_response(304) - self.send_header("ETag", etag) - self.send_header("Cache-Control", CACHE_CONTROL) - self.send_header("X-Favicon-Host", host or "default") - self.send_header("X-Favicon-Mapping", matched_host or "default") - self.end_headers() - return - - payload = asset_path.read_bytes() + print(f"favicon-colors bg={colors['bg']} fg={colors['fg']}") + payload = _render_icon(colors) self.send_response(200) self.send_header("Content-Type", "image/x-icon") self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", CACHE_CONTROL) - self.send_header("ETag", etag) self.send_header("X-Favicon-Host", host or "default") self.send_header("X-Favicon-Mapping", matched_host or "default") self.end_headers() @@ -278,25 +216,18 @@ let }; in { runtime = { - paths = [ - { - path = "${serverCfg.path.config.path}/favicon"; - mode = "0755"; - dirs = [ "cache" ]; - } - ]; + # paths = [ + # { + # path = "${serverCfg.path.config.path}/favicon"; + # mode = "0755"; + # dirs = [ "cache" ]; + # } + # ]; containers = { server = builder.mkContainer { imageStream = image; port = port; - extraEnv = { - FAVICON_CONFIG = "/config/config.json"; - FAVICON_LOGO = logoSvgMount; - FAVICON_CACHE_DIR = "/cache"; - FAVICON_PORT = toString port; - FAVICON_ASSET_SIZE = toString assetSize; - }; extraLabels = { "traefik.enable" = "true"; "traefik.http.routers.${name}.entrypoints" = "web-secure"; @@ -307,8 +238,7 @@ in { }; overrides = { volumes = [ - "${configFile}:/config/config.json:ro" - "${serverCfg.path.config.path}/favicon/cache:/cache" + # "${serverCfg.path.config.path}/favicon/cache:/cache" "${mediaCfg.logo.svg}:${logoSvgMount}:ro" ]; };