254 lines
8.7 KiB
Nix
254 lines
8.7 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}";
|
|
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
|
|
|
|
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):
|
|
candidates = _host_candidates(host)
|
|
print(f"favicon-profile host={host!r} candidates={candidates!r} mappings={list(MAPPINGS.keys())!r}")
|
|
for candidate in candidates:
|
|
profile = MAPPINGS.get(candidate)
|
|
print(f"favicon-profile-check candidate={candidate!r} hit={profile is not None}")
|
|
if profile:
|
|
print(f"favicon-profile-match candidate={candidate!r} profile={profile!r}")
|
|
return candidate, profile
|
|
print(f"favicon-profile-default host={host!r} default={DEFAULT_PROFILE!r}")
|
|
return None, DEFAULT_PROFILE
|
|
|
|
def _replace_logo_fill(svg, color):
|
|
svg, count = re.subn(
|
|
r"fill:\\s*#3193f5\\b",
|
|
f"fill:{color}",
|
|
svg,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
print(f"favicon-fill replace_count={count} color={color}")
|
|
return svg
|
|
|
|
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 _add_background(svg, color):
|
|
return re.sub(
|
|
r"(<svg\\b[^>]*>)",
|
|
rf'\\1<circle cx="64" cy="64" r="64" fill="{color}"/>',
|
|
svg,
|
|
count=1,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
def _render_icon(colors):
|
|
svg = LOGO_BYTES.decode("utf-8")
|
|
svg = _replace_logo_fill(svg, colors["fg"])
|
|
svg = _add_background(svg, colors["bg"])
|
|
print(f"favicon-render fg={colors['fg']} bg={colors['bg']} mode=svg2png")
|
|
|
|
png = cairosvg.svg2png(
|
|
bytestring=svg.encode("utf-8"),
|
|
output_width=ASSET_SIZE,
|
|
output_height=ASSET_SIZE,
|
|
)
|
|
output = BytesIO()
|
|
with Image.open(BytesIO(png)) as image:
|
|
with image.convert("RGBA") as rgba:
|
|
rgba.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" "-u" 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"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|