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,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)" || exit 1
CMD ["gunicorn", "-w", "2", "-t", "60", "-b", "0.0.0.0:8000", "app:app"]
@@ -0,0 +1,139 @@
"""
Rongorongo state-sync backend.
Single-user. Stores state as one JSON file and images as content-addressed
files on disk. No DB, no auth — auth is enforced by Traefik basicAuth in
front of the whole app.
"""
import hashlib
import json
import mimetypes
import os
import tempfile
from pathlib import Path
from flask import Flask, abort, jsonify, request, send_file
DATA_DIR = Path(os.environ.get("RONGORONGO_DATA_DIR", "/data"))
STATE_FILE = DATA_DIR / "state.json"
IMAGES_DIR = DATA_DIR / "images"
MAX_IMAGE_BYTES = 25 * 1024 * 1024 # 25 MB per image
DATA_DIR.mkdir(parents=True, exist_ok=True)
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = MAX_IMAGE_BYTES + 1024
def _atomic_write(path: Path, data: bytes) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".part")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
raise
def _ext_for(mime: str) -> str:
if not mime:
return ".bin"
guess = mimetypes.guess_extension(mime) or ".bin"
# mimetypes returns .jpe for image/jpeg on some systems
return ".jpg" if guess == ".jpe" else guess
@app.get("/health")
def health():
return ("ok\n", 200, {"Content-Type": "text/plain"})
@app.get("/api/health")
def api_health():
return ("ok\n", 200, {"Content-Type": "text/plain"})
@app.get("/api/state")
def get_state():
if not STATE_FILE.exists():
return jsonify(
{
"images": [],
"glyphs": [],
"anchors": [],
"analyses": [],
"feasibilityRuns": [],
"confidenceHistory": {},
}
)
return send_file(STATE_FILE, mimetype="application/json")
@app.put("/api/state")
def put_state():
if not request.is_json:
abort(415, description="expected application/json")
payload = request.get_json(silent=False)
if not isinstance(payload, dict):
abort(400, description="state must be a JSON object")
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
_atomic_write(STATE_FILE, body)
return jsonify({"ok": True, "bytes": len(body)})
def _find_image(image_hash: str) -> Path | None:
for p in IMAGES_DIR.glob(f"{image_hash}.*"):
return p
return None
@app.post("/api/images")
def upload_image():
f = request.files.get("file")
if f is None or f.filename == "":
abort(400, description="missing file")
data = f.read()
if len(data) == 0:
abort(400, description="empty file")
if len(data) > MAX_IMAGE_BYTES:
abort(413, description="image too large")
digest = hashlib.sha256(data).hexdigest()
mime = f.mimetype or "application/octet-stream"
existing = _find_image(digest)
if existing is None:
target = IMAGES_DIR / f"{digest}{_ext_for(mime)}"
_atomic_write(target, data)
return jsonify(
{"hash": digest, "name": f.filename, "mimeType": mime, "size": len(data)}
)
@app.get("/api/images/<image_hash>")
def get_image(image_hash: str):
if not all(c in "0123456789abcdef" for c in image_hash) or len(image_hash) != 64:
abort(400, description="invalid hash")
p = _find_image(image_hash)
if p is None:
abort(404)
mime, _ = mimetypes.guess_type(str(p))
return send_file(p, mimetype=mime or "application/octet-stream")
@app.delete("/api/images/<image_hash>")
def delete_image(image_hash: str):
if not all(c in "0123456789abcdef" for c in image_hash) or len(image_hash) != 64:
abort(400, description="invalid hash")
p = _find_image(image_hash)
if p is None:
return ("", 204)
p.unlink()
return ("", 204)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
@@ -0,0 +1,2 @@
flask==3.0.3
gunicorn==23.0.0
@@ -1,17 +1,19 @@
services:
rongorongo:
rongorongo-api:
build:
context: .
context: ./backend
dockerfile: Dockerfile
image: rongorongo:latest
container_name: rongorongo
image: rongorongo-api:latest
container_name: rongorongo-api
restart: unless-stopped
ports:
- "8181:80"
mem_limit: 128M
memswap_limit: 128M
networks:
- rongorongo
volumes:
- /mnt/docker-data/configs/rongorongo/data:/data
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
@@ -23,3 +25,36 @@ services:
options:
max-size: "10m"
max-file: "3"
rongorongo-web:
build:
context: ./frontend
dockerfile: Dockerfile
image: rongorongo-web:latest
container_name: rongorongo-web
restart: unless-stopped
depends_on:
- rongorongo-api
networks:
- rongorongo
ports:
- "8181:80"
mem_limit: 128M
memswap_limit: 128M
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=false"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
rongorongo:
driver: bridge
@@ -2,11 +2,12 @@ FROM nginx:1.25-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY frontend/index.html /usr/share/nginx/html/index.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://localhost/health || exit 1
CMD wget -qO- http://127.0.0.1/health || exit 1
EXPOSE 80
@@ -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');
}
@@ -5,6 +5,8 @@ server {
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;
@@ -14,7 +16,7 @@ server {
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;
gzip_types text/html text/css application/javascript application/json;
gzip_min_length 1024;
location /health {
@@ -23,6 +25,19 @@ server {
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;