Files
crushdb/backend/app.py
T
2026-10-14 20:29:22 +02:00

730 lines
27 KiB
Python

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/<person_id>")
@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/<person_id>", 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/<person_id>", 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/<person_id>/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/<person_id>/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/<article_id>")
@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/<article_id>", 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/<article_id>", 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/<group_id>")
@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/<group_id>", 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/<group_id>", 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/<group_id>/members/<person_id>", 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/<group_id>/members/<person_id>", 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(
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Who² - Create account</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="card">
<h1>Create account</h1>
<form id="create-account-form" method="post" action="/account/create">
<label>email
<input name="email" type="email" autocomplete="username" required />
</label>
<label>password
<input name="password" type="password" autocomplete="new-password" required />
</label>
<button type="submit">Create account</button>
</form>
<p class="hint">Already have an account? <a href="/">Log in</a></p>
<pre id="create-msg" class="msg{% if not error %} hidden{% endif %}">{{ error }}</pre>
</div>
<script src="/static/crypto.js"></script>
<script src="/static/ui.js"></script>
<script>
(function () {
var form = document.getElementById("create-account-form");
var msg = document.getElementById("create-msg");
form.addEventListener("submit", function (e) {
e.preventDefault();
msg.textContent = "";
msg.classList.add("hidden");
var email = form.email.value.trim();
var password = form.password.value;
if (!email || !password) {
msg.textContent = "email and password required";
msg.style.color = "#dc2626";
msg.classList.remove("hidden");
return;
}
fetch("/account/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email, password: password })
})
.then(function (r) { return r.json().then(function (data) { if (!r.ok) throw new Error(data.error || "account creation failed"); return data; }); })
.then(function (data) {
msg.textContent = "account created: " + (data.email || "");
msg.style.color = "#047857";
msg.classList.remove("hidden");
window.setTimeout(function () { window.location.href = "/"; }, 700);
})
.catch(function (err) {
msg.textContent = err.message || "account creation failed";
msg.style.color = "#dc2626";
msg.classList.remove("hidden");
});
});
})();
</script>
</body>
</html>
""",
error=error or "",
)
def render_login_page(error=None):
return render_template_string(
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Who² - Login</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="card">
<h1>Who²</h1>
<form id="login-form" method="post" action="/login">
<label>email
<input name="email" type="email" autocomplete="username" required />
</label>
<label>password
<input name="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">Log in</button>
</form>
<p class="hint">No account yet? <a href="/account/create">Create one</a></p>
<pre id="login-msg" class="msg{% if not error %} hidden{% endif %}">{{ error }}</pre>
</div>
<script src="/static/crypto.js"></script>
<script src="/static/vendor/noble-hashes/blake2.js"></script>
<script src="/static/vendor/noble-hashes/argon2.js"></script>
<script src="/static/vendor/noble-ciphers/chacha.js"></script>
<script src="/static/vendor/noble-ciphers/_arx.js"></script>
<script src="/static/vendor/noble-ciphers/_poly1305.js"></script>
<script src="/static/vendor/noble-ciphers/utils.js"></script>
<script src="/static/vendor/noble-hashes/utils.js"></script>
<script src="/static/vendor/noble-hashes/_u64.js"></script>
<script src="/static/vendor/noble-hashes/_md.js"></script>
<script src="/static/vendor/noble-hashes/_blake.js"></script>
<script src="/static/ui.js"></script>
</body>
</html>
""",
error=error or "",
)
def render_index(account=None):
is_admin = storage.is_admin(account)
return render_template_string(
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Who²</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<h1>Who²</h1>
<div class="account-bar">
<span id="user-email"></span>
<span id="user-role" class="badge"></span>
<a href="/logout" class="btn-small">Log out</a>
</div>
</header>
<main>
<section class="panel" id="myperson-panel">
<h2>My person</h2>
<div id="myperson" class="meta"></div>
</section>
<section class="panel" id="note-panel">
<h2>{% if is_admin %}Add article{% else %}Add note{% endif %}</h2>
<form id="note-form">
{% if is_admin %}
<label>person
<select id="note-person" name="person_id" required></select>
</label>
<label>type
<select id="note-type" name="article_type" required></select>
</label>
{% endif %}
<label>title
<input id="note-title" name="title" />
</label>
<label>content
<textarea id="note-content" name="content" required></textarea>
</label>
{% if is_admin %}
<label>visibility
<select id="note-visibility" name="visibility">
<option value="private">private</option>
<option value="group">group</option>
<option value="public">public</option>
</select>
</label>
{% endif %}
<button type="submit">Save</button>
</form>
<p class="hint" id="note-hint"></p>
</section>
<section class="panel">
<h2>Articles</h2>
<ul id="articles-list"></ul>
</section>
<section class="panel" id="groups-panel">
<h2>My groups</h2>
<form id="group-form">
<label>name
<input id="group-name" name="name" required />
</label>
<button type="submit">Create group</button>
</form>
<ul id="groups-list"></ul>
<form id="invite-form">
<label>group
<select id="invite-group" name="group_id" required></select>
</label>
<label>person
<select id="invite-person" name="person_id" required></select>
</label>
<button type="submit">Invite person</button>
<button type="button" id="join-self" class="btn-ghost">Add me</button>
</form>
</section>
{% if is_admin %}
<section class="panel admin" id="admin-person-panel">
<h2>Admin · create person</h2>
<form id="admin-person-form">
<label>name
<input id="admin-person-name" name="name" required />
</label>
<label>email
<input id="admin-person-email" name="email" type="email" />
</label>
<label>password (only for a new account)
<input id="admin-person-password" name="password" type="password" />
</label>
<button type="submit">Create person</button>
</form>
<ul id="admin-persons-list"></ul>
</section>
<section class="panel admin" id="admin-email-panel">
<h2>Admin · person emails</h2>
<form id="attach-form">
<label>person
<select id="attach-person" name="person_id" required></select>
</label>
<label>email
<input id="attach-email" name="email" type="email" required />
</label>
<button type="submit">Attach</button>
<button type="button" id="detach-email" class="btn-ghost">Detach</button>
</form>
<p class="hint">The email needs an account; create one above by giving a password.</p>
</section>
<section class="panel admin" id="admin-rights-panel">
<h2>Admin · accounts &amp; rights</h2>
<form id="right-form">
<label>account
<select id="right-account" name="account_id" required></select>
</label>
<label>right
<select id="right-right" name="right" required></select>
</label>
<button type="submit">Grant</button>
<button type="button" id="revoke-right" class="btn-ghost">Revoke selected</button>
</form>
<ul id="rights-list"></ul>
</section>
{% endif %}
</main>
<pre id="app-msg" class="msg hidden"></pre>
<script src="/static/crypto.js"></script>
<script src="/static/vendor/noble-hashes/blake2.js"></script>
<script src="/static/vendor/noble-hashes/argon2.js"></script>
<script src="/static/vendor/noble-ciphers/chacha.js"></script>
<script src="/static/vendor/noble-ciphers/_arx.js"></script>
<script src="/static/vendor/noble-ciphers/_poly1305.js"></script>
<script src="/static/vendor/noble-ciphers/utils.js"></script>
<script src="/static/vendor/noble-hashes/utils.js"></script>
<script src="/static/vendor/noble-hashes/_u64.js"></script>
<script src="/static/vendor/noble-hashes/_md.js"></script>
<script src="/static/vendor/noble-hashes/_blake.js"></script>
<script src="/static/ui.js"></script>
</body>
</html>
""",
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