Files
stackpilot/backend/services/icon_service.py
T
menzeljandClaude Opus 5 b629d1b2c2
CI / check (push) Successful in 12m8s
CI / build-and-push (push) Successful in 2m1s
Use the apps' real logos as stack icons, fetched server-side (0.52.0)
0.51.0 gave every stack an icon, but a generic one: jellyfin got a clapperboard,
not the Jellyfin logo. Glyphs make a list readable; they do not make a stack
recognisable, which was the point. This resolves stacks against the selfh.st
icon catalog (~2900 self-hosted apps, the set Homarr and Homepage draw on), so
the row shows the thing people already recognise. All 83 bundled templates
resolve to their own logo.

The whole design question was *who* talks to the CDN. If the <img> points at
jsdelivr, then every client needs internet, every page load leaks the names of
somebody's stacks to a third party, and an air-gapped box gets nothing. So the
backend does it: the catalog on startup and weekly after, each logo once on
first use, both into ${DATA_DIR}/stack-icons/. Browsers keep reading icons from
the authenticated endpoint that already existed for uploads, and after the first
fetch the feature is fully offline. Logos are cached per *app*, not per stack —
verified: two stacks resolving to jellyfin produce one download.

Nothing here can fail loudly. Every entry point returns None rather than raising
when the network is absent, the catalog refresh is a task the lifespan does not
await, and an install with no outbound internet simply keeps 0.51.0's glyphs.
That fallback is also what covers a name the catalog does not know
("Mediaserver Wohnzimmer" is still a clapperboard), and the seconds after a
fresh install before the catalog lands. The glyph is derived even for stacks
that *do* have a logo, so an image that cannot be fetched degrades to something
meaningful instead of a box.

Matching gained a second source that turned out to matter more than expected:
the compose images. A stack called "medienserver" says nothing, but it pulls
lscr.io/linuxserver/jellyfin — strip the registry, the vendor and the tag and
the app is right there. Name first, then the longest run of words inside it,
then the images. It is deliberately cautious: a single word shorter than four
characters never claims a logo, because "web", "app" and "db" are all catalog
entries and a *wrong* logo is worse than a neutral glyph. A short alias table
covers what the catalog spells differently from Docker Hub (postgres →
postgresql, pihole → pi-hole, wg-easy → wireguard).

A slug arrives from the database and from query strings and then becomes a
filename, so it is pattern-checked before it is ever joined to a path, catalog
entries that are not slug-shaped are dropped on load, and a downloaded logo is
verified to start with the PNG magic bytes before being cached.

The picker searches the catalog too — pre-seeded with the stack's own name, so
opening it on "jellyfin" offers the Jellyfin logo first — which is how a wrong
match gets corrected, and how a stack can be given any app's logo on purpose.

Verified end to end against the live catalog and real downloads: list rows carry
the resolved logo, the icon endpoint serves real PNG bytes, an unmatched stack
404s (and falls through to its glyph), a hand-picked logo round-trips, reset
clears it, and a traversal slug 404s. 30 new backend tests and 12 new frontend
ones run without any network at all.

0.52.0 rather than amending 0.51.0: those images are already in the registry,
and rebuilding a published version tag with different content is exactly what
breaks the self-update checker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:39:14 +02:00

222 lines
7.9 KiB
Python

"""Stack icons — validation of the stored choice and custom-image storage.
A stack's ``icon`` column holds one of three things:
``None``
Automatic: the app logo the stack's name resolves to, and failing that the
glyph the frontend derives from the name (``frontend/src/lib/stackIcons.ts``).
Nothing is stored, so every stack that existed before this feature lands
here and an upgraded install shows sensible icons without a data migration.
``logo:<slug>``
The real logo of a known app, from the selfh.st catalog (see
``services/logo_service.py``). The file is cached per *slug*, not per stack,
so every Postgres stack shares one download.
``lucide:<name>``
A built-in icon the user picked explicitly. The catalog of names lives in
the frontend because that is the only place that can actually *render* one;
keeping a second copy here would only add a list to drift out of sync. An
unknown name is therefore not an error — the UI falls back to the automatic
icon for it.
``custom:<ext>:<version>``
An uploaded image at ``${DATA_DIR}/stack-icons/<stack_id>.<ext>``.
``<version>`` is the upload's unix timestamp. It carries no meaning beyond
changing the column value on every re-upload, which is what makes the
frontend's cache key change and the new image appear instead of the one the
browser already has.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import time
from typing import Optional
from config import settings
logger = logging.getLogger("stackpilot.icons")
#: Uploads are meant to be small app logos. The cap is deliberately generous
#: for a logo and still far too small to make the data directory grow.
MAX_ICON_BYTES = 512 * 1024
#: Extension per accepted image type. The extension is derived from the bytes
#: (see :func:`_sniff`), never from the upload's filename or Content-Type — a
#: client is free to lie about both.
_EXTENSIONS = {"png", "jpg", "gif", "webp", "svg"}
_BUILTIN_RE = re.compile(r"^lucide:[a-z0-9-]{1,48}$")
_LOGO_RE = re.compile(r"^logo:[a-z0-9][a-z0-9-]{0,63}$")
_CUSTOM_RE = re.compile(r"^custom:(png|jpg|gif|webp|svg):(\d{1,12})$")
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
#: ``<img src>`` never executes script, but a custom icon is also reachable
#: directly under /api/..., where an SVG *would* run in the browser's own
#: context. Serving it as a download-only attachment keeps that door shut.
_CONTENT_TYPES = {
"png": "image/png",
"jpg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"svg": "image/svg+xml",
}
class IconError(Exception):
"""An icon value or upload the server refuses."""
# --------------------------------------------------------------------------- #
# Paths
# --------------------------------------------------------------------------- #
def icon_dir() -> str:
return os.path.join(settings.DATA_DIR, "stack-icons")
def _safe_id(stack_id: str) -> str:
"""Reject anything that could escape the icon directory.
Stack ids are slugs, so this never fires in practice — it is here because
the id arrives from the URL and is about to be pasted into a filesystem
path.
"""
if not _SAFE_ID_RE.match(stack_id) or stack_id in (".", ".."):
raise IconError(f"Invalid stack id '{stack_id}'")
return stack_id
def custom_path(stack_id: str, ext: str) -> str:
return os.path.join(icon_dir(), f"{_safe_id(stack_id)}.{ext}")
def custom_ext(value: Optional[str]) -> Optional[str]:
"""The file extension of a ``custom:`` icon value, or None for the rest."""
match = _CUSTOM_RE.match(value or "")
return match.group(1) if match else None
def content_type(ext: str) -> str:
return _CONTENT_TYPES.get(ext, "application/octet-stream")
def file_for(stack_id: str, value: Optional[str]) -> Optional[str]:
"""Existing file backing a ``custom:`` icon value, or None."""
ext = custom_ext(value)
if not ext:
return None
path = custom_path(stack_id, ext)
return path if os.path.isfile(path) else None
# --------------------------------------------------------------------------- #
# Stored value
# --------------------------------------------------------------------------- #
def normalize_choice(value: str) -> Optional[str]:
"""Validate an icon chosen through the API.
An empty string means "back to automatic" and maps to ``None``. A built-in
glyph and an app logo are both fine to take from a client — they name a
catalog entry, not a file this stack owns. A ``custom:`` value is not: it is
minted by :func:`store_upload`, or a stack could be pointed at another
stack's uploaded image.
"""
value = (value or "").strip()
if not value:
return None
if _BUILTIN_RE.match(value) or _LOGO_RE.match(value):
return value
raise IconError(
"Icon must be empty (automatic), 'lucide:<name>' or 'logo:<slug>'; "
"upload custom images through POST /api/stacks/{id}/icon"
)
def logo_slug(value: Optional[str]) -> Optional[str]:
"""The catalog slug of a ``logo:`` icon value, or None for the rest."""
return value[len("logo:"):] if _LOGO_RE.match(value or "") else None
# --------------------------------------------------------------------------- #
# Uploads
# --------------------------------------------------------------------------- #
def _sniff(data: bytes) -> str:
"""Extension for the image these bytes actually are.
Trusting the declared Content-Type would mean storing (and later serving)
whatever a client cares to send under an image's name.
"""
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if data.startswith(b"\xff\xd8\xff"):
return "jpg"
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
return "gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
head = data[:512].lstrip()
if head.startswith(b"<?xml") or head.startswith(b"<svg") or b"<svg" in head:
return "svg"
raise IconError("Unsupported image type — use PNG, JPEG, GIF, WebP or SVG")
def store_upload(stack_id: str, data: bytes) -> str:
"""Write an uploaded icon and return the value for ``Stack.icon``."""
if not data:
raise IconError("The uploaded file is empty")
if len(data) > MAX_ICON_BYTES:
raise IconError(
f"Icon is too large ({len(data) // 1024} KiB); "
f"the limit is {MAX_ICON_BYTES // 1024} KiB"
)
ext = _sniff(data)
os.makedirs(icon_dir(), exist_ok=True)
# A re-upload in a different format would otherwise leave the old file
# behind as an orphan nothing ever cleans up.
remove(stack_id)
path = custom_path(stack_id, ext)
with open(path, "wb") as fh:
fh.write(data)
return f"custom:{ext}:{int(time.time())}"
def remove(stack_id: str) -> None:
"""Delete every custom icon file belonging to a stack. Best effort."""
for ext in _EXTENSIONS:
try:
os.remove(custom_path(stack_id, ext))
except FileNotFoundError:
continue
except OSError as exc: # noqa: PERF203 - one bad file must not block the rest
logger.warning("Could not remove icon %s.%s: %s", stack_id, ext, exc)
def copy(src_id: str, dst_id: str, value: Optional[str]) -> Optional[str]:
"""Copy a stack's custom icon to another stack (used when cloning).
Returns the icon value for the new stack: the copied ``custom:`` value, the
unchanged built-in choice, or None when there is nothing to carry over.
"""
ext = custom_ext(value)
if not ext:
return value
source = file_for(src_id, value)
if not source:
return None
os.makedirs(icon_dir(), exist_ok=True)
try:
shutil.copyfile(source, custom_path(dst_id, ext))
except OSError as exc:
logger.warning("Could not copy icon %s -> %s: %s", src_id, dst_id, exc)
return None
return f"custom:{ext}:{int(time.time())}"