From c274b4bc1d4f3c9dd532c91387d5ed268230de66 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 23 Apr 2026 10:57:22 -0400 Subject: [PATCH 1/4] fix(security): close three HIGH-severity API issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Thumbnail ``color`` / ``size`` / ``radius`` are now whitelisted before the ``color`` string reaches the cache-filename construction. Values like ``?color=../../evil`` previously escaped the thumbnail cache directory via ``Path /`` concatenation and let PIL write a .png to an arbitrary location. * ``POST /version/update`` and ``POST /version/restart`` now honour ``PHOTOMAP_INLINE_UPGRADE`` server-side (previously only the UI button was hidden) and require an ``X-Requested-With: photomap`` header, which forces a CORS preflight that we do not answer — so a cross-origin simple POST from any page the user visits can no longer silently trigger a pip install or kill the server. The frontend already sends the header from ``about.js``. * InvokeAI settings reject URLs whose scheme is not ``http``/``https`` (and empty-host values like ``http://``) so the config field cannot be flipped to ``file://`` or ``javascript:`` and used as an SSRF pivot from ``/status``, ``/boards``, ``/recall`` or ``/use_ref_image``. The ``queue_id`` field is constrained to ``[A-Za-z0-9_.-]{1,64}`` so a request body can't splice ``../`` into the outbound path and reach arbitrary endpoints on the configured backend. New tests: tests/backend/test_thumbnail_validation.py, tests/backend/test_upgrade_router.py, plus SSRF / queue-id coverage appended to tests/backend/test_invoke_router.py. 175 backend + 239 frontend tests pass; ruff / eslint / prettier clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- photomap/backend/routers/invoke.py | 54 +++++++++- photomap/backend/routers/search.py | 17 +++ photomap/backend/routers/upgrade.py | 42 +++++++- photomap/frontend/static/javascript/about.js | 6 +- tests/backend/test_invoke_router.py | 62 +++++++++++ tests/backend/test_thumbnail_validation.py | 69 ++++++++++++ tests/backend/test_upgrade_router.py | 104 +++++++++++++++++++ 7 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 tests/backend/test_thumbnail_validation.py create mode 100644 tests/backend/test_upgrade_router.py diff --git a/photomap/backend/routers/invoke.py b/photomap/backend/routers/invoke.py index da78dfbc..260a065d 100644 --- a/photomap/backend/routers/invoke.py +++ b/photomap/backend/routers/invoke.py @@ -30,6 +30,7 @@ import time from collections.abc import Awaitable, Callable from pathlib import Path +from urllib.parse import urlsplit import httpx from fastapi import APIRouter, HTTPException @@ -212,6 +213,44 @@ async def _do(headers: dict[str, str]) -> httpx.Response: return resp.status_code == 200 +def _validate_invokeai_url(url: str | None) -> str | None: + """Reject non-http(s) schemes so configured URLs cannot be used for SSRF. + + The configured URL is later concatenated into outbound requests for + ``/status``, ``/boards``, ``/recall`` and ``/use_ref_image``; ``httpx`` + already refuses non-http(s) schemes, but validating up front returns + a clean 400 to the settings panel rather than a 502 at call time, and + blocks obviously-wrong values like ``file://`` or ``javascript:`` from + ever reaching the config file. + + Empty / None is allowed — that's "not configured yet". + """ + if not url: + return url + try: + parts = urlsplit(url) + except ValueError as exc: + raise HTTPException( + status_code=400, detail=f"Invalid InvokeAI URL: {exc}" + ) from exc + if parts.scheme not in {"http", "https"}: + raise HTTPException( + status_code=400, + detail="InvokeAI URL must use http:// or https://", + ) + if not parts.netloc: + raise HTTPException( + status_code=400, detail="InvokeAI URL must include a host" + ) + return url + + +# InvokeAI queue ids are short opaque tokens (e.g. ``default``); restrict +# the pattern so a caller can't splice ``../`` into the outbound URL path +# and reach an arbitrary endpoint on the configured backend. +_QUEUE_ID_PATTERN = r"^[A-Za-z0-9_.-]{1,64}$" + + class InvokeAISettings(BaseModel): """Mirrors the config fields we expose via the settings panel.""" @@ -230,7 +269,11 @@ class RecallRequest(BaseModel): True, description="If False, omit the seed from the recall payload (remix mode)", ) - queue_id: str = Field("default", description="InvokeAI queue id to target") + queue_id: str = Field( + "default", + description="InvokeAI queue id to target", + pattern=_QUEUE_ID_PATTERN, + ) class UseRefImageRequest(BaseModel): @@ -238,7 +281,11 @@ class UseRefImageRequest(BaseModel): album_key: str = Field(..., description="Album containing the image") index: int = Field(..., ge=0, description="Image index within the album") - queue_id: str = Field("default", description="InvokeAI queue id to target") + queue_id: str = Field( + "default", + description="InvokeAI queue id to target", + pattern=_QUEUE_ID_PATTERN, + ) @invoke_router.get("/config") @@ -265,13 +312,14 @@ async def set_invokeai_config(settings: InvokeAISettings) -> dict: untouched so the settings panel can PATCH individual fields without clobbering what was saved. Send an empty string to explicitly clear. """ + url = _validate_invokeai_url(settings.url) _invalidate_token_cache() existing = config_manager.get_invokeai_settings() password = settings.password if settings.password is not None else existing["password"] board_id = settings.board_id if settings.board_id is not None else existing["board_id"] try: config_manager.set_invokeai_settings( - url=settings.url, + url=url, username=settings.username, password=password, board_id=board_id, diff --git a/photomap/backend/routers/search.py b/photomap/backend/routers/search.py index 7eb37c7d..b51c8ff4 100644 --- a/photomap/backend/routers/search.py +++ b/photomap/backend/routers/search.py @@ -7,6 +7,7 @@ import base64 import json +import re import zipfile from io import BytesIO from logging import getLogger @@ -29,6 +30,15 @@ search_router = APIRouter() logger = getLogger(__name__) +# The ``color`` query param is interpolated into the on-disk thumbnail +# cache filename, so anything that survives here becomes a path segment. +# Accept only a 6-digit hex literal (with or without ``#``) or an +# ``r,g,b`` CSV of three 0-255 integers — reject everything else so a +# value like ``../../evil`` cannot escape the thumbnail cache dir. +_COLOR_RE = re.compile(r"\A#?[0-9A-Fa-f]{6}\Z|\A\d{1,3},\d{1,3},\d{1,3}\Z") +_MAX_THUMB_SIZE = 2048 +_MAX_THUMB_RADIUS = 512 + # Response Models class SearchResult(BaseModel): @@ -181,6 +191,13 @@ async def serve_thumbnail( radius: int = 12, # Add a radius parameter for rounded corners ) -> FileResponse: """Serve a reduced-size thumbnail for an image by index, with optional colored border.""" + if size <= 0 or size > _MAX_THUMB_SIZE: + raise HTTPException(status_code=400, detail="Invalid thumbnail size") + if radius < 0 or radius > _MAX_THUMB_RADIUS: + raise HTTPException(status_code=400, detail="Invalid thumbnail radius") + if color is not None and not _COLOR_RE.match(color): + raise HTTPException(status_code=400, detail="Invalid color parameter") + embeddings = get_embeddings_for_album(album_key) try: image_path = embeddings.get_image_path(index) diff --git a/photomap/backend/routers/upgrade.py b/photomap/backend/routers/upgrade.py index 4354a9da..7b27e222 100644 --- a/photomap/backend/routers/upgrade.py +++ b/photomap/backend/routers/upgrade.py @@ -8,7 +8,7 @@ from logging import getLogger import requests -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse from packaging import version as pversion @@ -16,6 +16,38 @@ logger = getLogger(__name__) +def _require_inline_upgrades_enabled() -> None: + """Honour the ``PHOTOMAP_INLINE_UPGRADE`` deployment switch. + + The flag is set from ``--inline-upgrade`` / env on startup; when the + operator has explicitly disabled it the UI hides the button, but the + endpoint was previously still callable. Enforce it here so the backend + is the source of truth. + """ + if os.environ.get("PHOTOMAP_INLINE_UPGRADE", "1") != "1": + raise HTTPException( + status_code=403, + detail="Inline upgrades are disabled on this deployment.", + ) + + +def _require_same_origin_header(request: Request) -> None: + """Reject requests that lack the ``X-Requested-With`` marker. + + The update and restart endpoints perform side effects (pip install, + process kill) and have no authentication. A cross-origin page could + otherwise submit a CSRF-able simple POST to ``http://localhost:8050`` + and trigger either action. Requiring a non-standard request header + forces the caller through a CORS preflight, which our server does not + answer — so only same-origin JS with an explicit header succeeds. + """ + if request.headers.get("x-requested-with") != "photomap": + raise HTTPException( + status_code=403, + detail="Missing required X-Requested-With header.", + ) + + @upgrade_router.get("/version/check", tags=["Upgrade"]) async def check_version(): """Check if a newer version is available on PyPI""" @@ -55,8 +87,10 @@ async def check_version(): @upgrade_router.post("/version/update", tags=["Upgrade"]) -async def update_version(): +async def update_version(request: Request): """Update PhotoMapAI to the latest version using pip""" + _require_inline_upgrades_enabled() + _require_same_origin_header(request) try: # Run pip install --upgrade photomapai result = subprocess.run( @@ -98,8 +132,10 @@ async def update_version(): @upgrade_router.post("/version/restart", tags=["Upgrade"]) -async def restart_server(): +async def restart_server(request: Request): """Restart the server after update""" + _require_inline_upgrades_enabled() + _require_same_origin_header(request) def delayed_restart(): time.sleep(2) # Give time for response to be sent diff --git a/photomap/frontend/static/javascript/about.js b/photomap/frontend/static/javascript/about.js index 466cede9..270a938f 100644 --- a/photomap/frontend/static/javascript/about.js +++ b/photomap/frontend/static/javascript/about.js @@ -116,6 +116,7 @@ class AboutManager { try { const response = await fetch("version/update", { method: "POST", + headers: { "X-Requested-With": "photomap" }, }); const data = await response.json(); @@ -128,7 +129,10 @@ class AboutManager { if (data.restart_available) { setTimeout(async () => { try { - await fetch("version/restart", { method: "POST" }); + await fetch("version/restart", { + method: "POST", + headers: { "X-Requested-With": "photomap" }, + }); updateStatus.textContent = "Server restarting... Waiting for server to come back online..."; // Wait 5 seconds before starting to poll diff --git a/tests/backend/test_invoke_router.py b/tests/backend/test_invoke_router.py index b1b931db..34a1b364 100644 --- a/tests/backend/test_invoke_router.py +++ b/tests/backend/test_invoke_router.py @@ -1400,3 +1400,65 @@ async def post(self, url, **kwargs): body = response.json() assert body["reused_existing"] is False assert body["uploaded_image_name"] == "uploaded.png" + + +# ── SSRF / URL-scheme hardening ─────────────────────────────────────── + + +@pytest.mark.parametrize( + "bad_url", + [ + "file:///etc/passwd", + "javascript:alert(1)", + "ftp://example.com/", + "://no-scheme", + "http://", # missing host + ], +) +def test_set_config_rejects_unsafe_url(client, clear_invokeai_config, bad_url): + response = client.post("/invokeai/config", json={"url": bad_url}) + assert response.status_code == 400, response.text + # And nothing was persisted. + assert get_config_manager().get_invokeai_settings()["url"] is None + + +@pytest.mark.parametrize( + "good_url", + [ + "http://localhost:9090", + "https://invoke.example.com", + "http://127.0.0.1:9090/", + ], +) +def test_set_config_accepts_http_and_https(client, clear_invokeai_config, good_url): + response = client.post("/invokeai/config", json={"url": good_url}) + assert response.status_code == 200, response.text + assert get_config_manager().get_invokeai_settings()["url"] == good_url + + +@pytest.mark.parametrize( + "bad_queue", + ["../auth/login", "default/../foo", "has space", "with/slash", ""], +) +def test_recall_rejects_unsafe_queue_id(client, clear_invokeai_config, bad_queue): + client.post("/invokeai/config", json={"url": "http://localhost:9090"}) + response = client.post( + "/invokeai/recall", + json={"album_key": "any", "index": 0, "queue_id": bad_queue}, + ) + assert response.status_code == 422, response.text + + +@pytest.mark.parametrize( + "bad_queue", + ["../auth/login", "default/../foo", "has space", "with/slash"], +) +def test_use_ref_image_rejects_unsafe_queue_id( + client, clear_invokeai_config, bad_queue +): + client.post("/invokeai/config", json={"url": "http://localhost:9090"}) + response = client.post( + "/invokeai/use_ref_image", + json={"album_key": "any", "index": 0, "queue_id": bad_queue}, + ) + assert response.status_code == 422, response.text diff --git a/tests/backend/test_thumbnail_validation.py b/tests/backend/test_thumbnail_validation.py new file mode 100644 index 00000000..edd7157c --- /dev/null +++ b/tests/backend/test_thumbnail_validation.py @@ -0,0 +1,69 @@ +"""Input validation for the ``/thumbnails/{album}/{index}`` endpoint. + +The ``color`` query parameter is interpolated into the on-disk thumbnail +cache filename, so unsanitised values like ``../../evil`` would have +escaped the cache directory and caused arbitrary-location writes. These +tests lock down the whitelist. +""" + +from __future__ import annotations + +import pytest +from fixtures import build_index + + +@pytest.fixture +def indexed_album(client, new_album, monkeypatch): + build_index(client, new_album, monkeypatch) + return new_album + + +@pytest.mark.parametrize( + "bad_color", + [ + "../../evil", + "#../foo", + "red", # not hex / rgb triple + "#xyz", + "#abc", # short-form hex — not supported by our 6-digit parser + "256,256,256" + "," * 20, + "#1234567", # wrong length + "1,2", # too few components + ], +) +def test_thumbnail_rejects_unsafe_color(client, indexed_album, bad_color): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"color": bad_color}, + ) + assert response.status_code == 400, response.text + + +@pytest.mark.parametrize( + "good_color", + ["#ff00aa", "ff00aa", "255,128,0"], +) +def test_thumbnail_accepts_valid_color(client, indexed_album, good_color): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"color": good_color}, + ) + assert response.status_code == 200 + + +@pytest.mark.parametrize("bad_size", [0, -1, 10_000, 99_999]) +def test_thumbnail_rejects_out_of_range_size(client, indexed_album, bad_size): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"size": bad_size}, + ) + assert response.status_code == 400 + + +@pytest.mark.parametrize("bad_radius", [-1, 10_000]) +def test_thumbnail_rejects_out_of_range_radius(client, indexed_album, bad_radius): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"radius": bad_radius}, + ) + assert response.status_code == 400 diff --git a/tests/backend/test_upgrade_router.py b/tests/backend/test_upgrade_router.py new file mode 100644 index 00000000..c23702ec --- /dev/null +++ b/tests/backend/test_upgrade_router.py @@ -0,0 +1,104 @@ +"""Tests for the upgrade router's auth/origin gating. + +The update and restart endpoints have no authentication; both the +``PHOTOMAP_INLINE_UPGRADE`` deployment switch and the +``X-Requested-With`` header requirement must be enforced server-side. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def inline_upgrades_enabled(monkeypatch): + monkeypatch.setenv("PHOTOMAP_INLINE_UPGRADE", "1") + + +@pytest.fixture +def inline_upgrades_disabled(monkeypatch): + monkeypatch.setenv("PHOTOMAP_INLINE_UPGRADE", "0") + + +@pytest.fixture +def no_pip(monkeypatch): + """Stub out ``subprocess.run`` so tests never actually invoke pip.""" + from photomap.backend.routers import upgrade as upgrade_module + + class _FakeCompleted: + returncode = 0 + stdout = "stub" + stderr = "" + + def _fake_run(*args, **kwargs): + return _FakeCompleted() + + monkeypatch.setattr(upgrade_module.subprocess, "run", _fake_run) + + +@pytest.fixture +def no_restart(monkeypatch): + """Stub out the ``os.kill`` path so tests never actually restart.""" + from photomap.backend.routers import upgrade as upgrade_module + + class _FakeThread: + def __init__(self, *args, **kwargs): + pass + + def start(self): + pass + + monkeypatch.setattr(upgrade_module.threading, "Thread", _FakeThread) + + +def test_update_rejects_missing_xrw_header(client, inline_upgrades_enabled, no_pip): + response = client.post("/version/update") + assert response.status_code == 403 + assert "x-requested-with" in response.json()["detail"].lower() + + +def test_update_accepts_xrw_header(client, inline_upgrades_enabled, no_pip): + response = client.post( + "/version/update", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 200 + + +def test_update_blocked_when_inline_disabled( + client, inline_upgrades_disabled, no_pip +): + response = client.post( + "/version/update", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 403 + assert "disabled" in response.json()["detail"].lower() + + +def test_restart_rejects_missing_xrw_header( + client, inline_upgrades_enabled, no_restart +): + response = client.post("/version/restart") + assert response.status_code == 403 + + +def test_restart_accepts_xrw_header(client, inline_upgrades_enabled, no_restart): + response = client.post( + "/version/restart", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 200 + + +def test_restart_blocked_when_inline_disabled( + client, inline_upgrades_disabled, no_restart +): + response = client.post( + "/version/restart", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 403 + + +def test_check_version_is_still_unauthenticated(client, inline_upgrades_enabled): + """The read-only check endpoint stays open — only state-changing ones are gated.""" + # May return 200 or 503 depending on network; either is fine, just not 403. + response = client.get("/version/check") + assert response.status_code != 403 From 81986453a5c4f906fd69538193ce7f28c174cf4f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 23 Apr 2026 11:18:28 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(security):=20close=20add=5Falbum=20?= =?UTF-8?q?=E2=86=92=20serve=5Fimage=20arbitrary-file-read=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``POST /add_album`` accepts arbitrary absolute ``image_paths``, and ``serve_image`` / ``get_image_by_name`` previously returned any file under those paths as long as ``validate_image_access``'s ``is_relative_to`` guard passed — the guard checks *location* only, not *type*. A caller could therefore create an album with ``image_paths=["/etc"]`` and read ``/etc/passwd`` via ``GET /images//passwd``. Both endpoints now require the resolved file's suffix to be in ``SUPPORTED_EXTENSIONS`` (the same allowlist the indexer uses). Co-Authored-By: Claude Opus 4.7 (1M context) --- photomap/backend/routers/search.py | 12 ++++ tests/backend/test_image_type_guard.py | 77 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/backend/test_image_type_guard.py diff --git a/photomap/backend/routers/search.py b/photomap/backend/routers/search.py index b51c8ff4..784acca5 100644 --- a/photomap/backend/routers/search.py +++ b/photomap/backend/routers/search.py @@ -19,6 +19,7 @@ from pydantic import BaseModel from ..config import get_config_manager +from ..embeddings import SUPPORTED_EXTENSIONS from ..metadata_modules import SlideSummary from .album import ( get_embeddings_for_album, @@ -283,6 +284,14 @@ async def serve_image(album_key: str, path: str): if not validate_image_access(album_config, image_path): raise HTTPException(status_code=403, detail="Access denied") + # Enforce the image-extension allowlist on any file-serving endpoint. + # ``add_album`` accepts arbitrary absolute ``image_paths``; without this + # check a caller could point an album at ``/etc`` and then read + # ``/images//passwd`` (the ``is_relative_to`` guard above only + # checks *location*, not type). + if image_path.suffix.lower() not in SUPPORTED_EXTENSIONS: + raise HTTPException(status_code=403, detail="Unsupported image type") + if not image_path.exists() or not image_path.is_file(): raise HTTPException(status_code=404, detail="File not found") @@ -362,6 +371,9 @@ async def get_image_by_name(album_key: str, filename: str) -> FileResponse: """ Serve an image by its filename within the specified album. """ + if Path(filename).suffix.lower() not in SUPPORTED_EXTENSIONS: + raise HTTPException(status_code=403, detail="Unsupported image type") + embeddings = get_embeddings_for_album(album_key) if not embeddings: raise HTTPException(status_code=404, detail="Album not found") diff --git a/tests/backend/test_image_type_guard.py b/tests/backend/test_image_type_guard.py new file mode 100644 index 00000000..5d41484b --- /dev/null +++ b/tests/backend/test_image_type_guard.py @@ -0,0 +1,77 @@ +"""Image-extension allowlist on the file-serving endpoints. + +``add_album`` accepts arbitrary absolute ``image_paths``; without an +extension guard a caller could point an album at, say, ``/etc`` and +then ``GET /images//passwd`` to read any file the server user can +open. These tests lock down both ``serve_image`` and +``get_image_by_name`` so only files with a known image suffix can be +served. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + + +def _add_album(client: TestClient, key: str, image_path: Path, index_path: Path) -> None: + """Create an album pointing at an arbitrary directory (no indexing).""" + response = client.post( + "/add_album/", + json={ + "key": key, + "name": "chain", + "image_paths": [image_path.as_posix()], + "index": index_path.as_posix(), + "umap_eps": 0.1, + "description": "", + }, + ) + assert response.status_code == 201, response.text + + +def test_serve_image_rejects_non_image_extension(client, tmp_path): + """The add_album → serve_image arbitrary-file-read chain is closed.""" + secret_dir = tmp_path / "secrets" + secret_dir.mkdir() + secret_file = secret_dir / "passwd" + secret_file.write_text("root:x:0:0:/root:/bin/bash\n") + + try: + _add_album( + client, + "chain_album", + secret_dir, + tmp_path / "chain.npz", + ) + + # Even though the file exists and is inside the configured album + # path, its extension is not in the allowlist — so the request is + # refused rather than the raw file being returned. + response = client.get("/images/chain_album/passwd") + assert response.status_code == 403, response.text + assert "unsupported" in response.json()["detail"].lower() + + # And a file with a disallowed ``.txt`` extension is refused even + # when the path looks innocent. + (secret_dir / "notes.txt").write_text("nothing interesting") + response = client.get("/images/chain_album/notes.txt") + assert response.status_code == 403 + finally: + client.delete("/delete_album/chain_album") + + +def test_image_by_name_rejects_non_image_extension(client, tmp_path): + """Defense-in-depth: ``get_image_by_name`` also refuses non-image suffixes.""" + secret_dir = tmp_path / "secrets2" + secret_dir.mkdir() + (secret_dir / "passwd").write_text("fake") + try: + _add_album( + client, "name_album", secret_dir, tmp_path / "name.npz" + ) + response = client.get("/image_by_name/name_album/passwd") + assert response.status_code == 403 + finally: + client.delete("/delete_album/name_album") From 1b8b1df10d3eb81893a3a1656617ea53a3ce4095 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 25 Apr 2026 15:22:44 -0400 Subject: [PATCH 3/4] fix(backend): PHOTOMAP_INLINE_UPGRADE env variable now works as documented --- photomap/backend/args.py | 2 +- photomap/backend/photomap_server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/photomap/backend/args.py b/photomap/backend/args.py index 5e2a2c95..01db64ae 100644 --- a/photomap/backend/args.py +++ b/photomap/backend/args.py @@ -72,6 +72,6 @@ def get_args() -> argparse.Namespace: "--inline-upgrade", action=argparse.BooleanOptionalAction, default=True, - help="Perform inline database upgrades", + help="Allow inline package upgrades from the About dialog (default: True). Overridden by PHOTOMAP_INLINE_UPGRADE env var.", ) return parser.parse_args() diff --git a/photomap/backend/photomap_server.py b/photomap/backend/photomap_server.py index ea463a5e..b7ef875c 100644 --- a/photomap/backend/photomap_server.py +++ b/photomap/backend/photomap_server.py @@ -202,7 +202,7 @@ def main(): # Convert list of album keys to comma-separated string os.environ["PHOTOMAP_ALBUM_LOCKED"] = ",".join(args.album_locked) - os.environ["PHOTOMAP_INLINE_UPGRADE"] = "1" if args.inline_upgrade else "0" + os.environ.setdefault("PHOTOMAP_INLINE_UPGRADE", "1" if args.inline_upgrade else "0") app_url = get_app_url(host, port) From 69e193453b14b6378e1a146e5b423599b96c1316 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 25 Apr 2026 15:50:18 -0400 Subject: [PATCH 4/4] fix(settings): surface InvokeAI URL validation and reachability errors inline Previously the settings panel silently dropped the 400 returned for invalid URLs (e.g. file:// schemes) and only flipped auth-row visibility for unreachable backends, leaving the user with no feedback. The hint under the URL field now turns red with a warning icon and shows the backend's detail for: invalid scheme/host, unreachable host, and reachable-but-not-InvokeAI servers. Restores the default hint once the URL is valid and reachable. Co-Authored-By: Claude Opus 4.7 (1M context) --- photomap/backend/routers/invoke.py | 5 +- .../frontend/static/javascript/settings.js | 81 ++++++++++++++++--- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/photomap/backend/routers/invoke.py b/photomap/backend/routers/invoke.py index 260a065d..359145e8 100644 --- a/photomap/backend/routers/invoke.py +++ b/photomap/backend/routers/invoke.py @@ -360,15 +360,16 @@ async def invokeai_status() -> dict: "reachable": False, "detail": f"Backend returned HTTP {resp.status_code}", } + not_invokeai = "Server is reachable but doesn't appear to be an InvokeAI backend" try: payload = resp.json() except ValueError: - return {"reachable": False, "detail": "Backend did not return JSON"} + return {"reachable": False, "detail": not_invokeai} version = payload.get("version") if not version: # A non-InvokeAI server happening to have /api/v1/app/version would # almost certainly not return a version field. - return {"reachable": False, "detail": "Response missing version field"} + return {"reachable": False, "detail": not_invokeai} return {"reachable": True, "version": version} diff --git a/photomap/frontend/static/javascript/settings.js b/photomap/frontend/static/javascript/settings.js index a2ad3113..f159af6d 100644 --- a/photomap/frontend/static/javascript/settings.js +++ b/photomap/frontend/static/javascript/settings.js @@ -326,9 +326,40 @@ export async function loadInvokeAISettings() { await refreshInvokeAIStatus(); } +// Captured lazily on first use so setInvokeAIUrlError can restore the original +// hint after clearing a validation error. Has to be lazy because cacheElements() +// runs after module import. +let _defaultInvokeAIHintHTML = null; + +function setInvokeAIUrlError(message) { + const hint = elements.invokeaiStatusHint; + if (!hint) { + return; + } + if (_defaultInvokeAIHintHTML === null) { + _defaultInvokeAIHintHTML = hint.innerHTML; + } + if (message) { + hint.style.color = "#c0392b"; + hint.setAttribute("role", "alert"); + hint.textContent = ""; + const icon = document.createElement("span"); + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "⚠ "; + hint.appendChild(icon); + hint.appendChild(document.createTextNode(message)); + } else { + hint.style.color = "#666"; + hint.removeAttribute("role"); + hint.innerHTML = _defaultInvokeAIHintHTML; + } +} + +// Returns null on success, or the backend's error detail string on failure, +// so the caller can render it inline under the URL field. async function saveInvokeAISettings() { if (!elements.invokeaiUrlInput) { - return; + return null; } const body = { url: elements.invokeaiUrlInput.value, @@ -343,13 +374,27 @@ async function saveInvokeAISettings() { body.board_id = elements.invokeaiBoardSelect.value || ""; } try { - await fetch("invokeai/config", { + const response = await fetch("invokeai/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + if (!response.ok) { + let detail = `Save failed (${response.status})`; + try { + const data = await response.json(); + if (data && data.detail) { + detail = String(data.detail); + } + } catch { + // Non-JSON body — keep the generic message. + } + return detail; + } + return null; } catch (error) { console.error("Failed to save InvokeAI settings:", error); + return error.message || "Save failed"; } } @@ -362,26 +407,33 @@ function setInvokeAIAuthSectionVisible(visible) { export async function refreshInvokeAIStatus() { // Don't even try to probe when the URL is blank — keep the auth rows - // hidden so the UI stays calm for users who don't run InvokeAI. + // hidden so the UI stays calm for users who don't run InvokeAI. Also + // clear any stale error from a previous typing session. if (!elements.invokeaiUrlInput || !elements.invokeaiUrlInput.value.trim()) { setInvokeAIAuthSectionVisible(false); + setInvokeAIUrlError(null); return; } try { const response = await fetch("invokeai/status"); if (!response.ok) { setInvokeAIAuthSectionVisible(false); + setInvokeAIUrlError(`Status check failed (HTTP ${response.status})`); return; } const data = await response.json(); const reachable = !!data.reachable; setInvokeAIAuthSectionVisible(reachable); if (reachable) { + setInvokeAIUrlError(null); await loadInvokeAIBoards(); + } else { + setInvokeAIUrlError(data.detail || "InvokeAI backend is not reachable"); } } catch (error) { console.error("Failed to probe InvokeAI status:", error); setInvokeAIAuthSectionVisible(false); + setInvokeAIUrlError("Could not contact the status endpoint"); } } @@ -448,24 +500,29 @@ function setupInvokeAISettingsControls() { }; // Every credential/URL edit has to save first and then probe status: the // status endpoint reads the persisted config, not whatever's in the field. - const debouncedSaveAndRefresh = debounced(async () => { - await saveInvokeAISettings(); - await refreshInvokeAIStatus(); - }); + // If the save was rejected (e.g. invalid URL scheme), skip the probe — the + // persisted config still holds the previous value, so a probe would be + // misleading — and surface the error inline under the URL field instead. + const saveAndRefresh = async () => { + const error = await saveInvokeAISettings(); + setInvokeAIUrlError(error); + if (!error) { + await refreshInvokeAIStatus(); + } + }; + const debouncedSaveAndRefresh = debounced(saveAndRefresh); const textInputs = [elements.invokeaiUrlInput, elements.invokeaiUsernameInput, elements.invokeaiPasswordInput].filter( Boolean ); textInputs.forEach((input) => { input.addEventListener("input", debouncedSaveAndRefresh); - input.addEventListener("blur", async () => { - await saveInvokeAISettings(); - await refreshInvokeAIStatus(); - }); + input.addEventListener("blur", saveAndRefresh); }); if (elements.invokeaiBoardSelect) { elements.invokeaiBoardSelect.addEventListener("change", async () => { invokeaiSelectedBoardId = elements.invokeaiBoardSelect.value || ""; - await saveInvokeAISettings(); + const error = await saveInvokeAISettings(); + setInvokeAIUrlError(error); }); } }