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.

add require_session helper to handle bans and attempted unauthenticated actions

+430 -206
+9 -3
core/auth/oauth.py
··· 117 117 ).decode("utf-8") 118 118 119 119 120 - def pds_dpop_jwt(method: str, url: str, nonce: str, access_token: str, dpop_private_jwk) -> str: 120 + def pds_dpop_jwt( 121 + method: str, url: str, nonce: str, access_token: str, dpop_private_jwk 122 + ) -> str: 121 123 """Create a DPoP proof JWT for PDS requests (includes ath claim).""" 122 124 dpop_pub_jwk = json.loads(dpop_private_jwk.as_json(is_private=False)) 123 125 body = { ··· 253 255 authserver_url = auth_request["authserver_iss"] 254 256 authserver_meta = await fetch_authserver_meta(client, authserver_url) 255 257 token_url = authserver_meta["token_endpoint"] 256 - dpop_private_jwk = JsonWebKey.import_key(json.loads(auth_request["dpop_private_jwk"])) 258 + dpop_private_jwk = JsonWebKey.import_key( 259 + json.loads(auth_request["dpop_private_jwk"]) 260 + ) 257 261 258 262 dpop_nonce, resp = await auth_server_post( 259 263 client=client, ··· 355 359 access_token = session["access_token"] 356 360 357 361 for _ in range(2): 358 - dpop_proof = pds_dpop_jwt(method, url, dpop_nonce, access_token, dpop_private_jwk) 362 + dpop_proof = pds_dpop_jwt( 363 + method, url, dpop_nonce, access_token, dpop_private_jwk 364 + ) 359 365 headers = { 360 366 "Authorization": f"DPoP {access_token}", 361 367 "DPoP": dpop_proof,
+12 -8
core/auth/session.py
··· 94 94 95 95 def get_session(self, did: str) -> dict | None: 96 96 con = self._connect() 97 - row = con.execute( 98 - "SELECT * FROM oauth_session WHERE did = ?", [did] 99 - ).fetchone() 97 + row = con.execute("SELECT * FROM oauth_session WHERE did = ?", [did]).fetchone() 100 98 con.close() 101 99 return dict(row) if row else None 102 100 103 - ALLOWED_FIELDS = {"dpop_pds_nonce", "dpop_authserver_nonce", "access_token", "refresh_token", "client_id"} 101 + ALLOWED_FIELDS = { 102 + "dpop_pds_nonce", 103 + "dpop_authserver_nonce", 104 + "access_token", 105 + "refresh_token", 106 + "client_id", 107 + } 104 108 105 109 def update_session_field(self, did: str, field: str, value: str): 106 110 if field not in self.ALLOWED_FIELDS: 107 111 raise ValueError(f"Invalid field: {field}") 108 112 con = self._connect() 109 - con.execute( 110 - f"UPDATE oauth_session SET {field} = ? WHERE did = ?", [value, did] 111 - ) 113 + con.execute(f"UPDATE oauth_session SET {field} = ? WHERE did = ?", [value, did]) 112 114 con.commit() 113 115 con.close() 114 116 115 - def update_session_tokens(self, did: str, access_token: str, refresh_token: str, dpop_nonce: str): 117 + def update_session_tokens( 118 + self, did: str, access_token: str, refresh_token: str, dpop_nonce: str 119 + ): 116 120 con = self._connect() 117 121 con.execute( 118 122 """UPDATE oauth_session
+15 -6
core/constellation.py
··· 44 44 ) -> BacklinksResponse: 45 45 """Get threads pointing at a board.""" 46 46 return await get_backlinks( 47 - client, subject=board_uri, source="xyz.atboards.thread:board", 48 - limit=limit, cursor=cursor, 47 + client, 48 + subject=board_uri, 49 + source="xyz.atboards.thread:board", 50 + limit=limit, 51 + cursor=cursor, 49 52 ) 50 53 51 54 ··· 57 60 ) -> BacklinksResponse: 58 61 """Get news posts pointing at a site.""" 59 62 return await get_backlinks( 60 - client, subject=site_uri, source="xyz.atboards.news:site", 61 - limit=limit, cursor=cursor, 63 + client, 64 + subject=site_uri, 65 + source="xyz.atboards.news:site", 66 + limit=limit, 67 + cursor=cursor, 62 68 ) 63 69 64 70 ··· 70 76 ) -> BacklinksResponse: 71 77 """Get replies pointing at a thread.""" 72 78 return await get_backlinks( 73 - client, subject=thread_uri, source="xyz.atboards.reply:subject", 74 - limit=limit, cursor=cursor, 79 + client, 80 + subject=thread_uri, 81 + source="xyz.atboards.reply:subject", 82 + limit=limit, 83 + cursor=cursor, 75 84 )
+5 -2
core/filters.py
··· 1 1 from core.models import Record 2 2 3 3 4 - def filter_moderated(records: list[Record], banned_dids: set[str], hidden_posts: set[str]) -> list[Record]: 4 + def filter_moderated( 5 + records: list[Record], banned_dids: set[str], hidden_posts: set[str] 6 + ) -> list[Record]: 5 7 """Remove records from banned users or hidden by the sysop.""" 6 8 return [ 7 - r for r in records 9 + r 10 + for r in records 8 11 if r.uri.split("/")[2] not in banned_dids and r.uri not in hidden_posts 9 12 ]
+9 -1
core/resolver.py
··· 2 2 3 3 import httpx 4 4 5 - from core.models import BBS, Board, News, Site, BBSNotFoundError, NoBBSError, NetworkError 5 + from core.models import ( 6 + BBS, 7 + Board, 8 + News, 9 + Site, 10 + BBSNotFoundError, 11 + NoBBSError, 12 + NetworkError, 13 + ) 6 14 from core.constellation import get_news 7 15 from core.slingshot import get_record, get_records_batch, resolve_identity 8 16
+2 -6
core/slingshot.py
··· 53 53 """Resolve multiple DIDs concurrently, skipping failures.""" 54 54 tasks = [resolve_identity(client, did) for did in dids] 55 55 results = await asyncio.gather(*tasks, return_exceptions=True) 56 - return { 57 - r.did: r for r in results if isinstance(r, MiniDoc) 58 - } 56 + return {r.did: r for r in results if isinstance(r, MiniDoc)} 59 57 60 58 61 59 async def get_records_batch( 62 60 client: httpx.AsyncClient, refs: list[BacklinkRef] 63 61 ) -> list[Record]: 64 62 """Fetch multiple records concurrently, skipping failures.""" 65 - tasks = [ 66 - get_record(client, ref.did, ref.collection, ref.rkey) for ref in refs 67 - ] 63 + tasks = [get_record(client, ref.did, ref.collection, ref.rkey) for ref in refs] 68 64 results = await asyncio.gather(*tasks, return_exceptions=True) 69 65 return [r for r in results if isinstance(r, Record)]
+3
tui/app.py
··· 80 80 def _restore_session(self) -> None: 81 81 """Load the most recent session from the database.""" 82 82 import sqlite3 83 + 83 84 try: 84 85 con = sqlite3.connect(self.session_store.db_path) 85 86 con.row_factory = sqlite3.Row ··· 96 97 self.push_screen(LogoutConfirmScreen()) 97 98 else: 98 99 from tui.screens.login import LoginScreen 100 + 99 101 self.push_screen(LoginScreen()) 100 102 101 103 def do_logout(self) -> None: ··· 112 114 self.notify("Log in to see your inbox.", severity="warning") 113 115 return 114 116 from tui.screens.activity import ActivityScreen 117 + 115 118 self.push_screen(ActivityScreen()) 116 119 117 120 def watch_screen(self) -> None:
+5 -1
tui/fetchers.py
··· 1 1 """TUI data fetching — thin wrappers around core.records.""" 2 2 3 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 4 + from core.records import ( 5 + delete_record, 6 + hydrate_replies as fetch_replies, 7 + hydrate_threads as fetch_threads, 8 + )
+19 -4
tui/screens/activity.py
··· 19 19 20 20 def compose(self) -> ComposeResult: 21 21 from tui.widgets.breadcrumb import Breadcrumb 22 + 22 23 yield Breadcrumb( 23 24 ("@bbs", 1), 24 25 ("inbox", 0), 25 26 ) 26 27 with VerticalScroll(id="activity-scroll"): 27 28 yield Static("Inbox", classes="title") 28 - yield Static("Replies to your threads and quotes of your replies.", classes="subtitle") 29 + yield Static( 30 + "Replies to your threads and quotes of your replies.", 31 + classes="subtitle", 32 + ) 29 33 yield Static("Loading...", id="activity-loading") 30 34 yield Footer() 31 35 ··· 39 43 self.query_one("#activity-loading").remove() 40 44 except Exception: 41 45 pass 42 - self.query_one("#activity-scroll").mount(Static("Loading...", id="activity-loading")) 46 + self.query_one("#activity-scroll").mount( 47 + Static("Loading...", id="activity-loading") 48 + ) 43 49 self.load_inbox() 44 50 45 51 def action_open_thread(self) -> None: ··· 69 75 client = self.app.http_client 70 76 try: 71 77 bbs = await resolve_bbs(client, handle) 72 - rec = await get_record(client, thread_did, "xyz.atboards.thread", thread_tid) 78 + rec = await get_record( 79 + client, thread_did, "xyz.atboards.thread", thread_tid 80 + ) 73 81 author = await resolve_identity(client, thread_did) 74 82 thread = Thread( 75 83 uri=rec.uri, ··· 82 90 attachments=rec.value.get("attachments"), 83 91 ) 84 92 from tui.screens.thread import ThreadScreen 93 + 85 94 self.app.push_screen(ThreadScreen(bbs, handle, thread)) 86 95 except Exception: 87 96 self.notify("Could not open thread.", severity="error") ··· 97 106 return 98 107 99 108 from core.records import fetch_inbox 109 + 100 110 client = self.app.http_client 101 111 102 112 try: ··· 120 130 if a["type"] == "reply": 121 131 title = f"on: {title}" 122 132 await scroll.mount( 123 - Post(author=a["handle"], date=a["created_at"], title=title, body=a["body"]) 133 + Post( 134 + author=a["handle"], 135 + date=a["created_at"], 136 + title=title, 137 + body=a["body"], 138 + ) 124 139 ) 125 140 126 141 # Focus first post
+16 -9
tui/screens/board.py
··· 1 + from textual import work 1 2 from textual.app import ComposeResult 2 3 from textual.containers import VerticalScroll 3 4 from textual.screen import Screen 4 5 from textual.widgets import Button, Footer, ListItem, ListView, Static 5 - from textual import work 6 6 7 7 from core.models import BBS, Board 8 8 from tui.fetchers import fetch_threads 9 + from tui.util import require_session 9 10 from tui.util import format_datetime 10 11 11 12 ··· 32 33 33 34 def compose(self) -> ComposeResult: 34 35 from tui.widgets.breadcrumb import Breadcrumb 36 + 35 37 yield Breadcrumb( 36 38 ("@bbs", 2), 37 39 (self.bbs.site.name, 1), ··· 39 41 ) 40 42 with VerticalScroll(): 41 43 yield Static("") 42 - yield Static(f"{self.board.name} — {self.board.description}", classes="subtitle") 44 + yield Static( 45 + f"{self.board.name} — {self.board.description}", classes="subtitle" 46 + ) 43 47 yield ListView(id="thread-list") 44 48 yield Footer() 45 49 ··· 53 57 cursor = self.cursor_history[self.page] 54 58 try: 55 59 self.threads, next_cursor = await fetch_threads( 56 - client, self.bbs, self.board, cursor=cursor, 60 + client, 61 + self.bbs, 62 + self.board, 63 + cursor=cursor, 57 64 ) 58 65 except Exception: 59 66 self.notify("Failed to load threads.", severity="error") ··· 62 69 lv = self.query_one("#thread-list", ListView) 63 70 lv.clear() 64 71 for t in self.threads: 65 - label = f" {t.title} — {t.author.handle} · {format_datetime(t.created_at)}" 72 + label = ( 73 + f" {t.title} — {t.author.handle} · {format_datetime(t.created_at)}" 74 + ) 66 75 await lv.append(ListItem(Static(label), name=t.uri)) 67 76 68 77 if self.threads: ··· 86 95 thread = next((t for t in self.threads if t.uri == uri), None) 87 96 if thread: 88 97 from tui.screens.thread import ThreadScreen 98 + 89 99 self.app.push_screen(ThreadScreen(self.bbs, self.handle, thread)) 90 100 91 101 def refresh_data(self) -> None: ··· 99 109 self.load_threads() 100 110 101 111 def action_new_thread(self) -> None: 102 - session = self.app.user_session 103 - if not session: 104 - return 105 - if session["did"] in self.bbs.site.banned_dids: 106 - self.notify("You have been banned from this BBS.", severity="error") 112 + if not require_session(self): 107 113 return 108 114 from tui.screens.compose import ComposeThreadScreen 115 + 109 116 self.app.push_screen(ComposeThreadScreen(self.bbs, self.handle, self.board))
+27 -16
tui/screens/compose.py
··· 8 8 from pathlib import Path 9 9 10 10 from core.records import create_thread_record, create_reply_record, upload_blob 11 + from tui.util import require_session 11 12 12 13 13 14 async def _upload_file(screen, file_path: str, session: dict) -> list[dict] | None: ··· 21 22 return None 22 23 data = p.read_bytes() 23 24 mime = mimetypes.guess_type(str(p))[0] or "application/octet-stream" 25 + 24 26 async def _update_nonce(did, field, value): 25 - if hasattr(screen.app, 'user_session') and screen.app.user_session: 27 + if hasattr(screen.app, "user_session") and screen.app.user_session: 26 28 screen.app.user_session[field] = value 27 29 28 30 try: 29 - blob_ref = await upload_blob(screen.app.http_client, session, data, mime, session_updater=_update_nonce) 31 + blob_ref = await upload_blob( 32 + screen.app.http_client, session, data, mime, session_updater=_update_nonce 33 + ) 30 34 return [{"file": blob_ref, "name": p.name}] 31 35 except Exception as e: 32 36 screen.notify(f"Failed to upload file: {e}", severity="error") ··· 44 48 45 49 def compose(self) -> ComposeResult: 46 50 from tui.widgets.breadcrumb import Breadcrumb 51 + 47 52 yield Breadcrumb( 48 53 ("@bbs", 3), 49 54 (self.bbs.site.name, 2), ··· 66 71 67 72 @work(exclusive=True) 68 73 async def post_thread(self) -> None: 69 - session = self.app.user_session 74 + session = require_session(self) 70 75 if not session: 71 - self.notify("Not logged in.", severity="error") 72 - return 73 - if session["did"] in self.bbs.site.banned_dids: 74 - self.notify("You have been banned from this BBS.", severity="error") 75 76 return 76 77 77 78 title = self.query_one("#thread-title", Input).value.strip() ··· 92 93 93 94 try: 94 95 resp = await create_thread_record( 95 - self.app.http_client, session, board_uri, title, body, 96 + self.app.http_client, 97 + session, 98 + board_uri, 99 + title, 100 + body, 96 101 attachments=attachments or None, 97 102 ) 98 103 resp.raise_for_status() ··· 115 120 116 121 def compose(self) -> ComposeResult: 117 122 from tui.widgets.breadcrumb import Breadcrumb 123 + 118 124 yield Breadcrumb( 119 125 ("@bbs", 3), 120 126 (self.bbs.site.name, 2), ··· 124 130 with Vertical(): 125 131 yield Static(f"reply to: {self.thread.title}", classes="title") 126 132 if self.quote: 127 - body_preview = self.quote.body[:60] + ("..." if len(self.quote.body) > 60 else "") 128 - yield Static(f"quoting {self.quote.author.handle}: {body_preview} [clear: ctrl+g]", classes="subtitle", id="quote-info") 133 + body_preview = self.quote.body[:60] + ( 134 + "..." if len(self.quote.body) > 60 else "" 135 + ) 136 + yield Static( 137 + f"quoting {self.quote.author.handle}: {body_preview} [clear: ctrl+g]", 138 + classes="subtitle", 139 + id="quote-info", 140 + ) 129 141 yield TextArea(id="reply-body", language=None) 130 142 yield Input(placeholder="attach file (path, optional)", id="reply-file") 131 143 yield Static("ctrl+s to post", classes="subtitle") ··· 148 160 149 161 @work(exclusive=True) 150 162 async def post_reply(self) -> None: 151 - session = self.app.user_session 163 + session = require_session(self) 152 164 if not session: 153 - self.notify("Not logged in.", severity="error") 154 - return 155 - if session["did"] in self.bbs.site.banned_dids: 156 - self.notify("You have been banned from this BBS.", severity="error") 157 165 return 158 166 159 167 body = self.query_one("#reply-body", TextArea).text.strip() ··· 171 179 172 180 try: 173 181 resp = await create_reply_record( 174 - self.app.http_client, session, self.thread.uri, body, 182 + self.app.http_client, 183 + session, 184 + self.thread.uri, 185 + body, 175 186 attachments=attachments or None, 176 187 quote=self.quote.uri if self.quote else None, 177 188 )
+11 -6
tui/screens/home.py
··· 24 24 def compose(self) -> ComposeResult: 25 25 with Vertical(id="home-container"): 26 26 yield Static("\n [#d97706]@[/]bbs\n", classes="title", id="hero-title") 27 - yield Static("Bulletin boards on the Atmosphere.", classes="subtitle", id="hero-sub1") 28 - yield Static("Run a BBS from your own account. No server required. Users own their posts, communities migrate freely.", classes="subtitle", id="hero-sub2") 27 + yield Static( 28 + "Bulletin boards on the Atmosphere.", classes="subtitle", id="hero-sub1" 29 + ) 30 + yield Static( 31 + "Run a BBS from your own account. No server required. Users own their posts, communities migrate freely.", 32 + classes="subtitle", 33 + id="hero-sub2", 34 + ) 29 35 yield Static("") 30 36 yield Static("Connect to a BBS", classes="title") 31 37 yield Input(placeholder="handle.example.com", id="handle-input") 32 - yield Static( 33 - "OR FIND ONE", id="discover-label", classes="section-label" 34 - ) 38 + yield Static("OR FIND ONE", id="discover-label", classes="section-label") 35 39 yield ListView(id="discover-list") 36 40 yield Footer() 37 41 ··· 79 83 return 80 84 81 85 from tui.screens.site import SiteScreen 86 + 82 87 self.app.push_screen(SiteScreen(bbs, handle)) 83 88 self.query_one("#handle-input", Input).value = "" 84 89 ··· 117 122 118 123 self.query_one("#discover-label").display = True 119 124 lv.display = True 120 - lv.index = 0 # select first bbs 125 + lv.index = 0 # select first bbs 121 126 122 127 except Exception: 123 128 pass
+6 -1
tui/screens/login.py
··· 30 30 31 31 def compose(self) -> ComposeResult: 32 32 from tui.widgets.breadcrumb import Breadcrumb 33 + 33 34 yield Breadcrumb( 34 35 ("@bbs", 1), 35 36 ("log in", 0), 36 37 ) 37 38 with Vertical(): 38 39 yield Static("log in", classes="title") 39 - yield Static("Sign in with your atproto handle. A browser window will open.", classes="subtitle") 40 + yield Static( 41 + "Sign in with your atproto handle. A browser window will open.", 42 + classes="subtitle", 43 + ) 40 44 yield Input(placeholder="your-handle.bsky.social", id="login-handle") 41 45 yield Footer() 42 46 ··· 67 71 68 72 # Load client secrets from TUI data dir 69 73 from tui.app import DATA_DIR 74 + 70 75 secrets = load_secrets(DATA_DIR) 71 76 client_secret_jwk = json.loads(secrets["client_secret_jwk"]) 72 77
+1
tui/screens/news.py
··· 18 18 19 19 def compose(self) -> ComposeResult: 20 20 from tui.widgets.breadcrumb import Breadcrumb 21 + 21 22 yield Breadcrumb( 22 23 ("@bbs", 2), 23 24 (self.bbs.site.name, 1),
+9 -2
tui/screens/site.py
··· 19 19 20 20 def compose(self) -> ComposeResult: 21 21 from tui.widgets.breadcrumb import Breadcrumb 22 + 22 23 yield Breadcrumb( 23 24 ("@bbs", 1), 24 25 (self.handle, 0), ··· 41 42 yield ListView( 42 43 *[ 43 44 ListItem( 44 - Static(f" {item.title} — {format_datetime(item.created_at)}"), 45 + Static( 46 + f" {item.title} — {format_datetime(item.created_at)}" 47 + ), 45 48 name=str(i), 46 49 ) 47 50 for i, item in enumerate(self.bbs.news) ··· 73 76 board = next((b for b in self.bbs.site.boards if b.slug == slug), None) 74 77 if board: 75 78 from tui.screens.board import BoardScreen 79 + 76 80 self.app.push_screen(BoardScreen(self.bbs, self.handle, board)) 77 81 elif event.list_view.id == "news-list": 78 82 idx = int(event.item.name) 79 83 if 0 <= idx < len(self.bbs.news): 80 84 from tui.screens.news import NewsScreen 81 - self.app.push_screen(NewsScreen(self.bbs, self.handle, self.bbs.news[idx])) 85 + 86 + self.app.push_screen( 87 + NewsScreen(self.bbs, self.handle, self.bbs.news[idx]) 88 + )
+28 -14
tui/screens/thread.py
··· 1 + from textual import work 1 2 from textual.app import ComposeResult 2 3 from textual.binding import Binding 3 4 from textual.containers import VerticalScroll 4 5 from textual.screen import Screen 5 6 from textual.widgets import Button, Footer 6 - from textual import work 7 7 8 8 from core.models import BBS, Thread 9 9 from tui.fetchers import delete_record, fetch_replies 10 + from tui.util import require_session 10 11 from tui.widgets.post import Post 11 12 12 13 ··· 28 29 29 30 def compose(self) -> ComposeResult: 30 31 board_slug = self.thread.board_uri.split("/")[-1] 31 - board_name = next((b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug) 32 + board_name = next( 33 + (b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug 34 + ) 32 35 from tui.widgets.breadcrumb import Breadcrumb 36 + 33 37 yield Breadcrumb( 34 38 ("@bbs", 3), 35 39 (self.bbs.site.name, 2), ··· 62 66 client = self.app.http_client 63 67 try: 64 68 replies, self.next_cursor = await fetch_replies( 65 - client, self.bbs, self.thread, cursor=cursor, 69 + client, 70 + self.bbs, 71 + self.thread, 72 + cursor=cursor, 66 73 ) 67 74 except Exception: 68 75 self.notify("Failed to load replies.", severity="error") ··· 96 103 ) 97 104 98 105 if self.next_cursor: 99 - await scroll.mount( 100 - Button("next page →", id="next-page") 101 - ) 106 + await scroll.mount(Button("next page →", id="next-page")) 102 107 103 108 def refresh_data(self) -> None: 104 109 self._do_refresh() ··· 116 121 client = self.app.http_client 117 122 try: 118 123 replies, self.next_cursor = await fetch_replies( 119 - client, self.bbs, self.thread, 124 + client, 125 + self.bbs, 126 + self.thread, 120 127 ) 121 128 except Exception: 122 129 self.notify("Failed to load replies.", severity="error") ··· 157 164 self.load_replies(cursor=self.next_cursor) 158 165 159 166 def action_reply(self) -> None: 160 - session = self.app.user_session 167 + session = require_session(self) 161 168 if not session: 162 - return 163 - if session["did"] in self.bbs.site.banned_dids: 164 - self.notify("You have been banned from this BBS.", severity="error") 165 169 return 166 170 167 171 # If focused on a reply, quote it 168 172 quote = None 169 173 focused = self.focused 170 - if isinstance(focused, Post) and focused.collection == "xyz.atboards.reply" and focused.record_uri: 174 + if ( 175 + isinstance(focused, Post) 176 + and focused.collection == "xyz.atboards.reply" 177 + and focused.record_uri 178 + ): 171 179 quote = self._replies_map.get(focused.record_uri) 172 180 173 181 from tui.screens.compose import ComposeReplyScreen 174 - self.app.push_screen(ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote)) 182 + 183 + self.app.push_screen( 184 + ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote) 185 + ) 175 186 176 187 def action_delete(self) -> None: 177 188 session = self.app.user_session ··· 192 203 session = self.app.user_session 193 204 try: 194 205 await delete_record( 195 - self.app.http_client, session, post.collection, post.rkey, 206 + self.app.http_client, 207 + session, 208 + post.collection, 209 + post.rkey, 196 210 ) 197 211 except Exception: 198 212 self.notify("Failed to delete.", severity="error")
+12
tui/util.py
··· 1 1 """TUI utilities — delegates to core.""" 2 2 3 3 from core.util import format_datetime_local as format_datetime 4 + 5 + 6 + def require_session(screen) -> dict | None: 7 + """Return the user session if logged in and not banned, else notify and return None.""" 8 + session = screen.app.user_session 9 + if not session: 10 + screen.notify("You are not logged in.", severity="error") 11 + return None 12 + if session["did"] in screen.bbs.site.banned_dids: 13 + screen.notify("You have been banned from this BBS.", severity="error") 14 + return None 15 + return session
+3 -1
tui/widgets/breadcrumb.py
··· 74 74 # Show logged-in user on the right 75 75 session = getattr(self.app, "user_session", None) 76 76 if session: 77 - yield Static(f" {session['handle']} ", classes="breadcrumb-user", markup=False) 77 + yield Static( 78 + f" {session['handle']} ", classes="breadcrumb-user", markup=False 79 + ) 78 80 79 81 for i, (label, pop_count) in enumerate(self._segments): 80 82 if i > 0:
+1
web/app.py
··· 39 39 @app.before_request 40 40 async def load_user(): 41 41 from quart import g, session 42 + 42 43 did = session.get("did") 43 44 if did: 44 45 g.user = app.session_store.get_session(did)
+25 -10
web/routes.py
··· 4 4 from quart import Blueprint, current_app, render_template, request 5 5 6 6 from core.models import ( 7 - BBSNotFoundError, NetworkError, NoBBSError, Thread, 7 + BBSNotFoundError, 8 + NetworkError, 9 + NoBBSError, 10 + Thread, 8 11 ) 9 12 from core.records import hydrate_replies, hydrate_threads 10 13 from core.resolver import resolve_bbs ··· 20 23 async def check_banned(bbs): 21 24 """Return an error response if the current user is banned, or None.""" 22 25 from quart import g 26 + 23 27 if g.user and g.user.get("did") in bbs.site.banned_dids: 24 - return await render_template("error.html", message="You have been banned from this BBS."), 403 28 + return await render_template( 29 + "error.html", message="You have been banned from this BBS." 30 + ), 403 25 31 return None 26 32 27 33 ··· 38 44 @bp.route("/api/inbox") 39 45 async def api_inbox(): 40 46 from quart import g 47 + 41 48 if not g.user: 42 49 return {"inbox": [], "cursor": None} 43 50 44 51 from core.records import fetch_inbox 52 + 45 53 client = current_app.http_client 46 54 cursor = request.args.get("cursor") 47 55 offset = int(cursor) if cursor else 0 48 56 limit = 20 49 57 50 58 all_items = await fetch_inbox(client, g.user["did"], g.user["pds_url"]) 51 - page = all_items[offset:offset + limit] 59 + page = all_items[offset : offset + limit] 52 60 next_cursor = str(offset + limit) if offset + limit < len(all_items) else None 53 61 54 62 return {"inbox": page, "cursor": next_cursor} ··· 82 90 for r in raw: 83 91 did = r["did"] 84 92 if did in authors: 85 - bbses.append({ 86 - "handle": authors[did].handle, 87 - "name": r["record"].get("name", ""), 88 - "description": r["record"].get("description", ""), 89 - }) 93 + bbses.append( 94 + { 95 + "handle": authors[did].handle, 96 + "name": r["record"].get("name", ""), 97 + "description": r["record"].get("description", ""), 98 + } 99 + ) 90 100 except Exception: 91 101 pass 92 102 return {"bbses": bbses} ··· 158 168 return {"threads": [], "cursor": None} 159 169 160 170 try: 161 - threads, next_cursor = await hydrate_threads(client, bbs, current_board, cursor=cursor) 171 + threads, next_cursor = await hydrate_threads( 172 + client, bbs, current_board, cursor=cursor 173 + ) 162 174 except Exception: 163 175 return {"threads": [], "cursor": None} 164 176 ··· 251 263 # Build a minimal Thread object for hydrate_replies 252 264 thread_uri = f"at://{did}/xyz.atboards.thread/{tid}" 253 265 from core.models import MiniDoc 266 + 254 267 dummy_thread = Thread( 255 268 uri=thread_uri, 256 269 board_uri="", ··· 261 274 ) 262 275 263 276 try: 264 - replies, next_cursor = await hydrate_replies(client, bbs, dummy_thread, cursor=cursor) 277 + replies, next_cursor = await hydrate_replies( 278 + client, bbs, dummy_thread, cursor=cursor 279 + ) 265 280 except Exception: 266 281 return {"replies": [], "cursor": None} 267 282
+1 -3
web/routes_auth.py
··· 75 75 return { 76 76 "keys": [ 77 77 json.loads( 78 - JsonWebKey.import_key( 79 - _client_secret_jwk() 80 - ).as_json(is_private=False) 78 + JsonWebKey.import_key(_client_secret_jwk()).as_json(is_private=False) 81 79 ) 82 80 ] 83 81 }
+157 -93
web/routes_sysop.py
··· 14 14 client = current_app.http_client 15 15 try: 16 16 from core.slingshot import get_record 17 + 17 18 await get_record(client, user["did"], "xyz.atboards.site", "self") 18 19 return True 19 20 except Exception: ··· 31 32 if has_bbs: 32 33 try: 33 34 from core.slingshot import get_record 34 - record = await get_record(current_app.http_client, user["did"], "xyz.atboards.site", "self") 35 + 36 + record = await get_record( 37 + current_app.http_client, user["did"], "xyz.atboards.site", "self" 38 + ) 35 39 bbs_name = record.value.get("name", user["handle"]) 36 40 except Exception: 37 41 bbs_name = user["handle"] 38 - return await render_template("account.html", user=user, has_bbs=has_bbs, bbs_name=bbs_name) 42 + return await render_template( 43 + "account.html", user=user, has_bbs=has_bbs, bbs_name=bbs_name 44 + ) 39 45 40 46 41 47 @bp.route("/account/delete", methods=["POST"]) ··· 48 54 49 55 # Fetch site record to get board slugs and news 50 56 from core.slingshot import get_record 57 + 51 58 try: 52 59 existing = await get_record(client, user["did"], "xyz.atboards.site", "self") 53 60 board_slugs = existing.value.get("boards", []) ··· 63 70 64 71 # Delete news records (via Constellation backlinks) 65 72 from core.constellation import get_news 73 + 66 74 site_uri = f"at://{user['did']}/xyz.atboards.site/self" 67 75 try: 68 76 backlinks = await get_news(client, site_uri) ··· 99 107 board_descs = form.getlist("board_desc") 100 108 101 109 if not name or not board_slugs: 102 - return await render_template("sysop_create.html", error="Name and at least one board are required.") 110 + return await render_template( 111 + "sysop_create.html", error="Name and at least one board are required." 112 + ) 103 113 104 114 now = now_iso() 105 115 ··· 107 117 for i, slug in enumerate(board_slugs): 108 118 board_name = board_names[i] if i < len(board_names) else slug 109 119 board_desc = board_descs[i].strip() if i < len(board_descs) else "" 110 - await _authed_pds_post(user, "com.atproto.repo.putRecord", { 120 + await _authed_pds_post( 121 + user, 122 + "com.atproto.repo.putRecord", 123 + { 124 + "repo": user["did"], 125 + "collection": "xyz.atboards.board", 126 + "rkey": slug, 127 + "record": { 128 + "$type": "xyz.atboards.board", 129 + "name": board_name, 130 + "description": board_desc, 131 + "createdAt": now, 132 + }, 133 + }, 134 + ) 135 + 136 + # Create site record 137 + await _authed_pds_post( 138 + user, 139 + "com.atproto.repo.putRecord", 140 + { 111 141 "repo": user["did"], 112 - "collection": "xyz.atboards.board", 113 - "rkey": slug, 142 + "collection": "xyz.atboards.site", 143 + "rkey": "self", 114 144 "record": { 115 - "$type": "xyz.atboards.board", 116 - "name": board_name, 117 - "description": board_desc, 145 + "$type": "xyz.atboards.site", 146 + "name": name, 147 + "description": description, 148 + "intro": intro, 149 + "boards": board_slugs, 150 + "bannedDids": [], 151 + "hiddenPosts": [], 118 152 "createdAt": now, 119 153 }, 120 - }) 121 - 122 - # Create site record 123 - await _authed_pds_post(user, "com.atproto.repo.putRecord", { 124 - "repo": user["did"], 125 - "collection": "xyz.atboards.site", 126 - "rkey": "self", 127 - "record": { 128 - "$type": "xyz.atboards.site", 129 - "name": name, 130 - "description": description, 131 - "intro": intro, 132 - "boards": board_slugs, 133 - "bannedDids": [], 134 - "hiddenPosts": [], 135 - "createdAt": now, 136 154 }, 137 - }) 155 + ) 138 156 139 157 return redirect(f"/bbs/{user['handle']}") 140 158 ··· 150 168 if request.method == "GET": 151 169 try: 152 170 from core.resolver import resolve_bbs 171 + 153 172 bbs = await resolve_bbs(client, user["handle"]) 154 173 except Exception: 155 174 return redirect("/account/create") 156 175 157 176 from core.slingshot import resolve_identities_batch, get_record_by_uri 177 + 158 178 banned_handles = {} 159 179 if bbs.site.banned_dids: 160 180 authors = await resolve_identities_batch(client, list(bbs.site.banned_dids)) ··· 162 182 163 183 hidden_posts = [] 164 184 if bbs.site.hidden_posts: 165 - hidden_dids = list({uri.split("/")[2] for uri in bbs.site.hidden_posts if len(uri.split("/")) > 2}) 185 + hidden_dids = list( 186 + { 187 + uri.split("/")[2] 188 + for uri in bbs.site.hidden_posts 189 + if len(uri.split("/")) > 2 190 + } 191 + ) 166 192 hidden_authors = await resolve_identities_batch(client, hidden_dids) 167 193 168 194 for uri in bbs.site.hidden_posts: ··· 172 198 173 199 try: 174 200 record = await get_record_by_uri(client, uri) 175 - hidden_posts.append({ 176 - "uri": uri, 177 - "handle": handle, 178 - "title": record.value.get("title", ""), 179 - "body": record.value.get("body", "")[:100], 180 - }) 201 + hidden_posts.append( 202 + { 203 + "uri": uri, 204 + "handle": handle, 205 + "title": record.value.get("title", ""), 206 + "body": record.value.get("body", "")[:100], 207 + } 208 + ) 181 209 except Exception: 182 - hidden_posts.append({ 183 - "uri": uri, 184 - "handle": handle, 185 - "title": "", 186 - "body": parts[-1] if parts else uri, 187 - }) 210 + hidden_posts.append( 211 + { 212 + "uri": uri, 213 + "handle": handle, 214 + "title": "", 215 + "body": parts[-1] if parts else uri, 216 + } 217 + ) 188 218 189 - return await render_template("sysop_moderate.html", bbs=bbs, banned_handles=banned_handles, hidden_posts=hidden_posts) 219 + return await render_template( 220 + "sysop_moderate.html", 221 + bbs=bbs, 222 + banned_handles=banned_handles, 223 + hidden_posts=hidden_posts, 224 + ) 190 225 191 226 # POST — save moderation changes 192 227 form = await request.form ··· 194 229 hidden_uris = [u.strip() for u in form.getlist("hidden_uri") if u.strip()] 195 230 196 231 from core.slingshot import get_record 232 + 197 233 try: 198 234 existing = await get_record(client, user["did"], "xyz.atboards.site", "self") 199 235 site_value = existing.value ··· 204 240 site_value["hiddenPosts"] = hidden_uris 205 241 site_value["updatedAt"] = now_iso() 206 242 207 - await _authed_pds_post(user, "com.atproto.repo.putRecord", { 208 - "repo": user["did"], 209 - "collection": "xyz.atboards.site", 210 - "rkey": "self", 211 - "record": site_value, 212 - }) 243 + await _authed_pds_post( 244 + user, 245 + "com.atproto.repo.putRecord", 246 + { 247 + "repo": user["did"], 248 + "collection": "xyz.atboards.site", 249 + "rkey": "self", 250 + "record": site_value, 251 + }, 252 + ) 213 253 214 254 return redirect("/account/moderate") 215 255 ··· 225 265 if request.method == "GET": 226 266 try: 227 267 from core.resolver import resolve_bbs 268 + 228 269 bbs = await resolve_bbs(client, user["handle"]) 229 270 except Exception: 230 271 return redirect("/account/create") ··· 245 286 246 287 # Fetch existing site record to preserve createdAt 247 288 from core.slingshot import get_record 289 + 248 290 try: 249 291 existing = await get_record(client, user["did"], "xyz.atboards.site", "self") 250 292 created_at = existing.value.get("createdAt", now) ··· 259 301 for i, slug in enumerate(board_slugs): 260 302 board_name = board_names[i] if i < len(board_names) else slug 261 303 board_desc = board_descs[i].strip() if i < len(board_descs) else "" 262 - await _authed_pds_post(user, "com.atproto.repo.putRecord", { 304 + await _authed_pds_post( 305 + user, 306 + "com.atproto.repo.putRecord", 307 + { 308 + "repo": user["did"], 309 + "collection": "xyz.atboards.board", 310 + "rkey": slug, 311 + "record": { 312 + "$type": "xyz.atboards.board", 313 + "name": board_name, 314 + "description": board_desc, 315 + "createdAt": now, 316 + }, 317 + }, 318 + ) 319 + 320 + # Update site record 321 + await _authed_pds_post( 322 + user, 323 + "com.atproto.repo.putRecord", 324 + { 263 325 "repo": user["did"], 264 - "collection": "xyz.atboards.board", 265 - "rkey": slug, 326 + "collection": "xyz.atboards.site", 327 + "rkey": "self", 266 328 "record": { 267 - "$type": "xyz.atboards.board", 268 - "name": board_name, 269 - "description": board_desc, 270 - "createdAt": now, 329 + "$type": "xyz.atboards.site", 330 + "name": name, 331 + "description": description, 332 + "intro": intro, 333 + "boards": board_slugs, 334 + "bannedDids": existing_banned, 335 + "hiddenPosts": existing_hidden, 336 + "createdAt": created_at, 337 + "updatedAt": now, 271 338 }, 272 - }) 273 - 274 - # Update site record 275 - await _authed_pds_post(user, "com.atproto.repo.putRecord", { 276 - "repo": user["did"], 277 - "collection": "xyz.atboards.site", 278 - "rkey": "self", 279 - "record": { 280 - "$type": "xyz.atboards.site", 281 - "name": name, 282 - "description": description, 283 - "intro": intro, 284 - "boards": board_slugs, 285 - "bannedDids": existing_banned, 286 - "hiddenPosts": existing_hidden, 287 - "createdAt": created_at, 288 - "updatedAt": now, 289 339 }, 290 - }) 340 + ) 291 341 292 342 return redirect(f"/bbs/{user['handle']}") 293 343 ··· 307 357 site_uri = f"at://{user['did']}/xyz.atboards.site/self" 308 358 now = now_iso() 309 359 310 - await _authed_pds_post(user, "com.atproto.repo.createRecord", { 311 - "repo": user["did"], 312 - "collection": "xyz.atboards.news", 313 - "record": { 314 - "$type": "xyz.atboards.news", 315 - "site": site_uri, 316 - "title": title, 317 - "body": body, 318 - "createdAt": now, 360 + await _authed_pds_post( 361 + user, 362 + "com.atproto.repo.createRecord", 363 + { 364 + "repo": user["did"], 365 + "collection": "xyz.atboards.news", 366 + "record": { 367 + "$type": "xyz.atboards.news", 368 + "site": site_uri, 369 + "title": title, 370 + "body": body, 371 + "createdAt": now, 372 + }, 319 373 }, 320 - }) 374 + ) 321 375 322 376 return redirect(f"/bbs/{handle}") 323 377 ··· 343 397 344 398 # Fetch existing site record 345 399 from core.slingshot import get_record 400 + 346 401 try: 347 402 existing = await get_record(client, user["did"], "xyz.atboards.site", "self") 348 403 site_value = existing.value ··· 357 412 # Update site record 358 413 site_value["bannedDids"] = banned 359 414 site_value["updatedAt"] = now_iso() 360 - await _authed_pds_post(user, "com.atproto.repo.putRecord", { 361 - "repo": user["did"], 362 - "collection": "xyz.atboards.site", 363 - "rkey": "self", 364 - "record": site_value, 365 - }) 415 + await _authed_pds_post( 416 + user, 417 + "com.atproto.repo.putRecord", 418 + { 419 + "repo": user["did"], 420 + "collection": "xyz.atboards.site", 421 + "rkey": "self", 422 + "record": site_value, 423 + }, 424 + ) 366 425 367 426 return redirect(request.referrer or f"/bbs/{handle}") 368 427 ··· 381 440 client = current_app.http_client 382 441 383 442 from core.slingshot import get_record 443 + 384 444 try: 385 445 existing = await get_record(client, user["did"], "xyz.atboards.site", "self") 386 446 site_value = existing.value ··· 393 453 394 454 site_value["hiddenPosts"] = hidden 395 455 site_value["updatedAt"] = now_iso() 396 - await _authed_pds_post(user, "com.atproto.repo.putRecord", { 397 - "repo": user["did"], 398 - "collection": "xyz.atboards.site", 399 - "rkey": "self", 400 - "record": site_value, 401 - }) 456 + await _authed_pds_post( 457 + user, 458 + "com.atproto.repo.putRecord", 459 + { 460 + "repo": user["did"], 461 + "collection": "xyz.atboards.site", 462 + "rkey": "self", 463 + "record": site_value, 464 + }, 465 + ) 402 466 403 467 return redirect(request.referrer or f"/bbs/{handle}")
+54 -20
web/routes_write.py
··· 12 12 async def _authed_pds_post(user: dict, endpoint: str, body: dict): 13 13 """Make an authenticated POST to the user's PDS.""" 14 14 from core.records import _pds_post 15 - return await _pds_post(current_app.http_client, user, endpoint, body, session_updater) 15 + 16 + return await _pds_post( 17 + current_app.http_client, user, endpoint, body, session_updater 18 + ) 16 19 17 20 18 21 async def authed_delete_record(user: dict, collection: str, rkey: str): 19 22 """Delete a record from the user's repo.""" 20 - resp = await _authed_pds_post(user, "com.atproto.repo.deleteRecord", { 21 - "repo": user["did"], 22 - "collection": collection, 23 - "rkey": rkey, 24 - }) 23 + resp = await _authed_pds_post( 24 + user, 25 + "com.atproto.repo.deleteRecord", 26 + { 27 + "repo": user["did"], 28 + "collection": collection, 29 + "rkey": rkey, 30 + }, 31 + ) 25 32 resp.raise_for_status() 26 33 return resp 27 34 ··· 39 46 return redirect(f"/bbs/{handle}/board/{slug}") 40 47 41 48 from core.resolver import resolve_bbs 49 + 42 50 client = current_app.http_client 43 51 try: 44 52 bbs = await resolve_bbs(client, handle) ··· 57 65 if f.filename: 58 66 data = f.read() 59 67 try: 60 - blob_ref = await upload_blob(client, user, data, f.content_type or "application/octet-stream", session_updater) 68 + blob_ref = await upload_blob( 69 + client, 70 + user, 71 + data, 72 + f.content_type or "application/octet-stream", 73 + session_updater, 74 + ) 61 75 attachments.append({"file": blob_ref, "name": f.filename}) 62 76 except Exception: 63 - return await render_template("error.html", message=f"Failed to upload {f.filename}. The file may be too large."), 400 77 + return await render_template( 78 + "error.html", 79 + message=f"Failed to upload {f.filename}. The file may be too large.", 80 + ), 400 64 81 65 82 record = { 66 83 "$type": "xyz.atboards.thread", ··· 72 89 if attachments: 73 90 record["attachments"] = attachments 74 91 75 - resp = await _authed_pds_post(user, "com.atproto.repo.createRecord", { 76 - "repo": user["did"], 77 - "collection": "xyz.atboards.thread", 78 - "record": record, 79 - }) 92 + resp = await _authed_pds_post( 93 + user, 94 + "com.atproto.repo.createRecord", 95 + { 96 + "repo": user["did"], 97 + "collection": "xyz.atboards.thread", 98 + "record": record, 99 + }, 100 + ) 80 101 resp.raise_for_status() 81 102 82 103 return redirect(f"/bbs/{handle}/board/{slug}") ··· 104 125 if f.filename: 105 126 data = f.read() 106 127 try: 107 - blob_ref = await upload_blob(client, user, data, f.content_type or "application/octet-stream", session_updater) 128 + blob_ref = await upload_blob( 129 + client, 130 + user, 131 + data, 132 + f.content_type or "application/octet-stream", 133 + session_updater, 134 + ) 108 135 attachments.append({"file": blob_ref, "name": f.filename}) 109 136 except Exception: 110 - return await render_template("error.html", message=f"Failed to upload {f.filename}. The file may be too large."), 400 137 + return await render_template( 138 + "error.html", 139 + message=f"Failed to upload {f.filename}. The file may be too large.", 140 + ), 400 111 141 112 142 record = { 113 143 "$type": "xyz.atboards.reply", ··· 120 150 if quote: 121 151 record["quote"] = quote 122 152 123 - resp = await _authed_pds_post(user, "com.atproto.repo.createRecord", { 124 - "repo": user["did"], 125 - "collection": "xyz.atboards.reply", 126 - "record": record, 127 - }) 153 + resp = await _authed_pds_post( 154 + user, 155 + "com.atproto.repo.createRecord", 156 + { 157 + "repo": user["did"], 158 + "collection": "xyz.atboards.reply", 159 + "record": record, 160 + }, 161 + ) 128 162 resp.raise_for_status() 129 163 130 164 return redirect(f"/bbs/{handle}/thread/{did}/{tid}")