Files

329 lines
11 KiB
Python
Raw Permalink Normal View History

2026-07-18 20:27:19 +02:00
"""HTTP routes for the Flowers browser UI and legacy form API."""
2026-07-18 20:27:19 +02:00
from __future__ import annotations
2026-07-18 20:27:19 +02:00
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,
)
2026-07-18 20:27:19 +02:00
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
2026-07-18 21:16:07 +02:00
def _login_page(name="Welcome back", password="Enter your bouquet password", status=200):
2026-07-18 20:27:19 +02:00
return render_template(
"index.html", name_suggestion=name, pwd_suggestion=password
), status
2026-07-18 20:27:19 +02:00
@flower_bp.get("/")
def index():
return _login_page()
2026-07-18 20:27:19 +02:00
@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:
2026-07-18 21:16:07 +02:00
flash("The bouquet database could not complete that request.", "error")
2026-07-18 20:27:19 +02:00
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
2026-07-18 20:27:19 +02:00
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:
2026-07-18 21:16:07 +02:00
flash("The bouquet database could not complete that request.", "error")
2026-07-18 20:27:19 +02:00
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():
2026-07-18 20:27:19 +02:00
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"
)
2026-07-18 21:16:07 +02:00
flash("Your bouquet is ready. Sign in to continue.", "success")
2026-07-18 20:27:19 +02:00
return redirect(url_for("flower_vase.index"))
return render_template(
2026-07-18 21:16:07 +02:00
"create.html", message="The bouquet could not be created. Check the server log."
2026-07-18 20:27:19 +02:00
), 500
@flower_bp.route("/update_pwd", methods=["GET", "POST"])
def update_pwd():
2026-07-18 20:27:19 +02:00
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():
2026-07-18 20:27:19 +02:00
"""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:
2026-07-18 20:27:19 +02:00
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")