From f82d25eb84cea20c4109372263c14ddfb878abe2 Mon Sep 17 00:00:00 2001 From: soraefir Date: Thu, 4 Jun 2026 22:11:13 +0200 Subject: [PATCH] fix --- modules/server/containers/apps/favicon.nix | 173 +++++++-------------- 1 file changed, 56 insertions(+), 117 deletions(-) diff --git a/modules/server/containers/apps/favicon.nix b/modules/server/containers/apps/favicon.nix index 847aa99..7c96514 100644 --- a/modules/server/containers/apps/favicon.nix +++ b/modules/server/containers/apps/favicon.nix @@ -7,6 +7,11 @@ let priority = toString (containerCfg.extra.priority or 2147482647); logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg); logoSvgMount = "/assets/${logoSvgFileName}"; + hostMappings = lib.mapAttrs' (mapping: profile: + lib.nameValuePair + (if lib.hasInfix "." mapping then mapping else "${mapping}.${serverCfg.domain}") + profile + ) (containerCfg.extra.mappings or {}); traefikAssetPathRegexp = "^/(.*/)?" + "(favicon(-[0-9]+x[0-9]+)?\\.(ico|png|svg)" @@ -15,9 +20,8 @@ let + "|mstile-[0-9]+x[0-9]+\\.png)$"; configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON { inherit cacheControl; - mappings = containerCfg.extra.mappings or {}; + mappings = hostMappings; default = containerCfg.extra.default or null; - logoScale = containerCfg.extra.logoScale or 0.72; }); pythonEnv = pkgs.python3.withPackages (ps: with ps; [ cairosvg @@ -32,7 +36,6 @@ let import re from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path - from urllib.parse import urlsplit import cairosvg from PIL import Image @@ -44,21 +47,13 @@ let 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-[0-9]{2,4}x[0-9]{2,4}\.png" - r"|mstile-[0-9]{2,4}x[0-9]{2,4}\.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") + LOGO_HASH = hashlib.sha256(LOGO_BYTES).hexdigest() def _normalize_host(host): @@ -67,20 +62,7 @@ let 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") + return mappings.get(host) or APP_CONFIG.get("default") def _color(value, fallback): @@ -89,19 +71,36 @@ let 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() + return str(badge).strip() - 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 _tinted_logo_data_uri(color): + svg = LOGO_BYTES.decode("utf-8") + svg = re.sub( + r'fill="(?!none\b)[^"]*"', + f'fill="{color}"', + svg, + flags=re.IGNORECASE, + ) + svg = re.sub( + r"fill='(?!none\b)[^']*'", + f"fill='{color}'", + svg, + flags=re.IGNORECASE, + ) + svg = re.sub( + r'stroke="(?!none\b)[^"]*"', + f'stroke="{color}"', + svg, + flags=re.IGNORECASE, + ) + svg = re.sub( + r"stroke='(?!none\b)[^']*'", + f"stroke='{color}'", + svg, + flags=re.IGNORECASE, + ) + return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii") def _render_svg(profile): @@ -109,125 +108,65 @@ let fg = _color(profile.get("foreground"), "#f8fafc") accent = _color(profile.get("accent"), "#38bdf8") badge = _badge_text(profile) + logo_data_uri = _tinted_logo_data_uri(fg) canvas = 256 - panel = 188 - panel_offset = (canvas - panel) // 2 - logo_size = int(canvas * _logo_scale()) - logo_offset = (canvas - logo_size) // 2 + circle_radius = 120 badge_svg = "" if badge: badge_svg = f""" - - - {badge} - + {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): + def _cache_key(host, profile): payload = json.dumps( - {"host": host, "spec": spec, "profile": profile, "logo": hashlib.sha256(LOGO_BYTES).hexdigest()}, + {"host": host, "profile": profile, "logo": LOGO_HASH}, 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]}" + def _generate_asset(host, profile): + cache_name = f"{_cache_key(host, profile)}.ico" target = CACHE_DIR / cache_name if target.exists(): - return target, _mime(spec[0]) + return target 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") - + png_bytes = cairosvg.svg2png(bytestring=svg, output_width=64, output_height=64) image = Image.open(BytesIO(png_bytes)) - image.save(target, format="ICO", sizes=[(64, 64), (48, 48), (32, 32), (16, 16)]) + image.save(target, format="ICO", sizes=[(64, 64)]) image.close() - return target, _mime("ico") + return target 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) + asset_path = _generate_asset(host, profile) payload = asset_path.read_bytes() self.send_response(200) - self.send_header("Content-Type", content_type) + self.send_header("Content-Type", "image/x-icon") self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", APP_CONFIG.get("cacheControl", DEFAULT_CACHE_CONTROL)) self.end_headers()