fix favicon

This commit is contained in:
soraefir
2026-06-08 22:11:17 +02:00
parent 9f4fc933cf
commit 5c5f2dd3de

View File

@@ -5,7 +5,6 @@ let
palette = serverCfg.colorScheme.palette or { };
port = 8080;
assetSize = 64;
cacheControl = containerCfg.extra.cacheControl or "public, max-age=86400";
priority = toString (containerCfg.extra.priority or 2147482647);
logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg);
logoSvgMount = "/assets/${logoSvgFileName}";
@@ -64,46 +63,29 @@ let
+ "|apple-touch-icon(-precomposed)?\\.png"
+ "|android-chrome-[0-9]+x[0-9]+\\.png"
+ "|mstile-[0-9]+x[0-9]+\\.png)$";
configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON {
inherit cacheControl;
inherit borderRadius;
domain = serverCfg.domain;
mappings = hostMappings;
default = defaultProfile;
});
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
cairosvg
pillow
]);
serverScript = pkgs.writeText "favicon-server.py" ''
import hashlib
from io import BytesIO
import json
import os
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import cairosvg
from PIL import Image, ImageDraw
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"))
ASSET_SIZE = int(os.environ.get("FAVICON_ASSET_SIZE", "${toString assetSize}"))
DEFAULT_CACHE_CONTROL = "public, max-age=86400"
LOGO_PATH = ${builtins.toJSON logoSvgMount}
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = ${toString port}
ASSET_SIZE = ${toString assetSize}
CACHE_CONTROL = "no-store"
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()
MAPPINGS = APP_CONFIG.get("mappings", {})
DEFAULT_PROFILE = APP_CONFIG.get("default")
APP_DOMAIN = (APP_CONFIG.get("domain", "") or "").strip().lower()
CACHE_CONTROL = APP_CONFIG.get("cacheControl", DEFAULT_CACHE_CONTROL)
LOGO_HASH = hashlib.sha256(LOGO_BYTES).hexdigest()
MAPPINGS = ${builtins.toJSON hostMappings}
DEFAULT_PROFILE = ${if defaultProfile == null then "None" else builtins.toJSON defaultProfile}
APP_DOMAIN = (${builtins.toJSON serverCfg.domain} or "").strip().lower()
DEFAULT_COLORS = {"bg": "#111827", "fg": "#f8fafc"}
def _request_host(headers):
@@ -138,30 +120,15 @@ let
return candidate, profile
return None, DEFAULT_PROFILE
def _replace_svg_color(svg, attribute, color):
if attribute not in {"fill", "stroke"}:
return svg
svg = re.sub(
rf'({attribute}\\s*=\\s*")(?!none\\b)[^"]*(")',
rf"\\1{color}\\2",
svg,
flags=re.IGNORECASE,
)
svg = re.sub(
rf"({attribute}\\s*=\\s*')(?!none\\b)[^']*(')",
rf"\\1{color}\\2",
svg,
flags=re.IGNORECASE,
)
def _replace_logo_fill(svg, color):
return re.sub(
rf"({attribute}\\s*:\\s*)(?!none\\b)[^;\"']+",
rf"\\1{color}",
r"fill:\\s*#3193f5\\b",
f"fill:{color}",
svg,
flags=re.IGNORECASE,
)
border_radius = str(APP_CONFIG.get("borderRadius", "8")).strip()
border_radius = str(${builtins.toJSON borderRadius}).strip()
if not border_radius.endswith("px"):
border_radius = f"{border_radius}px"
border_radius_px = max(0, int(float(border_radius[:-2])))
@@ -173,34 +140,17 @@ let
"fg": profile.get("fg") or profile.get("foreground") or DEFAULT_COLORS["fg"],
}
def _asset_path(host, colors):
payload = json.dumps(
{
"asset_size": ASSET_SIZE,
"bg": colors["bg"],
"border_radius": border_radius,
"fg": colors["fg"],
"host": host,
"logo_hash": LOGO_HASH,
},
sort_keys=True,
separators=(",", ":"),
)
safe_host = re.sub(r"[^a-z0-9.-]+", "_", host or "default")
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
return CACHE_DIR / f"{safe_host}-{digest}.ico"
def _render_icon(colors, target):
def _render_icon(colors):
svg = LOGO_BYTES.decode("utf-8")
for attribute in ("fill", "stroke"):
svg = _replace_svg_color(svg, attribute, colors["fg"])
svg = _replace_logo_fill(svg, colors["fg"])
print(f"favicon-render fg={colors['fg']} bg={colors['bg']}")
logo_png = cairosvg.svg2png(
bytestring=svg.encode("utf-8"),
output_width=ASSET_SIZE,
output_height=ASSET_SIZE,
)
tmp_target = target.with_suffix(".tmp")
output = BytesIO()
with Image.new("RGBA", (ASSET_SIZE, ASSET_SIZE), (0, 0, 0, 0)) as canvas:
ImageDraw.Draw(canvas).rounded_rectangle(
(0, 0, ASSET_SIZE, ASSET_SIZE),
@@ -209,8 +159,8 @@ let
)
with Image.open(BytesIO(logo_png)) as logo:
canvas.alpha_composite(logo.convert("RGBA"))
canvas.save(tmp_target, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)])
os.replace(tmp_target, target)
canvas.save(output, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)])
return output.getvalue()
class Handler(BaseHTTPRequestHandler):
server_version = "favicon-router/1.0"
@@ -218,31 +168,19 @@ let
def _serve(self, include_body):
host = _request_host(self.headers)
matched_host, profile = _profile_for_host(host)
print(f"favicon-request host={host!r} matched={matched_host!r} include_body={include_body}")
if not profile:
print("favicon-request no-profile")
self.send_error(404, "No favicon mapping for host")
return
colors = _colors(profile)
asset_path = _asset_path(host, colors)
if not asset_path.exists():
CACHE_DIR.mkdir(parents=True, exist_ok=True)
_render_icon(colors, asset_path)
etag = f'"{asset_path.stem.rsplit("-", 1)[-1]}"'
if self.headers.get("If-None-Match") == etag:
self.send_response(304)
self.send_header("ETag", etag)
self.send_header("Cache-Control", CACHE_CONTROL)
self.send_header("X-Favicon-Host", host or "default")
self.send_header("X-Favicon-Mapping", matched_host or "default")
self.end_headers()
return
payload = asset_path.read_bytes()
print(f"favicon-colors bg={colors['bg']} fg={colors['fg']}")
payload = _render_icon(colors)
self.send_response(200)
self.send_header("Content-Type", "image/x-icon")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", CACHE_CONTROL)
self.send_header("ETag", etag)
self.send_header("X-Favicon-Host", host or "default")
self.send_header("X-Favicon-Mapping", matched_host or "default")
self.end_headers()
@@ -278,25 +216,18 @@ let
};
in {
runtime = {
paths = [
{
path = "${serverCfg.path.config.path}/favicon";
mode = "0755";
dirs = [ "cache" ];
}
];
# paths = [
# {
# path = "${serverCfg.path.config.path}/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;
FAVICON_ASSET_SIZE = toString assetSize;
};
extraLabels = {
"traefik.enable" = "true";
"traefik.http.routers.${name}.entrypoints" = "web-secure";
@@ -307,8 +238,7 @@ in {
};
overrides = {
volumes = [
"${configFile}:/config/config.json:ro"
"${serverCfg.path.config.path}/favicon/cache:/cache"
# "${serverCfg.path.config.path}/favicon/cache:/cache"
"${mediaCfg.logo.svg}:${logoSvgMount}:ro"
];
};