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