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>
This commit is contained in:
@@ -15,6 +15,10 @@ class Stack(SQLModel, table=True):
|
||||
id: str = Field(primary_key=True)
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
# None = automatic (the UI derives one from the name), "lucide:<name>" for a
|
||||
# built-in icon, "custom:<ext>:<version>" for an uploaded image.
|
||||
# See services/icon_service.py.
|
||||
icon: Optional[str] = None
|
||||
stacks_dir_override: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=_now)
|
||||
updated_at: datetime = Field(default_factory=_now)
|
||||
@@ -26,6 +30,7 @@ class Stack(SQLModel, table=True):
|
||||
class StackCreate(SQLModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None # "lucide:<name>", or None/"" for automatic
|
||||
yaml: Optional[str] = None # initial compose content
|
||||
env: Optional[str] = None
|
||||
|
||||
@@ -33,6 +38,8 @@ class StackCreate(SQLModel):
|
||||
class StackUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
# Omitted leaves the icon alone; "" resets it to automatic.
|
||||
icon: Optional[str] = None
|
||||
yaml: Optional[str] = None
|
||||
env: Optional[str] = None
|
||||
|
||||
|
||||
+107
-4
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -31,6 +31,7 @@ from services import (
|
||||
audit_service,
|
||||
auto_update_service,
|
||||
compose_service,
|
||||
icon_service,
|
||||
notify_service,
|
||||
stack_lock_service,
|
||||
stats_service,
|
||||
@@ -99,6 +100,7 @@ def _stack_summary(
|
||||
"id": stack.id,
|
||||
"name": stack.name,
|
||||
"description": stack.description,
|
||||
"icon": stack.icon,
|
||||
"status": status,
|
||||
"service_count": total,
|
||||
"running_count": running,
|
||||
@@ -137,10 +139,16 @@ def create_stack(
|
||||
stack_id = compose_service.slugify(body.name)
|
||||
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)):
|
||||
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
|
||||
try:
|
||||
icon = icon_service.normalize_choice(body.icon or "")
|
||||
except icon_service.IconError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
compose_service.write_compose(stack_id, body.yaml or "services:\n")
|
||||
if body.env:
|
||||
compose_service.write_env(stack_id, body.env)
|
||||
stack = Stack(id=stack_id, name=body.name, description=body.description)
|
||||
stack = Stack(
|
||||
id=stack_id, name=body.name, description=body.description, icon=icon
|
||||
)
|
||||
session.add(stack)
|
||||
session.commit()
|
||||
session.refresh(stack)
|
||||
@@ -185,6 +193,7 @@ def get_stack(
|
||||
"id": stack.id,
|
||||
"name": stack.name,
|
||||
"description": stack.description,
|
||||
"icon": stack.icon,
|
||||
"status": status,
|
||||
"yaml": compose_service.read_compose(stack_id),
|
||||
# The .env is where credentials live by convention, so it is withheld
|
||||
@@ -214,6 +223,16 @@ def update_stack(
|
||||
stack.name = body.name
|
||||
if body.description is not None:
|
||||
stack.description = body.description
|
||||
if body.icon is not None:
|
||||
try:
|
||||
icon = icon_service.normalize_choice(body.icon)
|
||||
except icon_service.IconError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
# Switching to a built-in icon (or back to automatic) makes any
|
||||
# uploaded image dead weight, so it goes with the choice.
|
||||
if icon_service.custom_ext(stack.icon) and icon != stack.icon:
|
||||
icon_service.remove(stack_id)
|
||||
stack.icon = icon
|
||||
stack.updated_at = compose_service.now()
|
||||
session.add(stack)
|
||||
session.commit()
|
||||
@@ -240,6 +259,7 @@ async def delete_stack(
|
||||
pass
|
||||
if delete_files:
|
||||
compose_service.delete_stack_files(stack_id)
|
||||
icon_service.remove(stack_id)
|
||||
session.delete(stack)
|
||||
session.commit()
|
||||
audit_service.record(
|
||||
@@ -257,7 +277,7 @@ def clone_stack(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
_get_stack_or_404(session, stack_id)
|
||||
source = _get_stack_or_404(session, stack_id)
|
||||
new_id = compose_service.slugify(body.name)
|
||||
if session.get(Stack, new_id):
|
||||
raise HTTPException(status_code=409, detail=f"Stack '{new_id}' already exists")
|
||||
@@ -265,7 +285,11 @@ def clone_stack(
|
||||
compose_service.clone_stack_files(stack_id, new_id)
|
||||
except compose_service.StackFileError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
stack = Stack(id=new_id, name=body.name)
|
||||
stack = Stack(
|
||||
id=new_id,
|
||||
name=body.name,
|
||||
icon=icon_service.copy(stack_id, new_id, source.icon),
|
||||
)
|
||||
session.add(stack)
|
||||
session.commit()
|
||||
session.refresh(stack)
|
||||
@@ -276,6 +300,85 @@ def clone_stack(
|
||||
return _stack_summary(stack)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# icon
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/{stack_id}/icon")
|
||||
def get_stack_icon(
|
||||
stack_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> FileResponse:
|
||||
"""Serve a stack's uploaded icon.
|
||||
|
||||
Authenticated like everything else, which is why the frontend fetches it
|
||||
through the API client and renders the blob rather than pointing an
|
||||
``<img src>`` straight at this URL (that request would carry no token).
|
||||
"""
|
||||
stack = _get_stack_or_404(session, stack_id)
|
||||
path = icon_service.file_for(stack_id, stack.icon)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="This stack has no custom icon")
|
||||
ext = icon_service.custom_ext(stack.icon) or ""
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=icon_service.content_type(ext),
|
||||
# An SVG opened as a top-level document would run its own script in the
|
||||
# API's origin. Nothing here is ever meant to be a document.
|
||||
headers={"Content-Disposition": f'attachment; filename="{stack_id}.{ext}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{stack_id}/icon")
|
||||
async def upload_stack_icon(
|
||||
stack_id: str,
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Replace a stack's icon with an uploaded image."""
|
||||
stack = _get_stack_or_404(session, stack_id)
|
||||
data = await file.read(icon_service.MAX_ICON_BYTES + 1)
|
||||
try:
|
||||
stack.icon = icon_service.store_upload(stack_id, data)
|
||||
except icon_service.IconError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
stack.updated_at = compose_service.now()
|
||||
session.add(stack)
|
||||
session.commit()
|
||||
session.refresh(stack)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="stack.icon", target=stack_id,
|
||||
detail=f"uploaded {file.filename or 'image'}", ip=_client_ip(request),
|
||||
)
|
||||
return _stack_summary(stack)
|
||||
|
||||
|
||||
@router.delete("/{stack_id}/icon")
|
||||
def reset_stack_icon(
|
||||
stack_id: str,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Drop any explicit choice and go back to the name-derived icon."""
|
||||
stack = _get_stack_or_404(session, stack_id)
|
||||
icon_service.remove(stack_id)
|
||||
stack.icon = None
|
||||
stack.updated_at = compose_service.now()
|
||||
session.add(stack)
|
||||
session.commit()
|
||||
session.refresh(stack)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="stack.icon", target=stack_id,
|
||||
detail="reset to automatic", ip=_client_ip(request),
|
||||
)
|
||||
return _stack_summary(stack)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# lifecycle
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Stack icons — validation of the stored choice and custom-image storage.
|
||||
|
||||
A stack's ``icon`` column holds one of three things:
|
||||
|
||||
``None``
|
||||
Automatic. Nothing is stored and the UI derives an icon from the stack's
|
||||
name (see ``frontend/src/lib/stackIcons.ts``). Every stack that existed
|
||||
before this feature lands here, so an upgraded install shows sensible icons
|
||||
without a data migration.
|
||||
|
||||
``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}$")
|
||||
_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``. Only the
|
||||
built-in form is accepted here: a ``custom:`` value is minted by
|
||||
:func:`store_upload` and never taken from a client, or a stack could be
|
||||
pointed at another stack's icon file.
|
||||
"""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
if _BUILTIN_RE.match(value):
|
||||
return value
|
||||
raise IconError(
|
||||
"Icon must be empty (automatic) or 'lucide:<name>'; upload custom "
|
||||
"images through POST /api/stacks/{id}/icon"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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())}"
|
||||
@@ -63,6 +63,9 @@ USER_READABLE = {
|
||||
"GET /api/stacks/updates",
|
||||
"GET /api/stacks/{stack_id}",
|
||||
"GET /api/stacks/{stack_id}/auto-update",
|
||||
# The stack's uploaded icon — an image the user already sees next to the
|
||||
# stack's name in the list. Uploading and resetting it stay admin-only.
|
||||
"GET /api/stacks/{stack_id}/icon",
|
||||
"GET /api/stacks/{stack_id}/logs",
|
||||
"GET /api/stacks/{stack_id}/services/{service}/logs",
|
||||
"POST /api/stacks/convert",
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Stack icons: what the server accepts, stores and hands back.
|
||||
|
||||
The interesting part is not "does a column round-trip" — it is the three rules
|
||||
that keep the feature from becoming a liability:
|
||||
|
||||
* an icon value can only ever be the automatic one or a built-in name; a
|
||||
``custom:`` value is minted by the server, so a stack can never be pointed at
|
||||
another stack's uploaded file,
|
||||
* an upload is classified by its bytes, not by what the client claims it is, and
|
||||
* files follow the stack: replaced, cloned and deleted along with it, so the
|
||||
data directory does not fill up with icons of stacks that are long gone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
PNG = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDAT\x78\x9cc\x00"
|
||||
b"\x01\x00\x00\x05\x00\x01\x0d\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
SVG = b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc(db):
|
||||
from services import icon_service
|
||||
|
||||
return icon_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stack(db):
|
||||
"""A bare stack row to hang icons off, cleaned up afterwards."""
|
||||
from sqlmodel import Session
|
||||
|
||||
from database import engine
|
||||
from models.stack import Stack
|
||||
from services import icon_service
|
||||
|
||||
stack_id = "icon-test-stack"
|
||||
with Session(engine) as session:
|
||||
if (existing := session.get(Stack, stack_id)) is not None:
|
||||
session.delete(existing)
|
||||
session.commit()
|
||||
session.add(Stack(id=stack_id, name="Icon Test Stack"))
|
||||
session.commit()
|
||||
yield stack_id
|
||||
icon_service.remove(stack_id)
|
||||
with Session(engine) as session:
|
||||
if (row := session.get(Stack, stack_id)) is not None:
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The stored value
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_builtin_choice_is_stored_as_given(svc):
|
||||
assert svc.normalize_choice("lucide:database") == "lucide:database"
|
||||
|
||||
|
||||
def test_an_empty_choice_means_automatic(svc):
|
||||
assert svc.normalize_choice("") is None
|
||||
assert svc.normalize_choice(" ") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
# A client-supplied custom value would name a file: that is how one
|
||||
# stack would end up serving another's icon.
|
||||
"custom:png:1",
|
||||
"custom:../other:1",
|
||||
"lucide:../../etc/passwd",
|
||||
"lucide:Database", # the catalog is lower-kebab; anything else is a typo
|
||||
"https://example.com/logo.png",
|
||||
"<script>alert(1)</script>",
|
||||
],
|
||||
)
|
||||
def test_anything_else_is_refused(svc, value):
|
||||
with pytest.raises(svc.IconError):
|
||||
svc.normalize_choice(value)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uploads
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_an_upload_is_typed_by_its_bytes(svc, stack):
|
||||
assert svc.store_upload(stack, PNG).startswith("custom:png:")
|
||||
assert svc.store_upload(stack, SVG).startswith("custom:svg:")
|
||||
|
||||
|
||||
def test_a_file_that_is_not_an_image_is_refused(svc, stack):
|
||||
with pytest.raises(svc.IconError):
|
||||
svc.store_upload(stack, b"#!/bin/sh\nrm -rf /\n")
|
||||
|
||||
|
||||
def test_an_oversized_image_is_refused(svc, stack):
|
||||
payload = PNG + b"\x00" * svc.MAX_ICON_BYTES
|
||||
with pytest.raises(svc.IconError):
|
||||
svc.store_upload(stack, payload)
|
||||
|
||||
|
||||
def test_reuploading_in_another_format_leaves_no_orphan(svc, stack):
|
||||
svc.store_upload(stack, PNG)
|
||||
assert os.path.isfile(svc.custom_path(stack, "png"))
|
||||
value = svc.store_upload(stack, SVG)
|
||||
assert not os.path.exists(svc.custom_path(stack, "png"))
|
||||
assert svc.file_for(stack, value) == svc.custom_path(stack, "svg")
|
||||
|
||||
|
||||
def test_the_value_changes_on_every_upload(svc, stack, monkeypatch):
|
||||
"""Otherwise a browser keeps showing the image it already cached."""
|
||||
monkeypatch.setattr("services.icon_service.time.time", lambda: 1000)
|
||||
first = svc.store_upload(stack, PNG)
|
||||
monkeypatch.setattr("services.icon_service.time.time", lambda: 2000)
|
||||
assert svc.store_upload(stack, PNG) != first
|
||||
|
||||
|
||||
def test_removing_clears_the_file(svc, stack):
|
||||
value = svc.store_upload(stack, PNG)
|
||||
svc.remove(stack)
|
||||
assert svc.file_for(stack, value) is None
|
||||
|
||||
|
||||
def test_a_clone_gets_its_own_copy(svc, stack):
|
||||
value = svc.store_upload(stack, PNG)
|
||||
copied = svc.copy(stack, "icon-test-clone", value)
|
||||
try:
|
||||
assert copied is not None and copied.startswith("custom:png:")
|
||||
assert os.path.isfile(svc.custom_path("icon-test-clone", "png"))
|
||||
# Deleting the source must not take the clone's icon with it.
|
||||
svc.remove(stack)
|
||||
assert svc.file_for("icon-test-clone", copied)
|
||||
finally:
|
||||
svc.remove("icon-test-clone")
|
||||
|
||||
|
||||
def test_cloning_a_builtin_choice_carries_the_name_over(svc):
|
||||
assert svc.copy("a", "b", "lucide:database") == "lucide:database"
|
||||
assert svc.copy("a", "b", None) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Through the API
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_the_list_row_carries_the_icon(as_admin, stack, svc):
|
||||
from sqlmodel import Session
|
||||
|
||||
from database import engine
|
||||
from models.stack import Stack
|
||||
|
||||
with Session(engine) as session:
|
||||
row = session.get(Stack, stack)
|
||||
row.icon = "lucide:database"
|
||||
session.add(row)
|
||||
session.commit()
|
||||
|
||||
body = as_admin.get(f"/api/stacks/{stack}").json()
|
||||
assert body["icon"] == "lucide:database"
|
||||
|
||||
|
||||
def test_upload_download_and_reset_round_trip(as_admin, stack):
|
||||
uploaded = as_admin.post(
|
||||
f"/api/stacks/{stack}/icon",
|
||||
files={"file": ("logo.png", PNG, "image/png")},
|
||||
)
|
||||
assert uploaded.status_code == 200, uploaded.text
|
||||
assert uploaded.json()["icon"].startswith("custom:png:")
|
||||
|
||||
served = as_admin.get(f"/api/stacks/{stack}/icon")
|
||||
assert served.status_code == 200
|
||||
assert served.content == PNG
|
||||
assert served.headers["content-type"] == "image/png"
|
||||
# An SVG icon must never be openable as a document in the API's own origin.
|
||||
assert served.headers["content-disposition"].startswith("attachment")
|
||||
|
||||
assert as_admin.delete(f"/api/stacks/{stack}/icon").json()["icon"] is None
|
||||
assert as_admin.get(f"/api/stacks/{stack}/icon").status_code == 404
|
||||
|
||||
|
||||
def test_a_lying_content_type_does_not_get_through(as_admin, stack):
|
||||
response = as_admin.post(
|
||||
f"/api/stacks/{stack}/icon",
|
||||
files={"file": ("logo.png", b"not an image at all", "image/png")},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_switching_to_a_builtin_icon_drops_the_uploaded_file(as_admin, stack, svc):
|
||||
as_admin.post(
|
||||
f"/api/stacks/{stack}/icon", files={"file": ("logo.png", PNG, "image/png")}
|
||||
)
|
||||
as_admin.put(f"/api/stacks/{stack}", json={"icon": "lucide:database"})
|
||||
assert not os.path.exists(svc.custom_path(stack, "png"))
|
||||
|
||||
|
||||
def test_an_invalid_icon_on_update_is_a_400(as_admin, stack):
|
||||
response = as_admin.put(f"/api/stacks/{stack}", json={"icon": "custom:png:1"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_the_read_only_role_cannot_change_an_icon(as_user, stack):
|
||||
assert (
|
||||
as_user.post(
|
||||
f"/api/stacks/{stack}/icon", files={"file": ("l.png", PNG, "image/png")}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert as_user.delete(f"/api/stacks/{stack}/icon").status_code == 403
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.50.0"
|
||||
APP_VERSION = "0.51.0"
|
||||
|
||||
Reference in New Issue
Block a user