Files
nixconfig/modules/server/containers/apps/favicon.nix
soraefir 32f20fb0ba fix
2026-06-04 22:30:19 +02:00

258 lines
8.2 KiB
Nix

{ config, containerCfg, pkgs, lib, builder, name, ... }:
let
serverCfg = config.syscfg.server;
mediaCfg = config.syscfg.media;
port = 8080;
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}";
borderRadius = toString (containerCfg.extra.borderRadius or 8);
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)"
+ "|logo\\.(ico)"
+ "|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;
borderRadius = borderRadius;
mappings = hostMappings;
default = containerCfg.extra.default or null;
});
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
cairosvg
pillow
]);
serverScript = pkgs.writeText "favicon-server.py" ''
import base64
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
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"))
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:
LOGO_BYTES = fh.read()
LOGO_HASH = hashlib.sha256(LOGO_BYTES).hexdigest()
def _normalize_host(host):
return (host or "").split(":", 1)[0].strip().lower()
def _pick_profile(host):
mappings = APP_CONFIG.get("mappings", {})
return mappings.get(host) or APP_CONFIG.get("default")
def _color(value, fallback):
return value if isinstance(value, str) and value else fallback
def _badge_text(profile):
badge = profile.get("icon") or profile.get("label") or profile.get("text") or ""
return str(badge).strip()
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 _border_radius():
value = APP_CONFIG.get("borderRadius", "8")
text = str(value).strip()
if text.endswith("px"):
return text
return f"{text}px"
def _render_svg(profile):
bg = _color(profile.get("background"), "#111827")
fg = _color(profile.get("foreground"), "#f8fafc")
accent = _color(profile.get("accent"), "#38bdf8")
badge = _badge_text(profile)
border_radius = _border_radius()
logo_data_uri = _tinted_logo_data_uri(fg)
canvas = 256
badge_svg = ""
if badge:
badge_svg = f"""
<text x="196" y="72"
font-family="DejaVu Sans, sans-serif"
font-size="72"
font-weight="900"
text-anchor="middle"
fill="{accent}">{badge}</text>
"""
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{canvas}" height="{canvas}" viewBox="0 0 {canvas} {canvas}">
<rect x="0" y="0" width="{canvas}" height="{canvas}" rx="{border_radius}" ry="{border_radius}" fill="{bg}" />
<image href="{logo_data_uri}" x="0" y="0" width="{canvas}" height="{canvas}" preserveAspectRatio="xMidYMid meet" />
{badge_svg}
</svg>"""
def _cache_key(host, profile):
payload = json.dumps(
{"host": host, "profile": profile, "logo": LOGO_HASH},
sort_keys=True,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _generate_asset(host, profile):
cache_name = f"{_cache_key(host, profile)}.ico"
target = CACHE_DIR / cache_name
if target.exists():
return target
CACHE_DIR.mkdir(parents=True, exist_ok=True)
svg = _render_svg(profile).encode("utf-8")
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)])
image.close()
return target
class Handler(BaseHTTPRequestHandler):
server_version = "favicon-router/1.0"
def _serve(self, include_body):
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 = _generate_asset(host, profile)
payload = asset_path.read_bytes()
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", APP_CONFIG.get("cacheControl", DEFAULT_CACHE_CONTROL))
self.end_headers()
if include_body:
self.wfile.write(payload)
def do_GET(self):
self._serve(include_body=True)
def do_HEAD(self):
self._serve(include_body=False)
def log_message(self, fmt, *args):
print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), fmt % args))
if __name__ == "__main__":
httpd = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
httpd.serve_forever()
'';
image = pkgs.dockerTools.streamLayeredImage {
name = "favicon";
tag = "1";
contents = [
pythonEnv
pkgs.fontconfig
pkgs.dejavu_fonts
pkgs.cacert
pkgs.tzdata
];
config = {
Entrypoint = [ "${pythonEnv}/bin/python3" serverScript ];
ExposedPorts = { "${toString port}/tcp" = { }; };
WorkingDir = "/";
};
};
in {
runtime = {
paths = [
{
path = "${serverCfg.path.config}/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;
FONTCONFIG_FILE = "${pkgs.fontconfig.out}/etc/fonts/fonts.conf";
FONTCONFIG_PATH = "${pkgs.fontconfig.out}/etc/fonts";
};
extraLabels = {
"traefik.enable" = "true";
"traefik.http.routers.${name}.entrypoints" = "web-secure";
"traefik.http.routers.${name}.rule" = "PathRegexp(`${traefikAssetPathRegexp}`)";
"traefik.http.routers.${name}.priority" = priority;
"traefik.http.routers.${name}.tls" = "true";
"traefik.http.services.${name}.loadbalancer.server.port" = toString port;
};
overrides = {
volumes = [
"${configFile}:/config/config.json:ro"
"${serverCfg.path.config}/favicon/cache:/cache"
"${mediaCfg.logo.svg}:${logoSvgMount}:ro"
];
};
};
};
};
}