SHA256
rewirite to new name
This commit is contained in:
+702
-128
@@ -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 & 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
|
||||
|
||||
Reference in New Issue
Block a user