import os import logging from functools import wraps from flask import Flask, request, jsonify, session, render_template_string, redirect, url_for from backend import storage SENSITIVE_FIELDS = {"password", "passwd", "secret", "token", "access_token", "refresh_token"} """Redact sensitive values and long blobs from log-friendly repr.""" def redact_for_log(value): if isinstance(value, dict): return {k: "[redacted]" if k.lower() in SENSITIVE_FIELDS else redact_for_log(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [redact_for_log(v) for v in value] return value def _request_snapshot(request): body = {} try: if request.is_json: body = request.get_json(silent=True) or {} elif request.method in ("POST", "PUT", "PATCH") and request.form: body = {k: v for k, v in request.form.items()} except Exception: body = {} return { "method": request.method, "path": request.path, "args": dict(request.args), "body": redact_for_log(body), } def _maybe_log_request(app, request): if app.debug: app.logger.debug("request: %s", _request_snapshot(request)) ARTICLE_TYPE_LABELS = { "contact_details": "Contact details", "notes": "Notes", "profile_text": "Profile text", "documents": "Documents", "links": "Links", "phone_numbers": "Phone numbers", "addresses": "Addresses", "social_profiles": "Social profiles", } def create_app(): app = Flask(__name__, static_folder="static", static_url_path="/static") app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(24).hex() storage.init_db() @app.route("/") def index(): if request.args: # Never accept credentials (or anything else) via the query string. app.logger.warning("ignoring query string on index: keys=%s", list(request.args.keys())) account = session.get("account") if account: fresh = storage.get_account_by_id(account.get("id")) or account session["account"] = fresh return render_index(fresh) return render_login_page() def _wants_json(): if request.is_json: return True if request.form: return False best = request.accept_mimetypes.best_match(["application/json", "text/html"]) return best == "application/json" def _form_or_json(field): if field in request.form: return request.form.get(field) if request.is_json: data = request.get_json(silent=True) or {} return data.get(field) return None @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "GET": if request.args: app.logger.warning("ignoring query string on login: keys=%s", list(request.args.keys())) return render_login_page() _maybe_log_request(app, request) email = _form_or_json("email") password = _form_or_json("password") wants_json = _wants_json() if not email or not password: app.logger.warning("login attempt missing credentials: path=%s", request.path) if wants_json: return reply_error("email and password required") return render_login_page("email and password required") account = storage.authenticate(email, password) if not account: app.logger.info("login failed for email=%s path=%s", email, request.path) if wants_json: return reply_error("invalid credentials") return render_login_page("invalid credentials") session["account"] = account app.logger.info("login ok for email=%s path=%s", email, request.path) if wants_json: return reply_ok(account) return redirect("/") @app.route("/logout") def logout(): session.pop("account", None) return redirect("/") @app.route("/account/create", methods=["GET", "POST"]) def create_account_page(): if request.method == "GET": return render_account_create_page() _maybe_log_request(app, request) return _create_account() @app.route("/account", methods=["POST"]) def create_account_route(): return _create_account() def _create_account(): email = _form_or_json("email") password = _form_or_json("password") wants_json = _wants_json() if not email or not password: if wants_json: return reply_error("email and password required") return render_account_create_page("email and password required") try: account = storage.create_account(email, password) app.logger.info("account created email=%s path=%s", email, request.path) if wants_json: return reply_ok(account) return redirect("/") except ValueError as e: if "already exists" in str(e).lower(): app.logger.info("account exists email=%s path=%s", email, request.path) else: app.logger.warning("account create failed email=%s err=%s path=%s", email, str(e), request.path) if wants_json: return reply_error(str(e)) return render_account_create_page(str(e)) @app.route("/account/me") def me(): account = session.get("account") if not account: return reply_error("not authenticated") fresh = storage.get_account_by_id(account.get("id")) or account session["account"] = fresh return reply_ok(fresh) def get_account(): return session.get("account") def login_required(f): @wraps(f) def wrapped(*args, **kwargs): account = get_account() if not account: return reply_error("authentication required") request.account = account return f(*args, **kwargs) return wrapped @app.route("/persons", methods=["GET"]) @login_required def list_persons_route(): persons = storage.list_persons(request.account) return reply_ok(persons) @app.route("/persons", methods=["POST"]) @login_required def create_person_route(): name = _form_or_json("name") or "" try: person = storage.create_person(request.account, name) except (PermissionError, ValueError) as e: return reply_error(str(e)) return reply_ok(person) @app.route("/admin/persons", methods=["POST"]) @login_required def admin_create_person_route(): name = _form_or_json("name") or "" email = _form_or_json("email") password = _form_or_json("password") try: person = storage.create_person_with_account(request.account, name, email, password) return reply_ok(person) except (PermissionError, ValueError) as e: return reply_error(str(e)) @app.route("/persons/") @login_required def get_person_route(person_id): person = storage.get_person(person_id) if not person: return reply_error("person not found") if not storage.is_admin(request.account) and person_id != request.account.get("person_id"): # Only admins see which emails belong to another person. person = dict(person) person["emails"] = [] return reply_ok(person) @app.route("/persons/", methods=["PATCH"]) @login_required def update_person_route(person_id): name = request.form.get("name") or request.json.get("name") name = name if name is not None else None person = storage.update_person(request.account, person_id, name) if not person: return reply_error("person not found") return reply_ok(person) @app.route("/persons/", methods=["DELETE"]) @login_required def delete_person_route(person_id): ok = storage.delete_person(request.account, person_id) if not ok: return reply_error("person not found") return reply_ok({"deleted": True}) @app.route("/persons//emails/attach", methods=["POST"]) @login_required def attach_email_route(person_id): email = request.form.get("email") or request.json.get("email") if not email: return reply_error("email required") try: person = storage.attach_email_to_person(request.account, person_id, email) return reply_ok(person) except (ValueError, PermissionError) as e: return reply_error(str(e)) @app.route("/persons//emails/detach", methods=["POST"]) @login_required def detach_email_route(person_id): email = request.form.get("email") or request.json.get("email") if not email: return reply_error("email required") try: person = storage.detach_email_from_person(request.account, person_id, email) return reply_ok(person) except (ValueError, PermissionError) as e: return reply_error(str(e)) @app.route("/articles", methods=["GET"]) @login_required def list_articles_route(): person_id = request.args.get("person_id") articles = storage.list_articles(request.account, person_id) return reply_ok(articles) @app.route("/articles", methods=["POST"]) @login_required def create_article_route(): person_id = _form_or_json("person_id") article_type = _form_or_json("article_type") title = _form_or_json("title") or "" content = _form_or_json("content") or "" visibility = _form_or_json("visibility") or "private" group_ids = request.json.get("group_ids") if request.is_json else None if group_ids is None: group_ids = [] if article_type not in storage.ARTICLE_TYPES: return reply_error("invalid article type") if person_id is None: return reply_error("person_id required") try: article = storage.create_article( request.account, person_id, article_type, title, content, visibility, group_ids ) return reply_ok(article) except (ValueError, PermissionError) as e: return reply_error(str(e)) @app.route("/articles/") @login_required def get_article_route(article_id): article = storage.get_article(article_id) if not article: return reply_error("article not found") if not storage._can_read_article(request.account, article): article = {k: v for k, v in article.items() if k != "content"} return reply_ok(article) @app.route("/articles/", methods=["PATCH"]) @login_required def update_article_route(article_id): title = request.form.get("title") or request.json.get("title") content = request.form.get("content") or request.json.get("content") visibility = request.form.get("visibility") or request.json.get("visibility") group_ids = request.json.get("group_ids") if request.is_json else None title = title if title is not None else None content = content if content is not None else None visibility = visibility if visibility is not None else None try: article = storage.update_article( request.account, article_id, title=title, content=content, visibility=visibility, group_ids=group_ids ) if not article: return reply_error("article not found") return reply_ok(article) except (ValueError, PermissionError) as e: return reply_error(str(e)) @app.route("/articles/", methods=["DELETE"]) @login_required def delete_article_route(article_id): try: ok = storage.delete_article(request.account, article_id) if not ok: return reply_error("article not found") return reply_ok({"deleted": True}) except PermissionError as e: return reply_error(str(e)) @app.route("/groups", methods=["GET"]) @login_required def list_groups_route(): person_id = request.args.get("person_id") groups = storage.list_groups(request.account, person_id) return reply_ok(groups) @app.route("/groups", methods=["POST"]) @login_required def create_group_route(): person_id = request.form.get("person_id") or request.json.get("person_id") name = request.form.get("name") or request.json.get("name") or "" if not person_id: return reply_error("person_id required") try: group = storage.create_group(request.account, person_id, name) return reply_ok(group) except PermissionError as e: return reply_error(str(e)) @app.route("/groups/") @login_required def get_group_route(group_id): group = storage.get_group(group_id) if not group: return reply_error("group not found") if not storage.group_is_visible_to(request.account, group_id): return reply_error("group not found") return reply_ok(group) @app.route("/groups/", methods=["PATCH"]) @login_required def update_group_route(group_id): name = request.form.get("name") or request.json.get("name") name = name if name is not None else None try: group = storage.update_group(request.account, group_id, name) if not group: return reply_error("group not found") return reply_ok(group) except PermissionError as e: return reply_error(str(e)) @app.route("/groups/", methods=["DELETE"]) @login_required def delete_group_route(group_id): try: ok = storage.delete_group(request.account, group_id) if not ok: return reply_error("group not found") return reply_ok({"deleted": True}) except PermissionError as e: return reply_error(str(e)) @app.route("/groups//members/", methods=["POST"]) @login_required def add_member_route(group_id, person_id): try: group = storage.add_group_member(request.account, group_id, person_id) return reply_ok(group) except (ValueError, PermissionError) as e: return reply_error(str(e)) @app.route("/groups//members/", methods=["DELETE"]) @login_required def remove_member_route(group_id, person_id): try: group = storage.remove_group_member(request.account, group_id, person_id) return reply_ok(group) except PermissionError as e: return reply_error(str(e)) @app.route("/rights", methods=["GET"]) @login_required def list_rights_route(): account_id = request.args.get("account_id") try: rights = storage.list_rights(request.account, account_id) except PermissionError as e: return reply_error(str(e)) return reply_ok(rights) @app.route("/rights/grant", methods=["POST"]) @login_required def grant_right_route(): target_account_id = request.form.get("account_id") or request.json.get("account_id") right_name = request.form.get("right") or request.json.get("right") if not target_account_id or not right_name: return reply_error("account_id and right required") try: storage.grant_right(request.account, target_account_id, right_name) return reply_ok({"granted": right_name}) except (ValueError, PermissionError) as e: return reply_error(str(e)) @app.route("/rights/revoke", methods=["POST"]) @login_required def revoke_right_route(): target_account_id = request.form.get("account_id") or request.json.get("account_id") right_name = request.form.get("right") or request.json.get("right") if not target_account_id or not right_name: return reply_error("account_id and right required") try: storage.revoke_right(request.account, target_account_id, right_name) return reply_ok({"revoked": right_name}) except PermissionError as e: return reply_error(str(e)) @app.route("/api/types") def article_types(): return reply_ok({ "article_types": storage.ARTICLE_TYPES, "rights": storage.RIGHTS, "article_type_labels": ARTICLE_TYPE_LABELS, }) return app def render_account_create_page(error=None): return render_template_string( """ Who² - Create account

Create account

Already have an account? Log in

{{ error }}
""", error=error or "", ) def render_login_page(error=None): return render_template_string( """ Who² - Login

Who²

No account yet? Create one

{{ error }}
""", error=error or "", ) def render_index(account=None): is_admin = storage.is_admin(account) return render_template_string( """ Who²

Who²

My person

{% if is_admin %}Add article{% else %}Add note{% endif %}

{% if is_admin %} {% endif %} {% if is_admin %} {% endif %}

Articles

    My groups

      {% if is_admin %}

      Admin · create person

        Admin · person emails

        The email needs an account; create one above by giving a password.

        Admin · accounts & rights

          {% endif %}
          """, is_admin=is_admin, ) def reply_ok(body): resp = jsonify(body) resp.status_code = 200 return resp def reply_error(message): resp = jsonify({"error": message}) resp.status_code = 400 return resp