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
+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();