diff --git a/README.md b/README.md index de25681..0915dc7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/admin.py b/app/admin.py index 41f2ec8..6bed046 100644 --- a/app/admin.py +++ b/app/admin.py @@ -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): diff --git a/app/db.py b/app/db.py index 81e3bb4..e6344d2 100644 --- a/app/db.py +++ b/app/db.py @@ -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 diff --git a/app/main.py b/app/main.py index 5ead433..30c56f6 100644 --- a/app/main.py +++ b/app/main.py @@ -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]: diff --git a/requirements.txt b/requirements.txt index 3414f51..0629954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/static/app.css b/static/app.css index 3b26fd2..fe54884 100644 --- a/static/app.css +++ b/static/app.css @@ -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); } diff --git a/static/app.js b/static/app.js index b4655ab..6af8df3 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = `