SHA256
Compare commits
24
Commits
7f045492b1
..
main
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
1cdb3fa784 | ||
|
|
2120c56f45 | ||
|
|
516cb13dcf | ||
|
|
c82de902a8 | ||
|
|
8d488f44ff | ||
|
|
168927453a | ||
|
|
2df603b32b | ||
|
|
535a9c9db0 | ||
|
|
863cd1fd3a | ||
|
|
6bb6a38040 | ||
|
|
47b65508f4 | ||
|
|
505b1839dd | ||
|
|
7c48057360 | ||
|
|
6c41779667 | ||
|
|
ae1af1f8d2 | ||
|
|
bd9097ba27 | ||
|
|
bb604768cc | ||
|
|
d6f508f848 | ||
|
|
9bab17e5dd | ||
|
|
a963baa8de | ||
|
|
7cbc2eecd2 | ||
|
|
2ff58441f4 | ||
|
|
a6a37d675d | ||
|
|
440cd78203 |
+10
-161
@@ -1,133 +1,10 @@
|
||||
pastel
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
@@ -137,40 +14,12 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
# SQLite
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Instance / state
|
||||
instance/
|
||||
certs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# crushdb
|
||||
|
||||
a secure database for saving transaktions betwen persons
|
||||
+729
@@ -0,0 +1,729 @@
|
||||
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 & 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
|
||||
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
|
||||
function req(path, options) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
options = Object.assign({ method: "GET", headers }, options || {});
|
||||
if (options.body && typeof options.body === "object") {
|
||||
options.body = JSON.stringify(options.body);
|
||||
}
|
||||
return fetch(path, options).then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || "request failed");
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function postForm(path, form) {
|
||||
const data = {};
|
||||
for (const [k, v] of new FormData(form)) {
|
||||
if (v) data[k] = v;
|
||||
}
|
||||
return req(path, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
function patchJson(path, body) {
|
||||
return req(path, { method: "PATCH", body: body });
|
||||
}
|
||||
|
||||
function delReq(path) {
|
||||
return req(path, { method: "DELETE" });
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function uuid() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
||||
const a = new Uint8Array(16);
|
||||
(crypto.getRandomValues || function () { for (let i = 0; i < a.length; i++) a[i] = Math.floor(Math.random() * 256); })(a);
|
||||
a[6] = (a[6] & 0x0f) | 0x40;
|
||||
a[8] = (a[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(a, b => b.toString(16).padStart(2, "0")).join("");
|
||||
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
|
||||
}
|
||||
|
||||
function appendMsg(el, message, isError) {
|
||||
el.textContent = message || "";
|
||||
el.classList.toggle("hidden", !message);
|
||||
el.style.color = isError ? "#dc2626" : "#047857";
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
background: #f6f7f9;
|
||||
color: #1f2330;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e4e7ee;
|
||||
}
|
||||
.account-bar { display: flex; align-items: center; gap: 12px; }
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 16px auto;
|
||||
padding: 0 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e7ee;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.panel h2 { margin-top: 0; font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280; }
|
||||
.panel label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea { min-height: 60px; resize: vertical; }
|
||||
button, .btn-small {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
button[type="button"] { background: #e5e7eb; color: #1f2330; }
|
||||
.btn-small { padding: 4px 8px; font-size: 12px; }
|
||||
.btn-ghost { background: transparent; color: #dc2626; border: 1px solid #dc2626; }
|
||||
.card { max-width: 420px; margin: 60px auto; padding: 24px; }
|
||||
.card h1 { margin: 0 0 16px; }
|
||||
.hint { font-size: 12px; color: #6b7280; margin-top: 12px; }
|
||||
.msg { color: #dc2626; font-size: 12px; margin-top: 8px; word-break: break-word; }
|
||||
.hidden { display: none; }
|
||||
ul { list-style: none; padding: 0; margin: 8px 0 0; }
|
||||
li {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e4e7ee;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
li .meta { color: #6b7280; font-size: 11px; display: block; margin-top: 4px; }
|
||||
.account-bar .dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; display: inline-block; }
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.panel.admin {
|
||||
border-color: #c7d2fe;
|
||||
background: #f8faff;
|
||||
}
|
||||
.panel.admin h2 { color: #4338ca; }
|
||||
#myperson .meta { margin-top: 4px; }
|
||||
@@ -0,0 +1,504 @@
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
const msgEl = document.getElementById("app-msg") || document.getElementById("login-msg");
|
||||
const isLoginPage = !!document.getElementById("login-form");
|
||||
|
||||
let currentAccount = null;
|
||||
|
||||
function showMessage(text, isError) {
|
||||
if (!msgEl) return;
|
||||
appendMsg(msgEl, text, isError);
|
||||
}
|
||||
|
||||
function clearMessage() {
|
||||
showMessage("", false);
|
||||
}
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function setOptions(sel, items, valueOf, labelOf, keepSelection) {
|
||||
if (!sel) return;
|
||||
const current = keepSelection ? sel.value : "";
|
||||
sel.innerHTML = "";
|
||||
items.forEach(function (item) {
|
||||
const o = document.createElement("option");
|
||||
o.value = valueOf(item);
|
||||
o.textContent = labelOf(item);
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if (current) {
|
||||
for (const o of sel.options) {
|
||||
if (o.value === current) o.selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------- login page ----------------------------- */
|
||||
|
||||
function initLoginPage() {
|
||||
const form = byId("login-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
const email = form.email.value.trim();
|
||||
const password = form.password.value;
|
||||
if (!email || !password) {
|
||||
showMessage("email and password required", true);
|
||||
return;
|
||||
}
|
||||
fetch("/login", {
|
||||
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 || "login failed");
|
||||
return data;
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
window.location.href = "/";
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "login failed", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------ main app ------------------------------ */
|
||||
|
||||
function initMainApp() {
|
||||
req("/account/me")
|
||||
.then(function (account) {
|
||||
currentAccount = account;
|
||||
startMainApp(account);
|
||||
})
|
||||
.catch(function () {
|
||||
window.location.href = "/login";
|
||||
});
|
||||
}
|
||||
|
||||
function startMainApp(account) {
|
||||
const emailEl = byId("user-email");
|
||||
if (emailEl) emailEl.textContent = account.email;
|
||||
const roleEl = byId("user-role");
|
||||
if (roleEl) roleEl.textContent = account.is_admin ? "admin" : "user";
|
||||
|
||||
renderMyPerson(account);
|
||||
|
||||
bindNoteForm();
|
||||
bindGroupForm();
|
||||
bindInviteForm();
|
||||
if (account.is_admin) {
|
||||
bindAdminPersonForm();
|
||||
bindAttachForm();
|
||||
bindRightForm();
|
||||
loadArticleTypes();
|
||||
populateRightsDropdown();
|
||||
refreshRights();
|
||||
}
|
||||
|
||||
refreshPersons();
|
||||
refreshArticles();
|
||||
refreshGroups();
|
||||
}
|
||||
|
||||
function renderMyPerson(account) {
|
||||
const box = byId("myperson");
|
||||
const form = byId("note-form");
|
||||
const hint = byId("note-hint");
|
||||
if (!account.person_id) {
|
||||
if (box) box.innerHTML = "Your account is not linked to a person yet. Ask an admin to link it.";
|
||||
if (form) {
|
||||
Array.prototype.forEach.call(form.querySelectorAll("input, textarea, button"), function (el) {
|
||||
el.disabled = true;
|
||||
});
|
||||
}
|
||||
if (hint) hint.textContent = "You can add notes, groups and invites once an admin links you to a person.";
|
||||
return;
|
||||
}
|
||||
req("/persons/" + encodeURIComponent(account.person_id))
|
||||
.then(function (p) {
|
||||
if (!box) return;
|
||||
box.innerHTML =
|
||||
"<strong>" + escapeHtml(p.name || "(unnamed)") + "</strong>" +
|
||||
'<span class="meta">' +
|
||||
(p.emails && p.emails.length ? "emails: " + escapeHtml(p.emails.join(", ")) : "no email shown") +
|
||||
"</span>";
|
||||
})
|
||||
.catch(function () {
|
||||
if (box) box.textContent = "Person not found.";
|
||||
});
|
||||
}
|
||||
|
||||
function loadArticleTypes() {
|
||||
req("/api/types")
|
||||
.then(function (data) {
|
||||
setOptions(
|
||||
byId("note-type"),
|
||||
data.article_types || [],
|
||||
function (t) { return t; },
|
||||
function (t) { return (data.article_type_labels && data.article_type_labels[t]) || t; }
|
||||
);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function populateRightsDropdown() {
|
||||
req("/api/types")
|
||||
.then(function (data) {
|
||||
const rights = data.rights || {};
|
||||
setOptions(
|
||||
byId("right-right"),
|
||||
Object.keys(rights),
|
||||
function (k) { return k; },
|
||||
function (k) { return k + " - " + rights[k]; }
|
||||
);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function bindNoteForm() {
|
||||
const form = byId("note-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
const isAdmin = currentAccount && currentAccount.is_admin;
|
||||
const personSel = byId("note-person");
|
||||
const personId = isAdmin && personSel ? personSel.value : currentAccount && currentAccount.person_id;
|
||||
if (!personId) {
|
||||
showMessage("no person linked to your account", true);
|
||||
return;
|
||||
}
|
||||
const typeSel = byId("note-type");
|
||||
const visSel = byId("note-visibility");
|
||||
req("/articles", {
|
||||
method: "POST",
|
||||
body: {
|
||||
person_id: personId,
|
||||
article_type: isAdmin && typeSel ? typeSel.value : "notes",
|
||||
title: form.title.value,
|
||||
content: form.content.value,
|
||||
visibility: isAdmin && visSel ? visSel.value : "private",
|
||||
group_ids: [],
|
||||
},
|
||||
})
|
||||
.then(function () {
|
||||
form.reset();
|
||||
refreshArticles();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to save", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshArticles() {
|
||||
req("/articles")
|
||||
.then(function (data) {
|
||||
const list = byId("articles-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
if (!data.length) {
|
||||
list.innerHTML = '<li class="meta">Nothing here yet.</li>';
|
||||
return;
|
||||
}
|
||||
data.forEach(function (a) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(a.title || "(untitled)") + "</strong> " +
|
||||
'<span class="meta">' +
|
||||
escapeHtml(a.article_type) +
|
||||
" · " + escapeHtml(a.visibility) +
|
||||
"</span>" +
|
||||
(a.content
|
||||
? "<pre style='margin:4px 0;white-space:pre-wrap;font:inherit;background:#fff;border:1px solid #e4e7ee;padding:4px;border-radius:4px;'>" +
|
||||
escapeHtml(a.content) + "</pre>"
|
||||
: "") +
|
||||
(a.visibility === "group" && a.allowed_groups && a.allowed_groups.length
|
||||
? '<span class="meta">groups: ' + escapeHtml(a.allowed_groups.join(", ")) + "</span>"
|
||||
: "");
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load articles", true);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshPersons() {
|
||||
req("/persons")
|
||||
.then(function (data) {
|
||||
const label = function (p) { return p.name || "(unnamed)"; };
|
||||
|
||||
setOptions(byId("invite-person"), data, function (p) { return p.id; }, label, true);
|
||||
|
||||
const noteSel = byId("note-person");
|
||||
setOptions(noteSel, data, function (p) { return p.id; }, label, true);
|
||||
if (noteSel && !noteSel.value && currentAccount && currentAccount.person_id) {
|
||||
noteSel.value = currentAccount.person_id;
|
||||
}
|
||||
|
||||
setOptions(byId("attach-person"), data, function (p) { return p.id; }, label, true);
|
||||
|
||||
const list = byId("admin-persons-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
if (!data.length) {
|
||||
list.innerHTML = '<li class="meta">No persons yet.</li>';
|
||||
return;
|
||||
}
|
||||
data.forEach(function (p) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(p.name || "(unnamed)") + "</strong>" +
|
||||
'<span class="meta">' +
|
||||
(p.emails && p.emails.length ? "emails: " + escapeHtml(p.emails.join(", ")) : "no emails") +
|
||||
"</span>";
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load persons", true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindAdminPersonForm() {
|
||||
const form = byId("admin-person-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
req("/admin/persons", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: form.name.value,
|
||||
email: form.email.value,
|
||||
password: form.password.value,
|
||||
},
|
||||
})
|
||||
.then(function (person) {
|
||||
form.reset();
|
||||
showMessage("created person: " + (person.name || person.id), false);
|
||||
refreshPersons();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to create person", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindAttachForm() {
|
||||
const form = byId("attach-form");
|
||||
if (!form) return;
|
||||
const send = function (action, okText) {
|
||||
const personId = byId("attach-person").value;
|
||||
const email = byId("attach-email").value.trim();
|
||||
if (!personId || !email) {
|
||||
showMessage("select a person and enter an email", true);
|
||||
return;
|
||||
}
|
||||
req("/persons/" + encodeURIComponent(personId) + "/emails/" + action, {
|
||||
method: "POST",
|
||||
body: { email: email },
|
||||
})
|
||||
.then(function () {
|
||||
showMessage(okText + " " + email, false);
|
||||
refreshPersons();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to change email", true);
|
||||
});
|
||||
};
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
send("attach", "attached");
|
||||
});
|
||||
const detach = byId("detach-email");
|
||||
if (detach) {
|
||||
detach.addEventListener("click", function () {
|
||||
clearMessage();
|
||||
send("detach", "detached");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindGroupForm() {
|
||||
const form = byId("group-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
if (!currentAccount || !currentAccount.person_id) {
|
||||
showMessage("no person linked to your account", true);
|
||||
return;
|
||||
}
|
||||
req("/groups", {
|
||||
method: "POST",
|
||||
body: { person_id: currentAccount.person_id, name: form.name.value },
|
||||
})
|
||||
.then(function () {
|
||||
form.reset();
|
||||
refreshGroups();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to create group", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshGroups() {
|
||||
req("/groups")
|
||||
.then(function (data) {
|
||||
setOptions(
|
||||
byId("invite-group"),
|
||||
data,
|
||||
function (g) { return g.id; },
|
||||
function (g) { return g.name || "(unnamed)"; },
|
||||
true
|
||||
);
|
||||
const list = byId("groups-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
if (!data.length) {
|
||||
list.innerHTML = '<li class="meta">No groups yet. Create one above.</li>';
|
||||
return;
|
||||
}
|
||||
data.forEach(function (g) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(g.name || "(unnamed)") + "</strong> " +
|
||||
'<span class="meta">members: ' +
|
||||
(g.members && g.members.length
|
||||
? escapeHtml(g.members.map(function (m) { return m.name || "(unnamed)"; }).join(", "))
|
||||
: "none") +
|
||||
"</span>";
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load groups", true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindInviteForm() {
|
||||
const form = byId("invite-form");
|
||||
if (!form) return;
|
||||
const addMember = function (groupId, personId) {
|
||||
if (!groupId || !personId) {
|
||||
showMessage("select a group and a person", true);
|
||||
return;
|
||||
}
|
||||
req("/groups/" + encodeURIComponent(groupId) + "/members/" + encodeURIComponent(personId), {
|
||||
method: "POST",
|
||||
body: {},
|
||||
})
|
||||
.then(function () {
|
||||
clearMessage();
|
||||
refreshGroups();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to add member", true);
|
||||
});
|
||||
};
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
addMember(byId("invite-group").value, byId("invite-person").value);
|
||||
});
|
||||
const join = byId("join-self");
|
||||
if (join) {
|
||||
join.addEventListener("click", function () {
|
||||
clearMessage();
|
||||
if (!currentAccount || !currentAccount.person_id) {
|
||||
showMessage("no person linked to your account", true);
|
||||
return;
|
||||
}
|
||||
addMember(byId("invite-group").value, currentAccount.person_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRights() {
|
||||
req("/rights")
|
||||
.then(function (data) {
|
||||
const accountSel = byId("right-account");
|
||||
setOptions(
|
||||
accountSel,
|
||||
data,
|
||||
function (a) { return a.id; },
|
||||
function (a) { return a.email + (a.rights && a.rights.length ? " (" + a.rights.join(", ") + ")" : ""); },
|
||||
true
|
||||
);
|
||||
const list = byId("rights-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
data.forEach(function (a) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(a.email) + "</strong> " +
|
||||
'<span class="meta">' +
|
||||
(a.rights && a.rights.length ? escapeHtml(a.rights.join(", ")) : "no rights") +
|
||||
"</span>";
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load rights", true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRightForm() {
|
||||
const form = byId("right-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
if (!form.account_id.value || !form.right.value) {
|
||||
showMessage("select account and right", true);
|
||||
return;
|
||||
}
|
||||
req("/rights/grant", {
|
||||
method: "POST",
|
||||
body: { account_id: form.account_id.value, right: form.right.value },
|
||||
})
|
||||
.then(refreshRights)
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to grant right", true);
|
||||
});
|
||||
});
|
||||
const revokeBtn = byId("revoke-right");
|
||||
if (revokeBtn) {
|
||||
revokeBtn.addEventListener("click", function () {
|
||||
clearMessage();
|
||||
const accountSel = byId("right-account");
|
||||
const rightSel = byId("right-right");
|
||||
if (!accountSel.value || !rightSel.value) {
|
||||
showMessage("select account and right to revoke", true);
|
||||
return;
|
||||
}
|
||||
req("/rights/revoke", {
|
||||
method: "POST",
|
||||
body: { account_id: accountSel.value, right: rightSel.value },
|
||||
})
|
||||
.then(refreshRights)
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to revoke right", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoginPage) {
|
||||
initLoginPage();
|
||||
} else if (byId("app-msg")) {
|
||||
initMainApp();
|
||||
}
|
||||
})();
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Stub placeholder for noble-ciphers internal _arx module
|
||||
export function arx() {
|
||||
throw new Error("noble _arx stub: not implemented");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Stub placeholder for noble-ciphers internal _poly1305 module
|
||||
export function poly1305() {
|
||||
throw new Error("noble _poly1305 stub: not implemented");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Stub placeholder for noble-ciphers chacha module
|
||||
export function chacha20(poly1305) {
|
||||
throw new Error("noble chacha stub: not implemented");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Stub placeholder for noble-ciphers utils module
|
||||
export function bytesToHex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
export function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Stub placeholder for noble-hashes internal _blake module
|
||||
export function blake() {
|
||||
throw new Error("noble _blake stub: not implemented");
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Stub placeholder for noble-hashes internal _md module
|
||||
export function md() {
|
||||
throw new Error("noble _md stub: not implemented");
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Stub placeholder for noble-hashes internal _u64 module
|
||||
export function u64() {
|
||||
throw new Error("noble _u64 stub: not implemented");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Stub placeholder for noble-hashes argon2 module
|
||||
export function argon2(data) {
|
||||
throw new Error("noble argon2 stub: not implemented");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Stub placeholder for noble-hashes blake2 module
|
||||
// In a full vendor checkout this would be the real blake2 implementation.
|
||||
export function blake2b(data) {
|
||||
throw new Error("noble blake2 stub: not implemented");
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Stub placeholder for noble-hashes utils module
|
||||
export function toHex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
export function fromHex(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
import sqlite3
|
||||
import uuid
|
||||
import time
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
DB_PATH = os.environ.get("WHO2_DB_PATH", "instance/who2.sqlite")
|
||||
|
||||
RIGHTS = {
|
||||
"view_private": "Can view private articles",
|
||||
"manage_persons": "Can manage persons",
|
||||
"manage_articles": "Can manage articles",
|
||||
"manage_groups": "Can manage groups",
|
||||
"manage_accounts": "Can manage accounts",
|
||||
}
|
||||
|
||||
|
||||
ADMIN_RIGHT = "manage_accounts"
|
||||
|
||||
|
||||
def _grant_all_rights(conn, account_id: str) -> None:
|
||||
for right_name in RIGHTS:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO account_rights (account_id, right_name) VALUES (?,?)",
|
||||
(account_id, right_name),
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_admin(account_id: str) -> None:
|
||||
"""Grant all rights to an account so it can manage the system.
|
||||
|
||||
In this model an account has no rights by default, so bootstrap grants a starting
|
||||
set so the first admin can then grant rights to others.
|
||||
"""
|
||||
conn = _conn()
|
||||
try:
|
||||
_grant_all_rights(conn, account_id)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_admin(account: dict | None) -> bool:
|
||||
return has_right(account, ADMIN_RIGHT)
|
||||
|
||||
|
||||
def ensure_admin() -> None:
|
||||
"""Guarantee that at least one account holds the admin right.
|
||||
|
||||
WHO2_ADMIN_EMAIL promotes that account when it exists. Otherwise the oldest
|
||||
account is promoted, so an existing database gets exactly one admin.
|
||||
"""
|
||||
admin_email = (os.environ.get("WHO2_ADMIN_EMAIL") or "").strip()
|
||||
conn = _conn()
|
||||
try:
|
||||
if admin_email:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE lower(email) = lower(?)", (admin_email,)
|
||||
).fetchone()
|
||||
if row:
|
||||
_grant_all_rights(conn, row["id"])
|
||||
conn.commit()
|
||||
return
|
||||
existing = conn.execute(
|
||||
"SELECT account_id FROM account_rights WHERE right_name = ? LIMIT 1",
|
||||
(ADMIN_RIGHT,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return
|
||||
# rowid breaks ties between accounts created in the same second, so the
|
||||
# earliest-inserted account is reliably the one that gets promoted.
|
||||
oldest = conn.execute("SELECT id FROM accounts ORDER BY created_at, rowid LIMIT 1").fetchone()
|
||||
if oldest:
|
||||
_grant_all_rights(conn, oldest["id"])
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
ARTICLE_TYPES = [
|
||||
"contact_details",
|
||||
"notes",
|
||||
"profile_text",
|
||||
"documents",
|
||||
"links",
|
||||
"phone_numbers",
|
||||
"addresses",
|
||||
"social_profiles",
|
||||
]
|
||||
|
||||
|
||||
def _now():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def _hash_password(password: str, salt: str) -> str:
|
||||
key = (salt + password).encode("utf-8")
|
||||
return hashlib.sha256(key).hexdigest()
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_salt TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS person_accounts (
|
||||
person_id TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
PRIMARY KEY (person_id, account_id),
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id TEXT PRIMARY KEY,
|
||||
person_id TEXT NOT NULL,
|
||||
article_type TEXT NOT NULL CHECK(article_type IN (
|
||||
'contact_details','notes','profile_text','documents',
|
||||
'links','phone_numbers','addresses','social_profiles'
|
||||
)),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
visibility TEXT NOT NULL DEFAULT 'private'
|
||||
CHECK(visibility IN ('private','group','public')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
person_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
group_id TEXT NOT NULL,
|
||||
person_id TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, person_id),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id),
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_rights (
|
||||
account_id TEXT NOT NULL,
|
||||
right_name TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, right_name),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS article_group_access (
|
||||
article_id TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
PRIMARY KEY (article_id, group_id),
|
||||
FOREIGN KEY (article_id) REFERENCES articles(id),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_person ON articles(person_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_article_group_access_article ON article_group_access(article_id);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
ensure_admin()
|
||||
|
||||
|
||||
def create_account(email: str, password: str) -> dict:
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("Invalid email address")
|
||||
if not password:
|
||||
raise ValueError("Password required")
|
||||
salt = uuid.uuid4().hex
|
||||
password_hash = _hash_password(password, salt)
|
||||
account_id = uuid.uuid4().hex
|
||||
conn = _conn()
|
||||
try:
|
||||
is_first = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] == 0
|
||||
conn.execute(
|
||||
"INSERT INTO accounts (id, email, password_hash, password_salt, created_at) VALUES (?,?,?,?,?)",
|
||||
(account_id, email, password_hash, salt, _now()),
|
||||
)
|
||||
if is_first:
|
||||
# The very first account becomes the bootstrap admin.
|
||||
_grant_all_rights(conn, account_id)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError as e:
|
||||
raise ValueError("An account with this email already exists") from e
|
||||
finally:
|
||||
conn.close()
|
||||
return get_account_by_id(account_id)
|
||||
|
||||
|
||||
def authenticate(email: str, password: str) -> dict | None:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, email, password_hash, password_salt FROM accounts WHERE email = ?",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
expected = _hash_password(password, row["password_salt"])
|
||||
if not hmac.compare_digest(expected, row["password_hash"]):
|
||||
return None
|
||||
rights = [
|
||||
r["right_name"] for r in conn.execute(
|
||||
"SELECT right_name FROM account_rights WHERE account_id = ?",
|
||||
(row["id"],),
|
||||
).fetchall()
|
||||
]
|
||||
person = conn.execute(
|
||||
"SELECT person_id FROM person_accounts WHERE account_id = ? ORDER BY person_id LIMIT 1",
|
||||
(row["id"],),
|
||||
).fetchone()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"email": row["email"],
|
||||
"rights": rights,
|
||||
"person_id": person["person_id"] if person else None,
|
||||
"is_admin": ADMIN_RIGHT in rights,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def has_right(account: dict | None, right: str) -> bool:
|
||||
if account is None:
|
||||
return False
|
||||
if right in account.get("rights", []):
|
||||
return True
|
||||
if "id" in account:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM account_rights WHERE account_id = ? AND right_name = ?",
|
||||
(account["id"], right),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
|
||||
def create_person(account: dict | None, name: str = "") -> dict:
|
||||
"""Create a person. Admins only; persons are never auto-linked to the creator."""
|
||||
if account is None or not has_right(account, "manage_persons"):
|
||||
raise PermissionError("Only admins can create persons")
|
||||
if name is None:
|
||||
name = ""
|
||||
person_id = uuid.uuid4().hex
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO persons (id, name, created_at) VALUES (?,?,?)",
|
||||
(person_id, name, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": person_id, "name": name}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _find_account_by_email(email: str) -> dict | None:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, email FROM accounts WHERE lower(email) = lower(?)", (email,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {"id": row["id"], "email": row["email"]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def link_account_to_person(account: dict | None, person_id: str, account_id: str) -> dict | None:
|
||||
"""Link an account (email) to a person. Admins only.
|
||||
|
||||
The specification allows several accounts per person but only one person per
|
||||
account, so an account already linked elsewhere is rejected.
|
||||
"""
|
||||
if account is None or not has_right(account, "manage_persons"):
|
||||
raise PermissionError("Only admins can link accounts to persons")
|
||||
conn = _conn()
|
||||
try:
|
||||
linked = conn.execute(
|
||||
"SELECT person_id FROM person_accounts WHERE account_id = ?", (account_id,)
|
||||
).fetchall()
|
||||
for row in linked:
|
||||
if row["person_id"] != person_id:
|
||||
raise ValueError("This email is already linked to another person")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO person_accounts (person_id, account_id) VALUES (?,?)",
|
||||
(person_id, account_id),
|
||||
)
|
||||
conn.commit()
|
||||
return get_person(person_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_person_with_account(account: dict | None, name: str = "",
|
||||
email: str | None = None, password: str | None = None) -> dict:
|
||||
"""Admin flow: create a person and optionally give them an email address.
|
||||
|
||||
If the email has no account yet, an account is created for it (a password is
|
||||
then required). Otherwise the existing account is linked to the new person.
|
||||
"""
|
||||
if account is None or not has_right(account, "manage_persons"):
|
||||
raise PermissionError("Only admins can create persons")
|
||||
person = create_person(account, name)
|
||||
email = (email or "").strip()
|
||||
if not email:
|
||||
return get_person(person["id"])
|
||||
target = _find_account_by_email(email)
|
||||
if target is None:
|
||||
if not password:
|
||||
raise ValueError("A password is required to create the account for " + email)
|
||||
target = create_account(email, password)
|
||||
link_account_to_person(account, person["id"], target["id"])
|
||||
return get_person(person["id"])
|
||||
|
||||
|
||||
def list_persons(account: dict | None) -> list[dict]:
|
||||
show_emails = is_admin(account)
|
||||
conn = _conn()
|
||||
try:
|
||||
rows = conn.execute("SELECT id, name, created_at FROM persons ORDER BY name, id").fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
emails = []
|
||||
if show_emails:
|
||||
emails = [
|
||||
a["email"] for a in conn.execute(
|
||||
"SELECT a.email FROM accounts a JOIN person_accounts pa ON pa.account_id = a.id WHERE pa.person_id = ?",
|
||||
(r["id"],),
|
||||
).fetchall()
|
||||
]
|
||||
out.append({
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"emails": sorted(set(emails)),
|
||||
"created_at": r["created_at"],
|
||||
})
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_person(person_id: str) -> dict | None:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute("SELECT id, name, created_at FROM persons WHERE id = ?", (person_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
person_accounts = [
|
||||
a["email"] for a in conn.execute(
|
||||
"SELECT a.email FROM accounts a JOIN person_accounts pa ON pa.account_id = a.id WHERE pa.person_id = ?",
|
||||
(person_id,),
|
||||
).fetchall()
|
||||
]
|
||||
groups = [
|
||||
{"id": g["id"], "name": g["name"]}
|
||||
for g in conn.execute("SELECT id, name FROM groups WHERE person_id = ?", (person_id,)).fetchall()
|
||||
]
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"emails": sorted(set(person_accounts)),
|
||||
"groups": groups,
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_person(account: dict | None, person_id: str, name: str | None = None) -> dict | None:
|
||||
person = get_person(person_id)
|
||||
if person is None:
|
||||
return None
|
||||
conn = _conn()
|
||||
try:
|
||||
if name is not None:
|
||||
conn.execute("UPDATE persons SET name = ? WHERE id = ?", (name if name is not None else "", person_id))
|
||||
conn.commit()
|
||||
return get_person(person_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_person(account: dict | None, person_id: str) -> bool:
|
||||
person = get_person(person_id)
|
||||
if person is None:
|
||||
return False
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM articles WHERE person_id = ?", (person_id,))
|
||||
conn.execute("DELETE FROM article_group_access WHERE article_id IN (SELECT id FROM articles WHERE person_id = ?)", (person_id,))
|
||||
conn.execute("DELETE FROM group_members WHERE group_id IN (SELECT id FROM groups WHERE person_id = ?)", (person_id,))
|
||||
conn.execute("DELETE FROM groups WHERE person_id = ?", (person_id,))
|
||||
conn.execute("DELETE FROM person_accounts WHERE person_id = ?", (person_id,))
|
||||
conn.execute("DELETE FROM persons WHERE id = ?", (person_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_article(account: dict, person_id: str, article_type: str, title: str, content: str,
|
||||
visibility: str = "private", group_ids: list[str] | None = None) -> dict:
|
||||
if article_type not in ARTICLE_TYPES:
|
||||
raise ValueError("Invalid article type")
|
||||
if visibility not in ("private", "group", "public"):
|
||||
raise ValueError("Invalid visibility")
|
||||
if account is None:
|
||||
raise PermissionError("Not authenticated")
|
||||
if not has_right(account, "manage_articles"):
|
||||
if article_type != "notes":
|
||||
raise PermissionError("Normal users can only add notes")
|
||||
if person_id != account.get("person_id"):
|
||||
raise PermissionError("You can only add notes to your own person")
|
||||
article_id = uuid.uuid4().hex
|
||||
now = _now()
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO articles (id, person_id, article_type, title, content, visibility, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(article_id, person_id, article_type, title, content, visibility, now, now),
|
||||
)
|
||||
if group_ids:
|
||||
for gid in group_ids:
|
||||
conn.execute("INSERT INTO article_group_access (article_id, group_id) VALUES (?,?)", (article_id, gid))
|
||||
conn.commit()
|
||||
return get_article(article_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_article(article_id: str) -> dict | None:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM articles WHERE id = ?", (article_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
allowed_groups = [
|
||||
g["id"] for g in conn.execute(
|
||||
"SELECT g.id FROM groups g JOIN article_group_access aga ON aga.group_id = g.id WHERE aga.article_id = ?",
|
||||
(article_id,),
|
||||
).fetchall()
|
||||
]
|
||||
return {
|
||||
"id": row["id"],
|
||||
"person_id": row["person_id"],
|
||||
"article_type": row["article_type"],
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"visibility": row["visibility"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"allowed_groups": allowed_groups,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_articles(account: dict | None, person_id: str | None = None) -> list[dict]:
|
||||
conn = _conn()
|
||||
try:
|
||||
query = "SELECT a.* FROM articles a"
|
||||
params: list = []
|
||||
if person_id:
|
||||
query += " WHERE a.person_id = ?"
|
||||
params.append(person_id)
|
||||
query += " ORDER BY a.updated_at DESC, a.created_at DESC"
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
article = {
|
||||
"id": r["id"],
|
||||
"person_id": r["person_id"],
|
||||
"article_type": r["article_type"],
|
||||
"title": r["title"],
|
||||
"visibility": r["visibility"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
allowed_groups = [
|
||||
g["id"] for g in conn.execute(
|
||||
"SELECT g.id FROM groups g JOIN article_group_access aga ON aga.group_id = g.id WHERE aga.article_id = ?",
|
||||
(r["id"],),
|
||||
).fetchall()
|
||||
]
|
||||
article["allowed_groups"] = allowed_groups
|
||||
if _can_read_article(account, article):
|
||||
article["content"] = r["content"]
|
||||
else:
|
||||
article["content"] = ""
|
||||
out.append(article)
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _can_read_article(account: dict | None, article: dict) -> bool:
|
||||
if article["visibility"] == "public":
|
||||
return True
|
||||
if account is None:
|
||||
return False
|
||||
# You always see the articles of your own person.
|
||||
if article["person_id"] == account.get("person_id"):
|
||||
return True
|
||||
if article["visibility"] == "group":
|
||||
for gid in article.get("allowed_groups", []):
|
||||
if _account_in_group(account, gid):
|
||||
return True
|
||||
return False
|
||||
if article["visibility"] == "private":
|
||||
return has_right(account, "view_private") and article["person_id"] == account.get("person_id")
|
||||
return False
|
||||
|
||||
|
||||
def _account_in_group(account: dict, group_id: str) -> bool:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM group_members gm JOIN person_accounts pa ON pa.person_id = gm.person_id WHERE gm.group_id = ? AND pa.account_id = ?",
|
||||
(group_id, account["id"]),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_article(account: dict, article_id: str, title: str | None = None,
|
||||
content: str | None = None, visibility: str | None = None,
|
||||
group_ids: list[str] | None = None) -> dict | None:
|
||||
a = get_article(article_id)
|
||||
if a is None:
|
||||
return None
|
||||
if a["person_id"] != account.get("person_id") and not has_right(account, "manage_articles"):
|
||||
raise PermissionError("Not allowed to edit this article")
|
||||
conn = _conn()
|
||||
try:
|
||||
if title is not None:
|
||||
conn.execute("UPDATE articles SET title = ? WHERE id = ?", (title, article_id))
|
||||
if content is not None:
|
||||
conn.execute("UPDATE articles SET content = ? WHERE id = ?", (content, article_id))
|
||||
if visibility is not None:
|
||||
conn.execute("UPDATE articles SET visibility = ? WHERE id = ?", (visibility, article_id))
|
||||
conn.execute("DELETE FROM article_group_access WHERE article_id = ?", (article_id,))
|
||||
if group_ids is not None:
|
||||
conn.execute("DELETE FROM article_group_access WHERE article_id = ?", (article_id,))
|
||||
for gid in group_ids:
|
||||
conn.execute("INSERT INTO article_group_access (article_id, group_id) VALUES (?,?)", (article_id, gid))
|
||||
conn.execute("UPDATE articles SET updated_at = ? WHERE id = ?", (_now(), article_id))
|
||||
conn.commit()
|
||||
return get_article(article_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_article(account: dict, article_id: str) -> bool:
|
||||
a = get_article(article_id)
|
||||
if a is None:
|
||||
return False
|
||||
if a["person_id"] != account.get("person_id") and not has_right(account, "manage_articles"):
|
||||
raise PermissionError("Not allowed to delete this article")
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM article_group_access WHERE article_id = ?", (article_id,))
|
||||
conn.execute("DELETE FROM articles WHERE id = ?", (article_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_group(account: dict, person_id: str, name: str = "") -> dict:
|
||||
if not has_right(account, "manage_groups") and account.get("person_id") != person_id:
|
||||
raise PermissionError("Not allowed to create groups for this person")
|
||||
group_id = uuid.uuid4().hex
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("INSERT INTO groups (id, person_id, name, created_at) VALUES (?,?,?,?)",
|
||||
(group_id, person_id, name, _now()))
|
||||
conn.commit()
|
||||
return {"id": group_id, "person_id": person_id, "name": name}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_groups(account: dict | None, person_id: str | None = None) -> list[dict]:
|
||||
conn = _conn()
|
||||
try:
|
||||
query = "SELECT id, person_id, name, created_at FROM groups"
|
||||
params: list = []
|
||||
if person_id:
|
||||
query += " WHERE person_id = ?"
|
||||
params.append(person_id)
|
||||
query += " ORDER BY name, id"
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
visible = _visible_group_ids(conn, account)
|
||||
out = []
|
||||
for r in rows:
|
||||
if visible is not None and r["id"] not in visible:
|
||||
continue
|
||||
members = get_group_members(r["id"])
|
||||
out.append({
|
||||
"id": r["id"],
|
||||
"person_id": r["person_id"],
|
||||
"name": r["name"],
|
||||
"created_at": r["created_at"],
|
||||
"members": members,
|
||||
})
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _visible_group_ids(conn, account: dict | None):
|
||||
"""Group ids the account may see, or None for unrestricted (admins)."""
|
||||
if account is None:
|
||||
return set()
|
||||
if has_right(account, "manage_groups"):
|
||||
return None
|
||||
person_id = account.get("person_id")
|
||||
if not person_id:
|
||||
return set()
|
||||
ids = {row["id"] for row in conn.execute("SELECT id FROM groups WHERE person_id = ?", (person_id,)).fetchall()}
|
||||
ids |= {row["group_id"] for row in conn.execute("SELECT group_id FROM group_members WHERE person_id = ?", (person_id,)).fetchall()}
|
||||
return ids
|
||||
|
||||
|
||||
def group_is_visible_to(account: dict | None, group_id: str) -> bool:
|
||||
conn = _conn()
|
||||
try:
|
||||
visible = _visible_group_ids(conn, account)
|
||||
return visible is None or group_id in visible
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_group(group_id: str) -> dict | None:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute("SELECT id, person_id, name, created_at FROM groups WHERE id = ?", (group_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"person_id": row["person_id"],
|
||||
"name": row["name"],
|
||||
"created_at": row["created_at"],
|
||||
"members": get_group_members(group_id),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_group_members(group_id: str) -> list[dict]:
|
||||
conn = _conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT p.id, p.name FROM persons p JOIN group_members gm ON gm.person_id = p.id WHERE gm.group_id = ?",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_group_member(account: dict, group_id: str, person_id: str) -> dict | None:
|
||||
group = get_group(group_id)
|
||||
if group is None:
|
||||
return None
|
||||
if account is None:
|
||||
raise PermissionError("Not allowed to manage this group")
|
||||
allowed = has_right(account, "manage_groups") or group["person_id"] == account.get("person_id")
|
||||
if not allowed and account.get("person_id"):
|
||||
conn = _conn()
|
||||
try:
|
||||
allowed = conn.execute(
|
||||
"SELECT 1 FROM group_members WHERE group_id = ? AND person_id = ?",
|
||||
(group_id, account["person_id"]),
|
||||
).fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
if not allowed:
|
||||
raise PermissionError("You can only invite people to groups you own or belong to")
|
||||
person = get_person(person_id)
|
||||
if person is None:
|
||||
raise ValueError("Person not found")
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("INSERT OR IGNORE INTO group_members (group_id, person_id) VALUES (?,?)", (group_id, person_id))
|
||||
conn.commit()
|
||||
return get_group(group_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def remove_group_member(account: dict, group_id: str, person_id: str) -> dict | None:
|
||||
group = get_group(group_id)
|
||||
if group is None:
|
||||
return None
|
||||
if group["person_id"] != account.get("person_id") and not has_right(account, "manage_groups"):
|
||||
raise PermissionError("Not allowed to manage this group")
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM group_members WHERE group_id = ? AND person_id = ?", (group_id, person_id))
|
||||
conn.commit()
|
||||
return get_group(group_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_group(account: dict, group_id: str, name: str | None = None) -> dict | None:
|
||||
group = get_group(group_id)
|
||||
if group is None:
|
||||
return None
|
||||
if group["person_id"] != account.get("person_id") and not has_right(account, "manage_groups"):
|
||||
raise PermissionError("Not allowed to manage this group")
|
||||
conn = _conn()
|
||||
try:
|
||||
if name is not None:
|
||||
conn.execute("UPDATE groups SET name = ? WHERE id = ?", (name, group_id))
|
||||
conn.commit()
|
||||
return get_group(group_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_group(account: dict, group_id: str) -> bool:
|
||||
group = get_group(group_id)
|
||||
if group is None:
|
||||
return False
|
||||
if group["person_id"] != account.get("person_id") and not has_right(account, "manage_groups"):
|
||||
raise PermissionError("Not allowed to manage this group")
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM article_group_access WHERE group_id = ?", (group_id,))
|
||||
conn.execute("DELETE FROM group_members WHERE group_id = ?", (group_id,))
|
||||
conn.execute("DELETE FROM groups WHERE id = ?", (group_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def grant_right(account: dict, target_account_id: str, right_name: str):
|
||||
if not has_right(account, "manage_accounts"):
|
||||
raise PermissionError("Not allowed to manage account rights")
|
||||
if right_name not in RIGHTS:
|
||||
raise ValueError("Unknown right")
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("INSERT OR IGNORE INTO account_rights (account_id, right_name) VALUES (?,?)",
|
||||
(target_account_id, right_name))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revoke_right(account: dict, target_account_id: str, right_name: str):
|
||||
if not has_right(account, "manage_accounts"):
|
||||
raise PermissionError("Not allowed to manage account rights")
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM account_rights WHERE account_id = ? AND right_name = ?",
|
||||
(target_account_id, right_name))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_rights(account: dict | None, account_id: str | None = None) -> list[dict]:
|
||||
if account is None or not has_right(account, "manage_accounts"):
|
||||
raise PermissionError("Only admins can view account rights")
|
||||
conn = _conn()
|
||||
try:
|
||||
query = "SELECT a.id, a.email, ar.right_name FROM accounts a LEFT JOIN account_rights ar ON ar.account_id = a.id"
|
||||
params: list = []
|
||||
if account_id:
|
||||
query += " WHERE a.id = ?"
|
||||
params.append(account_id)
|
||||
query += " ORDER BY a.email, a.id, ar.right_name"
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
out: dict = {}
|
||||
for r in rows:
|
||||
key = r["id"]
|
||||
if key not in out:
|
||||
out[key] = {"id": r["id"], "email": r["email"], "rights": []}
|
||||
if r["right_name"]:
|
||||
out[key]["rights"].append(r["right_name"])
|
||||
return list(out.values())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def attach_email_to_person(account: dict, person_id: str, email: str) -> dict | None:
|
||||
if account is None or not has_right(account, "manage_persons"):
|
||||
raise PermissionError("Only admins can change a person's emails")
|
||||
target = _find_account_by_email(email)
|
||||
if not target:
|
||||
raise ValueError("No account exists for " + email)
|
||||
return link_account_to_person(account, person_id, target["id"])
|
||||
|
||||
|
||||
def detach_email_from_person(account: dict, person_id: str, email: str) -> dict | None:
|
||||
if account is None or not has_right(account, "manage_persons"):
|
||||
raise PermissionError("Only admins can change a person's emails")
|
||||
conn = _conn()
|
||||
try:
|
||||
target = conn.execute(
|
||||
"SELECT id FROM accounts WHERE lower(email) = lower(?)", (email,)
|
||||
).fetchone()
|
||||
if not target:
|
||||
raise ValueError("No account exists for " + email)
|
||||
conn.execute("DELETE FROM person_accounts WHERE person_id = ? AND account_id = ?",
|
||||
(person_id, target["id"]))
|
||||
conn.commit()
|
||||
return get_person(person_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_account_by_id(account_id: str) -> dict | None:
|
||||
conn = _conn()
|
||||
try:
|
||||
row = conn.execute("SELECT id, email FROM accounts WHERE id = ?", (account_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
rights = [
|
||||
r["right_name"] for r in conn.execute(
|
||||
"SELECT right_name FROM account_rights WHERE account_id = ?",
|
||||
(account_id,),
|
||||
).fetchall()
|
||||
]
|
||||
person = conn.execute(
|
||||
"SELECT person_id FROM person_accounts WHERE account_id = ? ORDER BY person_id LIMIT 1",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"email": row["email"],
|
||||
"rights": rights,
|
||||
"person_id": person["person_id"] if person else None,
|
||||
"is_admin": ADMIN_RIGHT in rights,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,291 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
# Use an isolated DB per test run
|
||||
TEST_DB = "instance/test_integrity.sqlite"
|
||||
|
||||
os.environ["WHO2_DB_PATH"] = TEST_DB
|
||||
|
||||
import backend.storage as storage
|
||||
|
||||
|
||||
def _clean():
|
||||
if os.path.exists(TEST_DB):
|
||||
os.remove(TEST_DB)
|
||||
if os.path.exists(TEST_DB + "-journal"):
|
||||
os.remove(TEST_DB + "-journal")
|
||||
if os.path.exists(TEST_DB + "-wal"):
|
||||
os.remove(TEST_DB + "-wal")
|
||||
if os.path.exists(TEST_DB + "-shm"):
|
||||
os.remove(TEST_DB + "-shm")
|
||||
|
||||
|
||||
def _reset():
|
||||
_clean()
|
||||
storage.init_db()
|
||||
|
||||
|
||||
def test_create_and_authenticate():
|
||||
_reset()
|
||||
email = "alice@example.com"
|
||||
password = "Sup3rS3cret"
|
||||
acc = storage.create_account(email, password)
|
||||
assert acc["email"] == email
|
||||
# first account becomes the bootstrap admin
|
||||
assert acc["is_admin"] is True
|
||||
assert storage.authenticate(email, password) is not None
|
||||
assert storage.authenticate(email, "WRONG") is None
|
||||
assert storage.authenticate("noone@example.com", password) is None
|
||||
|
||||
|
||||
def test_only_admins_create_persons():
|
||||
_reset()
|
||||
admin = storage.create_account("admin@x.com", "pw")
|
||||
user = storage.create_account("user@x.com", "pw")
|
||||
assert storage.is_admin(admin)
|
||||
assert not storage.is_admin(user)
|
||||
|
||||
try:
|
||||
storage.create_person(user, "Nope")
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# admin creates a person and gives it the user's email address
|
||||
person = storage.create_person_with_account(admin, "User Person", "user@x.com")
|
||||
refreshed = storage.get_account_by_id(user["id"])
|
||||
assert refreshed["person_id"] == person["id"]
|
||||
assert person["emails"] == ["user@x.com"]
|
||||
|
||||
# a non-admin cannot link or unlink emails either
|
||||
try:
|
||||
storage.attach_email_to_person(user, person["id"], "admin@x.com")
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def test_person_account_linking():
|
||||
_reset()
|
||||
admin = storage.create_account("admin@x.com", "pw")
|
||||
# separate account so linking can be exercised explicitly
|
||||
a = storage.create_account("a@x.com", "pw")
|
||||
p = storage.create_person(admin, "Alice")
|
||||
storage.link_account_to_person(admin, p["id"], a["id"])
|
||||
person = storage.get_person(p["id"])
|
||||
assert person["emails"] == ["a@x.com"]
|
||||
|
||||
# one account can only ever be linked to one person
|
||||
p2 = storage.create_person(admin, "Bob")
|
||||
try:
|
||||
storage.attach_email_to_person(admin, p2["id"], "a@x.com")
|
||||
assert False, "expected ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
storage.attach_email_to_person(admin, p2["id"], "admin@x.com")
|
||||
assert "admin@x.com" in storage.get_person(p2["id"])["emails"]
|
||||
# detaching works and is admin-only
|
||||
storage.detach_email_from_person(admin, p2["id"], "admin@x.com")
|
||||
assert storage.get_person(p2["id"])["emails"] == []
|
||||
try:
|
||||
storage.detach_email_from_person(a, p["id"], "a@x.com")
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def test_article_visibility():
|
||||
_reset()
|
||||
owner = storage.create_account("owner@x.com", "pw")
|
||||
storage.bootstrap_admin(owner["id"])
|
||||
owner["rights"] = list(storage.RIGHTS.keys())
|
||||
admin_person = storage.create_person(owner, "Owner")
|
||||
owner["person_id"] = admin_person["id"]
|
||||
other = storage.create_account("other@x.com", "pw")
|
||||
storage.grant_right(owner, other["id"], "view_private")
|
||||
other["person_id"] = storage.create_person_with_account(owner, "Other", "other@x.com")["id"]
|
||||
other["rights"] = ["view_private"]
|
||||
|
||||
storage.create_article(
|
||||
owner, owner["person_id"], "notes", "Secret",
|
||||
"hidden content", visibility="private",
|
||||
)
|
||||
articles = storage.list_articles(other, owner["person_id"])
|
||||
assert len(articles) == 0 or articles[0].get("content") == ""
|
||||
storage.grant_right(owner, owner["id"], "view_private")
|
||||
articles = storage.list_articles(owner, owner["person_id"])
|
||||
assert any(a["title"] == "Secret" and a.get("content") == "hidden content" for a in articles)
|
||||
|
||||
|
||||
def test_group_based_visibility():
|
||||
_reset()
|
||||
owner = storage.create_account("owner@x.com", "pw")
|
||||
storage.bootstrap_admin(owner["id"])
|
||||
owner["rights"] = list(storage.RIGHTS.keys())
|
||||
owner["person_id"] = storage.create_person(owner, "Owner")["id"]
|
||||
reader = storage.create_account("reader@x.com", "pw")
|
||||
storage.grant_right(owner, reader["id"], "view_private")
|
||||
reader["person_id"] = storage.create_person_with_account(owner, "Reader", "reader@x.com")["id"]
|
||||
reader["rights"] = ["view_private"]
|
||||
|
||||
group = storage.create_group(owner, owner["person_id"], "Team")
|
||||
storage.add_group_member(owner, group["id"], reader["person_id"])
|
||||
|
||||
storage.create_article(
|
||||
owner, owner["person_id"], "notes", "Shared",
|
||||
"shared content", visibility="group",
|
||||
group_ids=[group["id"]],
|
||||
)
|
||||
noaccess = storage.list_articles(None, owner["person_id"])
|
||||
assert not any(a["title"] == "Shared" and a.get("content") == "shared content" for a in noaccess)
|
||||
|
||||
view = storage.list_articles(reader, owner["person_id"])
|
||||
assert any(a["title"] == "Shared" and a.get("content") == "shared content" for a in view)
|
||||
|
||||
|
||||
def test_normal_user_only_notes_and_own_groups():
|
||||
_reset()
|
||||
admin = storage.create_account("admin@x.com", "pw")
|
||||
me = storage.create_person_with_account(admin, "Me", "me@x.com", "pw")
|
||||
friend = storage.create_person_with_account(admin, "Friend", "friend@x.com", "pw")
|
||||
user = storage.authenticate("me@x.com", "pw")
|
||||
assert user["person_id"] == me["id"]
|
||||
assert not user["is_admin"]
|
||||
|
||||
# a normal user may add notes to their own person only
|
||||
storage.create_article(user, me["id"], "notes", "Note", "hello", visibility="private")
|
||||
try:
|
||||
storage.create_article(user, me["id"], "links", "Link", "http://x")
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
try:
|
||||
storage.create_article(user, friend["id"], "notes", "Peek", "nope")
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# own group + invite a friend into it
|
||||
g = storage.create_group(user, me["id"], "My Team")
|
||||
storage.add_group_member(user, g["id"], friend["id"])
|
||||
assert any(m["id"] == friend["id"] for m in storage.get_group(g["id"])["members"])
|
||||
|
||||
friend_user = storage.authenticate("friend@x.com", "pw")
|
||||
admin_person = storage.create_person(admin, "Admin")
|
||||
foreign = storage.create_group(admin, admin_person["id"], "Admins")
|
||||
|
||||
# the friend is a member, so they see the group but not foreign groups
|
||||
assert {x["id"] for x in storage.list_groups(friend_user)} == {g["id"]}
|
||||
assert foreign["id"] not in {x["id"] for x in storage.list_groups(friend_user)}
|
||||
assert not storage.group_is_visible_to(friend_user, foreign["id"])
|
||||
|
||||
# a member may invite, a bystander may not
|
||||
try:
|
||||
storage.add_group_member(friend_user, foreign["id"], friend_user["person_id"])
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# only admins may read the rights list
|
||||
try:
|
||||
storage.list_rights(user)
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def test_ensure_admin_promotes_oldest_account():
|
||||
_reset()
|
||||
storage.create_account("first@x.com", "pw")
|
||||
storage.create_account("second@x.com", "pw")
|
||||
conn = storage._conn()
|
||||
conn.execute("DELETE FROM account_rights") # simulate a database from before roles existed
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert storage.authenticate("first@x.com", "pw")["is_admin"] is False
|
||||
storage.ensure_admin()
|
||||
assert storage.authenticate("first@x.com", "pw")["is_admin"] is True
|
||||
assert storage.authenticate("second@x.com", "pw")["is_admin"] is False
|
||||
|
||||
|
||||
def test_article_type_constraint():
|
||||
_reset()
|
||||
acc = storage.create_account("x@x.com", "pw")
|
||||
acc["person_id"] = storage.create_person(acc, "X")["id"]
|
||||
try:
|
||||
storage.create_article(acc, acc["person_id"], "bogus", "T", "C")
|
||||
assert False, "expected ValueError"
|
||||
except ValueError as e:
|
||||
assert "Invalid article type" in str(e)
|
||||
|
||||
|
||||
def test_right_management():
|
||||
_reset()
|
||||
admin = storage.create_account("admin@x.com", "pw")
|
||||
storage.bootstrap_admin(admin["id"])
|
||||
admin["rights"] = list(storage.RIGHTS.keys())
|
||||
admin["person_id"] = storage.create_person(admin, "Admin")["id"]
|
||||
storage.grant_right(admin, admin["id"], "view_private")
|
||||
storage.grant_right(admin, admin["id"], "manage_articles")
|
||||
rights = storage.list_rights(admin, admin["id"])
|
||||
assert any(r["id"] == admin["id"] and "view_private" in r["rights"] for r in rights)
|
||||
storage.revoke_right(admin, admin["id"], "manage_articles")
|
||||
rights = storage.list_rights(admin)
|
||||
assert any(r["id"] == admin["id"] and "view_private" in r["rights"] for r in rights)
|
||||
assert not any(r["id"] == admin["id"] and "manage_articles" in r["rights"] for r in rights)
|
||||
|
||||
|
||||
def test_delete_person_cascades():
|
||||
_reset()
|
||||
acc = storage.create_account("x@x.com", "pw")
|
||||
acc["person_id"] = storage.create_person(acc, "X")["id"]
|
||||
g = storage.create_group(acc, acc["person_id"], "G")
|
||||
storage.add_group_member(acc, g["id"], acc["person_id"])
|
||||
storage.create_article(acc, acc["person_id"], "notes", "T", "C")
|
||||
ok = storage.delete_person(acc, acc["person_id"])
|
||||
assert ok
|
||||
assert storage.get_person(acc["person_id"]) is None
|
||||
assert storage.get_group(g["id"]) is None
|
||||
|
||||
|
||||
def run_all():
|
||||
tests = [
|
||||
test_create_and_authenticate,
|
||||
test_only_admins_create_persons,
|
||||
test_person_account_linking,
|
||||
test_article_visibility,
|
||||
test_group_based_visibility,
|
||||
test_normal_user_only_notes_and_own_groups,
|
||||
test_ensure_admin_promotes_oldest_account,
|
||||
test_article_type_constraint,
|
||||
test_right_management,
|
||||
test_delete_person_cascades,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
last_err = None
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
passed += 1
|
||||
print("PASS", t.__name__)
|
||||
except AssertionError as e:
|
||||
failed += 1
|
||||
last_err = e
|
||||
print("FAIL", t.__name__, "-", str(e))
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
last_err = e
|
||||
print("ERROR", t.__name__, "-", str(e))
|
||||
_clean()
|
||||
if failed:
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
raise SystemExit(1)
|
||||
print(f"\nAll {passed} tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_all())
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Who² contact directory
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/srv/serverjonas/who2
|
||||
Environment=WHO2_DB_PATH=instance/who2.sqlite
|
||||
Environment=FLASK_SECRET_KEY=replace-me-in-production
|
||||
ExecStart=/usr/bin/python3 /srv/serverjonas/who2/run.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,3 @@
|
||||
python-dotenv>=1.0
|
||||
Flask>=3.0
|
||||
Jinja2>=3.1
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
|
||||
from werkzeug.serving import WSGIRequestHandler
|
||||
|
||||
from backend.app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
class RedactingRequestHandler(WSGIRequestHandler):
|
||||
"""Werkzeug access log handler that never writes query strings to the log.
|
||||
|
||||
Credentials must never be sent in URLs, but as defense in depth this also
|
||||
prevents any accidental query value (tokens, emails, passwords) from being
|
||||
recorded in the server log.
|
||||
"""
|
||||
|
||||
def log_request(self, code="-", size="-"):
|
||||
try:
|
||||
target = self.path or self.requestline.split(" ")[1]
|
||||
except Exception:
|
||||
target = self.requestline
|
||||
if "?" in target:
|
||||
target = target.split("?", 1)[0] + "?[redacted]"
|
||||
try:
|
||||
msg = f"{self.command} {target} {self.request_version}"
|
||||
except Exception:
|
||||
msg = self.requestline
|
||||
code = str(code)
|
||||
level = "warning" if code[:1] in ("4", "5") else "info"
|
||||
self.log(level, '"%s" %s %s', msg, code, size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.environ.get("FLASK_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("FLASK_PORT", "8085"))
|
||||
debug = os.environ.get("FLASK_DEBUG", "false").lower() == "true"
|
||||
app.run(host=host, port=port, debug=debug, request_handler=RedactingRequestHandler)
|
||||
@@ -0,0 +1,168 @@
|
||||
SPECIFICATION (German)
|
||||
|
||||
Project role
|
||||
- This is an online contact directory / personal archive, not a transaction
|
||||
ledger. Its main job is to store and share structured information about
|
||||
people and their related items under controlled access.
|
||||
|
||||
Core concepts
|
||||
- Account (Login identity)
|
||||
* One account is permanently tied to exactly one email address.
|
||||
* Each email address has its own password.
|
||||
* Different accounts can have different rights.
|
||||
* An account is the login boundary, not the data owner in every case.
|
||||
|
||||
- Person
|
||||
* A person is the central real-world entity.
|
||||
* One person can be linked to multiple email addresses / accounts but every account /e-mail can only be linke to oner person.
|
||||
* A person is not owned by any single account.
|
||||
|
||||
- Article
|
||||
* Every person has articles.
|
||||
* Articles exist in different article types.
|
||||
* Article visibility is not global.
|
||||
* Articles are only visible to people who hold the required rights and/or
|
||||
belong to the required group(s).
|
||||
|
||||
- Article types
|
||||
* Examples of article types:
|
||||
- Contact details
|
||||
- Notes
|
||||
- Profile text
|
||||
- Documents
|
||||
- Links
|
||||
- Phone numbers
|
||||
- Addresses
|
||||
- Social profiles
|
||||
* The set of article types is fixed for the implementation, but the system
|
||||
treats them as distinct, named categories.
|
||||
|
||||
- Groups
|
||||
* Groups are person-dependent.
|
||||
* Groups are NOT email-dependent.
|
||||
* A group describes a set of persons.
|
||||
* Group membership follows the person, regardless of which account or email
|
||||
is used to access the system.
|
||||
|
||||
- Rights
|
||||
* Rights are fixed, named capabilities.
|
||||
* Rights control what can be seen, created, edited, or removed.
|
||||
* Rights are used together with group membership to decide access.
|
||||
|
||||
Relationships
|
||||
- Account <-> email
|
||||
* One account, one email address, fixed binding.
|
||||
- Email <-> password
|
||||
* Each email has its own password.
|
||||
- Person <-> accounts / emails
|
||||
* One person may relate to several emails/accounts.
|
||||
- Person <-> groups
|
||||
* Groups are built around persons.
|
||||
- Person <-> articles
|
||||
* A person owns articles of various types.
|
||||
- Articles <-> readers
|
||||
* Reading and other actions depend on rights.
|
||||
* Reading can also depend on group membership.
|
||||
- Groups <-> persons
|
||||
* Membership is defined by persons, not by accounts.
|
||||
|
||||
Access model
|
||||
- Visibility rule
|
||||
* An article is shown only if the requesting party has the required right
|
||||
AND is allowed by the relevant group constraints.
|
||||
* If either condition is not met, the article is not visible.
|
||||
- Purpose of the rule
|
||||
* Some articles are private.
|
||||
* Some articles are shared only with selected persons or groups.
|
||||
* Group-based visibility is independent from email/account identity.
|
||||
|
||||
Account scope
|
||||
- Login
|
||||
* Accounts are used to authenticate a user.
|
||||
* Authentication is tied to an email address and password.
|
||||
- Rights assignment
|
||||
* Different accounts can be granted different rights.
|
||||
* Rights are assigned at account level or in relation to persons/groups,
|
||||
depending on the access rule for the article.
|
||||
|
||||
Person scope
|
||||
- Data ownership
|
||||
* A person is the owner of their own articles.
|
||||
* A person can have multiple linked accounts/emails.
|
||||
- Shared context
|
||||
* Other parties may access articles only through the rights/group model.
|
||||
|
||||
Groups and persons
|
||||
- Membership
|
||||
* Groups contain persons.
|
||||
* Group membership does not depend on the email used for login.
|
||||
- Example interpretation
|
||||
* If a person belongs to a group, then that group relation follows the
|
||||
person, not a specific email account.
|
||||
|
||||
Article model
|
||||
- Each article has:
|
||||
* An article type
|
||||
* Content belonging to that type
|
||||
* An owner person context
|
||||
* Access rules based on rights and group membership
|
||||
- Article types are distinct categories, not free text, so that access can be
|
||||
handled per type in a structured way.
|
||||
|
||||
Security and privacy goals
|
||||
- Private by default where appropriate
|
||||
* Not everything is publicly visible.
|
||||
- Controlled sharing
|
||||
* Sharing happens via rights and groups, not via open access.
|
||||
- Person-centric sharing
|
||||
* Visibility follows persons and groups, not only the account that logs in.
|
||||
- Separation of concerns
|
||||
* Login (account/email/password) is separate from data ownership (person).
|
||||
* This allows one person to be reachable through several emails without
|
||||
merging all content into one account identity.
|
||||
|
||||
Functional expectations
|
||||
- Account management
|
||||
* Create account for an email address with a password.
|
||||
* The email address is fixed for that account.
|
||||
- Person management
|
||||
* Represent persons independently of a single account.
|
||||
* Link multiple emails/accounts to one person where needed.
|
||||
- Article management
|
||||
* Create, view, edit, and remove articles for a person.
|
||||
* Store articles by type.
|
||||
* Enforce visibility by rights and group membership.
|
||||
- Group management
|
||||
* Groups are defined around persons.
|
||||
* Membership is used for visibility/access decisions.
|
||||
- Rights handling
|
||||
* Rights decide what is allowed for an account/requesting party.
|
||||
* Rights work together with group-based visibility rules.
|
||||
|
||||
Non-goals for this version
|
||||
- This is not a payment system.
|
||||
- This is not a transaction ledger.
|
||||
- This is not primarily an account-consumption platform; accounts exist for
|
||||
login and rights, but persons and their articles are the main content.
|
||||
|
||||
Level of detail required from the specification
|
||||
- Account model
|
||||
* Fixed email per account.
|
||||
* Password per email.
|
||||
* Different rights per account.
|
||||
- Person model
|
||||
* Persons as first-class entities.
|
||||
* Support for multiple emails per person.
|
||||
- Article model
|
||||
* Article types.
|
||||
* Ownership context.
|
||||
* Rights/group-based visibility.
|
||||
- Group model
|
||||
* Person-dependent groups.
|
||||
* No email-dependent groups.
|
||||
- Access behavior
|
||||
* Explicit visibility rules.
|
||||
* No open global visibility for restricted articles.
|
||||
|
||||
This specification intentionally describes what the system is and what rules
|
||||
it follows, so that later implementation matches the intended model exactly.
|
||||
Reference in New Issue
Block a user