thejayman77 96d2a1a087 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>
2026-08-24 17:03:36 -04:00
2026-08-24 17:03:36 -04:00
2026-08-24 17:03:36 -04:00
2026-08-24 17:03:36 -04:00
2026-08-24 17:03:36 -04:00
2026-08-24 17:03:36 -04:00

Parts Inventory

A catalog of what's actually on the shelf — components, filament, fasteners, tooling — so "do I have one of those?" is a five-second search instead of an hour of opening drawers.

Runs as a FastAPI + SQLite container on mainserver (192.168.50.8) behind Caddy at https://parts.tjm77.com, alongside the other ~/srv services.

The data model

One parts table holds what every kind of stock has in common — name, quantity, unit, location, cost, min-stock threshold. Everything type-specific lives in part_specs as key/value rows, so a 0603 resistor, a spool of PLA and a box of M3 screws share a table without a schema fork.

Categories and locations are both nestable trees. Workshop / Bin A3 is a real path, and filtering by Workshop returns everything in every bin beneath it. Sidebar counts roll up the same way, so they always match what clicking the filter actually returns.

Each category carries a spec template — the properties worth recording for that kind of thing. Pick "Filament" on the add form and it pre-fills Material, Colour, Diameter, Brand, Print Temp, and switches the unit to grams. That's what keeps manual entry from being a blank page. Templates are suggestions only: delete any row, add your own, ignore them entirely.

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.

Running it locally

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
PARTS_AUTH=off PARTS_DB=$PWD/data/parts.db .venv/bin/uvicorn app.main:app --port 8123

Then open http://127.0.0.1:8123.

Tests

.venv/bin/python -m tests.test_api          # 203 checks, in-process
.venv/bin/python -m tests.test_concurrency  # 25 checks, against a real uvicorn

test_api exercises the API end to end against a throwaway database — the auth gate and session-token signing, nested categories and locations, search across every indexed field, filter rollups, stock adjustment and history, patch semantics including explicit nulls, taxonomy cycle rejection, security headers 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 (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, that a photo uploads and comes back as a thumbnail you can open, and that nothing overflows on a 390px phone.

.venv/bin/python -m playwright install chromium   # once
.venv/bin/python -m tools.render_check --keep-shots /tmp/shots

It uses Playwright's own Chromium deliberately: headless Chrome driving the installed browser returns nothing at all while a desktop Chrome is open, which is silent enough to look like the app is broken.

test_concurrency needs a real server process, because a lost update only shows up when two requests genuinely overlap inside SQLite. It fires overlapping adjustments, patches and creates at one part and asserts the stock log always sums to the stored quantity; races taxonomy renames against reads to check the search index never describes a name the tree no longer has; and races session revocations to check no epoch increment is lost.

Configuration

The password is managed from the app, not from a config file. PARTS_PASSWORD is only the bootstrap credential: it works until a password is set through the UI, and is ignored from then on (otherwise "changing" the password would leave the old one working). The stored password is a salted scrypt hash in the settings table, which lives in the /data volume and survives rebuilds.

Variable Meaning
PARTS_PASSWORD Bootstrap password, used only until one is set in the app.
PARTS_SECRET Signing key for the session cookie. openssl rand -hex 32.
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.

Every token carries a session epoch. Changing the password bumps it, which invalidates every outstanding cookie at once while re-issuing one for the browser that made the change — so a password change really does sign out other devices, with no need to touch PARTS_SECRET on the host. Sign out other devices in Settings bumps the epoch on its own.

Failed logins are throttled on a global window rather than per source address. Caddy appends to X-Forwarded-For instead of replacing it, so the client-supplied end of that header is forgeable and a per-IP bucket would be trivially evaded. There is one legitimate user, so a global cap costs nothing real; the trade-off is that a guessing spray can block the login form for the window. Existing sessions keep working throughout.

Deploying

Lives at /home/jay/srv/parts/ on .8 and follows the same conventions as the other services there — build: ., external caddy_web network, named volume for /data.

~/srv/parts is a checkout of this repository — git log there tells you exactly what is deployed — but deploys go over rsync, not git pull:

rsync -az --delete \
  --exclude '.venv' --exclude 'data' --exclude '__pycache__' --exclude '*.pyc' \
  --exclude '.env' --exclude '.env.bak-*' \
  ./ jay@192.168.50.8:/home/jay/srv/parts/
ssh jay@192.168.50.8 'cd ~/srv/parts && docker compose up -d --build'

.env is excluded from the transfer, and rsync's --delete leaves excluded files alone, so the deployed credentials survive.

The reason it isn't git pull: gitea on .8 cannot serve a clone or fetch to .8 itself. Auth succeeds and gitea logs git-upload-pack ... 200 OK, then the transfer dies with fetch-pack: unexpected disconnect while reading sideband packet. Reproduced against both git.tjm77.com:2222 and 127.0.0.1:2222, with --depth 1, with protocol v0, and with fsck disabled; git ls-remote succeeds every time, and cloning the same repo from a laptop works. Undiagnosed. Until it's fixed, the checkout in ~/srv/parts is placed there by rsyncing the working tree including .git, which is why git status there is clean.

~/srv (the infra repo) ignores parts/, since this directory is its own repository.

The database is in the parts_parts_data docker volume, which survives rebuilds.

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 snapshots a live database consistently:

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 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

Everything under /api is JSON and cookie-authenticated. GET /healthz is open.

GET    /api/parts?q=&category_id=&location_id=&tag=&low_stock=&sort=&limit=&offset=
POST   /api/parts
GET    /api/parts/{id}
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
GET    /api/stats

The "what could I build with what I'm holding?" idea is meant to arrive as another consumer of these endpoints — the arbiter on .8 already has the lane routing and typed-action machinery for it — rather than as a fork of them.

Passwords

Open Settings (the gear in the header) and use the Password section. It asks for the current password, takes a new one twice, and signs out every other device. Until a password has been set in the app, a banner says so.

The only reason to touch a terminal is a forgotten password:

docker exec -it parts python -m app.admin set-password    # prompts, twice
docker exec parts python -m app.admin show-status         # where the password comes from
docker exec parts python -m app.admin clear-password      # fall back to PARTS_PASSWORD

Each of those signs out every session too.

Hardening notes

Responses carry X-Content-Type-Options, X-Frame-Options, Referrer-Policy and a Content-Security-Policy that keeps the page to same-origin scripts and no framing; HSTS is added when X-Forwarded-Proto says the original request was HTTPS. The session cookie is HttpOnly, SameSite=Lax and Secure, and its signature is a fixed-width HMAC appended to the payload rather than delimited — a delimiter byte can occur inside a raw digest, which previously invalidated about 12% of issued tokens.

Static assets are served under content-hashed URLs (app.js?v=<sha>), so Cloudflare caching them for hours is harmless: a deploy changes the URL. The bare, unhashed paths are served no-cache so nothing can pin stale frontend code against a newer API.

S
Description
No description provided
Readme 311 KiB
Languages
Python 74.7%
JavaScript 16.9%
CSS 4.3%
Shell 2.6%
HTML 1.4%
Other 0.1%