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