{ config, containerCfg, pkgs, lib, builder, name, ... }: let serverCfg = config.syscfg.server; mediaCfg = config.syscfg.media; 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}"; borderRadius = toString (containerCfg.extra.borderRadius or 32); ensureAttrSet = field: value: if builtins.isAttrs value then value else throw "favicon `${field}` must be an attribute set"; resolveColor = value: if value == null then null else if !builtins.isString value then throw "favicon color values must be strings" else if lib.hasPrefix "#" value then value else let paletteValue = lib.attrByPath [ value ] (throw "Unknown favicon color reference `${value}`") palette; in if builtins.isString paletteValue then if lib.hasPrefix "#" paletteValue then paletteValue else "#${paletteValue}" else throw "favicon palette reference `${value}` must resolve to a string"; normalizeProfile = profile: let normalizedProfile = ensureAttrSet "profile" profile; bg = if normalizedProfile ? bg then resolveColor normalizedProfile.bg else if normalizedProfile ? background then resolveColor normalizedProfile.background else null; fg = if normalizedProfile ? fg then resolveColor normalizedProfile.fg else if normalizedProfile ? foreground then resolveColor normalizedProfile.foreground else null; in (lib.filterAttrs (name: _: !(builtins.elem name [ "bg" "background" "fg" "foreground" ])) normalizedProfile) // lib.optionalAttrs (bg != null) { bg = bg; } // lib.optionalAttrs (fg != null) { fg = fg; }; hostMappings = lib.mapAttrs (_: profile: normalizeProfile profile) ( ensureAttrSet "mappings" (containerCfg.extra.mappings or { }) ); defaultProfile = if containerCfg.extra ? default then normalizeProfile containerCfg.extra.default else null; traefikAssetPathRegexp = "^/(.*/)?" + "(fav(icon)?(-[0-9]+x[0-9]+)?\\.(ico|png|svg)" + "|(favicon|apple-icon)(-[0-9]+)?(\\.(ico|png))?" + "|logo\\.(ico)" + "|fav([0-9]+)?\\.(ico|png)" + "|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" 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() DEFAULT_COLORS = {"bg": "#111827", "fg": "#f8fafc"} def _request_host(headers): host = ( headers.get("X-Forwarded-Host") or headers.get("X-Original-Host") or headers.get("Host", "") ) return (host or "").split(",", 1)[0].split(":", 1)[0].strip().lower().rstrip(".") def _host_candidates(host): candidates = [] def add(candidate): if candidate and candidate not in candidates: candidates.append(candidate) add(host) if APP_DOMAIN: suffix = f".{APP_DOMAIN}" if host.endswith(suffix): add(host[: -len(suffix)].rstrip(".")) if "." in host: add(host.split(".", 1)[0]) return candidates def _profile_for_host(host): for candidate in _host_candidates(host): profile = MAPPINGS.get(candidate) if profile: return candidate, profile return None, DEFAULT_PROFILE def _replace_svg_color(svg, attribute, color): if attribute in {"fill", "stroke"}: svg = re.sub( rf'{attribute}="(?!none\\b)[^"]*"', f'{attribute}="{color}"', svg, flags=re.IGNORECASE, ) svg = re.sub( rf"{attribute}='(?!none\\b)[^']*'", f"{attribute}='{color}'", svg, flags=re.IGNORECASE, ) return re.sub( rf"{attribute}\\s*:\\s*(?!none\\b)[^;\"\\']+", f"{attribute}:{color}", svg, flags=re.IGNORECASE, ) border_radius = str(APP_CONFIG.get("borderRadius", "8")).strip() if not border_radius.endswith("px"): border_radius = f"{border_radius}px" border_radius_px = max(0, int(float(border_radius[:-2]))) def _colors(profile): profile = profile or {} return { "bg": profile.get("bg") or profile.get("background") or DEFAULT_COLORS["bg"], "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): svg = LOGO_BYTES.decode("utf-8") for attribute in ("fill", "stroke"): svg = _replace_svg_color(svg, attribute, colors["fg"]) logo_png = cairosvg.svg2png( bytestring=svg.encode("utf-8"), output_width=ASSET_SIZE, output_height=ASSET_SIZE, ) tmp_target = target.with_suffix(".tmp") 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), radius=min(border_radius_px, ASSET_SIZE // 2), fill=colors["bg"], ) 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) class Handler(BaseHTTPRequestHandler): server_version = "favicon-router/1.0" def _serve(self, include_body): host = _request_host(self.headers) matched_host, profile = _profile_for_host(host) if not 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() 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() 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.cacert pkgs.tzdata ]; config = { Entrypoint = [ "${pythonEnv}/bin/python3" serverScript ]; ExposedPorts = { "${toString port}/tcp" = { }; }; WorkingDir = "/"; }; }; in { runtime = { 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"; "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.path}/favicon/cache:/cache" "${mediaCfg.logo.svg}:${logoSvgMount}:ro" ]; }; }; }; }; }