diff --git a/modules/server/containers/apps/favicon.nix b/modules/server/containers/apps/favicon.nix index 3ef3364..0395598 100644 --- a/modules/server/containers/apps/favicon.nix +++ b/modules/server/containers/apps/favicon.nix @@ -10,6 +10,11 @@ let 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 @@ -17,48 +22,62 @@ let else if lib.hasPrefix "#" value then value else - lib.attrByPath [ value ] (throw "Unknown favicon color reference `${value}`") palette; + let + paletteValue = lib.attrByPath [ value ] (throw "Unknown favicon color reference `${value}`") palette; + in + if builtins.isString paletteValue then + "#${paletteValue}" + else + throw "favicon palette reference `${value}` must resolve to a string"; normalizeProfile = profile: let + normalizedProfile = ensureAttrSet "profile" profile; bg = - if profile ? bg then resolveColor profile.bg - else if profile ? background then resolveColor profile.background + if normalizedProfile ? bg then resolveColor normalizedProfile.bg + else if normalizedProfile ? background then resolveColor normalizedProfile.background else null; fg = - if profile ? fg then resolveColor profile.fg - else if profile ? foreground then resolveColor profile.foreground + 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" ])) profile) + (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' (mapping: profile: - lib.nameValuePair mapping (normalizeProfile profile) - ) (containerCfg.extra.mappings or {}); + hostMappings = lib.mapAttrs (_: profile: normalizeProfile profile) ( + ensureAttrSet "mappings" (containerCfg.extra.mappings or { }) + ); + defaultProfile = + if containerCfg.extra ? default then + normalizeProfile containerCfg.extra.default + else + null; + faviconPathPatterns = [ + "favicon(-[0-9]+x[0-9]+)?(\\.(ico|png|svg))?" + "fav(icon)?(-[0-9]+x[0-9]+)?\\.(ico|png|svg)" + "apple-icon(-[0-9]+)?(\\.(ico|png))?" + "apple-touch-icon(-precomposed)?\\.png" + "android-chrome-[0-9]+x[0-9]+\\.png" + "mstile-[0-9]+x[0-9]+\\.png" + "logo\\.ico" + ]; 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)$"; + + "(" + + lib.concatStringsSep "|" faviconPathPatterns + + ")$"; configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON { inherit cacheControl; - borderRadius = borderRadius; + inherit borderRadius; domain = serverCfg.domain; mappings = hostMappings; - default = - if containerCfg.extra ? default then normalizeProfile containerCfg.extra.default - else null; + default = defaultProfile; }); 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 @@ -68,7 +87,7 @@ let from pathlib import Path import cairosvg - from PIL import Image + 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") @@ -87,31 +106,19 @@ let 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 _normalize_host(host): + def _request_host(headers): + host = ( + headers.get("X-Forwarded-Host") + or headers.get("X-Original-Host") + or headers.get("Host", "") + ) host = (host or "").split(",", 1)[0].split(":", 1)[0].strip().lower() if APP_DOMAIN and host.endswith(f".{APP_DOMAIN}"): return host[:-(len(APP_DOMAIN) + 1)] return host - def _request_host(headers): - forwarded = headers.get("X-Forwarded-Host", "") - original = headers.get("X-Original-Host", "") - host = forwarded or original or headers.get("Host", "") - return _normalize_host(host) - - def _pick_profile(host): - return MAPPINGS.get(host) or DEFAULT_PROFILE - - def _color(value, fallback): - return value if isinstance(value, str) and value else fallback - - def _resolved_profile(profile): - return { - "bg": _color(profile.get("bg") or profile.get("background"), "#111827"), - "fg": _color(profile.get("fg") or profile.get("foreground"), "#f8fafc"), - } - def _replace_svg_color(svg, attribute, color): if attribute in {"fill", "stroke"}: svg = re.sub( @@ -133,67 +140,72 @@ let flags=re.IGNORECASE, ) - def _tinted_logo_data_uri(color): - svg = LOGO_BYTES.decode("utf-8") - svg = _replace_svg_color(svg, "fill", color) - svg = _replace_svg_color(svg, "stroke", color) - return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii") - 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 _render_svg(colors): - logo_data_uri = _tinted_logo_data_uri(colors["fg"]) - - canvas = 256 - return f""" - - - """ - - def _cache_key(host, colors): - cache_inputs = { - "asset_size": ASSET_SIZE, - "bg": colors["bg"], - "border_radius": border_radius, - "fg": colors["fg"], - "host": host, - "logo_hash": LOGO_HASH, + 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"], } - payload = json.dumps(cache_inputs, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] - def _cache_name(host, colors): + 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") - return f"{safe_host}-{_cache_key(host, colors)}.ico" + digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + return CACHE_DIR / f"{safe_host}-{digest}.ico" - def _generate_asset(host, profile): - colors = _resolved_profile(profile) - cache_name = _cache_name(host, colors) - target = CACHE_DIR / cache_name - if target.exists(): - return target + def _render_icon(colors, target): + svg = LOGO_BYTES.decode("utf-8") + for attribute in ("fill", "stroke"): + svg = _replace_svg_color(svg, attribute, colors["fg"]) - CACHE_DIR.mkdir(parents=True, exist_ok=True) - svg = _render_svg(colors).encode("utf-8") - png_bytes = cairosvg.svg2png(bytestring=svg, output_width=ASSET_SIZE, output_height=ASSET_SIZE) - image = Image.open(BytesIO(png_bytes)) - image.save(target, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)]) - image.close() - return target + 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) - profile = _pick_profile(host) + profile = MAPPINGS.get(host) or DEFAULT_PROFILE if not profile: self.send_error(404, "No favicon mapping for host") return - asset_path = _generate_asset(host, profile) + 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)