302 lines
10 KiB
Nix
302 lines
10 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 1000);
|
|
logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg);
|
|
logoSvgMount = "/assets/${logoSvgFileName}";
|
|
configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON {
|
|
inherit cacheControl;
|
|
mappings = containerCfg.extra.mappings or {};
|
|
default = containerCfg.extra.default or null;
|
|
logoScale = containerCfg.extra.logoScale or 0.72;
|
|
});
|
|
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
|
|
from urllib.parse import urlsplit
|
|
|
|
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"
|
|
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-(?:192x192|512x512)\.png"
|
|
r"|mstile-150x150\.png"
|
|
r")$"
|
|
)
|
|
|
|
|
|
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_DATA_URI = "data:image/svg+xml;base64," + base64.b64encode(LOGO_BYTES).decode("ascii")
|
|
|
|
|
|
def _normalize_host(host):
|
|
return (host or "").split(":", 1)[0].strip().lower()
|
|
|
|
|
|
def _pick_profile(host):
|
|
mappings = APP_CONFIG.get("mappings", {})
|
|
exact = mappings.get(host)
|
|
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):
|
|
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 ""
|
|
badge = str(badge).strip()
|
|
if not badge:
|
|
return ""
|
|
return badge[:3].upper()
|
|
|
|
|
|
def _logo_scale():
|
|
scale = APP_CONFIG.get("logoScale", 0.72)
|
|
try:
|
|
scale = float(scale)
|
|
except (TypeError, ValueError):
|
|
return 0.72
|
|
return max(0.2, min(0.9, scale))
|
|
|
|
|
|
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)
|
|
|
|
canvas = 256
|
|
panel = 188
|
|
panel_offset = (canvas - panel) // 2
|
|
logo_size = int(canvas * _logo_scale())
|
|
logo_offset = (canvas - logo_size) // 2
|
|
badge_svg = ""
|
|
if badge:
|
|
badge_svg = f"""
|
|
<g>
|
|
<circle cx="196" cy="60" r="34" fill="{accent}" />
|
|
<text x="196" y="72"
|
|
font-family="DejaVu Sans, sans-serif"
|
|
font-size="34"
|
|
font-weight="700"
|
|
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}">
|
|
<defs>
|
|
<radialGradient id="glow" cx="84%" cy="16%" r="75%">
|
|
<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}
|
|
</svg>"""
|
|
|
|
|
|
def _target_spec(path):
|
|
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(
|
|
{"host": host, "spec": spec, "profile": profile, "logo": hashlib.sha256(LOGO_BYTES).hexdigest()},
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def _mime(ext):
|
|
return {
|
|
"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
|
|
if target.exists():
|
|
return target, _mime(spec[0])
|
|
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
svg = _render_svg(profile).encode("utf-8")
|
|
if spec[0] == "svg":
|
|
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.save(target, format="ICO", sizes=[(64, 64), (48, 48), (32, 32), (16, 16)])
|
|
image.close()
|
|
return target, _mime("ico")
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "favicon-router/1.0"
|
|
|
|
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", ""))
|
|
profile = _pick_profile(host)
|
|
if not profile:
|
|
self.send_error(404, "No favicon mapping for host")
|
|
return
|
|
|
|
asset_path, content_type = _generate_asset(host, profile, path)
|
|
payload = asset_path.read_bytes()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", content_type)
|
|
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" = "HostRegexp(`{host:.+}`) && PathRegexp(`^/(?:.*\\/)?(?:favicon(?:-[0-9]+x[0-9]+)?\\.(?:ico|png|svg)|apple-touch-icon(?:-precomposed)?\\.png|android-chrome-(?:192x192|512x512)\\.png|mstile-150x150\\.png)$`)";
|
|
"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"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|