Add photo attachments to parts

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

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

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

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

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

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

Checks go from 197 to 271.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 17:03:36 -04:00
parent 59949e91ca
commit 96d2a1a087
9 changed files with 742 additions and 26 deletions
+35 -10
View File
@@ -29,6 +29,16 @@ Every quantity change is written to `stock_log`, so a part's history answers
"where did those 40 headers go" instead of just showing a smaller number than
you remembered.
Parts carry **photos**. A bag falling apart after forty years still has the part
number printed on it, and the picture is worth more than any field you could
type it into. Add them from the part form — on a phone the picker opens the
camera directly — and they are downscaled in the browser before upload, which
keeps the server free of an image library. Files live beside the database in the
same volume; only metadata is in SQLite, because blobs there bloat the database
and complicate the backup. Uploads are sniffed by content rather than trusted by
their declared type, so nothing that claims to be a JPEG can come back out as
something a browser will execute; SVG is refused for the same reason.
Search is SQLite FTS5 over name, description, manufacturer, MPN, spec values,
tags, category and location, with prefix matching so results narrow as you type.
Typing `1.75mm`, `prusament`, `0603` or `Bin A3` all find the right things.
@@ -45,7 +55,7 @@ Then open http://127.0.0.1:8123.
## Tests
```sh
.venv/bin/python -m tests.test_api # 172 checks, in-process
.venv/bin/python -m tests.test_api # 203 checks, in-process
.venv/bin/python -m tests.test_concurrency # 25 checks, against a real uvicorn
```
@@ -57,11 +67,12 @@ and asset versioning, login throttling on both the login and change-password
routes, and the full password-management flow including scrypt hashing, session
invalidation and the recovery CLI.
`tools/render_check.py` drives the real UI in a browser and asserts what a
person would see: that the JavaScript runs under the Content-Security-Policy,
`tools/render_check.py` (43 checks) drives the real UI in a browser and
asserts what a person would see: that the JavaScript runs under the Content-Security-Policy,
that a wrong password says so, that picking "Filament" pre-fills its spec
template and switches the unit to grams, that the password section works, and
that nothing overflows on a 390px phone.
template and switches the unit to grams, that the password section works, that a
photo uploads and comes back as a thumbnail you can open, and that nothing
overflows on a 390px phone.
```sh
.venv/bin/python -m playwright install chromium # once
@@ -94,6 +105,7 @@ the old one working). The stored password is a salted scrypt hash in the
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
| `PARTS_DB` | SQLite path. `/data/parts.db` in the container. |
| `PARTS_IMAGE_DIR` | Where photos are written. Defaults to `images/` beside the database. |
| `PARTS_LOGIN_MAX_FAILURES` | Failed logins allowed per window, default 10. |
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
@@ -146,20 +158,28 @@ repository.
The database is in the `parts_parts_data` docker volume, which survives
rebuilds.
**Back it up with SQLite's backup API, not `cp`.** The database runs in WAL
A backup needs **two** things: the database and the photo files. The database
must be captured with SQLite's backup API rather than `cp` — it runs in WAL
mode, so recently committed rows may still live in `parts.db-wal` and copying
`parts.db` alone can silently lose them. `VACUUM INTO` takes a consistent
snapshot of a live database:
`parts.db` alone can silently lose them. `VACUUM INTO` snapshots a live
database consistently:
```sh
docker exec parts python -c \
"import sqlite3; sqlite3.connect('/data/parts.db').execute(\"VACUUM INTO '/data/backup.db'\")"
docker cp parts:/data/backup.db ./parts-backup-$(date +%F).db
docker exec parts rm /data/backup.db
# the photos, which the database only holds pointers to
docker exec parts tar -cf - -C /data images > ./parts-images-$(date +%F).tar
```
Restore by stopping the container, copying the file back over `/data/parts.db`
and deleting any leftover `-wal`/`-shm` alongside it.
Restore by stopping the container, copying the database back over
`/data/parts.db`, deleting any leftover `-wal`/`-shm` alongside it, and
unpacking the image tar into `/data`. If the two ever drift apart,
`docker exec parts python -m app.admin prune-images` deletes files nothing
points at; rows whose file is missing surface as a broken thumbnail rather than
an error.
## API
@@ -173,6 +193,11 @@ PATCH /api/parts/{id}
DELETE /api/parts/{id}
POST /api/parts/{id}/adjust {delta, reason}
GET /api/parts/{id}/history
GET /api/parts/{id}/images
POST /api/parts/{id}/images multipart: file, caption
GET /api/parts/{id}/images/{token}
PATCH /api/parts/{id}/images/{token} {caption, position}
DELETE /api/parts/{id}/images/{token}
GET /api/categories POST /api/categories PATCH|DELETE /api/categories/{id}
GET /api/locations POST /api/locations PATCH|DELETE /api/locations/{id}
GET /api/tags
+36 -6
View File
@@ -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):
+60
View File
@@ -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
+173 -5
View File
@@ -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]:
+2
View File
@@ -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
+56
View File
@@ -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); }
+171 -4
View File
@@ -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 = `<div><img src="${esc(src)}" alt="${esc(caption || "")}">` +
(caption ? `<div class="cap">${esc(caption)}</div>` : "") + "</div>";
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
? [`<span class="chip">\u{1F4F7} ${part.images.length}</span>`] : []),
...part.specs.filter((s) => s.value).slice(0, 5)
.map((s) => `<span class="chip spec">${esc(s.key)}: ${esc(s.value)}</span>`),
...part.tags.map((t) => `<span class="chip tag">${esc(t)}</span>`),
@@ -191,7 +250,15 @@ function partCard(part) {
<button class="small" data-delta="1" title="Add one">+</button>
</div>`;
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) {
<div id="spec-rows">${p.specs.map((s) => specRowHTML(s.key, s.value)).join("")}</div>
<button type="button" class="small ghost" id="add-spec">+ Add property</button>
</div>
<div class="full">
<label>Photos</label>
<div class="shots" id="shots"></div>
<input type="file" id="f-photos" accept="image/jpeg,image/png,image/webp,image/gif"
multiple capture="environment" style="font-size:13px;padding:6px">
<div id="shot-msg" style="font-size:12px;color:var(--dim);margin-top:5px">
Snap the bag or the markings. Large photos are shrunk before upload.
</div>
</div>
<div class="full">
<label>Tags (comma separated)</label>
<input id="f-tags" value="${esc(p.tags.join(", "))}" placeholder="smd, salvaged, project-x">
@@ -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 = `<img src="${src}" loading="lazy" alt="${esc(img.caption || "Photo")}">
<button type="button" class="small rm" title="Remove">✕</button>
<input class="cap" placeholder="Caption" value="${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 = `<img src="${url}" alt="Queued photo">
<button type="button" class="small rm" title="Remove">✕</button>`;
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();
+114
View File
@@ -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"<html><script>alert(1)</script></html>").status_code == 415)
check("SVG is refused (it can carry script)",
post_image(photo_part, b'<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>',
"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)
+95 -1
View File
@@ -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(