Files
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

199 lines
6.8 KiB
Python

"""App logos: matching a stack to a catalog slug, and the caching around it.
Nothing here touches the network. The catalog is a file on disk, so the tests
write one; the download paths are covered by pointing them at a stub client.
What matters is the matching — it decides the icon of every stack that has not
been given one by hand — and that a slug can never become a path.
"""
from __future__ import annotations
import json
import os
import pytest
CATALOG = [
{"Name": "Jellyfin", "Reference": "jellyfin"},
{"Name": "Plex", "Reference": "plex"},
{"Name": "AdGuard Home", "Reference": "adguard-home"},
{"Name": "Pi-hole", "Reference": "pi-hole"},
{"Name": "PostgreSQL", "Reference": "postgresql"},
{"Name": "Home Assistant", "Reference": "home-assistant"},
{"Name": "Homepage", "Reference": "homepage"},
{"Name": "Paperless-ngx", "Reference": "paperless-ngx"},
{"Name": "Vaultwarden", "Reference": "vaultwarden"},
{"Name": "Gitea", "Reference": "gitea"},
{"Name": "Web", "Reference": "web"},
{"Name": "Bad Slug", "Reference": "../../etc/passwd"},
]
@pytest.fixture
def svc(db, tmp_path, monkeypatch):
"""logo_service with a catalog on disk and its caches cleared."""
from services import logo_service
monkeypatch.setattr(logo_service.settings, "DATA_DIR", str(tmp_path))
os.makedirs(os.path.join(tmp_path, "stack-icons"), exist_ok=True)
with open(logo_service.catalog_path(), "w", encoding="utf-8") as fh:
json.dump(CATALOG, fh)
logo_service._catalog = None
logo_service._catalog_mtime = 0.0
logo_service._resolved.clear()
logo_service.load_catalog()
return logo_service
# --------------------------------------------------------------------------- #
# Matching
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"name,slug",
[
("jellyfin", "jellyfin"),
("Jellyfin", "jellyfin"),
# Display name with a space vs. a hyphenated slug, in both directions.
("AdGuard Home", "adguard-home"),
("adguard-home", "adguard-home"),
# People drop the punctuation the catalog keeps.
("pihole", "pi-hole"),
("Paperless NGX", "paperless-ngx"),
# The app's name inside a longer one.
("my-jellyfin-stack", "jellyfin"),
("Medien Plex Wohnzimmer", "plex"),
# An alias for what the Docker image is called.
("postgres", "postgresql"),
],
)
def test_a_stack_name_finds_its_app(svc, name, slug):
assert svc.match_slug(name) == slug
def test_the_longest_run_of_words_wins(svc):
""""home assistant" must not lose to "home" or "homepage"."""
assert svc.match_slug("Home Assistant") == "home-assistant"
def test_a_name_that_means_nothing_gets_no_logo(svc):
assert svc.match_slug("zzz-42") is None
assert svc.match_slug("") is None
def test_a_single_short_word_is_not_enough(svc):
""""web" is a catalog entry, so a stack called "web ui" must not claim it —
a wrong logo is worse than the generic glyph."""
assert svc.match_slug("web ui") is None
# Spelled out on its own it is a deliberate match.
assert svc.match_slug("web") == "web"
def test_the_images_answer_when_the_name_does_not(svc):
assert svc.match_slug("medienserver", ["jellyfin"]) == "jellyfin"
# The name still wins when it matches by itself.
assert svc.match_slug("gitea", ["jellyfin"]) == "gitea"
def test_a_malformed_catalog_entry_is_ignored(svc):
assert "../../etc/passwd" not in svc.load_catalog().slugs
def test_no_catalog_means_no_logo(svc, tmp_path):
os.remove(svc.catalog_path())
svc._catalog = None
svc._catalog_mtime = 0.0
assert svc.load_catalog() is None
assert svc.match_slug("jellyfin") is None
# --------------------------------------------------------------------------- #
# Image names out of a compose file
# --------------------------------------------------------------------------- #
def test_image_names_drop_registry_vendor_and_tag(svc, monkeypatch, tmp_path):
stack_dir = tmp_path / "stacks" / "medien"
stack_dir.mkdir(parents=True)
(stack_dir / "compose.yaml").write_text(
"services:\n"
" app:\n"
" image: lscr.io/linuxserver/jellyfin:latest\n"
" db:\n"
" image: postgres:16-alpine\n"
)
monkeypatch.setattr(
svc.compose_service, "stack_dir", lambda sid, override=None: str(stack_dir)
)
assert svc.images_for("medien") == ["jellyfin", "postgres"]
def test_a_stack_with_no_compose_file_has_no_images(svc, monkeypatch, tmp_path):
monkeypatch.setattr(
svc.compose_service, "stack_dir", lambda sid, override=None: str(tmp_path / "nope")
)
assert svc.images_for("nope") == []
# --------------------------------------------------------------------------- #
# Caching
# --------------------------------------------------------------------------- #
def test_the_match_is_recomputed_when_the_name_changes(svc, monkeypatch):
calls = []
real = svc.match_slug
monkeypatch.setattr(
svc, "match_slug", lambda name, images=(): (calls.append(name), real(name, images))[1]
)
assert svc.auto_slug("s1", "jellyfin") == "jellyfin"
assert svc.auto_slug("s1", "jellyfin") == "jellyfin"
assert len(calls) == 1, "a repeat lookup must come from the memo"
assert svc.auto_slug("s1", "gitea") == "gitea"
assert len(calls) == 2, "a rename must re-match"
def test_forgetting_a_stack_drops_its_match(svc):
svc.auto_slug("s1", "jellyfin")
svc.forget("s1")
assert "s1" not in svc._resolved
# --------------------------------------------------------------------------- #
# Slug → path
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"slug", ["../../../etc/passwd", "..", "foo/bar", "foo.png", "", "UPPER", "-lead"]
)
def test_a_slug_that_is_not_a_slug_has_no_path(svc, slug):
assert svc.logo_path(slug) is None
def test_a_real_slug_lands_in_the_logo_directory(svc):
path = svc.logo_path("jellyfin")
assert path == os.path.join(svc.logo_dir(), "jellyfin.png")
# --------------------------------------------------------------------------- #
# Search
# --------------------------------------------------------------------------- #
def test_search_ranks_exact_then_prefix_then_substring(svc):
slugs = [row["slug"] for row in svc.search("home")]
# "Home Assistant" starts with it; "AdGuard Home" only contains it.
assert slugs.index("home-assistant") < slugs.index("adguard-home")
def test_search_with_no_term_lists_the_catalog(svc):
assert len(svc.search("", limit=5)) == 5
def test_search_without_a_catalog_is_empty_not_an_error(svc):
os.remove(svc.catalog_path())
svc._catalog = None
svc._catalog_mtime = 0.0
assert svc.search("jellyfin") == []