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