commit b05c7310a6dc9ee2c0b24b97740b8d4ed7320398 Author: ryan Date: Sat Aug 15 11:15:31 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f3a9b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# --------------------------------------------------------------------------- +# Environment & secrets +# --------------------------------------------------------------------------- +# config.py loads .env via load_dotenv(). Under APP_ROLE=full this carries +# APP_SECRET, DATABASE_URL, OIDC_CLIENT_SECRET and SMTP credentials. +.env +.env.* +*.env +!.env.example + +# Kerberos / FreeIPA -- ipa.py kinit's with a keytab +*.keytab +krb5cc_* +*.ccache + +# TLS material +*.pem +*.key +*.crt +*.p12 +*.pfx + +# The live unit carries host-specific paths and the local account name. +# Ship the template instead; see sticknife-home.service.example. +*.service +!*.service.example + +# --------------------------------------------------------------------------- +# Databases +# --------------------------------------------------------------------------- +# app/schema.sql is the tracked schema; the populated database is not. +*.db +*.db-journal +*.db-wal +*.db-shm +*.sqlite +*.sqlite3 + +# --------------------------------------------------------------------------- +# Python +# --------------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*.egg-info/ +*.egg +dist/ +build/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# --------------------------------------------------------------------------- +# Virtual environments +# --------------------------------------------------------------------------- +venv/ +venv*/ +.venv/ + +# --------------------------------------------------------------------------- +# Logs +# --------------------------------------------------------------------------- +*.log +logs/ + +# --------------------------------------------------------------------------- +# IDE & editor +# --------------------------------------------------------------------------- +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# --------------------------------------------------------------------------- +# Backup & temp files +# --------------------------------------------------------------------------- +*.bak +*.tmp +*.temp diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..b563ee5 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Sticknife registration app.""" + diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..c5795c5 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,6 @@ +from .server import main + + +if __name__ == "__main__": + main() + diff --git a/app/authentik.py b/app/authentik.py new file mode 100644 index 0000000..92458bf --- /dev/null +++ b/app/authentik.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +from .config import Config + + +@dataclass +class AuthentikSyncResult: + ok: bool + message: str + + +def trigger_ldap_sync(config: Config) -> AuthentikSyncResult: + if not config.authentik_sync_enabled: + return AuthentikSyncResult(True, "authentik sync disabled") + if not config.authentik_ldap_source_slugs: + return AuthentikSyncResult(False, "authentik sync enabled but AUTHENTIK_LDAP_SOURCE_SLUGS is empty") + + cmd = [ + "docker", + "exec", + "-d", + config.authentik_sync_container, + "ak", + "ldap_sync", + *config.authentik_ldap_source_slugs, + ] + try: + subprocess.run(cmd, check=True, text=True, capture_output=True, timeout=10) + except subprocess.TimeoutExpired: + return AuthentikSyncResult(False, "authentik LDAP sync trigger timed out") + except subprocess.CalledProcessError as exc: + detail = exc.stderr.strip() or exc.stdout.strip() or str(exc) + return AuthentikSyncResult(False, f"authentik LDAP sync trigger failed: {detail}") + return AuthentikSyncResult(True, f"triggered authentik LDAP sync for {', '.join(config.authentik_ldap_source_slugs)}") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..01e0ac5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import os +import secrets +from dataclasses import dataclass +from pathlib import Path + + +def load_dotenv(path: str = ".env") -> None: + env_path = Path(path) + if not env_path.exists(): + return + for raw_line in env_path.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def _bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def _csv(name: str) -> set[str]: + return { + item.strip().lower() + for item in os.environ.get(name, "").split(",") + if item.strip() + } + + +#: A "home" deployment serves the public landing page and nothing else: no database, no session, +#: no OIDC. Everything that needs an identity lives on the "full" deployment at auth_base_url. +ROLES = {"full", "home"} + + +@dataclass(frozen=True) +class Config: + role: str + auth_base_url: str + base_url: str + host: str + port: int + secret: str + cookie_secure: bool + database_url: str + trusted_domains: set[str] + default_ipa_group: str + app_admin_emails: set[str] + admin_groups: set[str] + oidc_issuer: str + oidc_client_id: str + oidc_client_secret: str + oidc_scopes: str + smtp_host: str + smtp_port: int + smtp_username: str + smtp_password: str + smtp_from: str + smtp_starttls: bool + smtp_ssl: bool + ipa_mode: str + ipa_principal: str + ipa_keytab: str + ipa_default_shell: str + ipa_home_root: str + authentik_sync_enabled: bool + authentik_sync_container: str + authentik_ldap_source_slugs: tuple[str, ...] + + +def get_config() -> Config: + load_dotenv() + role = os.environ.get("APP_ROLE", "full").strip().lower() + if role not in ROLES: + raise SystemExit(f"APP_ROLE must be one of {sorted(ROLES)}, got {role!r}") + base_url = os.environ.get("APP_BASE_URL", "http://127.0.0.1:8080").rstrip("/") + database_url = os.environ.get("DATABASE_URL", "") + if role != "home" and not database_url: + raise SystemExit("DATABASE_URL is required unless APP_ROLE=home") + return Config( + role=role, + # Where the pages this deployment does not serve actually live. A "full" deployment serves + # them itself, so its own base URL is the right answer. + auth_base_url=os.environ.get("AUTH_BASE_URL", base_url).rstrip("/"), + base_url=base_url, + host=os.environ.get("APP_HOST", "127.0.0.1"), + port=int(os.environ.get("APP_PORT", "8080")), + secret=os.environ.get("APP_SECRET", secrets.token_hex(32)), + cookie_secure=_bool("COOKIE_SECURE", False), + database_url=database_url, + trusted_domains=_csv("TRUSTED_EMAIL_DOMAINS"), + default_ipa_group=os.environ.get("DEFAULT_IPA_GROUP", "sticknife_users"), + app_admin_emails=_csv("APP_ADMIN_EMAILS"), + admin_groups=_csv("APP_ADMIN_GROUPS") or {"sticknife_admins"}, + oidc_issuer=os.environ.get("OIDC_ISSUER", "https://auth.sticknife.com/application/o/charon/").rstrip("/"), + oidc_client_id=os.environ.get("OIDC_CLIENT_ID", ""), + oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""), + oidc_scopes=os.environ.get("OIDC_SCOPES", "openid profile email groups"), + smtp_host=os.environ.get("SMTP_HOST", "mail.sticknife.com"), + smtp_port=int(os.environ.get("SMTP_PORT", "587")), + smtp_username=os.environ.get("SMTP_USERNAME", ""), + smtp_password=os.environ.get("SMTP_PASSWORD", ""), + smtp_from=os.environ.get("SMTP_FROM", "accounts@sticknife.com"), + smtp_starttls=_bool("SMTP_STARTTLS", True), + smtp_ssl=_bool("SMTP_SSL", False), + ipa_mode=os.environ.get("STICKNIFE_IPA_PROVISION_MODE", "dry-run"), + ipa_principal=os.environ.get("IPA_PRINCIPAL", ""), + ipa_keytab=os.environ.get("IPA_KEYTAB", ""), + ipa_default_shell=os.environ.get("IPA_DEFAULT_SHELL", "/bin/bash"), + ipa_home_root=os.environ.get("IPA_HOME_ROOT", "/home"), + authentik_sync_enabled=_bool("AUTHENTIK_SYNC_ENABLED", False), + authentik_sync_container=os.environ.get("AUTHENTIK_SYNC_CONTAINER", "authentik-worker-1"), + authentik_ldap_source_slugs=tuple(sorted(_csv("AUTHENTIK_LDAP_SOURCE_SLUGS"))), + ) + diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..522b82b --- /dev/null +++ b/app/db.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + + +def sql_literal(value: object) -> str: + if value is None: + return "NULL" + if isinstance(value, bool): + return "TRUE" if value else "FALSE" + text = str(value) + return "'" + text.replace("'", "''") + "'" + + +@dataclass +class Database: + url: str + + def execute(self, sql: str) -> None: + subprocess.run( + ["psql", self.url, "-X", "-v", "ON_ERROR_STOP=1", "-q"], + input=sql, + text=True, + check=True, + ) + + def rows(self, sql: str) -> list[dict[str, str | None]]: + result = subprocess.run( + [ + "psql", + self.url, + "-X", + "-v", + "ON_ERROR_STOP=1", + "-q", + "-P", + "footer=off", + "-A", + "-F", + "\t", + "-c", + sql, + ], + text=True, + check=True, + capture_output=True, + ) + lines = [line for line in result.stdout.splitlines() if line.strip()] + if not lines: + return [] + headers = lines[0].split("\t") + out: list[dict[str, str | None]] = [] + for line in lines[1:]: + values = line.split("\t") + out.append( + { + key: None if idx >= len(values) or values[idx] == "" else values[idx] + for idx, key in enumerate(headers) + } + ) + return out + + def one(self, sql: str) -> dict[str, str | None] | None: + rows = self.rows(sql) + return rows[0] if rows else None + diff --git a/app/ipa.py b/app/ipa.py new file mode 100644 index 0000000..2c11302 --- /dev/null +++ b/app/ipa.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +from dataclasses import dataclass + +from .config import Config + + +@dataclass +class IpaResult: + ok: bool + message: str + + +def kinit(config: Config) -> None: + if config.ipa_principal and config.ipa_keytab: + subprocess.run( + ["kinit", "-kt", config.ipa_keytab, config.ipa_principal], + check=True, + text=True, + capture_output=True, + ) + + +def create_user(config: Config, user: dict[str, str | None]) -> IpaResult: + username = user["username"] or "" + email = user["email"] or "" + full_name = user["full_name"] or username + parts = full_name.split(" ", 1) + first_name = parts[0] + last_name = parts[1] if len(parts) > 1 else username + + if config.ipa_mode != "cli": + return IpaResult(True, f"dry-run: would create IPA user {username} <{email}>") + + kinit(config) + + add_cmd = [ + "ipa", + "user-add", + username, + "--first", + first_name, + "--last", + last_name, + "--cn", + full_name, + "--email", + email, + "--shell", + config.ipa_default_shell, + "--homedir", + f"{config.ipa_home_root.rstrip('/')}/{username}", + "--random", + "--user-auth-type", + "password", + ] + try: + subprocess.run(add_cmd, check=True, text=True, capture_output=True) + except subprocess.CalledProcessError as exc: + return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc)) + return IpaResult(True, f"created IPA user {username}") + + +def add_default_group(config: Config, username: str) -> IpaResult: + if config.ipa_mode != "cli": + return IpaResult(True, f"dry-run: would add {username} to {config.default_ipa_group}") + try: + kinit(config) + subprocess.run( + ["ipa", "group-add-member", config.default_ipa_group, "--users", username], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc)) + return IpaResult(True, f"added {username} to {config.default_ipa_group}") + + +def set_user_enabled(config: Config, username: str, enabled: bool) -> IpaResult: + action = "enable" if enabled else "disable" + if config.ipa_mode != "cli": + return IpaResult(True, f"dry-run: would {action} IPA user {username}") + try: + kinit(config) + subprocess.run( + ["ipa", f"user-{action}", username], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc)) + return IpaResult(True, f"{action}d IPA user {username}") + + +def provision_user(config: Config, user: dict[str, str | None], *, active: bool = True) -> IpaResult: + username = user["username"] or "" + created = create_user(config, user) + if not created.ok: + return created + + messages = [created.message] + if active: + grouped = add_default_group(config, username) + messages.append(grouped.message) + if not grouped.ok: + return IpaResult(False, "; ".join(messages)) + else: + disabled = set_user_enabled(config, username, False) + messages.append(disabled.message) + if not disabled.ok: + return IpaResult(False, "; ".join(messages)) + return IpaResult(True, "; ".join(messages)) + + +def approve_user(config: Config, username: str) -> IpaResult: + enabled = set_user_enabled(config, username, True) + if not enabled.ok: + return enabled + grouped = add_default_group(config, username) + if not grouped.ok: + return IpaResult(False, f"{enabled.message}; {grouped.message}") + return IpaResult(True, f"{enabled.message}; {grouped.message}") + + +def set_password(config: Config, username: str, password: str) -> IpaResult: + if config.ipa_mode != "cli": + return IpaResult(True, f"dry-run: would set IPA password for {username}") + try: + kinit(config) + subprocess.run( + ["ipa", "passwd", username], + input=f"{password}\n{password}\n", + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc)) + return IpaResult(True, f"set IPA password for {username}") + + +def email_matches(config: Config, username: str, email: str) -> bool: + try: + kinit(config) + result = subprocess.run( + ["ipa", "user-show", username], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError: + return False + expected = f"email address: {email.lower()}" + return expected in result.stdout.lower() + + +def verify_password(config: Config, username: str, password: str) -> IpaResult: + realm = config.ipa_principal.rsplit("@", 1)[-1] if "@" in config.ipa_principal else "POTTERNET.LAN" + principal = f"{username}@{realm}" + with tempfile.NamedTemporaryFile(prefix="charon-krb5cc-") as cache: + env = os.environ.copy() + env["KRB5CCNAME"] = cache.name + try: + subprocess.run( + ["kinit", principal], + input=f"{password}\n", + check=True, + text=True, + capture_output=True, + env=env, + ) + except subprocess.CalledProcessError as exc: + return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or "old password did not authenticate") + finally: + subprocess.run(["kdestroy", "-c", cache.name], text=True, capture_output=True, env=env) + return IpaResult(True, f"verified password for {username}") diff --git a/app/mailer.py b/app/mailer.py new file mode 100644 index 0000000..6230dc4 --- /dev/null +++ b/app/mailer.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import smtplib +from email.message import EmailMessage + +from .config import Config + + +def _send_message(config: Config, message: EmailMessage) -> None: + smtp_class = smtplib.SMTP_SSL if config.smtp_ssl else smtplib.SMTP + with smtp_class(config.smtp_host, config.smtp_port, timeout=20) as smtp: + if config.smtp_starttls and not config.smtp_ssl: + smtp.starttls() + if config.smtp_username: + smtp.login(config.smtp_username, config.smtp_password) + smtp.send_message(message) + + +def send_verification_email(config: Config, to_email: str, verify_url: str) -> None: + message = EmailMessage() + message["From"] = config.smtp_from + message["To"] = to_email + message["Subject"] = "Verify your Sticknife account" + message.set_content( + "\n".join( + [ + "Welcome to Sticknife.", + "", + "Verify your email address to continue:", + verify_url, + "", + "If you did not request this, you can ignore this email.", + ] + ) + ) + + _send_message(config, message) + + + +def send_password_reset_email(config: Config, to_email: str, reset_url: str) -> None: + message = EmailMessage() + message["From"] = config.smtp_from + message["To"] = to_email + message["Subject"] = "Set your Sticknife password" + message.set_content( + "\n".join( + [ + "Your Sticknife account is ready.", + "", + "Set or reset your password here:", + reset_url, + "", + "This link expires soon. If you did not request this, you can ignore this email.", + ] + ) + ) + _send_message(config, message) diff --git a/app/oidc.py b/app/oidc.py new file mode 100644 index 0000000..bca62c7 --- /dev/null +++ b/app/oidc.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import base64 +import json +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any + +from .config import Config + + +@dataclass(frozen=True) +class OidcProvider: + authorization_endpoint: str + token_endpoint: str + userinfo_endpoint: str + end_session_endpoint: str | None = None + + +def _json_get(url: str) -> dict[str, Any]: + with urllib.request.urlopen(url, timeout=20) as response: + return json.loads(response.read().decode()) + + +def discover(config: Config) -> OidcProvider: + metadata = _json_get(f"{config.oidc_issuer}/.well-known/openid-configuration") + return OidcProvider( + authorization_endpoint=metadata["authorization_endpoint"], + token_endpoint=metadata["token_endpoint"], + userinfo_endpoint=metadata["userinfo_endpoint"], + end_session_endpoint=metadata.get("end_session_endpoint"), + ) + + +def authorization_url(config: Config, state: str, next_path: str) -> str: + provider = discover(config) + query = urllib.parse.urlencode( + { + "client_id": config.oidc_client_id, + "redirect_uri": f"{config.base_url}/auth/callback", + "response_type": "code", + "scope": config.oidc_scopes, + "state": state, + } + ) + return f"{provider.authorization_endpoint}?{query}" + + +def exchange_code(config: Config, code: str) -> dict[str, Any]: + provider = discover(config) + body = urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": f"{config.base_url}/auth/callback", + } + ).encode() + request = urllib.request.Request(provider.token_endpoint, data=body, method="POST") + auth = base64.b64encode(f"{config.oidc_client_id}:{config.oidc_client_secret}".encode()).decode() + request.add_header("Authorization", f"Basic {auth}") + request.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace") + raise RuntimeError(f"OIDC token exchange failed: HTTP {exc.code} {detail}") from exc + + +def userinfo(config: Config, access_token: str) -> dict[str, Any]: + provider = discover(config) + request = urllib.request.Request(provider.userinfo_endpoint) + request.add_header("Authorization", f"Bearer {access_token}") + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace") + raise RuntimeError(f"OIDC userinfo failed: HTTP {exc.code} {detail}") from exc + + +def groups_from_claims(claims: dict[str, Any]) -> set[str]: + raw = claims.get("groups") or claims.get("ak_groups") or [] + if isinstance(raw, str): + return {raw} + if isinstance(raw, list): + return {str(group) for group in raw} + return set() + + +def logout_url(config: Config) -> str: + provider = discover(config) + endpoint = provider.end_session_endpoint or "https://auth.sticknife.com/if/session-end/" + query = urllib.parse.urlencode({"post_logout_redirect_uri": config.base_url}) + return f"{endpoint}?{query}" diff --git a/app/pantheon.py b/app/pantheon.py new file mode 100644 index 0000000..d761b2e --- /dev/null +++ b/app/pantheon.py @@ -0,0 +1,400 @@ +"""The Sticknife pantheon: who the gods are, what they run, and who they live with. + +This module holds the *shape* of the pantheon. The words live one file per deity in +``templates/pantheon/`` — ``charon.toml``, ``hermes.toml``, and so on — so rewriting copy +never means editing layout code. To change what a god says, edit its file; the change shows +up on the next page load without a restart, the same as an HTML template. + +It feeds three things: + + * ``payload()`` — JSON embedded in the page for the force graph to lay out + * ``fallback()`` — server-rendered markup shown when JavaScript does not run + * ``tally()`` — a count line, if a page wants one + +A member's seal is not declared anywhere; it is discovered. Drop a new master into +media/pantheon/, run ``scripts/build_pantheon_assets.py``, and the deity stops rendering as +an empty niche and starts wearing its seal — no code change. + +Houses are functional groups. ``RING_ORDER`` sets the order they sit in, going clockwise, and +``RING_ANCHORS`` pins whichever of them have to land somewhere exact; the rest spread evenly +through the arc between two anchors. Reorder that list to reorder the pantheon — the angles +are derived, so nothing else needs touching and adding or dropping a house re-spaces the ring +on its own. The house marked ``center`` is the exception: bare metal sits at the middle and +does not orbit anything. + +A house may name a ``hub``: the member that sits at the middle of the family, with the rest +hanging off it. Without one, a house with a ``role="host"`` member uses that instead, and a +house with neither links every member to every other, which leaves nobody in particular at +the middle. Tenants always hang off whatever they name in ``inside``, hub or not, so choosing +a hub rearranges a family without breaking who lives with whom. + +Which mythology a name comes from is load-bearing in the layout, not trivia: the Greeks are +sprung toward the ring, while the Mesopotamian names are pushed gently outward off it. Those +four are exactly the tenants — Nabu on Hermes, Nanshe on Xenia, Uttu on Plutus, Gibil on +Astrape — so every borrowed god hangs outside the circle, tethered to the Greek whose metal +it lives on. +""" +from __future__ import annotations + +import html +import json +import tomllib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ART = HERE / "static" / "img" / "pantheon" +ART_URL = "/static/img/pantheon" + +#: One file per deity, named for its key. +COPY = HERE / "templates" / "pantheon" + +#: Fields a copy file may leave out, and what they mean when it does. A file with no ``house`` +#: belongs to none and is not shown; no ``order`` puts it at the end of whatever house it names. +OPTIONAL = {"host": "", "runs": "", "role": "", "inside": "", "house": "", "order": 99} + +#: Borrowed from Sumer and Babylon rather than Greece. The four on the ring are each a resident +#: on a Greek host's metal, which is why the layout pushes them outside it; the Abzu names never +#: touch the ring's physics at all, since their house lives in a drawer of its own. +MESOPOTAMIAN = {"nabu", "nanshe", "uttu", "gibil", + "marduk", "enki", "isimud", "kulla", "nisaba"} + +#: The northern names. They do not orbit the pantheon at all — they live in a panel of their +#: own off the right-hand side, so the ring never has to make room for them. +NORSE = {"thor", "garmr", "ratatoskr", "kari", "mimir", "bifrost", "yggdrasil"} + + +def origin(key: str) -> str: + if key in MESOPOTAMIAN: + return "mesopotamian" + if key in NORSE: + return "norse" + return "greek" + +#: Going round the ring, clockwise. This list is the only thing that decides the order — swap +#: two ids to swap two houses, insert one and the ring re-spaces itself. Any house left out of +#: it (the machines) does not orbit. +RING_ORDER = [ + "media", # at the top + "message", # flanking one side + "count", + "access", # the gate, at the bottom + "power", + "make", # crafting, flanking the other side of media +] + +#: Houses pinned to an exact angle, in degrees clockwise from twelve o'clock: -90 is the top, +#: 0 the right, 90 the bottom. Anything not pinned spreads evenly through the arc between its +#: neighbouring anchors, keeping RING_ORDER. Pin nothing and the whole ring spaces evenly from +#: the top. With six houses the anchors cost nothing — media sits three seats from access, so +#: holding both top and bottom still leaves the rest exactly 60 degrees apart. At counts where +#: 180 is not a multiple of 360/n the two anchors win and the arcs either side compress. +RING_ANCHORS = { + "media": -90, # media at twelve o'clock, flanked by make and message + "access": 90, # the gate at the bottom +} + +#: The order the families stack in on a narrow screen, where the ring gives way to a column of +#: blocks. Deliberately separate from RING_ORDER: what reads well going clockwise is not what +#: reads well scrolling down, and the ring has two houses that are not on it at all. Anything +#: left out of this keeps its place from HOUSES, after everything that is listed. It is applied +#: only under the mobile breakpoint, so reordering here cannot disturb the ring. +STACK_ORDER = [ + "media", + "message", + "make", + "count", + "access", + "power", + "control", + "abzu", + "norse", +] + +#: The houses themselves. Who is *in* each one is not listed here — every copy file names the +#: house it belongs to and an ``order`` for where it sits along the ring, so adding a deity is +#: dropping in a file. ``bias`` nudges a whole family along the ring in seats, trimming the +#: lean a one-sided tenant puts on its kin. +HOUSES: list[dict] = [ + {"id": "access", "name": "policy & access", "hub": "nomos"}, + {"id": "make", "name": "creation"}, + {"id": "message", "name": "communication"}, + {"id": "count", "name": "finance"}, + {"id": "power", "name": "systems"}, + { + "id": "media", + "name": "media", + "hub": "dionysus", + # Nanshe sits out beyond Xenia with nothing on the far side to answer her, and the push + # of her leans the rest of the house anticlockwise; this puts Dionysus back on twelve. + "bias": 0.1, + }, + { + "id": "control", + "name": "control", + "center": True, # the machines do not orbit; they are the middle + }, + { + "id": "abzu", + "name": "abzu", + "side": True, + "hub": "marduk", + }, + { + "id": "norse", + "name": "norse", + # Neither on the ring nor at the middle: this one lives in a drawer off the right edge + # and is drawn by its own little simulation, so it never competes for ring seats. + "side": True, + "hub": "thor", # sits above the rest of the cluster, and larger + }, +] + + +# --------------------------------------------------------------------------------- the copy + +#: key -> (mtime, parsed). Re-read only when a file actually changes, so editing copy shows up +#: on the next request without a restart but a page view is not nineteen parses. +_copy: dict[str, tuple[int, dict]] = {} + + +def paragraphs(text: str) -> list[str]: + """Split prose on blank lines, one entry per paragraph. + + A blank line starts a new paragraph; a single line break is just where the author happened + to wrap, and is undone. So copy can be hard-wrapped in the file to stay readable there + without the wrapping meaning anything on the page. + """ + out = [] + for block in text.split("\n\n"): + joined = " ".join(block.split()) + if joined: + out.append(joined) + return out + + +def member(key: str) -> dict: + """One deity, read from its copy file. + + A file being mid-edit should not take the page down: if it will not parse and we have read + it successfully before, the last good version is served and the bad one simply does not + take. With nothing cached to fall back on there is nothing to serve, and the error stands. + """ + path = COPY / f"{key}.toml" + cached = _copy.get(key) + try: + stamp = path.stat().st_mtime_ns + except OSError: + if cached: + return cached[1] + raise + if cached and cached[0] == stamp: + return cached[1] + + try: + with path.open("rb") as handle: + loaded = tomllib.load(handle) + except (tomllib.TOMLDecodeError, OSError): + if cached: + return cached[1] + raise + + entry = {**OPTIONAL, **loaded, "key": key} + entry["does"] = " ".join(entry.get("does", "").split()) + entry["long"] = paragraphs(entry.get("long", "")) + _copy[key] = (stamp, entry) + return entry + + +def roster() -> dict[str, list[str]]: + """House id to member keys, in the order they sit along the ring. + + Membership is read from the copy files rather than declared here: each names its ``house`` + and an ``order``, low to high. A file naming a house that does not exist is left out — the + houses are the fixed part, and a typo should cost one deity rather than the page. + """ + grouped: dict[str, list[tuple[int, str]]] = {house["id"]: [] for house in HOUSES} + for path in sorted(COPY.glob("*.toml")): + key = path.stem + who = member(key) + if who["house"] in grouped: + grouped[who["house"]].append((who["order"], key)) + # ties break on key, so the order is stable whatever the filesystem hands back + return {hid: [key for _, key in sorted(rows)] for hid, rows in grouped.items()} + + +def _members() -> list[dict]: + seats = roster() + return [member(key) for house in HOUSES for key in seats[house["id"]]] + + +def has_seal(key: str) -> bool: + """Whether the asset build has produced art for this deity yet.""" + return (ART / f"{key}-node.webp").is_file() + + +def ring_angles() -> dict[str, float]: + """Each ring house's angle in degrees clockwise from twelve o'clock. + + Pinned houses sit exactly where ``RING_ANCHORS`` puts them; the rest are spread evenly + around the arc between whichever anchors they fall between, in ``RING_ORDER``. With no + anchors at all this is a plain even ring starting at the top. + """ + def tidy(deg: float) -> float: + return round(((deg + 180.0) % 360.0) - 180.0, 1) + + count = len(RING_ORDER) + anchors = [(i, RING_ANCHORS[h]) for i, h in enumerate(RING_ORDER) if h in RING_ANCHORS] + + if not anchors: + step = 360.0 / count + return {h: tidy(-90.0 + i * step) for i, h in enumerate(RING_ORDER)} + + angles: dict[str, float] = {} + for n, (index, degrees) in enumerate(anchors): + angles[RING_ORDER[index]] = tidy(degrees) + next_index, next_degrees = anchors[(n + 1) % len(anchors)] + between = (next_index - index) % count or count # houses in this arc, plus one + span = (next_degrees - degrees) % 360 or 360.0 # clockwise sweep to the next + for step in range(1, between): + angles[RING_ORDER[(index + step) % count]] = tidy(degrees + span * step / between) + return angles + + +def data() -> list[dict]: + """The houses, with copy read in and art URLs filled in for whichever seals exist.""" + angles = ring_angles() + seats = roster() + out = [] + for house in HOUSES: + members = [] + for key in seats[house["id"]]: + art = has_seal(key) + entry = { + **member(key), + "origin": origin(key), + "node": f"{ART_URL}/{key}-node.webp" if art else "", + "icon": f"{ART_URL}/{key}-icon.webp" if art else "", + "seal": f"{ART_URL}/{key}-seal.webp" if art else "", + } + if house.get("hub") == key: + entry["hub"] = True + members.append(entry) + shape = {"id": house["id"], "name": house["name"], "members": members} + if house.get("center"): + shape["center"] = True + elif house.get("side"): + shape["side"] = True + else: + shape["angle"] = angles.get(house["id"]) + if "bias" in house: + shape["bias"] = house["bias"] + out.append(shape) + return out + + +def payload() -> str: + """JSON for the page, safe to drop inside a + + \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..2f079f9 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,12 @@ +$message +
+ +

Sign in

+

Manage your Sticknife account request.

+
+
+ + + +
+ diff --git a/app/templates/pantheon/ampelos.toml b/app/templates/pantheon/ampelos.toml new file mode 100644 index 0000000..ddecfcb --- /dev/null +++ b/app/templates/pantheon/ampelos.toml @@ -0,0 +1,23 @@ +# Ampelos — The Gatherer + +house = "media" +order = 1 + +name = "Ampelos" +domain = "The Gatherer" +state = "Testing" +label = "Testing" +host = "ampelos.sticknife.com" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +The gatherer of dionysus' media, and the lord of the vineyard. +""" + +# the paragraph in the detail panel +long = """ +Ampelos is the lord of the vineyard, and runs our media collection. + +Visit him to request new content, or to see what has been gathered so far. +""" diff --git a/app/templates/pantheon/apollo.toml b/app/templates/pantheon/apollo.toml new file mode 100644 index 0000000..37fe334 --- /dev/null +++ b/app/templates/pantheon/apollo.toml @@ -0,0 +1,21 @@ +# Apollo — workstation + +house = "control" +order = 4 + +name = "Apollo" +domain = "The Persistent" +state = "machine" +label = "Live" +runs = "workstation" + +# one line, used by the no-JavaScript listing +does = """ +Primary persistent compute unit. +""" + +# the paragraph in the detail panel +long = """ +Primary persistent compute unit. Hosts most services requiring GPU acceleration, including the main AI/ML workloads. + +""" diff --git a/app/templates/pantheon/astrape.toml b/app/templates/pantheon/astrape.toml new file mode 100644 index 0000000..9e691e1 --- /dev/null +++ b/app/templates/pantheon/astrape.toml @@ -0,0 +1,22 @@ +# Astrape — the lightning + +house = "power" +order = 1 + +name = "Astrape" +domain = "The Energy Keeper" +state = "In Development" +label = "In Development" +role = "host" +host = "astrape.sticknife.com" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +The manager for the household power systems. +""" + +# the paragraph in the detail panel +long = """ +The manager for the household power systems. Astrape controls how much power flows in and out between the house and the grid. +""" diff --git a/app/templates/pantheon/bifrost.toml b/app/templates/pantheon/bifrost.toml new file mode 100644 index 0000000..c7c6cb5 --- /dev/null +++ b/app/templates/pantheon/bifrost.toml @@ -0,0 +1,17 @@ +# Bifrost — the bridge + +house = "norse" +order = 3 + +name = "Bifrost" +domain = "The Bridge" +state = "planned" +label = "Infrastructure" + +does = """ +The firewall. What crosses, and what does not. +""" + +long = """ +The burning bridge between one world and the next, the firewall that protects the network from the outside world. +""" diff --git a/app/templates/pantheon/charon.toml b/app/templates/pantheon/charon.toml new file mode 100644 index 0000000..6514710 --- /dev/null +++ b/app/templates/pantheon/charon.toml @@ -0,0 +1,21 @@ +# Charon — the gate + +house = "access" +order = 1 + +name = "Charon" +domain = "The Ferryman" +state = "here" +label = "Live" +host = "auth.sticknife.com" +runs = "Authentik + FreeIPA" + +# one line, used by the no-JavaScript listing +does = """ +Accounts, sign-in, and the token everything else checks. +""" + +# the paragraph in the detail panel +long = """ +Charon verifies identities and issues the token that lets you access the rest of the Pantheon. +""" diff --git a/app/templates/pantheon/daedalus.toml b/app/templates/pantheon/daedalus.toml new file mode 100644 index 0000000..7ebad95 --- /dev/null +++ b/app/templates/pantheon/daedalus.toml @@ -0,0 +1,21 @@ +# Daedalus — the workshop + +house = "make" +order = 1 + +name = "Daedalus" +domain = "The Craftsman" +state = "live" +label = "in service" +host = "daedalus.sticknife.com" +runs = "Hypertower" + +# one line, used by the no-JavaScript listing +does = """ +Host of the workshop. +""" + +# the paragraph in the detail panel +long = """ +Daedalus hosts the workshop, the place where new projects are designed. +""" diff --git a/app/templates/pantheon/dionysus.toml b/app/templates/pantheon/dionysus.toml new file mode 100644 index 0000000..11df6dd --- /dev/null +++ b/app/templates/pantheon/dionysus.toml @@ -0,0 +1,23 @@ +# Dionysus — the Entertainer + +house = "media" +order = 2 + +name = "Dionysus" +domain = "The Entertainer" +state = "live" +label = "Live" +host = "dionysus.sticknife.com" +runs = "PLEX" + +# one line, used by the no-JavaScript listing +does = """ +Lord of entertainment, and the host of our PLEX server. +""" + +# the paragraph in the detail panel +long = """ +What the vineyard was always for. + +Dionysus is the lord of entertainment, and runs our PLEX server, where you can stream all the movies, shows, and music Ampelos has gathered. +""" diff --git a/app/templates/pantheon/enki.toml b/app/templates/pantheon/enki.toml new file mode 100644 index 0000000..1298203 --- /dev/null +++ b/app/templates/pantheon/enki.toml @@ -0,0 +1,18 @@ +# Enki — the deep + +house = "abzu" +order = 3 +row = 2 + +name = "Enki" +domain = "The Deep" +state = "planned" +label = "In Development" + +does = """ +Lead designer. Decides what a thing should be. +""" + +long = """ +The god of the fresh water deep and lead designer for the Abzu development team. +""" diff --git a/app/templates/pantheon/ganymede.toml b/app/templates/pantheon/ganymede.toml new file mode 100644 index 0000000..f91528c --- /dev/null +++ b/app/templates/pantheon/ganymede.toml @@ -0,0 +1,20 @@ +# Ganymede — the house + +house = "control" +order = 1 + +name = "Ganymede" +domain = "the house" +state = "machine" +label = "Home Assistant" +runs = "home server" + +# one line, used by the no-JavaScript listing +does = """ +The home assistant. Runs smart home automation. +""" + +# the paragraph in the detail panel +long = """ +Cupbearer to the gods. Ganymede maintains the household, all lights, climate, and security systems. +""" diff --git a/app/templates/pantheon/garmr.toml b/app/templates/pantheon/garmr.toml new file mode 100644 index 0000000..ee678dc --- /dev/null +++ b/app/templates/pantheon/garmr.toml @@ -0,0 +1,17 @@ +# Garmr — the names + +house = "norse" +order = 5 + +name = "Garmr" +domain = "The Guardian" +state = "planned" +label = "Infrastructure" + +does = """ +DNS. Which name means which machine. +""" + +long = """ +The hound at the mouth of the cave, who knows every name that goes past. Controls the dns and the names of the machines, and who they are allowed to talk to. +""" diff --git a/app/templates/pantheon/gibil.toml b/app/templates/pantheon/gibil.toml new file mode 100644 index 0000000..2ab5dcc --- /dev/null +++ b/app/templates/pantheon/gibil.toml @@ -0,0 +1,21 @@ +# Gibil — the kiln + +house = "power" +order = 2 + +name = "Gibil" +domain = "Oracle of Energy" +state = "planned" +label = "Testing" +inside = "astrape" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +Predictive intelligence for energy management. +""" + +# the paragraph in the detail panel +long = """ +Gibil is the Oracle of Energy, a predictive intelligence that determines future power needs and supply, allowing Astrape to make the best use of its energy resources. +""" diff --git a/app/templates/pantheon/hades.toml b/app/templates/pantheon/hades.toml new file mode 100644 index 0000000..991c898 --- /dev/null +++ b/app/templates/pantheon/hades.toml @@ -0,0 +1,20 @@ +# Hades — workstation + +house = "control" +order = 2 + +name = "Hades" +domain = "The Powerhouse" +state = "machine" +label = "Human-Controlled" +runs = "workstation" + +# one line, used by the no-JavaScript listing +does = """ +Primary development workstation. +""" + +# the paragraph in the detail panel +long = """ +Primary development workstation. Most powerful single machine in the Pantheon. +""" diff --git a/app/templates/pantheon/hecate.toml b/app/templates/pantheon/hecate.toml new file mode 100644 index 0000000..921ddd6 --- /dev/null +++ b/app/templates/pantheon/hecate.toml @@ -0,0 +1,21 @@ +# Hecate — the keys + +house = "access" +order = 3 + +name = "Hecate" +domain = "The Gateway" +state = "planned" +label = "Live" +host = "" +runs = "" + +# one line, used by the no-JavaScript listing +does = """ +Reverse tunnel manager. Provides secure access to internal services. +""" + +# the paragraph in the detail panel +long = """ +Hecate controls the tunnels that provide a secure path for privileged users through the Bifrost. +""" diff --git a/app/templates/pantheon/hephaestus.toml b/app/templates/pantheon/hephaestus.toml new file mode 100644 index 0000000..8c8a52f --- /dev/null +++ b/app/templates/pantheon/hephaestus.toml @@ -0,0 +1,22 @@ +# Hephaestus — the forge + +house = "make" +order = 2 + +name = "Hephaestus" +domain = "The Forge" +state = "live" +label = "Live" +host = "git.sticknife.com" +runs = "Git forge" + +# one line, used by the no-JavaScript listing +does = """ +Git repositories, builds, packages. +""" + +# the paragraph in the detail panel +long = """ +Everything gets hammered out here first. Repositories, build pipelines, and the package +registry the rest of the pantheon pulls from. +""" diff --git a/app/templates/pantheon/hermes.toml b/app/templates/pantheon/hermes.toml new file mode 100644 index 0000000..b54aa2b --- /dev/null +++ b/app/templates/pantheon/hermes.toml @@ -0,0 +1,22 @@ +# Hermes — the carrier + +house = "message" +order = 1 + +name = "Hermes" +domain = "The Messenger" +state = "live" +label = "Live" +role = "host" +host = "matrix.sticknife.com" +runs = "Mail relay + Matrix" + +# one line, used by the no-JavaScript listing +does = """ +Handles mail routing and matrix chat services.""" + +# the paragraph in the detail panel +long = """ +Hermes carries the messaging protocols for the pantheon. Outbound mail for every service in the pantheon +leaves through Hermes, as well as the Matrix homeserver that provides chat services. +""" diff --git a/app/templates/pantheon/isimud.toml b/app/templates/pantheon/isimud.toml new file mode 100644 index 0000000..1e37c0b --- /dev/null +++ b/app/templates/pantheon/isimud.toml @@ -0,0 +1,18 @@ +# Isimud — the second face + +house = "abzu" +order = 4 +row = 2 + +name = "Isimud" +domain = "The Counterpart" +state = "planned" +label = "In Development" + +does = """ +Editor and critic. Argues with Enki. +""" + +long = """ +Enki's own attendant, with a face pointing each way. Reads everything the deep produces back to it, and says where it is wrong. +""" diff --git a/app/templates/pantheon/kari.toml b/app/templates/pantheon/kari.toml new file mode 100644 index 0000000..6f2c35c --- /dev/null +++ b/app/templates/pantheon/kari.toml @@ -0,0 +1,17 @@ +# Kari — the air + +house = "norse" +order = 6 + +name = "Kari" +domain = "The Air" +state = "planned" +label = "Infrastructure" + +does = """ +Wifi network and wireless access point manager. +""" + +long = """ +Spirit of the wind, and lord of wifi. Everything that reaches the pantheon without a cable comes in on Kari. +""" diff --git a/app/templates/pantheon/kulla.toml b/app/templates/pantheon/kulla.toml new file mode 100644 index 0000000..12d8b1c --- /dev/null +++ b/app/templates/pantheon/kulla.toml @@ -0,0 +1,19 @@ +# Kulla — the brick + +house = "abzu" +order = 5 +row = 3 + +name = "Kulla" +domain = "The Builder" +state = "planned" +label = "In Development" + +does = """ +Lead developer. Builds the thing. +""" + +long = """ +God of bricks and lead developer for the Abzu development team. Takes what Enki designed and makes it a thing that runs on +real machines. +""" diff --git a/app/templates/pantheon/marduk.toml b/app/templates/pantheon/marduk.toml new file mode 100644 index 0000000..04e2767 --- /dev/null +++ b/app/templates/pantheon/marduk.toml @@ -0,0 +1,17 @@ +# Marduk — the order + +house = "abzu" +order = 1 + +name = "Marduk" +domain = "The Order" +state = "planned" +label = "In Development" + +does = """ +Network-level systems planning agent. +""" + +long = """ +The one who cut the old chaos in half and built the world out of it. Head network engineer and head of the AI dev team. +""" diff --git a/app/templates/pantheon/mimir.toml b/app/templates/pantheon/mimir.toml new file mode 100644 index 0000000..6d8a601 --- /dev/null +++ b/app/templates/pantheon/mimir.toml @@ -0,0 +1,17 @@ +# Mimir — the well + +house = "norse" +order = 4 + +name = "Mimir" +domain = "The Well" +state = "planned" +label = "Infrastructure" + +does = """ +All sql and mongo databases for the pantheon services. +""" + +long = """ +The head at the well that remembers everything. Mimir provides the databases that the pantheon services use to store their data. +""" diff --git a/app/templates/pantheon/mnemosyne.toml b/app/templates/pantheon/mnemosyne.toml new file mode 100644 index 0000000..4beda8b --- /dev/null +++ b/app/templates/pantheon/mnemosyne.toml @@ -0,0 +1,19 @@ +# Mnemosyne — the store + +house = "power" +order = 3 + +name = "Mnemosyne" +domain = "The Archive" +state = "planned" +label = "Live" + +# one line, used by the no-JavaScript listing +does = """ +Continuous backup and archival storage for the pantheon. +""" + +# the paragraph in the detail panel +long = """ +Mnemosyne is the memory of the pantheon. She stores backups of every system. +""" diff --git a/app/templates/pantheon/nabu.toml b/app/templates/pantheon/nabu.toml new file mode 100644 index 0000000..c0b2167 --- /dev/null +++ b/app/templates/pantheon/nabu.toml @@ -0,0 +1,21 @@ +# Nabu — the record + +house = "message" +order = 2 + +name = "Nabu" +domain = "The Voice" +state = "live" +label = "Live" +inside = "hermes" +runs = "AI" + +# one line, used by the no-JavaScript listing +does = """ +The voice of the pantheon. +""" + +# the paragraph in the detail panel +long = """ +Nabu is the friendly voice of the pantheon, speaking the languages of humans as well as machine. +""" diff --git a/app/templates/pantheon/nanshe.toml b/app/templates/pantheon/nanshe.toml new file mode 100644 index 0000000..8c2cb14 --- /dev/null +++ b/app/templates/pantheon/nanshe.toml @@ -0,0 +1,21 @@ +# Nanshe — the watch + +house = "media" +order = 4 + +name = "Nanshe" +domain = "The Researcher" +state = "locked" +label = "no access" +inside = "xenia" +runs = "Metrics + alerting" + +# one line, used by the no-JavaScript listing +does = """ +Research specialist and article curator. +""" + +# the paragraph in the detail panel +long = """ +Specialist in finding connections across articles, Nanshe is a research companion to help discover studies based on your interests and past knowledge to help you learn. +""" diff --git a/app/templates/pantheon/nisaba.toml b/app/templates/pantheon/nisaba.toml new file mode 100644 index 0000000..a5880d5 --- /dev/null +++ b/app/templates/pantheon/nisaba.toml @@ -0,0 +1,19 @@ +# Nisaba — the tongue + +house = "abzu" +order = 2 +# straight under Marduk: everything the house says goes through her +row = 1 + +name = "Nisaba" +domain = "The Translator" +state = "planned" +label = "In Service" + +does = """ +Asks every database a question in plain words. +""" + +long = """ +Goddess of writing and master of languages, Nisaba speaks to the databases and provides a single unified interface no matter if they speak SQL, Mongo, or plain old English. +""" diff --git a/app/templates/pantheon/nomos.toml b/app/templates/pantheon/nomos.toml new file mode 100644 index 0000000..85596f8 --- /dev/null +++ b/app/templates/pantheon/nomos.toml @@ -0,0 +1,21 @@ +# Nomos — the statute + +house = "access" +order = 2 + +name = "Nomos" +domain = "The Law" +state = "planned" +label = "Live" +host = "" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +Central policy engine for the pantheon. +""" + +# the paragraph in the detail panel +long = """ +The central policy engine of the pantheon, Nomos is the law that governs what an account is allowed to do. It is the source of truth for all access control decisions. +""" diff --git a/app/templates/pantheon/pheme.toml b/app/templates/pantheon/pheme.toml new file mode 100644 index 0000000..1275792 --- /dev/null +++ b/app/templates/pantheon/pheme.toml @@ -0,0 +1,21 @@ +# Pheme — the herald + +house = "message" +order = 3 + +name = "Pheme" +domain = "The Rumor" +state = "planned" +label = "Planning" +host = "" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +Social media systems. +""" + +# the paragraph in the detail panel +long = """ +Social media, federated. Pheme is the herald of the pantheon, spreading the word of what is happening in the house and beyond. Runs federated social media services. +""" diff --git a/app/templates/pantheon/plutus.toml b/app/templates/pantheon/plutus.toml new file mode 100644 index 0000000..77add34 --- /dev/null +++ b/app/templates/pantheon/plutus.toml @@ -0,0 +1,22 @@ +# Plutus — the ledger + +house = "count" +order = 1 + +name = "Plutus" +domain = "The Economist" +state = "planned" +label = "live" +role = "host" +host = "" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +Financial services for sticknife users. +""" + +# the paragraph in the detail panel +long = """ +Financial services for sticknife users. +""" diff --git a/app/templates/pantheon/ratatoskr.toml b/app/templates/pantheon/ratatoskr.toml new file mode 100644 index 0000000..212312a --- /dev/null +++ b/app/templates/pantheon/ratatoskr.toml @@ -0,0 +1,17 @@ +# Ratatoskr — the runner + +house = "norse" +order = 7 + +name = "Ratatoskr" +domain = "The Runner" +state = "planned" +label = "Infrastructure" + +does = """ +The reverse proxy. Everything arrives through it. +""" + +long = """ +The squirrel that delivers messages across the world tree. Runs the reverse proxy, directing traffic to the right service. +""" diff --git a/app/templates/pantheon/theseus.toml b/app/templates/pantheon/theseus.toml new file mode 100644 index 0000000..988d161 --- /dev/null +++ b/app/templates/pantheon/theseus.toml @@ -0,0 +1,23 @@ +# Theseus — workstation + +house = "control" +order = 3 + +name = "Theseus" +domain = "The Adventurer" +state = "machine" +label = "Human-Controlled" +runs = "workstation" + +# hangs off the two workstations rather than reaching across the middle +attach = ["hades", "apollo"] + +# one line, used by the no-JavaScript listing +does = """ +Portable development workstation. +""" + +# the paragraph in the detail panel +long = """ +Portable development workstation. +""" diff --git a/app/templates/pantheon/thor.toml b/app/templates/pantheon/thor.toml new file mode 100644 index 0000000..e3feadc --- /dev/null +++ b/app/templates/pantheon/thor.toml @@ -0,0 +1,18 @@ +# Thor — the anvil + +house = "norse" +order = 1 + +name = "Thor" +domain = "The Storm" +state = "planned" +label = "Infrastructure" + +does = """ +The bare metal most of the pantheon runs on. +""" + +long = """ +The one holding the hammer, and the one holding everything else up. Bare metal: most of the +gods in this pantheon exist on Thor's hardware. +""" diff --git a/app/templates/pantheon/uttu.toml b/app/templates/pantheon/uttu.toml new file mode 100644 index 0000000..3fef2ae --- /dev/null +++ b/app/templates/pantheon/uttu.toml @@ -0,0 +1,21 @@ +# Uttu — the loom + +house = "count" +order = 2 + +name = "Uttu" +domain = "The Loom" +state = "planned" +label = "live" +inside = "plutus" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +Market prediction and trading intelligence. +""" + +# the paragraph in the detail panel +long = """ +The goddess of weaving, Uttu weaves the predictions of echo state networks to rank the future performance of assets, creating profit from the fabric. +""" diff --git a/app/templates/pantheon/xenia.toml b/app/templates/pantheon/xenia.toml new file mode 100644 index 0000000..f097859 --- /dev/null +++ b/app/templates/pantheon/xenia.toml @@ -0,0 +1,22 @@ +# Xenia — the welcome + +house = "media" +order = 3 + +name = "Xenia" +domain = "The Librarian" +state = "planned" +label = "live" +role = "host" +host = "" +runs = "not yet built" + +# one line, used by the no-JavaScript listing +does = """ +Host of the library and related services. +""" + +# the paragraph in the detail panel +long = """ +Xenia is the goddess of hospitality, and the librarian of the pantheon. She runs sticknife library, a home for books, journals, and comics. +""" diff --git a/app/templates/pantheon/yggdrasil.toml b/app/templates/pantheon/yggdrasil.toml new file mode 100644 index 0000000..0585451 --- /dev/null +++ b/app/templates/pantheon/yggdrasil.toml @@ -0,0 +1,17 @@ +# Yggdrasil — the tree + +house = "norse" +order = 2 + +name = "Yggdrasil" +domain = "The Tree" +state = "planned" +label = "unbuilt" + +does = """ +The NAS. Where the bulk of the data lives. +""" + +long = """ +The world tree, with nine worlds hanging in its branches and roots. Provides the network attached storage underlying anything that needs more than a few gigabytes. +""" diff --git a/app/templates/password_request.html b/app/templates/password_request.html new file mode 100644 index 0000000..2dffeea --- /dev/null +++ b/app/templates/password_request.html @@ -0,0 +1,10 @@ +$message +
+

Password reset

+

Request a link for an existing Sticknife account.

+
+
+ + + +
diff --git a/app/templates/password_reset.html b/app/templates/password_reset.html new file mode 100644 index 0000000..d297d55 --- /dev/null +++ b/app/templates/password_reset.html @@ -0,0 +1,11 @@ +$message +
+

Set password

+

Choose a password for $username.

+
+
+ + + + +
diff --git a/app/templates/profile.html b/app/templates/profile.html new file mode 100644 index 0000000..404cc0f --- /dev/null +++ b/app/templates/profile.html @@ -0,0 +1,24 @@ +$message +
+
+

Profile

+

Status: $status

+
+
$username
+
+
+ + + + +
+
+

Change password

+
+ + + + +
+
+ diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..c853e86 --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,44 @@ +$message +
+ + + $registration_flyout +
\ No newline at end of file diff --git a/app/web.py b/app/web.py new file mode 100644 index 0000000..696d404 --- /dev/null +++ b/app/web.py @@ -0,0 +1,767 @@ +from __future__ import annotations + +import hashlib +import html +import mimetypes +import urllib.parse +from http import HTTPStatus +from http.cookies import SimpleCookie +from pathlib import Path +from string import Template +from urllib.parse import parse_qs, urlparse + +from . import pantheon +from .config import Config +from .authentik import trigger_ldap_sync +from .db import Database, sql_literal +from .ipa import approve_user, email_matches, provision_user, set_password, verify_password as verify_ipa_password +from .mailer import send_password_reset_email, send_verification_email +from .oidc import authorization_url, exchange_code, groups_from_claims, logout_url, userinfo +from .security import hash_password, sign, token_urlsafe, unsign, verify_password + + +ROOT = Path(__file__).resolve().parent +TEMPLATES = ROOT / "templates" +STATIC = ROOT / "static" + +#: The only routes a role="home" deployment answers. Everything else belongs to the deployment +#: that holds the database and the OIDC client, and is reached through config.auth_base_url. +HOME_ROUTES = {("GET", "/"), ("GET", "/trusted-domains")} + + +def esc(value: object) -> str: + return html.escape("" if value is None else str(value), quote=True) + + +class WebApp: + def __init__(self, config: Config): + self.config = config + # A home deployment has no database credentials at all, so there is nothing to point at. + self.db = None if config.role == "home" else Database(config.database_url) + + def dispatch(self, handler) -> None: + parsed = urlparse(handler.path) + path = parsed.path + method = handler.command + if path.startswith("/static/"): + return self.static(handler, path) + routes = { + ("GET", "/"): self.register_form, + ("POST", "/register"): self.register_submit, + ("GET", "/verify"): self.verify_email, + ("GET", "/trusted-domains"): self.trusted_domains_page, + ("GET", "/login"): self.login_form, + ("POST", "/login"): self.login_submit, + ("POST", "/logout"): self.logout, + ("GET", "/auth/start"): self.auth_start, + ("GET", "/auth/callback"): self.auth_callback, + ("GET", "/profile"): self.profile, + ("POST", "/profile"): self.profile_update, + ("POST", "/profile/password"): self.profile_password_update, + ("GET", "/password/request"): self.password_request_form, + ("POST", "/password/request"): self.password_request_submit, + ("GET", "/password/reset"): self.password_reset_form, + ("POST", "/password/reset"): self.password_reset_submit, + ("GET", "/admin"): self.admin, + ("POST", "/admin/approve"): self.admin_approve, + ("POST", "/admin/reject"): self.admin_reject, + ("POST", "/admin/delete-unverified"): self.admin_delete_unverified, + ("POST", "/admin/purge-unverified"): self.admin_purge_unverified, + } + if self.config.role == "home" and (method, path) not in HOME_ROUTES: + # Not a 404: the page exists, it just lives on the deployment that owns identity. Only + # GET is forwarded — a 303 would turn a POST into a GET and drop the form body, so any + # form that submits off-box has to target auth_base_url directly. + if method != "GET": + return self.render(handler, "Not found", "

That page does not exist.

", HTTPStatus.NOT_FOUND) + query = f"?{parsed.query}" if parsed.query else "" + return self.redirect(handler, f"{self.config.auth_base_url}{path}{query}") + view = routes.get((method, path)) + if view is None: + return self.render(handler, "Not found", "

That page does not exist.

", HTTPStatus.NOT_FOUND) + return view(handler) + + def form_data(self, handler) -> dict[str, str]: + length = int(handler.headers.get("Content-Length", "0")) + raw = handler.rfile.read(length).decode() + return {key: values[0] for key, values in parse_qs(raw).items()} + + def current_user(self, handler) -> dict[str, str | None] | None: + if self.db is None: + return None + cookie = SimpleCookie(handler.headers.get("Cookie")) + morsel = cookie.get("snreg_session") + if not morsel: + return None + user_id = unsign(morsel.value, self.config.secret) + if not user_id: + return None + return self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(user_id)}") + + def require_user(self, handler) -> dict[str, str | None] | None: + user = self.current_user(handler) + if user: + return user + self.redirect(handler, f"/auth/start?next={urllib.parse.quote(urlparse(handler.path).path)}") + return None + + def require_admin(self, handler) -> dict[str, str | None] | None: + user = self.require_user(handler) + if not user: + return None + if user.get("is_admin") == "t": + return user + self.render(handler, "Forbidden", "

You do not have access to the admin dashboard.

", HTTPStatus.FORBIDDEN) + return None + + def static(self, handler, path: str) -> None: + rel = path.removeprefix("/static/").lstrip("/") + target = (STATIC / rel).resolve() + if not str(target).startswith(str(STATIC.resolve())) or not target.exists(): + handler.send_error(404) + return + content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream" + data = target.read_bytes() + # Nothing here is fingerprinted, so a cached copy is a stale copy: no-cache makes the + # browser ask every time, and the ETag lets it keep the bytes it already has. + tag = '"%s"' % hashlib.sha1(data).hexdigest()[:16] + if handler.headers.get("If-None-Match") == tag: + handler.send_response(HTTPStatus.NOT_MODIFIED.value) + handler.send_header("ETag", tag) + handler.send_header("Cache-Control", "no-cache") + handler.end_headers() + return + handler.send_response(200) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(data))) + handler.send_header("ETag", tag) + handler.send_header("Cache-Control", "no-cache") + handler.end_headers() + handler.wfile.write(data) + + def render( + self, + handler, + title: str, + content: str, + status: HTTPStatus = HTTPStatus.OK, + wide: bool = False, + head: str = "", + body_class: str = "", + shell_class: str = "", + ) -> None: + template = Template((TEMPLATES / "base.html").read_text()) + user = self.current_user(handler) + html_out = template.substitute( + title=esc(title), + content=content, + shell_class=shell_class or ("shell shell-wide" if wide else "shell"), + nav=self.nav(user), + head=head, + body_class=body_class, + ).encode() + handler.send_response(status.value) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Length", str(len(html_out))) + handler.end_headers() + handler.wfile.write(html_out) + + def nav(self, user: dict[str, str | None] | None) -> str: + if not user: + return f'Sign in' + admin = 'Admin' if user.get("is_admin") == "t" else "" + return f'HomeProfile{admin}
' + + def redirect(self, handler, location: str) -> None: + handler.send_response(303) + handler.send_header("Location", location) + handler.end_headers() + + def set_session(self, handler, user_id: str) -> None: + cookie = f"snreg_session={sign(user_id, self.config.secret)}; Path=/; HttpOnly; SameSite=Lax" + if self.config.cookie_secure: + cookie += "; Secure" + handler.send_header("Set-Cookie", cookie) + + def off_box(self, path: str) -> str: + """Absolute URL for a page this deployment does not serve; unchanged when it does. + + Only worth using where a relative link would not survive the forward in ``dispatch`` — + form actions above all, since a POST cannot be redirected without losing its body. + """ + return path if self.config.role != "home" else f"{self.config.auth_base_url}{path}" + + def trusted_domain(self, email: str) -> bool: + domain = email.rsplit("@", 1)[-1].lower() + return domain in self.config.trusted_domains + + def trusted_domains_page(self, handler) -> None: + """Placeholder — explains the shortcut the registration fine print points at.""" + domains = sorted(self.config.trusted_domains) + if domains: + listing = ( + "

Right now these are trusted:

" + ) + else: + listing = "

No domains are trusted yet, so every request is read by a human.

" + self.render( + handler, + "Trusted domains", + "

Trusted domains

" + "

Some email domains belong to people I already know — a workplace, or a " + "shared project. An address at one of those is approved the moment you click the " + "link in the verification email, with no wait for me to read the request.

" + f"{listing}" + "

Everything else still works exactly the same, it just waits for me. If you " + "think your domain belongs on this list, ask.

" + '

Back to the pantheon

', + ) + + def create_password_token(self, username: str, email: str) -> str: + token = token_urlsafe() + self.db.execute( + f""" + INSERT INTO snreg_password_tokens (token, username, email, expires_at) + VALUES ({sql_literal(token)}, {sql_literal(username)}, {sql_literal(email)}, now() + interval '2 hours'); + """ + ) + return token + + def send_password_link(self, username: str, email: str) -> str: + token = self.create_password_token(username, email) + reset_url = f"{self.config.base_url}/password/reset?token={token}" + send_password_reset_email(self.config, email, reset_url) + return reset_url + + def auth_start(self, handler) -> None: + if not self.config.oidc_client_id or not self.config.oidc_client_secret: + return self.render(handler, "Sign in unavailable", "

Authentik OIDC is not configured for Charon yet.

", HTTPStatus.SERVICE_UNAVAILABLE) + query = parse_qs(urlparse(handler.path).query) + next_path = query.get("next", ["/profile"])[0] + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "/profile" + state = sign(next_path, self.config.secret) + self.redirect(handler, authorization_url(self.config, state, next_path)) + + def auth_callback(self, handler) -> None: + query = parse_qs(urlparse(handler.path).query) + code = query.get("code", [""])[0] + state = query.get("state", [""])[0] + next_path = unsign(state, self.config.secret, max_age=600) or "/profile" + if not code: + return self.render(handler, "Sign in failed", "

Authentik did not return an authorization code.

", HTTPStatus.BAD_REQUEST) + try: + token = exchange_code(self.config, code) + claims = userinfo(self.config, token["access_token"]) + user = self.upsert_oidc_user(claims) + except Exception as exc: + return self.render(handler, "Sign in failed", f"

{esc(exc)}

", HTTPStatus.BAD_GATEWAY) + handler.send_response(303) + handler.send_header("Location", next_path) + self.set_session(handler, user["id"] or "") + handler.end_headers() + + def upsert_oidc_user(self, claims: dict) -> dict[str, str | None]: + subject = str(claims.get("sub") or "") + email = str(claims.get("email") or "").lower() + username = str(claims.get("preferred_username") or email.split("@", 1)[0] or subject).lower() + full_name = str(claims.get("name") or username) + groups = groups_from_claims(claims) + is_admin = bool(email in self.config.app_admin_emails or groups.intersection(self.config.admin_groups)) + group_text = ",".join(sorted(groups)) + existing = self.db.one( + f""" + SELECT * FROM snreg_users + WHERE oidc_subject = {sql_literal(subject)} + OR lower(email) = {sql_literal(email)} + OR lower(username) = {sql_literal(username)} + ORDER BY CASE WHEN oidc_subject = {sql_literal(subject)} THEN 0 ELSE 1 END + LIMIT 1 + """ + ) + if existing: + self.db.execute( + f""" + UPDATE snreg_users + SET oidc_subject = {sql_literal(subject)}, + email = {sql_literal(email)}, + full_name = {sql_literal(full_name)}, + status = CASE WHEN status IN ('pending_email', 'pending_approval', 'rejected') THEN status ELSE 'active' END, + is_admin = {sql_literal(is_admin)}, + auth_groups = {sql_literal(group_text)}, + updated_at = now() + WHERE id = {sql_literal(existing['id'])}; + """ + ) + return self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(existing['id'])}") or existing + self.db.execute( + f""" + INSERT INTO snreg_users (username, email, full_name, password_hash, status, email_verified_at, trusted_domain, is_admin, oidc_subject, auth_groups) + VALUES ({sql_literal(username)}, {sql_literal(email)}, {sql_literal(full_name)}, {sql_literal(hash_password(token_urlsafe()))}, 'active', now(), {sql_literal(self.trusted_domain(email))}, {sql_literal(is_admin)}, {sql_literal(subject)}, {sql_literal(group_text)}); + """ + ) + return self.db.one(f"SELECT * FROM snreg_users WHERE oidc_subject = {sql_literal(subject)}") or {} + + def registration_flyout(self, handler) -> str: + if self.current_user(handler): + return "" + return """ +
+ Create account +
+
+

Create your Sticknife account

+

Use a verified email address to request access. You will set your password after verification.

+
+
+ + + + +
+

Trusted email domains are auto-approved after verification. Other requests go to admin review.

+
+
+""" + + def registration_card(self, handler) -> str: + if self.current_user(handler): + return ( + '

You already have an account

' + '

It opens everything below. ' + f'Your account.

' + ) + return f""" +

Ask for an account

+
+ + + + +
+ +""" + + def register_form(self, handler, message: str = "") -> None: + content = Template((TEMPLATES / "landing.html").read_text()).substitute( + message=message, + registration_form=self.registration_card(handler), + payload=pantheon.payload(), + fallback=pantheon.fallback(), + ) + self.render( + handler, + "The Pantheon", # base.html appends " · Sticknife" + content, + head='', + body_class="landing", + shell_class="landing-shell", + ) + + def register_submit(self, handler) -> None: + data = self.form_data(handler) + username = data.get("username", "").strip().lower() + email = data.get("email", "").strip().lower() + full_name = data.get("full_name", "").strip() + if not username or not email or not full_name or "@" not in email: + return self.register_form(handler, '
Fill every field and use a valid email address.
') + placeholder_password_hash = hash_password(token_urlsafe()) + trusted = self.trusted_domain(email) + token = token_urlsafe() + try: + self.db.execute( + f""" + WITH inserted AS ( + INSERT INTO snreg_users (username, email, full_name, password_hash, trusted_domain, is_admin) + VALUES ({sql_literal(username)}, {sql_literal(email)}, {sql_literal(full_name)}, {sql_literal(placeholder_password_hash)}, {sql_literal(trusted)}, {sql_literal(email in self.config.app_admin_emails)}) + RETURNING id + ) + INSERT INTO snreg_email_tokens (token, user_id, expires_at) + SELECT {sql_literal(token)}, id, now() + interval '24 hours' FROM inserted; + """ + ) + except Exception: + return self.register_form(handler, '
That username or email is already registered.
') + verify_url = f"{self.config.base_url}/verify?token={token}" + try: + send_verification_email(self.config, email, verify_url) + message = '
Check your email for a verification link.
' + except Exception as exc: + message = f'
Registration saved, but email could not be sent: {esc(exc)}
' + self.register_form(handler, message) + + def verify_email(self, handler) -> None: + token = parse_qs(urlparse(handler.path).query).get("token", [""])[0] + row = self.db.one( + f""" + SELECT u.* FROM snreg_email_tokens t + JOIN snreg_users u ON u.id = t.user_id + WHERE t.token = {sql_literal(token)} + AND t.used_at IS NULL + AND t.expires_at > now() + """ + ) + if not row: + return self.render(handler, "Verification failed", '

The verification link is invalid or expired.

') + + trusted = row.get("trusted_domain") == "t" + next_status = "approved" if trusted else "pending_approval" + self.db.execute( + f""" + UPDATE snreg_email_tokens SET used_at = now() WHERE token = {sql_literal(token)}; + UPDATE snreg_users + SET email_verified_at = now(), status = {sql_literal(next_status)}, updated_at = now() + WHERE id = {sql_literal(row['id'])}; + """ + ) + + fresh = self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(row['id'])}") + user = fresh or row + result = provision_user(self.config, user, active=trusted) + if not result.ok: + failed_status = "ipa_error" if trusted else "pending_approval" + self.db.execute( + f""" + UPDATE snreg_users + SET status = {sql_literal(failed_status)}, + ipa_message = {sql_literal(result.message)}, + updated_at = now() + WHERE id = {sql_literal(row['id'])}; + INSERT INTO snreg_audit_log (target_user_id, action, detail) + VALUES ({sql_literal(row['id'])}, 'email_verify_provision', {sql_literal(result.message)}); + """ + ) + return self.render(handler, "Verified", '

Your email is verified, but your account could not be prepared automatically. An admin will review it.

') + + reset_url = "" + if self.config.ipa_mode == "cli": + reset_url = self.send_password_link(user["username"] or "", user["email"] or "") + + if trusted: + sync_result = trigger_ldap_sync(self.config) + self.db.execute( + f""" + INSERT INTO snreg_audit_log (target_user_id, action, detail) + VALUES ({sql_literal(row['id'])}, 'auto_provision', {sql_literal(result.message)}); + INSERT INTO snreg_audit_log (target_user_id, action, detail) + VALUES ({sql_literal(row['id'])}, 'authentik_sync', {sql_literal(sync_result.message)}); + DELETE FROM snreg_users WHERE id = {sql_literal(row['id'])}; + """ + ) + if reset_url: + return self.redirect(handler, reset_url) + return self.render(handler, "Verified", '

Your email is verified. Your account is ready for Sticknife services.

Continue') + + self.db.execute( + f""" + UPDATE snreg_users + SET status = 'pending_approval', + ipa_provisioned_at = now(), + ipa_message = {sql_literal(result.message)}, + updated_at = now() + WHERE id = {sql_literal(row['id'])}; + INSERT INTO snreg_audit_log (target_user_id, action, detail) + VALUES ({sql_literal(row['id'])}, 'pending_provision', {sql_literal(result.message)}); + """ + ) + if reset_url: + return self.redirect(handler, reset_url) + return self.render(handler, "Verified", '

Your email is verified. Your account exists, but an admin must approve it before it can access Sticknife services.

') + + def login_form(self, handler, message: str = "") -> None: + content = Template((TEMPLATES / "login.html").read_text()).substitute(message=message) + self.render(handler, "Sign in", content) + + def login_submit(self, handler) -> None: + data = self.form_data(handler) + identifier = data.get("identifier", "").strip().lower() + password = data.get("password", "") + user = self.db.one( + f"SELECT * FROM snreg_users WHERE lower(username) = {sql_literal(identifier)} OR lower(email) = {sql_literal(identifier)}" + ) + if not user or not verify_password(password, user.get("password_hash") or ""): + return self.login_form(handler, '
Invalid username or password.
') + handler.send_response(303) + handler.send_header("Location", "/profile") + self.set_session(handler, user["id"] or "") + handler.end_headers() + + def logout(self, handler) -> None: + try: + location = logout_url(self.config) + except Exception: + location = "/" + handler.send_response(303) + handler.send_header("Location", location) + handler.send_header("Set-Cookie", "snreg_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax") + handler.end_headers() + + def profile(self, handler, message: str = "") -> None: + user = self.require_user(handler) + if not user: + return + content = Template((TEMPLATES / "profile.html").read_text()).substitute( + message=message, + username=esc(user["username"]), + email=esc(user["email"]), + full_name=esc(user["full_name"]), + status=esc(user["status"]), + ) + self.render(handler, "Profile", content, wide=True) + + def profile_update(self, handler) -> None: + user = self.require_user(handler) + if not user: + return + data = self.form_data(handler) + full_name = data.get("full_name", "").strip() + email = data.get("email", "").strip().lower() + if not full_name or "@" not in email: + return self.profile(handler, '
Use a valid name and email.
') + self.db.execute( + f""" + UPDATE snreg_users + SET full_name = {sql_literal(full_name)}, email = {sql_literal(email)}, updated_at = now() + WHERE id = {sql_literal(user['id'])}; + INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) + VALUES ({sql_literal(user['id'])}, {sql_literal(user['id'])}, 'profile_update', 'profile fields updated'); + """ + ) + self.profile(handler, '
Profile updated.
') + + def profile_password_update(self, handler) -> None: + user = self.require_user(handler) + if not user: + return + data = self.form_data(handler) + old_password = data.get("old_password", "") + new_password = data.get("new_password", "") + confirm_password = data.get("confirm_password", "") + if len(new_password) < 12 or new_password != confirm_password: + return self.profile(handler, '
Use matching new passwords of at least 12 characters.
') + username = user["username"] or "" + verified = verify_ipa_password(self.config, username, old_password) + if not verified.ok: + return self.profile(handler, '
Old password did not match.
') + result = set_password(self.config, username, new_password) + if not result.ok: + return self.profile(handler, f'
Password could not be changed: {esc(result.message)}
') + self.db.execute( + f""" + INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) + VALUES ({sql_literal(user['id'])}, {sql_literal(user['id'])}, 'profile_password_change', 'password changed'); + """ + ) + self.profile(handler, '
Password changed.
') + + def password_request_form(self, handler, message: str = "") -> None: + content = Template((TEMPLATES / "password_request.html").read_text()).substitute(message=message) + self.render(handler, "Password reset", content) + + def password_request_submit(self, handler) -> None: + data = self.form_data(handler) + username = data.get("username", "").strip().lower() + email = data.get("email", "").strip().lower() + detail = "password reset requested" + if username and email and email_matches(self.config, username, email): + try: + self.send_password_link(username, email) + detail = "password reset email sent" + except Exception as exc: + detail = f"password reset email failed: {exc}" + self.db.execute( + f""" + INSERT INTO snreg_audit_log (action, detail) + VALUES ('password_request', {sql_literal(username + ' ' + detail)}); + """ + ) + message = '
If that account exists, a password reset link has been sent.
' + self.password_request_form(handler, message) + + def password_reset_form(self, handler, message: str = "") -> None: + token = parse_qs(urlparse(handler.path).query).get("token", [""])[0] + row = self.db.one( + f""" + SELECT * FROM snreg_password_tokens + WHERE token = {sql_literal(token)} + AND used_at IS NULL + AND expires_at > now() + """ + ) + if not row: + return self.render(handler, "Password reset", '

The password reset link is invalid or expired.

Request another link') + content = Template((TEMPLATES / "password_reset.html").read_text()).substitute( + message=message, + username=esc(row["username"]), + token=esc(token), + ) + self.render(handler, "Set password", content) + + def password_reset_submit(self, handler) -> None: + data = self.form_data(handler) + token = data.get("token", "") + password = data.get("password", "") + confirm_password = data.get("confirm_password", "") + row = self.db.one( + f""" + SELECT * FROM snreg_password_tokens + WHERE token = {sql_literal(token)} + AND used_at IS NULL + AND expires_at > now() + """ + ) + if not row: + return self.render(handler, "Password reset", '

The password reset link is invalid or expired.

Request another link') + if len(password) < 12 or password != confirm_password: + return self.password_reset_form(handler, '
Use matching passwords of at least 12 characters.
') + result = set_password(self.config, row["username"] or "", password) + if not result.ok: + return self.password_reset_form(handler, f'
Password could not be set: {esc(result.message)}
') + self.db.execute( + f""" + UPDATE snreg_password_tokens SET used_at = now() WHERE token = {sql_literal(token)}; + INSERT INTO snreg_audit_log (action, detail) + VALUES ('password_reset', {sql_literal(result.message)}); + """ + ) + self.render(handler, "Password set", '

Your password is set. You can now sign in.

Sign in') + + def admin(self, handler) -> None: + user = self.require_admin(handler) + if not user: + return + pending = self.db.rows("SELECT * FROM snreg_users WHERE status = 'pending_approval' ORDER BY created_at ASC") + unverified = self.db.rows("SELECT * FROM snreg_users WHERE status = 'pending_email' ORDER BY created_at ASC") + recent = self.db.rows("SELECT * FROM snreg_users ORDER BY created_at DESC LIMIT 25") + pending_rows = "".join(self.user_row(row, actions=True) for row in pending) or 'No pending approvals.' + unverified_rows = "".join(self.user_row(row, actions=False, delete_unverified=True) for row in unverified) or 'No unverified registrations.' + recent_rows = "".join(self.user_row(row, actions=False) for row in recent) + purge_disabled = "disabled" if not unverified else "" + content = Template((TEMPLATES / "admin.html").read_text()).substitute( + pending_rows=pending_rows, + unverified_rows=unverified_rows, + purge_disabled=purge_disabled, + recent_rows=recent_rows, + ) + self.render(handler, "Admin", content, wide=True) + + def user_row(self, row: dict[str, str | None], actions: bool, delete_unverified: bool = False) -> str: + controls = "" + if actions: + controls = f""" +
+
+ """ + elif delete_unverified: + controls = f""" +
+ """ + return f""" + + {esc(row['username'])} + {esc(row['email'])} + {esc(row['full_name'])} + {esc(row['status'])} + {controls} + + """ + + def admin_approve(self, handler) -> None: + admin = self.require_admin(handler) + if not admin: + return + user_id = self.form_data(handler).get("user_id", "") + user = self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(user_id)}") + if user: + already_provisioned = bool(user.get("ipa_provisioned_at")) + result = approve_user(self.config, user["username"] or "") if already_provisioned else provision_user(self.config, user, active=True) + if result.ok and self.config.ipa_mode == "cli": + if not already_provisioned: + self.send_password_link(user["username"] or "", user["email"] or "") + sync_result = trigger_ldap_sync(self.config) + self.db.execute( + f""" + INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) + VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'admin_approve', {sql_literal(result.message)}); + INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) + VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'authentik_sync', {sql_literal(sync_result.message)}); + DELETE FROM snreg_users WHERE id = {sql_literal(user_id)}; + """ + ) + else: + status = "active" if result.ok else "ipa_error" + self.db.execute( + f""" + UPDATE snreg_users + SET status = {sql_literal(status)}, + ipa_provisioned_at = CASE WHEN {sql_literal(result.ok)} THEN COALESCE(ipa_provisioned_at, now()) ELSE ipa_provisioned_at END, + ipa_message = {sql_literal(result.message)}, + updated_at = now() + WHERE id = {sql_literal(user_id)}; + INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) + VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'admin_approve', {sql_literal(result.message)}); + """ + ) + self.redirect(handler, "/admin") + + def admin_reject(self, handler) -> None: + admin = self.require_admin(handler) + if not admin: + return + user_id = self.form_data(handler).get("user_id", "") + self.db.execute( + f""" + UPDATE snreg_users SET status = 'rejected', updated_at = now() WHERE id = {sql_literal(user_id)}; + INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) + VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'admin_reject', 'registration rejected'); + """ + ) + self.redirect(handler, "/admin") + + def admin_delete_unverified(self, handler) -> None: + admin = self.require_admin(handler) + if not admin: + return + user_id = self.form_data(handler).get("user_id", "") + self.db.execute( + f""" + WITH deleted AS ( + DELETE FROM snreg_users + WHERE id = {sql_literal(user_id)} AND status = 'pending_email' + RETURNING id + ) + INSERT INTO snreg_audit_log (actor_user_id, action, detail) + SELECT {sql_literal(admin['id'])}, 'admin_delete_unverified', 'deleted pending_email user id ' || id + FROM deleted; + """ + ) + self.redirect(handler, "/admin") + + def admin_purge_unverified(self, handler) -> None: + admin = self.require_admin(handler) + if not admin: + return + self.db.execute( + f""" + WITH deleted AS ( + DELETE FROM snreg_users + WHERE status = 'pending_email' + RETURNING id + ), count_deleted AS ( + SELECT count(*) AS total FROM deleted + ) + INSERT INTO snreg_audit_log (actor_user_id, action, detail) + SELECT {sql_literal(admin['id'])}, 'admin_purge_unverified', 'purged ' || total || ' pending_email users' + FROM count_deleted; + """ + ) + self.redirect(handler, "/admin") diff --git a/sticknife-home.service.example b/sticknife-home.service.example new file mode 100644 index 0000000..cbd341d --- /dev/null +++ b/sticknife-home.service.example @@ -0,0 +1,33 @@ +# Template for the systemd unit. Copy to sticknife-home.service, edit the +# three host-specific lines below, then install it: +# +# cp sticknife-home.service.example sticknife-home.service +# $EDITOR sticknife-home.service +# sudo install -m 0644 sticknife-home.service /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl enable --now sticknife-home +# +# The app has no third-party dependencies, so the system python3 is enough +# (3.11+ is required for tomllib). Config is read from .env in the working +# directory -- see .env.example. + +[Unit] +Description=Sticknife public homepage +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# EDIT: absolute path to the checkout on this host. +WorkingDirectory=/opt/sticknife-home +EnvironmentFile=/opt/sticknife-home/.env +ExecStart=/usr/bin/python3 -m app +# Unbuffered so log lines reach the journal immediately. +Environment=PYTHONUNBUFFERED=1 +Restart=on-failure +RestartSec=5 +# EDIT: the account that owns the checkout. +User=sticknife +Group=sticknife + +[Install] +WantedBy=multi-user.target