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""""""
+
+
+ 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"
+ ];
+ };
+ };
+ };
+ };
+}