From 215b546128fc580b73447e68344dcbe58cac15ef Mon Sep 17 00:00:00 2001 From: sora-ext Date: Thu, 4 Jun 2026 17:58:00 +0200 Subject: [PATCH] Add modules/server/containers/apps/favicon.nix --- modules/server/containers/apps/favicon.nix | 301 +++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 modules/server/containers/apps/favicon.nix diff --git a/modules/server/containers/apps/favicon.nix b/modules/server/containers/apps/favicon.nix new file mode 100644 index 0000000..b6ef305 --- /dev/null +++ b/modules/server/containers/apps/favicon.nix @@ -0,0 +1,301 @@ +{ 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" + ]; + }; + }; + }; + }; +}