Add modules/server/containers/apps/favicon.nix
This commit is contained in:
301
modules/server/containers/apps/favicon.nix
Normal file
301
modules/server/containers/apps/favicon.nix
Normal file
@@ -0,0 +1,301 @@
|
||||
{ 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"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user