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(