b629d1b2c2bc3f2302b0959f0e4b7fc91f92d771
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b629d1b2c2 |
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>
|
||
|
|
7682460b4f |
Give every stack an icon, and put the status on it (0.51.0)
Stacks were a name and a coloured dot. The dot carried the status but nothing
carried identity, so a list of twenty stacks read as twenty identical rows.
This gives each one an icon in front of its name and moves the status onto that
icon as a halo in the status colour, which is the thing the eye lands on anyway.
The constraint that shaped the design: people already have stacks. Asking them
to pick an icon for each one before the feature does anything would mean it
never gets used, so the icon is *derived* from the stack's name and the column
stays empty until somebody overrides it. ~700 keywords in 79 groups cover the
self-hosted long tail (jellyfin -> clapperboard, vaultwarden -> key,
home-assistant -> house) plus generic English and German terms; the longest
match wins, so photoprism beats a bare photo, and short keywords like "tv" only
match as whole words. No backfill, no migration, and a rename moves the icon
with it.
That is also why the catalog and the matcher live in the frontend. It is the
only place that can render an icon, so a copy in the backend would be a list to
keep in sync and nothing else. The server validates the shape of the stored
value and stores uploads; it never needs to know what "lucide:database" looks
like. An icon name that later leaves the catalog falls back to the derived one
rather than blanking the row.
Overriding happens in two places, because there are two moments: the editor
(holding a chosen file until the stack exists, since uploading needs an id) and
a click on the icon on the detail page, which is how a stack that has existed
for a year gets one without a trip through the editor.
Uploads are classified by their bytes, not by the filename or Content-Type the
browser claims, and land in ${DATA_DIR}/stack-icons/ under the stack id. SVG is
allowed — <img> does not execute it — but the endpoint serves every icon as an
attachment so one can never be opened as a document in the API's own origin. A
client-supplied "custom:" value is refused: the server mints those, so a stack
cannot be pointed at a file it does not own. Files follow the stack: replaced on
re-upload (including across formats, or the old one orphans), copied on clone,
removed on delete.
The one piece of plumbing worth knowing about: the icon endpoint needs the
bearer token like everything else, and an <img src> would not carry it. So
StackIcon fetches the bytes through the API client and renders the blob, keyed
on the stored value — which carries an upload timestamp precisely so a re-upload
changes the key and retires the cached image.
Covered by 22 backend tests (the value rules, byte-sniffing, the file lifecycle,
the API round-trip, and that the read-only role cannot change an icon) and 29
frontend ones for the matcher. The schema change was verified against a
hand-built pre-0.51 database: the column is added on start and existing rows
come back NULL, i.e. automatic. Not click-tested in a browser — no Docker in
this environment — so the row height the taller icon produces is unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
51d1998307 |
Remove the remote-host (agent) integration (0.48.0)
StackPilot now manages exactly one Docker host: the one it runs on. The
stackpilot-agent sidecar and everything that proxied to it are gone — 4721
lines deleted against 657 added.
Deleted outright: agent/ (image, compose, env), agent_app.py, models/agent.py,
routers/agents.py (1200 lines), services/agent_service.py, the agent API client,
RemoteStackDetail, the host components and AgentStacksSection. That removes 57
API routes and the three /ws/agent-* proxies.
Threaded out everywhere else, which was the bulk of the work. Every API module
carried an optional agentId that switched the base path; every page that listed
Docker objects rendered one section per host behind a HostHeader; Files had a
host switcher; the New Stack editor and the template dialog had host selectors;
schedules, auto-update policies and stack summaries carried agent_id. All of it
is gone, and the typechecker drove the sweep — 85 files touched, tsc and the
build clean.
Two things the removal exposed as dead weight rather than merely unused:
compose_service kept an in-process busy set purely because the agent needed a
lock and has no database. With the agent gone that was a second source of truth
next to the real DB lock, so it is deleted; compute_status now reports only what
the containers say and the two callers that want "updating" overlay the lock.
StacksTable's linkBase prop only ever existed to point at /hosts/{id}/stacks.
The dashboard's "Hosts 1/1 online" KPI can no longer say anything else, so the
tile and the KPIs behind it are gone and the row is five wide.
Upgrading matters here. An existing install still has an agent table holding
each remote host's URL and bearer token — full Docker control of that host,
sitting in the database with nothing left to use it. _drop_removed_schema drops
it on first start, and drops the agent_id columns where the SQLite build
supports DROP COLUMN. Each statement runs in its own transaction on purpose: a
failed DDL poisons the transaction it is in, so sharing one would let an
unsupported column drop take the table drop down with it. test_agent_removal
covers both branches plus the fresh-install and idempotent cases, and an
end-to-end run against a seeded pre-0.48 database confirms the table is gone and
every /api/agents route answers 404.
Docstrings that justified a design by "shared with the agent, which has no
database" were rewritten rather than left lying: update_service's persistence
callback and image_status_store are still the right split (registry logic stays
testable without a database), but for that reason now, not the old one. The
README's multi-host sections are removed and an upgrade note explains what to do
with running agent containers; ROADMAP keeps its history behind a note saying
the feature it describes no longer exists.
CI no longer builds or pushes stackpilot-agent.
735 tests pass, ruff and tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
|
||
|
|
41a21b5a25 |
Make tokens revocable and move the refresh token out of localStorage (0.46.0)
F5 — A token was valid until it expired, full stop. Resetting a compromised account's password changed nothing for whoever held its tokens (up to 30 days for a refresh token), demoting or disabling an account only took effect once the same clock ran out, and logout was purely client-side. Every account now has a token_version, every token is minted carrying it, and every request compares the two. Bumping it is the revoke switch, pulled on the three changes that alter what an account may do: password, role, active flag. "Sign out everywhere" in the user menu bumps your own. Plain "Sign out" only drops the cookie, because signing out on your phone should not kill your desktop session. The refresh token left localStorage for an httpOnly cookie (SameSite=Lax, scoped to /api/auth), and the access token is now held in memory only. A successful XSS can still act inside the open page but can no longer walk off with 30 days of access. The cookie is marked Secure only when the request arrived over HTTPS — request.url.scheme is trustworthy since the F4 fix — so a plain-HTTP homelab keeps working. Any refresh token an older build left in localStorage is deleted on first load. Scripted clients that cannot hold a cookie can still ask for it in the body with ?in_body=true. F9 comes with it, as predicted: the WebSocket helpers read the role off the live user instead of the token's claim. /ws/exec is root-equivalent on the host, and a token minted while the account was an admin stayed syntactically valid after a demotion. The sharp edge was the migration, not the feature. _ensure_model_columns emits ADD COLUMN without a DEFAULT, so SQLite would have filled token_version with NULL on every existing install, every version check would have failed against it, and the upgrade would have locked out every user everywhere. The helper now renders NOT NULL DEFAULT <literal> for scalar defaults; test_schema_migration builds a genuinely old-shaped user table and asserts the backfill. The version comparison also tolerates NULL as 1, so a database migrated by some other route still works. Writing that test surfaced an undocumented precondition: _ensure_model_columns does nothing unless `models` has been imported, since SQLModel.metadata is empty until then. It holds in production because init_db imports first; now it says so. The authorization matrix did its job — adding two auth routes failed the suite until both were classified, which is exactly the review moment it exists for. 30 new tests (698 total). Upgrading signs everyone out once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG |
||
|
|
60a7ccff93 |
Add a test suite, a linter and a CI gate in front of the build (0.45.0)
The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.
670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.
test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.
test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.
Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.
test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.
The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.
ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).
CI now runs check (ruff, pytest, tsc) and only builds if it passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
|