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:
@@ -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
|
||||
Reference in New Issue
Block a user