infra: rongorongo state-sync backend + Traefik basicAuth

Add a Flask backend service for cross-device state persistence, storing
JSON state on an NFS volume at /mnt/docker-data/configs/rongorongo/data.
Frontend Dockerfile/nginx.conf reorganised into frontend/ subdirectory;
frontend API calls now route to /api/* handled by the backend container
over a dedicated bridge network. Traefik basicAuth middleware added to
the public URL with the bcrypt hash deliberately kept out of git
(REPLACE_AT_DEPLOY placeholder in rongorongo.yml). Prometheus probe moved
from https-external to internal http://192.168.86.192:8181/health to
bypass the new auth wall.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:51:47 -05:00
parent 795908e1ef
commit a908973264
10 changed files with 354 additions and 34 deletions
@@ -0,0 +1,14 @@
FROM nginx:1.25-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN chmod 644 /usr/share/nginx/html/index.html
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/health || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
@@ -727,6 +727,7 @@ select option { background: var(--surface); }
<div class="sidebar-footer">
<label class="api-key-label">Anthropic API Key</label>
<input class="api-key-input" type="password" id="apiKeyInput" placeholder="sk-ant-…" oninput="saveApiKey(this.value)">
<div id="syncStatus" style="margin-top:0.5rem;font-size:0.75rem;color:#888;">● synced</div>
</div>
</nav>
@@ -1167,32 +1168,107 @@ const S = {
confidenceHistory: {}, // anchorId -> [{ts, score}]
};
// ── PERSISTENCE ────────────────────────────
// ── PERSISTENCE (server-synced via /api/state) ────────────────────────
let _saveTimer = null;
let _saveSuppressed = false; // true while load() is rehydrating
let _loaded = false; // true after first successful load
function save() {
if (_saveSuppressed || !_loaded) return;
clearTimeout(_saveTimer);
_saveTimer = setTimeout(_flushSave, 400);
}
async function _flushSave() {
try {
const snap = {
images: S.images.map(i => ({ name: i.name, mimeType: i.mimeType, hash: i.hash })),
glyphs: S.glyphs,
anchors: S.anchors,
analyses: S.analyses.slice(0,10),
feasibilityRuns: S.feasibilityRuns.slice(0,5),
confidenceHistory: S.confidenceHistory,
};
localStorage.setItem('rongo_state', JSON.stringify(snap));
} catch(e) {}
const r = await fetch('/api/state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(snap),
});
if (!r.ok) throw new Error('save failed: HTTP ' + r.status);
setSyncStatus('synced');
} catch (e) {
setSyncStatus('error', e.message);
}
}
function load() {
function setSyncStatus(state, detail) {
const el = document.getElementById('syncStatus');
if (!el) return;
if (state === 'syncing') { el.textContent = '⟳ syncing…'; el.style.color = '#888'; }
else if (state === 'synced') { el.textContent = '● synced'; el.style.color = '#4caf50'; }
else if (state === 'error') { el.textContent = '✕ sync error'; el.style.color = '#e57373'; el.title = detail||''; }
else if (state === 'loading') { el.textContent = '⟳ loading…'; el.style.color = '#888'; }
}
async function _hydrateImage(meta) {
// Fetch image bytes by hash and rebuild {dataUrl, base64} in memory.
const r = await fetch('/api/images/' + encodeURIComponent(meta.hash));
if (!r.ok) throw new Error('image fetch failed: ' + meta.hash + ' HTTP ' + r.status);
const blob = await r.blob();
const dataUrl = await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result);
fr.onerror = () => rej(fr.error);
fr.readAsDataURL(blob);
});
return {
name: meta.name,
mimeType: meta.mimeType || blob.type || 'image/jpeg',
hash: meta.hash,
dataUrl,
base64: dataUrl.split(',')[1],
};
}
async function load() {
setSyncStatus('loading');
_saveSuppressed = true;
let imgMetas = [];
try {
const raw = localStorage.getItem('rongo_state');
if (!raw) return;
const snap = JSON.parse(raw);
const r = await fetch('/api/state');
if (!r.ok) throw new Error('GET /api/state HTTP ' + r.status);
const snap = await r.json();
S.glyphs = snap.glyphs || [];
S.anchors = snap.anchors || [];
S.analyses = snap.analyses || [];
S.feasibilityRuns = snap.feasibilityRuns || [];
S.confidenceHistory = snap.confidenceHistory || {};
imgMetas = snap.images || [];
S.images = imgMetas.map(m => ({
name: m.name, mimeType: m.mimeType, hash: m.hash,
dataUrl: '', base64: '',
}));
renderImageStrip();
refreshAll();
} catch(e) {}
setSyncStatus('synced');
} catch (e) {
setSyncStatus('error', e.message);
console.error('load failed', e);
} finally {
_saveSuppressed = false;
_loaded = true;
}
// Hydrate image bytes in parallel; render thumbs as each arrives.
// Don't block save() on this — save snapshots only metadata anyway.
await Promise.all(imgMetas.map(async (meta, idx) => {
try {
const full = await _hydrateImage(meta);
Object.assign(S.images[idx], full);
renderImageStrip();
} catch (e) {
console.error('image hydrate failed', meta, e);
}
}));
}
function saveApiKey(v) {
@@ -1290,17 +1366,35 @@ uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('over
uploadZone.addEventListener('drop', e => { e.preventDefault(); uploadZone.classList.remove('over'); handleFiles([...e.dataTransfer.files].filter(f=>f.type.startsWith('image/'))); });
fileInput.addEventListener('change', e => handleFiles([...e.target.files]));
function handleFiles(files) {
files.forEach(f => {
const fr = new FileReader();
fr.onload = e => {
S.images.push({ name: f.name, dataUrl: e.target.result, base64: e.target.result.split(',')[1], mimeType: f.type||'image/jpeg' });
async function handleFiles(files) {
for (const f of files) {
try {
const dataUrl = await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = e => res(e.target.result);
fr.onerror = () => rej(fr.error);
fr.readAsDataURL(f);
});
const fd = new FormData();
fd.append('file', f, f.name);
const r = await fetch('/api/images', { method: 'POST', body: fd });
if (!r.ok) throw new Error('upload failed: HTTP ' + r.status);
const meta = await r.json();
S.images.push({
name: f.name,
mimeType: f.type || meta.mimeType || 'image/jpeg',
hash: meta.hash,
dataUrl,
base64: dataUrl.split(',')[1],
});
renderImageStrip();
refreshAll();
toast(`Loaded ${f.name}`);
};
fr.readAsDataURL(f);
});
} catch (e) {
console.error('upload failed', f.name, e);
toast(`Upload failed: ${f.name}`);
}
}
}
function renderImageStrip() {
@@ -1312,10 +1406,17 @@ function renderImageStrip() {
</div>`).join('');
}
function removeImage(i) {
async function removeImage(i) {
const img = S.images[i];
if (!img) return;
S.images.splice(i,1);
renderImageStrip();
refreshAll();
if (img.hash) {
// best-effort backend delete; safe to ignore failures (state is already saved)
try { await fetch('/api/images/' + encodeURIComponent(img.hash), { method: 'DELETE' }); }
catch(e) { console.warn('image delete failed on server', img.hash, e); }
}
}
function refreshAll() {
@@ -2032,12 +2133,17 @@ function downloadReport() {
toast('Session exported');
}
function clearSession() {
async function clearSession() {
if (!confirm('Clear all session data? This cannot be undone.')) return;
const oldHashes = S.images.map(i => i.hash).filter(Boolean);
S.images=[]; S.glyphs=[]; S.anchors=[]; S.analyses=[]; S.feasibilityRuns=[]; S.confidenceHistory={};
try { localStorage.removeItem('rongo_state'); } catch(e){}
refreshAll();
renderImageStrip();
// Force-flush an empty state immediately, then drop image blobs server-side.
await _flushSave();
await Promise.all(oldHashes.map(h =>
fetch('/api/images/' + encodeURIComponent(h), { method: 'DELETE' }).catch(()=>{})
));
toast('Session cleared');
}
@@ -0,0 +1,51 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 30m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CSP — allows Anthropic API calls and Google Fonts
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://api.anthropic.com; worker-src blob:;" always;
gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_min_length 1024;
location /health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
location /api/ {
resolver 127.0.0.11 valid=10s;
set $backend "http://rongorongo-api:8000";
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_read_timeout 60s;
}
location / {
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}