From 96d2a1a0876e4b38ce32afdb1e2d603b6e9b423a Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 24 Aug 2026 17:03:36 -0400 Subject: [PATCH] Add photo attachments to parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 45 ++++++++--- app/admin.py | 42 ++++++++-- app/db.py | 60 ++++++++++++++ app/main.py | 178 ++++++++++++++++++++++++++++++++++++++++-- requirements.txt | 2 + static/app.css | 56 +++++++++++++ static/app.js | 175 ++++++++++++++++++++++++++++++++++++++++- tests/test_api.py | 114 +++++++++++++++++++++++++++ tools/render_check.py | 96 ++++++++++++++++++++++- 9 files changed, 742 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index de25681..0915dc7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/admin.py b/app/admin.py index 41f2ec8..6bed046 100644 --- a/app/admin.py +++ b/app/admin.py @@ -5,6 +5,7 @@ cannot help with — a forgotten password — and for the initial handover. docker exec -it parts python -m app.admin set-password docker exec parts python -m app.admin show-status + docker exec parts python -m app.admin prune-images """ import getpass @@ -40,16 +41,45 @@ def clear_password(_argv): return 0 -def show_status(_argv): +def prune_images(_argv): + """Delete image files with no row pointing at them. + + Uploads write the file before the row, so an ill-timed crash can strand + one. Nothing else creates orphans; this is a sweeper, not a routine step. + """ + import os + with db.session() as conn: - stored = auth.stored_hash(conn) - print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var") - print("session epoch :", auth.current_epoch(conn)) - print("auth enabled :", auth.auth_enabled()) + known = { + os.path.basename(db.image_path(r["token"], r["mime"])) + for r in conn.execute("SELECT token, mime FROM part_images").fetchall() + } + removed = 0 + for name in os.listdir(db.IMAGE_DIR): + if name not in known: + os.remove(os.path.join(db.IMAGE_DIR, name)) + removed += 1 + print(f"{removed} orphaned image file(s) removed; {len(known)} kept.") return 0 -COMMANDS = {"set-password": set_password, "clear-password": clear_password, "show-status": show_status} +def show_status(_argv): + import os + + with db.session() as conn: + stored = auth.stored_hash(conn) + images = conn.execute("SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b " + "FROM part_images").fetchone() + print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var") + print("session epoch :", auth.current_epoch(conn)) + print("auth enabled :", auth.auth_enabled()) + print("images :", f"{images['n']} rows, {images['b'] / 1024 / 1024:.1f} MB", + f"({len(os.listdir(db.IMAGE_DIR))} files in {db.IMAGE_DIR})") + return 0 + + +COMMANDS = {"set-password": set_password, "clear-password": clear_password, + "show-status": show_status, "prune-images": prune_images} def main(argv=None): diff --git a/app/db.py b/app/db.py index 81e3bb4..e6344d2 100644 --- a/app/db.py +++ b/app/db.py @@ -16,6 +16,11 @@ from contextlib import contextmanager DB_PATH = os.environ.get("PARTS_DB", os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "parts.db")) +# Photos live beside the database, inside the same volume, so one backup target +# covers both. +IMAGE_DIR = os.environ.get( + "PARTS_IMAGE_DIR", os.path.join(os.path.dirname(os.path.abspath(DB_PATH)), "images")) + _init_lock = threading.Lock() _initialised = False @@ -94,6 +99,22 @@ CREATE TABLE IF NOT EXISTS stock_log ( ); CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC); +-- Photos of a part: the bag it came in, the markings on the chip, whatever the +-- datasheet doesn't tell you. Only metadata lives here; the file itself sits in +-- IMAGE_DIR, because blobs in SQLite bloat the database and complicate the +-- VACUUM INTO backup. +CREATE TABLE IF NOT EXISTS part_images ( + id INTEGER PRIMARY KEY, + part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + mime TEXT NOT NULL, + bytes INTEGER NOT NULL, + caption TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS part_images_part ON part_images(part_id, position, id); + -- Small key/value store for things that must outlive a container rebuild and -- be changeable without editing a file on the host: the password hash and the -- session epoch live here, in the /data volume. @@ -109,6 +130,30 @@ CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5( """ +# Only these are accepted and only these are ever served back. The uploaded +# bytes are sniffed rather than trusted, so a file that merely claims to be a +# JPEG cannot come back out as something the browser will execute. +MAGIC = [ + (b"\xff\xd8\xff", "image/jpeg"), + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"GIF87a", "image/gif"), + (b"GIF89a", "image/gif"), +] +EXTENSIONS = {"image/jpeg": ".jpg", "image/png": ".png", + "image/gif": ".gif", "image/webp": ".webp"} + + +def sniff_image(head: bytes) -> str | None: + """The real type of an uploaded file, or None if it isn't an image we take.""" + for magic, mime in MAGIC: + if head.startswith(magic): + return mime + # WEBP is "RIFF" + 4 size bytes + "WEBP". + if head[:4] == b"RIFF" and head[8:12] == b"WEBP": + return "image/webp" + return None + + def connect(): # FastAPI runs a sync `yield` dependency and its endpoint on different # threadpool threads, so the connection must not be thread-pinned. Each @@ -128,6 +173,7 @@ def init(): if _initialised: return os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True) + os.makedirs(IMAGE_DIR, exist_ok=True) conn = connect() try: conn.executescript(SCHEMA) @@ -139,6 +185,20 @@ def init(): _initialised = True +def image_path(token: str, mime: str) -> str: + """Where one image file lives. The token is generated, never client-supplied.""" + return os.path.join(IMAGE_DIR, token + EXTENSIONS.get(mime, ".bin")) + + +def delete_image_files(rows): + """Remove the files behind a set of part_images rows, ignoring absentees.""" + for row in rows: + try: + os.remove(image_path(row["token"], row["mime"])) + except FileNotFoundError: + pass + + def get_setting(conn, key: str, default: str | None = None) -> str | None: row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() return row["value"] if row else default diff --git a/app/main.py b/app/main.py index 5ead433..30c56f6 100644 --- a/app/main.py +++ b/app/main.py @@ -11,13 +11,15 @@ import hashlib import json import math import os +import secrets import sqlite3 from contextlib import asynccontextmanager from typing import Annotated, Any, Literal -from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response +from fastapi import (Depends, FastAPI, File, Form, HTTPException, Query, Request, + Response, UploadFile) from fastapi.exceptions import RequestValidationError -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import AfterValidator, BaseModel, Field @@ -25,9 +27,15 @@ from . import auth, db STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static") +# A downscaled phone photo lands around 300KB; the cap is generous enough for an +# un-resized original from a browser that couldn't downscale, and small enough +# that nothing can fill the volume by accident. +MAX_IMAGE_BYTES = 8 * 1024 * 1024 +MAX_IMAGES_PER_PART = 12 + CSP = ( "default-src 'self'; " - "img-src 'self' data:; " + "img-src 'self' data: blob:; " "style-src 'self' 'unsafe-inline'; " # the UI sets inline style attributes "script-src 'self'; " "connect-src 'self'; " @@ -110,6 +118,11 @@ PART_COLUMNS = [ ] +class ImagePatch(BaseModel): + caption: Text | None = Field(default=None, max_length=300) + position: int | None = Field(default=None, ge=0) + + class AdjustIn(BaseModel): delta: float = Field(allow_inf_nan=False) reason: Text = Field(default="", max_length=300) @@ -165,12 +178,30 @@ def _tags_for(conn, part_ids: list[int]) -> dict[int, list[str]]: return out -def _serialise(rows, specs, tags, cat_paths, loc_paths) -> list[dict]: +def _images_for(conn, part_ids: list[int]) -> dict[int, list[dict]]: + if not part_ids: + return {} + marks = ",".join("?" * len(part_ids)) + rows = conn.execute( + f"""SELECT part_id, token, mime, bytes, caption, position + FROM part_images WHERE part_id IN ({marks}) + ORDER BY position, id""", + part_ids, + ).fetchall() + out: dict[int, list[dict]] = {} + for r in rows: + out.setdefault(r["part_id"], []).append(dict(r)) + return out + + +def _serialise(rows, specs, tags, cat_paths, loc_paths, images=None) -> list[dict]: + images = images or {} out = [] for r in rows: d = dict(r) d["specs"] = specs.get(r["id"], []) d["tags"] = tags.get(r["id"], []) + d["images"] = images.get(r["id"], []) d["category_path"] = cat_paths.get(r["category_id"]) if r["category_id"] else None d["location_path"] = loc_paths.get(r["location_id"]) if r["location_id"] else None d["low_stock"] = ( @@ -224,6 +255,7 @@ def _fetch_part(conn, part_id: int) -> dict: return _serialise( [row], _specs_for(conn, [part_id]), _tags_for(conn, [part_id]), db.tree_paths(conn, "categories"), db.tree_paths(conn, "locations"), + _images_for(conn, [part_id]), )[0] @@ -295,7 +327,9 @@ async def security_headers(request: Request, call_next): else: headers["Cache-Control"] = "no-cache" elif request.url.path.startswith("/api/"): - headers["Cache-Control"] = "no-store" + # setdefault, not assignment: the image route serves immutable, + # token-addressed files and sets its own caching. + headers.setdefault("Cache-Control", "no-store") return response @@ -483,6 +517,7 @@ def list_parts( items = _serialise( rows, _specs_for(conn, ids), _tags_for(conn, ids), db.tree_paths(conn, "categories"), db.tree_paths(conn, "locations"), + _images_for(conn, ids), ) return {"total": total, "limit": limit, "offset": offset, "items": items} @@ -565,10 +600,16 @@ def update_part(part_id: int, body: PartPatch, conn=Depends(db.get_db)): @app.delete("/api/parts/{part_id}", dependencies=[Depends(auth.require_auth)]) def delete_part(part_id: int, conn=Depends(db.get_db)): + # Collect the image rows first: the cascade removes them from the database, + # but the files on disk would be left behind as orphans. + images = conn.execute( + "SELECT token, mime FROM part_images WHERE part_id = ?", (part_id,) + ).fetchall() cur = conn.execute("DELETE FROM parts WHERE id = ?", (part_id,)) if cur.rowcount == 0: raise HTTPException(404, "Part not found") conn.execute("DELETE FROM parts_fts WHERE rowid = ?", (part_id,)) + db.delete_image_files(images) return {"deleted": part_id} @@ -606,6 +647,133 @@ def part_history(part_id: int, conn=Depends(db.get_db), limit: int = Query(defau return {"items": [dict(r) for r in rows]} +# --- part images --------------------------------------------------------- +# +# Photos of the bag a part came out of, the print on a chip, a wiring note. The +# file is written to the volume beside the database; only metadata is stored in +# SQLite. + + +def _image_row(conn, part_id: int, token: str): + row = conn.execute( + "SELECT * FROM part_images WHERE part_id = ? AND token = ?", (part_id, token) + ).fetchone() + if row is None: + raise HTTPException(404, "Image not found") + return row + + +@app.get("/api/parts/{part_id}/images", dependencies=[Depends(auth.require_auth)]) +def list_images(part_id: int, conn=Depends(db.get_db)): + if conn.execute("SELECT 1 FROM parts WHERE id = ?", (part_id,)).fetchone() is None: + raise HTTPException(404, "Part not found") + return {"items": _images_for(conn, [part_id]).get(part_id, [])} + + +@app.post("/api/parts/{part_id}/images", dependencies=[Depends(auth.require_auth)], + status_code=201) +async def upload_image( + part_id: int, + file: UploadFile = File(...), + caption: str = Form(default=""), + conn=Depends(db.get_db), +): + if conn.execute("SELECT 1 FROM parts WHERE id = ?", (part_id,)).fetchone() is None: + raise HTTPException(404, "Part not found") + + existing = conn.execute( + "SELECT COUNT(*) AS n FROM part_images WHERE part_id = ?", (part_id,) + ).fetchone()["n"] + if existing >= MAX_IMAGES_PER_PART: + raise HTTPException(409, f"A part can hold at most {MAX_IMAGES_PER_PART} images") + + # Read with a ceiling rather than trusting Content-Length, which a client + # controls. One byte over the cap is enough to know it is too big. + data = b"" + while len(data) <= MAX_IMAGE_BYTES: + chunk = await file.read(256 * 1024) + if not chunk: + break + data += chunk + if len(data) > MAX_IMAGE_BYTES: + raise HTTPException(413, f"Images must be {MAX_IMAGE_BYTES // (1024 * 1024)}MB or smaller") + if not data: + raise HTTPException(422, "Empty upload") + + # The declared content type is ignored; the bytes decide. That is what stops + # an HTML or SVG payload being stored and later served back from this origin. + mime = db.sniff_image(data[:32]) + if mime is None: + raise HTTPException(415, "Not a supported image (JPEG, PNG, WEBP or GIF)") + + token = secrets.token_urlsafe(16) + path = db.image_path(token, mime) + with open(path, "wb") as fh: + fh.write(data) + try: + nxt = conn.execute( + "SELECT COALESCE(MAX(position), -1) + 1 AS n FROM part_images WHERE part_id = ?", + (part_id,), + ).fetchone()["n"] + conn.execute( + "INSERT INTO part_images(part_id, token, mime, bytes, caption, position) " + "VALUES (?,?,?,?,?,?)", + (part_id, token, mime, len(data), caption.strip()[:300], nxt), + ) + conn.execute( + "UPDATE parts SET updated_at = datetime('now') WHERE id = ?", (part_id,) + ) + except Exception: + # Don't leave a file behind for a row that never landed. + db.delete_image_files([{"token": token, "mime": mime}]) + raise + return {"token": token, "mime": mime, "bytes": len(data), + "caption": caption.strip()[:300], "position": nxt} + + +@app.get("/api/parts/{part_id}/images/{token}", dependencies=[Depends(auth.require_auth)]) +def get_image(part_id: int, token: str, conn=Depends(db.get_db)): + row = _image_row(conn, part_id, token) + path = db.image_path(row["token"], row["mime"]) + if not os.path.exists(path): + raise HTTPException(404, "Image file is missing") + return FileResponse( + path, + media_type=row["mime"], + headers={ + # The token addresses exactly these bytes and never changes, so the + # browser may keep it. Private, because it sits behind the login. + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Disposition": "inline", + }, + ) + + +@app.patch("/api/parts/{part_id}/images/{token}", dependencies=[Depends(auth.require_auth)]) +def update_image(part_id: int, token: str, body: ImagePatch, conn=Depends(db.get_db)): + _image_row(conn, part_id, token) + if body.caption is not None: + conn.execute( + "UPDATE part_images SET caption = ? WHERE part_id = ? AND token = ?", + (body.caption, part_id, token), + ) + if body.position is not None: + conn.execute( + "UPDATE part_images SET position = ? WHERE part_id = ? AND token = ?", + (body.position, part_id, token), + ) + return {"token": token} + + +@app.delete("/api/parts/{part_id}/images/{token}", dependencies=[Depends(auth.require_auth)]) +def delete_image(part_id: int, token: str, conn=Depends(db.get_db)): + row = _image_row(conn, part_id, token) + conn.execute("DELETE FROM part_images WHERE part_id = ? AND token = ?", (part_id, token)) + conn.execute("UPDATE parts SET updated_at = datetime('now') WHERE id = ?", (part_id,)) + db.delete_image_files([row]) + return {"deleted": token} + + # --- categories & locations ------------------------------------------------- def _node_payload(conn, table: str) -> list[dict]: diff --git a/requirements.txt b/requirements.txt index 3414f51..0629954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ fastapi==0.141.1 starlette==1.6.0 uvicorn[standard]==0.52.4 pydantic==2.13.4 +# Needed for multipart photo uploads. 0.0.20 carried advisories; 0.0.32 is clean. +python-multipart==0.0.32 diff --git a/static/app.css b/static/app.css index 3b26fd2..fe54884 100644 --- a/static/app.css +++ b/static/app.css @@ -217,6 +217,62 @@ main { flex: 1; min-width: 0; } .spec-row { display: flex; gap: 8px; margin-bottom: 7px; align-items: center; } .spec-row input:first-child { flex: 0 0 38%; } +.shots { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } +.shot { + position: relative; + width: 96px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: var(--panel-2); +} +.shot img { display: block; width: 96px; height: 96px; object-fit: cover; cursor: zoom-in; } +.shot .cap { + width: 100%; + border: 0; + border-top: 1px solid var(--line); + border-radius: 0; + background: var(--panel); + font-size: 11px; + padding: 4px 6px; + min-height: 0; +} +.shot .rm { + position: absolute; + top: 3px; + right: 3px; + min-height: 0; + padding: 0 6px; + font-size: 12px; + line-height: 18px; + background: rgba(6, 8, 13, .78); + border-color: transparent; + color: var(--bad); +} +.shot.pending { opacity: .6; } +.shot.pending::after { + content: "queued"; + position: absolute; + left: 3px; top: 3px; + background: rgba(6,8,13,.78); + font-size: 10px; + padding: 1px 5px; + border-radius: 4px; + color: var(--dim); +} +.lightbox { + position: fixed; + inset: 0; + background: rgba(4, 6, 10, .93); + display: grid; + place-items: center; + z-index: 200; + padding: 20px; + cursor: zoom-out; +} +.lightbox img { max-width: 100%; max-height: 88vh; border-radius: 10px; } +.lightbox .cap { margin-top: 10px; color: var(--dim); font-size: 13px; text-align: center; } + .history { font-size: 13px; color: var(--dim); } .history div { padding: 4px 0; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; gap: 10px; } .pos { color: var(--good); } diff --git a/static/app.js b/static/app.js index b4655ab..6af8df3 100644 --- a/static/app.js +++ b/static/app.js @@ -50,6 +50,63 @@ async function api(path, options = {}) { return res.status === 204 ? null : res.json(); } +async function upload(partId, file, caption = "") { + const form = new FormData(); + form.append("file", file); + form.append("caption", caption); + // Not via api(): that sets a JSON content-type, and multipart needs the + // browser to supply its own boundary. + const res = await fetch(`/api/parts/${partId}/images`, { method: "POST", body: form }); + if (res.status === 401) { showLogin(); throw new Error("Not authenticated"); } + if (!res.ok) { + let detail = res.statusText; + try { detail = (await res.json()).detail || detail; } catch (_) {} + throw new Error(typeof detail === "string" ? detail : "Upload failed"); + } + return res.json(); +} + +// Phone photos run 3-12MB, which is wasteful to store and slow to open over a +// phone connection. Shrinking in the browser keeps the server free of an image +// library and its native dependencies. +const MAX_EDGE = 2000; + +async function shrink(file) { + let bitmap; + try { + // from-image honours the EXIF rotation phones write, which canvas would + // otherwise ignore and hand back a sideways photo. + bitmap = await createImageBitmap(file, { imageOrientation: "from-image" }); + } catch (_) { + return file; // can't decode it here — let the server accept or reject it + } + const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height)); + if (scale === 1 && file.size <= 600 * 1024) { + bitmap.close?.(); + return file; + } + const w = Math.round(bitmap.width * scale); + const h = Math.round(bitmap.height * scale); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h); + bitmap.close?.(); + const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.85)); + if (!blob || blob.size >= file.size) return file; + return new File([blob], (file.name || "photo").replace(/\.\w+$/, "") + ".jpg", + { type: "image/jpeg" }); +} + +function openLightbox(src, caption) { + const box = document.createElement("div"); + box.className = "lightbox"; + box.innerHTML = `
${esc(caption || ` + + (caption ? `
${esc(caption)}
` : "") + "
"; + box.onclick = () => box.remove(); + document.body.appendChild(box); +} + let toastTimer; function toast(message, bad = false) { document.querySelectorAll(".toast").forEach((t) => t.remove()); @@ -172,6 +229,8 @@ function partCard(part) { if (part.mpn) bits.push(part.mpn); const chips = [ + ...(part.images && part.images.length + ? [`\u{1F4F7} ${part.images.length}`] : []), ...part.specs.filter((s) => s.value).slice(0, 5) .map((s) => `${esc(s.key)}: ${esc(s.value)}`), ...part.tags.map((t) => `${esc(t)}`), @@ -191,7 +250,15 @@ function partCard(part) { `; - node.querySelector(".body").onclick = () => openPartModal(part); + node.querySelector(".body").onclick = async () => { + // Fetch fresh rather than opening the copy this row was drawn from: photos, + // captions and quantities can all have moved on since the list was built. + try { + openPartModal(await api(`/api/parts/${part.id}`)); + } catch (ex) { + toast(ex.message, true); + } + }; node.querySelectorAll("[data-delta]").forEach((btn) => { btn.onclick = async (e) => { e.stopPropagation(); @@ -290,7 +357,13 @@ function openModal(html) { } document.addEventListener("keydown", (e) => { - if (e.key === "Escape") closeModal(); + if (e.key === "Escape") { + const box = document.querySelector(".lightbox"); + // A lightbox sits above the form, so Escape should close that first rather + // than throwing away a half-filled part. + if (box) box.remove(); + else closeModal(); + } if (e.key === "/" && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA") { e.preventDefault(); $("#search").focus(); @@ -375,6 +448,15 @@ function openPartModal(part) {
${p.specs.map((s) => specRowHTML(s.key, s.value)).join("")}
+
+ +
+ +
+ Snap the bag or the markings. Large photos are shrunk before upload. +
+
@@ -430,6 +512,84 @@ function openPartModal(part) { bindRemove(); }; + // Photos for a part that doesn't exist yet are held here and uploaded once + // saving gives us an id. + const pending = []; + const shots = overlay.querySelector("#shots"); + const shotMsg = overlay.querySelector("#shot-msg"); + + // The row behind the modal shows a photo count, so it has to be redrawn when + // photos are added or removed — otherwise you close the form and the count + // you just changed is still the old one. + const refreshRow = () => { if (!isNew) search(false).catch(() => {}); }; + + const renderShots = () => { + shots.innerHTML = ""; + for (const img of p.images || []) { + const el = document.createElement("div"); + el.className = "shot"; + const src = `/api/parts/${p.id}/images/${img.token}`; + el.innerHTML = `${esc(img.caption || + + `; + el.querySelector("img").onclick = () => openLightbox(src, img.caption); + el.querySelector(".rm").onclick = async () => { + if (!confirm("Remove this photo?")) return; + try { + await api(`/api/parts/${p.id}/images/${img.token}`, { method: "DELETE" }); + p.images = p.images.filter((i) => i.token !== img.token); + renderShots(); + refreshRow(); + } catch (ex) { toast(ex.message, true); } + }; + const cap = el.querySelector(".cap"); + cap.onchange = async () => { + try { + await api(`/api/parts/${p.id}/images/${img.token}`, + { method: "PATCH", body: { caption: cap.value } }); + img.caption = cap.value; + } catch (ex) { toast(ex.message, true); } + }; + shots.appendChild(el); + } + for (const [i, file] of pending.entries()) { + const el = document.createElement("div"); + el.className = "shot pending"; + const url = URL.createObjectURL(file); + el.innerHTML = `Queued photo + `; + el.querySelector("img").onclick = () => openLightbox(url, "Not uploaded yet"); + el.querySelector(".rm").onclick = () => { pending.splice(i, 1); renderShots(); }; + shots.appendChild(el); + } + }; + renderShots(); + + overlay.querySelector("#f-photos").onchange = async (e) => { + const files = [...e.target.files]; + e.target.value = ""; + if (!files.length) return; + shotMsg.textContent = `Preparing ${files.length} photo${files.length === 1 ? "" : "s"}…`; + for (const raw of files) { + try { + const file = await shrink(raw); + if (isNew) { + pending.push(file); + } else { + const added = await upload(p.id, file); + p.images = (p.images || []).concat(added); + refreshRow(); + } + } catch (ex) { + toast(ex.message, true); + } + } + shotMsg.textContent = isNew + ? "Queued — they upload when you add the part." + : "Uploaded."; + renderShots(); + }; + overlay.querySelector("#save-btn").onclick = async () => { const num = (sel) => { const raw = overlay.querySelector(sel).value.trim(); @@ -459,8 +619,15 @@ function openPartModal(part) { if (!body.name) return toast("Give it a name", true); try { - if (isNew) await api("/api/parts", { method: "POST", body }); - else await api(`/api/parts/${p.id}`, { method: "PATCH", body }); + if (isNew) { + const created = await api("/api/parts", { method: "POST", body }); + for (const file of pending) { + try { await upload(created.id, file); } + catch (ex) { toast(`Photo failed: ${ex.message}`, true); } + } + } else { + await api(`/api/parts/${p.id}`, { method: "PATCH", body }); + } closeModal(); toast(isNew ? "Added" : "Saved"); await loadRefData(); diff --git a/tests/test_api.py b/tests/test_api.py index 042f72e..df35d93 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -390,6 +390,120 @@ with TestClient(app) as client: client.post("/api/login", json={"password": "hunter2"}).status_code == 200) check("a successful login resets the failure budget", auth_mod.login_retry_after() == 0) + # --- part photos --- + import struct as _struct, zlib as _zlib + + def make_png(w=8, h=8, rgb=(200, 40, 40)): + """A real, decodable PNG, so the browser check can display it too.""" + raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h)) + + def chunk(tag, data): + body = tag + data + return _struct.pack(">I", len(data)) + body + _struct.pack(">I", _zlib.crc32(body)) + + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", _struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", _zlib.compress(raw)) + + chunk(b"IEND", b"")) + + import app.db as _imgdb + photo_part = client.post("/api/parts", json={"name": "bag of 7400s", "quantity": 30}).json()["id"] + + def post_image(pid, content, filename="bag.png", ctype="image/png", caption=""): + return client.post(f"/api/parts/{pid}/images", + files={"file": (filename, content, ctype)}, + data={"caption": caption}) + + r = post_image(photo_part, make_png(), caption="Radio Shack bag, front") + check("photo uploads", r.status_code == 201, r.text) + tok = r.json()["token"] + check("upload reports the sniffed type", r.json()["mime"] == "image/png") + check("upload records the caption", r.json()["caption"] == "Radio Shack bag, front") + + r = client.get(f"/api/parts/{photo_part}/images/{tok}") + check("photo can be fetched back", r.status_code == 200) + check("served with its real content type", r.headers["content-type"].startswith("image/png")) + check("served bytes are identical", r.content == make_png()) + check("photo is cacheable but private", + "private" in r.headers.get("cache-control", "") and "immutable" in r.headers.get("cache-control", ""), + r.headers.get("cache-control")) + check("photo is served inline, not as a download", + "inline" in r.headers.get("content-disposition", "")) + check("nosniff still applies to uploads", + r.headers.get("x-content-type-options") == "nosniff") + + check("the part payload carries its photos", + [i["token"] for i in client.get(f"/api/parts/{photo_part}").json()["images"]] == [tok]) + check("the listing carries them too", + any(i["images"] for i in client.get("/api/parts", params={"q": "7400s"}).json()["items"])) + check("images endpoint lists them", + len(client.get(f"/api/parts/{photo_part}/images").json()["items"]) == 1) + + # --- what must not be storable --- + check("HTML claiming to be a PNG is refused", + post_image(photo_part, b"").status_code == 415) + check("SVG is refused (it can carry script)", + post_image(photo_part, b'', + "x.svg", "image/svg+xml").status_code == 415) + check("an empty upload is refused", post_image(photo_part, b"").status_code == 422) + oversized = make_png()[:8] + b"\x00" * (8 * 1024 * 1024 + 1024) + check("an oversized upload is refused", post_image(photo_part, oversized).status_code == 413) + check("a rejected upload leaves no file behind", + len(os.listdir(_imgdb.IMAGE_DIR)) == 1, str(os.listdir(_imgdb.IMAGE_DIR))) + + check("photos on a missing part 404", + post_image(999999, make_png()).status_code == 404) + check("fetching an unknown token 404s", + client.get(f"/api/parts/{photo_part}/images/nope").status_code == 404) + check("another part's token is not fetchable here", + client.get(f"/api/parts/{logged}/images/{tok}").status_code == 404) + + # --- captions, ordering and the per-part ceiling --- + client.patch(f"/api/parts/{photo_part}/images/{tok}", json={"caption": "back of the bag"}) + check("caption can be edited", + client.get(f"/api/parts/{photo_part}/images").json()["items"][0]["caption"] == "back of the bag") + second = post_image(photo_part, make_png(6, 6, (20, 90, 200))).json()["token"] + check("a second photo gets the next position", + [i["position"] for i in client.get(f"/api/parts/{photo_part}/images").json()["items"]] == [0, 1]) + client.patch(f"/api/parts/{photo_part}/images/{second}", json={"position": 0}) + client.patch(f"/api/parts/{photo_part}/images/{tok}", json={"position": 1}) + check("photos can be reordered", + [i["token"] for i in client.get(f"/api/parts/{photo_part}/images").json()["items"]] == [second, tok]) + + filler = [post_image(photo_part, make_png(4, 4, (i * 8, 60, 60))) for i in range(10)] + check("filling up to the ceiling works", all(f.status_code == 201 for f in filler)) + check("one past the ceiling is refused", + post_image(photo_part, make_png()).status_code == 409) + + # --- deletion cleans up the files, not just the rows --- + on_disk = len(os.listdir(_imgdb.IMAGE_DIR)) + check("every stored photo has a file", on_disk == 12, str(on_disk)) + client.delete(f"/api/parts/{photo_part}/images/{tok}") + check("deleting a photo removes its row", + len(client.get(f"/api/parts/{photo_part}/images").json()["items"]) == 11) + check("deleting a photo removes its file", + len(os.listdir(_imgdb.IMAGE_DIR)) == 11, str(len(os.listdir(_imgdb.IMAGE_DIR)))) + check("the deleted photo is no longer served", + client.get(f"/api/parts/{photo_part}/images/{tok}").status_code == 404) + + client.delete(f"/api/parts/{photo_part}") + check("deleting the part takes its photos with it", + len(os.listdir(_imgdb.IMAGE_DIR)) == 0, str(os.listdir(_imgdb.IMAGE_DIR))) + check("the part is gone", client.get(f"/api/parts/{photo_part}").status_code == 404) + + # --- the orphan sweeper --- + import app.admin as _admin_img + sweep_part = client.post("/api/parts", json={"name": "sweep target", "quantity": 1}).json()["id"] + kept = post_image(sweep_part, make_png()).json()["token"] + orphan = os.path.join(_imgdb.IMAGE_DIR, "stranded.jpg") + with open(orphan, "wb") as fh: + fh.write(b"\xff\xd8\xffleftover") + check("prune-images runs", _admin_img.main(["prune-images"]) == 0) + check("the orphan is gone", not os.path.exists(orphan)) + check("the referenced file is kept", + client.get(f"/api/parts/{sweep_part}/images/{kept}").status_code == 200) + client.delete(f"/api/parts/{sweep_part}") + # --- password management through the API --- check("bootstrap password is flagged", client.get("/api/me").json()["using_bootstrap_password"] is True) check("min length is advertised", client.get("/api/me").json()["min_password_length"] == 8) diff --git a/tools/render_check.py b/tools/render_check.py index 1bd0716..50d9e5a 100644 --- a/tools/render_check.py +++ b/tools/render_check.py @@ -13,6 +13,8 @@ Uses Playwright's own Chromium, which runs happily alongside a desktop Chrome import argparse import json +import struct +import zlib import os import subprocess import sys @@ -35,6 +37,20 @@ problems = [] expected_http = [] +def make_png(w=900, h=700, rgb=(190, 60, 45)): + """A real, decodable PNG — the browser has to actually draw this one.""" + raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h)) + + def chunk(tag, data): + body = tag + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) + + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + + chunk(b"IEND", b"")) + + def wait_until(fn, timeout=10.0): """Poll in Python rather than in the page. @@ -143,6 +159,9 @@ def main(): # --- desktop --- page = browser.new_page(viewport={"width": 1280, "height": 950}) watch(page) + # confirm() defaults to dismissed in Playwright, which would silently + # cancel every delete the suite performs. + page.on("dialog", lambda d: d.accept()) page.goto(BASE + "/", wait_until="networkidle") check("login gate is shown first", page.is_visible("#login")) @@ -180,6 +199,7 @@ def main(): page.fill("#search", "") wait_until(lambda: page.locator(".part").count() == seeded) + # --- quick adjust --- row = page.locator(".part", has_text="608ZZ bearing") before = row.locator(".qty .value").inner_text() @@ -205,6 +225,77 @@ def main(): page.screenshot(path=os.path.join(shots, "03-add-part.png")) page.keyboard.press("Escape") + # --- photos on an existing part --- + page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".body").click() + page.wait_for_selector("#f-photos", timeout=5000) + check("the part form offers a photo picker", page.is_visible("#f-photos")) + check("the picker asks for the camera on a phone", + page.get_attribute("#f-photos", "capture") == "environment") + + page.set_input_files("#f-photos", { + "name": "radioshack-bag.png", "mimeType": "image/png", "buffer": make_png()}) + got_shot = wait_until(lambda: page.locator("#shots .shot").count() == 1, timeout=20) + check("the uploaded photo appears as a thumbnail", got_shot, + f"{page.locator('#shots .shot').count()} shown") + drawn = page.evaluate( + "() => { const i = document.querySelector('#shots .shot img');" + " return i && i.complete ? i.naturalWidth : 0; }") + check("the thumbnail really decodes in the browser", drawn > 0, f"naturalWidth={drawn}") + check("a 900px-wide photo was not needlessly enlarged", drawn <= 2000, str(drawn)) + + page.fill("#shots .shot .cap", "Front of the bag") + page.locator("#shots .shot .cap").dispatch_event("change") + time.sleep(0.5) + + page.click("#shots .shot img") + page.wait_for_selector(".lightbox", timeout=5000) + check("clicking a thumbnail opens the full photo", page.is_visible(".lightbox")) + page.keyboard.press("Escape") + time.sleep(0.3) + check("escape closes the photo but keeps the form open", + not page.is_visible(".lightbox") and page.is_visible("#f-name")) + if shots: + page.screenshot(path=os.path.join(shots, "07-part-with-photo.png")) + + page.keyboard.press("Escape") + wait_until(lambda: page.locator(".modal").count() == 0) + camera = page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".chip") + check("the list row shows a photo count", + any("\U0001F4F7" in camera.nth(i).inner_text() for i in range(camera.count())), + [camera.nth(i).inner_text() for i in range(camera.count())]) + + # the caption survived a round trip + page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".body").click() + page.wait_for_selector("#shots .shot", timeout=5000) + check("the caption was saved", + page.input_value("#shots .shot .cap") == "Front of the bag", + page.input_value("#shots .shot .cap")) + page.click("#shots .shot .rm") + removed = wait_until(lambda: page.locator("#shots .shot").count() == 0) + check("a photo can be removed", removed) + page.keyboard.press("Escape") + wait_until(lambda: page.locator(".modal").count() == 0) + + # --- photos queued on a part that does not exist yet --- + page.click("#add-btn") + page.wait_for_selector("#f-name", timeout=5000) + page.fill("#f-name", "MC1458 dual op-amp") + page.fill("#f-quantity", "6") + page.set_input_files("#f-photos", { + "name": "chip.png", "mimeType": "image/png", "buffer": make_png(600, 480, (40, 120, 70))}) + queued = wait_until(lambda: page.locator("#shots .shot.pending").count() == 1, timeout=20) + check("a photo on a new part is queued, not uploaded", queued) + page.click("#save-btn") + saved = wait_until(lambda: page.locator(".part", has_text="MC1458").count() == 1, timeout=20) + check("the new part is created", saved) + page.locator(".part", has_text="MC1458").locator(".body").click() + page.wait_for_selector("#f-photos", timeout=5000) + attached = wait_until(lambda: page.locator("#shots .shot:not(.pending)").count() == 1) + check("the queued photo was uploaded once the part existed", attached, + f"{page.locator('#shots .shot').count()} shown") + page.keyboard.press("Escape") + wait_until(lambda: page.locator(".modal").count() == 0) + # --- settings modal, including the password section --- page.click("#manage-btn") page.wait_for_selector(".modal", timeout=5000) @@ -243,7 +334,10 @@ def main(): phone.fill("#password", "a-long-enough-password") phone.click("#login-form button[type=submit]") phone.wait_for_selector(".part", timeout=10000) - check("phone: results are visible immediately", phone.locator(".part").count() == seeded) + # seeded + the one added through the form above + check("phone: results are visible immediately", + phone.locator(".part").count() == seeded + 1, + str(phone.locator(".part").count())) check("phone: the filter sidebar starts collapsed", not phone.is_visible("#sidebar")) check("phone: a Filters button is offered", phone.is_visible("#filters-btn")) widths = phone.evaluate(