From 494f5e3b5bc9a58d07b07186d4e04d526c3710d1 Mon Sep 17 00:00:00 2001 From: tonym Date: Sat, 6 Jun 2026 14:43:35 -0500 Subject: [PATCH] Fix backups for JSON-file storage + healthcheck; drop handoff folder - backup.sh: snapshot all of /data as timestamped tar.gz to local NAS path; push to B2 only when a real bucket/key is set (no more nonexistent app.db). - compose: working CMD-SHELL healthcheck; mount /mnt/user/Backup target. - remove the accidentally committed moon-household-budget-handoff/ scaffolding. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + backup/backup.sh | 54 ++++--- docker-compose.yml | 11 +- .../backup/Dockerfile | 13 -- .../backup/backup.sh | 34 ---- .../docker/.dockerignore | 15 -- .../docker/.env.example | 24 --- .../docker/Dockerfile | 39 ----- .../docker/docker-compose.yml | 32 ---- .../docker/entrypoint.sh | 19 --- .../gitignore.example | 29 ---- .../suggestions/gitea.py | 116 -------------- .../suggestions/routes_fastapi.py | 121 --------------- .../suggestions/routes_flask.py | 146 ------------------ .../suggestions/static/suggestions.js | 87 ----------- .../suggestions/templates/suggestions.html | 46 ------ 16 files changed, 41 insertions(+), 748 deletions(-) delete mode 100644 moon-household-budget-handoff/backup/Dockerfile delete mode 100644 moon-household-budget-handoff/backup/backup.sh delete mode 100644 moon-household-budget-handoff/docker/.dockerignore delete mode 100644 moon-household-budget-handoff/docker/.env.example delete mode 100644 moon-household-budget-handoff/docker/Dockerfile delete mode 100644 moon-household-budget-handoff/docker/docker-compose.yml delete mode 100644 moon-household-budget-handoff/docker/entrypoint.sh delete mode 100644 moon-household-budget-handoff/gitignore.example delete mode 100644 moon-household-budget-handoff/suggestions/gitea.py delete mode 100644 moon-household-budget-handoff/suggestions/routes_fastapi.py delete mode 100644 moon-household-budget-handoff/suggestions/routes_flask.py delete mode 100644 moon-household-budget-handoff/suggestions/static/suggestions.js delete mode 100644 moon-household-budget-handoff/suggestions/templates/suggestions.html diff --git a/.gitignore b/.gitignore index 8092e66..1f34b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ env/ .idea/ .vscode/ bonnasclaude.md + +# handoff/scaffolding folder — not part of the app +moon-household-budget-handoff/ diff --git a/backup/backup.sh b/backup/backup.sh index 73e6bbd..9e1e1bf 100644 --- a/backup/backup.sh +++ b/backup/backup.sh @@ -1,34 +1,44 @@ #!/bin/sh -# Snapshot the app's data to Backblaze B2 via rclone. -# - SQLite DB: a consistent .backup snapshot (safe while the app is running) -# - uploads: mirrored to B2 -# rclone's B2 remote ("b2:") is configured entirely from RCLONE_CONFIG_B2_* -# env vars (set in .env / compose). Runs once; the container loops it. +# Back up the app's data under /data (the budget JSON files, imported PDFs/CSVs, +# and uploaded images). This app stores everything as plain JSON files — there +# is no SQLite database. +# +# Always writes a timestamped tar.gz to the local NAS path (/backups, mounted +# from the host). ALSO pushes to Backblaze B2 — but only when a real bucket and +# credentials are configured, so it's harmless before B2 is set up. +# +# rclone's B2 remote ("b2:") is configured from the RCLONE_CONFIG_B2_* env vars. +# Runs once; the container loops it on BACKUP_INTERVAL. set -eu STAMP=$(date +%Y%m%d-%H%M%S) -DEST="b2:${B2_BUCKET}/moon-household-budget" KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}" +LOCAL_DIR="/backups" echo "[backup ${STAMP}] starting" -# 1. SQLite snapshot (only if a db file is present) -if [ -f /data/app.db ]; then - sqlite3 /data/app.db ".backup '/tmp/app-${STAMP}.db'" - rclone copyto "/tmp/app-${STAMP}.db" "${DEST}/db/app-${STAMP}.db" - rm -f "/tmp/app-${STAMP}.db" - echo "[backup ${STAMP}] db snapshot uploaded" +# 1. Local timestamped snapshot of everything under /data --------------------- +mkdir -p "${LOCAL_DIR}" +ARCHIVE="${LOCAL_DIR}/moon-data-${STAMP}.tar.gz" +tar -czf "${ARCHIVE}" -C /data . +echo "[backup ${STAMP}] local snapshot: ${ARCHIVE} ($(du -h "${ARCHIVE}" | cut -f1))" + +# prune old local snapshots +find "${LOCAL_DIR}" -name 'moon-data-*.tar.gz' -type f -mtime +"${KEEP_DAYS}" -delete 2>/dev/null || true + +# 2. Optional Backblaze B2 push (only when a real bucket + key are set) -------- +if [ -n "${B2_BUCKET:-}" ] && [ "${B2_BUCKET#__}" = "${B2_BUCKET}" ] \ + && [ -n "${RCLONE_CONFIG_B2_ACCOUNT:-}" ] && [ "${RCLONE_CONFIG_B2_ACCOUNT#__}" = "${RCLONE_CONFIG_B2_ACCOUNT}" ]; then + DEST="b2:${B2_BUCKET}/moon-household-budget" + if rclone copyto "${ARCHIVE}" "${DEST}/snapshots/moon-data-${STAMP}.tar.gz" 2>/tmp/rclone.err; then + rclone sync /data "${DEST}/current" 2>>/tmp/rclone.err || true + rclone delete --min-age "${KEEP_DAYS}d" "${DEST}/snapshots" 2>/dev/null || true + echo "[backup ${STAMP}] pushed to B2 (${B2_BUCKET})" + else + echo "[backup ${STAMP}] B2 push FAILED: $(head -1 /tmp/rclone.err 2>/dev/null)" + fi else - echo "[backup ${STAMP}] no /data/app.db — skipping db snapshot" + echo "[backup ${STAMP}] B2 not configured — local snapshot only" fi -# 2. Uploaded images (and anything else under /data except the live db) -if [ -d /data/uploads ]; then - rclone sync /data/uploads "${DEST}/uploads" - echo "[backup ${STAMP}] uploads synced" -fi - -# 3. Prune old db snapshots -rclone delete --min-age "${KEEP_DAYS}d" "${DEST}/db" 2>/dev/null || true - echo "[backup ${STAMP}] done" diff --git a/docker-compose.yml b/docker-compose.yml index d798430..dd25b32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ # Moon Household Budget — self-contained stack. -# app : the Python web app (SQLite on the ./data volume — no DB container) -# backup : rclone sidecar that snapshots ./data to Backblaze B2 on a loop +# app : the Python web app (JSON-file storage on the ./data volume) +# backup : sidecar that tar.gz-snapshots ./data locally + optionally to B2 # # Deployed on the Unraid NAS under /mnt/user/appdata/moon-household-budget/. # Bring up with: docker compose up -d --build @@ -15,9 +15,9 @@ services: - "8052:8000" # http://192.168.0.5:8052 env_file: .env volumes: - - ./data:/data # SQLite db + uploaded images live here + - ./data:/data # budget JSON files + uploaded images live here healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8000/", "||", "exit", "1"] + test: ["CMD-SHELL", "curl -fsS http://localhost:8000/ || exit 1"] interval: 30s timeout: 5s retries: 3 @@ -29,4 +29,5 @@ services: restart: unless-stopped env_file: .env volumes: - - ./data:/data:ro # read-only — backup never writes app data + - ./data:/data:ro # read-only — never writes app data + - /mnt/user/Backup/moon-household-budget:/backups # local snapshot target diff --git a/moon-household-budget-handoff/backup/Dockerfile b/moon-household-budget-handoff/backup/Dockerfile deleted file mode 100644 index df6850e..0000000 --- a/moon-household-budget-handoff/backup/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -# Tiny rclone + sqlite sidecar that backs up /data to Backblaze B2 on a loop. -FROM alpine:3.20 - -RUN apk add --no-cache rclone sqlite tzdata - -COPY backup.sh /backup.sh -RUN chmod +x /backup.sh - -ENV BACKUP_INTERVAL=86400 - -# Run once on boot, then every BACKUP_INTERVAL seconds. A failed run is logged -# but does not kill the loop. -CMD ["sh", "-c", "while true; do /backup.sh || echo '[backup] FAILED'; sleep \"${BACKUP_INTERVAL}\"; done"] diff --git a/moon-household-budget-handoff/backup/backup.sh b/moon-household-budget-handoff/backup/backup.sh deleted file mode 100644 index 73e6bbd..0000000 --- a/moon-household-budget-handoff/backup/backup.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -# Snapshot the app's data to Backblaze B2 via rclone. -# - SQLite DB: a consistent .backup snapshot (safe while the app is running) -# - uploads: mirrored to B2 -# rclone's B2 remote ("b2:") is configured entirely from RCLONE_CONFIG_B2_* -# env vars (set in .env / compose). Runs once; the container loops it. -set -eu - -STAMP=$(date +%Y%m%d-%H%M%S) -DEST="b2:${B2_BUCKET}/moon-household-budget" -KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}" - -echo "[backup ${STAMP}] starting" - -# 1. SQLite snapshot (only if a db file is present) -if [ -f /data/app.db ]; then - sqlite3 /data/app.db ".backup '/tmp/app-${STAMP}.db'" - rclone copyto "/tmp/app-${STAMP}.db" "${DEST}/db/app-${STAMP}.db" - rm -f "/tmp/app-${STAMP}.db" - echo "[backup ${STAMP}] db snapshot uploaded" -else - echo "[backup ${STAMP}] no /data/app.db — skipping db snapshot" -fi - -# 2. Uploaded images (and anything else under /data except the live db) -if [ -d /data/uploads ]; then - rclone sync /data/uploads "${DEST}/uploads" - echo "[backup ${STAMP}] uploads synced" -fi - -# 3. Prune old db snapshots -rclone delete --min-age "${KEEP_DAYS}d" "${DEST}/db" 2>/dev/null || true - -echo "[backup ${STAMP}] done" diff --git a/moon-household-budget-handoff/docker/.dockerignore b/moon-household-budget-handoff/docker/.dockerignore deleted file mode 100644 index 1bf53bd..0000000 --- a/moon-household-budget-handoff/docker/.dockerignore +++ /dev/null @@ -1,15 +0,0 @@ -.git -.gitignore -__pycache__/ -*.pyc -.venv/ -venv/ -env/ -.env -data/ -*.sqlite -*.sqlite3 -*.db -.DS_Store -node_modules/ -bonnasclaude.md diff --git a/moon-household-budget-handoff/docker/.env.example b/moon-household-budget-handoff/docker/.env.example deleted file mode 100644 index 3f0b2b3..0000000 --- a/moon-household-budget-handoff/docker/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -# Copy to `.env` and fill in. NEVER commit the real .env (it's gitignored). -# Tony will hand over the two B2 keys and confirm the Gitea token. - -# ---- App ---- -PORT=8000 -SECRET_KEY=__GENERATE_A_RANDOM_STRING__ -# SQLite on the mounted volume — keeps the stack self-contained: -DATABASE_URL=sqlite:////data/app.db -# Public URL the app is reached at (used for absolute upload links): -APP_ORIGIN=http://192.168.0.5:8052 - -# ---- Suggestions → Gitea issues ---- -GITEA_URL=http://192.168.0.5:3022 -GITEA_TOKEN=__BONNA_GITEA_TOKEN__ -GITEA_REPO=bonna61/moon-household-budget - -# ---- Backblaze B2 backup (read by the rclone sidecar) ---- -# rclone reads its remote config straight from these env vars: -RCLONE_CONFIG_B2_TYPE=b2 -RCLONE_CONFIG_B2_ACCOUNT=__B2_KEY_ID__ -RCLONE_CONFIG_B2_KEY=__B2_APPLICATION_KEY__ -B2_BUCKET=moon-household-budget-backup -BACKUP_INTERVAL=86400 # seconds between backups (86400 = daily) -BACKUP_KEEP_DAYS=30 # prune DB snapshots older than this diff --git a/moon-household-budget-handoff/docker/Dockerfile b/moon-household-budget-handoff/docker/Dockerfile deleted file mode 100644 index fb4fc00..0000000 --- a/moon-household-budget-handoff/docker/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# Default image for a pip-based Python web app (Flask via gunicorn). -# ADJUST the CMD at the bottom to match the app's entry point. -FROM python:3.12-slim - -WORKDIR /app -ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 - -# sqlite3 CLI is used by the backup snapshot; curl for healthchecks. -RUN apt-get update \ - && apt-get install -y --no-install-recommends sqlite3 curl \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip install -r requirements.txt && pip install gunicorn requests - -COPY . . - -# Persistent data lives on a mounted volume, not in the image. -RUN mkdir -p /data/uploads/suggestions - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 8000 -ENTRYPOINT ["/entrypoint.sh"] - -# ---- ADJUST THIS LINE to the app's real entry point ---- -# Flask (app object named `app` in app.py): -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] -# -# FastAPI (app object named `app` in main.py) — swap the CMD for: -# RUN pip install "uvicorn[standard]" -# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -# -# Django (project package `mysite`): -# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"] -# -# Streamlit: -# CMD ["streamlit", "run", "app.py", "--server.port=8000", "--server.address=0.0.0.0"] diff --git a/moon-household-budget-handoff/docker/docker-compose.yml b/moon-household-budget-handoff/docker/docker-compose.yml deleted file mode 100644 index 2602dbf..0000000 --- a/moon-household-budget-handoff/docker/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Moon Household Budget — self-contained stack. -# app : the Python web app (SQLite on the ./data volume — no DB container) -# backup : rclone sidecar that snapshots ./data to Backblaze B2 on a loop -# -# Deployed on the Unraid NAS under /mnt/user/appdata/moon-household-budget/. -# Bring up with: docker compose up -d --build -services: - app: - build: - context: .. - dockerfile: docker/Dockerfile - container_name: moon-household-budget - restart: unless-stopped - ports: - - "8052:8000" # http://192.168.0.5:8052 - env_file: .env - volumes: - - ./data:/data # SQLite db + uploaded images live here - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8000/", "||", "exit", "1"] - interval: 30s - timeout: 5s - retries: 3 - - backup: - build: - context: ../backup - container_name: moon-budget-backup - restart: unless-stopped - env_file: .env - volumes: - - ./data:/data:ro # read-only — backup never writes app data diff --git a/moon-household-budget-handoff/docker/entrypoint.sh b/moon-household-budget-handoff/docker/entrypoint.sh deleted file mode 100644 index f3ee8ef..0000000 --- a/moon-household-budget-handoff/docker/entrypoint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Runs before the app starts. Ensures the data dir exists and runs DB -# migrations if the app uses them. Uncomment the lines that apply. -set -e - -mkdir -p /data/uploads/suggestions - -# --- Database migrations (pick what the app uses, leave the rest commented) --- -# Flask-Migrate / Alembic via Flask: -# flask db upgrade -# Raw Alembic: -# alembic upgrade head -# Django: -# python manage.py migrate --noinput -# python manage.py collectstatic --noinput -# Plain SQLite with a schema file (only first run): -# [ -f /data/app.db ] || sqlite3 /data/app.db < schema.sql - -exec "$@" diff --git a/moon-household-budget-handoff/gitignore.example b/moon-household-budget-handoff/gitignore.example deleted file mode 100644 index 740c858..0000000 --- a/moon-household-budget-handoff/gitignore.example +++ /dev/null @@ -1,29 +0,0 @@ -# Append these to the project's .gitignore (create one if missing). -# CRITICAL: secrets and the live database must never be committed. - -# Secrets / env -.env -*.env -!.env.example - -# Local database + uploads (live data lives on the NAS volume) -data/ -*.sqlite -*.sqlite3 -*.db -*.db-journal - -# Python -__pycache__/ -*.pyc -.venv/ -venv/ -env/ - -# OS / editor -.DS_Store -.idea/ -.vscode/ - -# This handoff instruction file — don't ship it into the repo -bonnasclaude.md diff --git a/moon-household-budget-handoff/suggestions/gitea.py b/moon-household-budget-handoff/suggestions/gitea.py deleted file mode 100644 index 0c966a7..0000000 --- a/moon-household-budget-handoff/suggestions/gitea.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Gitea-backed suggestions module — framework-agnostic core. - -Files user-submitted suggestions as issues in a Gitea repo, mirroring the -pattern used by Tony's `dms` and `fullmoonbakehouse` apps. No database of its -own — Gitea issues ARE the storage. - -Configure via environment variables: - GITEA_URL e.g. http://192.168.0.5:3022 - GITEA_TOKEN a Gitea access token with repo issue read/write scope - GITEA_REPO "owner/repo", e.g. bonna61/moon-household-budget - -If any are unset, gitea_config() returns None and callers should degrade -gracefully (show "feedback isn't set up yet" instead of crashing). - -Depends only on `requests` (add it to requirements.txt if not already there). -""" -from __future__ import annotations - -import os -import secrets -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Optional - -import requests - -ALLOWED_IMAGE_TYPES = { - "image/jpeg", "image/png", "image/webp", - "image/gif", "image/heic", "image/heif", -} -MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB - - -@dataclass -class GiteaConfig: - base_url: str - token: str - repo: str # "owner/repo" - - -def gitea_config() -> Optional[GiteaConfig]: - base = os.environ.get("GITEA_URL") - token = os.environ.get("GITEA_TOKEN") - repo = os.environ.get("GITEA_REPO") - if not (base and token and repo): - return None - return GiteaConfig(base.rstrip("/"), token, repo) - - -class GiteaError(RuntimeError): - pass - - -class GiteaClient: - def __init__(self, cfg: GiteaConfig): - self.cfg = cfg - - def _url(self, path: str) -> str: - return f"{self.cfg.base_url}/api/v1/repos/{self.cfg.repo}{path}" - - def _req(self, method: str, path: str, **kw): - headers = { - "Authorization": f"token {self.cfg.token}", - "Content-Type": "application/json", - } - r = requests.request(method, self._url(path), headers=headers, timeout=15, **kw) - if not r.ok: - raise GiteaError(f"Gitea {r.status_code}: {r.text[:300]}") - return r.json() - - def list_issues(self, state: str = "open"): - return self._req("GET", f"/issues?type=issues&state={state}&limit=50") - - def create_issue(self, title: str, body: str): - return self._req("POST", "/issues", json={"title": title, "body": body}) - - def list_comments(self, number: int): - return self._req("GET", f"/issues/{number}/comments") - - def create_comment(self, number: int, body: str): - return self._req("POST", f"/issues/{number}/comments", json={"body": body}) - - -def sniff_image(buf: bytes) -> Optional[str]: - """Return a MIME type by inspecting magic bytes, or None if unrecognized.""" - if buf[:3] == b"\xff\xd8\xff": - return "image/jpeg" - if buf[:8] == b"\x89PNG\r\n\x1a\n": - return "image/png" - if buf[:4] == b"GIF8": - return "image/gif" - if buf[:4] == b"RIFF" and buf[8:12] == b"WEBP": - return "image/webp" - if buf[4:8] == b"ftyp": - brand = buf[8:12].decode("ascii", "ignore") - if brand in ("heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs"): - return "image/heic" - if brand in ("mif1", "msf1"): - return "image/heif" - return None - - -def save_upload(filename: str, data: bytes, upload_dir: Path, origin: str) -> str: - """Persist an upload and return an absolute URL for embedding in markdown.""" - upload_dir.mkdir(parents=True, exist_ok=True) - ext = Path(filename).suffix.lower() or ".bin" - safe = f"{int(time.time() * 1000)}-{secrets.token_urlsafe(8)}{ext}" - (upload_dir / safe).write_bytes(data) - return f"{origin.rstrip('/')}/uploads/suggestions/{safe}" - - -def footer(name: str, email: str, kind: str = "user") -> str: - """Attribution footer appended to a filed issue body (dms/fmb style).""" - return f"\n\n---\n_Filed by {name} ({email}) · {kind}_" diff --git a/moon-household-budget-handoff/suggestions/routes_fastapi.py b/moon-household-budget-handoff/suggestions/routes_fastapi.py deleted file mode 100644 index 0b5bc7c..0000000 --- a/moon-household-budget-handoff/suggestions/routes_fastapi.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -FastAPI adapter for the Gitea-backed suggestions module. - -Use this INSTEAD of routes_flask.py if the app is FastAPI. Wire it in with: - - from suggestions.routes_fastapi import router as suggestions_router - app.include_router(suggestions_router) - -AUTH: replace `current_user()` with the app's real dependency. It must return -something exposing `.name` and `.email`, or None. -""" -from __future__ import annotations - -from pathlib import Path - -from fastapi import APIRouter, File, Request, UploadFile -from fastapi.responses import JSONResponse - -from .gitea import ( - ALLOWED_IMAGE_TYPES, - MAX_UPLOAD_BYTES, - GiteaClient, - GiteaError, - footer, - gitea_config, - save_upload, - sniff_image, -) - -router = APIRouter() -UPLOAD_DIR = Path("/data/uploads/suggestions") - - -def current_user(): - """ADAPT THIS to the app's real auth. Return obj with .name/.email or None.""" - return type("U", (), {"name": "Household", "email": "household@local"})() - - -def _client_or_error(): - cfg = gitea_config() - if not cfg: - return None, JSONResponse({"error": "not_configured"}, status_code=503) - return GiteaClient(cfg), None - - -@router.get("/api/suggestions") -def list_suggestions(state: str = "open"): - if current_user() is None: - return JSONResponse({"error": "unauthorized"}, status_code=401) - client, err = _client_or_error() - if err: - return err - try: - return {"issues": client.list_issues(state)} - except GiteaError as e: - return JSONResponse({"error": str(e)}, status_code=502) - - -@router.post("/api/suggestions") -async def create_suggestion(request: Request): - user = current_user() - if user is None: - return JSONResponse({"error": "unauthorized"}, status_code=401) - client, err = _client_or_error() - if err: - return err - data = await request.json() - title = (data.get("title") or "").strip() - if len(title) < 3: - return JSONResponse({"error": "title_required"}, status_code=400) - body = (data.get("body") or "").strip() + footer(user.name, user.email) - try: - return JSONResponse({"issue": client.create_issue(title, body)}, status_code=201) - except GiteaError as e: - return JSONResponse({"error": str(e)}, status_code=502) - - -@router.get("/api/suggestions/{number}/comments") -def list_comments(number: int): - if current_user() is None: - return JSONResponse({"error": "unauthorized"}, status_code=401) - client, err = _client_or_error() - if err: - return err - try: - return {"comments": client.list_comments(number)} - except GiteaError as e: - return JSONResponse({"error": str(e)}, status_code=502) - - -@router.post("/api/suggestions/{number}/comments") -async def add_comment(number: int, request: Request): - user = current_user() - if user is None: - return JSONResponse({"error": "unauthorized"}, status_code=401) - client, err = _client_or_error() - if err: - return err - data = await request.json() - text = (data.get("body") or "").strip() - if not text: - return JSONResponse({"error": "body_required"}, status_code=400) - text += f"\n\n_— {user.name}_" - try: - return JSONResponse({"comment": client.create_comment(number, text)}, status_code=201) - except GiteaError as e: - return JSONResponse({"error": str(e)}, status_code=502) - - -@router.post("/api/suggestions/upload") -async def upload(request: Request, file: UploadFile = File(...)): - if current_user() is None: - return JSONResponse({"error": "unauthorized"}, status_code=401) - data = await file.read() - if len(data) > MAX_UPLOAD_BYTES: - return JSONResponse({"error": "too_large"}, status_code=413) - if sniff_image(data) not in ALLOWED_IMAGE_TYPES: - return JSONResponse({"error": "unsupported_type"}, status_code=415) - origin = str(request.base_url) - url = save_upload(file.filename or "image", data, UPLOAD_DIR, origin) - return JSONResponse({"url": url}, status_code=201) diff --git a/moon-household-budget-handoff/suggestions/routes_flask.py b/moon-household-budget-handoff/suggestions/routes_flask.py deleted file mode 100644 index cacb112..0000000 --- a/moon-household-budget-handoff/suggestions/routes_flask.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -Flask adapter for the Gitea-backed suggestions module. - -Wire it into the app with: - - from suggestions.routes_flask import suggestions_bp - app.register_blueprint(suggestions_bp) - -Then a logged-in user can open /suggestions to file feedback. The blueprint -exposes both the HTML page and the JSON API the page's JS talks to. - -AUTH: replace `current_user()` below with however this app identifies the -logged-in person. It must return an object with `.name` and `.email`, or None. -If the app has no auth at all, you can return a fixed identity (see the stub). -""" -from __future__ import annotations - -from pathlib import Path - -from flask import Blueprint, jsonify, render_template, request - -from .gitea import ( - ALLOWED_IMAGE_TYPES, - MAX_UPLOAD_BYTES, - GiteaClient, - GiteaError, - footer, - gitea_config, - save_upload, - sniff_image, -) - -suggestions_bp = Blueprint( - "suggestions", - __name__, - template_folder="templates", - static_folder="static", - static_url_path="/suggestions-static", -) - -# Where uploaded images land. Lives under the mounted /data volume so backups -# and restarts keep them. Served at /uploads/suggestions/ (see below). -UPLOAD_DIR = Path("/data/uploads/suggestions") - - -def current_user(): - """Resolve the logged-in user. ADAPT THIS to the app's real auth. - - Must return an object exposing `.name` and `.email`, or None if anonymous. - """ - # --- Example: Flask-Login --- - # from flask_login import current_user as u - # return u if getattr(u, "is_authenticated", False) else None - # - # --- Example: session dict --- - # from flask import session - # if "user_email" in session: - # return type("U", (), {"name": session.get("user_name", "User"), - # "email": session["user_email"]})() - # return None - # - # --- Stub: single-household app with no real auth --- - return type("U", (), {"name": "Household", "email": "household@local"})() - - -def _guard(): - return current_user() is not None - - -@suggestions_bp.route("/suggestions") -def suggestions_page(): - return render_template("suggestions.html", configured=gitea_config() is not None) - - -@suggestions_bp.route("/api/suggestions", methods=["GET", "POST"]) -def suggestions_api(): - if not _guard(): - return jsonify({"error": "unauthorized"}), 401 - cfg = gitea_config() - if not cfg: - return jsonify({"error": "not_configured"}), 503 - client = GiteaClient(cfg) - - if request.method == "GET": - state = request.args.get("state", "open") - try: - return jsonify({"issues": client.list_issues(state)}) - except GiteaError as e: - return jsonify({"error": str(e)}), 502 - - data = request.get_json(silent=True) or {} - title = (data.get("title") or "").strip() - if len(title) < 3: - return jsonify({"error": "title_required"}), 400 - body = (data.get("body") or "").strip() - user = current_user() - if user is not None: - body += footer(user.name, user.email) - try: - return jsonify({"issue": client.create_issue(title, body)}), 201 - except GiteaError as e: - return jsonify({"error": str(e)}), 502 - - -@suggestions_bp.route("/api/suggestions//comments", methods=["GET", "POST"]) -def suggestion_comments(number: int): - if not _guard(): - return jsonify({"error": "unauthorized"}), 401 - cfg = gitea_config() - if not cfg: - return jsonify({"error": "not_configured"}), 503 - client = GiteaClient(cfg) - - if request.method == "GET": - try: - return jsonify({"comments": client.list_comments(number)}) - except GiteaError as e: - return jsonify({"error": str(e)}), 502 - - data = request.get_json(silent=True) or {} - text = (data.get("body") or "").strip() - if not text: - return jsonify({"error": "body_required"}), 400 - user = current_user() - if user is not None: - text += f"\n\n_— {user.name}_" - try: - return jsonify({"comment": client.create_comment(number, text)}), 201 - except GiteaError as e: - return jsonify({"error": str(e)}), 502 - - -@suggestions_bp.route("/api/suggestions/upload", methods=["POST"]) -def suggestions_upload(): - if not _guard(): - return jsonify({"error": "unauthorized"}), 401 - if "file" not in request.files: - return jsonify({"error": "file_required"}), 400 - f = request.files["file"] - data = f.read(MAX_UPLOAD_BYTES + 1) - if len(data) > MAX_UPLOAD_BYTES: - return jsonify({"error": "too_large"}), 413 - if sniff_image(data) not in ALLOWED_IMAGE_TYPES: - return jsonify({"error": "unsupported_type"}), 415 - url = save_upload(f.filename or "image", data, UPLOAD_DIR, request.host_url) - return jsonify({"url": url}), 201 diff --git a/moon-household-budget-handoff/suggestions/static/suggestions.js b/moon-household-budget-handoff/suggestions/static/suggestions.js deleted file mode 100644 index e9d6ad3..0000000 --- a/moon-household-budget-handoff/suggestions/static/suggestions.js +++ /dev/null @@ -1,87 +0,0 @@ -// Minimal vanilla-JS client for the suggestions page. Talks to the JSON API -// exposed by routes_flask.py / routes_fastapi.py. No build step, no framework. - -const $ = (id) => document.getElementById(id); - -async function loadIssues() { - const list = $("s-list"); - if (!list) return; - try { - const res = await fetch("/api/suggestions?state=open"); - const data = await res.json(); - const issues = data.issues || []; - if (!issues.length) { - list.innerHTML = '

No open suggestions yet.

'; - return; - } - list.innerHTML = issues.map((i) => ` -
-
#${i.number} ${escapeHtml(i.title)}
-
${i.comments} comment(s) · - view on Gitea
-
`).join(""); - } catch (e) { - list.innerHTML = '

Could not load suggestions.

'; - } -} - -function escapeHtml(s) { - return (s || "").replace(/[&<>"']/g, (c) => ( - { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] - )); -} - -async function uploadImage(file) { - const fd = new FormData(); - fd.append("file", file); - const res = await fetch("/api/suggestions/upload", { method: "POST", body: fd }); - if (!res.ok) throw new Error("upload failed"); - return (await res.json()).url; -} - -function wireComposer() { - const send = $("s-send"); - const body = $("s-body"); - if (!send || !body) return; - - // Paste-to-attach: drop an image embed into the body. - body.addEventListener("paste", async (ev) => { - const item = [...(ev.clipboardData?.items || [])].find((i) => i.type.startsWith("image/")); - if (!item) return; - ev.preventDefault(); - $("s-status").textContent = "Uploading image…"; - try { - const url = await uploadImage(item.getAsFile()); - body.value += `\n\n![pasted image](${url})`; - $("s-status").textContent = "Image attached."; - } catch { - $("s-status").textContent = "Image upload failed."; - } - }); - - send.addEventListener("click", async () => { - const title = $("s-title").value.trim(); - if (title.length < 3) { $("s-status").textContent = "Add a longer title."; return; } - send.disabled = true; - $("s-status").textContent = "Sending…"; - try { - const res = await fetch("/api/suggestions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, body: body.value }), - }); - if (!res.ok) throw new Error(); - $("s-title").value = ""; - body.value = ""; - $("s-status").textContent = "Thanks! Sent."; - loadIssues(); - } catch { - $("s-status").textContent = "Could not send — try again."; - } finally { - send.disabled = false; - } - }); -} - -wireComposer(); -loadIssues(); diff --git a/moon-household-budget-handoff/suggestions/templates/suggestions.html b/moon-household-budget-handoff/suggestions/templates/suggestions.html deleted file mode 100644 index 54f8fd0..0000000 --- a/moon-household-budget-handoff/suggestions/templates/suggestions.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - Suggestions · Moon Household Budget - - - -

💡 Suggestions & Feedback

-

Ideas, bugs, “can it also do…?” — they go straight to Tony.

- - {% if not configured %} -
Feedback isn’t set up yet (Gitea env vars missing). Once configured, this page goes live.
- {% else %} -
- - -
- - -
-
-

Open suggestions

-

Loading…

- {% endif %} - - - -