297 lines
10 KiB
Nix
297 lines
10 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;
|
|
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 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;
|
|
faviconPathPatterns = [
|
|
"favicon(-[0-9]+x[0-9]+)?(\\.(ico|png|svg))?"
|
|
"fav(icon)?(-[0-9]+x[0-9]+)?\\.(ico|png|svg)"
|
|
"apple-icon(-[0-9]+)?(\\.(ico|png))?"
|
|
"apple-touch-icon(-precomposed)?\\.png"
|
|
"android-chrome-[0-9]+x[0-9]+\\.png"
|
|
"mstile-[0-9]+x[0-9]+\\.png"
|
|
"logo\\.ico"
|
|
];
|
|
traefikAssetPathRegexp =
|
|
"^/(.*/)?"
|
|
+ "("
|
|
+ lib.concatStringsSep "|" faviconPathPatterns
|
|
+ ")$";
|
|
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; [
|
|
cairosvg
|
|
pillow
|
|
]);
|
|
serverScript = pkgs.writeText "favicon-server.py" ''
|
|
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, ImageDraw
|
|
|
|
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"))
|
|
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:
|
|
LOGO_BYTES = fh.read()
|
|
MAPPINGS = APP_CONFIG.get("mappings", {})
|
|
DEFAULT_PROFILE = APP_CONFIG.get("default")
|
|
APP_DOMAIN = (APP_CONFIG.get("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"}
|
|
|
|
def _request_host(headers):
|
|
host = (
|
|
headers.get("X-Forwarded-Host")
|
|
or headers.get("X-Original-Host")
|
|
or headers.get("Host", "")
|
|
)
|
|
host = (host or "").split(",", 1)[0].split(":", 1)[0].strip().lower()
|
|
if APP_DOMAIN and host.endswith(f".{APP_DOMAIN}"):
|
|
return host[:-(len(APP_DOMAIN) + 1)]
|
|
return host
|
|
|
|
def _replace_svg_color(svg, attribute, color):
|
|
if attribute in {"fill", "stroke"}:
|
|
svg = re.sub(
|
|
rf'{attribute}="(?!none\\b)[^"]*"',
|
|
f'{attribute}="{color}"',
|
|
svg,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
svg = re.sub(
|
|
rf"{attribute}='(?!none\\b)[^']*'",
|
|
f"{attribute}='{color}'",
|
|
svg,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
return re.sub(
|
|
rf"{attribute}\\s*:\\s*(?!none\\b)[^;\"\\']+",
|
|
f"{attribute}:{color}",
|
|
svg,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
border_radius = str(APP_CONFIG.get("borderRadius", "8")).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 _asset_path(host, 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")
|
|
for attribute in ("fill", "stroke"):
|
|
svg = _replace_svg_color(svg, attribute, colors["fg"])
|
|
|
|
logo_png = cairosvg.svg2png(
|
|
bytestring=svg.encode("utf-8"),
|
|
output_width=ASSET_SIZE,
|
|
output_height=ASSET_SIZE,
|
|
)
|
|
tmp_target = target.with_suffix(".tmp")
|
|
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(tmp_target, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)])
|
|
os.replace(tmp_target, target)
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "favicon-router/1.0"
|
|
|
|
def _serve(self, include_body):
|
|
host = _request_host(self.headers)
|
|
profile = MAPPINGS.get(host) or DEFAULT_PROFILE
|
|
if not profile:
|
|
self.send_error(404, "No favicon mapping for host")
|
|
return
|
|
|
|
colors = _colors(profile)
|
|
asset_path = _asset_path(host, colors)
|
|
if not asset_path.exists():
|
|
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.end_headers()
|
|
return
|
|
|
|
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", CACHE_CONTROL)
|
|
self.send_header("ETag", etag)
|
|
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;
|
|
extraEnv = {
|
|
FAVICON_CONFIG = "/config/config.json";
|
|
FAVICON_LOGO = logoSvgMount;
|
|
FAVICON_CACHE_DIR = "/cache";
|
|
FAVICON_PORT = toString port;
|
|
FAVICON_ASSET_SIZE = toString assetSize;
|
|
};
|
|
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.path}/favicon/cache:/cache"
|
|
"${mediaCfg.logo.svg}:${logoSvgMount}:ro"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|