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""""""
- 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()