96d2a1a087
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>
96 lines
3.2 KiB
Python
96 lines
3.2 KiB
Python
"""Recovery CLI.
|
|
|
|
Everyday password changes happen in the UI. This exists for the one case the UI
|
|
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
|
|
import sys
|
|
|
|
from . import auth, db
|
|
|
|
|
|
def set_password(argv):
|
|
password = argv[0] if argv else None
|
|
if password is None:
|
|
password = getpass.getpass("New password: ")
|
|
if password != getpass.getpass("Repeat: "):
|
|
print("Passwords did not match.", file=sys.stderr)
|
|
return 1
|
|
problem = auth.password_problem(password)
|
|
if problem:
|
|
print(problem, file=sys.stderr)
|
|
return 1
|
|
with db.session() as conn:
|
|
auth.set_password(conn, password)
|
|
print("Password set. All existing sessions have been signed out.")
|
|
return 0
|
|
|
|
|
|
def clear_password(_argv):
|
|
"""Fall back to the PARTS_PASSWORD environment variable again."""
|
|
with db.session() as conn:
|
|
conn.execute("DELETE FROM settings WHERE key = ?", (auth.PASSWORD_KEY,))
|
|
auth.bump_epoch(conn)
|
|
print("Stored password cleared; PARTS_PASSWORD from the environment is live again.")
|
|
print("All existing sessions have been signed out.")
|
|
return 0
|
|
|
|
|
|
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:
|
|
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
|
|
|
|
|
|
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):
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
if not argv or argv[0] not in COMMANDS:
|
|
print("usage: python -m app.admin {" + "|".join(COMMANDS) + "}", file=sys.stderr)
|
|
return 2
|
|
db.init()
|
|
return COMMANDS[argv[0]](argv[1:])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|