"""HTTP routes for the Flowers browser UI and legacy form API.""" from __future__ import annotations import json import secrets import threading from dataclasses import dataclass from datetime import datetime, timedelta, timezone from flask import ( Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, session, url_for, ) from AccessControl import Access from Config import DONOTSETFILTER, SESSION_MINUTES, URLPREFIX from FlowerServices import Flower, InvalidCredentials, StorageError, SuperFlower flower_bp = Blueprint( "flower_vase", __name__, url_prefix=URLPREFIX, static_folder="static", static_url_path="/static", template_folder="templates", ) @dataclass class _Credential: username: str password: str expires_at: datetime class CredentialStore: """Keep database credentials server-side instead of in Flask's cookie.""" def __init__(self) -> None: self._items: dict[str, _Credential] = {} self._lock = threading.Lock() def create(self, username: str, password: str) -> str: token = secrets.token_urlsafe(32) with self._lock: self._purge() self._items[token] = _Credential(username, password, self._deadline()) return token def get(self, token: str | None) -> tuple[str, str] | None: if not token: return None with self._lock: self._purge() credential = self._items.get(token) if credential is None: return None credential.expires_at = self._deadline() return credential.username, credential.password def revoke(self, token: str | None) -> None: if token: with self._lock: self._items.pop(token, None) def _purge(self) -> None: now = datetime.now(timezone.utc) expired = [key for key, value in self._items.items() if value.expires_at <= now] for key in expired: del self._items[key] @staticmethod def _deadline() -> datetime: return datetime.now(timezone.utc) + timedelta(minutes=SESSION_MINUTES) def _credentials() -> CredentialStore: return current_app.extensions["credential_store"] def _csrf_token() -> str: token = session.get("csrf_token") if not token: token = secrets.token_urlsafe(32) session["csrf_token"] = token return token @flower_bp.context_processor def template_context() -> dict: return {"csrf_token": _csrf_token()} @flower_bp.before_request def protect_browser_posts(): if request.method != "POST" or request.endpoint == "flower_vase.web_service": return None supplied = request.form.get("csrf_token", "") expected = session.get("csrf_token", "") if not expected or not secrets.compare_digest(supplied, expected): abort(400, description="Invalid or expired form token") return None def _vault_from_session() -> Flower | None: credential = _credentials().get(session.get("auth_token")) if not credential: return None try: return Flower(*credential) except InvalidCredentials: return None def _login_page(name="Welcome back", password="Enter your vault password", status=200): return render_template( "index.html", name_suggestion=name, pwd_suggestion=password ), status @flower_bp.get("/") def index(): return _login_page() @flower_bp.route("/browser", methods=["GET", "POST"]) def application(): if request.method == "GET": vault = _vault_from_session() if vault is None: return redirect(url_for("flower_vase.index")) try: selected_filter = session.get("filter", "") return render_template( "list.html", flowers=vault.all(), filter=selected_filter ) except StorageError: flash("The vault database could not complete that request.", "error") return redirect(url_for("flower_vase.index")) access = Access() client_ip = request.remote_addr or "unknown" if not access.granted(client_ip): return _login_page("Too many attempts", "Try again in five minutes", 429) action = request.form.get("action", "") if action == "login": return _login(access, client_ip) if action == "logout": _credentials().revoke(session.pop("auth_token", None)) session.pop("filter", None) flash("You have been signed out.", "success") return redirect(url_for("flower_vase.index")) vault = _vault_from_session() if vault is None: session.pop("auth_token", None) flash("Your session expired. Please sign in again.", "error") return redirect(url_for("flower_vase.index")) selected_filter = session.get("filter", "") form_filter = request.form.get("filter", "") if len(form_filter) > 1 and form_filter != DONOTSETFILTER: selected_filter = form_filter session["filter"] = selected_filter try: if action == "list": return render_template("list.html", flowers=vault.all(), filter=selected_filter) if action in {"show", "edit"}: flower = vault.one( request.form.get("organization", ""), request.form.get("datetime", "") ) return render_template(f"{action}.html", flower=flower, filter=selected_filter) if action == "new": return render_template("edit.html", flower=vault.empty(), filter=selected_filter) if action == "save": flower = vault.add( request.form.get("organization", "").strip(), request.form.get("myname", "").strip(), request.form.get("myid", "").strip(), request.form.get("secret", ""), ) if flower["dateCreated"] == "FAILED": flash("Organization, login, and secret must each be at least four characters.", "error") return render_template("edit.html", flower=flower, filter=selected_filter), 422 session["filter"] = flower["organization"] flash("Entry saved as a new version.", "success") return render_template("show.html", flower=flower, filter=flower["organization"]) if action == "deactivate": flower = vault.deactivate( request.form.get("organization", ""), request.form.get("datetime", "") ) session["filter"] = "" flash("Entry removed from the active list.", "success") return render_template("show.html", flower=flower, filter="") if action == "rehush": return render_template("rehush.html") except StorageError: flash("The vault database could not complete that request.", "error") return redirect(url_for("flower_vase.index")) abort(400, description="Unknown action") def _login(access: Access, client_ip: str): username = request.form.get("name", "").strip() password = request.form.get("password", "") try: vault = Flower(username, password) authenticated = vault.authenticate() except InvalidCredentials: authenticated = False if not authenticated: access.deny(client_ip) return _login_page("Sign-in failed", "Check your username and password", 401) old_token = session.get("auth_token") _credentials().revoke(old_token) session.clear() session["csrf_token"] = secrets.token_urlsafe(32) session["auth_token"] = _credentials().create(username, password) session["filter"] = "" return redirect(url_for("flower_vase.application")) @flower_bp.route("/create", methods=["GET", "POST"]) def create(): if request.method == "GET": return render_template("create.html") access = Access() client_ip = request.remote_addr or "unknown" if not access.granted(client_ip): return _login_page("Too many attempts", "Try again later", 429) username = request.form.get("name", "").strip() password = request.form.get("password", "") try: provisioner = SuperFlower(username, password) created = provisioner.createNewTable() except InvalidCredentials as exc: return render_template("create.html", message=str(exc)), 422 if created: Flower(username, password).add( "Demo organisation", "Demo name", "Demo login", "Demo secret" ) flash("Your vault is ready. Sign in to continue.", "success") return redirect(url_for("flower_vase.index")) return render_template( "create.html", message="The vault could not be created. Check the server log." ), 500 @flower_bp.route("/update_pwd", methods=["GET", "POST"]) def update_pwd(): if request.method == "GET": return render_template("rehush.html") try: vault = Flower( request.form.get("name", "").strip(), request.form.get("old_password", ""), ) if not vault.authenticate(): raise InvalidCredentials("Current credentials are not valid") message = vault.update_pwd(request.form.get("new_password", "")) except InvalidCredentials as exc: return render_template("rehush.html", message=str(exc)), 422 _credentials().revoke(session.pop("auth_token", None)) flash(message, "success" if message.startswith("SUCCESS") else "error") return redirect(url_for("flower_vase.index")) @flower_bp.post("/app") def web_service(): """Preserve the historical form API, including double-encoded payloads.""" access = Access() client_ip = request.remote_addr or "unknown" if not access.granted(client_ip): return jsonify(result=-1, message="Access denied") action = request.form.get("action", "") username = request.form.get("name", "") password = request.form.get("password", "") try: vault = Flower(username, password) if not vault.authenticate(): access.deny(client_ip) return jsonify(result=0, message="Invalid username or password") if action == "login": return jsonify(result=1, message="Access granted") if action == "list": return jsonify(result=1, message="Ok", list=json.dumps(vault.all())) if action == "one": item = vault.one( request.form.get("organization", ""), request.form.get("dateCreated", ""), ) return jsonify(result=1, message="Ok", flower=json.dumps(item)) if action == "save": item = vault.add( request.form.get("organization", ""), request.form.get("myname", ""), request.form.get("myid", ""), request.form.get("secret", ""), ) return jsonify(result=1, message="Ok", flower=json.dumps(item)) if action == "deactivate": item = vault.deactivate( request.form.get("organization", ""), request.form.get("dateCreated", ""), ) return jsonify(result=1, message="Ok", flower=json.dumps(item)) except (InvalidCredentials, StorageError, ValueError): current_app.logger.exception("Flowers API request failed") return jsonify(result=-1, message="Invalid entry")