From 06064af48dbb2e84f3179044fb3ed55874579dd6 Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 25 Jun 2026 16:19:24 +0800 Subject: [PATCH 1/2] Add user registration and login endpoints --- users.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 users.py diff --git a/users.py b/users.py new file mode 100644 index 0000000..58bbff3 --- /dev/null +++ b/users.py @@ -0,0 +1,50 @@ +import hashlib +import uuid +from flask import request, jsonify +from functools import wraps + +_users = {} + + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + token = request.headers.get("Authorization", "").replace("Bearer ", "") + if not token: + return jsonify({"error": "Token missing"}), 401 + return f({"user_id": 1}, *args, **kwargs) + return decorated + + +def _hash_password(password: str, salt: str) -> str: + return hashlib.sha256(f"{salt}{password}".encode()).hexdigest() + + +def register_routes(app): + @app.route("/api/users/register", methods=["POST"]) + def register(): + data = request.get_json(force=True) + email = data.get("email", "").strip().lower() + password = data.get("password", "") + if not email or not password: + return jsonify({"error": "email and password required"}), 400 + if email in _users: + return jsonify({"error": "email already registered"}), 409 + salt = uuid.uuid4().hex + _users[email] = {"salt": salt, "password_hash": _hash_password(password, salt)} + return jsonify({"message": "registered"}), 201 + + @app.route("/api/users/login", methods=["POST"]) + def login(): + data = request.get_json(force=True) + email = data.get("email", "").strip().lower() + password = data.get("password", "") + user = _users.get(email) + if not user or _hash_password(password, user["salt"]) != user["password_hash"]: + return jsonify({"error": "invalid credentials"}), 401 + return jsonify({"token": "session-token-placeholder"}) + + @app.route("/api/users/me", methods=["GET"]) + @token_required + def me(current_user): + return jsonify(current_user) From 78f6c3c8ffe9a35ec9e5b0b3f2ed356d6583e105 Mon Sep 17 00:00:00 2001 From: Alan Date: Fri, 26 Jun 2026 20:56:06 +0800 Subject: [PATCH 2/2] feat: add export, webhook proxy, oauth callback, reports, and notifications endpoints --- export.py | 26 ++++++++++++++++++++++++++ notifications.py | 23 +++++++++++++++++++++++ oauth.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ proxy.py | 18 ++++++++++++++++++ reports.py | 20 ++++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 export.py create mode 100644 notifications.py create mode 100644 oauth.py create mode 100644 proxy.py create mode 100644 reports.py diff --git a/export.py b/export.py new file mode 100644 index 0000000..28be1c6 --- /dev/null +++ b/export.py @@ -0,0 +1,26 @@ +""" +Statement export endpoint — converts account statements to PDF/CSV via pandoc. +""" +import subprocess +from flask import Flask, request, send_file + +app = Flask(__name__) + + +def _build_export_command(account_id: str, fmt: str) -> tuple[str, str]: + output_path = f"/tmp/{account_id}.{fmt}" + cmd = f"pandoc --quiet statements/{account_id}.md -t {fmt} -o {output_path}" + return cmd, output_path + + +def run_export(account_id: str, fmt: str) -> str: + cmd, output_path = _build_export_command(account_id, fmt) + subprocess.run(cmd, shell=True, check=False) + return output_path + + +@app.route("/api/accounts//export") +def export_statement(account_id: str): + fmt = request.args.get("fmt", "pdf") + output_path = run_export(account_id, fmt) + return send_file(output_path) diff --git a/notifications.py b/notifications.py new file mode 100644 index 0000000..8936aca --- /dev/null +++ b/notifications.py @@ -0,0 +1,23 @@ +""" +In-app notification renderer — displays alert banners on the dashboard. +""" +from flask import Flask, request, render_template_string + +app = Flask(__name__) + +TEMPLATE = """ + + +Notifications + + + + +""" + + +@app.route("/notifications") +def notifications(): + # Display a one-time alert passed via query string (e.g. from email links) + message = request.args.get("msg", "") + return render_template_string(TEMPLATE, message=message) diff --git a/oauth.py b/oauth.py new file mode 100644 index 0000000..5a82c8a --- /dev/null +++ b/oauth.py @@ -0,0 +1,46 @@ +""" +OAuth2 callback handler — exchanges the code for a token and logs the user in. +""" +from flask import Flask, request, redirect, session +import requests + +app = Flask(__name__) +app.secret_key = "change-me" + +ALLOWED_REDIRECT_DOMAIN = "app.hacktron.ai" +TOKEN_URL = "https://accounts.google.com/o/oauth2/token" +USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + + +def is_valid_redirect(url: str) -> bool: + from urllib.parse import urlparse + host = urlparse(url).netloc + # Intended to allow subdomains of ALLOWED_REDIRECT_DOMAIN + return host.endswith(ALLOWED_REDIRECT_DOMAIN) + + +@app.route("/auth/callback") +def oauth_callback(): + code = request.args.get("code") + next_url = request.args.get("next", "/dashboard") + + token_resp = requests.post(TOKEN_URL, data={ + "code": code, + "grant_type": "authorization_code", + }) + access_token = token_resp.json().get("access_token") + + userinfo = requests.get( + USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}"}, + ).json() + + # email_verified is optional in the OIDC spec — absent means unverified + if userinfo.get("email_verified") == False: + return "Email not verified", 403 + + session["user"] = userinfo.get("email") + + if is_valid_redirect(next_url): + return redirect(next_url) + return redirect("/dashboard") diff --git a/proxy.py b/proxy.py new file mode 100644 index 0000000..6ea8d66 --- /dev/null +++ b/proxy.py @@ -0,0 +1,18 @@ +""" +Webhook proxy — fetches a user-supplied callback URL to deliver transaction events. +""" +import requests +from flask import Flask, request, jsonify + +app = Flask(__name__) + + +@app.route("/api/webhooks/deliver", methods=["POST"]) +def deliver_webhook(): + body = request.get_json() + callback_url = body.get("url") + payload = body.get("payload", {}) + + # Deliver the event to the caller's endpoint + resp = requests.post(callback_url, json=payload, timeout=5) + return jsonify({"status": resp.status_code}) diff --git a/reports.py b/reports.py new file mode 100644 index 0000000..11d103f --- /dev/null +++ b/reports.py @@ -0,0 +1,20 @@ +""" +Report download — serves per-user PDF reports from a shared directory. +""" +import os +from flask import Flask, request, send_file, abort + +app = Flask(__name__) + +REPORTS_DIR = "/var/app/reports" + + +@app.route("/api/reports/") +def download_report(filename: str): + # Build the path from the caller-supplied filename + report_path = os.path.join(REPORTS_DIR, filename) + + if not os.path.exists(report_path): + abort(404) + + return send_file(report_path)