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
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue