Docker hardening (non-root, healthcheck, .dockerignore, log caps) + UI table/typography polish

This commit is contained in:
Barely Removable 2026-08-22 11:25:52 -07:00
parent e586bc92e7
commit bb5d50502c
6 changed files with 84 additions and 8 deletions

12
.dockerignore Normal file
View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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() {
</td>
<td><span class="pill ${gradePillClass(g.estimated_grade)}">${
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
<td class="cardcell-meta">${esc(g.confidence || '')}</td>
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
<td class="num">
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
<td><span class="cell-conf conf-${esc(g.confidence || 'low')}">${esc(g.confidence || '')}</span></td>
<td class="cardcell-meta cell-when">${esc(fmtWhen(g.created_at))}</td>
<td>
<div class="row-actions">
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
</div>
</td>
</tr>`).join('');
}

View file

@ -15,6 +15,9 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Card Grader">
<link rel="apple-touch-icon" href="__BASE__/static/icon-180.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap">
<link rel="stylesheet" href="__BASE__/static/style.css">
</head>
<body>

View file

@ -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; }
}