Four audit findings on the photo feature.
The upload route was async, so its blocking SQLite work ran on the event loop:
under contention it stalled every other request for SQLite's busy timeout, not
just its own. It also read the photo count without the write lock, so
overlapping uploads all observed the same total and stored past the ceiling
together. It is a synchronous endpoint now, running in the threadpool, taking
BEGIN IMMEDIATE before re-checking the part, the ceiling and the position, and
committing before it returns. Reverting either half makes the new test die with
the same TimeoutError the audit reported.
The 8MB cap protected nothing: Starlette parses and spools an entire multipart
body before a route's dependencies run — before the login check — so the bytes
were already on disk by the time anything rejected them, and an anonymous
caller could make us write them. A plain ASGI middleware outside routing now
refuses an over-large body first, and Caddy enforces the same ceiling at the
edge.
The documented backup captured the database and the photos at two different
moments while the app stayed writable, so a photo deleted in between left the
saved database pointing at a file the archive did not contain. tools/backup.sh
stops the app for the few seconds the copy takes and verifies afterwards that
every referenced photo is in the archive.
Cleanup could destroy data rather than merely litter: prune-images could delete
a file between an upload writing it and inserting its row, and deletions
unlinked before their transaction committed. Pruning now ignores anything under
an hour old unless forced, and deletes commit before unlinking — an orphaned
file is recoverable, a row without its photo is not. check-images reports drift
in both directions and fails only on the direction that loses data.
Checks go from 271 to 291.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Two session-management bugs from the audit:
- /api/password recorded failed attempts against the login budget but never
checked it, so a borrowed session could guess the current password without
limit while still locking the owner out of /api/login. Verified: thirteen
consecutive wrong guesses all returned 403 and none returned 429. It now
spends from the same budget it was topping up.
- bump_epoch read the epoch and wrote it back without the write lock, so
concurrent revocations lost increments and sessions that should have been cut
off survived. Verified: twenty concurrent bumps advanced the counter from 2 to
6, and twenty concurrent "sign out other devices" calls left four sessions
authenticated. It takes BEGIN IMMEDIATE now; set_password hashes before
locking, so scrypt doesn't serialise unrelated writes.
Removing either fix makes its test fail with exactly that symptom.
README corrections: deployment is rsync, not git pull — gitea on .8 cannot
serve a clone to .8 itself, which the deploy section now documents — and the
concurrency check count was understated.
Checks go from 182 to 197.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Password management moves out of the CLI entirely. The credential is now a
salted scrypt hash in the database (so it survives rebuilds, living in the /data
volume) rather than an environment variable; PARTS_PASSWORD is demoted to a
bootstrap value that stops working the moment a password is set in the UI. Every
token carries a session epoch, so changing the password — or "sign out other
devices" — invalidates outstanding cookies while keeping the browser that made
the change signed in. A banner nags until the handed-over password is replaced.
app/admin.py remains for the one case the UI cannot cover, a forgotten password.
Audit findings:
- Taxonomy update and delete scanned affected parts before taking the write
lock, so a concurrent rename could leave the search index matching a name the
UI no longer showed. All four routes now lock first; removing the lock again
makes the new test fail exactly that way.
- History of a missing part returned 200 with an empty list; now 404.
- Infinity and NaN passed ge=0 and failed at the database. They are rejected as
422 now, and the validation error handler no longer chokes trying to echo a
non-finite value back.
Checks go from 134 to 181.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking:
- Stock adjustments read-modified-wrote outside a transaction, so concurrent
changes silently overwrote each other. Verified: 50 concurrent -1 requests
moved a quantity of 100 to 99 rather than 50, while all 51 history rows were
written, leaving the ledger disagreeing with the stock. Both adjust and patch
now take SQLite's write lock up front.
- Session tokens joined payload and HMAC with "." and split on the last
occurrence. A raw digest can contain that byte, so ~12% of issued tokens
failed their own validator (measured 121/1000). The digest is fixed width;
slice by length instead.
- starlette 0.41.3 and python-multipart 0.0.20 carried 15 advisories between
them, including a FileResponse Range-header DoS reachable through the public
static assets. Pinned starlette explicitly; python-multipart was unused.
Also:
- PATCH quantity now writes history, and a floored adjustment logs the delta it
applied rather than the one requested, so the log sums to the stock.
- Renaming or deleting a category or location rebuilds the search index for
every part beneath it; full paths are indexed, so "Workshop" finds Bin A3.
- Blank names, negative quantities and explicit nulls on NOT NULL columns are
422s instead of silent writes or 500s; taxonomy routes 404 on missing ids,
409 on duplicates, and reject indirect parent cycles.
- Security headers, HSTS behind X-Forwarded-Proto, Secure cookie via the
forwarded scheme, content-hashed asset URLs so Cloudflare cannot serve stale
frontend code, and a global failed-login throttle.
- README documents a WAL-safe backup; cp of parts.db alone could lose commits.
Checks go from 68 to 134, including a suite that runs against a real server
because lost updates only appear when requests genuinely overlap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FastAPI + SQLite behind a single-page frontend, deployed as a container on
mainserver behind Caddy.
One flexible parts table plus key/value specs so components, filament,
fasteners and tooling share a schema. Categories and locations are nestable
trees whose filters and sidebar counts both roll up through descendants.
Category spec templates pre-fill the properties worth recording for each kind
of part, which is what makes manual entry tolerable. Every quantity change is
logged. Search is FTS5 with prefix matching across names, MPNs, spec values,
tags, category and location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>