Add photo attachments to parts

A bag falling apart after forty years still has the part number printed on it,
and the picture carries more than any field you could retype it into. Photos
attach from the part form; on a phone the picker opens the camera directly.

Files live beside the database in the same volume, with only metadata in SQLite
— blobs there bloat the database and complicate the VACUUM INTO backup. The
browser downscales to 2000px before uploading, honouring EXIF orientation via
createImageBitmap, which keeps an image library and its native dependencies out
of the server entirely.

Uploads are sniffed by content rather than trusted by their declared type, so a
file that merely claims to be a JPEG cannot be stored and served back from this
origin as something a browser will execute. SVG is refused for the same reason.
Reads are capped rather than trusting Content-Length, at most 12 photos per
part, and a row that fails to insert takes its file with it.

Deleting a photo or a part removes the files, not just the rows, and
`app.admin prune-images` sweeps anything a crash stranded. The README's backup
procedure now covers both halves; capturing only the database would have
silently lost every photo.

python-multipart returns for the upload, pinned at 0.0.32 — the version removed
earlier was 0.0.20, which carried advisories. Audit is clean.

Two frontend bugs surfaced while testing this in the browser: a photo count
changing left the list row stale, and the part form opened from the list's
cached copy rather than fetching current data.

Checks go from 197 to 271.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 17:03:36 -04:00
parent 59949e91ca
commit 96d2a1a087
9 changed files with 742 additions and 26 deletions
+35 -10
View File
@@ -29,6 +29,16 @@ Every quantity change is written to `stock_log`, so a part's history answers
"where did those 40 headers go" instead of just showing a smaller number than
you remembered.
Parts carry **photos**. A bag falling apart after forty years still has the part
number printed on it, and the picture is worth more than any field you could
type it into. Add them from the part form — on a phone the picker opens the
camera directly — and they are downscaled in the browser before upload, which
keeps the server free of an image library. Files live beside the database in the
same volume; only metadata is in SQLite, because blobs there bloat the database
and complicate the backup. Uploads are sniffed by content rather than trusted by
their declared type, so nothing that claims to be a JPEG can come back out as
something a browser will execute; SVG is refused for the same reason.
Search is SQLite FTS5 over name, description, manufacturer, MPN, spec values,
tags, category and location, with prefix matching so results narrow as you type.
Typing `1.75mm`, `prusament`, `0603` or `Bin A3` all find the right things.
@@ -45,7 +55,7 @@ Then open http://127.0.0.1:8123.
## Tests
```sh
.venv/bin/python -m tests.test_api # 172 checks, in-process
.venv/bin/python -m tests.test_api # 203 checks, in-process
.venv/bin/python -m tests.test_concurrency # 25 checks, against a real uvicorn
```
@@ -57,11 +67,12 @@ and asset versioning, login throttling on both the login and change-password
routes, and the full password-management flow including scrypt hashing, session
invalidation and the recovery CLI.
`tools/render_check.py` drives the real UI in a browser and asserts what a
person would see: that the JavaScript runs under the Content-Security-Policy,
`tools/render_check.py` (43 checks) drives the real UI in a browser and
asserts what a person would see: that the JavaScript runs under the Content-Security-Policy,
that a wrong password says so, that picking "Filament" pre-fills its spec
template and switches the unit to grams, that the password section works, and
that nothing overflows on a 390px phone.
template and switches the unit to grams, that the password section works, that a
photo uploads and comes back as a thumbnail you can open, and that nothing
overflows on a 390px phone.
```sh
.venv/bin/python -m playwright install chromium # once
@@ -94,6 +105,7 @@ the old one working). The stored password is a salted scrypt hash in the
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
| `PARTS_DB` | SQLite path. `/data/parts.db` in the container. |
| `PARTS_IMAGE_DIR` | Where photos are written. Defaults to `images/` beside the database. |
| `PARTS_LOGIN_MAX_FAILURES` | Failed logins allowed per window, default 10. |
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
@@ -146,20 +158,28 @@ repository.
The database is in the `parts_parts_data` docker volume, which survives
rebuilds.
**Back it up with SQLite's backup API, not `cp`.** The database runs in WAL
A backup needs **two** things: the database and the photo files. The database
must be captured with SQLite's backup API rather than `cp` — it runs in WAL
mode, so recently committed rows may still live in `parts.db-wal` and copying
`parts.db` alone can silently lose them. `VACUUM INTO` takes a consistent
snapshot of a live database:
`parts.db` alone can silently lose them. `VACUUM INTO` snapshots a live
database consistently:
```sh
docker exec parts python -c \
"import sqlite3; sqlite3.connect('/data/parts.db').execute(\"VACUUM INTO '/data/backup.db'\")"
docker cp parts:/data/backup.db ./parts-backup-$(date +%F).db
docker exec parts rm /data/backup.db
# the photos, which the database only holds pointers to
docker exec parts tar -cf - -C /data images > ./parts-images-$(date +%F).tar
```
Restore by stopping the container, copying the file back over `/data/parts.db`
and deleting any leftover `-wal`/`-shm` alongside it.
Restore by stopping the container, copying the database back over
`/data/parts.db`, deleting any leftover `-wal`/`-shm` alongside it, and
unpacking the image tar into `/data`. If the two ever drift apart,
`docker exec parts python -m app.admin prune-images` deletes files nothing
points at; rows whose file is missing surface as a broken thumbnail rather than
an error.
## API
@@ -173,6 +193,11 @@ PATCH /api/parts/{id}
DELETE /api/parts/{id}
POST /api/parts/{id}/adjust {delta, reason}
GET /api/parts/{id}/history
GET /api/parts/{id}/images
POST /api/parts/{id}/images multipart: file, caption
GET /api/parts/{id}/images/{token}
PATCH /api/parts/{id}/images/{token} {caption, position}
DELETE /api/parts/{id}/images/{token}
GET /api/categories POST /api/categories PATCH|DELETE /api/categories/{id}
GET /api/locations POST /api/locations PATCH|DELETE /api/locations/{id}
GET /api/tags