Docker hardening (non-root, healthcheck, .dockerignore, log caps) + UI table/typography polish
This commit is contained in:
parent
e586bc92e7
commit
bb5d50502c
6 changed files with 84 additions and 8 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
|
||||
15
Dockerfile
15
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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue