rewirite to new name

This commit is contained in:
2026-10-14 20:29:22 +02:00
parent 2120c56f45
commit 1cdb3fa784
23 changed files with 2521 additions and 5755 deletions
+702 -128
View File
@@ -1,155 +1,729 @@
"""Flask application exposing the records API.
import os
import logging
from functools import wraps
The server is a dumb ciphertext store: it never sees plaintext or the
key. Every record body is opaque base64. Requests are validated only
for shape and size, never for content.
from flask import Flask, request, jsonify, session, render_template_string, redirect, url_for
Security properties:
- No plaintext is logged, stored, or echoed.
- Version counters are enforced server-side to detect replays.
- HTTPS is handled by ssl context in run.py (TLS for dev).
"""
import base64
import binascii
from flask import Flask, jsonify, request, send_from_directory
from . import storage
MAX_CIPHERTEXT_B64 = 32 * 1024 # plenty for one record, stops abuse
MAX_SALT_NONCE_B64 = 512
app = Flask(__name__, static_folder=None)
from backend import storage
# ---------- basic hardening ----------
@app.after_request
def _security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "no-referrer"
resp.headers["Cache-Control"] = "no-store"
return resp
SENSITIVE_FIELDS = {"password", "passwd", "secret", "token", "access_token", "refresh_token"}
"""Redact sensitive values and long blobs from log-friendly repr."""
def _b64_field(data, field, max_len, required=True):
"""Validate and decode a base64 field from the JSON body. Returns bytes or None."""
if field not in data:
if required:
raise ValueError(f"missing field: {field}")
return None
raw = data[field]
if not isinstance(raw, str) or not raw or len(raw) > max_len:
raise ValueError(f"invalid field: {field}")
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:
return base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError):
raise ValueError(f"invalid base64 in field: {field}")
def _record_json(rec):
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 {
"uuid": rec["uuid"],
"ciphertext": base64.b64encode(rec["ciphertext"]).decode(),
"nonce": base64.b64encode(rec["nonce"]).decode(),
"salt": base64.b64encode(rec["salt"]).decode(),
"version": rec["version"],
"created_at": rec["created_at"],
"updated_at": rec["updated_at"],
"method": request.method,
"path": request.path,
"args": dict(request.args),
"body": redact_for_log(body),
}
# ---------- static UI ----------
@app.route("/")
def index():
return send_from_directory("static", "index.html")
def _maybe_log_request(app, request):
if app.debug:
app.logger.debug("request: %s", _request_snapshot(request))
@app.route("/static/<path:filename>")
def static_files(filename):
return send_from_directory("static", filename)
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",
}
# ---------- API ----------
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.get("/api/records")
def api_list():
return jsonify([_record_json(r) for r in storage.list_records()])
@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
@app.get("/api/records/<uuid>")
def api_get(uuid):
rec = storage.get_record(uuid)
if rec is None:
return jsonify({"error": "not found"}), 404
return jsonify(_record_json(rec))
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 "",
)
@app.post("/api/records")
def api_create():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "invalid JSON body"}), 400
try:
uuid = data.get("uuid")
if not isinstance(uuid, str) or not uuid or len(uuid) > 64:
raise ValueError("invalid field: uuid")
ct = _b64_field(data, "ciphertext", MAX_CIPHERTEXT_B64)
nonce = _b64_field(data, "nonce", MAX_SALT_NONCE_B64)
salt = _b64_field(data, "salt", MAX_SALT_NONCE_B64)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if storage.get_record(uuid) is not None:
return jsonify({"error": "record already exists"}), 409
rec = storage.create_record(uuid, ct, nonce, salt)
return jsonify(_record_json(rec)), 201
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 "",
)
@app.put("/api/records/<uuid>")
def api_update(uuid):
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "invalid JSON body"}), 400
try:
version = data.get("version")
if not isinstance(version, int) or version < 1:
raise ValueError("invalid field: version")
ct = _b64_field(data, "ciphertext", MAX_CIPHERTEXT_B64)
nonce = _b64_field(data, "nonce", MAX_SALT_NONCE_B64)
salt = _b64_field(data, "salt", MAX_SALT_NONCE_B64)
except ValueError as e:
return jsonify({"error": str(e)}), 400
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>
rec = storage.update_record(uuid, ct, nonce, salt, expected_version=version)
if rec is None:
# 409: version mismatch (concurrent edit or replay) — do not
# distinguish from "not found" to avoid leaking existence.
return jsonify({"error": "version conflict or not found"}), 409
return jsonify(_record_json(rec))
<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,
)
@app.delete("/api/records/<uuid>")
def api_delete(uuid):
data = request.get_json(silent=True) or {}
version = data.get("version")
if not isinstance(version, int) or version < 1:
return jsonify({"error": "invalid field: version"}), 400
if not storage.delete_record(uuid, expected_version=version):
return jsonify({"error": "version conflict or not found"}), 409
return "", 204
def reply_ok(body):
resp = jsonify(body)
resp.status_code = 200
return resp
@app.errorhandler(404)
def not_found(_e):
return jsonify({"error": "not found"}), 404
@app.errorhandler(500)
def server_error(_e):
# Never leak stack traces or data.
return jsonify({"error": "internal server error"}), 500
def reply_error(message):
resp = jsonify({"error": message})
resp.status_code = 400
return resp