From bb5d50502c67334b567be355ccaf19fe22fea84d Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sat, 22 Aug 2026 11:25:52 -0700 Subject: [PATCH] Docker hardening (non-root, healthcheck, .dockerignore, log caps) + UI table/typography polish --- .dockerignore | 12 ++++++++++++ Dockerfile | 15 +++++++++++++++ docker-compose.yml | 10 ++++++++++ static/app.js | 25 ++++++++++++++++++------- static/index.html | 3 +++ static/style.css | 27 ++++++++++++++++++++++++++- 6 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a3ce72a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# The database (and everything else in the mounted volume) must never be +# baked into an image — it's live data, and it made every build context +# larger by the full size of everyone's grading history. +data/ +grades.db +.git/ +nginx/ +README.md +docker-compose.yml +.env* +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile index 6103ac0..296c4b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ FROM python:3.12-slim +# Logs reach `docker logs` the moment they're printed rather than whenever a +# buffer happens to flush — without this, a crash can eat the lines that +# explain it. +ENV PYTHONUNBUFFERED=1 + # Only optional deps (see README) — the app itself is stdlib only. RUN pip install --no-cache-dir anthropic pillow @@ -13,4 +18,14 @@ ENV CARD_GRADER_DB_PATH=/data/grades.db ENV PORT=8778 EXPOSE 8778 +# nobody:users — Unraid's appdata convention. Running as root inside the +# container was needless: the app needs exactly one writable path (/data), +# so give it only that. The host ./data dir must be owned 99:100 to match. +USER 99:100 + +# python (not curl — slim image doesn't ship it) probing the app's own root. +# BASE_PATH doesn't affect this: un-prefixed paths still route normally. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8778/', timeout=4).status == 200 else 1)"] + CMD ["python3", "app.py"] diff --git a/docker-compose.yml b/docker-compose.yml index a1f827f..e441e2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,16 @@ services: build: . container_name: card-grader restart: unless-stopped + # Proper PID-1 signal handling — a clean, immediate stop on + # `docker compose down` instead of the 10s SIGKILL timeout. + init: true + # Without a cap, an always-on container's json log grows unbounded on + # the array — this is a server that never reboots, so it would. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" # Bound to all interfaces (not 127.0.0.1) because Nginx Proxy Manager # runs in its own container on a separate macvlan IP (192.168.86.2), # not in this host's network namespace — it has to reach this over the diff --git a/static/app.js b/static/app.js index 08124f1..e74212e 100644 --- a/static/app.js +++ b/static/app.js @@ -333,9 +333,18 @@ function gradePillClass(grade) { return 'pill-critical'; } +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + function fmtWhen(iso) { if (!iso) return ''; - return String(iso).replace('T', ' ').slice(0, 16); + const s = String(iso); + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/); + if (!m) return s.replace('T', ' ').slice(0, 16); + const nowYear = String(new Date().getFullYear()); + const day = `${MONTHS[Number(m[2]) - 1]} ${Number(m[3])}`; + // Only spend width on the year when it isn't this year's. + return `${m[1] === nowYear ? day : `${day} ${m[1]}`} · ${m[4]}`; } async function loadHistory() { @@ -361,12 +370,14 @@ function renderHistory() { ${ g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`} - ${esc(g.confidence || '')} - ${esc(fmtWhen(g.created_at))} - - - + ${esc(g.confidence || '')} + ${esc(fmtWhen(g.created_at))} + +
+ + +
`).join(''); } diff --git a/static/index.html b/static/index.html index 8692748..17b9f93 100644 --- a/static/index.html +++ b/static/index.html @@ -15,6 +15,9 @@ + + + diff --git a/static/style.css b/static/style.css index cec8c7f..7e34f1f 100644 --- a/static/style.css +++ b/static/style.css @@ -51,7 +51,7 @@ body { margin: 0; color: var(--ink); - font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font: 16px/1.55 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; @@ -263,6 +263,22 @@ textarea { resize: vertical; min-height: 64px; } .cardcell-meta { font-size: 12.5px; color: var(--muted); } .dash { color: var(--muted); } +/* Table refinements the density audit turned up: dates were wrapping onto + two lines, action buttons were stacking vertically in a cramped cell, and + confidence read as one more grey word. */ +.cell-when { white-space: nowrap; font-variant-numeric: tabular-nums; } +.cell-conf { + font-size: 11px; font-weight: 700; letter-spacing: .08em; + text-transform: uppercase; +} +.cell-conf.conf-high { color: var(--good-text); } +.cell-conf.conf-medium { color: var(--warning); } +.cell-conf.conf-low { color: var(--muted); } +.row-actions { + display: flex; gap: 7px; justify-content: flex-end; + white-space: nowrap; +} + /* --------------------------------------------------------------- pills */ .pill { @@ -471,3 +487,12 @@ textarea { resize: vertical; min-height: 64px; } .slab-grade { width: 108px; padding: 16px 8px; } .slab-grade .n { font-size: 44px; } } + +/* On a narrow phone the five-column history table forces sideways + scrolling. Confidence is the most expendable column there — the grade + pill and the card name are what someone is actually scanning for, and + confidence is still shown in the card's detail view. */ +@media (max-width: 560px) { + #history-table th:nth-child(3), + #history-table td:nth-child(3) { display: none; } +}