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:
+36
-6
@@ -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):
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user