diff --git a/app.py b/app.py index 50a23a4..5f8535d 100644 --- a/app.py +++ b/app.py @@ -26,7 +26,16 @@ from income import (load_income_log, save_income_log, income_by_month, month_key as income_month_key) app = Flask(__name__) -app.secret_key = "fullmoonbakehouse" +app.secret_key = os.environ.get("SECRET_KEY", "fullmoonbakehouse") + +from suggestions.routes_flask import suggestions_bp +app.register_blueprint(suggestions_bp) + +from flask import send_from_directory + +@app.route("/uploads/") +def _uploads(p): + return send_from_directory("/data/uploads", p) import re as _re from markupsafe import Markup diff --git a/suggestions/gitea.py b/suggestions/gitea.py new file mode 100644 index 0000000..0c966a7 --- /dev/null +++ b/suggestions/gitea.py @@ -0,0 +1,116 @@ +""" +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/suggestions/routes_fastapi.py b/suggestions/routes_fastapi.py new file mode 100644 index 0000000..0b5bc7c --- /dev/null +++ b/suggestions/routes_fastapi.py @@ -0,0 +1,121 @@ +""" +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/suggestions/routes_flask.py b/suggestions/routes_flask.py new file mode 100644 index 0000000..cacb112 --- /dev/null +++ b/suggestions/routes_flask.py @@ -0,0 +1,146 @@ +""" +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/suggestions/static/suggestions.js b/suggestions/static/suggestions.js new file mode 100644 index 0000000..e9d6ad3 --- /dev/null +++ b/suggestions/static/suggestions.js @@ -0,0 +1,87 @@ +// 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/suggestions/templates/suggestions.html b/suggestions/templates/suggestions.html new file mode 100644 index 0000000..54f8fd0 --- /dev/null +++ b/suggestions/templates/suggestions.html @@ -0,0 +1,46 @@ + + + + + + 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 %} + + + +