This commit is contained in:
soraefir
2026-06-04 22:11:13 +02:00
parent 1898c015fe
commit f82d25eb84

View File

@@ -7,6 +7,11 @@ let
priority = toString (containerCfg.extra.priority or 2147482647); priority = toString (containerCfg.extra.priority or 2147482647);
logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg); logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg);
logoSvgMount = "/assets/${logoSvgFileName}"; 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 = traefikAssetPathRegexp =
"^/(.*/)?" "^/(.*/)?"
+ "(favicon(-[0-9]+x[0-9]+)?\\.(ico|png|svg)" + "(favicon(-[0-9]+x[0-9]+)?\\.(ico|png|svg)"
@@ -15,9 +20,8 @@ let
+ "|mstile-[0-9]+x[0-9]+\\.png)$"; + "|mstile-[0-9]+x[0-9]+\\.png)$";
configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON { configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON {
inherit cacheControl; inherit cacheControl;
mappings = containerCfg.extra.mappings or {}; mappings = hostMappings;
default = containerCfg.extra.default or null; default = containerCfg.extra.default or null;
logoScale = containerCfg.extra.logoScale or 0.72;
}); });
pythonEnv = pkgs.python3.withPackages (ps: with ps; [ pythonEnv = pkgs.python3.withPackages (ps: with ps; [
cairosvg cairosvg
@@ -32,7 +36,6 @@ let
import re import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
import cairosvg import cairosvg
from PIL import Image from PIL import Image
@@ -44,21 +47,13 @@ let
LISTEN_HOST = os.environ.get("FAVICON_LISTEN_HOST", "0.0.0.0") LISTEN_HOST = os.environ.get("FAVICON_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("FAVICON_PORT", "8080")) LISTEN_PORT = int(os.environ.get("FAVICON_PORT", "8080"))
DEFAULT_CACHE_CONTROL = "public, max-age=86400" 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: with open(CONFIG_PATH, "r", encoding="utf-8") as fh:
APP_CONFIG = json.load(fh) APP_CONFIG = json.load(fh)
with open(LOGO_PATH, "rb") as fh: with open(LOGO_PATH, "rb") as fh:
LOGO_BYTES = fh.read() 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): def _normalize_host(host):
@@ -67,20 +62,7 @@ let
def _pick_profile(host): def _pick_profile(host):
mappings = APP_CONFIG.get("mappings", {}) mappings = APP_CONFIG.get("mappings", {})
exact = mappings.get(host) return mappings.get(host) or APP_CONFIG.get("default")
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): def _color(value, fallback):
@@ -89,19 +71,36 @@ let
def _badge_text(profile): def _badge_text(profile):
badge = profile.get("icon") or profile.get("label") or profile.get("text") or "" badge = profile.get("icon") or profile.get("label") or profile.get("text") or ""
badge = str(badge).strip() return str(badge).strip()
if not badge:
return ""
return badge[:3].upper()
def _logo_scale(): def _tinted_logo_data_uri(color):
scale = APP_CONFIG.get("logoScale", 0.72) svg = LOGO_BYTES.decode("utf-8")
try: svg = re.sub(
scale = float(scale) r'fill="(?!none\b)[^"]*"',
except (TypeError, ValueError): f'fill="{color}"',
return 0.72 svg,
return max(0.2, min(0.9, scale)) 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): def _render_svg(profile):
@@ -109,125 +108,65 @@ let
fg = _color(profile.get("foreground"), "#f8fafc") fg = _color(profile.get("foreground"), "#f8fafc")
accent = _color(profile.get("accent"), "#38bdf8") accent = _color(profile.get("accent"), "#38bdf8")
badge = _badge_text(profile) badge = _badge_text(profile)
logo_data_uri = _tinted_logo_data_uri(fg)
canvas = 256 canvas = 256
panel = 188 circle_radius = 120
panel_offset = (canvas - panel) // 2
logo_size = int(canvas * _logo_scale())
logo_offset = (canvas - logo_size) // 2
badge_svg = "" badge_svg = ""
if badge: if badge:
badge_svg = f""" badge_svg = f"""
<g> <text x="196" y="72"
<circle cx="196" cy="60" r="34" fill="{accent}" /> font-family="DejaVu Sans, sans-serif"
<text x="196" y="72" font-size="52"
font-family="DejaVu Sans, sans-serif" font-weight="700"
font-size="34" text-anchor="middle"
font-weight="700" fill="{accent}">{badge}</text>
text-anchor="middle"
fill="{fg}">{badge}</text>
</g>
""" """
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{canvas}" height="{canvas}" viewBox="0 0 {canvas} {canvas}"> return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{canvas}" height="{canvas}" viewBox="0 0 {canvas} {canvas}">
<defs> <circle cx="128" cy="128" r="{circle_radius}" fill="{bg}" />
<radialGradient id="glow" cx="84%" cy="16%" r="75%"> <image href="{logo_data_uri}" x="0" y="0" width="{canvas}" height="{canvas}" preserveAspectRatio="xMidYMid meet" />
<stop offset="0%" stop-color="{accent}" stop-opacity="0.34" />
<stop offset="100%" stop-color="{accent}" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="{canvas}" height="{canvas}" rx="56" fill="{bg}" />
<rect width="{canvas}" height="{canvas}" rx="56" fill="url(#glow)" />
<rect x="{panel_offset}" y="{panel_offset}" width="{panel}" height="{panel}" rx="44" fill="#ffffff" fill-opacity="0.08" stroke="{accent}" stroke-opacity="0.6" stroke-width="6" />
<rect x="42" y="214" width="172" height="10" rx="5" fill="{accent}" fill-opacity="0.9" />
<image href="{LOGO_DATA_URI}" x="{logo_offset}" y="{logo_offset}" width="{logo_size}" height="{logo_size}" preserveAspectRatio="xMidYMid meet" />
{badge_svg} {badge_svg}
</svg>""" </svg>"""
def _target_spec(path): def _cache_key(host, profile):
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( 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, sort_keys=True,
).encode("utf-8") ).encode("utf-8")
return hashlib.sha256(payload).hexdigest() return hashlib.sha256(payload).hexdigest()
def _mime(ext): def _generate_asset(host, profile):
return { cache_name = f"{_cache_key(host, profile)}.ico"
"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 target = CACHE_DIR / cache_name
if target.exists(): if target.exists():
return target, _mime(spec[0]) return target
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
svg = _render_svg(profile).encode("utf-8") svg = _render_svg(profile).encode("utf-8")
if spec[0] == "svg": png_bytes = cairosvg.svg2png(bytestring=svg, output_width=64, output_height=64)
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 = 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() image.close()
return target, _mime("ico") return target
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "favicon-router/1.0" server_version = "favicon-router/1.0"
def _serve(self, include_body): 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", "")) host = _normalize_host(self.headers.get("Host", ""))
profile = _pick_profile(host) profile = _pick_profile(host)
if not profile: if not profile:
self.send_error(404, "No favicon mapping for host") self.send_error(404, "No favicon mapping for host")
return return
asset_path, content_type = _generate_asset(host, profile, path) asset_path = _generate_asset(host, profile)
payload = asset_path.read_bytes() payload = asset_path.read_bytes()
self.send_response(200) 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("Content-Length", str(len(payload)))
self.send_header("Cache-Control", APP_CONFIG.get("cacheControl", DEFAULT_CACHE_CONTROL)) self.send_header("Cache-Control", APP_CONFIG.get("cacheControl", DEFAULT_CACHE_CONTROL))
self.end_headers() self.end_headers()