fix
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user