Update modules/server/containers/apps/favicon.nix
This commit is contained in:
@@ -10,6 +10,11 @@ let
|
||||
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
|
||||
@@ -17,48 +22,62 @@ let
|
||||
else if lib.hasPrefix "#" value then
|
||||
value
|
||||
else
|
||||
lib.attrByPath [ value ] (throw "Unknown favicon color reference `${value}`") palette;
|
||||
let
|
||||
paletteValue = lib.attrByPath [ value ] (throw "Unknown favicon color reference `${value}`") palette;
|
||||
in
|
||||
if builtins.isString paletteValue then
|
||||
"#${paletteValue}"
|
||||
else
|
||||
throw "favicon palette reference `${value}` must resolve to a string";
|
||||
normalizeProfile = profile:
|
||||
let
|
||||
normalizedProfile = ensureAttrSet "profile" profile;
|
||||
bg =
|
||||
if profile ? bg then resolveColor profile.bg
|
||||
else if profile ? background then resolveColor profile.background
|
||||
if normalizedProfile ? bg then resolveColor normalizedProfile.bg
|
||||
else if normalizedProfile ? background then resolveColor normalizedProfile.background
|
||||
else null;
|
||||
fg =
|
||||
if profile ? fg then resolveColor profile.fg
|
||||
else if profile ? foreground then resolveColor profile.foreground
|
||||
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" ])) profile)
|
||||
(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' (mapping: profile:
|
||||
lib.nameValuePair mapping (normalizeProfile profile)
|
||||
) (containerCfg.extra.mappings or {});
|
||||
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 =
|
||||
"^/(.*/)?"
|
||||
+ "(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)$";
|
||||
+ "("
|
||||
+ lib.concatStringsSep "|" faviconPathPatterns
|
||||
+ ")$";
|
||||
configFile = pkgs.writeText "favicon-config.json" (builtins.toJSON {
|
||||
inherit cacheControl;
|
||||
borderRadius = borderRadius;
|
||||
inherit borderRadius;
|
||||
domain = serverCfg.domain;
|
||||
mappings = hostMappings;
|
||||
default =
|
||||
if containerCfg.extra ? default then normalizeProfile containerCfg.extra.default
|
||||
else null;
|
||||
default = defaultProfile;
|
||||
});
|
||||
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
|
||||
@@ -68,7 +87,7 @@ let
|
||||
from pathlib import Path
|
||||
|
||||
import cairosvg
|
||||
from PIL import Image
|
||||
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")
|
||||
@@ -87,31 +106,19 @@ let
|
||||
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 _normalize_host(host):
|
||||
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 _request_host(headers):
|
||||
forwarded = headers.get("X-Forwarded-Host", "")
|
||||
original = headers.get("X-Original-Host", "")
|
||||
host = forwarded or original or headers.get("Host", "")
|
||||
return _normalize_host(host)
|
||||
|
||||
def _pick_profile(host):
|
||||
return MAPPINGS.get(host) or DEFAULT_PROFILE
|
||||
|
||||
def _color(value, fallback):
|
||||
return value if isinstance(value, str) and value else fallback
|
||||
|
||||
def _resolved_profile(profile):
|
||||
return {
|
||||
"bg": _color(profile.get("bg") or profile.get("background"), "#111827"),
|
||||
"fg": _color(profile.get("fg") or profile.get("foreground"), "#f8fafc"),
|
||||
}
|
||||
|
||||
def _replace_svg_color(svg, attribute, color):
|
||||
if attribute in {"fill", "stroke"}:
|
||||
svg = re.sub(
|
||||
@@ -133,67 +140,72 @@ let
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _tinted_logo_data_uri(color):
|
||||
svg = LOGO_BYTES.decode("utf-8")
|
||||
svg = _replace_svg_color(svg, "fill", color)
|
||||
svg = _replace_svg_color(svg, "stroke", color)
|
||||
return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
||||
|
||||
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 _render_svg(colors):
|
||||
logo_data_uri = _tinted_logo_data_uri(colors["fg"])
|
||||
|
||||
canvas = 256
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{canvas}" height="{canvas}" viewBox="0 0 {canvas} {canvas}">
|
||||
<rect x="0" y="0" width="{canvas}" height="{canvas}" rx="{border_radius}" ry="{border_radius}" fill="{colors["bg"]}" />
|
||||
<image href="{logo_data_uri}" x="0" y="0" width="{canvas}" height="{canvas}" preserveAspectRatio="xMidYMid meet" />
|
||||
</svg>"""
|
||||
|
||||
def _cache_key(host, colors):
|
||||
cache_inputs = {
|
||||
"asset_size": ASSET_SIZE,
|
||||
"bg": colors["bg"],
|
||||
"border_radius": border_radius,
|
||||
"fg": colors["fg"],
|
||||
"host": host,
|
||||
"logo_hash": LOGO_HASH,
|
||||
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"],
|
||||
}
|
||||
payload = json.dumps(cache_inputs, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def _cache_name(host, colors):
|
||||
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")
|
||||
return f"{safe_host}-{_cache_key(host, colors)}.ico"
|
||||
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
return CACHE_DIR / f"{safe_host}-{digest}.ico"
|
||||
|
||||
def _generate_asset(host, profile):
|
||||
colors = _resolved_profile(profile)
|
||||
cache_name = _cache_name(host, colors)
|
||||
target = CACHE_DIR / cache_name
|
||||
if target.exists():
|
||||
return target
|
||||
def _render_icon(colors, target):
|
||||
svg = LOGO_BYTES.decode("utf-8")
|
||||
for attribute in ("fill", "stroke"):
|
||||
svg = _replace_svg_color(svg, attribute, colors["fg"])
|
||||
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
svg = _render_svg(colors).encode("utf-8")
|
||||
png_bytes = cairosvg.svg2png(bytestring=svg, output_width=ASSET_SIZE, output_height=ASSET_SIZE)
|
||||
image = Image.open(BytesIO(png_bytes))
|
||||
image.save(target, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)])
|
||||
image.close()
|
||||
return target
|
||||
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 = _pick_profile(host)
|
||||
profile = MAPPINGS.get(host) or DEFAULT_PROFILE
|
||||
if not profile:
|
||||
self.send_error(404, "No favicon mapping for host")
|
||||
return
|
||||
|
||||
asset_path = _generate_asset(host, profile)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user