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 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. # Only optional deps (see README) — the app itself is stdlib only.
RUN pip install --no-cache-dir anthropic pillow RUN pip install --no-cache-dir anthropic pillow
@ -13,4 +18,14 @@ ENV CARD_GRADER_DB_PATH=/data/grades.db
ENV PORT=8778 ENV PORT=8778
EXPOSE 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"] CMD ["python3", "app.py"]

View file

@ -3,6 +3,16 @@ services:
build: . build: .
container_name: card-grader container_name: card-grader
restart: unless-stopped 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 # 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), # 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 # 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'; return 'pill-critical';
} }
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function fmtWhen(iso) { function fmtWhen(iso) {
if (!iso) return ''; 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() { async function loadHistory() {
@ -361,12 +370,14 @@ function renderHistory() {
</td> </td>
<td><span class="pill ${gradePillClass(g.estimated_grade)}">${ <td><span class="pill ${gradePillClass(g.estimated_grade)}">${
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td> g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
<td class="cardcell-meta">${esc(g.confidence || '')}</td> <td><span class="cell-conf conf-${esc(g.confidence || 'low')}">${esc(g.confidence || '')}</span></td>
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td> <td class="cardcell-meta cell-when">${esc(fmtWhen(g.created_at))}</td>
<td class="num"> <td>
<div class="row-actions">
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}" <button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button> 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> <button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
</div>
</td> </td>
</tr>`).join(''); </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-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Card Grader"> <meta name="apple-mobile-web-app-title" content="Card Grader">
<link rel="apple-touch-icon" href="__BASE__/static/icon-180.png"> <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"> <link rel="stylesheet" href="__BASE__/static/style.css">
</head> </head>
<body> <body>

View file

@ -51,7 +51,7 @@
body { body {
margin: 0; margin: 0;
color: var(--ink); 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; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -263,6 +263,22 @@ textarea { resize: vertical; min-height: 64px; }
.cardcell-meta { font-size: 12.5px; color: var(--muted); } .cardcell-meta { font-size: 12.5px; color: var(--muted); }
.dash { 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 */ /* --------------------------------------------------------------- pills */
.pill { .pill {
@ -471,3 +487,12 @@ textarea { resize: vertical; min-height: 64px; }
.slab-grade { width: 108px; padding: 16px 8px; } .slab-grade { width: 108px; padding: 16px 8px; }
.slab-grade .n { font-size: 44px; } .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; }
}