fix favicon
This commit is contained in:
@@ -5,7 +5,6 @@ let
|
|||||||
palette = serverCfg.colorScheme.palette or { };
|
palette = serverCfg.colorScheme.palette or { };
|
||||||
port = 8080;
|
port = 8080;
|
||||||
assetSize = 64;
|
assetSize = 64;
|
||||||
cacheControl = containerCfg.extra.cacheControl or "public, max-age=86400";
|
|
||||||
priority = toString (containerCfg.extra.priority or 2147482647);
|
priority = toString (containerCfg.extra.priority or 2147482647);
|
||||||
logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg);
|
logoSvgFileName = builtins.baseNameOf (toString mediaCfg.logo.svg);
|
||||||
logoSvgMount = "/assets/${logoSvgFileName}";
|
logoSvgMount = "/assets/${logoSvgFileName}";
|
||||||
@@ -64,46 +63,29 @@ let
|
|||||||
+ "|apple-touch-icon(-precomposed)?\\.png"
|
+ "|apple-touch-icon(-precomposed)?\\.png"
|
||||||
+ "|android-chrome-[0-9]+x[0-9]+\\.png"
|
+ "|android-chrome-[0-9]+x[0-9]+\\.png"
|
||||||
+ "|mstile-[0-9]+x[0-9]+\\.png)$";
|
+ "|mstile-[0-9]+x[0-9]+\\.png)$";
|
||||||
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; [
|
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||||
cairosvg
|
cairosvg
|
||||||
pillow
|
pillow
|
||||||
]);
|
]);
|
||||||
serverScript = pkgs.writeText "favicon-server.py" ''
|
serverScript = pkgs.writeText "favicon-server.py" ''
|
||||||
import hashlib
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import cairosvg
|
import cairosvg
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
CONFIG_PATH = os.environ.get("FAVICON_CONFIG", "/config/config.json")
|
LOGO_PATH = ${builtins.toJSON logoSvgMount}
|
||||||
LOGO_PATH = os.environ.get("FAVICON_LOGO", "/assets/logo.svg")
|
LISTEN_HOST = "0.0.0.0"
|
||||||
CACHE_DIR = Path(os.environ.get("FAVICON_CACHE_DIR", "/cache"))
|
LISTEN_PORT = ${toString port}
|
||||||
LISTEN_HOST = os.environ.get("FAVICON_LISTEN_HOST", "0.0.0.0")
|
ASSET_SIZE = ${toString assetSize}
|
||||||
LISTEN_PORT = int(os.environ.get("FAVICON_PORT", "8080"))
|
CACHE_CONTROL = "no-store"
|
||||||
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:
|
with open(LOGO_PATH, "rb") as fh:
|
||||||
LOGO_BYTES = fh.read()
|
LOGO_BYTES = fh.read()
|
||||||
MAPPINGS = APP_CONFIG.get("mappings", {})
|
MAPPINGS = ${builtins.toJSON hostMappings}
|
||||||
DEFAULT_PROFILE = APP_CONFIG.get("default")
|
DEFAULT_PROFILE = ${if defaultProfile == null then "None" else builtins.toJSON defaultProfile}
|
||||||
APP_DOMAIN = (APP_CONFIG.get("domain", "") or "").strip().lower()
|
APP_DOMAIN = (${builtins.toJSON serverCfg.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"}
|
DEFAULT_COLORS = {"bg": "#111827", "fg": "#f8fafc"}
|
||||||
|
|
||||||
def _request_host(headers):
|
def _request_host(headers):
|
||||||
@@ -138,30 +120,15 @@ let
|
|||||||
return candidate, profile
|
return candidate, profile
|
||||||
return None, DEFAULT_PROFILE
|
return None, DEFAULT_PROFILE
|
||||||
|
|
||||||
def _replace_svg_color(svg, attribute, color):
|
def _replace_logo_fill(svg, color):
|
||||||
if attribute not in {"fill", "stroke"}:
|
|
||||||
return svg
|
|
||||||
|
|
||||||
svg = re.sub(
|
|
||||||
rf'({attribute}\\s*=\\s*")(?!none\\b)[^"]*(")',
|
|
||||||
rf"\\1{color}\\2",
|
|
||||||
svg,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
svg = re.sub(
|
|
||||||
rf"({attribute}\\s*=\\s*')(?!none\\b)[^']*(')",
|
|
||||||
rf"\\1{color}\\2",
|
|
||||||
svg,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
return re.sub(
|
return re.sub(
|
||||||
rf"({attribute}\\s*:\\s*)(?!none\\b)[^;\"']+",
|
r"fill:\\s*#3193f5\\b",
|
||||||
rf"\\1{color}",
|
f"fill:{color}",
|
||||||
svg,
|
svg,
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
border_radius = str(APP_CONFIG.get("borderRadius", "8")).strip()
|
border_radius = str(${builtins.toJSON borderRadius}).strip()
|
||||||
if not border_radius.endswith("px"):
|
if not border_radius.endswith("px"):
|
||||||
border_radius = f"{border_radius}px"
|
border_radius = f"{border_radius}px"
|
||||||
border_radius_px = max(0, int(float(border_radius[:-2])))
|
border_radius_px = max(0, int(float(border_radius[:-2])))
|
||||||
@@ -173,34 +140,17 @@ let
|
|||||||
"fg": profile.get("fg") or profile.get("foreground") or DEFAULT_COLORS["fg"],
|
"fg": profile.get("fg") or profile.get("foreground") or DEFAULT_COLORS["fg"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _asset_path(host, colors):
|
def _render_icon(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")
|
svg = LOGO_BYTES.decode("utf-8")
|
||||||
for attribute in ("fill", "stroke"):
|
svg = _replace_logo_fill(svg, colors["fg"])
|
||||||
svg = _replace_svg_color(svg, attribute, colors["fg"])
|
print(f"favicon-render fg={colors['fg']} bg={colors['bg']}")
|
||||||
|
|
||||||
logo_png = cairosvg.svg2png(
|
logo_png = cairosvg.svg2png(
|
||||||
bytestring=svg.encode("utf-8"),
|
bytestring=svg.encode("utf-8"),
|
||||||
output_width=ASSET_SIZE,
|
output_width=ASSET_SIZE,
|
||||||
output_height=ASSET_SIZE,
|
output_height=ASSET_SIZE,
|
||||||
)
|
)
|
||||||
tmp_target = target.with_suffix(".tmp")
|
output = BytesIO()
|
||||||
with Image.new("RGBA", (ASSET_SIZE, ASSET_SIZE), (0, 0, 0, 0)) as canvas:
|
with Image.new("RGBA", (ASSET_SIZE, ASSET_SIZE), (0, 0, 0, 0)) as canvas:
|
||||||
ImageDraw.Draw(canvas).rounded_rectangle(
|
ImageDraw.Draw(canvas).rounded_rectangle(
|
||||||
(0, 0, ASSET_SIZE, ASSET_SIZE),
|
(0, 0, ASSET_SIZE, ASSET_SIZE),
|
||||||
@@ -209,8 +159,8 @@ let
|
|||||||
)
|
)
|
||||||
with Image.open(BytesIO(logo_png)) as logo:
|
with Image.open(BytesIO(logo_png)) as logo:
|
||||||
canvas.alpha_composite(logo.convert("RGBA"))
|
canvas.alpha_composite(logo.convert("RGBA"))
|
||||||
canvas.save(tmp_target, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)])
|
canvas.save(output, format="ICO", sizes=[(ASSET_SIZE, ASSET_SIZE)])
|
||||||
os.replace(tmp_target, target)
|
return output.getvalue()
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "favicon-router/1.0"
|
server_version = "favicon-router/1.0"
|
||||||
@@ -218,31 +168,19 @@ let
|
|||||||
def _serve(self, include_body):
|
def _serve(self, include_body):
|
||||||
host = _request_host(self.headers)
|
host = _request_host(self.headers)
|
||||||
matched_host, profile = _profile_for_host(host)
|
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:
|
if not profile:
|
||||||
|
print("favicon-request no-profile")
|
||||||
self.send_error(404, "No favicon mapping for host")
|
self.send_error(404, "No favicon mapping for host")
|
||||||
return
|
return
|
||||||
|
|
||||||
colors = _colors(profile)
|
colors = _colors(profile)
|
||||||
asset_path = _asset_path(host, colors)
|
print(f"favicon-colors bg={colors['bg']} fg={colors['fg']}")
|
||||||
if not asset_path.exists():
|
payload = _render_icon(colors)
|
||||||
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.send_header("X-Favicon-Host", host or "default")
|
|
||||||
self.send_header("X-Favicon-Mapping", matched_host or "default")
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = asset_path.read_bytes()
|
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "image/x-icon")
|
self.send_header("Content-Type", "image/x-icon")
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
self.send_header("Cache-Control", CACHE_CONTROL)
|
self.send_header("Cache-Control", CACHE_CONTROL)
|
||||||
self.send_header("ETag", etag)
|
|
||||||
self.send_header("X-Favicon-Host", host or "default")
|
self.send_header("X-Favicon-Host", host or "default")
|
||||||
self.send_header("X-Favicon-Mapping", matched_host or "default")
|
self.send_header("X-Favicon-Mapping", matched_host or "default")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -278,25 +216,18 @@ let
|
|||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
runtime = {
|
runtime = {
|
||||||
paths = [
|
# paths = [
|
||||||
{
|
# {
|
||||||
path = "${serverCfg.path.config.path}/favicon";
|
# path = "${serverCfg.path.config.path}/favicon";
|
||||||
mode = "0755";
|
# mode = "0755";
|
||||||
dirs = [ "cache" ];
|
# dirs = [ "cache" ];
|
||||||
}
|
# }
|
||||||
];
|
# ];
|
||||||
|
|
||||||
containers = {
|
containers = {
|
||||||
server = builder.mkContainer {
|
server = builder.mkContainer {
|
||||||
imageStream = image;
|
imageStream = image;
|
||||||
port = port;
|
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 = {
|
extraLabels = {
|
||||||
"traefik.enable" = "true";
|
"traefik.enable" = "true";
|
||||||
"traefik.http.routers.${name}.entrypoints" = "web-secure";
|
"traefik.http.routers.${name}.entrypoints" = "web-secure";
|
||||||
@@ -307,8 +238,7 @@ in {
|
|||||||
};
|
};
|
||||||
overrides = {
|
overrides = {
|
||||||
volumes = [
|
volumes = [
|
||||||
"${configFile}:/config/config.json:ro"
|
# "${serverCfg.path.config.path}/favicon/cache:/cache"
|
||||||
"${serverCfg.path.config.path}/favicon/cache:/cache"
|
|
||||||
"${mediaCfg.logo.svg}:${logoSvgMount}:ro"
|
"${mediaCfg.logo.svg}:${logoSvgMount}:ro"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user