Files
nixconfig/modules/server/containers/apps/favicon.nix
2026-06-08 22:11:17 +02:00

249 lines
8.5 KiB
Nix

{ config, containerCfg, pkgs, lib, builder, name, ... }:
let
serverCfg = config.syscfg.server;
mediaCfg = config.syscfg.media;
palette = serverCfg.colorScheme.palette or { };
port = 8080;
assetSize = 64;
priority = toString (containerCfg.extra.priority or 2147482647);
logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg);
logoSvgMount = "/assets/${logoSvgFileName}";
borderRadius = toString (containerCfg.extra.borderRadius or 32);
ensureAttrSet = field: value:
if builtins.isAttrs value then
value
else
throw "favicon `${field}` must be an attribute set";
resolveColor = value:
if value == null then null
else if !builtins.isString value then
throw "favicon color values must be strings"
else if lib.hasPrefix "#" value then
value
else
let
paletteValue = lib.attrByPath [ value ] (throw "Unknown favicon color reference `${value}`") palette;
in
if builtins.isString paletteValue then
if lib.hasPrefix "#" paletteValue then
paletteValue
else
"#${paletteValue}"
else
throw "favicon palette reference `${value}` must resolve to a string";
normalizeProfile = profile:
let
normalizedProfile = ensureAttrSet "profile" profile;
bg =
if normalizedProfile ? bg then resolveColor normalizedProfile.bg
else if normalizedProfile ? background then resolveColor normalizedProfile.background
else null;
fg =
if normalizedProfile ? fg then resolveColor normalizedProfile.fg
else if normalizedProfile ? foreground then resolveColor normalizedProfile.foreground
else null;
in
(lib.filterAttrs (name: _: !(builtins.elem name [ "bg" "background" "fg" "foreground" ])) normalizedProfile)
// lib.optionalAttrs (bg != null) { bg = bg; }
// lib.optionalAttrs (fg != null) { fg = fg; };
hostMappings = lib.mapAttrs (_: profile: normalizeProfile profile) (
ensureAttrSet "mappings" (containerCfg.extra.mappings or { })
);
defaultProfile =
if containerCfg.extra ? default then
normalizeProfile containerCfg.extra.default
else
null;
traefikAssetPathRegexp =
"^/(.*/)?"
+ "(fav(icon)?(-[0-9]+x[0-9]+)?\\.(ico|png|svg)"
+ "|(favicon|apple-icon)(-[0-9]+)?(\\.(ico|png))?"
+ "|logo\\.(ico)"
+ "|fav([0-9]+)?\\.(ico|png)"
+ "|apple-touch-icon(-precomposed)?\\.png"
+ "|android-chrome-[0-9]+x[0-9]+\\.png"
+ "|mstile-[0-9]+x[0-9]+\\.png)$";
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
cairosvg
pillow
]);
serverScript = pkgs.writeText "favicon-server.py" ''
from io import BytesIO
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import cairosvg
from PIL import Image, ImageDraw
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(LOGO_PATH, "rb") as fh:
LOGO_BYTES = fh.read()
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):
host = (
headers.get("X-Forwarded-Host")
or headers.get("X-Original-Host")
or headers.get("Host", "")
)
return (host or "").split(",", 1)[0].split(":", 1)[0].strip().lower().rstrip(".")
def _host_candidates(host):
candidates = []
def add(candidate):
if candidate and candidate not in candidates:
candidates.append(candidate)
add(host)
if APP_DOMAIN:
suffix = f".{APP_DOMAIN}"
if host.endswith(suffix):
add(host[: -len(suffix)].rstrip("."))
if "." in host:
add(host.split(".", 1)[0])
return candidates
def _profile_for_host(host):
for candidate in _host_candidates(host):
profile = MAPPINGS.get(candidate)
if profile:
return candidate, profile
return None, DEFAULT_PROFILE
def _replace_logo_fill(svg, color):
return re.sub(
r"fill:\\s*#3193f5\\b",
f"fill:{color}",
svg,
flags=re.IGNORECASE,
)
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])))
def _colors(profile):
profile = profile or {}
return {
"bg": profile.get("bg") or profile.get("background") or DEFAULT_COLORS["bg"],
"fg": profile.get("fg") or profile.get("foreground") or DEFAULT_COLORS["fg"],
}
def _render_icon(colors):
svg = LOGO_BYTES.decode("utf-8")
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,
)
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),
radius=min(border_radius_px, ASSET_SIZE // 2),
fill=colors["bg"],
)
with Image.open(BytesIO(logo_png)) as logo:
canvas.alpha_composite(logo.convert("RGBA"))
canvas.save(output, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)])
return output.getvalue()
class Handler(BaseHTTPRequestHandler):
server_version = "favicon-router/1.0"
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)
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("X-Favicon-Host", host or "default")
self.send_header("X-Favicon-Mapping", matched_host or "default")
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.cacert
pkgs.tzdata
];
config = {
Entrypoint = [ "${pythonEnv}/bin/python3" serverScript ];
ExposedPorts = { "${toString port}/tcp" = { }; };
WorkingDir = "/";
};
};
in {
runtime = {
# paths = [
# {
# path = "${serverCfg.path.config.path}/favicon";
# mode = "0755";
# dirs = [ "cache" ];
# }
# ];
containers = {
server = builder.mkContainer {
imageStream = image;
port = port;
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 = [
# "${serverCfg.path.config.path}/favicon/cache:/cache"
"${mediaCfg.logo.svg}:${logoSvgMount}:ro"
];
};
};
};
};
}