{ config, containerCfg, pkgs, lib, builder, name, ... }: let serverCfg = config.syscfg.server; mediaCfg = config.syscfg.media; palette = serverCfg.colorScheme.palette or { }; port = 8080; assetSize = 64; 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 re 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_CONTROL = "no-store" 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"} 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): candidates = _host_candidates(host) print(f"favicon-profile host={host!r} candidates={candidates!r} mappings={list(MAPPINGS.keys())!r}") for candidate in candidates: profile = MAPPINGS.get(candidate) print(f"favicon-profile-check candidate={candidate!r} hit={profile is not None}") if profile: print(f"favicon-profile-match candidate={candidate!r} profile={profile!r}") return candidate, profile print(f"favicon-profile-default host={host!r} default={DEFAULT_PROFILE!r}") return None, DEFAULT_PROFILE def _replace_logo_fill(svg, color): svg, count = re.subn( r"fill:\\s*#3193f5\\b", f"fill:{color}", svg, flags=re.IGNORECASE, ) print(f"favicon-fill replace_count={count} color={color}") 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"]) print(f"favicon-render fg={colors['fg']} bg={colors['bg']} mode=svg2png") 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() 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) 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) 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("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" ]; }; }; }; }; }