{ 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 1000); logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg); logoSvgMount = "/assets/${logoSvgFileName}"; configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON { inherit cacheControl; mappings = containerCfg.extra.mappings or {}; default = containerCfg.extra.default or null; logoScale = containerCfg.extra.logoScale or 0.72; }); 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 from urllib.parse import urlsplit 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" PATH_PATTERN = re.compile( r"(?i)(?:^|/)(?:" r"favicon(?:-[0-9]+x[0-9]+)?\.(?:ico|png|svg)" r"|apple-touch-icon(?:-precomposed)?\.png" r"|android-chrome-(?:192x192|512x512)\.png" r"|mstile-150x150\.png" r")$" ) 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_DATA_URI = "data:image/svg+xml;base64," + base64.b64encode(LOGO_BYTES).decode("ascii") def _normalize_host(host): return (host or "").split(":", 1)[0].strip().lower() def _pick_profile(host): mappings = APP_CONFIG.get("mappings", {}) exact = mappings.get(host) if exact is not None: return exact wildcard_matches = [] for pattern, profile in mappings.items(): if pattern.startswith("*.") and host.endswith(pattern[1:].lower()): wildcard_matches.append((pattern.count("."), profile)) if wildcard_matches: wildcard_matches.sort(key=lambda item: item[0], reverse=True) return wildcard_matches[0][1] return 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 "" badge = str(badge).strip() if not badge: return "" return badge[:3].upper() def _logo_scale(): scale = APP_CONFIG.get("logoScale", 0.72) try: scale = float(scale) except (TypeError, ValueError): return 0.72 return max(0.2, min(0.9, scale)) 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) canvas = 256 panel = 188 panel_offset = (canvas - panel) // 2 logo_size = int(canvas * _logo_scale()) logo_offset = (canvas - logo_size) // 2 badge_svg = "" if badge: badge_svg = f""" {badge} """ return f""" {badge_svg} """ def _target_spec(path): name = Path(path).name.lower() if name.endswith(".svg"): return ("svg", None) if name.endswith(".ico"): return ("ico", 64) known_sizes = { "apple-touch-icon.png": 180, "apple-touch-icon-precomposed.png": 180, "android-chrome-192x192.png": 192, "android-chrome-512x512.png": 512, "mstile-150x150.png": 150, "favicon.png": 256, } if name in known_sizes: return ("png", known_sizes[name]) match = re.search(r"([0-9]{2,4})x([0-9]{2,4})", name) if match: return ("png", int(match.group(1))) return ("png", 256) def _cache_key(host, spec, profile): payload = json.dumps( {"host": host, "spec": spec, "profile": profile, "logo": hashlib.sha256(LOGO_BYTES).hexdigest()}, sort_keys=True, ).encode("utf-8") return hashlib.sha256(payload).hexdigest() def _mime(ext): return { "svg": "image/svg+xml", "png": "image/png", "ico": "image/x-icon", }[ext] def _generate_asset(host, profile, path): spec = _target_spec(path) cache_name = f"{_cache_key(host, spec, profile)}.{spec[0]}" target = CACHE_DIR / cache_name if target.exists(): return target, _mime(spec[0]) CACHE_DIR.mkdir(parents=True, exist_ok=True) svg = _render_svg(profile).encode("utf-8") if spec[0] == "svg": target.write_bytes(svg) return target, _mime("svg") png_bytes = cairosvg.svg2png(bytestring=svg, output_width=spec[1], output_height=spec[1]) if spec[0] == "png": target.write_bytes(png_bytes) return target, _mime("png") image = Image.open(BytesIO(png_bytes)) image.save(target, format="ICO", sizes=[(64, 64), (48, 48), (32, 32), (16, 16)]) image.close() return target, _mime("ico") class Handler(BaseHTTPRequestHandler): server_version = "favicon-router/1.0" def _serve(self, include_body): path = urlsplit(self.path).path if not PATH_PATTERN.search(path): self.send_error(404) return 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, content_type = _generate_asset(host, profile, path) payload = asset_path.read_bytes() self.send_response(200) self.send_header("Content-Type", content_type) 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" = "HostRegexp(`{host:.+}`) && PathRegexp(`^/(?:.*\\/)?(?:favicon(?:-[0-9]+x[0-9]+)?\\.(?:ico|png|svg)|apple-touch-icon(?:-precomposed)?\\.png|android-chrome-(?:192x192|512x512)\\.png|mstile-150x150\\.png)$`)"; "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" ]; }; }; }; }; }