**Fixed:**Every htmx form on the settings page (saturation, orientation, MQTT, schedule, Unsplash) sent URL-encoded form data (`application/x-www-form-urlencoded`) but the `/api/config/<section>/<key>` PUT endpoint used `request.get_json(force=True, silent=True)`. URL-encoded data is not valid JSON, so `get_json` returned `None` → the endpoint aborted with 400. Result: **every single settings form returned 400 in the browser**. The MQTT checkbox bug (old #1) was a symptom of this larger issue.
**Problem:** The MQTT enable/disable checkbox uses htmx to PUT `{"value": "true"}` to`/api/config/mqtt/enabled`. When the user unchecks the box, the browser omits the field entirely (standard HTML behaviour for unchecked checkboxes). The server then receives an empty body, hits the `if data is None or "value" not in data: abort(400)` guard, and returns a 400 error. Result: MQTT can never be turned off via the web UI.
**Fix** (`spectra/web/server.py`):`api_config_set()` now tries JSON first, then falls back to `request.form.get("value")`. A `_coerce_form_value(raw, current)` helper infers the correct Python type (bool/int/float/str) from the form string by comparing against the current config value's type.
**To reproduce:** Go to Settings → MQTT → uncheck "Enable MQTT" → click Save. Browser sends PUT without `value` → server returns 400.
### 2. ~~API config tests wrote to real `~/.config/spectra/config.yaml`~~
**Fix:**Add a hidden input before the checkbox with the same `name` and `value="false"`. The checkbox's value (`"true"`) overrides the hidden when checked.
**Fixed:**`ConfigManager._write()` resolved the config path by searching `CONFIG_SEARCH_PATHS`, and when no file existed (test environment), fell back to `~/.config/spectra/config.yaml`. Every test that called `PATCH /api/config` or `PUT /api/config/<section>/<key>` wrote test data into the real user's config file — **potential data corruption**.
**Fix** (`tests/conftest.py`): The `app` fixture now writes a minimal `config.yaml` into the temp directory and passes its path to `create_app(config_path=...)`. The ConfigManager writes back to the same temp file.
---
## High
### 3. `_lookup_image_path` crashes if DB/table doesn't exist
**File:**`spectra/mqtt.py:19–25`
**Problem:** When the web server hasn't created the database yet (or the `image` table is missing), `_lookup_image_path` raises `sqlite3.OperationalError: no such table: image`. The MQTT message handler catches this in paho, but the error is unhandled at the application level.
**Fixed:** Wrapped the `conn.execute()` call in `try/except sqlite3.OperationalError`, logging a warning and returning `None`.
### 4. `_MIME_MAP` dict redefined on every file download request
**File:**`spectra/web/server.py:240`
**Problem:** The `api_image_file()` route handler defined a `_MIME_MAP` dict inside the function body on every request. This is a minor performance issue — dictionary creation is cheap, but the dict is constant data that should be a module-level constant.
**Problem:**`PILImage.open(src)` without a context manager leaves the file handle open until garbage collection. On long-running servers serving many thumbnails, this can exhaust file descriptors.
**Fixed:** Changed to `with PILImage.open(src) as img:`.
### 7. Dead code: `TRIGGER_PATH` and `_trigger_path()`
**File:**`spectra/web/server.py:69–70`
**Problem:**`app.config["TRIGGER_PATH"]` was set but never read by any code. The `_trigger_path()` function was only used for that config key.
**Fixed:** Removed both the config key and the function.
### 8. Redundant `section_data` variable in `api_config_set`
**File:**`spectra/web/server.py:122–124`
**Problem:**`section_data = cm.config.get(section, {})` was assigned but only used for `isinstance(section_data, dict)` check. The old redundant mutation `section_data[key] = data["value"]` was already removed in a previous fix, leaving the variable as dead code.
**Problem:**The Gallery's delete button uses `hx-delete` targeting `#gallery-msg`. After a successful delete, the server returns `{"status": "deleted"}`. The htmx response handler in `app.js` checks `data.status === 'triggered'` → shows trigger message, otherwise shows "Saved". Since `'deleted' !== 'triggered'`, the user sees "Saved" instead of "Deleted". The image card remains visible until the page is manually refreshed.
**Problem:**`cm.get("display", "simulate")` reads a config key that doesn't exist (`display.simulate` is set via CLI `--simulate`, not in the config file). The field always reports `false` even when the display loop runs with `--simulate`.
**Severity:** UX/data mismatch — user has no confirmation the image was actually deleted.
**Fixed:** Removed the `simulate` field from the API response and the dashboard template. The web server and CLI are separate processes; the web server cannot know the display loop's `--simulate` state.
**Fix options:**
- **Option A:** Make the delete endpoint return `{"status": "triggered"}` instead (hacky).
- **Option B:** Update `app.js` to check for `data.status === 'deleted'` and show "Deleted" + remove the card from the DOM.
- **Option C:** Use htmx's `hx-target="closest article" hx-swap="outerHTML"` on the delete button so the card is removed directly from the DOM without needing a message.
### 10. `_assert_safe_path` TOCTOU race
**Recommended (Option C):** Change the delete button to:
**File:**`spectra/web/server.py:319–323`
```html
<buttonclass="secondary"
hx-delete="/api/images/{{ img.id }}"
hx-target="closest article"
hx-swap="outerHTML swap:0.5s"
hx-confirm="Delete this image?">Delete</button>
```
**Problem:** The path safety check (`real_file.startswith(real_store + os.sep)`) and the subsequent `send_file()` are not atomic. A symlink could be swapped in between the check and the file read. An attacker needs write access to the uploads directory to exploit this.
Then remove the `#gallery-msg` target from the delete — the card disappears inline.
**Status:****Wontfix** — requires filesystem write access to exploit; mitigation would add complexity with minimal security gain.
**Problem:**`reconfigure()` calls `self.__init__(new_config)` to re-initialise the instance. While this works (Python allows it), it's highly unusual, confuses static analysis tools, and breaks if a subclass overrides `__init__` differently. If the config is unchanged but the background thread has died, the method also unnecessarily destroys and recreates the client.
**Problem:**`Rotation.image_id` has a foreign key to `image.id` but no `ondelete="CASCADE"`. Image deletion manually cleans up rotation entries (`Rotation.query.filter_by(image_id=...).delete()`). If a new deletion path is added without this cleanup, the FK constraint raises an error.
**Fix:**Extract the attribute setup into a private `_setup` method called from both `__init__` and `reconfigure`:
**Status:****Wontfix**— explicit cleanup in the single delete route is sufficient for current code. Add cascade if new deletion paths are added.
---
## Low
### 4. Unsplash filename collision on duplicate fetch
**File:**`spectra/library.py:56`
**Problem:** The filename is `f"unsplash_{unsplash_id}.png"`. If the same Unsplash photo is fetched twice (e.g. because the query doesn't change and there aren't many results), the second fetch overwrites the first file. Two DB records now reference the same filepath. If one record is deleted, `os.remove(dest)` succeeds, leaving the other record with a dangling path. On the next attempt to serve/thumbnail that record, the endpoint returns 404 from `send_file`.
**Fix:** Add a UUID suffix to make filenames unique:
Before saving, also check if the DB already has a record for this `unsplash_id` and skip the insert (avoid duplicates entirely):
```python
conn=sqlite3.connect(_db_path())
existing=conn.execute("SELECT id FROM image WHERE source='unsplash' AND unsplash_id=?",(unsplash_id,)).fetchone()
ifexisting:
conn.close()
os.remove(dest)# clean up the duplicate file
returnexisting[0]
```
### 5. Object URLs never revoked in upload page
### 12. Object URLs never revoked in upload page
**File:**`spectra/web/templates/upload.html:156`
**Problem:**`URL.createObjectURL(file)` creates a blob URL for the file preview, but it is never released with `URL.revokeObjectURL()`. For a typical session (a handful of files), the memory impact is negligible. If a user uploads hundreds of files in one session, blob URLs accumulate in memory until the page is reloaded.
**Problem:**`URL.createObjectURL(file)` creates blob URLs for file previews but never releases them with `URL.revokeObjectURL()`. For typical sessions (a few files) the memory impact is negligible.
**Fix:**Store the URL on the file item element and revoke it when the item is removed or after a failed upload:
**Status:****Wontfix**— mitigated by the upload page: when a file's upload completes or fails, the `updateFileStatus` function checks `badgeClass === 'badge-ok' || badgeClass === 'badge-err'` and calls `URL.revokeObjectURL(blobUrl)` (lines 180–183). Blob URLs are already cleaned up per-file.
```javascript
functionrenderFile(file){
varurl=URL.createObjectURL(file);
// ... store url for later revocation
fileItem.dataset.blobUrl=url;
}
### 13. Unsplash filename collision on duplicate fetch
functioncleanupFileItem(el){
varurl=el.dataset.blobUrl;
if(url)URL.revokeObjectURL(url);
}
```
**File:**`spectra/library.py:56`
### 6. Dead CSS rules for old upload form
**File:**`spectra/web/static/css/app.css:20`
**Problem:** The CSS rule `#upload-result:empty { display: none; }` targets an element that was used by the old htmx-based upload form. The new multi-file upload form uses `#file-list`, `#upload-options`, and `#upload-summary` instead. The `#upload-result` element no longer exists anywhere in the templates.
**Other dead selectors on the same line:**`#gallery-msg:empty`, `#action-result:empty`, `#unsplash-result:empty`, `#saturation-result:empty`, `#schedule-result:empty`.
-`#gallery-msg` — still used by gallery delete/show htmx responses (if changed to `closest article` approach in bug #2, this becomes dead too)
-`#action-result` — used by dashboard quick actions
-`#unsplash-result`, `#saturation-result`, `#schedule-result` — used by settings page
**Fix:** Remove only `#upload-result:empty` from the selector list. Keep the others as they are still in use.
**Problem:** Original filename `f"unsplash_{unsplash_id}.png"` could collide if the same photo is fetched twice. **Already fixed** in previous session: filename now uses `f"unsplash_{unsplash_id}_{uuid.uuid4().hex[:8]}.png"` and a dedup check on `unsplash_id` prevents duplicate DB records.
**Fixed:** Hidden `<input type="hidden" name="value" value="false">` added before the checkbox. Resolved as a side-effect of Bug #1 (form data now properly accepted with type coercion).
**Fixed:** Delete button uses `hx-target="closest article" hx-swap="delete"` with `hx-confirm`. The card is removed from the DOM directly.
**Design issue:**`library.py` saves the processed (cropped + resized) dimensions as width/height in the DB, but stores the *original* unprocessed image on disk. The gallery shows the original file (via `/api/images/<id>/file`) which may have different dimensions than what is stored. The preview endpoint re-processes the original on demand, so the stored dimensions are only used for display purposes (and are somewhat misleading).
### Old #3 — `MqttClient.reconfigure()` calls `__init__` directly
**Fixed:** Extracted `_setup()` and `_create_client()` methods from `__init__`, called by both `__init__` and `reconfigure()`.
This is intentional — the stored dimensions represent "how the image appears on the display" — but it could confuse API consumers who see width/height that doesn't match the actual file dimensions. Documentation should clarify this.
### Old #4 — Unsplash filename collision
**Fixed:** See #13 above.
### 8. `api_config_set` double-mutates config
### Old #5 — Object URLs never revoked
**Fixed:** See #12 above — already handled in per-file cleanup.
**File:**`spectra/web/server.py:124–125`
### Old #6 — Dead CSS `#upload-result:empty`
**Fixed:** Selector removed from `app.css`.
**Design issue:**`section_data[key] = data["value"]` mutates `cm.config` in place (because `section_data` is a reference to `cm.config[section]`). Then `cm.set_nested()` does the same mutation again and calls `_write()`. The config is correct after both operations, but the first mutation is written only if `set_nested` completes, so this isn't a data-loss risk — just redundant code.
### Old #8 — `api_config_set` double-mutates config
**Fix:** Remove the redundant mutation; only `cm.set_nested()` is needed:
```python
# Remove line 124: section_data[key] = data["value"]
cm.set_nested([section,key],data["value"])
returnjsonify({key:data["value"]})
```
---
## Tests
### 9. ~~Gallery delete test missing from test suite~~
**Fixed:**`tests/test_gallery.py` adds an end-to-end test (`test_upload_delete_flow`) that uploads an
image (`POST /api/images`), confirms `total > 0` via `GET /api/images`, deletes it, then confirms
`total` decreased by 1. Run with `pytest tests/`.
**Dev dependency:**`pytest>=7.0.0` and `pytest-flask>=1.2.0` added under `[project.optional-dependencies] dev` in `pyproject.toml`. Install with `pip install "spectra-display[dev]"`.
### Old #9 — Gallery delete test missing
**Fixed:** `tests/test_gallery.py` added with upload-delete flow E2E test.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.