{ config, containerCfg, pkgs, lib, builder, name, ... }: let serverCfg = config.syscfg.server; mediaCfg = config.syscfg.media; port = 8080; 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}"; hostMappings = lib.mapAttrs' (mapping: profile: lib.nameValuePair (if lib.hasInfix "." mapping then mapping else "${mapping}.${serverCfg.domain}") profile ) (containerCfg.extra.mappings or {}); traefikAssetPathRegexp = "^/(.*/)?" + "(favicon(-[0-9]+x[0-9]+)?\\.(ico|png|svg)" + "|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; mappings = hostMappings; default = containerCfg.extra.default or null; }); pythonEnv = pkgs.python3.withPackages (ps: with ps; [ cairosvg pillow ]); serverScript = pkgs.writeText "favicon-server.py" '' import base64 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 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")) DEFAULT_CACHE_CONTROL = "public, max-age=86400" 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() LOGO_HASH = hashlib.sha256(LOGO_BYTES).hexdigest() def _normalize_host(host): return (host or "").split(":", 1)[0].strip().lower() def _pick_profile(host): mappings = APP_CONFIG.get("mappings", {}) return mappings.get(host) or APP_CONFIG.get("default") def _color(value, fallback): return value if isinstance(value, str) and value else fallback def _badge_text(profile): badge = profile.get("icon") or profile.get("label") or profile.get("text") or "" return str(badge).strip() def _tinted_logo_data_uri(color): svg = LOGO_BYTES.decode("utf-8") svg = re.sub( r'fill="(?!none\b)[^"]*"', f'fill="{color}"', svg, flags=re.IGNORECASE, ) svg = re.sub( r"fill='(?!none\b)[^']*'", f"fill='{color}'", svg, flags=re.IGNORECASE, ) svg = re.sub( r'stroke="(?!none\b)[^"]*"', f'stroke="{color}"', svg, flags=re.IGNORECASE, ) svg = re.sub( r"stroke='(?!none\b)[^']*'", f"stroke='{color}'", svg, flags=re.IGNORECASE, ) return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii") def _render_svg(profile): bg = _color(profile.get("background"), "#111827") fg = _color(profile.get("foreground"), "#f8fafc") accent = _color(profile.get("accent"), "#38bdf8") badge = _badge_text(profile) logo_data_uri = _tinted_logo_data_uri(fg) canvas = 256 circle_radius = 120 badge_svg = "" if badge: badge_svg = f""" {badge} """ return f""" {badge_svg} """ def _cache_key(host, profile): payload = json.dumps( {"host": host, "profile": profile, "logo": LOGO_HASH}, sort_keys=True, ).encode("utf-8") return hashlib.sha256(payload).hexdigest() def _generate_asset(host, profile): cache_name = f"{_cache_key(host, profile)}.ico" target = CACHE_DIR / cache_name if target.exists(): return target CACHE_DIR.mkdir(parents=True, exist_ok=True) svg = _render_svg(profile).encode("utf-8") png_bytes = cairosvg.svg2png(bytestring=svg, output_width=64, output_height=64) image = Image.open(BytesIO(png_bytes)) image.save(target, format="ICO", sizes=[(64, 64)]) image.close() return target class Handler(BaseHTTPRequestHandler): server_version = "favicon-router/1.0" def _serve(self, include_body): host = _normalize_host(self.headers.get("Host", "")) profile = _pick_profile(host) if not profile: self.send_error(404, "No favicon mapping for host") return asset_path = _generate_asset(host, profile) payload = asset_path.read_bytes() 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", APP_CONFIG.get("cacheControl", DEFAULT_CACHE_CONTROL)) self.end_headers() if include_body: self.wfile.write(payload) def do_GET(self): self._serve(include_body=True) def do_HEAD(self): self._serve(include_body=False) def log_message(self, fmt, *args): print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), fmt % args)) if __name__ == "__main__": httpd = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) httpd.serve_forever() ''; image = pkgs.dockerTools.streamLayeredImage { name = "favicon"; tag = "1"; contents = [ pythonEnv pkgs.fontconfig pkgs.dejavu_fonts pkgs.cacert pkgs.tzdata ]; config = { Entrypoint = [ "${pythonEnv}/bin/python3" serverScript ]; ExposedPorts = { "${toString port}/tcp" = { }; }; WorkingDir = "/"; }; }; in { runtime = { paths = [ { path = "${serverCfg.path.config}/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; FONTCONFIG_FILE = "${pkgs.fontconfig.out}/etc/fonts/fonts.conf"; FONTCONFIG_PATH = "${pkgs.fontconfig.out}/etc/fonts"; }; extraLabels = { "traefik.enable" = "true"; "traefik.http.routers.${name}.entrypoints" = "web-secure"; "traefik.http.routers.${name}.rule" = "PathRegexp(`${traefikAssetPathRegexp}`)"; "traefik.http.routers.${name}.priority" = priority; "traefik.http.routers.${name}.tls" = "true"; "traefik.http.services.${name}.loadbalancer.server.port" = toString port; }; overrides = { volumes = [ "${configFile}:/config/config.json:ro" "${serverCfg.path.config}/favicon/cache:/cache" "${mediaCfg.logo.svg}:${logoSvgMount}:ro" ]; }; }; }; }; }