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