Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

init

Aly Raffauf cc8c0403

+4946
+29
.github/workflows/docker.yml
··· 1 + name: Build and Push Web Server 2 + 3 + on: 4 + push: 5 + branches: [master] 6 + 7 + jobs: 8 + build: 9 + runs-on: ubuntu-latest 10 + permissions: 11 + contents: read 12 + packages: write 13 + 14 + steps: 15 + - uses: actions/checkout@v4 16 + 17 + - name: Log in to GitHub Container Registry 18 + uses: docker/login-action@v3 19 + with: 20 + registry: ghcr.io 21 + username: ${{ github.actor }} 22 + password: ${{ secrets.GITHUB_TOKEN }} 23 + 24 + - name: Build and push 25 + uses: docker/build-push-action@v6 26 + with: 27 + context: . 28 + push: true 29 + tags: ghcr.io/${{ github.repository }}:latest
+31
.github/workflows/pypi.yml
··· 1 + name: Build and Publish TUI 2 + 3 + on: 4 + push: 5 + tags: ["v*"] 6 + 7 + jobs: 8 + build: 9 + runs-on: ubuntu-latest 10 + 11 + steps: 12 + - uses: actions/checkout@v4 13 + 14 + - name: Install uv 15 + uses: astral-sh/setup-uv@v4 16 + 17 + - name: Install Tailwind CSS 18 + run: | 19 + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o tailwindcss 20 + chmod +x tailwindcss 21 + 22 + - name: Build CSS 23 + run: ./tailwindcss -i web/static/input.css -o web/static/style.css --minify 24 + 25 + - name: Build package 26 + run: uv build 27 + 28 + - name: Publish to PyPI 29 + env: 30 + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} 31 + run: uv publish
+20
.gitignore
··· 1 + # Python-generated files 2 + __pycache__/ 3 + *.py[oc] 4 + build/ 5 + dist/ 6 + wheels/ 7 + *.egg-info 8 + 9 + # Virtual environments 10 + .venv 11 + 12 + # Tailwind binary 13 + tailwindcss 14 + 15 + # Auth secrets and database 16 + secrets.json 17 + *.db 18 + 19 + # Seed scripts 20 + seeds/
+1
.python-version
··· 1 + 3.14
+35
Dockerfile
··· 1 + FROM python:3.14-slim 2 + 3 + WORKDIR /app 4 + 5 + # Install uv for fast dependency management 6 + COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv 7 + 8 + # Install Tailwind CSS standalone CLI 9 + ADD https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 /usr/local/bin/tailwindcss 10 + RUN chmod +x /usr/local/bin/tailwindcss 11 + 12 + # Copy dependency files first (layer caching) 13 + COPY pyproject.toml uv.lock ./ 14 + 15 + # Install dependencies 16 + RUN uv sync --frozen --no-dev 17 + 18 + # Copy application code 19 + COPY main.py ./ 20 + COPY core/ core/ 21 + COPY web/ web/ 22 + COPY lexicons/ lexicons/ 23 + 24 + # Build Tailwind CSS 25 + RUN tailwindcss -i web/static/input.css -o web/static/style.css --minify 26 + 27 + # Create data directory for secrets and database 28 + RUN mkdir -p /data 29 + 30 + ENV ATBOARDS_DATA_DIR=/data 31 + ENV PYTHONUNBUFFERED=1 32 + 33 + EXPOSE 8000 34 + 35 + CMD ["uv", "run", "hypercorn", "main:app", "--bind", "0.0.0.0:8000", "--workers", "3"]
README.md

This is a binary file and will not be displayed.

core/__init__.py

This is a binary file and will not be displayed.

core/auth/__init__.py

This is a binary file and will not be displayed.

+34
core/auth/config.py
··· 1 + """Client key generation and persistence.""" 2 + 3 + import json 4 + import os 5 + import time 6 + from pathlib import Path 7 + 8 + from authlib.jose import JsonWebKey 9 + 10 + 11 + def _generate_secret_key() -> str: 12 + return os.urandom(32).hex() 13 + 14 + 15 + def _generate_client_jwk() -> str: 16 + key = JsonWebKey.generate_key("EC", "P-256", is_private=True) 17 + key_dict = json.loads(key.as_json(is_private=True)) 18 + key_dict["kid"] = f"atboards-{int(time.time())}" 19 + return json.dumps(key_dict) 20 + 21 + 22 + def load_secrets(data_dir: str = ".") -> dict: 23 + """Load or generate secrets. Returns dict with secret_key and client_secret_jwk.""" 24 + secrets_path = Path(data_dir) / "secrets.json" 25 + 26 + if secrets_path.exists(): 27 + return json.loads(secrets_path.read_text()) 28 + 29 + secrets = { 30 + "secret_key": _generate_secret_key(), 31 + "client_secret_jwk": _generate_client_jwk(), 32 + } 33 + secrets_path.write_text(json.dumps(secrets, indent=2)) 34 + return secrets
+371
core/auth/oauth.py
··· 1 + """AT Protocol OAuth helpers. 2 + 3 + Async, framework-agnostic. Handles DPoP proof generation, PAR requests, 4 + token exchange, and authenticated PDS requests. 5 + 6 + Adapted from morsels (github.com/alyraffauf/morsels). 7 + """ 8 + 9 + import json 10 + import time 11 + import urllib.request 12 + 13 + import httpx 14 + from authlib.common.security import generate_token 15 + from authlib.jose import JsonWebKey, jwt 16 + from authlib.oauth2.rfc7636 import create_s256_code_challenge 17 + 18 + 19 + def is_safe_url(url: str) -> bool: 20 + """SSRF check — only allows HTTPS URLs with public hostnames.""" 21 + from urllib.parse import urlparse 22 + 23 + parts = urlparse(url) 24 + if not ( 25 + parts.scheme == "https" 26 + and parts.hostname is not None 27 + and parts.hostname == parts.netloc 28 + and parts.username is None 29 + and parts.password is None 30 + and parts.port is None 31 + ): 32 + return False 33 + segments = parts.hostname.split(".") 34 + if not ( 35 + len(segments) >= 2 36 + and segments[-1] not in ["local", "arpa", "internal", "localhost"] 37 + ): 38 + return False 39 + if segments[-1].isdigit(): 40 + return False 41 + return True 42 + 43 + 44 + def is_valid_authserver_meta(obj: dict, url: str) -> bool: 45 + """Validate authorization server metadata against atproto requirements.""" 46 + from urllib.parse import urlparse 47 + 48 + fetch_url = urlparse(url) 49 + issuer_url = urlparse(obj["issuer"]) 50 + assert issuer_url.hostname == fetch_url.hostname 51 + assert issuer_url.scheme == "https" 52 + assert "code" in obj["response_types_supported"] 53 + assert "authorization_code" in obj["grant_types_supported"] 54 + assert "refresh_token" in obj["grant_types_supported"] 55 + assert "S256" in obj["code_challenge_methods_supported"] 56 + assert "private_key_jwt" in obj["token_endpoint_auth_methods_supported"] 57 + assert "ES256" in obj["token_endpoint_auth_signing_alg_values_supported"] 58 + assert "atproto" in obj["scopes_supported"] 59 + assert obj["authorization_response_iss_parameter_supported"] is True 60 + assert obj["pushed_authorization_request_endpoint"] is not None 61 + assert obj["require_pushed_authorization_requests"] is True 62 + assert "ES256" in obj["dpop_signing_alg_values_supported"] 63 + assert obj["client_id_metadata_document_supported"] is True 64 + return True 65 + 66 + 67 + async def resolve_pds_authserver(client: httpx.AsyncClient, pds_url: str) -> str: 68 + """Given a PDS URL, find its authorization server.""" 69 + assert is_safe_url(pds_url) 70 + resp = await client.get(f"{pds_url}/.well-known/oauth-protected-resource") 71 + resp.raise_for_status() 72 + return resp.json()["authorization_servers"][0] 73 + 74 + 75 + async def fetch_authserver_meta(client: httpx.AsyncClient, url: str) -> dict: 76 + """Fetch and validate authorization server metadata.""" 77 + assert is_safe_url(url) 78 + resp = await client.get(f"{url}/.well-known/oauth-authorization-server") 79 + resp.raise_for_status() 80 + meta = resp.json() 81 + assert is_valid_authserver_meta(meta, url) 82 + return meta 83 + 84 + 85 + def client_assertion_jwt(client_id: str, authserver_url: str, client_secret_jwk) -> str: 86 + """Create a signed JWT asserting our client identity.""" 87 + return jwt.encode( 88 + {"alg": "ES256", "kid": client_secret_jwk["kid"]}, 89 + { 90 + "iss": client_id, 91 + "sub": client_id, 92 + "aud": authserver_url, 93 + "jti": generate_token(), 94 + "iat": int(time.time()), 95 + "exp": int(time.time()) + 60, 96 + }, 97 + client_secret_jwk, 98 + ).decode("utf-8") 99 + 100 + 101 + def authserver_dpop_jwt(method: str, url: str, nonce: str, dpop_private_jwk) -> str: 102 + """Create a DPoP proof JWT for auth server requests.""" 103 + dpop_pub_jwk = json.loads(dpop_private_jwk.as_json(is_private=False)) 104 + body = { 105 + "jti": generate_token(), 106 + "htm": method, 107 + "htu": url, 108 + "iat": int(time.time()), 109 + "exp": int(time.time()) + 30, 110 + } 111 + if nonce: 112 + body["nonce"] = nonce 113 + return jwt.encode( 114 + {"typ": "dpop+jwt", "alg": "ES256", "jwk": dpop_pub_jwk}, 115 + body, 116 + dpop_private_jwk, 117 + ).decode("utf-8") 118 + 119 + 120 + def pds_dpop_jwt(method: str, url: str, nonce: str, access_token: str, dpop_private_jwk) -> str: 121 + """Create a DPoP proof JWT for PDS requests (includes ath claim).""" 122 + dpop_pub_jwk = json.loads(dpop_private_jwk.as_json(is_private=False)) 123 + body = { 124 + "jti": generate_token(), 125 + "htm": method, 126 + "htu": url, 127 + "iat": int(time.time()), 128 + "exp": int(time.time()) + 10, 129 + "ath": create_s256_code_challenge(access_token), 130 + } 131 + if nonce: 132 + body["nonce"] = nonce 133 + return jwt.encode( 134 + {"typ": "dpop+jwt", "alg": "ES256", "jwk": dpop_pub_jwk}, 135 + body, 136 + dpop_private_jwk, 137 + ).decode("utf-8") 138 + 139 + 140 + def _parse_www_authenticate(data: str): 141 + scheme, _, params = data.partition(" ") 142 + items = urllib.request.parse_http_list(params) 143 + opts = urllib.request.parse_keqv_list(items) 144 + return scheme, opts 145 + 146 + 147 + def is_use_dpop_nonce_error(resp: httpx.Response) -> bool: 148 + """Check if a response is asking us to retry with a new DPoP nonce.""" 149 + if resp.status_code not in [400, 401]: 150 + return False 151 + www_authenticate = resp.headers.get("WWW-Authenticate") 152 + if www_authenticate: 153 + try: 154 + scheme, params = _parse_www_authenticate(www_authenticate) 155 + if scheme.lower() == "dpop" and params.get("error") == "use_dpop_nonce": 156 + return True 157 + except Exception: 158 + pass 159 + try: 160 + json_body = resp.json() 161 + if isinstance(json_body, dict) and json_body.get("error") == "use_dpop_nonce": 162 + return True 163 + except Exception: 164 + pass 165 + return False 166 + 167 + 168 + async def auth_server_post( 169 + client: httpx.AsyncClient, 170 + authserver_url: str, 171 + client_id: str, 172 + client_secret_jwk, 173 + dpop_private_jwk, 174 + dpop_nonce: str, 175 + post_url: str, 176 + post_data: dict, 177 + ) -> tuple[str, httpx.Response]: 178 + """POST to auth server with client assertion and DPoP, handling nonce rotation.""" 179 + assertion = client_assertion_jwt(client_id, authserver_url, client_secret_jwk) 180 + post_data = { 181 + **post_data, 182 + "client_id": client_id, 183 + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 184 + "client_assertion": assertion, 185 + } 186 + 187 + assert is_safe_url(post_url) 188 + dpop_proof = authserver_dpop_jwt("POST", post_url, dpop_nonce, dpop_private_jwk) 189 + resp = await client.post(post_url, data=post_data, headers={"DPoP": dpop_proof}) 190 + 191 + if is_use_dpop_nonce_error(resp): 192 + dpop_nonce = resp.headers["DPoP-Nonce"] 193 + dpop_proof = authserver_dpop_jwt("POST", post_url, dpop_nonce, dpop_private_jwk) 194 + resp = await client.post(post_url, data=post_data, headers={"DPoP": dpop_proof}) 195 + 196 + return dpop_nonce, resp 197 + 198 + 199 + async def send_par_request( 200 + client: httpx.AsyncClient, 201 + authserver_url: str, 202 + authserver_meta: dict, 203 + login_hint: str, 204 + client_id: str, 205 + redirect_uri: str, 206 + scope: str, 207 + client_secret_jwk, 208 + dpop_private_jwk, 209 + ) -> tuple[str, str, str, dict]: 210 + """Send a Pushed Authorization Request. 211 + 212 + Returns (pkce_verifier, state, dpop_nonce, response_json). 213 + """ 214 + par_url = authserver_meta["pushed_authorization_request_endpoint"] 215 + state = generate_token() 216 + pkce_verifier = generate_token(48) 217 + code_challenge = create_s256_code_challenge(pkce_verifier) 218 + 219 + par_body = { 220 + "response_type": "code", 221 + "code_challenge": code_challenge, 222 + "code_challenge_method": "S256", 223 + "state": state, 224 + "redirect_uri": redirect_uri, 225 + "scope": scope, 226 + } 227 + if login_hint: 228 + par_body["login_hint"] = login_hint 229 + 230 + dpop_nonce, resp = await auth_server_post( 231 + client=client, 232 + authserver_url=authserver_url, 233 + client_id=client_id, 234 + client_secret_jwk=client_secret_jwk, 235 + dpop_private_jwk=dpop_private_jwk, 236 + dpop_nonce="", 237 + post_url=par_url, 238 + post_data=par_body, 239 + ) 240 + resp.raise_for_status() 241 + return pkce_verifier, state, dpop_nonce, resp.json() 242 + 243 + 244 + async def exchange_code( 245 + client: httpx.AsyncClient, 246 + auth_request: dict, 247 + code: str, 248 + client_id: str, 249 + redirect_uri: str, 250 + client_secret_jwk, 251 + ) -> tuple[dict, str]: 252 + """Exchange authorization code for tokens. Returns (token_body, dpop_nonce).""" 253 + authserver_url = auth_request["authserver_iss"] 254 + authserver_meta = await fetch_authserver_meta(client, authserver_url) 255 + token_url = authserver_meta["token_endpoint"] 256 + dpop_private_jwk = JsonWebKey.import_key(json.loads(auth_request["dpop_private_jwk"])) 257 + 258 + dpop_nonce, resp = await auth_server_post( 259 + client=client, 260 + authserver_url=authserver_url, 261 + client_id=client_id, 262 + client_secret_jwk=client_secret_jwk, 263 + dpop_private_jwk=dpop_private_jwk, 264 + dpop_nonce=auth_request["dpop_authserver_nonce"], 265 + post_url=token_url, 266 + post_data={ 267 + "redirect_uri": redirect_uri, 268 + "grant_type": "authorization_code", 269 + "code": code, 270 + "code_verifier": auth_request["pkce_verifier"], 271 + }, 272 + ) 273 + resp.raise_for_status() 274 + return resp.json(), dpop_nonce 275 + 276 + 277 + async def refresh_tokens( 278 + client: httpx.AsyncClient, 279 + session: dict, 280 + client_id: str, 281 + client_secret_jwk, 282 + ) -> tuple[dict, str]: 283 + """Refresh an access token. Returns (token_body, dpop_nonce).""" 284 + authserver_url = session["authserver_iss"] 285 + authserver_meta = await fetch_authserver_meta(client, authserver_url) 286 + token_url = authserver_meta["token_endpoint"] 287 + dpop_private_jwk = JsonWebKey.import_key(json.loads(session["dpop_private_jwk"])) 288 + 289 + dpop_nonce, resp = await auth_server_post( 290 + client=client, 291 + authserver_url=authserver_url, 292 + client_id=client_id, 293 + client_secret_jwk=client_secret_jwk, 294 + dpop_private_jwk=dpop_private_jwk, 295 + dpop_nonce=session["dpop_authserver_nonce"], 296 + post_url=token_url, 297 + post_data={ 298 + "grant_type": "refresh_token", 299 + "refresh_token": session["refresh_token"], 300 + }, 301 + ) 302 + resp.raise_for_status() 303 + return resp.json(), dpop_nonce 304 + 305 + 306 + async def revoke_tokens( 307 + client: httpx.AsyncClient, 308 + session: dict, 309 + client_id: str, 310 + client_secret_jwk, 311 + ) -> None: 312 + """Revoke access and refresh tokens.""" 313 + authserver_url = session["authserver_iss"] 314 + authserver_meta = await fetch_authserver_meta(client, authserver_url) 315 + revoke_url = authserver_meta.get("revocation_endpoint") 316 + if not revoke_url: 317 + return 318 + 319 + dpop_private_jwk = JsonWebKey.import_key(json.loads(session["dpop_private_jwk"])) 320 + dpop_nonce = session["dpop_authserver_nonce"] 321 + 322 + for token_type in ["access_token", "refresh_token"]: 323 + dpop_nonce, resp = await auth_server_post( 324 + client=client, 325 + authserver_url=authserver_url, 326 + client_id=client_id, 327 + client_secret_jwk=client_secret_jwk, 328 + dpop_private_jwk=dpop_private_jwk, 329 + dpop_nonce=dpop_nonce, 330 + post_url=revoke_url, 331 + post_data={ 332 + "token": session[token_type], 333 + "token_type_hint": token_type, 334 + }, 335 + ) 336 + 337 + 338 + async def pds_request( 339 + client: httpx.AsyncClient, 340 + method: str, 341 + url: str, 342 + session: dict, 343 + session_updater, 344 + body: dict | None = None, 345 + ) -> httpx.Response: 346 + """Make an authenticated request to a user's PDS with DPoP. 347 + 348 + session_updater is a callable(did, field, value) to persist nonce updates. 349 + """ 350 + dpop_private_jwk = JsonWebKey.import_key(json.loads(session["dpop_private_jwk"])) 351 + dpop_nonce = session.get("dpop_pds_nonce") or "" 352 + access_token = session["access_token"] 353 + 354 + for _ in range(2): 355 + dpop_proof = pds_dpop_jwt(method, url, dpop_nonce, access_token, dpop_private_jwk) 356 + headers = { 357 + "Authorization": f"DPoP {access_token}", 358 + "DPoP": dpop_proof, 359 + } 360 + if method == "GET": 361 + resp = await client.get(url, headers=headers) 362 + else: 363 + resp = await client.post(url, headers=headers, json=body) 364 + 365 + if is_use_dpop_nonce_error(resp): 366 + dpop_nonce = resp.headers["DPoP-Nonce"] 367 + await session_updater(session["did"], "dpop_pds_nonce", dpop_nonce) 368 + continue 369 + break 370 + 371 + return resp
+129
core/auth/session.py
··· 1 + """OAuth session storage using SQLite. Framework-agnostic.""" 2 + 3 + import sqlite3 4 + 5 + SCHEMA = """ 6 + CREATE TABLE IF NOT EXISTS oauth_auth_request ( 7 + state TEXT NOT NULL PRIMARY KEY, 8 + authserver_iss TEXT NOT NULL, 9 + did TEXT, 10 + handle TEXT, 11 + pds_url TEXT, 12 + pkce_verifier TEXT NOT NULL, 13 + scope TEXT NOT NULL, 14 + dpop_authserver_nonce TEXT NOT NULL, 15 + dpop_private_jwk TEXT NOT NULL 16 + ); 17 + 18 + CREATE TABLE IF NOT EXISTS oauth_session ( 19 + did TEXT NOT NULL PRIMARY KEY, 20 + handle TEXT, 21 + pds_url TEXT NOT NULL, 22 + authserver_iss TEXT NOT NULL, 23 + access_token TEXT, 24 + refresh_token TEXT, 25 + dpop_authserver_nonce TEXT NOT NULL, 26 + dpop_pds_nonce TEXT, 27 + dpop_private_jwk TEXT NOT NULL 28 + ); 29 + """ 30 + 31 + 32 + class SessionStore: 33 + """SQLite-backed OAuth session store.""" 34 + 35 + def __init__(self, db_path: str = "atboards.db"): 36 + self.db_path = db_path 37 + self._init_db() 38 + 39 + def _init_db(self): 40 + con = sqlite3.connect(self.db_path) 41 + con.executescript(SCHEMA) 42 + con.close() 43 + 44 + def _connect(self) -> sqlite3.Connection: 45 + con = sqlite3.connect(self.db_path) 46 + con.row_factory = sqlite3.Row 47 + return con 48 + 49 + # --- Auth requests (temporary, during login flow) --- 50 + 51 + def save_auth_request(self, **kwargs): 52 + con = self._connect() 53 + con.execute( 54 + """INSERT OR REPLACE INTO oauth_auth_request 55 + (state, authserver_iss, did, handle, pds_url, pkce_verifier, 56 + scope, dpop_authserver_nonce, dpop_private_jwk) 57 + VALUES (:state, :authserver_iss, :did, :handle, :pds_url, 58 + :pkce_verifier, :scope, :dpop_authserver_nonce, :dpop_private_jwk)""", 59 + kwargs, 60 + ) 61 + con.commit() 62 + con.close() 63 + 64 + def get_auth_request(self, state: str) -> dict | None: 65 + con = self._connect() 66 + row = con.execute( 67 + "SELECT * FROM oauth_auth_request WHERE state = ?", [state] 68 + ).fetchone() 69 + con.close() 70 + return dict(row) if row else None 71 + 72 + def delete_auth_request(self, state: str): 73 + con = self._connect() 74 + con.execute("DELETE FROM oauth_auth_request WHERE state = ?", [state]) 75 + con.commit() 76 + con.close() 77 + 78 + # --- Sessions (persistent, per logged-in user) --- 79 + 80 + def save_session(self, **kwargs): 81 + con = self._connect() 82 + con.execute( 83 + """INSERT OR REPLACE INTO oauth_session 84 + (did, handle, pds_url, authserver_iss, access_token, refresh_token, 85 + dpop_authserver_nonce, dpop_pds_nonce, dpop_private_jwk) 86 + VALUES (:did, :handle, :pds_url, :authserver_iss, :access_token, 87 + :refresh_token, :dpop_authserver_nonce, :dpop_pds_nonce, 88 + :dpop_private_jwk)""", 89 + kwargs, 90 + ) 91 + con.commit() 92 + con.close() 93 + 94 + def get_session(self, did: str) -> dict | None: 95 + con = self._connect() 96 + row = con.execute( 97 + "SELECT * FROM oauth_session WHERE did = ?", [did] 98 + ).fetchone() 99 + con.close() 100 + return dict(row) if row else None 101 + 102 + ALLOWED_FIELDS = {"dpop_pds_nonce", "dpop_authserver_nonce", "access_token", "refresh_token"} 103 + 104 + def update_session_field(self, did: str, field: str, value: str): 105 + if field not in self.ALLOWED_FIELDS: 106 + raise ValueError(f"Invalid field: {field}") 107 + con = self._connect() 108 + con.execute( 109 + f"UPDATE oauth_session SET {field} = ? WHERE did = ?", [value, did] 110 + ) 111 + con.commit() 112 + con.close() 113 + 114 + def update_session_tokens(self, did: str, access_token: str, refresh_token: str, dpop_nonce: str): 115 + con = self._connect() 116 + con.execute( 117 + """UPDATE oauth_session 118 + SET access_token = ?, refresh_token = ?, dpop_authserver_nonce = ? 119 + WHERE did = ?""", 120 + [access_token, refresh_token, dpop_nonce, did], 121 + ) 122 + con.commit() 123 + con.close() 124 + 125 + def delete_session(self, did: str): 126 + con = self._connect() 127 + con.execute("DELETE FROM oauth_session WHERE did = ?", [did]) 128 + con.commit() 129 + con.close()
+75
core/constellation.py
··· 1 + import httpx 2 + 3 + from core.models import BacklinkRef, BacklinksResponse 4 + 5 + BASE_URL = "https://constellation.microcosm.blue/xrpc" 6 + 7 + 8 + async def get_backlinks( 9 + client: httpx.AsyncClient, 10 + subject: str, 11 + source: str, 12 + limit: int = 50, 13 + cursor: str | None = None, 14 + ) -> BacklinksResponse: 15 + """Query Constellation for records that link to a subject.""" 16 + params: dict[str, str | int] = { 17 + "subject": subject, 18 + "source": source, 19 + "limit": limit, 20 + } 21 + if cursor is not None: 22 + params["cursor"] = cursor 23 + resp = await client.get( 24 + f"{BASE_URL}/blue.microcosm.links.getBacklinks", 25 + params=params, 26 + ) 27 + resp.raise_for_status() 28 + data = resp.json() 29 + return BacklinksResponse( 30 + total=data["total"], 31 + records=[ 32 + BacklinkRef(did=r["did"], collection=r["collection"], rkey=r["rkey"]) 33 + for r in data["records"] 34 + ], 35 + cursor=data.get("cursor"), 36 + ) 37 + 38 + 39 + async def get_threads( 40 + client: httpx.AsyncClient, 41 + board_uri: str, 42 + limit: int = 50, 43 + cursor: str | None = None, 44 + ) -> BacklinksResponse: 45 + """Get threads pointing at a board.""" 46 + return await get_backlinks( 47 + client, subject=board_uri, source="xyz.atboards.thread:board", 48 + limit=limit, cursor=cursor, 49 + ) 50 + 51 + 52 + async def get_news( 53 + client: httpx.AsyncClient, 54 + site_uri: str, 55 + limit: int = 50, 56 + cursor: str | None = None, 57 + ) -> BacklinksResponse: 58 + """Get news posts pointing at a site.""" 59 + return await get_backlinks( 60 + client, subject=site_uri, source="xyz.atboards.news:site", 61 + limit=limit, cursor=cursor, 62 + ) 63 + 64 + 65 + async def get_replies( 66 + client: httpx.AsyncClient, 67 + thread_uri: str, 68 + limit: int = 50, 69 + cursor: str | None = None, 70 + ) -> BacklinksResponse: 71 + """Get replies pointing at a thread.""" 72 + return await get_backlinks( 73 + client, subject=thread_uri, source="xyz.atboards.reply:subject", 74 + limit=limit, cursor=cursor, 75 + )
+6
core/filters.py
··· 1 + from core.models import Record 2 + 3 + 4 + def filter_banned(records: list[Record], banned_dids: set[str]) -> list[Record]: 5 + """Remove records authored by banned DIDs.""" 6 + return [r for r in records if r.uri.split("/")[2] not in banned_dids]
+130
core/models.py
··· 1 + from dataclasses import dataclass 2 + 3 + 4 + # errors 5 + 6 + 7 + class BBSNotFoundError(Exception): 8 + """Handle could not be resolved.""" 9 + 10 + 11 + class NoBBSError(Exception): 12 + """Handle resolved but has no site record.""" 13 + 14 + 15 + class NetworkError(Exception): 16 + """Slingshot or Constellation is unreachable.""" 17 + 18 + 19 + # microcosm response types 20 + 21 + 22 + @dataclass 23 + class MiniDoc: 24 + did: str 25 + handle: str 26 + pds: str | None = None 27 + signing_key: str | None = None 28 + 29 + 30 + @dataclass 31 + class BacklinkRef: 32 + """A single backlink from Constellation (record locator, no content).""" 33 + 34 + did: str 35 + collection: str 36 + rkey: str 37 + 38 + @property 39 + def uri(self) -> str: 40 + return f"at://{self.did}/{self.collection}/{self.rkey}" 41 + 42 + 43 + @dataclass 44 + class BacklinksResponse: 45 + """Full Constellation getBacklinks response.""" 46 + 47 + total: int 48 + records: list[BacklinkRef] 49 + cursor: str | None = None 50 + 51 + 52 + @dataclass 53 + class Record: 54 + """A single record from Slingshot getRecord.""" 55 + 56 + uri: str 57 + cid: str 58 + value: dict 59 + 60 + 61 + # lexicons 62 + 63 + 64 + @dataclass 65 + class Board: 66 + """xyz.atboards.board — a subforum defined by the sysop.""" 67 + 68 + slug: str 69 + name: str 70 + description: str 71 + created_at: str 72 + updated_at: str | None = None 73 + 74 + 75 + @dataclass 76 + class News: 77 + """xyz.atboards.news — a sysop announcement.""" 78 + 79 + tid: str 80 + site_uri: str 81 + title: str 82 + body: str 83 + created_at: str 84 + 85 + 86 + @dataclass 87 + class Site: 88 + """xyz.atboards.site/self — the BBS front door.""" 89 + 90 + name: str 91 + description: str 92 + intro: str 93 + boards: list[Board] 94 + banned_dids: set[str] 95 + created_at: str 96 + updated_at: str | None = None 97 + 98 + 99 + @dataclass 100 + class Thread: 101 + """xyz.atboards.thread — a user's thread on a board.""" 102 + 103 + uri: str 104 + board_uri: str 105 + title: str 106 + body: str 107 + created_at: str 108 + author: MiniDoc 109 + updated_at: str | None = None 110 + 111 + 112 + @dataclass 113 + class Reply: 114 + """xyz.atboards.reply — a user's reply to a thread.""" 115 + 116 + uri: str 117 + subject_uri: str 118 + body: str 119 + created_at: str 120 + author: MiniDoc 121 + updated_at: str | None = None 122 + 123 + 124 + @dataclass 125 + class BBS: 126 + """Fully resolved BBS: resolve_bbs(handle).""" 127 + 128 + identity: MiniDoc 129 + site: Site 130 + news: list[News]
+157
core/records.py
··· 1 + """Shared record operations — create, delete, hydrate. 2 + 3 + Framework-agnostic. Used by both web and TUI. 4 + """ 5 + 6 + import httpx 7 + 8 + from core.constellation import get_replies, get_threads 9 + from core.filters import filter_banned 10 + from core.models import BBS, Board, Reply, Thread 11 + from core.slingshot import get_records_batch, resolve_identities_batch 12 + from core.util import now_iso 13 + 14 + 15 + async def hydrate_threads( 16 + client: httpx.AsyncClient, 17 + bbs: BBS, 18 + board: Board, 19 + cursor: str | None = None, 20 + ) -> tuple[list[Thread], str | None]: 21 + """Fetch and hydrate threads for a board.""" 22 + board_uri = f"at://{bbs.identity.did}/xyz.atboards.board/{board.slug}" 23 + backlinks = await get_threads(client, board_uri, cursor=cursor) 24 + records = await get_records_batch(client, backlinks.records) 25 + records = filter_banned(records, bbs.site.banned_dids) 26 + 27 + dids = [r.uri.split("/")[2] for r in records] 28 + authors = await resolve_identities_batch(client, dids) 29 + 30 + threads = [ 31 + Thread( 32 + uri=r.uri, 33 + board_uri=r.value["board"], 34 + title=r.value["title"], 35 + body=r.value["body"], 36 + created_at=r.value["createdAt"], 37 + author=authors[r.uri.split("/")[2]], 38 + updated_at=r.value.get("updatedAt"), 39 + ) 40 + for r in records 41 + if r.uri.split("/")[2] in authors 42 + ] 43 + threads.sort(key=lambda t: t.created_at, reverse=True) 44 + return threads, backlinks.cursor 45 + 46 + 47 + async def hydrate_replies( 48 + client: httpx.AsyncClient, 49 + bbs: BBS, 50 + thread: Thread, 51 + cursor: str | None = None, 52 + ) -> tuple[list[Reply], str | None]: 53 + """Fetch and hydrate replies for a thread.""" 54 + backlinks = await get_replies(client, thread.uri, cursor=cursor) 55 + records = await get_records_batch(client, backlinks.records) 56 + records = filter_banned(records, bbs.site.banned_dids) 57 + 58 + dids = [r.uri.split("/")[2] for r in records] 59 + authors = await resolve_identities_batch(client, dids) 60 + 61 + replies = [ 62 + Reply( 63 + uri=r.uri, 64 + subject_uri=r.value["subject"], 65 + body=r.value["body"], 66 + created_at=r.value["createdAt"], 67 + author=authors[r.uri.split("/")[2]], 68 + updated_at=r.value.get("updatedAt"), 69 + ) 70 + for r in records 71 + if r.uri.split("/")[2] in authors 72 + ] 73 + replies.sort(key=lambda t: t.created_at) 74 + return replies, backlinks.cursor 75 + 76 + 77 + async def _pds_post( 78 + client: httpx.AsyncClient, 79 + session: dict, 80 + endpoint: str, 81 + body: dict, 82 + session_updater=None, 83 + ) -> httpx.Response: 84 + """POST to a user's PDS, using DPoP if available, Bearer otherwise.""" 85 + url = f"{session['pds_url']}/xrpc/{endpoint}" 86 + 87 + if "dpop_private_jwk" in session and session["dpop_private_jwk"]: 88 + from core.auth.oauth import pds_request 89 + async def _noop(*a): pass 90 + updater = session_updater or _noop 91 + return await pds_request(client, "POST", url, session, updater, body=body) 92 + 93 + resp = await client.post( 94 + url, 95 + headers={"Authorization": f"Bearer {session['access_token']}"}, 96 + json=body, 97 + ) 98 + return resp 99 + 100 + 101 + async def create_thread_record( 102 + client: httpx.AsyncClient, 103 + session: dict, 104 + board_uri: str, 105 + title: str, 106 + body: str, 107 + session_updater=None, 108 + ) -> httpx.Response: 109 + """Create a thread record in the user's repo.""" 110 + return await _pds_post(client, session, "com.atproto.repo.createRecord", { 111 + "repo": session["did"], 112 + "collection": "xyz.atboards.thread", 113 + "record": { 114 + "$type": "xyz.atboards.thread", 115 + "board": board_uri, 116 + "title": title, 117 + "body": body, 118 + "createdAt": now_iso(), 119 + }, 120 + }, session_updater) 121 + 122 + 123 + async def create_reply_record( 124 + client: httpx.AsyncClient, 125 + session: dict, 126 + thread_uri: str, 127 + body: str, 128 + session_updater=None, 129 + ) -> httpx.Response: 130 + """Create a reply record in the user's repo.""" 131 + return await _pds_post(client, session, "com.atproto.repo.createRecord", { 132 + "repo": session["did"], 133 + "collection": "xyz.atboards.reply", 134 + "record": { 135 + "$type": "xyz.atboards.reply", 136 + "subject": thread_uri, 137 + "body": body, 138 + "createdAt": now_iso(), 139 + }, 140 + }, session_updater) 141 + 142 + 143 + async def delete_record( 144 + client: httpx.AsyncClient, 145 + session: dict, 146 + collection: str, 147 + rkey: str, 148 + session_updater=None, 149 + ) -> httpx.Response: 150 + """Delete a record from the user's repo.""" 151 + resp = await _pds_post(client, session, "com.atproto.repo.deleteRecord", { 152 + "repo": session["did"], 153 + "collection": collection, 154 + "rkey": rkey, 155 + }, session_updater) 156 + resp.raise_for_status() 157 + return resp
+83
core/resolver.py
··· 1 + import asyncio 2 + 3 + import httpx 4 + 5 + from core.models import BBS, Board, News, Site, BBSNotFoundError, NoBBSError, NetworkError 6 + from core.constellation import get_news 7 + from core.slingshot import get_record, get_records_batch, resolve_identity 8 + 9 + 10 + async def resolve_bbs(client: httpx.AsyncClient, handle: str) -> BBS: 11 + """Handle -> fully resolved BBS config.""" 12 + try: 13 + identity = await resolve_identity(client, handle) 14 + except httpx.HTTPStatusError: 15 + raise BBSNotFoundError(f"Could not resolve handle: {handle}") 16 + except httpx.TransportError: 17 + raise NetworkError("Could not reach the network.") 18 + 19 + try: 20 + site_record = await get_record( 21 + client, identity.did, "xyz.atboards.site", "self" 22 + ) 23 + except httpx.HTTPStatusError: 24 + raise NoBBSError(f"{handle} isn't running a BBS.") 25 + except httpx.TransportError: 26 + raise NetworkError("Could not reach the network.") 27 + 28 + sv = site_record.value 29 + site_uri = f"at://{identity.did}/xyz.atboards.site/self" 30 + 31 + # Fetch boards and news backlinks concurrently 32 + board_slugs = sv["boards"] 33 + board_tasks = [ 34 + get_record(client, identity.did, "xyz.atboards.board", slug) 35 + for slug in board_slugs 36 + ] 37 + news_task = get_news(client, site_uri) 38 + 39 + results = await asyncio.gather(*board_tasks, news_task, return_exceptions=True) 40 + board_records = results[:-1] 41 + news_result = results[-1] 42 + 43 + boards = [ 44 + Board( 45 + slug=slug, 46 + name=r.value["name"], 47 + description=r.value["description"], 48 + created_at=r.value["createdAt"], 49 + updated_at=r.value.get("updatedAt"), 50 + ) 51 + for slug, r in zip(board_slugs, board_records) 52 + if not isinstance(r, BaseException) 53 + ] 54 + 55 + # Hydrate news records (only from the sysop's repo) 56 + if isinstance(news_result, BaseException): 57 + news_records = [] 58 + else: 59 + sysop_news = [r for r in news_result.records if r.did == identity.did] 60 + news_records = await get_records_batch(client, sysop_news) 61 + news = [ 62 + News( 63 + tid=r.uri.split("/")[-1], 64 + site_uri=r.value["site"], 65 + title=r.value["title"], 66 + body=r.value["body"], 67 + created_at=r.value["createdAt"], 68 + ) 69 + for r in news_records 70 + ] 71 + news.sort(key=lambda n: n.created_at, reverse=True) 72 + 73 + site = Site( 74 + name=sv["name"], 75 + description=sv["description"], 76 + intro=sv["intro"], 77 + boards=boards, 78 + banned_dids=set(sv["bannedDids"]), 79 + created_at=sv.get("createdAt", ""), 80 + updated_at=sv.get("updatedAt"), 81 + ) 82 + 83 + return BBS(identity=identity, site=site, news=news)
+69
core/slingshot.py
··· 1 + import asyncio 2 + 3 + import httpx 4 + 5 + from core.models import BacklinkRef, MiniDoc, Record 6 + 7 + BASE_URL = "https://slingshot.microcosm.blue/xrpc" 8 + 9 + 10 + async def get_record( 11 + client: httpx.AsyncClient, repo: str, collection: str, rkey: str 12 + ) -> Record: 13 + """Fetch a single record by repo/collection/rkey.""" 14 + resp = await client.get( 15 + f"{BASE_URL}/com.atproto.repo.getRecord", 16 + params={"repo": repo, "collection": collection, "rkey": rkey}, 17 + ) 18 + resp.raise_for_status() 19 + data = resp.json() 20 + return Record(uri=data["uri"], cid=data["cid"], value=data["value"]) 21 + 22 + 23 + async def get_record_by_uri(client: httpx.AsyncClient, at_uri: str) -> Record: 24 + """Fetch a single record by AT-URI.""" 25 + resp = await client.get( 26 + f"{BASE_URL}/blue.microcosm.repo.getRecordByUri", 27 + params={"uri": at_uri}, 28 + ) 29 + resp.raise_for_status() 30 + data = resp.json() 31 + return Record(uri=data["uri"], cid=data["cid"], value=data["value"]) 32 + 33 + 34 + async def resolve_identity(client: httpx.AsyncClient, identifier: str) -> MiniDoc: 35 + """Resolve a handle or DID to a MiniDoc.""" 36 + resp = await client.get( 37 + f"{BASE_URL}/blue.microcosm.identity.resolveMiniDoc", 38 + params={"identifier": identifier}, 39 + ) 40 + resp.raise_for_status() 41 + data = resp.json() 42 + return MiniDoc( 43 + did=data["did"], 44 + handle=data["handle"], 45 + pds=data.get("pds"), 46 + signing_key=data.get("signing_key"), 47 + ) 48 + 49 + 50 + async def resolve_identities_batch( 51 + client: httpx.AsyncClient, dids: list[str] 52 + ) -> dict[str, MiniDoc]: 53 + """Resolve multiple DIDs concurrently, skipping failures.""" 54 + tasks = [resolve_identity(client, did) for did in dids] 55 + results = await asyncio.gather(*tasks, return_exceptions=True) 56 + return { 57 + r.did: r for r in results if isinstance(r, MiniDoc) 58 + } 59 + 60 + 61 + async def get_records_batch( 62 + client: httpx.AsyncClient, refs: list[BacklinkRef] 63 + ) -> list[Record]: 64 + """Fetch multiple records concurrently, skipping failures.""" 65 + tasks = [ 66 + get_record(client, ref.did, ref.collection, ref.rkey) for ref in refs 67 + ] 68 + results = await asyncio.gather(*tasks, return_exceptions=True) 69 + return [r for r in results if isinstance(r, Record)]
+20
core/util.py
··· 1 + """Shared utilities.""" 2 + 3 + from datetime import datetime, timezone 4 + 5 + 6 + def now_iso() -> str: 7 + """Current UTC timestamp in ISO format.""" 8 + return datetime.now(timezone.utc).isoformat() 9 + 10 + 11 + def format_datetime_utc(value: str) -> str: 12 + """Format an ISO datetime string as UTC.""" 13 + dt = datetime.fromisoformat(value) 14 + return dt.strftime("%Y-%m-%d %H:%M UTC") 15 + 16 + 17 + def format_datetime_local(value: str) -> str: 18 + """Format an ISO datetime string in local timezone.""" 19 + dt = datetime.fromisoformat(value).astimezone() 20 + return dt.strftime("%Y-%m-%d %H:%M")
+13
docker-compose.yml
··· 1 + services: 2 + atboards: 3 + build: . 4 + ports: 5 + - "8000:8000" 6 + volumes: 7 + - atboards-data:/data 8 + environment: 9 + - PUBLIC_URL=https://atboards.example.com 10 + restart: unless-stopped 11 + 12 + volumes: 13 + atboards-data:
+26
justfile
··· 1 + default: dev 2 + 3 + dev: 4 + #!/bin/sh 5 + trap 'kill 0' EXIT 6 + ./tailwindcss -i web/static/input.css -o web/static/style.css --watch & 7 + QUART_DEBUG=1 uv run quart --app main:app run --reload & 8 + wait 9 + 10 + css: 11 + ./tailwindcss -i web/static/input.css -o web/static/style.css --minify 12 + 13 + tui: 14 + uv run python -m tui 15 + 16 + build: 17 + docker build -t atboards . 18 + 19 + up: 20 + docker compose up -d 21 + 22 + down: 23 + docker compose down 24 + 25 + logs: 26 + docker compose logs -f
+32
lexicons/xyz.atboards.board.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.atboards.board", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "record": { 9 + "type": "object", 10 + "required": ["name", "description", "createdAt"], 11 + "properties": { 12 + "name": { 13 + "type": "string", 14 + "maxLength": 100 15 + }, 16 + "description": { 17 + "type": "string", 18 + "maxLength": 500 19 + }, 20 + "createdAt": { 21 + "type": "string", 22 + "format": "datetime" 23 + }, 24 + "updatedAt": { 25 + "type": "string", 26 + "format": "datetime" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+37
lexicons/xyz.atboards.news.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.atboards.news", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "site", 12 + "title", 13 + "body", 14 + "createdAt" 15 + ], 16 + "properties": { 17 + "site": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "title": { 22 + "type": "string", 23 + "maxLength": 300 24 + }, 25 + "body": { 26 + "type": "string", 27 + "maxLength": 10000 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+36
lexicons/xyz.atboards.reply.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.atboards.reply", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "subject", 12 + "body", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "subject": { 17 + "type": "string", 18 + "format": "at-uri" 19 + }, 20 + "body": { 21 + "type": "string", 22 + "maxLength": 10000 23 + }, 24 + "createdAt": { 25 + "type": "string", 26 + "format": "datetime" 27 + }, 28 + "updatedAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+55
lexicons/xyz.atboards.site.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.atboards.site", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "name", 12 + "description", 13 + "intro", 14 + "boards", 15 + "bannedDids", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "maxLength": 100 22 + }, 23 + "description": { 24 + "type": "string", 25 + "maxLength": 1000 26 + }, 27 + "intro": { 28 + "type": "string", 29 + "maxLength": 5000 30 + }, 31 + "boards": { 32 + "type": "array", 33 + "items": { 34 + "type": "string" 35 + } 36 + }, 37 + "bannedDids": { 38 + "type": "array", 39 + "items": { 40 + "type": "string" 41 + } 42 + }, 43 + "createdAt": { 44 + "type": "string", 45 + "format": "datetime" 46 + }, 47 + "updatedAt": { 48 + "type": "string", 49 + "format": "datetime" 50 + } 51 + } 52 + } 53 + } 54 + } 55 + }
+41
lexicons/xyz.atboards.thread.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.atboards.thread", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "board", 12 + "title", 13 + "body", 14 + "createdAt" 15 + ], 16 + "properties": { 17 + "board": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "title": { 22 + "type": "string", 23 + "maxLength": 300 24 + }, 25 + "body": { 26 + "type": "string", 27 + "maxLength": 10000 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 32 + }, 33 + "updatedAt": { 34 + "type": "string", 35 + "format": "datetime" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+6
main.py
··· 1 + from web.app import create_app 2 + 3 + app = create_app() 4 + 5 + if __name__ == "__main__": 6 + app.run()
+25
pyproject.toml
··· 1 + [project] 2 + name = "atboards" 3 + version = "0.1.0" 4 + description = "Decentralized bulletin boards on atproto." 5 + readme = "README.md" 6 + requires-python = ">=3.14" 7 + dependencies = [ 8 + "aiohttp>=3.13.5", 9 + "authlib>=1.6.9", 10 + "httpx>=0.28.1", 11 + "hypercorn>=0.18.0", 12 + "quart>=0.20.0", 13 + "textual>=8.2.2", 14 + ] 15 + 16 + [build-system] 17 + requires = ["hatchling"] 18 + build-backend = "hatchling.build" 19 + 20 + [project.scripts] 21 + atb = "tui.__main__:main" 22 + atb-web = "web.cli:main" 23 + 24 + [tool.hatch.build.targets.wheel] 25 + packages = ["core", "web", "tui"]
tui/__init__.py

This is a binary file and will not be displayed.

+10
tui/__main__.py
··· 1 + from tui.app import AtboardsApp 2 + 3 + 4 + def main(): 5 + app = AtboardsApp() 6 + app.run() 7 + 8 + 9 + if __name__ == "__main__": 10 + main()
+121
tui/app.py
··· 1 + import os 2 + 3 + import httpx 4 + from platformdirs import user_data_dir 5 + from textual.app import App 6 + 7 + from core.auth.session import SessionStore 8 + from textual.app import ComposeResult 9 + from textual.containers import Vertical 10 + from textual.screen import Screen 11 + from textual.widgets import Button, Footer, Static 12 + 13 + from tui.screens.home import HomeScreen 14 + 15 + 16 + class LogoutConfirmScreen(Screen): 17 + BINDINGS = [("escape", "app.pop_screen", "cancel")] 18 + 19 + BINDINGS = [("escape", "app.pop_screen", "cancel")] 20 + 21 + DEFAULT_CSS = """ 22 + LogoutConfirmScreen { 23 + align: center middle; 24 + } 25 + LogoutConfirmScreen Vertical { 26 + width: 40; 27 + height: auto; 28 + padding: 1 2; 29 + } 30 + LogoutConfirmScreen Button { 31 + width: 100%; 32 + margin-top: 1; 33 + } 34 + """ 35 + 36 + def compose(self) -> ComposeResult: 37 + with Vertical(): 38 + yield Static("Log out?", classes="title") 39 + yield Button("log out", id="logout-confirm", variant="error") 40 + yield Button("cancel", id="logout-cancel") 41 + yield Footer() 42 + 43 + def on_mount(self) -> None: 44 + self.query_one("#logout-confirm", Button).focus() 45 + 46 + def on_button_pressed(self, event: Button.Pressed) -> None: 47 + if event.button.id == "logout-confirm": 48 + self.app.pop_screen() 49 + self.app.do_logout() 50 + else: 51 + self.app.pop_screen() 52 + 53 + 54 + DATA_DIR = os.environ.get("ATBOARDS_DATA_DIR", user_data_dir("atboards")) 55 + 56 + 57 + class AtboardsApp(App): 58 + TITLE = "atboards" 59 + CSS_PATH = "app.tcss" 60 + BINDINGS = [ 61 + ("ctrl+q", "quit", "quit"), 62 + ("ctrl+l", "login", "log in"), 63 + ("ctrl+r", "refresh", "refresh"), 64 + ] 65 + SCREENS = {"home": HomeScreen} 66 + 67 + def on_mount(self) -> None: 68 + self.http_client = httpx.AsyncClient() 69 + os.makedirs(DATA_DIR, exist_ok=True) 70 + db_path = os.path.join(DATA_DIR, "atboards.db") 71 + self.session_store = SessionStore(db_path) 72 + self.user_session = None 73 + 74 + # Restore saved session 75 + self._restore_session() 76 + 77 + self.push_screen(HomeScreen()) 78 + 79 + def _restore_session(self) -> None: 80 + """Load the most recent session from the database.""" 81 + import sqlite3 82 + try: 83 + con = sqlite3.connect(self.session_store.db_path) 84 + con.row_factory = sqlite3.Row 85 + row = con.execute("SELECT * FROM oauth_session LIMIT 1").fetchone() 86 + con.close() 87 + if row: 88 + self.user_session = dict(row) 89 + self.sub_title = self.user_session.get("handle", "") 90 + except Exception: 91 + pass 92 + 93 + def action_login(self) -> None: 94 + if self.user_session: 95 + self.push_screen(LogoutConfirmScreen()) 96 + else: 97 + from tui.screens.login import LoginScreen 98 + self.push_screen(LoginScreen()) 99 + 100 + def do_logout(self) -> None: 101 + did = self.user_session.get("did") 102 + if did: 103 + self.session_store.delete_session(did) 104 + handle = self.user_session.get("handle", "") 105 + self.user_session = None 106 + self.sub_title = "" 107 + self.notify(f"Logged out of {handle}.") 108 + 109 + def watch_screen(self) -> None: 110 + """Update title when returning from login.""" 111 + if self.user_session: 112 + self.sub_title = self.user_session.get("handle", "") 113 + 114 + def action_refresh(self) -> None: 115 + screen = self.screen 116 + if hasattr(screen, "refresh_data"): 117 + screen.refresh_data() 118 + 119 + async def on_unmount(self) -> None: 120 + if hasattr(self, "http_client"): 121 + await self.http_client.aclose()
+162
tui/app.tcss
··· 1 + Screen { 2 + background: #171717; 3 + color: #a3a3a3; 4 + } 5 + 6 + /* breadcrumb bar */ 7 + .breadcrumb-current { 8 + color: #a3a3a3; 9 + width: auto; 10 + } 11 + 12 + /* headings */ 13 + .title { 14 + color: #e5e5e5; 15 + text-style: bold; 16 + margin-bottom: 1; 17 + } 18 + 19 + .subtitle { 20 + color: #8a8a8a; 21 + margin-bottom: 1; 22 + } 23 + 24 + .section-label { 25 + color: #8a8a8a; 26 + } 27 + 28 + /* intro block */ 29 + .intro { 30 + color: #8a8a8a; 31 + padding: 1 2; 32 + border: solid #262626; 33 + } 34 + 35 + /* board list */ 36 + ListView { 37 + height: auto; 38 + background: transparent; 39 + border: none; 40 + } 41 + 42 + ListItem { 43 + padding: 0 1; 44 + height: 1; 45 + background: transparent; 46 + color: #a3a3a3; 47 + } 48 + 49 + ListItem:hover { 50 + background: #1f1f1f; 51 + } 52 + 53 + ListItem.-highlight { 54 + background: #262626; 55 + } 56 + 57 + /* thread table */ 58 + DataTable { 59 + height: auto; 60 + margin: 1 0; 61 + background: #171717; 62 + } 63 + 64 + DataTable > .datatable--header { 65 + color: #8a8a8a; 66 + text-style: none; 67 + background: #1f1f1f; 68 + } 69 + 70 + DataTable > .datatable--cursor { 71 + background: #262626; 72 + color: #e5e5e5; 73 + } 74 + 75 + /* buttons */ 76 + Button { 77 + background: #262626; 78 + color: #a3a3a3; 79 + border: none; 80 + min-width: 16; 81 + margin: 1 0 0 0; 82 + } 83 + 84 + Button:hover { 85 + background: #404040; 86 + color: #e5e5e5; 87 + } 88 + 89 + Button.-disabled { 90 + opacity: 0.3; 91 + } 92 + 93 + 94 + /* content screens */ 95 + SiteScreen VerticalScroll { 96 + padding: 1 2; 97 + } 98 + 99 + BoardScreen VerticalScroll, 100 + ThreadScreen VerticalScroll, 101 + NewsScreen VerticalScroll { 102 + padding: 1 4; 103 + } 104 + 105 + ComposeThreadScreen Vertical, 106 + ComposeReplyScreen Vertical { 107 + padding: 1 4; 108 + } 109 + 110 + /* home screen */ 111 + HomeScreen { 112 + align: center middle; 113 + } 114 + 115 + HomeScreen #home-container { 116 + width: 50; 117 + height: auto; 118 + padding: 1 2; 119 + } 120 + 121 + HomeScreen Input { 122 + margin: 1 0; 123 + background: #1f1f1f; 124 + border: solid #262626; 125 + color: #e5e5e5; 126 + } 127 + 128 + HomeScreen Input:focus { 129 + border: solid #404040; 130 + } 131 + 132 + /* login screen */ 133 + LoginScreen { 134 + align: center middle; 135 + } 136 + 137 + LoginScreen Vertical { 138 + width: 50; 139 + height: auto; 140 + padding: 1 2; 141 + } 142 + 143 + LoginScreen Input { 144 + margin: 1 0; 145 + background: #1f1f1f; 146 + border: solid #262626; 147 + color: #e5e5e5; 148 + } 149 + 150 + LoginScreen Input:focus { 151 + border: solid #404040; 152 + } 153 + 154 + Footer { 155 + background: #1f1f1f; 156 + color: #8a8a8a; 157 + } 158 + 159 + Footer > .footer--key { 160 + color: #a3a3a3; 161 + background: #262626; 162 + }
+4
tui/fetchers.py
··· 1 + """TUI data fetching — thin wrappers around core.records.""" 2 + 3 + # Re-export from core for backwards compatibility 4 + from core.records import delete_record, hydrate_replies as fetch_replies, hydrate_threads as fetch_threads
+39
tui/local_server.py
··· 1 + """Temporary local HTTP server to catch OAuth callbacks.""" 2 + 3 + import asyncio 4 + 5 + from aiohttp import web 6 + 7 + 8 + async def wait_for_callback(port: int = 23847) -> dict: 9 + """Start a local server and wait for the OAuth callback. 10 + 11 + Returns dict with 'code', 'state', and 'iss' from the callback query params. 12 + """ 13 + result: dict = {} 14 + event = asyncio.Event() 15 + 16 + async def handle_callback(request: web.Request) -> web.Response: 17 + result["code"] = request.query.get("code", "") 18 + result["state"] = request.query.get("state", "") 19 + result["iss"] = request.query.get("iss", "") 20 + event.set() 21 + return web.Response( 22 + text="<html><body><p>Login complete. You can close this tab.</p></body></html>", 23 + content_type="text/html", 24 + ) 25 + 26 + app = web.Application() 27 + app.router.add_get("/oauth/callback", handle_callback) 28 + 29 + runner = web.AppRunner(app) 30 + await runner.setup() 31 + site = web.TCPSite(runner, "127.0.0.1", port) 32 + await site.start() 33 + 34 + try: 35 + await event.wait() 36 + finally: 37 + await runner.cleanup() 38 + 39 + return result
tui/screens/__init__.py

This is a binary file and will not be displayed.

+102
tui/screens/board.py
··· 1 + from textual.app import ComposeResult 2 + from textual.containers import VerticalScroll 3 + from textual.screen import Screen 4 + from textual.widgets import Button, Footer, ListItem, ListView, Static 5 + from textual import work 6 + 7 + from core.models import BBS, Board 8 + from tui.fetchers import fetch_threads 9 + from tui.util import format_datetime 10 + 11 + 12 + class BoardScreen(Screen): 13 + BINDINGS = [ 14 + ("escape", "app.pop_screen", "back"), 15 + ("ctrl+n", "new_thread", "new thread"), 16 + ] 17 + 18 + DEFAULT_CSS = """ 19 + BoardScreen ListView { 20 + height: auto; 21 + } 22 + """ 23 + 24 + def __init__(self, bbs: BBS, handle: str, board: Board) -> None: 25 + super().__init__() 26 + self.bbs = bbs 27 + self.handle = handle 28 + self.board = board 29 + self.threads = [] 30 + self.cursor_history: list[str | None] = [None] 31 + self.page = 0 32 + 33 + def compose(self) -> ComposeResult: 34 + from tui.widgets.breadcrumb import Breadcrumb 35 + yield Breadcrumb( 36 + ("atboards", 2), 37 + (self.bbs.site.name, 1), 38 + (self.board.name, 0), 39 + ) 40 + with VerticalScroll(): 41 + yield Static("") 42 + yield Static(f"{self.board.name} — {self.board.description}", classes="subtitle") 43 + yield ListView(id="thread-list") 44 + yield Footer() 45 + 46 + def on_mount(self) -> None: 47 + self.query_one("#thread-list", ListView).focus() 48 + self.load_threads() 49 + 50 + @work(exclusive=True) 51 + async def load_threads(self) -> None: 52 + client = self.app.http_client 53 + cursor = self.cursor_history[self.page] 54 + try: 55 + self.threads, next_cursor = await fetch_threads( 56 + client, self.bbs, self.board, cursor=cursor, 57 + ) 58 + except Exception: 59 + self.notify("Failed to load threads.", severity="error") 60 + return 61 + 62 + lv = self.query_one("#thread-list", ListView) 63 + lv.clear() 64 + for t in self.threads: 65 + label = f" {t.title} — {t.author.handle} · {format_datetime(t.created_at)}" 66 + await lv.append(ListItem(Static(label), name=t.uri)) 67 + 68 + # Remove old next page button if present 69 + try: 70 + await self.query_one("#next-page", Button).remove() 71 + except Exception: 72 + pass 73 + 74 + if next_cursor: 75 + if self.page + 1 >= len(self.cursor_history): 76 + self.cursor_history.append(next_cursor) 77 + scroll = self.query_one(VerticalScroll) 78 + await scroll.mount(Button("next page →", id="next-page")) 79 + 80 + def on_list_view_selected(self, event: ListView.Selected) -> None: 81 + uri = event.item.name 82 + # Find thread by URI 83 + thread = next((t for t in self.threads if t.uri == uri), None) 84 + if thread: 85 + from tui.screens.thread import ThreadScreen 86 + self.app.push_screen(ThreadScreen(self.bbs, self.handle, thread)) 87 + 88 + def refresh_data(self) -> None: 89 + self.page = 0 90 + self.cursor_history = [None] 91 + self.load_threads() 92 + 93 + def on_button_pressed(self, event: Button.Pressed) -> None: 94 + if event.button.id == "next-page": 95 + self.page += 1 96 + self.load_threads() 97 + 98 + def action_new_thread(self) -> None: 99 + if not self.app.user_session: 100 + return 101 + from tui.screens.compose import ComposeThreadScreen 102 + self.app.push_screen(ComposeThreadScreen(self.bbs, self.handle, self.board))
+117
tui/screens/compose.py
··· 1 + from textual.app import ComposeResult 2 + from textual.containers import Vertical 3 + from textual.screen import Screen 4 + from textual.widgets import Footer, Input, Static, TextArea 5 + from textual import work 6 + 7 + from core.records import create_thread_record, create_reply_record 8 + 9 + 10 + class ComposeThreadScreen(Screen): 11 + BINDINGS = [("escape", "app.pop_screen", "back")] 12 + 13 + def __init__(self, bbs, handle: str, board) -> None: 14 + super().__init__() 15 + self.bbs = bbs 16 + self.handle = handle 17 + self.board = board 18 + 19 + def compose(self) -> ComposeResult: 20 + from tui.widgets.breadcrumb import Breadcrumb 21 + yield Breadcrumb( 22 + ("atboards", 3), 23 + (self.bbs.site.name, 2), 24 + (self.board.name, 1), 25 + ("new thread", 0), 26 + ) 27 + with Vertical(): 28 + yield Static("new thread", classes="title") 29 + yield Input(placeholder="Thread title", id="thread-title") 30 + yield TextArea(id="thread-body", language=None) 31 + yield Static("ctrl+s to post", classes="subtitle") 32 + yield Footer() 33 + 34 + def on_mount(self) -> None: 35 + self.query_one("#thread-title", Input).focus() 36 + 37 + def key_ctrl_s(self) -> None: 38 + self.post_thread() 39 + 40 + @work(exclusive=True) 41 + async def post_thread(self) -> None: 42 + session = self.app.user_session 43 + if not session: 44 + self.notify("Not logged in.", severity="error") 45 + return 46 + 47 + title = self.query_one("#thread-title", Input).value.strip() 48 + body = self.query_one("#thread-body", TextArea).text.strip() 49 + if not title or not body: 50 + self.notify("Title and body are required.", severity="error") 51 + return 52 + 53 + board_uri = f"at://{self.bbs.identity.did}/xyz.atboards.board/{self.board.slug}" 54 + 55 + try: 56 + resp = await create_thread_record( 57 + self.app.http_client, session, board_uri, title, body, 58 + ) 59 + resp.raise_for_status() 60 + except Exception as e: 61 + self.notify(f"Failed to post thread: {e}", severity="error") 62 + return 63 + 64 + self.app.pop_screen() 65 + 66 + 67 + class ComposeReplyScreen(Screen): 68 + BINDINGS = [("escape", "app.pop_screen", "back")] 69 + 70 + def __init__(self, bbs, handle: str, thread) -> None: 71 + super().__init__() 72 + self.bbs = bbs 73 + self.handle = handle 74 + self.thread = thread 75 + 76 + def compose(self) -> ComposeResult: 77 + from tui.widgets.breadcrumb import Breadcrumb 78 + yield Breadcrumb( 79 + ("atboards", 3), 80 + (self.bbs.site.name, 2), 81 + (self.thread.title, 1), 82 + ("reply", 0), 83 + ) 84 + with Vertical(): 85 + yield Static(f"reply to: {self.thread.title}", classes="title") 86 + yield TextArea(id="reply-body", language=None) 87 + yield Static("ctrl+s to post", classes="subtitle") 88 + yield Footer() 89 + 90 + def on_mount(self) -> None: 91 + self.query_one("#reply-body", TextArea).focus() 92 + 93 + def key_ctrl_s(self) -> None: 94 + self.post_reply() 95 + 96 + @work(exclusive=True) 97 + async def post_reply(self) -> None: 98 + session = self.app.user_session 99 + if not session: 100 + self.notify("Not logged in.", severity="error") 101 + return 102 + 103 + body = self.query_one("#reply-body", TextArea).text.strip() 104 + if not body: 105 + self.notify("Reply body is required.", severity="error") 106 + return 107 + 108 + try: 109 + resp = await create_reply_record( 110 + self.app.http_client, session, self.thread.uri, body, 111 + ) 112 + resp.raise_for_status() 113 + except Exception as e: 114 + self.notify(f"Failed to post reply: {e}", severity="error") 115 + return 116 + 117 + self.app.pop_screen()
+115
tui/screens/home.py
··· 1 + import random 2 + 3 + from textual import work 4 + from textual.app import ComposeResult 5 + from textual.containers import Vertical 6 + from textual.screen import Screen 7 + from textual.widgets import Footer, Input, ListItem, ListView, Static 8 + 9 + from core.models import BBSNotFoundError, NetworkError, NoBBSError 10 + from core.resolver import resolve_bbs 11 + from core.slingshot import resolve_identities_batch 12 + 13 + 14 + class HomeScreen(Screen): 15 + DEFAULT_CSS = """ 16 + HomeScreen ListView { 17 + height: auto; 18 + } 19 + HomeScreen #discover-label { 20 + margin-bottom: 1; 21 + } 22 + """ 23 + 24 + def compose(self) -> ComposeResult: 25 + with Vertical(id="home-container"): 26 + yield Static("atboards", classes="title") 27 + yield Static("Decentralized bulletin boards on atproto. Anyone can run a BBS from their own account, no server required. Users own their posts, and communities can migrate freely.", classes="subtitle") 28 + yield Static("") 29 + yield Static("Enter a handle to connect to a BBS.", classes="subtitle") 30 + yield Input(placeholder="handle.example.com", id="handle-input") 31 + yield Static( 32 + "DISCOVER", id="discover-label", classes="section-label" 33 + ) 34 + yield ListView(id="discover-list") 35 + yield Footer() 36 + 37 + def on_mount(self) -> None: 38 + self.query_one("#discover-label").display = False 39 + self.query_one("#discover-list").display = False 40 + self.load_discover() 41 + 42 + def refresh_data(self) -> None: 43 + lv = self.query_one("#discover-list", ListView) 44 + lv.clear() 45 + lv.display = False 46 + self.query_one("#discover-label").display = False 47 + self.load_discover() 48 + 49 + def on_input_submitted(self, event: Input.Submitted) -> None: 50 + handle = event.value.strip() 51 + if handle: 52 + self.connect(handle) 53 + 54 + def on_list_view_selected(self, event: ListView.Selected) -> None: 55 + handle = event.item.name 56 + if handle: 57 + self.connect(handle) 58 + 59 + @work(exclusive=True) 60 + async def connect(self, handle: str) -> None: 61 + client = self.app.http_client 62 + try: 63 + bbs = await resolve_bbs(client, handle) 64 + except BBSNotFoundError: 65 + self.notify("BBS not found.", severity="error") 66 + return 67 + except NoBBSError: 68 + self.notify("This account isn't running a BBS.", severity="error") 69 + return 70 + except NetworkError: 71 + self.notify("Could not reach the network.", severity="error") 72 + return 73 + 74 + from tui.screens.site import SiteScreen 75 + self.app.push_screen(SiteScreen(bbs, handle)) 76 + self.query_one("#handle-input", Input).value = "" 77 + 78 + @work 79 + async def load_discover(self) -> None: 80 + client = self.app.http_client 81 + try: 82 + resp = await client.get( 83 + "https://ufos-api.microcosm.blue/records", 84 + params={"collection": "xyz.atboards.site", "limit": 50}, 85 + ) 86 + if resp.status_code != 200: 87 + return 88 + raw = resp.json() 89 + if len(raw) > 5: 90 + raw = random.sample(raw, 5) 91 + 92 + dids = [r["did"] for r in raw] 93 + authors = await resolve_identities_batch(client, dids) 94 + 95 + items = [] 96 + for r in raw: 97 + did = r["did"] 98 + if did in authors: 99 + name = r["record"].get("name", "") 100 + desc = r["record"].get("description", "") 101 + handle = authors[did].handle 102 + items.append((handle, name, desc)) 103 + 104 + if not items: 105 + return 106 + 107 + lv = self.query_one("#discover-list", ListView) 108 + for handle, name, desc in items: 109 + await lv.append(ListItem(Static(f" {name or handle}"), name=handle)) 110 + 111 + self.query_one("#discover-label").display = True 112 + lv.display = True 113 + 114 + except Exception: 115 + pass
+161
tui/screens/login.py
··· 1 + import json 2 + import webbrowser 3 + from urllib.parse import quote, urlencode 4 + 5 + from textual.app import ComposeResult 6 + from textual.containers import Vertical 7 + from textual.screen import Screen 8 + from textual.widgets import Footer, Input, Static 9 + from textual import work 10 + 11 + from authlib.jose import JsonWebKey 12 + 13 + from core.auth.config import load_secrets 14 + from core.auth.oauth import ( 15 + exchange_code, 16 + fetch_authserver_meta, 17 + resolve_pds_authserver, 18 + send_par_request, 19 + ) 20 + from core.slingshot import resolve_identity 21 + from tui.local_server import wait_for_callback 22 + 23 + 24 + OAUTH_SCOPE = "atproto transition:generic collection:xyz.atboards.site collection:xyz.atboards.board collection:xyz.atboards.news collection:xyz.atboards.thread collection:xyz.atboards.reply" 25 + CALLBACK_PORT = 23847 26 + 27 + 28 + class LoginScreen(Screen): 29 + BINDINGS = [("escape", "app.pop_screen", "back")] 30 + 31 + def compose(self) -> ComposeResult: 32 + from tui.widgets.breadcrumb import Breadcrumb 33 + yield Breadcrumb( 34 + ("atboards", 1), 35 + ("log in", 0), 36 + ) 37 + with Vertical(): 38 + yield Static("log in", classes="title") 39 + yield Static("Sign in with your atproto handle. A browser window will open.", classes="subtitle") 40 + yield Input(placeholder="your-handle.bsky.social", id="login-handle") 41 + yield Footer() 42 + 43 + def on_mount(self) -> None: 44 + self.query_one("#login-handle", Input).focus() 45 + 46 + def on_input_submitted(self, event: Input.Submitted) -> None: 47 + if event.input.id == "login-handle": 48 + handle = event.value.strip() 49 + if handle: 50 + self.do_login(handle) 51 + 52 + @work(exclusive=True) 53 + async def do_login(self, handle: str) -> None: 54 + client = self.app.http_client 55 + 56 + # Resolve identity 57 + try: 58 + identity = await resolve_identity(client, handle) 59 + except Exception: 60 + self.notify("Could not resolve handle.", severity="error") 61 + return 62 + 63 + pds_url = identity.pds 64 + if not pds_url: 65 + self.notify("Could not find PDS for this handle.", severity="error") 66 + return 67 + 68 + # Load client secrets from TUI data dir 69 + from tui.app import DATA_DIR 70 + secrets = load_secrets(DATA_DIR) 71 + client_secret_jwk = json.loads(secrets["client_secret_jwk"]) 72 + 73 + # Build loopback client ID 74 + redirect_uri = f"http://127.0.0.1:{CALLBACK_PORT}/oauth/callback" 75 + client_id = "http://localhost?" + urlencode( 76 + {"redirect_uri": redirect_uri, "scope": OAUTH_SCOPE} 77 + ) 78 + 79 + # Discover auth server 80 + try: 81 + authserver_url = await resolve_pds_authserver(client, pds_url) 82 + authserver_meta = await fetch_authserver_meta(client, authserver_url) 83 + except Exception: 84 + self.notify("Could not discover auth server.", severity="error") 85 + return 86 + 87 + # Generate DPoP keypair 88 + dpop_key = JsonWebKey.generate_key("EC", "P-256", is_private=True) 89 + dpop_private_jwk_json = dpop_key.as_json(is_private=True) 90 + 91 + # Send PAR 92 + try: 93 + pkce_verifier, state, dpop_nonce, par_resp = await send_par_request( 94 + client=client, 95 + authserver_url=authserver_url, 96 + authserver_meta=authserver_meta, 97 + login_hint=handle, 98 + client_id=client_id, 99 + redirect_uri=redirect_uri, 100 + scope=OAUTH_SCOPE, 101 + client_secret_jwk=client_secret_jwk, 102 + dpop_private_jwk=dpop_key, 103 + ) 104 + except Exception: 105 + self.notify("Authorization request failed.", severity="error") 106 + return 107 + 108 + # Open browser and wait for callback 109 + auth_url = authserver_meta["authorization_endpoint"] 110 + browser_url = f"{auth_url}?client_id={quote(client_id, safe='')}&request_uri={quote(par_resp['request_uri'], safe='')}" 111 + webbrowser.open(browser_url) 112 + self.notify("Opened browser. Complete login there.") 113 + 114 + try: 115 + callback = await wait_for_callback(port=CALLBACK_PORT) 116 + except Exception: 117 + self.notify("Failed to receive callback.", severity="error") 118 + return 119 + 120 + if not callback.get("code") or callback.get("state") != state: 121 + self.notify("Login failed. Try again.", severity="error") 122 + return 123 + 124 + # Exchange code for tokens 125 + auth_request = { 126 + "authserver_iss": authserver_url, 127 + "dpop_private_jwk": dpop_private_jwk_json, 128 + "dpop_authserver_nonce": dpop_nonce, 129 + "pkce_verifier": pkce_verifier, 130 + } 131 + 132 + try: 133 + token_resp, final_dpop_nonce = await exchange_code( 134 + client=client, 135 + auth_request=auth_request, 136 + code=callback["code"], 137 + client_id=client_id, 138 + redirect_uri=redirect_uri, 139 + client_secret_jwk=client_secret_jwk, 140 + ) 141 + except Exception: 142 + self.notify("Token exchange failed.", severity="error") 143 + return 144 + 145 + # Store session persistently 146 + session_data = { 147 + "did": identity.did, 148 + "handle": identity.handle, 149 + "pds_url": pds_url, 150 + "access_token": token_resp["access_token"], 151 + "refresh_token": token_resp.get("refresh_token", ""), 152 + "authserver_iss": authserver_url, 153 + "dpop_authserver_nonce": final_dpop_nonce, 154 + "dpop_pds_nonce": "", 155 + "dpop_private_jwk": dpop_private_jwk_json, 156 + } 157 + self.app.session_store.save_session(**session_data) 158 + self.app.user_session = session_data 159 + 160 + self.notify(f"Logged in as {identity.handle}.") 161 + self.app.pop_screen()
+33
tui/screens/news.py
··· 1 + from textual.app import ComposeResult 2 + from textual.containers import VerticalScroll 3 + from textual.screen import Screen 4 + from textual.widgets import Footer 5 + 6 + from core.models import BBS, News 7 + from tui.widgets.post import Post 8 + 9 + 10 + class NewsScreen(Screen): 11 + BINDINGS = [("escape", "app.pop_screen", "back")] 12 + 13 + def __init__(self, bbs: BBS, handle: str, news: News) -> None: 14 + super().__init__() 15 + self.bbs = bbs 16 + self.handle = handle 17 + self.news = news 18 + 19 + def compose(self) -> ComposeResult: 20 + from tui.widgets.breadcrumb import Breadcrumb 21 + yield Breadcrumb( 22 + ("atboards", 2), 23 + (self.bbs.site.name, 1), 24 + ("news", 0), 25 + ) 26 + with VerticalScroll(): 27 + yield Post( 28 + author=self.handle, 29 + date=self.news.created_at, 30 + title=self.news.title, 31 + body=self.news.body, 32 + ) 33 + yield Footer()
+81
tui/screens/site.py
··· 1 + from textual.app import ComposeResult 2 + from textual.containers import VerticalScroll 3 + from textual.screen import Screen 4 + from textual.widgets import Footer, ListItem, ListView, Static 5 + from textual import work 6 + 7 + from core.models import BBS 8 + from core.resolver import resolve_bbs 9 + from tui.util import format_datetime 10 + 11 + 12 + class SiteScreen(Screen): 13 + BINDINGS = [("escape", "app.pop_screen", "back")] 14 + 15 + def __init__(self, bbs: BBS, handle: str) -> None: 16 + super().__init__() 17 + self.bbs = bbs 18 + self.handle = handle 19 + 20 + def compose(self) -> ComposeResult: 21 + from tui.widgets.breadcrumb import Breadcrumb 22 + yield Breadcrumb( 23 + ("atboards", 1), 24 + (self.handle, 0), 25 + ) 26 + with VerticalScroll(): 27 + yield Static(self.bbs.site.name, classes="title") 28 + yield Static(self.bbs.site.description, classes="subtitle") 29 + if self.bbs.site.intro: 30 + yield Static(self.bbs.site.intro, classes="intro", markup=False) 31 + yield Static("BOARDS", classes="section-label") 32 + yield ListView( 33 + *[ 34 + ListItem(Static(f" {b.name} — {b.description}"), name=b.slug) 35 + for b in self.bbs.site.boards 36 + ], 37 + id="board-list", 38 + ) 39 + if self.bbs.news: 40 + yield Static("NEWS", classes="section-label") 41 + yield ListView( 42 + *[ 43 + ListItem( 44 + Static(f" {item.title} — {format_datetime(item.created_at)}"), 45 + name=str(i), 46 + ) 47 + for i, item in enumerate(self.bbs.news) 48 + ], 49 + id="news-list", 50 + ) 51 + yield Footer() 52 + 53 + def on_mount(self) -> None: 54 + self.query_one("#board-list", ListView).focus() 55 + 56 + def refresh_data(self) -> None: 57 + self._do_refresh() 58 + 59 + @work(exclusive=True) 60 + async def _do_refresh(self) -> None: 61 + client = self.app.http_client 62 + try: 63 + bbs = await resolve_bbs(client, self.handle) 64 + self.bbs = bbs 65 + self.app.pop_screen() 66 + self.app.push_screen(SiteScreen(bbs, self.handle)) 67 + except Exception: 68 + pass 69 + 70 + def on_list_view_selected(self, event: ListView.Selected) -> None: 71 + if event.list_view.id == "board-list": 72 + slug = event.item.name 73 + board = next((b for b in self.bbs.site.boards if b.slug == slug), None) 74 + if board: 75 + from tui.screens.board import BoardScreen 76 + self.app.push_screen(BoardScreen(self.bbs, self.handle, board)) 77 + elif event.list_view.id == "news-list": 78 + idx = int(event.item.name) 79 + if 0 <= idx < len(self.bbs.news): 80 + from tui.screens.news import NewsScreen 81 + self.app.push_screen(NewsScreen(self.bbs, self.handle, self.bbs.news[idx]))
+163
tui/screens/thread.py
··· 1 + from textual.app import ComposeResult 2 + from textual.binding import Binding 3 + from textual.containers import VerticalScroll 4 + from textual.screen import Screen 5 + from textual.widgets import Button, Footer 6 + from textual import work 7 + 8 + from core.models import BBS, Thread 9 + from tui.fetchers import delete_record, fetch_replies 10 + from tui.widgets.post import Post 11 + 12 + 13 + class ThreadScreen(Screen): 14 + BINDINGS = [ 15 + Binding("escape", "app.pop_screen", "back"), 16 + Binding("ctrl+e", "reply", "reply"), 17 + Binding("ctrl+d", "delete", "delete"), 18 + ] 19 + 20 + def __init__(self, bbs: BBS, handle: str, thread: Thread) -> None: 21 + super().__init__() 22 + self.bbs = bbs 23 + self.handle = handle 24 + self.thread = thread 25 + self.next_cursor: str | None = None 26 + 27 + def compose(self) -> ComposeResult: 28 + board_slug = self.thread.board_uri.split("/")[-1] 29 + board_name = next((b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug) 30 + from tui.widgets.breadcrumb import Breadcrumb 31 + yield Breadcrumb( 32 + ("atboards", 3), 33 + (self.bbs.site.name, 2), 34 + (board_name, 1), 35 + (self.thread.title, 0), 36 + ) 37 + with VerticalScroll(id="thread-scroll"): 38 + yield Post( 39 + author=self.thread.author.handle, 40 + date=self.thread.created_at, 41 + title=self.thread.title, 42 + body=self.thread.body, 43 + author_did=self.thread.author.did, 44 + record_uri=self.thread.uri, 45 + collection="xyz.atboards.thread", 46 + ) 47 + yield Footer() 48 + 49 + def on_mount(self) -> None: 50 + try: 51 + self.query(Post).first().focus() 52 + except Exception: 53 + pass 54 + self.load_replies() 55 + 56 + @work(exclusive=True) 57 + async def load_replies(self, cursor: str | None = None) -> None: 58 + client = self.app.http_client 59 + try: 60 + replies, self.next_cursor = await fetch_replies( 61 + client, self.bbs, self.thread, cursor=cursor, 62 + ) 63 + except Exception: 64 + self.notify("Failed to load replies.", severity="error") 65 + return 66 + 67 + scroll = self.query_one("#thread-scroll") 68 + 69 + for r in replies: 70 + await scroll.mount( 71 + Post( 72 + author=r.author.handle, 73 + date=r.created_at, 74 + body=r.body, 75 + author_did=r.author.did, 76 + record_uri=r.uri, 77 + collection="xyz.atboards.reply", 78 + ) 79 + ) 80 + 81 + if self.next_cursor: 82 + await scroll.mount( 83 + Button("next page →", id="next-page") 84 + ) 85 + 86 + def refresh_data(self) -> None: 87 + self._do_refresh() 88 + 89 + @work(exclusive=True) 90 + async def _do_refresh(self) -> None: 91 + for post in self.query(Post): 92 + if post.collection == "xyz.atboards.reply": 93 + await post.remove() 94 + try: 95 + await self.query_one("#next-page", Button).remove() 96 + except Exception: 97 + pass 98 + 99 + client = self.app.http_client 100 + try: 101 + replies, self.next_cursor = await fetch_replies( 102 + client, self.bbs, self.thread, 103 + ) 104 + except Exception: 105 + self.notify("Failed to load replies.", severity="error") 106 + return 107 + 108 + scroll = self.query_one("#thread-scroll") 109 + for r in replies: 110 + await scroll.mount( 111 + Post( 112 + author=r.author.handle, 113 + date=r.created_at, 114 + body=r.body, 115 + author_did=r.author.did, 116 + record_uri=r.uri, 117 + collection="xyz.atboards.reply", 118 + ) 119 + ) 120 + 121 + if self.next_cursor: 122 + await scroll.mount(Button("next page →", id="next-page")) 123 + 124 + def on_button_pressed(self, event: Button.Pressed) -> None: 125 + if event.button.id == "next-page" and self.next_cursor: 126 + event.button.remove() 127 + self.load_replies(cursor=self.next_cursor) 128 + 129 + def action_reply(self) -> None: 130 + if not self.app.user_session: 131 + return 132 + from tui.screens.compose import ComposeReplyScreen 133 + self.app.push_screen(ComposeReplyScreen(self.bbs, self.handle, self.thread)) 134 + 135 + def action_delete(self) -> None: 136 + session = self.app.user_session 137 + if not session: 138 + return 139 + 140 + focused = self.focused 141 + if not isinstance(focused, Post): 142 + return 143 + 144 + if focused.author_did != session["did"]: 145 + return 146 + 147 + self.do_delete(focused) 148 + 149 + @work(exclusive=True) 150 + async def do_delete(self, post: Post) -> None: 151 + session = self.app.user_session 152 + try: 153 + await delete_record( 154 + self.app.http_client, session, post.collection, post.rkey, 155 + ) 156 + except Exception: 157 + self.notify("Failed to delete.", severity="error") 158 + return 159 + 160 + if post.collection == "xyz.atboards.thread": 161 + self.app.pop_screen() 162 + else: 163 + await post.remove()
+3
tui/util.py
··· 1 + """TUI utilities — delegates to core.""" 2 + 3 + from core.util import format_datetime_local as format_datetime
tui/widgets/__init__.py

This is a binary file and will not be displayed.

+81
tui/widgets/breadcrumb.py
··· 1 + from textual.app import ComposeResult 2 + from textual.widget import Widget 3 + from textual.widgets import Static 4 + 5 + 6 + class BreadcrumbLink(Static, can_focus=True): 7 + """A clickable breadcrumb segment.""" 8 + 9 + DEFAULT_CSS = """ 10 + BreadcrumbLink { 11 + color: #8a8a8a; 12 + width: auto; 13 + } 14 + BreadcrumbLink:hover { 15 + color: #a3a3a3; 16 + } 17 + BreadcrumbLink:focus { 18 + color: #e5e5e5; 19 + } 20 + """ 21 + 22 + def __init__(self, label: str, pop_count: int, **kwargs) -> None: 23 + super().__init__(f" {label} ", markup=False, **kwargs) 24 + self.pop_count = pop_count 25 + 26 + def on_click(self) -> None: 27 + for _ in range(self.pop_count): 28 + self.app.pop_screen() 29 + 30 + def key_enter(self) -> None: 31 + self.on_click() 32 + 33 + 34 + class BreadcrumbSep(Static): 35 + DEFAULT_CSS = """ 36 + BreadcrumbSep { 37 + color: #525252; 38 + width: auto; 39 + } 40 + """ 41 + 42 + def __init__(self) -> None: 43 + super().__init__(" / ") 44 + 45 + 46 + class Breadcrumb(Widget): 47 + """A breadcrumb bar with clickable segments.""" 48 + 49 + DEFAULT_CSS = """ 50 + Breadcrumb { 51 + dock: top; 52 + height: auto; 53 + min-height: 1; 54 + background: #262626; 55 + layout: horizontal; 56 + } 57 + Breadcrumb .breadcrumb-user { 58 + dock: right; 59 + width: auto; 60 + color: #8a8a8a; 61 + } 62 + """ 63 + 64 + def __init__(self, *segments: tuple[str, int]) -> None: 65 + """Each segment is (label, pop_count). pop_count=0 means current page (not clickable).""" 66 + super().__init__() 67 + self._segments = segments 68 + 69 + def compose(self) -> ComposeResult: 70 + # Show logged-in user on the right 71 + session = getattr(self.app, "user_session", None) 72 + if session: 73 + yield Static(f" {session['handle']} ", classes="breadcrumb-user", markup=False) 74 + 75 + for i, (label, pop_count) in enumerate(self._segments): 76 + if i > 0: 77 + yield BreadcrumbSep() 78 + if pop_count > 0: 79 + yield BreadcrumbLink(label, pop_count) 80 + else: 81 + yield Static(f" {label} ", classes="breadcrumb-current", markup=False)
+66
tui/widgets/post.py
··· 1 + from textual.app import ComposeResult 2 + from textual.widget import Widget 3 + from textual.widgets import Static 4 + 5 + from tui.util import format_datetime 6 + 7 + 8 + class Post(Widget, can_focus=True): 9 + """A post card showing author, date, optional title, and body.""" 10 + 11 + DEFAULT_CSS = """ 12 + Post { 13 + height: auto; 14 + padding: 1 2; 15 + margin: 0 0 1 0; 16 + border: solid #262626; 17 + background: #1f1f1f; 18 + } 19 + Post:focus { 20 + border: solid #525252; 21 + } 22 + Post .post-meta { 23 + color: #8a8a8a; 24 + margin-bottom: 1; 25 + } 26 + Post .post-title { 27 + color: #e5e5e5; 28 + text-style: bold; 29 + margin-bottom: 1; 30 + } 31 + Post .post-body { 32 + color: #a3a3a3; 33 + } 34 + """ 35 + 36 + def __init__( 37 + self, 38 + author: str, 39 + date: str, 40 + body: str, 41 + title: str | None = None, 42 + author_did: str | None = None, 43 + record_uri: str | None = None, 44 + collection: str | None = None, 45 + **kwargs, 46 + ) -> None: 47 + super().__init__(**kwargs) 48 + self._author = author 49 + self._date = format_datetime(date) 50 + self._title = title 51 + self._body = body 52 + self.author_did = author_did 53 + self.record_uri = record_uri 54 + self.collection = collection 55 + 56 + @property 57 + def rkey(self) -> str | None: 58 + if self.record_uri: 59 + return self.record_uri.split("/")[-1] 60 + return None 61 + 62 + def compose(self) -> ComposeResult: 63 + yield Static(f"{self._author} {self._date}", classes="post-meta", markup=False) 64 + if self._title: 65 + yield Static(self._title, classes="post-title", markup=False) 66 + yield Static(self._body, classes="post-body", markup=False)
+778
uv.lock
··· 1 + version = 1 2 + revision = 3 3 + requires-python = ">=3.14" 4 + 5 + [[package]] 6 + name = "aiofiles" 7 + version = "25.1.0" 8 + source = { registry = "https://pypi.org/simple" } 9 + sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } 10 + wheels = [ 11 + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, 12 + ] 13 + 14 + [[package]] 15 + name = "aiohappyeyeballs" 16 + version = "2.6.1" 17 + source = { registry = "https://pypi.org/simple" } 18 + sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } 19 + wheels = [ 20 + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, 21 + ] 22 + 23 + [[package]] 24 + name = "aiohttp" 25 + version = "3.13.5" 26 + source = { registry = "https://pypi.org/simple" } 27 + dependencies = [ 28 + { name = "aiohappyeyeballs" }, 29 + { name = "aiosignal" }, 30 + { name = "attrs" }, 31 + { name = "frozenlist" }, 32 + { name = "multidict" }, 33 + { name = "propcache" }, 34 + { name = "yarl" }, 35 + ] 36 + sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } 37 + wheels = [ 38 + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, 39 + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, 40 + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, 41 + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, 42 + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, 43 + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, 44 + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, 45 + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, 46 + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, 47 + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, 48 + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, 49 + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, 50 + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, 51 + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, 52 + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, 53 + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, 54 + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, 55 + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, 56 + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, 57 + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, 58 + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, 59 + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, 60 + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, 61 + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, 62 + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, 63 + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, 64 + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, 65 + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, 66 + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, 67 + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, 68 + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, 69 + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, 70 + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, 71 + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, 72 + ] 73 + 74 + [[package]] 75 + name = "aiosignal" 76 + version = "1.4.0" 77 + source = { registry = "https://pypi.org/simple" } 78 + dependencies = [ 79 + { name = "frozenlist" }, 80 + ] 81 + sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } 82 + wheels = [ 83 + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, 84 + ] 85 + 86 + [[package]] 87 + name = "anyio" 88 + version = "4.13.0" 89 + source = { registry = "https://pypi.org/simple" } 90 + dependencies = [ 91 + { name = "idna" }, 92 + ] 93 + sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } 94 + wheels = [ 95 + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, 96 + ] 97 + 98 + [[package]] 99 + name = "atboards" 100 + version = "0.1.0" 101 + source = { editable = "." } 102 + dependencies = [ 103 + { name = "aiohttp" }, 104 + { name = "authlib" }, 105 + { name = "httpx" }, 106 + { name = "hypercorn" }, 107 + { name = "quart" }, 108 + { name = "textual" }, 109 + ] 110 + 111 + [package.metadata] 112 + requires-dist = [ 113 + { name = "aiohttp", specifier = ">=3.13.5" }, 114 + { name = "authlib", specifier = ">=1.6.9" }, 115 + { name = "httpx", specifier = ">=0.28.1" }, 116 + { name = "hypercorn", specifier = ">=0.18.0" }, 117 + { name = "quart", specifier = ">=0.20.0" }, 118 + { name = "textual", specifier = ">=8.2.2" }, 119 + ] 120 + 121 + [[package]] 122 + name = "attrs" 123 + version = "26.1.0" 124 + source = { registry = "https://pypi.org/simple" } 125 + sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } 126 + wheels = [ 127 + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, 128 + ] 129 + 130 + [[package]] 131 + name = "authlib" 132 + version = "1.6.9" 133 + source = { registry = "https://pypi.org/simple" } 134 + dependencies = [ 135 + { name = "cryptography" }, 136 + ] 137 + sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } 138 + wheels = [ 139 + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, 140 + ] 141 + 142 + [[package]] 143 + name = "blinker" 144 + version = "1.9.0" 145 + source = { registry = "https://pypi.org/simple" } 146 + sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } 147 + wheels = [ 148 + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, 149 + ] 150 + 151 + [[package]] 152 + name = "certifi" 153 + version = "2026.2.25" 154 + source = { registry = "https://pypi.org/simple" } 155 + sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } 156 + wheels = [ 157 + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, 158 + ] 159 + 160 + [[package]] 161 + name = "cffi" 162 + version = "2.0.0" 163 + source = { registry = "https://pypi.org/simple" } 164 + dependencies = [ 165 + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, 166 + ] 167 + sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } 168 + wheels = [ 169 + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, 170 + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, 171 + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, 172 + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, 173 + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, 174 + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, 175 + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, 176 + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, 177 + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, 178 + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, 179 + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, 180 + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, 181 + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, 182 + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, 183 + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, 184 + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, 185 + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, 186 + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, 187 + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, 188 + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, 189 + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, 190 + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, 191 + ] 192 + 193 + [[package]] 194 + name = "click" 195 + version = "8.3.1" 196 + source = { registry = "https://pypi.org/simple" } 197 + dependencies = [ 198 + { name = "colorama", marker = "sys_platform == 'win32'" }, 199 + ] 200 + sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } 201 + wheels = [ 202 + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, 203 + ] 204 + 205 + [[package]] 206 + name = "colorama" 207 + version = "0.4.6" 208 + source = { registry = "https://pypi.org/simple" } 209 + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 210 + wheels = [ 211 + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 212 + ] 213 + 214 + [[package]] 215 + name = "cryptography" 216 + version = "46.0.6" 217 + source = { registry = "https://pypi.org/simple" } 218 + dependencies = [ 219 + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, 220 + ] 221 + sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } 222 + wheels = [ 223 + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, 224 + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, 225 + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, 226 + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, 227 + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, 228 + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, 229 + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, 230 + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, 231 + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, 232 + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, 233 + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, 234 + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, 235 + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, 236 + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, 237 + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, 238 + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, 239 + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, 240 + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, 241 + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, 242 + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, 243 + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, 244 + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, 245 + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, 246 + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, 247 + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, 248 + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, 249 + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, 250 + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, 251 + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, 252 + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, 253 + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, 254 + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, 255 + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, 256 + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, 257 + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, 258 + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, 259 + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, 260 + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, 261 + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, 262 + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, 263 + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, 264 + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, 265 + ] 266 + 267 + [[package]] 268 + name = "flask" 269 + version = "3.1.3" 270 + source = { registry = "https://pypi.org/simple" } 271 + dependencies = [ 272 + { name = "blinker" }, 273 + { name = "click" }, 274 + { name = "itsdangerous" }, 275 + { name = "jinja2" }, 276 + { name = "markupsafe" }, 277 + { name = "werkzeug" }, 278 + ] 279 + sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } 280 + wheels = [ 281 + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, 282 + ] 283 + 284 + [[package]] 285 + name = "frozenlist" 286 + version = "1.8.0" 287 + source = { registry = "https://pypi.org/simple" } 288 + sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } 289 + wheels = [ 290 + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, 291 + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, 292 + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, 293 + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, 294 + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, 295 + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, 296 + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, 297 + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, 298 + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, 299 + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, 300 + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, 301 + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, 302 + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, 303 + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, 304 + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, 305 + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, 306 + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, 307 + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, 308 + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, 309 + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, 310 + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, 311 + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, 312 + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, 313 + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, 314 + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, 315 + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, 316 + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, 317 + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, 318 + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, 319 + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, 320 + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, 321 + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, 322 + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, 323 + ] 324 + 325 + [[package]] 326 + name = "h11" 327 + version = "0.16.0" 328 + source = { registry = "https://pypi.org/simple" } 329 + sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 330 + wheels = [ 331 + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, 332 + ] 333 + 334 + [[package]] 335 + name = "h2" 336 + version = "4.3.0" 337 + source = { registry = "https://pypi.org/simple" } 338 + dependencies = [ 339 + { name = "hpack" }, 340 + { name = "hyperframe" }, 341 + ] 342 + sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } 343 + wheels = [ 344 + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, 345 + ] 346 + 347 + [[package]] 348 + name = "hpack" 349 + version = "4.1.0" 350 + source = { registry = "https://pypi.org/simple" } 351 + sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } 352 + wheels = [ 353 + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, 354 + ] 355 + 356 + [[package]] 357 + name = "httpcore" 358 + version = "1.0.9" 359 + source = { registry = "https://pypi.org/simple" } 360 + dependencies = [ 361 + { name = "certifi" }, 362 + { name = "h11" }, 363 + ] 364 + sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 365 + wheels = [ 366 + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 367 + ] 368 + 369 + [[package]] 370 + name = "httpx" 371 + version = "0.28.1" 372 + source = { registry = "https://pypi.org/simple" } 373 + dependencies = [ 374 + { name = "anyio" }, 375 + { name = "certifi" }, 376 + { name = "httpcore" }, 377 + { name = "idna" }, 378 + ] 379 + sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 380 + wheels = [ 381 + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 382 + ] 383 + 384 + [[package]] 385 + name = "hypercorn" 386 + version = "0.18.0" 387 + source = { registry = "https://pypi.org/simple" } 388 + dependencies = [ 389 + { name = "h11" }, 390 + { name = "h2" }, 391 + { name = "priority" }, 392 + { name = "wsproto" }, 393 + ] 394 + sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } 395 + wheels = [ 396 + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, 397 + ] 398 + 399 + [[package]] 400 + name = "hyperframe" 401 + version = "6.1.0" 402 + source = { registry = "https://pypi.org/simple" } 403 + sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } 404 + wheels = [ 405 + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, 406 + ] 407 + 408 + [[package]] 409 + name = "idna" 410 + version = "3.11" 411 + source = { registry = "https://pypi.org/simple" } 412 + sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } 413 + wheels = [ 414 + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, 415 + ] 416 + 417 + [[package]] 418 + name = "itsdangerous" 419 + version = "2.2.0" 420 + source = { registry = "https://pypi.org/simple" } 421 + sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } 422 + wheels = [ 423 + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, 424 + ] 425 + 426 + [[package]] 427 + name = "jinja2" 428 + version = "3.1.6" 429 + source = { registry = "https://pypi.org/simple" } 430 + dependencies = [ 431 + { name = "markupsafe" }, 432 + ] 433 + sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } 434 + wheels = [ 435 + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, 436 + ] 437 + 438 + [[package]] 439 + name = "linkify-it-py" 440 + version = "2.1.0" 441 + source = { registry = "https://pypi.org/simple" } 442 + dependencies = [ 443 + { name = "uc-micro-py" }, 444 + ] 445 + sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } 446 + wheels = [ 447 + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, 448 + ] 449 + 450 + [[package]] 451 + name = "markdown-it-py" 452 + version = "4.0.0" 453 + source = { registry = "https://pypi.org/simple" } 454 + dependencies = [ 455 + { name = "mdurl" }, 456 + ] 457 + sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } 458 + wheels = [ 459 + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, 460 + ] 461 + 462 + [package.optional-dependencies] 463 + linkify = [ 464 + { name = "linkify-it-py" }, 465 + ] 466 + 467 + [[package]] 468 + name = "markupsafe" 469 + version = "3.0.3" 470 + source = { registry = "https://pypi.org/simple" } 471 + sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } 472 + wheels = [ 473 + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, 474 + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, 475 + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, 476 + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, 477 + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, 478 + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, 479 + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, 480 + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, 481 + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, 482 + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, 483 + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, 484 + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, 485 + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, 486 + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, 487 + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, 488 + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, 489 + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, 490 + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, 491 + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, 492 + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, 493 + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, 494 + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, 495 + ] 496 + 497 + [[package]] 498 + name = "mdit-py-plugins" 499 + version = "0.5.0" 500 + source = { registry = "https://pypi.org/simple" } 501 + dependencies = [ 502 + { name = "markdown-it-py" }, 503 + ] 504 + sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } 505 + wheels = [ 506 + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, 507 + ] 508 + 509 + [[package]] 510 + name = "mdurl" 511 + version = "0.1.2" 512 + source = { registry = "https://pypi.org/simple" } 513 + sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } 514 + wheels = [ 515 + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, 516 + ] 517 + 518 + [[package]] 519 + name = "multidict" 520 + version = "6.7.1" 521 + source = { registry = "https://pypi.org/simple" } 522 + sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } 523 + wheels = [ 524 + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, 525 + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, 526 + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, 527 + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, 528 + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, 529 + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, 530 + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, 531 + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, 532 + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, 533 + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, 534 + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, 535 + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, 536 + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, 537 + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, 538 + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, 539 + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, 540 + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, 541 + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, 542 + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, 543 + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, 544 + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, 545 + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, 546 + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, 547 + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, 548 + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, 549 + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, 550 + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, 551 + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, 552 + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, 553 + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, 554 + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, 555 + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, 556 + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, 557 + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, 558 + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, 559 + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, 560 + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, 561 + ] 562 + 563 + [[package]] 564 + name = "platformdirs" 565 + version = "4.9.4" 566 + source = { registry = "https://pypi.org/simple" } 567 + sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } 568 + wheels = [ 569 + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, 570 + ] 571 + 572 + [[package]] 573 + name = "priority" 574 + version = "2.0.0" 575 + source = { registry = "https://pypi.org/simple" } 576 + sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } 577 + wheels = [ 578 + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, 579 + ] 580 + 581 + [[package]] 582 + name = "propcache" 583 + version = "0.4.1" 584 + source = { registry = "https://pypi.org/simple" } 585 + sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } 586 + wheels = [ 587 + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, 588 + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, 589 + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, 590 + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, 591 + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, 592 + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, 593 + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, 594 + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, 595 + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, 596 + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, 597 + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, 598 + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, 599 + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, 600 + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, 601 + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, 602 + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, 603 + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, 604 + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, 605 + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, 606 + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, 607 + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, 608 + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, 609 + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, 610 + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, 611 + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, 612 + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, 613 + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, 614 + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, 615 + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, 616 + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, 617 + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, 618 + ] 619 + 620 + [[package]] 621 + name = "pycparser" 622 + version = "3.0" 623 + source = { registry = "https://pypi.org/simple" } 624 + sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } 625 + wheels = [ 626 + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, 627 + ] 628 + 629 + [[package]] 630 + name = "pygments" 631 + version = "2.20.0" 632 + source = { registry = "https://pypi.org/simple" } 633 + sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } 634 + wheels = [ 635 + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, 636 + ] 637 + 638 + [[package]] 639 + name = "quart" 640 + version = "0.20.0" 641 + source = { registry = "https://pypi.org/simple" } 642 + dependencies = [ 643 + { name = "aiofiles" }, 644 + { name = "blinker" }, 645 + { name = "click" }, 646 + { name = "flask" }, 647 + { name = "hypercorn" }, 648 + { name = "itsdangerous" }, 649 + { name = "jinja2" }, 650 + { name = "markupsafe" }, 651 + { name = "werkzeug" }, 652 + ] 653 + sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } 654 + wheels = [ 655 + { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, 656 + ] 657 + 658 + [[package]] 659 + name = "rich" 660 + version = "14.3.3" 661 + source = { registry = "https://pypi.org/simple" } 662 + dependencies = [ 663 + { name = "markdown-it-py" }, 664 + { name = "pygments" }, 665 + ] 666 + sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } 667 + wheels = [ 668 + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, 669 + ] 670 + 671 + [[package]] 672 + name = "textual" 673 + version = "8.2.2" 674 + source = { registry = "https://pypi.org/simple" } 675 + dependencies = [ 676 + { name = "markdown-it-py", extra = ["linkify"] }, 677 + { name = "mdit-py-plugins" }, 678 + { name = "platformdirs" }, 679 + { name = "pygments" }, 680 + { name = "rich" }, 681 + { name = "typing-extensions" }, 682 + ] 683 + sdist = { url = "https://files.pythonhosted.org/packages/69/b0/a9aedf13af1bfb1bf01cbc645ea5d5a4151b5d77ac1748b85c4f0d777d7d/textual-8.2.2.tar.gz", hash = "sha256:94e85267650cf679ac16ade5ac929055e836dc00798a0e6e3925926a5beee303", size = 1848623, upload-time = "2026-04-03T13:19:06.057Z" } 684 + wheels = [ 685 + { url = "https://files.pythonhosted.org/packages/a7/18/4d59eb3f2241db6d346a90f2452fc47a19d61090a38b9cf331afe23e8431/textual-8.2.2-py3-none-any.whl", hash = "sha256:35a8f439875dc6e5b4dc7ee72dc9698a40bd13091c2de5bd5b2d4318522af8df", size = 724078, upload-time = "2026-04-03T13:19:08.115Z" }, 686 + ] 687 + 688 + [[package]] 689 + name = "typing-extensions" 690 + version = "4.15.0" 691 + source = { registry = "https://pypi.org/simple" } 692 + sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 693 + wheels = [ 694 + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 695 + ] 696 + 697 + [[package]] 698 + name = "uc-micro-py" 699 + version = "2.0.0" 700 + source = { registry = "https://pypi.org/simple" } 701 + sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } 702 + wheels = [ 703 + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, 704 + ] 705 + 706 + [[package]] 707 + name = "werkzeug" 708 + version = "3.1.8" 709 + source = { registry = "https://pypi.org/simple" } 710 + dependencies = [ 711 + { name = "markupsafe" }, 712 + ] 713 + sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } 714 + wheels = [ 715 + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, 716 + ] 717 + 718 + [[package]] 719 + name = "wsproto" 720 + version = "1.3.2" 721 + source = { registry = "https://pypi.org/simple" } 722 + dependencies = [ 723 + { name = "h11" }, 724 + ] 725 + sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } 726 + wheels = [ 727 + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, 728 + ] 729 + 730 + [[package]] 731 + name = "yarl" 732 + version = "1.23.0" 733 + source = { registry = "https://pypi.org/simple" } 734 + dependencies = [ 735 + { name = "idna" }, 736 + { name = "multidict" }, 737 + { name = "propcache" }, 738 + ] 739 + sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } 740 + wheels = [ 741 + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, 742 + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, 743 + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, 744 + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, 745 + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, 746 + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, 747 + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, 748 + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, 749 + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, 750 + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, 751 + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, 752 + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, 753 + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, 754 + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, 755 + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, 756 + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, 757 + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, 758 + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, 759 + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, 760 + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, 761 + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, 762 + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, 763 + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, 764 + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, 765 + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, 766 + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, 767 + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, 768 + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, 769 + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, 770 + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, 771 + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, 772 + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, 773 + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, 774 + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, 775 + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, 776 + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, 777 + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, 778 + ]
web/__init__.py

This is a binary file and will not be displayed.

+59
web/app.py
··· 1 + import os 2 + 3 + import httpx 4 + from quart import Quart 5 + 6 + from core.auth.config import load_secrets 7 + from core.auth.session import SessionStore 8 + from core.util import format_datetime_utc 9 + 10 + 11 + def create_app() -> Quart: 12 + app = Quart(__name__) 13 + 14 + # Data directory for secrets and database 15 + data_dir = os.environ.get("ATBOARDS_DATA_DIR", ".") 16 + 17 + # Load secrets 18 + secrets = load_secrets(data_dir) 19 + app.secret_key = secrets["secret_key"] 20 + app.config["CLIENT_SECRET_JWK"] = secrets["client_secret_jwk"] 21 + app.config["PUBLIC_URL"] = os.environ.get("PUBLIC_URL", "http://127.0.0.1:5000") 22 + 23 + # Session store 24 + db_path = os.path.join(data_dir, "atboards.db") 25 + app.session_store = SessionStore(db_path) 26 + 27 + # Jinja filters 28 + app.jinja_env.filters["datetime"] = format_datetime_utc 29 + 30 + @app.before_serving 31 + async def startup(): 32 + app.http_client = httpx.AsyncClient() 33 + 34 + @app.after_serving 35 + async def shutdown(): 36 + await app.http_client.aclose() 37 + 38 + # Load user for templates 39 + @app.before_request 40 + async def load_user(): 41 + from quart import g, session 42 + did = session.get("did") 43 + if did: 44 + g.user = app.session_store.get_session(did) 45 + else: 46 + g.user = None 47 + 48 + # Register blueprints 49 + from web.routes import bp as main_bp 50 + from web.routes_auth import bp as auth_bp 51 + from web.routes_write import bp as write_bp 52 + from web.routes_sysop import bp as sysop_bp 53 + 54 + app.register_blueprint(main_bp) 55 + app.register_blueprint(auth_bp) 56 + app.register_blueprint(write_bp) 57 + app.register_blueprint(sysop_bp) 58 + 59 + return app
+15
web/cli.py
··· 1 + """CLI entry point for the web server.""" 2 + 3 + import sys 4 + 5 + from web.app import create_app 6 + 7 + 8 + def main(): 9 + app = create_app() 10 + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 11 + app.run(host="0.0.0.0", port=port) 12 + 13 + 14 + if __name__ == "__main__": 15 + main()
+20
web/helpers.py
··· 1 + """Shared web route helpers.""" 2 + 3 + from quart import current_app, session 4 + 5 + from core.auth.session import SessionStore 6 + 7 + 8 + async def get_user() -> dict | None: 9 + """Get the current logged-in user's OAuth session.""" 10 + did = session.get("did") 11 + if not did: 12 + return None 13 + store: SessionStore = current_app.session_store 14 + return store.get_session(did) 15 + 16 + 17 + async def session_updater(did: str, field: str, value: str): 18 + """Callback for pds_request to persist nonce updates.""" 19 + store: SessionStore = current_app.session_store 20 + store.update_session_field(did, field, value)
+222
web/routes.py
··· 1 + import random 2 + 3 + import httpx 4 + from quart import Blueprint, current_app, render_template, request 5 + 6 + from core.models import ( 7 + BBSNotFoundError, NetworkError, NoBBSError, Thread, 8 + ) 9 + from core.records import hydrate_replies, hydrate_threads 10 + from core.resolver import resolve_bbs 11 + from core.slingshot import get_record, resolve_identity, resolve_identities_batch 12 + 13 + bp = Blueprint("main", __name__) 14 + 15 + 16 + async def error(message: str, status: int = 404): 17 + return await render_template("error.html", message=message), status 18 + 19 + 20 + @bp.route("/") 21 + async def home(): 22 + return await render_template("home.html") 23 + 24 + 25 + @bp.route("/login") 26 + async def login_page(): 27 + return await render_template("login.html") 28 + 29 + 30 + @bp.route("/api/discover") 31 + async def discover(): 32 + client = current_app.http_client 33 + bbses = [] 34 + try: 35 + resp = await client.get( 36 + "https://ufos-api.microcosm.blue/records", 37 + params={"collection": "xyz.atboards.site", "limit": 50}, 38 + ) 39 + if resp.status_code == 200: 40 + raw = resp.json() 41 + if len(raw) > 5: 42 + raw = random.sample(raw, 5) 43 + dids = [r["did"] for r in raw] 44 + authors = await resolve_identities_batch(client, dids) 45 + for r in raw: 46 + did = r["did"] 47 + if did in authors: 48 + bbses.append({ 49 + "handle": authors[did].handle, 50 + "name": r["record"].get("name", ""), 51 + "description": r["record"].get("description", ""), 52 + }) 53 + except Exception: 54 + pass 55 + return {"bbses": bbses} 56 + 57 + 58 + @bp.route("/bbs/<handle>") 59 + async def site(handle: str): 60 + client = current_app.http_client 61 + try: 62 + bbs = await resolve_bbs(client, handle) 63 + except BBSNotFoundError: 64 + return await error("BBS not found.") 65 + except NoBBSError: 66 + return await error("This account isn't running a BBS.") 67 + except NetworkError: 68 + return await error("Could not reach the network. Try again.", 502) 69 + 70 + return await render_template("site.html", bbs=bbs, handle=handle) 71 + 72 + 73 + @bp.route("/bbs/<handle>/board/<slug>") 74 + async def board(handle: str, slug: str): 75 + client = current_app.http_client 76 + try: 77 + bbs = await resolve_bbs(client, handle) 78 + except BBSNotFoundError: 79 + return await error("BBS not found.") 80 + except NoBBSError: 81 + return await error("This account isn't running a BBS.") 82 + except NetworkError: 83 + return await error("Could not reach the network. Try again.", 502) 84 + 85 + current_board = next((b for b in bbs.site.boards if b.slug == slug), None) 86 + if current_board is None: 87 + return await error("Board not found.") 88 + 89 + return await render_template( 90 + "board.html", 91 + bbs=bbs, 92 + board=current_board, 93 + handle=handle, 94 + ) 95 + 96 + 97 + @bp.route("/api/threads/<handle>/<slug>") 98 + async def api_threads(handle: str, slug: str): 99 + client = current_app.http_client 100 + cursor = request.args.get("cursor") 101 + 102 + try: 103 + bbs = await resolve_bbs(client, handle) 104 + except Exception: 105 + return {"threads": [], "cursor": None} 106 + 107 + current_board = next((b for b in bbs.site.boards if b.slug == slug), None) 108 + if not current_board: 109 + return {"threads": [], "cursor": None} 110 + 111 + try: 112 + threads, next_cursor = await hydrate_threads(client, bbs, current_board, cursor=cursor) 113 + except Exception: 114 + return {"threads": [], "cursor": None} 115 + 116 + return { 117 + "threads": [ 118 + { 119 + "uri": t.uri, 120 + "did": t.author.did, 121 + "rkey": t.uri.split("/")[-1], 122 + "handle": t.author.handle, 123 + "title": t.title, 124 + "body": t.body, 125 + "created_at": t.created_at, 126 + } 127 + for t in threads 128 + ], 129 + "cursor": next_cursor, 130 + } 131 + 132 + 133 + @bp.route("/bbs/<handle>/thread/<did>/<tid>") 134 + async def thread(handle: str, did: str, tid: str): 135 + client = current_app.http_client 136 + try: 137 + bbs = await resolve_bbs(client, handle) 138 + except BBSNotFoundError: 139 + return await error("BBS not found.") 140 + except NoBBSError: 141 + return await error("This account isn't running a BBS.") 142 + except NetworkError: 143 + return await error("Could not reach the network. Try again.", 502) 144 + 145 + try: 146 + thread_record = await get_record(client, did, "xyz.atboards.thread", tid) 147 + thread_author = await resolve_identity(client, did) 148 + except httpx.HTTPStatusError: 149 + return await error("Thread not found.") 150 + except httpx.TransportError: 151 + return await error("Could not reach the network. Try again.", 502) 152 + 153 + thread_obj = Thread( 154 + uri=thread_record.uri, 155 + board_uri=thread_record.value["board"], 156 + title=thread_record.value["title"], 157 + body=thread_record.value["body"], 158 + created_at=thread_record.value["createdAt"], 159 + author=thread_author, 160 + updated_at=thread_record.value.get("updatedAt"), 161 + ) 162 + 163 + board_slug = thread_obj.board_uri.split("/")[-1] 164 + current_board = next((b for b in bbs.site.boards if b.slug == board_slug), None) 165 + 166 + return await render_template( 167 + "thread.html", 168 + bbs=bbs, 169 + thread=thread_obj, 170 + board=current_board, 171 + handle=handle, 172 + ) 173 + 174 + 175 + @bp.route("/api/replies/<did>/<tid>") 176 + async def api_replies(did: str, tid: str): 177 + client = current_app.http_client 178 + cursor = request.args.get("cursor") 179 + handle = request.args.get("handle", "") 180 + 181 + try: 182 + if handle: 183 + bbs = await resolve_bbs(client, handle) 184 + else: 185 + bbs = None 186 + except Exception: 187 + bbs = None 188 + 189 + if not bbs: 190 + return {"replies": [], "cursor": None} 191 + 192 + # Build a minimal Thread object for hydrate_replies 193 + thread_uri = f"at://{did}/xyz.atboards.thread/{tid}" 194 + from core.models import MiniDoc 195 + dummy_thread = Thread( 196 + uri=thread_uri, 197 + board_uri="", 198 + title="", 199 + body="", 200 + created_at="", 201 + author=MiniDoc(did=did, handle=""), 202 + ) 203 + 204 + try: 205 + replies, next_cursor = await hydrate_replies(client, bbs, dummy_thread, cursor=cursor) 206 + except Exception: 207 + return {"replies": [], "cursor": None} 208 + 209 + return { 210 + "replies": [ 211 + { 212 + "uri": r.uri, 213 + "did": r.author.did, 214 + "rkey": r.uri.split("/")[-1], 215 + "handle": r.author.handle, 216 + "body": r.body, 217 + "created_at": r.created_at, 218 + } 219 + for r in replies 220 + ], 221 + "cursor": next_cursor, 222 + }
+209
web/routes_auth.py
··· 1 + """OAuth login/callback/logout routes for the web app.""" 2 + 3 + import json 4 + from urllib.parse import quote, urlencode, urlparse 5 + 6 + from authlib.jose import JsonWebKey 7 + from quart import Blueprint, current_app, redirect, request, session 8 + 9 + from core.auth.oauth import ( 10 + exchange_code, 11 + fetch_authserver_meta, 12 + resolve_pds_authserver, 13 + revoke_tokens, 14 + send_par_request, 15 + ) 16 + from core.slingshot import resolve_identity 17 + 18 + bp = Blueprint("auth", __name__) 19 + 20 + OAUTH_SCOPE = "atproto transition:generic collection:xyz.atboards.site collection:xyz.atboards.board collection:xyz.atboards.news collection:xyz.atboards.thread collection:xyz.atboards.reply" 21 + 22 + 23 + def _compute_client_id() -> tuple[str, str]: 24 + """Compute client_id and redirect_uri based on PUBLIC_URL. 25 + 26 + For loopback (localhost/127.0.0.1), uses the AT Protocol loopback 27 + client ID format. For production, points to the client metadata document. 28 + """ 29 + public_url = current_app.config["PUBLIC_URL"] 30 + parsed = urlparse(public_url) 31 + 32 + if parsed.hostname in ("localhost", "127.0.0.1"): 33 + redirect_uri = f"http://127.0.0.1:{parsed.port}/oauth/callback" 34 + client_id = "http://localhost?" + urlencode( 35 + {"redirect_uri": redirect_uri, "scope": OAUTH_SCOPE} 36 + ) 37 + else: 38 + app_url = public_url.replace("http://", "https://") 39 + if not app_url.endswith("/"): 40 + app_url += "/" 41 + redirect_uri = f"{app_url}oauth/callback" 42 + client_id = f"{app_url}oauth/client-metadata.json" 43 + 44 + return client_id, redirect_uri 45 + 46 + 47 + def _client_secret_jwk(): 48 + return json.loads(current_app.config["CLIENT_SECRET_JWK"]) 49 + 50 + 51 + @bp.route("/oauth/client-metadata.json") 52 + async def client_metadata(): 53 + client_id, redirect_uri = _compute_client_id() 54 + return { 55 + "client_id": client_id, 56 + "client_name": "atboards", 57 + "client_uri": current_app.config["PUBLIC_URL"], 58 + "application_type": "web", 59 + "grant_types": ["authorization_code", "refresh_token"], 60 + "response_types": ["code"], 61 + "redirect_uris": [redirect_uri], 62 + "scope": OAUTH_SCOPE, 63 + "token_endpoint_auth_method": "private_key_jwt", 64 + "token_endpoint_auth_signing_alg": "ES256", 65 + "dpop_bound_access_tokens": True, 66 + "jwks": { 67 + "keys": [ 68 + json.loads( 69 + JsonWebKey.import_key( 70 + _client_secret_jwk() 71 + ).as_json(is_private=False) 72 + ) 73 + ] 74 + }, 75 + } 76 + 77 + 78 + @bp.route("/oauth/login", methods=["POST"]) 79 + async def login(): 80 + form = await request.form 81 + handle = form.get("handle", "").strip() 82 + if not handle: 83 + return redirect("/") 84 + 85 + client = current_app.http_client 86 + store = current_app.session_store 87 + client_id, redirect_uri = _compute_client_id() 88 + 89 + # Resolve identity 90 + identity = await resolve_identity(client, handle) 91 + pds_url = identity.pds 92 + if not pds_url: 93 + return redirect("/") 94 + 95 + # Discover auth server 96 + authserver_url = await resolve_pds_authserver(client, pds_url) 97 + authserver_meta = await fetch_authserver_meta(client, authserver_url) 98 + 99 + # Generate DPoP keypair for this login attempt 100 + dpop_key = JsonWebKey.generate_key("EC", "P-256", is_private=True) 101 + dpop_private_jwk_json = dpop_key.as_json(is_private=True) 102 + 103 + # Send PAR 104 + pkce_verifier, state, dpop_nonce, par_resp = await send_par_request( 105 + client=client, 106 + authserver_url=authserver_url, 107 + authserver_meta=authserver_meta, 108 + login_hint=handle, 109 + client_id=client_id, 110 + redirect_uri=redirect_uri, 111 + scope=OAUTH_SCOPE, 112 + client_secret_jwk=_client_secret_jwk(), 113 + dpop_private_jwk=dpop_key, 114 + ) 115 + 116 + # Save auth request state 117 + store.save_auth_request( 118 + state=state, 119 + authserver_iss=authserver_url, 120 + did=identity.did, 121 + handle=identity.handle, 122 + pds_url=pds_url, 123 + pkce_verifier=pkce_verifier, 124 + scope=OAUTH_SCOPE, 125 + dpop_authserver_nonce=dpop_nonce, 126 + dpop_private_jwk=dpop_private_jwk_json, 127 + ) 128 + 129 + # Redirect to auth server 130 + auth_url = authserver_meta["authorization_endpoint"] 131 + return redirect( 132 + f"{auth_url}?client_id={quote(client_id, safe='')}&request_uri={quote(par_resp['request_uri'], safe='')}" 133 + ) 134 + 135 + 136 + @bp.route("/oauth/callback") 137 + async def callback(): 138 + code = request.args.get("code") 139 + state = request.args.get("state") 140 + iss = request.args.get("iss") 141 + 142 + if not code or not state: 143 + return redirect("/") 144 + 145 + client = current_app.http_client 146 + store = current_app.session_store 147 + client_id, redirect_uri = _compute_client_id() 148 + 149 + # Look up auth request 150 + auth_req = store.get_auth_request(state) 151 + if not auth_req: 152 + return redirect("/") 153 + 154 + # Validate issuer 155 + if iss and iss != auth_req["authserver_iss"]: 156 + store.delete_auth_request(state) 157 + return redirect("/") 158 + 159 + # Exchange code for tokens 160 + token_resp, dpop_nonce = await exchange_code( 161 + client=client, 162 + auth_request=auth_req, 163 + code=code, 164 + client_id=client_id, 165 + redirect_uri=redirect_uri, 166 + client_secret_jwk=_client_secret_jwk(), 167 + ) 168 + 169 + # Save session 170 + store.save_session( 171 + did=auth_req["did"], 172 + handle=auth_req["handle"], 173 + pds_url=auth_req["pds_url"], 174 + authserver_iss=auth_req["authserver_iss"], 175 + access_token=token_resp["access_token"], 176 + refresh_token=token_resp.get("refresh_token", ""), 177 + dpop_authserver_nonce=dpop_nonce, 178 + dpop_pds_nonce="", 179 + dpop_private_jwk=auth_req["dpop_private_jwk"], 180 + ) 181 + 182 + # Clean up auth request, set cookie 183 + store.delete_auth_request(state) 184 + session["did"] = auth_req["did"] 185 + 186 + return redirect("/") 187 + 188 + 189 + @bp.route("/oauth/logout", methods=["POST"]) 190 + async def logout(): 191 + did = session.get("did") 192 + if did: 193 + client = current_app.http_client 194 + store = current_app.session_store 195 + client_id, _ = _compute_client_id() 196 + oauth_session = store.get_session(did) 197 + if oauth_session: 198 + try: 199 + await revoke_tokens( 200 + client=client, 201 + session=oauth_session, 202 + client_id=client_id, 203 + client_secret_jwk=_client_secret_jwk(), 204 + ) 205 + except Exception: 206 + pass 207 + store.delete_session(did) 208 + session.pop("did", None) 209 + return redirect("/")
+203
web/routes_sysop.py
··· 1 + """Sysop routes — create and edit BBS.""" 2 + 3 + from quart import Blueprint, current_app, redirect, render_template, request 4 + 5 + from core.util import now_iso 6 + from web.helpers import get_user 7 + from web.routes_write import _authed_pds_post, authed_delete_record 8 + 9 + bp = Blueprint("sysop", __name__) 10 + 11 + 12 + async def _has_bbs(user: dict) -> bool: 13 + """Check if the user has a site record.""" 14 + client = current_app.http_client 15 + try: 16 + from core.slingshot import get_record 17 + await get_record(client, user["did"], "xyz.atboards.site", "self") 18 + return True 19 + except Exception: 20 + return False 21 + 22 + 23 + @bp.route("/account") 24 + async def account(): 25 + user = await get_user() 26 + if not user: 27 + return redirect("/login") 28 + 29 + has_bbs = await _has_bbs(user) 30 + return await render_template("account.html", user=user, has_bbs=has_bbs) 31 + 32 + 33 + @bp.route("/account/create", methods=["GET", "POST"]) 34 + async def create_bbs(): 35 + user = await get_user() 36 + if not user: 37 + return redirect("/login") 38 + 39 + if request.method == "GET": 40 + return await render_template("sysop_create.html") 41 + 42 + form = await request.form 43 + name = form.get("name", "").strip() 44 + description = form.get("description", "").strip() 45 + intro = form.get("intro", "") 46 + board_slugs = [s.strip() for s in form.getlist("board_slug") if s.strip()] 47 + board_names = [s.strip() for s in form.getlist("board_name") if s.strip()] 48 + board_descs = form.getlist("board_desc") 49 + 50 + if not name or not board_slugs: 51 + return await render_template("sysop_create.html", error="Name and at least one board are required.") 52 + 53 + now = now_iso() 54 + 55 + # Create board records 56 + for i, slug in enumerate(board_slugs): 57 + board_name = board_names[i] if i < len(board_names) else slug 58 + board_desc = board_descs[i].strip() if i < len(board_descs) else "" 59 + await _authed_pds_post(user, "com.atproto.repo.putRecord", { 60 + "repo": user["did"], 61 + "collection": "xyz.atboards.board", 62 + "rkey": slug, 63 + "record": { 64 + "$type": "xyz.atboards.board", 65 + "name": board_name, 66 + "description": board_desc, 67 + "createdAt": now, 68 + }, 69 + }) 70 + 71 + # Create site record 72 + await _authed_pds_post(user, "com.atproto.repo.putRecord", { 73 + "repo": user["did"], 74 + "collection": "xyz.atboards.site", 75 + "rkey": "self", 76 + "record": { 77 + "$type": "xyz.atboards.site", 78 + "name": name, 79 + "description": description, 80 + "intro": intro, 81 + "boards": board_slugs, 82 + "bannedDids": [], 83 + "createdAt": now, 84 + }, 85 + }) 86 + 87 + return redirect(f"/bbs/{user['handle']}") 88 + 89 + 90 + @bp.route("/account/edit", methods=["GET", "POST"]) 91 + async def edit_bbs(): 92 + user = await get_user() 93 + if not user: 94 + return redirect("/login") 95 + 96 + client = current_app.http_client 97 + 98 + if request.method == "GET": 99 + try: 100 + from core.resolver import resolve_bbs 101 + bbs = await resolve_bbs(client, user["handle"]) 102 + except Exception: 103 + return redirect("/account/create") 104 + return await render_template("sysop_edit.html", bbs=bbs) 105 + 106 + form = await request.form 107 + name = form.get("name", "").strip() 108 + description = form.get("description", "").strip() 109 + intro = form.get("intro", "") 110 + board_slugs = [s.strip() for s in form.getlist("board_slug") if s.strip()] 111 + board_names = [s.strip() for s in form.getlist("board_name") if s.strip()] 112 + board_descs = form.getlist("board_desc") 113 + 114 + if not name: 115 + return redirect("/account/edit") 116 + 117 + now = now_iso() 118 + 119 + # Fetch existing site record to preserve createdAt 120 + from core.slingshot import get_record 121 + try: 122 + existing = await get_record(client, user["did"], "xyz.atboards.site", "self") 123 + created_at = existing.value.get("createdAt", now) 124 + existing_banned = existing.value.get("bannedDids", []) 125 + except Exception: 126 + created_at = now 127 + existing_banned = [] 128 + 129 + # Upsert board records 130 + for i, slug in enumerate(board_slugs): 131 + board_name = board_names[i] if i < len(board_names) else slug 132 + board_desc = board_descs[i].strip() if i < len(board_descs) else "" 133 + await _authed_pds_post(user, "com.atproto.repo.putRecord", { 134 + "repo": user["did"], 135 + "collection": "xyz.atboards.board", 136 + "rkey": slug, 137 + "record": { 138 + "$type": "xyz.atboards.board", 139 + "name": board_name, 140 + "description": board_desc, 141 + "createdAt": now, 142 + }, 143 + }) 144 + 145 + # Update site record 146 + await _authed_pds_post(user, "com.atproto.repo.putRecord", { 147 + "repo": user["did"], 148 + "collection": "xyz.atboards.site", 149 + "rkey": "self", 150 + "record": { 151 + "$type": "xyz.atboards.site", 152 + "name": name, 153 + "description": description, 154 + "intro": intro, 155 + "boards": board_slugs, 156 + "bannedDids": existing_banned, 157 + "createdAt": created_at, 158 + "updatedAt": now, 159 + }, 160 + }) 161 + 162 + return redirect(f"/bbs/{user['handle']}") 163 + 164 + 165 + @bp.route("/bbs/<handle>/news/new", methods=["POST"]) 166 + async def create_news(handle: str): 167 + user = await get_user() 168 + if not user or user["handle"] != handle: 169 + return redirect(f"/bbs/{handle}") 170 + 171 + form = await request.form 172 + title = form.get("title", "").strip() 173 + body = form.get("body", "").strip() 174 + if not title or not body: 175 + return redirect(f"/bbs/{handle}") 176 + 177 + site_uri = f"at://{user['did']}/xyz.atboards.site/self" 178 + now = now_iso() 179 + 180 + await _authed_pds_post(user, "com.atproto.repo.createRecord", { 181 + "repo": user["did"], 182 + "collection": "xyz.atboards.news", 183 + "record": { 184 + "$type": "xyz.atboards.news", 185 + "site": site_uri, 186 + "title": title, 187 + "body": body, 188 + "createdAt": now, 189 + }, 190 + }) 191 + 192 + return redirect(f"/bbs/{handle}") 193 + 194 + 195 + @bp.route("/bbs/<handle>/news/<tid>/delete", methods=["POST"]) 196 + async def delete_news(handle: str, tid: str): 197 + user = await get_user() 198 + if not user or user["handle"] != handle: 199 + return redirect(f"/bbs/{handle}") 200 + 201 + await authed_delete_record(user, "xyz.atboards.news", tid) 202 + 203 + return redirect(f"/bbs/{handle}")
+143
web/routes_write.py
··· 1 + """Write routes — creating threads and replies.""" 2 + 3 + from quart import Blueprint, current_app, redirect, request 4 + 5 + from core.auth.oauth import pds_request, refresh_tokens 6 + from core.auth.session import SessionStore 7 + from core.util import now_iso 8 + from web.helpers import get_user, session_updater 9 + 10 + bp = Blueprint("write", __name__) 11 + 12 + 13 + async def _authed_pds_post(user: dict, endpoint: str, body: dict): 14 + """Make an authenticated POST to the user's PDS, with token refresh on 401.""" 15 + client = current_app.http_client 16 + url = f"{user['pds_url']}/xrpc/{endpoint}" 17 + 18 + resp = await pds_request(client, "POST", url, user, session_updater, body=body) 19 + 20 + # Token refresh on 401 21 + if resp.status_code == 401: 22 + from web.routes_auth import _compute_client_id, _client_secret_jwk 23 + client_id, _ = _compute_client_id() 24 + client_secret_jwk = _client_secret_jwk() 25 + 26 + token_resp, dpop_nonce = await refresh_tokens( 27 + client=client, 28 + session=user, 29 + client_id=client_id, 30 + client_secret_jwk=client_secret_jwk, 31 + ) 32 + 33 + store: SessionStore = current_app.session_store 34 + store.update_session_tokens( 35 + user["did"], 36 + token_resp["access_token"], 37 + token_resp.get("refresh_token", user["refresh_token"]), 38 + dpop_nonce, 39 + ) 40 + 41 + user["access_token"] = token_resp["access_token"] 42 + if "refresh_token" in token_resp: 43 + user["refresh_token"] = token_resp["refresh_token"] 44 + user["dpop_authserver_nonce"] = dpop_nonce 45 + 46 + resp = await pds_request(client, "POST", url, user, session_updater, body=body) 47 + 48 + return resp 49 + 50 + 51 + async def authed_delete_record(user: dict, collection: str, rkey: str): 52 + """Delete a record from the user's repo via OAuth.""" 53 + resp = await _authed_pds_post(user, "com.atproto.repo.deleteRecord", { 54 + "repo": user["did"], 55 + "collection": collection, 56 + "rkey": rkey, 57 + }) 58 + resp.raise_for_status() 59 + return resp 60 + 61 + 62 + @bp.route("/bbs/<handle>/board/<slug>/new-thread", methods=["POST"]) 63 + async def create_thread(handle: str, slug: str): 64 + user = await get_user() 65 + if not user: 66 + return redirect(f"/bbs/{handle}/board/{slug}") 67 + 68 + form = await request.form 69 + title = form.get("title", "").strip() 70 + body = form.get("body", "").strip() 71 + if not title or not body: 72 + return redirect(f"/bbs/{handle}/board/{slug}") 73 + 74 + from core.resolver import resolve_bbs 75 + client = current_app.http_client 76 + try: 77 + bbs = await resolve_bbs(client, handle) 78 + except Exception: 79 + return redirect(f"/bbs/{handle}/board/{slug}") 80 + board_uri = f"at://{bbs.identity.did}/xyz.atboards.board/{slug}" 81 + 82 + resp = await _authed_pds_post(user, "com.atproto.repo.createRecord", { 83 + "repo": user["did"], 84 + "collection": "xyz.atboards.thread", 85 + "record": { 86 + "$type": "xyz.atboards.thread", 87 + "board": board_uri, 88 + "title": title, 89 + "body": body, 90 + "createdAt": now_iso(), 91 + }, 92 + }) 93 + resp.raise_for_status() 94 + 95 + return redirect(f"/bbs/{handle}/board/{slug}") 96 + 97 + 98 + @bp.route("/bbs/<handle>/thread/<did>/<tid>/reply", methods=["POST"]) 99 + async def create_reply(handle: str, did: str, tid: str): 100 + user = await get_user() 101 + if not user: 102 + return redirect(f"/bbs/{handle}/thread/{did}/{tid}") 103 + 104 + form = await request.form 105 + body = form.get("body", "").strip() 106 + if not body: 107 + return redirect(f"/bbs/{handle}/thread/{did}/{tid}") 108 + 109 + thread_uri = f"at://{did}/xyz.atboards.thread/{tid}" 110 + 111 + resp = await _authed_pds_post(user, "com.atproto.repo.createRecord", { 112 + "repo": user["did"], 113 + "collection": "xyz.atboards.reply", 114 + "record": { 115 + "$type": "xyz.atboards.reply", 116 + "subject": thread_uri, 117 + "body": body, 118 + "createdAt": now_iso(), 119 + }, 120 + }) 121 + resp.raise_for_status() 122 + 123 + return redirect(f"/bbs/{handle}/thread/{did}/{tid}") 124 + 125 + 126 + @bp.route("/bbs/<handle>/thread/<did>/<tid>/delete", methods=["POST"]) 127 + async def delete_thread(handle: str, did: str, tid: str): 128 + user = await get_user() 129 + if not user or user["did"] != did: 130 + return redirect(f"/bbs/{handle}/thread/{did}/{tid}") 131 + 132 + await authed_delete_record(user, "xyz.atboards.thread", tid) 133 + return redirect(f"/bbs/{handle}") 134 + 135 + 136 + @bp.route("/bbs/<handle>/thread/<did>/<tid>/reply/<reply_tid>/delete", methods=["POST"]) 137 + async def delete_reply(handle: str, did: str, tid: str, reply_tid: str): 138 + user = await get_user() 139 + if not user: 140 + return redirect(f"/bbs/{handle}/thread/{did}/{tid}") 141 + 142 + await authed_delete_record(user, "xyz.atboards.reply", reply_tid) 143 + return redirect(f"/bbs/{handle}/thread/{did}/{tid}")
+2
web/static/input.css
··· 1 + @import "tailwindcss"; 2 + @source "../../web/templates";
+2
web/static/style.css
··· 1 + /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ 2 + @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-bold:700;--tracking-wide:.025em;--leading-snug:1.375;--leading-relaxed:1.625;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-4{margin-left:calc(var(--spacing) * 4)}.block{display:block}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.min-h-screen{min-height:100vh}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-neutral-800{border-color:var(--color-neutral-800)}.border-neutral-800\/50{border-color:#26262680}@supports (color:color-mix(in lab, red, red)){.border-neutral-800\/50{border-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-950{background-color:var(--color-neutral-950)}.p-4{padding:calc(var(--spacing) * 4)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-6{padding-top:calc(var(--spacing) * 6)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-red-500{color:var(--color-red-500)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-neutral-500::placeholder{color:var(--color-neutral-500)}@media (hover:hover){.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.hover\:text-neutral-200:hover{color:var(--color-neutral-200)}.hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:border-neutral-600:focus{border-color:var(--color-neutral-600)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
+24
web/templates/account.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}Account — atboards{% endblock %} 3 + {% block content %} 4 + <h1 class="text-lg text-neutral-200 mb-1">{{ user.handle }}</h1> 5 + <p class="text-neutral-500 mb-8">{{ user.did }}</p> 6 + 7 + <h2 class="text-xs text-neutral-500 uppercase tracking-wide mb-3">Your BBS</h2> 8 + 9 + {% if has_bbs %} 10 + <div class="space-y-2"> 11 + <a href="/bbs/{{ user.handle }}" class="block px-3 py-2 -mx-3 rounded hover:bg-neutral-900 text-neutral-300 hover:text-white"> 12 + browse your bbs 13 + </a> 14 + <a href="/account/edit" class="block px-3 py-2 -mx-3 rounded hover:bg-neutral-900 text-neutral-300 hover:text-white"> 15 + edit bbs 16 + </a> 17 + </div> 18 + {% else %} 19 + <p class="text-neutral-500 mb-4">You haven't set up a BBS yet.</p> 20 + <a href="/account/create" class="inline-block bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 21 + create a bbs 22 + </a> 23 + {% endif %} 24 + {% endblock %}
+41
web/templates/base.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>{% block title %}atboards{% endblock %}</title> 7 + <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> 8 + </head> 9 + <body class="bg-neutral-950 text-neutral-400 font-mono text-sm min-h-screen antialiased flex flex-col"> 10 + <header class="border-b border-neutral-800"> 11 + <div class="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> 12 + <div class="flex items-center gap-2 text-neutral-500 overflow-x-auto whitespace-nowrap"> 13 + <a href="/" class="text-neutral-200 hover:text-white shrink-0">atboards</a> 14 + {% block breadcrumb %}{% endblock %} 15 + </div> 16 + <div class="shrink-0 ml-4 flex items-center gap-3"> 17 + {% if g.user %} 18 + <a href="/account" class="text-neutral-500 hover:text-neutral-300">{{ g.user.handle }}</a> 19 + <form method="post" action="/oauth/logout" class="inline"> 20 + <button type="submit" class="text-neutral-500 hover:text-neutral-300">log out</button> 21 + </form> 22 + {% else %} 23 + <a href="/login" class="text-neutral-500 hover:text-neutral-300">log in</a> 24 + {% endif %} 25 + </div> 26 + </div> 27 + </header> 28 + <main class="max-w-2xl mx-auto px-4 py-8 flex-1 w-full"> 29 + {% block content %}{% endblock %} 30 + </main> 31 + <footer class="border-t border-neutral-800 mt-auto"> 32 + <div class="max-w-2xl mx-auto px-4 py-6 flex items-center justify-between text-xs text-neutral-500"> 33 + <span>made by <a href="https://aly.codes" class="text-neutral-500 hover:text-neutral-300">aly.codes</a></span> 34 + <div class="flex items-center gap-4"> 35 + <a href="https://github.com/alyraffauf/atboards" class="text-neutral-500 hover:text-neutral-300">github</a> 36 + <a href="https://ko-fi.com/alyraffauf" class="text-neutral-500 hover:text-neutral-300">ko-fi</a> 37 + </div> 38 + </div> 39 + </footer> 40 + </body> 41 + </html>
+111
web/templates/board.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}{{ board.name }} — {{ bbs.site.name }}{% endblock %} 3 + {% block breadcrumb %} 4 + <span>/</span> 5 + <a href="/bbs/{{ handle }}" class="text-neutral-500 hover:text-neutral-300">{{ bbs.site.name }}</a> 6 + <span>/</span> 7 + <span class="text-neutral-400">{{ board.name }}</span> 8 + {% endblock %} 9 + {% block content %} 10 + <div class="mb-6"> 11 + <h1 class="text-lg text-neutral-200 mb-1">{{ board.name }}</h1> 12 + <p class="text-neutral-500">{{ board.description }}</p> 13 + </div> 14 + 15 + {% if g.user %} 16 + <details class="mb-6 border border-neutral-800 rounded p-4"> 17 + <summary class="text-neutral-300 cursor-pointer">new thread</summary> 18 + <form method="post" action="/bbs/{{ handle }}/board/{{ board.slug }}/new-thread" class="mt-4 space-y-3"> 19 + <input 20 + type="text" 21 + name="title" 22 + placeholder="Thread title" 23 + required 24 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600" 25 + > 26 + <textarea 27 + name="body" 28 + placeholder="What's on your mind?" 29 + required 30 + rows="4" 31 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600 resize-y" 32 + ></textarea> 33 + <button type="submit" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 34 + post 35 + </button> 36 + </form> 37 + </details> 38 + {% endif %} 39 + 40 + <div id="threads"> 41 + <p id="threads-loading" class="text-neutral-500">Loading threads...</p> 42 + </div> 43 + <div id="threads-next" class="mt-6 text-center hidden"> 44 + <button id="load-more" class="text-neutral-500 hover:text-neutral-300">next page →</button> 45 + </div> 46 + 47 + <script> 48 + const handle = "{{ handle }}"; 49 + const slug = "{{ board.slug }}"; 50 + let nextCursor = null; 51 + 52 + function formatDate(iso) { 53 + const d = new Date(iso); 54 + const pad = n => String(n).padStart(2, '0'); 55 + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`; 56 + } 57 + 58 + function escapeHtml(s) { 59 + const el = document.createElement('span'); 60 + el.textContent = s; 61 + return el.innerHTML; 62 + } 63 + 64 + function renderThread(t) { 65 + const a = document.createElement('a'); 66 + a.href = `/bbs/${handle}/thread/${t.did}/${t.rkey}`; 67 + a.className = "flex items-baseline justify-between gap-4 px-3 py-2.5 -mx-3 rounded hover:bg-neutral-900 group"; 68 + a.innerHTML = `<span class="text-neutral-300 group-hover:text-white truncate">${escapeHtml(t.title)}</span><span class="shrink-0 text-xs text-neutral-500">${escapeHtml(t.handle)} · ${formatDate(t.created_at)}</span>`; 69 + return a; 70 + } 71 + 72 + function loadThreads(cursor) { 73 + let url = `/api/threads/${encodeURIComponent(handle)}/${encodeURIComponent(slug)}`; 74 + if (cursor) url += `?cursor=${encodeURIComponent(cursor)}`; 75 + 76 + fetch(url) 77 + .then(r => r.json()) 78 + .then(data => { 79 + const container = document.getElementById('threads'); 80 + const loading = document.getElementById('threads-loading'); 81 + if (loading) loading.remove(); 82 + 83 + data.threads.forEach(t => { 84 + container.appendChild(renderThread(t)); 85 + }); 86 + 87 + if (data.cursor) { 88 + nextCursor = data.cursor; 89 + document.getElementById('threads-next').classList.remove('hidden'); 90 + } else { 91 + nextCursor = null; 92 + document.getElementById('threads-next').classList.add('hidden'); 93 + } 94 + 95 + if (container.children.length === 0) { 96 + container.innerHTML = '<p class="text-neutral-500">No threads yet.</p>'; 97 + } 98 + }) 99 + .catch(() => { 100 + const loading = document.getElementById('threads-loading'); 101 + if (loading) loading.textContent = 'Failed to load threads.'; 102 + }); 103 + } 104 + 105 + document.getElementById('load-more').addEventListener('click', () => { 106 + if (nextCursor) loadThreads(nextCursor); 107 + }); 108 + 109 + loadThreads(null); 110 + </script> 111 + {% endblock %}
+8
web/templates/error.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}Error — atboards{% endblock %} 3 + {% block content %} 4 + <div class="mt-24 max-w-sm mx-auto text-center"> 5 + <p class="text-neutral-500 mb-4">{{ message }}</p> 6 + <a href="/" class="text-neutral-400 hover:text-neutral-200 underline underline-offset-2">back to home</a> 7 + </div> 8 + {% endblock %}
+51
web/templates/home.html
··· 1 + {% extends "base.html" %} 2 + {% block content %} 3 + <div class="max-w-2xl mx-auto px-4"> 4 + <h1 class="text-lg text-neutral-200 mb-1">atboards</h1> 5 + <p class="text-neutral-500 mb-8">Decentralized bulletin boards on <a href="https://atproto.com" class="text-neutral-400 hover:text-neutral-300">atproto</a>. Anyone can run a BBS from their own account, no server required. Users own their posts, and communities can migrate freely.</p> 6 + <div class="border-t border-neutral-800 pt-6"> 7 + <p class="text-neutral-500 mb-4">Enter a handle to connect to a BBS.</p> 8 + </div> 9 + <form action="/" method="get" id="handle-form" class="flex gap-2 mb-8"> 10 + <input 11 + type="text" 12 + name="handle" 13 + placeholder="handle.example.com" 14 + required 15 + class="flex-1 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600" 16 + > 17 + <button type="submit" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 18 + go 19 + </button> 20 + </form> 21 + 22 + <div id="discover" class="border-t border-neutral-800 pt-6 mb-8 hidden"> 23 + <h2 class="text-xs text-neutral-500 uppercase tracking-wide mb-3">Discover</h2> 24 + <div id="discover-list" class="space-y-1"></div> 25 + </div> 26 + </div> 27 + <script> 28 + document.getElementById("handle-form").addEventListener("submit", function(e) { 29 + e.preventDefault(); 30 + const handle = this.elements["handle"].value.trim(); 31 + if (handle) window.location.href = "/bbs/" + encodeURIComponent(handle); 32 + }); 33 + 34 + fetch("/api/discover") 35 + .then(r => r.json()) 36 + .then(data => { 37 + if (data.bbses && data.bbses.length > 0) { 38 + const list = document.getElementById("discover-list"); 39 + data.bbses.forEach(bbs => { 40 + const a = document.createElement("a"); 41 + a.href = "/bbs/" + encodeURIComponent(bbs.handle); 42 + a.className = "flex items-baseline gap-3 px-3 py-2 -mx-3 rounded hover:bg-neutral-900 group"; 43 + a.innerHTML = `<span class="text-neutral-200 group-hover:text-white">${bbs.name || bbs.handle}</span><span class="text-neutral-500">${bbs.description}</span>`; 44 + list.appendChild(a); 45 + }); 46 + document.getElementById("discover").classList.remove("hidden"); 47 + } 48 + }) 49 + .catch(() => {}); 50 + </script> 51 + {% endblock %}
+18
web/templates/login.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}Login — atboards{% endblock %} 3 + {% block content %} 4 + <h1 class="text-lg text-neutral-200 mb-1">log in</h1> 5 + <p class="text-neutral-500 mb-6">Sign in with your atproto handle to post threads and replies.</p> 6 + <form method="post" action="/oauth/login" class="flex gap-2 max-w-md"> 7 + <input 8 + type="text" 9 + name="handle" 10 + placeholder="your-handle.bsky.social" 11 + required 12 + class="flex-1 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600" 13 + > 14 + <button type="submit" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 15 + log in 16 + </button> 17 + </form> 18 + {% endblock %}
+78
web/templates/site.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}{{ bbs.site.name }} — atboards{% endblock %} 3 + {% block breadcrumb %} 4 + <span>/</span> 5 + <span class="text-neutral-400">{{ bbs.identity.handle }}</span> 6 + {% endblock %} 7 + {% block content %} 8 + <div class="mb-8"> 9 + <h1 class="text-lg text-neutral-200 mb-1">{{ bbs.site.name }}</h1> 10 + <p class="text-neutral-500">{{ bbs.site.description }}</p> 11 + </div> 12 + 13 + {% if bbs.site.intro %} 14 + <pre class="bg-neutral-900 border border-neutral-800 rounded p-4 mb-8 overflow-x-auto text-neutral-500 text-xs leading-snug">{{ bbs.site.intro }}</pre> 15 + {% endif %} 16 + 17 + <section class="mb-8"> 18 + <h2 class="text-xs text-neutral-500 uppercase tracking-wide mb-3">Boards</h2> 19 + <div class="space-y-1"> 20 + {% for board in bbs.site.boards %} 21 + <a href="/bbs/{{ handle }}/board/{{ board.slug }}" class="flex items-baseline gap-3 px-3 py-2 -mx-3 rounded hover:bg-neutral-900 group"> 22 + <span class="text-neutral-200 group-hover:text-white">{{ board.name }}</span> 23 + <span class="text-neutral-500">{{ board.description }}</span> 24 + </a> 25 + {% endfor %} 26 + </div> 27 + </section> 28 + 29 + <section> 30 + <h2 class="text-xs text-neutral-500 uppercase tracking-wide mb-3">News</h2> 31 + 32 + {% if g.user and g.user.did == bbs.identity.did %} 33 + <details class="mb-4 border border-neutral-800 rounded p-4"> 34 + <summary class="text-neutral-300 cursor-pointer">post news</summary> 35 + <form method="post" action="/bbs/{{ handle }}/news/new" class="mt-4 space-y-3"> 36 + <input 37 + type="text" 38 + name="title" 39 + placeholder="Headline" 40 + required 41 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600" 42 + > 43 + <textarea 44 + name="body" 45 + placeholder="Announcement body..." 46 + required 47 + rows="3" 48 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600 resize-y" 49 + ></textarea> 50 + <button type="submit" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 51 + post 52 + </button> 53 + </form> 54 + </details> 55 + {% endif %} 56 + 57 + {% if bbs.news %} 58 + {% for item in bbs.news %} 59 + <div class="bg-neutral-900 border border-neutral-800 rounded p-4 {% if not loop.last %}mb-2{% endif %}"> 60 + <div class="flex items-baseline justify-between mb-2"> 61 + <span class="text-neutral-200">{{ item.title }}</span> 62 + <div class="flex items-center gap-3"> 63 + <span class="text-xs text-neutral-500">{{ item.created_at | datetime }}</span> 64 + {% if g.user and g.user.did == bbs.identity.did %} 65 + <form method="post" action="/bbs/{{ handle }}/news/{{ item.tid }}/delete" class="inline" onsubmit="return confirm('Delete this news post?')"> 66 + <button type="submit" class="text-xs text-neutral-500 hover:text-red-400">delete</button> 67 + </form> 68 + {% endif %} 69 + </div> 70 + </div> 71 + <p class="text-neutral-500">{{ item.body }}</p> 72 + </div> 73 + {% endfor %} 74 + {% else %} 75 + <p class="text-neutral-500">No news yet.</p> 76 + {% endif %} 77 + </section> 78 + {% endblock %}
+60
web/templates/sysop_create.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}Create BBS — atboards{% endblock %} 3 + {% block content %} 4 + <h1 class="text-lg text-neutral-200 mb-1">Create a BBS</h1> 5 + <p class="text-neutral-500 mb-6">Set up your BBS. Your handle becomes the address.</p> 6 + 7 + {% if error %} 8 + <p class="text-red-500 mb-4">{{ error }}</p> 9 + {% endif %} 10 + 11 + <form method="post" class="space-y-6"> 12 + <div> 13 + <label class="block text-neutral-400 mb-1">BBS Name</label> 14 + <input type="text" name="name" required placeholder="My Cool BBS" 15 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 16 + </div> 17 + 18 + <div> 19 + <label class="block text-neutral-400 mb-1">Description</label> 20 + <input type="text" name="description" placeholder="A short description of your BBS" 21 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 22 + </div> 23 + 24 + <div> 25 + <label class="block text-neutral-400 mb-1">Intro / Welcome Message</label> 26 + <textarea name="intro" rows="6" placeholder="ASCII art, rules, welcome message..." 27 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600 resize-y"></textarea> 28 + </div> 29 + 30 + <div> 31 + <label class="block text-neutral-400 mb-1">Boards</label> 32 + <p class="text-neutral-500 text-xs mb-2">One board per row: slug, name, description</p> 33 + <div id="board-rows" class="space-y-2"> 34 + <div class="flex gap-2"> 35 + <input type="text" name="board_slug" placeholder="general" class="w-1/4 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 36 + <input type="text" name="board_name" placeholder="General Discussion" class="w-1/3 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 37 + <input type="text" name="board_desc" placeholder="Whatever's on your mind." class="flex-1 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 38 + </div> 39 + </div> 40 + <button type="button" id="add-board" class="mt-2 text-neutral-500 hover:text-neutral-300 text-xs">+ add board</button> 41 + </div> 42 + 43 + <button type="submit" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 44 + create bbs 45 + </button> 46 + </form> 47 + 48 + <script> 49 + document.getElementById("add-board").addEventListener("click", function() { 50 + const row = document.createElement("div"); 51 + row.className = "flex gap-2"; 52 + row.innerHTML = ` 53 + <input type="text" name="board_slug" placeholder="slug" class="w-1/4 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 54 + <input type="text" name="board_name" placeholder="Name" class="w-1/3 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 55 + <input type="text" name="board_desc" placeholder="Description" class="flex-1 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 56 + `; 57 + document.getElementById("board-rows").appendChild(row); 58 + }); 59 + </script> 60 + {% endblock %}
+58
web/templates/sysop_edit.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}Edit BBS — atboards{% endblock %} 3 + {% block content %} 4 + <h1 class="text-lg text-neutral-200 mb-1">Edit BBS</h1> 5 + <p class="text-neutral-500 mb-6">Update your BBS.</p> 6 + 7 + <form method="post" class="space-y-6"> 8 + <div> 9 + <label class="block text-neutral-400 mb-1">BBS Name</label> 10 + <input type="text" name="name" required value="{{ bbs.site.name }}" 11 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 12 + </div> 13 + 14 + <div> 15 + <label class="block text-neutral-400 mb-1">Description</label> 16 + <input type="text" name="description" value="{{ bbs.site.description }}" 17 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 18 + </div> 19 + 20 + <div> 21 + <label class="block text-neutral-400 mb-1">Intro / Welcome Message</label> 22 + <textarea name="intro" rows="6" 23 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600 resize-y">{{ bbs.site.intro }}</textarea> 24 + </div> 25 + 26 + <div> 27 + <label class="block text-neutral-400 mb-1">Boards</label> 28 + <p class="text-neutral-500 text-xs mb-2">One board per row: slug, name, description</p> 29 + <div id="board-rows" class="space-y-2"> 30 + {% for board in bbs.site.boards %} 31 + <div class="flex gap-2"> 32 + <input type="text" name="board_slug" value="{{ board.slug }}" placeholder="slug" class="w-1/4 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 33 + <input type="text" name="board_name" value="{{ board.name }}" placeholder="Name" class="w-1/3 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 34 + <input type="text" name="board_desc" value="{{ board.description }}" placeholder="Description" class="flex-1 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 35 + </div> 36 + {% endfor %} 37 + </div> 38 + <button type="button" id="add-board" class="mt-2 text-neutral-500 hover:text-neutral-300 text-xs">+ add board</button> 39 + </div> 40 + 41 + <button type="submit" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 42 + save changes 43 + </button> 44 + </form> 45 + 46 + <script> 47 + document.getElementById("add-board").addEventListener("click", function() { 48 + const row = document.createElement("div"); 49 + row.className = "flex gap-2"; 50 + row.innerHTML = ` 51 + <input type="text" name="board_slug" placeholder="slug" class="w-1/4 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 52 + <input type="text" name="board_name" placeholder="Name" class="w-1/3 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 53 + <input type="text" name="board_desc" placeholder="Description" class="flex-1 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"> 54 + `; 55 + document.getElementById("board-rows").appendChild(row); 56 + }); 57 + </script> 58 + {% endblock %}
+125
web/templates/thread.html
··· 1 + {% extends "base.html" %} 2 + {% block title %}{{ thread.title }} — {{ bbs.site.name }}{% endblock %} 3 + {% block breadcrumb %} 4 + <span>/</span> 5 + <a href="/bbs/{{ handle }}" class="text-neutral-500 hover:text-neutral-300">{{ bbs.site.name }}</a> 6 + <span>/</span> 7 + <a href="/bbs/{{ handle }}/board/{{ board.slug }}" class="text-neutral-500 hover:text-neutral-300">{{ board.name }}</a> 8 + <span>/</span> 9 + <span class="text-neutral-400 truncate">{{ thread.title }}</span> 10 + {% endblock %} 11 + {% block content %} 12 + <article class="bg-neutral-900 border border-neutral-800 rounded p-4 mb-4"> 13 + <div class="flex items-baseline justify-between mb-3"> 14 + <span class="text-neutral-200">{{ thread.author.handle }}</span> 15 + <div class="flex items-center gap-3"> 16 + <span class="text-xs text-neutral-500">{{ thread.created_at | datetime }}</span> 17 + {% if g.user and g.user.did == thread.author.did %} 18 + <form method="post" action="/bbs/{{ handle }}/thread/{{ thread.author.did }}/{{ thread.uri.split('/')[-1] }}/delete" class="inline" onsubmit="return confirm('Delete this thread?')"> 19 + <button type="submit" class="text-xs text-neutral-500 hover:text-red-400">delete</button> 20 + </form> 21 + {% endif %} 22 + </div> 23 + </div> 24 + <h1 class="text-base text-neutral-200 font-bold mb-3">{{ thread.title }}</h1> 25 + <p class="text-neutral-400 whitespace-pre-wrap leading-relaxed">{{ thread.body }}</p> 26 + </article> 27 + 28 + <div id="replies" class="space-y-2"> 29 + <p id="replies-loading" class="text-neutral-500">Loading replies...</p> 30 + </div> 31 + <div id="replies-next" class="mt-6 text-center hidden"> 32 + <button id="load-more" class="text-neutral-500 hover:text-neutral-300">next page →</button> 33 + </div> 34 + 35 + {% if g.user %} 36 + <form method="post" action="/bbs/{{ handle }}/thread/{{ thread.author.did }}/{{ thread.uri.split('/')[-1] }}/reply" class="mt-6 border border-neutral-800 rounded p-4"> 37 + <textarea 38 + name="body" 39 + placeholder="Write a reply..." 40 + required 41 + rows="3" 42 + class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600 resize-y mb-3" 43 + ></textarea> 44 + <button type="submit" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded"> 45 + reply 46 + </button> 47 + </form> 48 + {% endif %} 49 + 50 + <script> 51 + const threadDid = "{{ thread.author.did }}"; 52 + const threadTid = "{{ thread.uri.split('/')[-1] }}"; 53 + const handle = "{{ handle }}"; 54 + const userDid = "{{ g.user.did if g.user else '' }}"; 55 + let nextCursor = null; 56 + 57 + function formatDate(iso) { 58 + const d = new Date(iso); 59 + const pad = n => String(n).padStart(2, '0'); 60 + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`; 61 + } 62 + 63 + function escapeHtml(s) { 64 + const el = document.createElement('span'); 65 + el.textContent = s; 66 + return el.innerHTML; 67 + } 68 + 69 + function renderReply(r) { 70 + let deleteBtn = ''; 71 + if (userDid && userDid === r.did) { 72 + deleteBtn = `<form method="post" action="/bbs/${handle}/thread/${threadDid}/${threadTid}/reply/${r.rkey}/delete" class="inline" onsubmit="return confirm('Delete this reply?')"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">delete</button></form>`; 73 + } 74 + return `<div class="border border-neutral-800/50 rounded p-4"> 75 + <div class="flex items-baseline justify-between mb-2"> 76 + <span class="text-neutral-300">${escapeHtml(r.handle)}</span> 77 + <div class="flex items-center gap-3"> 78 + <span class="text-xs text-neutral-500">${formatDate(r.created_at)}</span> 79 + ${deleteBtn} 80 + </div> 81 + </div> 82 + <p class="text-neutral-400 whitespace-pre-wrap leading-relaxed">${escapeHtml(r.body)}</p> 83 + </div>`; 84 + } 85 + 86 + function loadReplies(cursor) { 87 + let url = `/api/replies/${threadDid}/${threadTid}?handle=${encodeURIComponent(handle)}`; 88 + if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; 89 + 90 + fetch(url) 91 + .then(r => r.json()) 92 + .then(data => { 93 + const container = document.getElementById('replies'); 94 + const loading = document.getElementById('replies-loading'); 95 + if (loading) loading.remove(); 96 + 97 + data.replies.forEach(r => { 98 + container.insertAdjacentHTML('beforeend', renderReply(r)); 99 + }); 100 + 101 + if (data.cursor) { 102 + nextCursor = data.cursor; 103 + document.getElementById('replies-next').classList.remove('hidden'); 104 + } else { 105 + nextCursor = null; 106 + document.getElementById('replies-next').classList.add('hidden'); 107 + } 108 + 109 + if (container.children.length === 0 && !userDid) { 110 + container.innerHTML = '<p class="text-neutral-500">No replies yet.</p>'; 111 + } 112 + }) 113 + .catch(() => { 114 + const loading = document.getElementById('replies-loading'); 115 + if (loading) loading.textContent = 'Failed to load replies.'; 116 + }); 117 + } 118 + 119 + document.getElementById('load-more').addEventListener('click', () => { 120 + if (nextCursor) loadReplies(nextCursor); 121 + }); 122 + 123 + loadReplies(null); 124 + </script> 125 + {% endblock %}