""" 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/") 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/") 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)