{ config, containerCfg, pkgs, lib, builder, name, ... }: let serverCfg = config.syscfg.server; mediaCfg = config.syscfg.media; palette = serverCfg.colorScheme.palette or { }; port = 8080; assetSize = 64; cacheMode = containerCfg.extra.cacheMode or "off"; cacheControl = if cacheMode == "disk" then containerCfg.extra.cacheControl or "public, max-age=3600" else if cacheMode == "off" then "no-store" else throw "favicon cacheMode must be either `off` or `disk`"; priority = toString (containerCfg.extra.priority or 2147482647); logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg); logoSvgMount = "/assets/${logoSvgFileName}"; 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)$"; pythonEnv = pkgs.python3.withPackages (ps: with ps; [ cairosvg pillow ]); serverScript = pkgs.writeText "favicon-server.py" '' from io import BytesIO import hashlib import os from pathlib import Path import re import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import cairosvg from PIL import Image LOGO_PATH = ${builtins.toJSON logoSvgMount} LISTEN_HOST = "0.0.0.0" LISTEN_PORT = ${toString port} ASSET_SIZE = ${toString assetSize} CACHE_MODE = ${builtins.toJSON cacheMode} CACHE_CONTROL = ${builtins.toJSON cacheControl} CACHE_DIR = Path("/cache") with open(LOGO_PATH, "rb") as fh: LOGO_BYTES = fh.read() 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"} LOGO_HASH = hashlib.sha256(LOGO_BYTES).hexdigest() ICON_CACHE_LOCK = threading.Lock() 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_logo_fill(svg, color): svg, _ = re.subn( "fill:#3193f5", f"fill:{color}", svg, flags=re.IGNORECASE, ) return svg 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 _add_background(svg, color): return re.sub( r"(]*>)", rf'\\1', svg, count=1, flags=re.IGNORECASE, ) def _render_icon(colors): svg = LOGO_BYTES.decode("utf-8") svg = _replace_logo_fill(svg, colors["fg"]) svg = _add_background(svg, colors["bg"]) png = cairosvg.svg2png( bytestring=svg.encode("utf-8"), output_width=ASSET_SIZE, output_height=ASSET_SIZE, ) output = BytesIO() with Image.open(BytesIO(png)) as image: with image.convert("RGBA") as rgba: rgba.save(output, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)]) return output.getvalue() def _cache_path(colors): digest = hashlib.sha256( f"{ASSET_SIZE}:{LOGO_HASH}:{colors['bg']}:{colors['fg']}".encode("utf-8") ).hexdigest() return CACHE_DIR / f"{digest}.ico" def _payload_for(colors): if CACHE_MODE != "disk": return _render_icon(colors) cache_path = _cache_path(colors) with ICON_CACHE_LOCK: if cache_path.exists(): return cache_path.read_bytes() payload = _render_icon(colors) with ICON_CACHE_LOCK: if cache_path.exists(): return cache_path.read_bytes() CACHE_DIR.mkdir(parents=True, exist_ok=True) tmp_path = cache_path.with_suffix(".tmp") tmp_path.write_bytes(payload) os.replace(tmp_path, cache_path) return payload 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) payload = _payload_for(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("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" "-u" 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; 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 = [ "${serverCfg.path.config.path}/favicon/cache:/cache" "${mediaCfg.logo.svg}:${logoSvgMount}:ro" ]; }; }; }; }; }