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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 14:43:35 -05:00
parent 8ea4c978dd
commit 494f5e3b5b
16 changed files with 41 additions and 748 deletions

3
.gitignore vendored
View File

@@ -15,3 +15,6 @@ env/
.idea/ .idea/
.vscode/ .vscode/
bonnasclaude.md bonnasclaude.md
# handoff/scaffolding folder — not part of the app
moon-household-budget-handoff/

View File

@@ -1,34 +1,44 @@
#!/bin/sh #!/bin/sh
# Snapshot the app's data to Backblaze B2 via rclone. # Back up the app's data under /data (the budget JSON files, imported PDFs/CSVs,
# - SQLite DB: a consistent .backup snapshot (safe while the app is running) # and uploaded images). This app stores everything as plain JSON files — there
# - uploads: mirrored to B2 # is no SQLite database.
# rclone's B2 remote ("b2:") is configured entirely from RCLONE_CONFIG_B2_* #
# env vars (set in .env / compose). Runs once; the container loops it. # 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 set -eu
STAMP=$(date +%Y%m%d-%H%M%S) STAMP=$(date +%Y%m%d-%H%M%S)
DEST="b2:${B2_BUCKET}/moon-household-budget"
KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}" KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}"
LOCAL_DIR="/backups"
echo "[backup ${STAMP}] starting" echo "[backup ${STAMP}] starting"
# 1. SQLite snapshot (only if a db file is present) # 1. Local timestamped snapshot of everything under /data ---------------------
if [ -f /data/app.db ]; then mkdir -p "${LOCAL_DIR}"
sqlite3 /data/app.db ".backup '/tmp/app-${STAMP}.db'" ARCHIVE="${LOCAL_DIR}/moon-data-${STAMP}.tar.gz"
rclone copyto "/tmp/app-${STAMP}.db" "${DEST}/db/app-${STAMP}.db" tar -czf "${ARCHIVE}" -C /data .
rm -f "/tmp/app-${STAMP}.db" echo "[backup ${STAMP}] local snapshot: ${ARCHIVE} ($(du -h "${ARCHIVE}" | cut -f1))"
echo "[backup ${STAMP}] db snapshot uploaded"
# 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 else
echo "[backup ${STAMP}] no /data/app.db — skipping db snapshot" echo "[backup ${STAMP}] B2 not configured — local snapshot only"
fi 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" echo "[backup ${STAMP}] done"

View File

@@ -1,6 +1,6 @@
# Moon Household Budget — self-contained stack. # Moon Household Budget — self-contained stack.
# app : the Python web app (SQLite on the ./data volume — no DB container) # app : the Python web app (JSON-file storage on the ./data volume)
# backup : rclone sidecar that snapshots ./data to Backblaze B2 on a loop # backup : sidecar that tar.gz-snapshots ./data locally + optionally to B2
# #
# Deployed on the Unraid NAS under /mnt/user/appdata/moon-household-budget/. # Deployed on the Unraid NAS under /mnt/user/appdata/moon-household-budget/.
# Bring up with: docker compose up -d --build # Bring up with: docker compose up -d --build
@@ -15,9 +15,9 @@ services:
- "8052:8000" # http://192.168.0.5:8052 - "8052:8000" # http://192.168.0.5:8052
env_file: .env env_file: .env
volumes: volumes:
- ./data:/data # SQLite db + uploaded images live here - ./data:/data # budget JSON files + uploaded images live here
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/", "||", "exit", "1"] test: ["CMD-SHELL", "curl -fsS http://localhost:8000/ || exit 1"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -29,4 +29,5 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
volumes: 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

View File

@@ -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"]

View File

@@ -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"

View File

@@ -1,15 +0,0 @@
.git
.gitignore
__pycache__/
*.pyc
.venv/
venv/
env/
.env
data/
*.sqlite
*.sqlite3
*.db
.DS_Store
node_modules/
bonnasclaude.md

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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 "$@"

View File

@@ -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

View File

@@ -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}_"

View File

@@ -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)

View File

@@ -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/<name> (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/<int:number>/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

View File

@@ -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 = '<p class="muted">No open suggestions yet.</p>';
return;
}
list.innerHTML = issues.map((i) => `
<div class="card">
<div class="issue-title">#${i.number} ${escapeHtml(i.title)}</div>
<div class="issue-meta">${i.comments} comment(s) ·
<a href="${i.html_url}" target="_blank" rel="noopener">view on Gitea</a></div>
</div>`).join("");
} catch (e) {
list.innerHTML = '<p class="muted">Could not load suggestions.</p>';
}
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();

View File

@@ -1,46 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Suggestions · Moon Household Budget</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1c1c1e; }
h1 { font-size: 1.4rem; }
.card { border: 1px solid #e3e3e6; border-radius: 12px; padding: 1rem; margin: .75rem 0; }
input, textarea { width: 100%; box-sizing: border-box; padding: .6rem; border-radius: 8px;
border: 1px solid #cfcfd4; font: inherit; margin-bottom: .5rem; }
textarea { min-height: 100px; resize: vertical; }
button { background: #4338ca; color: #fff; border: 0; border-radius: 8px;
padding: .6rem 1rem; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
.muted { color: #6b6b70; font-size: .85rem; }
.issue-title { font-weight: 600; }
.issue-meta { color: #6b6b70; font-size: .8rem; }
.notice { background: #fff8e1; border: 1px solid #f0e3a8; padding: .75rem; border-radius: 8px; }
a { color: #4338ca; }
</style>
</head>
<body>
<h1>💡 Suggestions &amp; Feedback</h1>
<p class="muted">Ideas, bugs, “can it also do…?” — they go straight to Tony.</p>
{% if not configured %}
<div class="notice">Feedback isnt set up yet (Gitea env vars missing). Once configured, this page goes live.</div>
{% else %}
<div class="card">
<input id="s-title" placeholder="Short title (required)" />
<textarea id="s-body" placeholder="Details… paste a screenshot to attach it."></textarea>
<div style="display:flex; gap:.5rem; align-items:center;">
<button id="s-send">Send suggestion</button>
<span id="s-status" class="muted"></span>
</div>
</div>
<h2 style="font-size:1.1rem;">Open suggestions</h2>
<div id="s-list"><p class="muted">Loading…</p></div>
{% endif %}
<script src="{{ url_for('suggestions.static', filename='suggestions.js') }}"></script>
</body>
</html>