this repo has no description

Better oauth, appview groundwork

lewis 3806206d 8d46c1d5

+18 -3
.env.example
··· 48 # Optional: rotation key for PLC operations (defaults to user's key) 49 # PLC_ROTATION_KEY=did:key:... 50 # ============================================================================= 51 - # Federation 52 # ============================================================================= 53 - # Appview URL for proxying app.bsky.* requests 54 - # APPVIEW_URL=https://api.bsky.app 55 # Comma-separated list of relay URLs to notify via requestCrawl 56 # CRAWLERS=https://bsky.network,https://relay.upcloud.world 57 # =============================================================================
··· 48 # Optional: rotation key for PLC operations (defaults to user's key) 49 # PLC_ROTATION_KEY=did:key:... 50 # ============================================================================= 51 + # AppView Federation 52 # ============================================================================= 53 + # AppViews are resolved via DID-based discovery. Configure by mapping lexicon 54 + # namespaces to AppView DIDs. The DID document is fetched and the service 55 + # endpoint is extracted automatically. 56 + # 57 + # Format: APPVIEW_DID_<NAMESPACE>=<did> 58 + # Where <NAMESPACE> uses underscores instead of dots (e.g., APP_BSKY for app.bsky) 59 + # 60 + # Default: app.bsky and com.atproto -> did:web:api.bsky.app 61 + # 62 + # Examples: 63 + # APPVIEW_DID_APP_BSKY=did:web:api.bsky.app 64 + # APPVIEW_DID_COM_WHTWND=did:web:whtwnd.com 65 + # APPVIEW_DID_BLUE_ZIO=did:plc:some-custom-appview 66 + # 67 + # Cache TTL for resolved AppView endpoints (default: 300 seconds) 68 + # APPVIEW_CACHE_TTL_SECS=300 69 + # 70 # Comma-separated list of relay URLs to notify via requestCrawl 71 # CRAWLERS=https://bsky.network,https://relay.upcloud.world 72 # =============================================================================
+11 -5
.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json .sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n handle, email, email_confirmed,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 "name": "preferred_channel: crate::notifications::NotificationChannel", 24 "type_info": { 25 "Custom": { ··· 36 } 37 }, 38 { 39 - "ordinal": 4, 40 "name": "discord_verified", 41 "type_info": "Bool" 42 }, 43 { 44 - "ordinal": 5, 45 "name": "telegram_verified", 46 "type_info": "Bool" 47 }, 48 { 49 - "ordinal": 6, 50 "name": "signal_verified", 51 "type_info": "Bool" 52 } ··· 63 false, 64 false, 65 false, 66 false 67 ] 68 }, 69 - "hash": "3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b" 70 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT\n handle, email, email_confirmed, is_admin,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 + "name": "is_admin", 24 + "type_info": "Bool" 25 + }, 26 + { 27 + "ordinal": 4, 28 "name": "preferred_channel: crate::notifications::NotificationChannel", 29 "type_info": { 30 "Custom": { ··· 41 } 42 }, 43 { 44 + "ordinal": 5, 45 "name": "discord_verified", 46 "type_info": "Bool" 47 }, 48 { 49 + "ordinal": 6, 50 "name": "telegram_verified", 51 "type_info": "Bool" 52 }, 53 { 54 + "ordinal": 7, 55 "name": "signal_verified", 56 "type_info": "Bool" 57 } ··· 68 false, 69 false, 70 false, 71 + false, 72 false 73 ] 74 }, 75 + "hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1" 76 }
+82 -284
TODO.md
··· 1 - # PDS Implementation TODOs 2 - Lewis' corrected big boy todofile 3 - ## Server Infrastructure & Proxying 4 - - [x] Health Check 5 - - [x] Implement `GET /health` endpoint (returns "OK"). 6 - - [x] Implement `GET /xrpc/_health` endpoint (returns "OK"). 7 - - [x] Server Description 8 - - [x] Implement `com.atproto.server.describeServer` (returns available user domains). 9 - - [x] XRPC Proxying 10 - - [x] Implement strict forwarding for all `app.bsky.*` and `chat.bsky.*` requests to an appview. 11 - - [x] Forward auth headers correctly. 12 - - [x] Handle appview errors/timeouts gracefully. 13 - ## Authentication & Account Management (`com.atproto.server`) 14 - - [x] Account Creation 15 - - [x] Implement `com.atproto.server.createAccount`. 16 - - [x] Validate handle format (reject invalid characters). 17 - - [x] Create DID for new user (PLC directory). 18 - - [x] Initialize user repository (Root commit). 19 - - [x] Return access JWT and DID. 20 - - [x] Create DID for new user (did:web). 21 - - [x] Session Management 22 - - [x] Implement `com.atproto.server.createSession` (Login). 23 - - [x] Implement `com.atproto.server.getSession`. 24 - - [x] Implement `com.atproto.server.refreshSession`. 25 - - [x] Implement `com.atproto.server.deleteSession` (Logout). 26 - - [x] Implement `com.atproto.server.activateAccount`. 27 - - [x] Implement `com.atproto.server.checkAccountStatus`. 28 - - [x] Implement `com.atproto.server.createAppPassword`. 29 - - [x] Implement `com.atproto.server.createInviteCode`. 30 - - [x] Implement `com.atproto.server.createInviteCodes`. 31 - - [x] Implement `com.atproto.server.deactivateAccount`. 32 - - [x] Implement `com.atproto.server.deleteAccount` (user-initiated, requires password + email token). 33 - - [x] Implement `com.atproto.server.getAccountInviteCodes`. 34 - - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). 35 - - [x] Implement `com.atproto.server.listAppPasswords`. 36 - - [x] Implement `com.atproto.server.requestAccountDelete`. 37 - - [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. 38 - - [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 39 - - [x] Implement `com.atproto.server.reserveSigningKey`. 40 - - [x] Implement `com.atproto.server.revokeAppPassword`. 41 - - [x] Implement `com.atproto.server.updateEmail`. 42 - - [x] Implement `com.atproto.server.confirmEmail`. 43 - ## Repository Operations (`com.atproto.repo`) 44 - - [x] Record CRUD 45 - - [x] Implement `com.atproto.repo.createRecord`. 46 - - [x] Generate `rkey` (TID) if not provided. 47 - - [x] Handle MST (Merkle Search Tree) insertion. 48 - - [x] **Trigger Firehose Event**. 49 - - [x] Implement `com.atproto.repo.putRecord`. 50 - - [x] Implement `com.atproto.repo.getRecord`. 51 - - [x] Implement `com.atproto.repo.deleteRecord`. 52 - - [x] Implement `com.atproto.repo.listRecords`. 53 - - [x] Implement `com.atproto.repo.describeRepo`. 54 - - [x] Implement `com.atproto.repo.applyWrites` (Batch writes). 55 - - [x] Implement `com.atproto.repo.importRepo` (Migration). 56 - - [x] Implement `com.atproto.repo.listMissingBlobs`. 57 - - [x] Blob Management 58 - - [x] Implement `com.atproto.repo.uploadBlob`. 59 - - [x] Store blob (S3). 60 - - [x] return `blob` ref (CID + MimeType). 61 - ## Sync & Federation (`com.atproto.sync`) 62 - - [x] The Firehose (WebSocket) 63 - - [x] Implement `com.atproto.sync.subscribeRepos`. 64 - - [x] Broadcast real-time commit events. 65 - - [x] Handle cursor replay (backfill). 66 - - [x] Bulk Export 67 - - [x] Implement `com.atproto.sync.getRepo` (Return full CAR file of repo). 68 - - [x] Implement `com.atproto.sync.getBlocks` (Return specific blocks via CIDs). 69 - - [x] Implement `com.atproto.sync.getLatestCommit`. 70 - - [x] Implement `com.atproto.sync.getRecord` (Sync version, distinct from repo.getRecord). 71 - - [x] Implement `com.atproto.sync.getRepoStatus`. 72 - - [x] Implement `com.atproto.sync.listRepos`. 73 - - [x] Implement `com.atproto.sync.notifyOfUpdate`. 74 - - [x] Blob Sync 75 - - [x] Implement `com.atproto.sync.getBlob`. 76 - - [x] Implement `com.atproto.sync.listBlobs`. 77 - - [x] Crawler Interaction 78 - - [x] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us). 79 - - [x] Deprecated Sync Endpoints (for compatibility) 80 - - [x] Implement `com.atproto.sync.getCheckout` (deprecated). 81 - - [x] Implement `com.atproto.sync.getHead` (deprecated). 82 - ## Identity (`com.atproto.identity`) 83 - - [x] Resolution 84 - - [x] Implement `com.atproto.identity.resolveHandle` (Can be internal or proxy to PLC). 85 - - [x] Implement `com.atproto.identity.updateHandle`. 86 - - [x] Implement `com.atproto.identity.submitPlcOperation` / `signPlcOperation` / `requestPlcOperationSignature`. 87 - - [x] Implement `com.atproto.identity.getRecommendedDidCredentials`. 88 - - [x] Implement `/.well-known/did.json` (Depends on supporting did:web). 89 - ## Admin Management (`com.atproto.admin`) 90 - - [x] Implement `com.atproto.admin.deleteAccount`. 91 - - [x] Implement `com.atproto.admin.disableAccountInvites`. 92 - - [x] Implement `com.atproto.admin.disableInviteCodes`. 93 - - [x] Implement `com.atproto.admin.enableAccountInvites`. 94 - - [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`. 95 - - [x] Implement `com.atproto.admin.getInviteCodes`. 96 - - [x] Implement `com.atproto.admin.getSubjectStatus`. 97 - - [x] Implement `com.atproto.admin.sendEmail`. 98 - - [x] Implement `com.atproto.admin.updateAccountEmail`. 99 - - [x] Implement `com.atproto.admin.updateAccountHandle`. 100 - - [x] Implement `com.atproto.admin.updateAccountPassword`. 101 - - [x] Implement `com.atproto.admin.updateSubjectStatus`. 102 - ## Moderation (`com.atproto.moderation`) 103 - - [x] Implement `com.atproto.moderation.createReport`. 104 - ## Temp Namespace (`com.atproto.temp`) 105 - - [x] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups). 106 - ## Misc HTTP Endpoints 107 - - [x] Implement `/robots.txt` endpoint. 108 - ## OAuth 2.1 Support 109 - Full OAuth 2.1 provider for ATProto native app authentication. 110 - - [x] OAuth Provider Core 111 - - [x] Implement `/.well-known/oauth-protected-resource` metadata endpoint. 112 - - [x] Implement `/.well-known/oauth-authorization-server` metadata endpoint. 113 - - [x] Implement `/oauth/authorize` authorization endpoint (with login UI). 114 - - [x] Implement `/oauth/par` Pushed Authorization Request endpoint. 115 - - [x] Implement `/oauth/token` token endpoint (authorization_code + refresh_token grants). 116 - - [x] Implement `/oauth/jwks` JSON Web Key Set endpoint. 117 - - [x] Implement `/oauth/revoke` token revocation endpoint. 118 - - [x] Implement `/oauth/introspect` token introspection endpoint. 119 - - [x] OAuth Database Tables 120 - - [x] Device table for tracking authorized devices. 121 - - [x] Authorization request table. 122 - - [x] Authorized client table. 123 - - [x] Token table for OAuth tokens. 124 - - [x] Used refresh token table (replay protection). 125 - - [x] DPoP JTI tracking table. 126 - - [x] DPoP (Demonstrating Proof-of-Possession) support. 127 - - [x] Client metadata fetching and validation. 128 - - [x] PKCE (S256) enforcement. 129 - - [x] OAuth token verification extractor for protected resources. 130 - - [x] Authorization UI templates (HTML login form). 131 - - [x] Implement `private_key_jwt` signature verification with async JWKS fetching. 132 - - [x] HS256 JWT support (matches reference PDS). 133 - ## OAuth Security Notes 134 - Security measures implemented: 135 - - Constant-time comparison for signature verification (prevents timing attacks) 136 - - HMAC-SHA256 for access token signing with configurable secret 137 - - Production secrets require 32+ character minimum 138 - - DPoP JTI replay protection via database 139 - - DPoP nonce validation with HMAC-based timestamps (5 min validity) 140 - - Refresh token rotation with reuse detection (revokes token family on reuse) 141 - - PKCE S256 enforced (plain not allowed) 142 - - Authorization code single-use enforcement 143 - - URL encoding for redirect parameters (prevents injection) 144 - - All database queries use parameterized statements (no SQL injection) 145 - - Deactivated/taken-down accounts blocked from OAuth authorization 146 - - Client ID validation on token exchange (defense-in-depth against cross-client attacks) 147 - - HTML escaping in OAuth templates (XSS prevention) 148 - ### Auth Notes 149 - - Dual algorithm support: ES256K (secp256k1 ECDSA) with per-user keys AND HS256 (HMAC) for compatibility with reference PDS. 150 - - Token storage: Storing only token JTIs in session_tokens table (defense in depth against DB breaches). Refresh token family tracking enables detection of token reuse attacks. 151 - - Key encryption: User signing keys encrypted at rest using AES-256-GCM with keys derived via HKDF from KEY_ENCRYPTION_KEY environment variable. 152 - ## PDS-Level App Endpoints 153 - These endpoints need to be implemented at the PDS level (not just proxied to appview). 154 - ### Actor (`app.bsky.actor`) 155 - - [x] Implement `app.bsky.actor.getPreferences` (user preferences storage). 156 - - [x] Implement `app.bsky.actor.putPreferences` (update user preferences). 157 - - [x] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback). 158 - - [x] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback). 159 - ### Feed (`app.bsky.feed`) 160 - These are implemented at PDS level to enable local-first reads (read-after-write pattern): 161 - - [x] Implement `app.bsky.feed.getTimeline` (PDS-level with proxy + RAW). 162 - - [x] Implement `app.bsky.feed.getAuthorFeed` (PDS-level with proxy + RAW). 163 - - [x] Implement `app.bsky.feed.getActorLikes` (PDS-level with proxy + RAW). 164 - - [x] Implement `app.bsky.feed.getPostThread` (PDS-level with proxy + RAW + NotFound handling). 165 - - [x] Implement `app.bsky.feed.getFeed` (proxy to feed generator). 166 - ### Notification (`app.bsky.notification`) 167 - - [x] Implement `app.bsky.notification.registerPush` (push notification registration, proxied). 168 - ## Infrastructure & Core Components 169 - - [x] Sequencer (Event Log) 170 - - [x] Implement a `Sequencer` (backed by `repo_seq` table). 171 - - [x] Implement event formatting (`commit`, `handle`, `identity`, `account`). 172 - - [x] Implement database polling / event emission mechanism. 173 - - [x] Implement cursor-based event replay (`requestSeqRange`). 174 - - [x] Repo Storage & Consistency (in postgres) 175 - - [x] Implement `RepoStorage` for postgres (replaces per-user SQLite). 176 - - [x] Read/Write IPLD blocks to `blocks` table (global deduplication). 177 - - [x] Manage Repo Root in `repos` table. 178 - - [x] Implement Atomic Repo Transactions. 179 - - [x] Ensure `blocks` write, `repo_root` update, `records` index update, and `sequencer` event are committed in a single transaction. 180 - - [x] Implement concurrency control (row-level locking via FOR UPDATE). 181 - - [x] DID Cache 182 - - [x] Implement caching layer for DID resolution (valkey). 183 - - [x] Handle cache invalidation/expiry. 184 - - [x] Graceful fallback to no-cache when valkey unavailable. 185 - - [x] Crawlers Service 186 - - [x] Implement `Crawlers` service (debounce notifications to relays). 187 - - [x] 20-minute notification debounce. 188 - - [x] Circuit breaker for relay failures. 189 - - [x] Notification Service 190 - - [x] Queue-based notification system with database table 191 - - [x] Background worker polling for pending notifications 192 - - [x] Extensible sender trait for multiple channels 193 - - [x] Email sender via OS sendmail/msmtp 194 - - [x] Discord webhook sender 195 - - [x] Telegram bot sender 196 - - [x] Signal CLI sender 197 - - [x] Helper functions for common notification types (welcome, password reset, email verification, etc.) 198 - - [x] Respect user's `preferred_notification_channel` setting for non-email-specific notifications 199 - - [x] Image Processing 200 - - [x] Implement image resize/formatting pipeline (for blob uploads). 201 - - [x] WebP conversion for thumbnails. 202 - - [x] EXIF stripping. 203 - - [x] File size limits (10MB default). 204 - - [x] IPLD & MST 205 - - [x] Implement Merkle Search Tree logic for repo signing. 206 - - [x] Implement CAR (Content Addressable Archive) encoding/decoding. 207 - - [x] Cycle detection in CAR export. 208 - - [x] Rate Limiting 209 - - [x] Per-IP rate limiting on login (10/min). 210 - - [x] Per-IP rate limiting on OAuth token endpoint (30/min). 211 - - [x] Per-IP rate limiting on password reset (5/hour). 212 - - [x] Per-IP rate limiting on account creation (10/hour). 213 - - [x] Per-IP rate limiting on refreshSession (60/min). 214 - - [x] Per-IP rate limiting on OAuth authorize POST (10/min). 215 - - [x] Per-IP rate limiting on OAuth 2FA POST (10/min). 216 - - [x] Per-IP rate limiting on OAuth PAR (30/min). 217 - - [x] Per-IP rate limiting on OAuth revoke/introspect (30/min). 218 - - [x] Per-IP rate limiting on createAppPassword (10/min). 219 - - [x] Per-IP rate limiting on email endpoints (5/hour). 220 - - [x] Distributed rate limiting via valkey (with in-memory fallback). 221 - - [x] Circuit Breakers 222 - - [x] PLC directory circuit breaker (5 failures → open, 60s timeout). 223 - - [x] Relay notification circuit breaker (10 failures → open, 30s timeout). 224 - - [x] Security Hardening 225 - - [x] Email header injection prevention (CRLF sanitization). 226 - - [x] Signal command injection prevention (phone number validation). 227 - - [x] Constant-time signature comparison. 228 - - [x] SSRF protection for outbound requests. 229 - - [x] Timing attack protection (dummy bcrypt on user-not-found prevents account enumeration). 230 - ## Lewis' fabulous mini-list of remaining TODOs 231 - - [x] The OAuth authorize POST endpoint has no rate limiting, allowing password brute-forcing. Fix this and audit all oauth and 2fa surface again. 232 - - [x] DID resolution caching (valkey). 233 - - [x] Record schema validation (generic validation framework). 234 - - [x] Fix any remaining TODOs in the code. 235 - ## Future: Web Management UI 236 - A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers. 237 - ### Architecture 238 - - [x] Static SPA served from PDS (or separate static host) 239 - - [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client) 240 - - [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below) 241 - - [x] No server-side sessions or CSRF - pure API client 242 - ### PDS-Specific XRPC Endpoints (new) 243 - Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D 244 - Anyway... endpoints for PDS settings not covered by standard ATProto: 245 - - [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels 246 - - [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel 247 - - [x] `com.bspds.account.getNotificationHistory` - list past notifications 248 - - [x] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal 249 - - [x] `com.bspds.account.confirmChannelVerification` - confirm with code 250 - - [x] `com.bspds.admin.getServerStats` - user count, storage usage, etc. 251 - ### Frontend Views 252 - Uses existing ATProto endpoints where possible: 253 - Authentication 254 - - [x] Login page (uses `com.atproto.server.createSession`) 255 - - [x] Registration page (uses `com.atproto.server.createAccount`) 256 - - [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`) 257 - - [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`) 258 - User Dashboard 259 - - [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) 260 - - [ ] Active sessions view (needs new endpoint or extend existing) 261 - - [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`) 262 - - [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) 263 - Notification Preferences 264 - - [x] Channel selector (uses `com.bspds.account.*` endpoints above) 265 - - [x] Verification flows for Discord/Telegram/Signal 266 - - [ ] Notification history view 267 - Account Settings 268 - - [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`) 269 - - [ ] Password change while logged in (needs new endpoint - change password with current password) 270 - - [x] Handle change (uses `com.atproto.identity.updateHandle`) 271 - - [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`) 272 - Data Management 273 - - [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`) 274 - - [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`) 275 - Admin Dashboard (privileged users only) 276 - - [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination) 277 - - [ ] User detail/actions (uses `com.atproto.admin.*` endpoints) 278 - - [ ] Invite management (uses `com.atproto.admin.getInviteCodes`, `disableInviteCodes`) 279 - - [ ] Server stats (uses `com.bspds.admin.getServerStats`) 280 - ## Future: private data 281 - I will see where the discourse about encrypted/privileged private data is at the current moment, and make an implementation that matches what the bsky team will likely do in their pds whenever they get around to it. 282 - Then when they come out with theirs, I can make adjustments to mine and be ready on day 1. Or 2. 283 - We want records that only authorized parties can see and decrypt. This requires some sort of federation of keys and communication between PDSes? 284 - Gotta figure all of this out as a first step.
··· 1 + # Lewis' Big Boy TODO list 2 + 3 + ## Active development 4 + 5 + ### OAuth scope authorization UI 6 + Display and manage OAuth scopes during authorization flows. 7 + 8 + - [ ] Parse and display requested scopes from authorization request 9 + - [ ] Human-readable scope descriptions (e.g., "Read your posts" not "app.bsky.feed.read") 10 + - [ ] Group scopes by category (read, write, admin, etc.) 11 + - [ ] Allow users to uncheck optional scopes before authorizing 12 + - [ ] Distinguish required vs optional scopes in UI 13 + - [ ] Remember scope preferences per client (don't ask again for same scopes) 14 + - [ ] Token endpoint respects user's scope selections 15 + - [ ] Protected endpoints check token scopes before allowing operations 16 + 17 + ### Frontend 18 + So like... make the thing unique, make it cool. 19 + 20 + - [ ] Frontpage that explains what this thing is 21 + - [ ] Unique "brand" style both unauthed and authed 22 + - [ ] Better documentation on how to sub out the entire frontend for whatever the users want 23 + 24 + ### Delegated accounts 25 + Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. 26 + 27 + - [ ] Account type flag in actors table (personal | delegated) 28 + - [ ] account_delegations table (delegated_did, controller_did, granted_scopes[], granted_at, granted_by, revoked_at) 29 + - [ ] Detect delegated account during authorize flow 30 + - [ ] Redirect to "authenticate as controller" instead of password prompt 31 + - [ ] Validate controller has delegation grant for this account 32 + - [ ] Issue token with intersection of (requested scopes :intersection-emoji: granted scopes) 33 + - [ ] Token includes act_as claim indicating delegation 34 + - [ ] Define standard scope sets (owner, admin, editor, viewer) 35 + - [ ] Create delegated account flow (no password, must add initial controller) 36 + - [ ] Controller management page (add/remove controllers, modify scopes) 37 + - [ ] "Act as" account switcher for users with delegation grants 38 + - [ ] Log all actions with both actor DID and controller DID 39 + - [ ] Audit log view for delegated account owners 40 + 41 + ### Passkey support 42 + Modern passwordless authentication using WebAuthn/FIDO2, alongside or instead of passwords. 43 + 44 + - [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 45 + - [ ] Generate WebAuthn registration challenge 46 + - [ ] Verify attestation response and store credential 47 + - [ ] UI for registering new passkey from settings 48 + - [ ] Detect if account has passkeys during OAuth authorize 49 + - [ ] Offer passkey option alongside password 50 + - [ ] Generate authentication challenge and verify assertion 51 + - [ ] Update sign count (replay protection) 52 + - [ ] Allow creating account with passkey instead of password 53 + - [ ] List/rename/remove passkeys in settings 54 + 55 + ### Private/encrypted data 56 + Records that only authorized parties can see and decrypt. Requires key federation between PDSes. 57 + 58 + - [ ] Survey current ATProto discourse on private data 59 + - [ ] Document Bluesky team's likely approach 60 + - [ ] Design key management strategy 61 + - [ ] Per-user encryption keys (separate from signing keys) 62 + - [ ] Key derivation for per-record or per-collection encryption 63 + - [ ] Encrypted record storage format 64 + - [ ] Transparent encryption/decryption in repo operations 65 + - [ ] Protocol for sharing decryption keys between PDSes 66 + - [ ] Handle key rotation and revocation 67 + 68 + --- 69 + 70 + ## Completed 71 + 72 + Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, did:web, full admin API, moderation reports. 73 + 74 + OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification. 75 + 76 + App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback). 77 + 78 + Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening. 79 + 80 + Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel. 81 + 82 + Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
+9
frontend/src/App.svelte
··· 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 import Login from './routes/Login.svelte' 5 import Register from './routes/Register.svelte' 6 import Dashboard from './routes/Dashboard.svelte' 7 import AppPasswords from './routes/AppPasswords.svelte' 8 import InviteCodes from './routes/InviteCodes.svelte' 9 import Settings from './routes/Settings.svelte' 10 import Notifications from './routes/Notifications.svelte' 11 import RepoExplorer from './routes/RepoExplorer.svelte' 12 13 const auth = getAuthState() 14 ··· 22 return Login 23 case '/register': 24 return Register 25 case '/dashboard': 26 return Dashboard 27 case '/app-passwords': ··· 30 return InviteCodes 31 case '/settings': 32 return Settings 33 case '/notifications': 34 return Notifications 35 case '/repo': 36 return RepoExplorer 37 default: 38 return auth.session ? Dashboard : Login 39 }
··· 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 import Login from './routes/Login.svelte' 5 import Register from './routes/Register.svelte' 6 + import ResetPassword from './routes/ResetPassword.svelte' 7 import Dashboard from './routes/Dashboard.svelte' 8 import AppPasswords from './routes/AppPasswords.svelte' 9 import InviteCodes from './routes/InviteCodes.svelte' 10 import Settings from './routes/Settings.svelte' 11 + import Sessions from './routes/Sessions.svelte' 12 import Notifications from './routes/Notifications.svelte' 13 import RepoExplorer from './routes/RepoExplorer.svelte' 14 + import Admin from './routes/Admin.svelte' 15 16 const auth = getAuthState() 17 ··· 25 return Login 26 case '/register': 27 return Register 28 + case '/reset-password': 29 + return ResetPassword 30 case '/dashboard': 31 return Dashboard 32 case '/app-passwords': ··· 35 return InviteCodes 36 case '/settings': 37 return Settings 38 + case '/sessions': 39 + return Sessions 40 case '/notifications': 41 return Notifications 42 case '/repo': 43 return RepoExplorer 44 + case '/admin': 45 + return Admin 46 default: 47 return auth.session ? Dashboard : Login 48 }
+147
frontend/src/lib/api.ts
··· 47 emailConfirmed?: boolean 48 preferredChannel?: string 49 preferredChannelVerified?: boolean 50 accessJwt: string 51 refreshJwt: string 52 } ··· 267 method: 'POST', 268 token, 269 body: prefs, 270 }) 271 }, 272
··· 47 emailConfirmed?: boolean 48 preferredChannel?: string 49 preferredChannelVerified?: boolean 50 + isAdmin?: boolean 51 accessJwt: string 52 refreshJwt: string 53 } ··· 268 method: 'POST', 269 token, 270 body: prefs, 271 + }) 272 + }, 273 + 274 + async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> { 275 + return xrpc('com.bspds.account.confirmChannelVerification', { 276 + method: 'POST', 277 + token, 278 + body: { channel, code }, 279 + }) 280 + }, 281 + 282 + async getNotificationHistory(token: string): Promise<{ 283 + notifications: Array<{ 284 + createdAt: string 285 + channel: string 286 + notificationType: string 287 + status: string 288 + subject: string | null 289 + body: string 290 + }> 291 + }> { 292 + return xrpc('com.bspds.account.getNotificationHistory', { token }) 293 + }, 294 + 295 + async getServerStats(token: string): Promise<{ 296 + userCount: number 297 + repoCount: number 298 + recordCount: number 299 + blobStorageBytes: number 300 + }> { 301 + return xrpc('com.bspds.admin.getServerStats', { token }) 302 + }, 303 + 304 + async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> { 305 + await xrpc('com.bspds.account.changePassword', { 306 + method: 'POST', 307 + token, 308 + body: { currentPassword, newPassword }, 309 + }) 310 + }, 311 + 312 + async listSessions(token: string): Promise<{ 313 + sessions: Array<{ 314 + id: string 315 + createdAt: string 316 + expiresAt: string 317 + isCurrent: boolean 318 + }> 319 + }> { 320 + return xrpc('com.bspds.account.listSessions', { token }) 321 + }, 322 + 323 + async revokeSession(token: string, sessionId: string): Promise<void> { 324 + await xrpc('com.bspds.account.revokeSession', { 325 + method: 'POST', 326 + token, 327 + body: { sessionId }, 328 + }) 329 + }, 330 + 331 + async searchAccounts(token: string, options?: { 332 + handle?: string 333 + cursor?: string 334 + limit?: number 335 + }): Promise<{ 336 + cursor?: string 337 + accounts: Array<{ 338 + did: string 339 + handle: string 340 + email?: string 341 + indexedAt: string 342 + emailConfirmedAt?: string 343 + deactivatedAt?: string 344 + }> 345 + }> { 346 + const params: Record<string, string> = {} 347 + if (options?.handle) params.handle = options.handle 348 + if (options?.cursor) params.cursor = options.cursor 349 + if (options?.limit) params.limit = String(options.limit) 350 + return xrpc('com.atproto.admin.searchAccounts', { token, params }) 351 + }, 352 + 353 + async getInviteCodes(token: string, options?: { 354 + sort?: 'recent' | 'usage' 355 + cursor?: string 356 + limit?: number 357 + }): Promise<{ 358 + cursor?: string 359 + codes: Array<{ 360 + code: string 361 + available: number 362 + disabled: boolean 363 + forAccount: string 364 + createdBy: string 365 + createdAt: string 366 + uses: Array<{ usedBy: string; usedAt: string }> 367 + }> 368 + }> { 369 + const params: Record<string, string> = {} 370 + if (options?.sort) params.sort = options.sort 371 + if (options?.cursor) params.cursor = options.cursor 372 + if (options?.limit) params.limit = String(options.limit) 373 + return xrpc('com.atproto.admin.getInviteCodes', { token, params }) 374 + }, 375 + 376 + async disableInviteCodes(token: string, codes?: string[], accounts?: string[]): Promise<void> { 377 + await xrpc('com.atproto.admin.disableInviteCodes', { 378 + method: 'POST', 379 + token, 380 + body: { codes, accounts }, 381 + }) 382 + }, 383 + 384 + async getAccountInfo(token: string, did: string): Promise<{ 385 + did: string 386 + handle: string 387 + email?: string 388 + indexedAt: string 389 + emailConfirmedAt?: string 390 + invitesDisabled?: boolean 391 + deactivatedAt?: string 392 + }> { 393 + return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } }) 394 + }, 395 + 396 + async disableAccountInvites(token: string, account: string): Promise<void> { 397 + await xrpc('com.atproto.admin.disableAccountInvites', { 398 + method: 'POST', 399 + token, 400 + body: { account }, 401 + }) 402 + }, 403 + 404 + async enableAccountInvites(token: string, account: string): Promise<void> { 405 + await xrpc('com.atproto.admin.enableAccountInvites', { 406 + method: 'POST', 407 + token, 408 + body: { account }, 409 + }) 410 + }, 411 + 412 + async adminDeleteAccount(token: string, did: string): Promise<void> { 413 + await xrpc('com.atproto.admin.deleteAccount', { 414 + method: 'POST', 415 + token, 416 + body: { did }, 417 }) 418 }, 419
+148 -4
frontend/src/lib/auth.svelte.ts
··· 1 import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api' 2 3 const STORAGE_KEY = 'bspds_session' 4 5 interface AuthState { 6 session: Session | null 7 loading: boolean 8 error: string | null 9 } 10 11 let state = $state<AuthState>({ 12 session: null, 13 loading: true, 14 error: null, 15 }) 16 17 function saveSession(session: Session | null) { ··· 34 return null 35 } 36 37 export async function initAuth() { 38 state.loading = true 39 state.error = null 40 const stored = loadSession() 41 if (stored) { 42 try { 43 const session = await api.getSession(stored.accessJwt) 44 state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt } 45 } catch (e) { 46 if (e instanceof ApiError && e.status === 401) { 47 try { 48 - const refreshed = await api.refreshSession(stored.refreshJwt) 49 - state.session = refreshed 50 - saveSession(refreshed) 51 } catch { 52 saveSession(null) 53 state.session = null ··· 68 const session = await api.createSession(identifier, password) 69 state.session = session 70 saveSession(session) 71 } catch (e) { 72 if (e instanceof ApiError) { 73 state.error = e.message ··· 80 } 81 } 82 83 export async function register(params: CreateAccountParams): Promise<CreateAccountResult> { 84 try { 85 const result = await api.createAccount(params) ··· 111 } 112 state.session = session 113 saveSession(session) 114 } catch (e) { 115 if (e instanceof ApiError) { 116 state.error = e.message ··· 146 saveSession(null) 147 } 148 149 export function getAuthState() { 150 return state 151 } ··· 158 return state.session !== null 159 } 160 161 - export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) { 162 state.session = newState.session 163 state.loading = newState.loading 164 state.error = newState.error 165 } 166 167 export function _testReset() { 168 state.session = null 169 state.loading = true 170 state.error = null 171 localStorage.removeItem(STORAGE_KEY) 172 }
··· 1 import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api' 2 + import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth' 3 4 const STORAGE_KEY = 'bspds_session' 5 + const ACCOUNTS_KEY = 'bspds_accounts' 6 + 7 + export interface SavedAccount { 8 + did: string 9 + handle: string 10 + accessJwt: string 11 + refreshJwt: string 12 + } 13 14 interface AuthState { 15 session: Session | null 16 loading: boolean 17 error: string | null 18 + savedAccounts: SavedAccount[] 19 } 20 21 let state = $state<AuthState>({ 22 session: null, 23 loading: true, 24 error: null, 25 + savedAccounts: [], 26 }) 27 28 function saveSession(session: Session | null) { ··· 45 return null 46 } 47 48 + function loadSavedAccounts(): SavedAccount[] { 49 + const stored = localStorage.getItem(ACCOUNTS_KEY) 50 + if (stored) { 51 + try { 52 + return JSON.parse(stored) 53 + } catch { 54 + return [] 55 + } 56 + } 57 + return [] 58 + } 59 + 60 + function saveSavedAccounts(accounts: SavedAccount[]) { 61 + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)) 62 + } 63 + 64 + function addOrUpdateSavedAccount(session: Session) { 65 + const accounts = loadSavedAccounts() 66 + const existing = accounts.findIndex(a => a.did === session.did) 67 + const savedAccount: SavedAccount = { 68 + did: session.did, 69 + handle: session.handle, 70 + accessJwt: session.accessJwt, 71 + refreshJwt: session.refreshJwt, 72 + } 73 + if (existing >= 0) { 74 + accounts[existing] = savedAccount 75 + } else { 76 + accounts.push(savedAccount) 77 + } 78 + saveSavedAccounts(accounts) 79 + state.savedAccounts = accounts 80 + } 81 + 82 + function removeSavedAccount(did: string) { 83 + const accounts = loadSavedAccounts().filter(a => a.did !== did) 84 + saveSavedAccounts(accounts) 85 + state.savedAccounts = accounts 86 + } 87 + 88 export async function initAuth() { 89 state.loading = true 90 state.error = null 91 + state.savedAccounts = loadSavedAccounts() 92 + 93 + const oauthCallback = checkForOAuthCallback() 94 + if (oauthCallback) { 95 + clearOAuthCallbackParams() 96 + try { 97 + const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state) 98 + const sessionInfo = await api.getSession(tokens.access_token) 99 + const session: Session = { 100 + ...sessionInfo, 101 + accessJwt: tokens.access_token, 102 + refreshJwt: tokens.refresh_token || '', 103 + } 104 + state.session = session 105 + saveSession(session) 106 + addOrUpdateSavedAccount(session) 107 + state.loading = false 108 + return 109 + } catch (e) { 110 + state.error = e instanceof Error ? e.message : 'OAuth login failed' 111 + state.loading = false 112 + return 113 + } 114 + } 115 + 116 const stored = loadSession() 117 if (stored) { 118 try { 119 const session = await api.getSession(stored.accessJwt) 120 state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt } 121 + addOrUpdateSavedAccount(state.session) 122 } catch (e) { 123 if (e instanceof ApiError && e.status === 401) { 124 try { 125 + const tokens = await refreshOAuthToken(stored.refreshJwt) 126 + const sessionInfo = await api.getSession(tokens.access_token) 127 + const session: Session = { 128 + ...sessionInfo, 129 + accessJwt: tokens.access_token, 130 + refreshJwt: tokens.refresh_token || stored.refreshJwt, 131 + } 132 + state.session = session 133 + saveSession(session) 134 + addOrUpdateSavedAccount(session) 135 } catch { 136 saveSession(null) 137 state.session = null ··· 152 const session = await api.createSession(identifier, password) 153 state.session = session 154 saveSession(session) 155 + addOrUpdateSavedAccount(session) 156 } catch (e) { 157 if (e instanceof ApiError) { 158 state.error = e.message ··· 165 } 166 } 167 168 + export async function loginWithOAuth(): Promise<void> { 169 + state.loading = true 170 + state.error = null 171 + try { 172 + await startOAuthLogin() 173 + } catch (e) { 174 + state.loading = false 175 + state.error = e instanceof Error ? e.message : 'Failed to start OAuth login' 176 + throw e 177 + } 178 + } 179 + 180 export async function register(params: CreateAccountParams): Promise<CreateAccountResult> { 181 try { 182 const result = await api.createAccount(params) ··· 208 } 209 state.session = session 210 saveSession(session) 211 + addOrUpdateSavedAccount(session) 212 } catch (e) { 213 if (e instanceof ApiError) { 214 state.error = e.message ··· 244 saveSession(null) 245 } 246 247 + export async function switchAccount(did: string): Promise<void> { 248 + const account = state.savedAccounts.find(a => a.did === did) 249 + if (!account) { 250 + throw new Error('Account not found') 251 + } 252 + state.loading = true 253 + state.error = null 254 + try { 255 + const session = await api.getSession(account.accessJwt) 256 + state.session = { ...session, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt } 257 + saveSession(state.session) 258 + addOrUpdateSavedAccount(state.session) 259 + } catch (e) { 260 + if (e instanceof ApiError && e.status === 401) { 261 + try { 262 + const tokens = await refreshOAuthToken(account.refreshJwt) 263 + const sessionInfo = await api.getSession(tokens.access_token) 264 + const session: Session = { 265 + ...sessionInfo, 266 + accessJwt: tokens.access_token, 267 + refreshJwt: tokens.refresh_token || account.refreshJwt, 268 + } 269 + state.session = session 270 + saveSession(session) 271 + addOrUpdateSavedAccount(session) 272 + } catch { 273 + removeSavedAccount(did) 274 + state.error = 'Session expired. Please log in again.' 275 + throw new Error('Session expired') 276 + } 277 + } else { 278 + state.error = 'Failed to switch account' 279 + throw e 280 + } 281 + } finally { 282 + state.loading = false 283 + } 284 + } 285 + 286 + export function forgetAccount(did: string): void { 287 + removeSavedAccount(did) 288 + } 289 + 290 export function getAuthState() { 291 return state 292 } ··· 299 return state.session !== null 300 } 301 302 + export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) { 303 state.session = newState.session 304 state.loading = newState.loading 305 state.error = newState.error 306 + state.savedAccounts = newState.savedAccounts ?? [] 307 } 308 309 export function _testReset() { 310 state.session = null 311 state.loading = true 312 state.error = null 313 + state.savedAccounts = [] 314 localStorage.removeItem(STORAGE_KEY) 315 + localStorage.removeItem(ACCOUNTS_KEY) 316 }
+181
frontend/src/lib/oauth.ts
···
··· 1 + const OAUTH_STATE_KEY = 'bspds_oauth_state' 2 + const OAUTH_VERIFIER_KEY = 'bspds_oauth_verifier' 3 + 4 + interface OAuthState { 5 + state: string 6 + codeVerifier: string 7 + returnTo?: string 8 + } 9 + 10 + function generateRandomString(length: number): string { 11 + const array = new Uint8Array(length) 12 + crypto.getRandomValues(array) 13 + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('') 14 + } 15 + 16 + async function sha256(plain: string): Promise<ArrayBuffer> { 17 + const encoder = new TextEncoder() 18 + const data = encoder.encode(plain) 19 + return crypto.subtle.digest('SHA-256', data) 20 + } 21 + 22 + function base64UrlEncode(buffer: ArrayBuffer): string { 23 + const bytes = new Uint8Array(buffer) 24 + let binary = '' 25 + for (const byte of bytes) { 26 + binary += String.fromCharCode(byte) 27 + } 28 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') 29 + } 30 + 31 + async function generateCodeChallenge(verifier: string): Promise<string> { 32 + const hash = await sha256(verifier) 33 + return base64UrlEncode(hash) 34 + } 35 + 36 + function generateState(): string { 37 + return generateRandomString(32) 38 + } 39 + 40 + function generateCodeVerifier(): string { 41 + return generateRandomString(32) 42 + } 43 + 44 + function saveOAuthState(state: OAuthState): void { 45 + sessionStorage.setItem(OAUTH_STATE_KEY, state.state) 46 + sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier) 47 + } 48 + 49 + function getOAuthState(): OAuthState | null { 50 + const state = sessionStorage.getItem(OAUTH_STATE_KEY) 51 + const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY) 52 + if (!state || !codeVerifier) return null 53 + return { state, codeVerifier } 54 + } 55 + 56 + function clearOAuthState(): void { 57 + sessionStorage.removeItem(OAUTH_STATE_KEY) 58 + sessionStorage.removeItem(OAUTH_VERIFIER_KEY) 59 + } 60 + 61 + export async function startOAuthLogin(): Promise<void> { 62 + const state = generateState() 63 + const codeVerifier = generateCodeVerifier() 64 + const codeChallenge = await generateCodeChallenge(codeVerifier) 65 + 66 + saveOAuthState({ state, codeVerifier }) 67 + 68 + const clientId = `${window.location.origin}/oauth/client-metadata.json` 69 + const redirectUri = `${window.location.origin}/` 70 + 71 + const parResponse = await fetch('/oauth/par', { 72 + method: 'POST', 73 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 74 + body: new URLSearchParams({ 75 + client_id: clientId, 76 + redirect_uri: redirectUri, 77 + response_type: 'code', 78 + scope: 'atproto transition:generic', 79 + state: state, 80 + code_challenge: codeChallenge, 81 + code_challenge_method: 'S256', 82 + }), 83 + }) 84 + 85 + if (!parResponse.ok) { 86 + const error = await parResponse.json().catch(() => ({ error: 'Unknown error' })) 87 + throw new Error(error.error_description || error.error || 'Failed to start OAuth flow') 88 + } 89 + 90 + const { request_uri } = await parResponse.json() 91 + 92 + const authorizeUrl = new URL('/oauth/authorize', window.location.origin) 93 + authorizeUrl.searchParams.set('client_id', clientId) 94 + authorizeUrl.searchParams.set('request_uri', request_uri) 95 + 96 + window.location.href = authorizeUrl.toString() 97 + } 98 + 99 + export interface OAuthTokens { 100 + access_token: string 101 + refresh_token?: string 102 + token_type: string 103 + expires_in?: number 104 + scope?: string 105 + sub: string 106 + } 107 + 108 + export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> { 109 + const savedState = getOAuthState() 110 + if (!savedState) { 111 + throw new Error('No OAuth state found. Please try logging in again.') 112 + } 113 + 114 + if (savedState.state !== state) { 115 + clearOAuthState() 116 + throw new Error('OAuth state mismatch. Please try logging in again.') 117 + } 118 + 119 + const clientId = `${window.location.origin}/oauth/client-metadata.json` 120 + const redirectUri = `${window.location.origin}/` 121 + 122 + const tokenResponse = await fetch('/oauth/token', { 123 + method: 'POST', 124 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 125 + body: new URLSearchParams({ 126 + grant_type: 'authorization_code', 127 + client_id: clientId, 128 + code: code, 129 + redirect_uri: redirectUri, 130 + code_verifier: savedState.codeVerifier, 131 + }), 132 + }) 133 + 134 + clearOAuthState() 135 + 136 + if (!tokenResponse.ok) { 137 + const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' })) 138 + throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens') 139 + } 140 + 141 + return tokenResponse.json() 142 + } 143 + 144 + export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> { 145 + const clientId = `${window.location.origin}/oauth/client-metadata.json` 146 + 147 + const tokenResponse = await fetch('/oauth/token', { 148 + method: 'POST', 149 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 150 + body: new URLSearchParams({ 151 + grant_type: 'refresh_token', 152 + client_id: clientId, 153 + refresh_token: refreshToken, 154 + }), 155 + }) 156 + 157 + if (!tokenResponse.ok) { 158 + const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' })) 159 + throw new Error(error.error_description || error.error || 'Failed to refresh token') 160 + } 161 + 162 + return tokenResponse.json() 163 + } 164 + 165 + export function checkForOAuthCallback(): { code: string; state: string } | null { 166 + const params = new URLSearchParams(window.location.search) 167 + const code = params.get('code') 168 + const state = params.get('state') 169 + 170 + if (code && state) { 171 + return { code, state } 172 + } 173 + 174 + return null 175 + } 176 + 177 + export function clearOAuthCallbackParams(): void { 178 + const url = new URL(window.location.href) 179 + url.search = '' 180 + window.history.replaceState({}, '', url.toString()) 181 + }
+744
frontend/src/routes/Admin.svelte
···
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { api, ApiError } from '../lib/api' 5 + const auth = getAuthState() 6 + let loading = $state(true) 7 + let error = $state<string | null>(null) 8 + let stats = $state<{ 9 + userCount: number 10 + repoCount: number 11 + recordCount: number 12 + blobStorageBytes: number 13 + } | null>(null) 14 + let usersLoading = $state(false) 15 + let usersError = $state<string | null>(null) 16 + let users = $state<Array<{ 17 + did: string 18 + handle: string 19 + email?: string 20 + indexedAt: string 21 + emailConfirmedAt?: string 22 + deactivatedAt?: string 23 + }>>([]) 24 + let usersCursor = $state<string | undefined>(undefined) 25 + let handleSearchQuery = $state('') 26 + let showUsers = $state(false) 27 + let invitesLoading = $state(false) 28 + let invitesError = $state<string | null>(null) 29 + let invites = $state<Array<{ 30 + code: string 31 + available: number 32 + disabled: boolean 33 + forAccount: string 34 + createdBy: string 35 + createdAt: string 36 + uses: Array<{ usedBy: string; usedAt: string }> 37 + }>>([]) 38 + let invitesCursor = $state<string | undefined>(undefined) 39 + let showInvites = $state(false) 40 + let selectedUser = $state<{ 41 + did: string 42 + handle: string 43 + email?: string 44 + indexedAt: string 45 + emailConfirmedAt?: string 46 + invitesDisabled?: boolean 47 + deactivatedAt?: string 48 + } | null>(null) 49 + let userDetailLoading = $state(false) 50 + let userActionLoading = $state(false) 51 + $effect(() => { 52 + if (!auth.loading && !auth.session) { 53 + navigate('/login') 54 + } else if (!auth.loading && auth.session && !auth.session.isAdmin) { 55 + navigate('/dashboard') 56 + } 57 + }) 58 + $effect(() => { 59 + if (auth.session?.isAdmin) { 60 + loadStats() 61 + } 62 + }) 63 + async function loadStats() { 64 + if (!auth.session) return 65 + loading = true 66 + error = null 67 + try { 68 + stats = await api.getServerStats(auth.session.accessJwt) 69 + } catch (e) { 70 + error = e instanceof ApiError ? e.message : 'Failed to load server stats' 71 + } finally { 72 + loading = false 73 + } 74 + } 75 + async function loadUsers(reset = false) { 76 + if (!auth.session) return 77 + usersLoading = true 78 + usersError = null 79 + if (reset) { 80 + users = [] 81 + usersCursor = undefined 82 + } 83 + try { 84 + const result = await api.searchAccounts(auth.session.accessJwt, { 85 + handle: handleSearchQuery || undefined, 86 + cursor: reset ? undefined : usersCursor, 87 + limit: 25, 88 + }) 89 + users = reset ? result.accounts : [...users, ...result.accounts] 90 + usersCursor = result.cursor 91 + showUsers = true 92 + } catch (e) { 93 + usersError = e instanceof ApiError ? e.message : 'Failed to load users' 94 + } finally { 95 + usersLoading = false 96 + } 97 + } 98 + function handleSearch(e: Event) { 99 + e.preventDefault() 100 + loadUsers(true) 101 + } 102 + async function loadInvites(reset = false) { 103 + if (!auth.session) return 104 + invitesLoading = true 105 + invitesError = null 106 + if (reset) { 107 + invites = [] 108 + invitesCursor = undefined 109 + } 110 + try { 111 + const result = await api.getInviteCodes(auth.session.accessJwt, { 112 + cursor: reset ? undefined : invitesCursor, 113 + limit: 25, 114 + }) 115 + invites = reset ? result.codes : [...invites, ...result.codes] 116 + invitesCursor = result.cursor 117 + showInvites = true 118 + } catch (e) { 119 + invitesError = e instanceof ApiError ? e.message : 'Failed to load invites' 120 + } finally { 121 + invitesLoading = false 122 + } 123 + } 124 + async function disableInvite(code: string) { 125 + if (!auth.session) return 126 + if (!confirm(`Disable invite code ${code}?`)) return 127 + try { 128 + await api.disableInviteCodes(auth.session.accessJwt, [code]) 129 + invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv) 130 + } catch (e) { 131 + invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite' 132 + } 133 + } 134 + async function selectUser(did: string) { 135 + if (!auth.session) return 136 + userDetailLoading = true 137 + try { 138 + selectedUser = await api.getAccountInfo(auth.session.accessJwt, did) 139 + } catch (e) { 140 + usersError = e instanceof ApiError ? e.message : 'Failed to load user details' 141 + } finally { 142 + userDetailLoading = false 143 + } 144 + } 145 + function closeUserDetail() { 146 + selectedUser = null 147 + } 148 + async function toggleUserInvites() { 149 + if (!auth.session || !selectedUser) return 150 + userActionLoading = true 151 + try { 152 + if (selectedUser.invitesDisabled) { 153 + await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did) 154 + selectedUser = { ...selectedUser, invitesDisabled: false } 155 + } else { 156 + await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did) 157 + selectedUser = { ...selectedUser, invitesDisabled: true } 158 + } 159 + } catch (e) { 160 + usersError = e instanceof ApiError ? e.message : 'Failed to update user' 161 + } finally { 162 + userActionLoading = false 163 + } 164 + } 165 + async function deleteUser() { 166 + if (!auth.session || !selectedUser) return 167 + if (!confirm(`Delete account @${selectedUser.handle}? This cannot be undone.`)) return 168 + userActionLoading = true 169 + try { 170 + await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did) 171 + users = users.filter(u => u.did !== selectedUser!.did) 172 + selectedUser = null 173 + } catch (e) { 174 + usersError = e instanceof ApiError ? e.message : 'Failed to delete user' 175 + } finally { 176 + userActionLoading = false 177 + } 178 + } 179 + function formatBytes(bytes: number): string { 180 + if (bytes === 0) return '0 B' 181 + const k = 1024 182 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 183 + const i = Math.floor(Math.log(bytes) / Math.log(k)) 184 + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` 185 + } 186 + function formatNumber(num: number): string { 187 + return num.toLocaleString() 188 + } 189 + </script> 190 + {#if auth.session?.isAdmin} 191 + <div class="page"> 192 + <header> 193 + <a href="#/dashboard" class="back">&larr; Dashboard</a> 194 + <h1>Admin Panel</h1> 195 + </header> 196 + {#if loading} 197 + <p class="loading">Loading...</p> 198 + {:else} 199 + {#if error} 200 + <div class="message error">{error}</div> 201 + {/if} 202 + {#if stats} 203 + <section> 204 + <h2>Server Statistics</h2> 205 + <div class="stats-grid"> 206 + <div class="stat-card"> 207 + <div class="stat-value">{formatNumber(stats.userCount)}</div> 208 + <div class="stat-label">Users</div> 209 + </div> 210 + <div class="stat-card"> 211 + <div class="stat-value">{formatNumber(stats.repoCount)}</div> 212 + <div class="stat-label">Repositories</div> 213 + </div> 214 + <div class="stat-card"> 215 + <div class="stat-value">{formatNumber(stats.recordCount)}</div> 216 + <div class="stat-label">Records</div> 217 + </div> 218 + <div class="stat-card"> 219 + <div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div> 220 + <div class="stat-label">Blob Storage</div> 221 + </div> 222 + </div> 223 + <button class="refresh-btn" onclick={loadStats}>Refresh Stats</button> 224 + </section> 225 + {/if} 226 + <section> 227 + <h2>User Management</h2> 228 + <form class="search-form" onsubmit={handleSearch}> 229 + <input 230 + type="text" 231 + bind:value={handleSearchQuery} 232 + placeholder="Search by handle (optional)" 233 + disabled={usersLoading} 234 + /> 235 + <button type="submit" disabled={usersLoading}> 236 + {usersLoading ? 'Loading...' : 'Search Users'} 237 + </button> 238 + </form> 239 + {#if usersError} 240 + <div class="message error">{usersError}</div> 241 + {/if} 242 + {#if showUsers} 243 + <div class="user-list"> 244 + {#if users.length === 0} 245 + <p class="no-results">No users found</p> 246 + {:else} 247 + <table> 248 + <thead> 249 + <tr> 250 + <th>Handle</th> 251 + <th>Email</th> 252 + <th>Status</th> 253 + <th>Created</th> 254 + </tr> 255 + </thead> 256 + <tbody> 257 + {#each users as user} 258 + <tr class="clickable" onclick={() => selectUser(user.did)}> 259 + <td class="handle">@{user.handle}</td> 260 + <td class="email">{user.email || '-'}</td> 261 + <td> 262 + {#if user.deactivatedAt} 263 + <span class="badge deactivated">Deactivated</span> 264 + {:else if user.emailConfirmedAt} 265 + <span class="badge verified">Verified</span> 266 + {:else} 267 + <span class="badge unverified">Unverified</span> 268 + {/if} 269 + </td> 270 + <td class="date">{new Date(user.indexedAt).toLocaleDateString()}</td> 271 + </tr> 272 + {/each} 273 + </tbody> 274 + </table> 275 + {#if usersCursor} 276 + <button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}> 277 + {usersLoading ? 'Loading...' : 'Load More'} 278 + </button> 279 + {/if} 280 + {/if} 281 + </div> 282 + {/if} 283 + </section> 284 + <section> 285 + <h2>Invite Codes</h2> 286 + <div class="section-actions"> 287 + <button onclick={() => loadInvites(true)} disabled={invitesLoading}> 288 + {invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'} 289 + </button> 290 + </div> 291 + {#if invitesError} 292 + <div class="message error">{invitesError}</div> 293 + {/if} 294 + {#if showInvites} 295 + <div class="invite-list"> 296 + {#if invites.length === 0} 297 + <p class="no-results">No invite codes found</p> 298 + {:else} 299 + <table> 300 + <thead> 301 + <tr> 302 + <th>Code</th> 303 + <th>Available</th> 304 + <th>Uses</th> 305 + <th>Status</th> 306 + <th>Created</th> 307 + <th>Actions</th> 308 + </tr> 309 + </thead> 310 + <tbody> 311 + {#each invites as invite} 312 + <tr class:disabled-row={invite.disabled}> 313 + <td class="code">{invite.code}</td> 314 + <td>{invite.available}</td> 315 + <td>{invite.uses.length}</td> 316 + <td> 317 + {#if invite.disabled} 318 + <span class="badge deactivated">Disabled</span> 319 + {:else if invite.available === 0} 320 + <span class="badge unverified">Exhausted</span> 321 + {:else} 322 + <span class="badge verified">Active</span> 323 + {/if} 324 + </td> 325 + <td class="date">{new Date(invite.createdAt).toLocaleDateString()}</td> 326 + <td> 327 + {#if !invite.disabled} 328 + <button class="action-btn danger" onclick={() => disableInvite(invite.code)}> 329 + Disable 330 + </button> 331 + {:else} 332 + <span class="muted">-</span> 333 + {/if} 334 + </td> 335 + </tr> 336 + {/each} 337 + </tbody> 338 + </table> 339 + {#if invitesCursor} 340 + <button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}> 341 + {invitesLoading ? 'Loading...' : 'Load More'} 342 + </button> 343 + {/if} 344 + {/if} 345 + </div> 346 + {/if} 347 + </section> 348 + {/if} 349 + </div> 350 + {#if selectedUser} 351 + <div class="modal-overlay" onclick={closeUserDetail} role="presentation"> 352 + <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true"> 353 + <div class="modal-header"> 354 + <h2>User Details</h2> 355 + <button class="close-btn" onclick={closeUserDetail}>&times;</button> 356 + </div> 357 + {#if userDetailLoading} 358 + <p class="loading">Loading...</p> 359 + {:else} 360 + <div class="modal-body"> 361 + <dl class="user-details"> 362 + <dt>Handle</dt> 363 + <dd>@{selectedUser.handle}</dd> 364 + <dt>DID</dt> 365 + <dd class="mono">{selectedUser.did}</dd> 366 + <dt>Email</dt> 367 + <dd>{selectedUser.email || '-'}</dd> 368 + <dt>Status</dt> 369 + <dd> 370 + {#if selectedUser.deactivatedAt} 371 + <span class="badge deactivated">Deactivated</span> 372 + {:else if selectedUser.emailConfirmedAt} 373 + <span class="badge verified">Verified</span> 374 + {:else} 375 + <span class="badge unverified">Unverified</span> 376 + {/if} 377 + </dd> 378 + <dt>Created</dt> 379 + <dd>{new Date(selectedUser.indexedAt).toLocaleString()}</dd> 380 + <dt>Invites</dt> 381 + <dd> 382 + {#if selectedUser.invitesDisabled} 383 + <span class="badge deactivated">Disabled</span> 384 + {:else} 385 + <span class="badge verified">Enabled</span> 386 + {/if} 387 + </dd> 388 + </dl> 389 + <div class="modal-actions"> 390 + <button 391 + class="action-btn" 392 + onclick={toggleUserInvites} 393 + disabled={userActionLoading} 394 + > 395 + {selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'} 396 + </button> 397 + <button 398 + class="action-btn danger" 399 + onclick={deleteUser} 400 + disabled={userActionLoading} 401 + > 402 + Delete Account 403 + </button> 404 + </div> 405 + </div> 406 + {/if} 407 + </div> 408 + </div> 409 + {/if} 410 + {:else if auth.loading} 411 + <div class="loading">Loading...</div> 412 + {/if} 413 + <style> 414 + .page { 415 + max-width: 800px; 416 + margin: 0 auto; 417 + padding: 2rem; 418 + } 419 + header { 420 + margin-bottom: 2rem; 421 + } 422 + .back { 423 + color: var(--text-secondary); 424 + text-decoration: none; 425 + font-size: 0.875rem; 426 + } 427 + .back:hover { 428 + color: var(--accent); 429 + } 430 + h1 { 431 + margin: 0.5rem 0 0 0; 432 + } 433 + .loading { 434 + text-align: center; 435 + color: var(--text-secondary); 436 + padding: 2rem; 437 + } 438 + .message { 439 + padding: 0.75rem; 440 + border-radius: 4px; 441 + margin-bottom: 1rem; 442 + } 443 + .message.error { 444 + background: var(--error-bg); 445 + border: 1px solid var(--error-border); 446 + color: var(--error-text); 447 + } 448 + section { 449 + background: var(--bg-secondary); 450 + padding: 1.5rem; 451 + border-radius: 8px; 452 + margin-bottom: 1.5rem; 453 + } 454 + section h2 { 455 + margin: 0 0 1rem 0; 456 + font-size: 1.25rem; 457 + } 458 + .stats-grid { 459 + display: grid; 460 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 461 + gap: 1rem; 462 + margin-bottom: 1rem; 463 + } 464 + .stat-card { 465 + background: var(--bg-card); 466 + border: 1px solid var(--border-color); 467 + border-radius: 8px; 468 + padding: 1rem; 469 + text-align: center; 470 + } 471 + .stat-value { 472 + font-size: 1.5rem; 473 + font-weight: 600; 474 + color: var(--accent); 475 + } 476 + .stat-label { 477 + font-size: 0.875rem; 478 + color: var(--text-secondary); 479 + margin-top: 0.25rem; 480 + } 481 + .refresh-btn { 482 + padding: 0.5rem 1rem; 483 + background: transparent; 484 + border: 1px solid var(--border-color); 485 + border-radius: 4px; 486 + cursor: pointer; 487 + color: var(--text-primary); 488 + } 489 + .refresh-btn:hover { 490 + background: var(--bg-card); 491 + border-color: var(--accent); 492 + } 493 + .search-form { 494 + display: flex; 495 + gap: 0.5rem; 496 + margin-bottom: 1rem; 497 + } 498 + .search-form input { 499 + flex: 1; 500 + padding: 0.5rem 0.75rem; 501 + border: 1px solid var(--border-color); 502 + border-radius: 4px; 503 + font-size: 0.875rem; 504 + background: var(--bg-input); 505 + color: var(--text-primary); 506 + } 507 + .search-form input:focus { 508 + outline: none; 509 + border-color: var(--accent); 510 + } 511 + .search-form button { 512 + padding: 0.5rem 1rem; 513 + background: var(--accent); 514 + color: white; 515 + border: none; 516 + border-radius: 4px; 517 + cursor: pointer; 518 + font-size: 0.875rem; 519 + } 520 + .search-form button:hover:not(:disabled) { 521 + background: var(--accent-hover); 522 + } 523 + .search-form button:disabled { 524 + opacity: 0.6; 525 + cursor: not-allowed; 526 + } 527 + .user-list { 528 + margin-top: 1rem; 529 + } 530 + .no-results { 531 + color: var(--text-secondary); 532 + text-align: center; 533 + padding: 1rem; 534 + } 535 + table { 536 + width: 100%; 537 + border-collapse: collapse; 538 + font-size: 0.875rem; 539 + } 540 + th, td { 541 + padding: 0.75rem 0.5rem; 542 + text-align: left; 543 + border-bottom: 1px solid var(--border-color); 544 + } 545 + th { 546 + font-weight: 600; 547 + color: var(--text-secondary); 548 + font-size: 0.75rem; 549 + text-transform: uppercase; 550 + letter-spacing: 0.05em; 551 + } 552 + .handle { 553 + font-weight: 500; 554 + } 555 + .email { 556 + color: var(--text-secondary); 557 + } 558 + .date { 559 + color: var(--text-secondary); 560 + font-size: 0.75rem; 561 + } 562 + .badge { 563 + display: inline-block; 564 + padding: 0.125rem 0.5rem; 565 + border-radius: 4px; 566 + font-size: 0.75rem; 567 + } 568 + .badge.verified { 569 + background: var(--success-bg); 570 + color: var(--success-text); 571 + } 572 + .badge.unverified { 573 + background: var(--warning-bg); 574 + color: var(--warning-text); 575 + } 576 + .badge.deactivated { 577 + background: var(--error-bg); 578 + color: var(--error-text); 579 + } 580 + .load-more { 581 + display: block; 582 + width: 100%; 583 + padding: 0.75rem; 584 + margin-top: 1rem; 585 + background: transparent; 586 + border: 1px solid var(--border-color); 587 + border-radius: 4px; 588 + cursor: pointer; 589 + color: var(--text-primary); 590 + font-size: 0.875rem; 591 + } 592 + .load-more:hover:not(:disabled) { 593 + background: var(--bg-card); 594 + border-color: var(--accent); 595 + } 596 + .load-more:disabled { 597 + opacity: 0.6; 598 + cursor: not-allowed; 599 + } 600 + .section-actions { 601 + margin-bottom: 1rem; 602 + } 603 + .section-actions button { 604 + padding: 0.5rem 1rem; 605 + background: var(--accent); 606 + color: white; 607 + border: none; 608 + border-radius: 4px; 609 + cursor: pointer; 610 + font-size: 0.875rem; 611 + } 612 + .section-actions button:hover:not(:disabled) { 613 + background: var(--accent-hover); 614 + } 615 + .section-actions button:disabled { 616 + opacity: 0.6; 617 + cursor: not-allowed; 618 + } 619 + .invite-list { 620 + margin-top: 1rem; 621 + } 622 + .code { 623 + font-family: monospace; 624 + font-size: 0.75rem; 625 + } 626 + .disabled-row { 627 + opacity: 0.5; 628 + } 629 + .action-btn { 630 + padding: 0.25rem 0.5rem; 631 + font-size: 0.75rem; 632 + border: none; 633 + border-radius: 4px; 634 + cursor: pointer; 635 + } 636 + .action-btn.danger { 637 + background: var(--error-text); 638 + color: white; 639 + } 640 + .action-btn.danger:hover { 641 + background: #900; 642 + } 643 + .muted { 644 + color: var(--text-muted); 645 + } 646 + .clickable { 647 + cursor: pointer; 648 + } 649 + .clickable:hover { 650 + background: var(--bg-card); 651 + } 652 + .modal-overlay { 653 + position: fixed; 654 + top: 0; 655 + left: 0; 656 + right: 0; 657 + bottom: 0; 658 + background: rgba(0, 0, 0, 0.5); 659 + display: flex; 660 + align-items: center; 661 + justify-content: center; 662 + z-index: 1000; 663 + } 664 + .modal { 665 + background: var(--bg-card); 666 + border-radius: 8px; 667 + max-width: 500px; 668 + width: 90%; 669 + max-height: 90vh; 670 + overflow-y: auto; 671 + } 672 + .modal-header { 673 + display: flex; 674 + justify-content: space-between; 675 + align-items: center; 676 + padding: 1rem 1.5rem; 677 + border-bottom: 1px solid var(--border-color); 678 + } 679 + .modal-header h2 { 680 + margin: 0; 681 + font-size: 1.25rem; 682 + } 683 + .close-btn { 684 + background: none; 685 + border: none; 686 + font-size: 1.5rem; 687 + cursor: pointer; 688 + color: var(--text-secondary); 689 + padding: 0; 690 + line-height: 1; 691 + } 692 + .close-btn:hover { 693 + color: var(--text-primary); 694 + } 695 + .modal-body { 696 + padding: 1.5rem; 697 + } 698 + .user-details { 699 + display: grid; 700 + grid-template-columns: auto 1fr; 701 + gap: 0.5rem 1rem; 702 + margin: 0 0 1.5rem 0; 703 + } 704 + .user-details dt { 705 + font-weight: 500; 706 + color: var(--text-secondary); 707 + } 708 + .user-details dd { 709 + margin: 0; 710 + } 711 + .mono { 712 + font-family: monospace; 713 + font-size: 0.75rem; 714 + word-break: break-all; 715 + } 716 + .modal-actions { 717 + display: flex; 718 + gap: 0.5rem; 719 + flex-wrap: wrap; 720 + } 721 + .modal-actions .action-btn { 722 + padding: 0.5rem 1rem; 723 + border: 1px solid var(--border-color); 724 + border-radius: 4px; 725 + background: transparent; 726 + cursor: pointer; 727 + font-size: 0.875rem; 728 + color: var(--text-primary); 729 + } 730 + .modal-actions .action-btn:hover:not(:disabled) { 731 + background: var(--bg-secondary); 732 + } 733 + .modal-actions .action-btn:disabled { 734 + opacity: 0.6; 735 + cursor: not-allowed; 736 + } 737 + .modal-actions .action-btn.danger { 738 + border-color: var(--error-text); 739 + color: var(--error-text); 740 + } 741 + .modal-actions .action-btn.danger:hover:not(:disabled) { 742 + background: var(--error-bg); 743 + } 744 + </style>
+160 -5
frontend/src/routes/Dashboard.svelte
··· 1 <script lang="ts"> 2 - import { getAuthState, logout } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 const auth = getAuthState() 5 $effect(() => { 6 if (!auth.loading && !auth.session) { 7 navigate('/login') ··· 11 await logout() 12 navigate('/login') 13 } 14 </script> 15 {#if auth.session} 16 <div class="dashboard"> 17 <header> 18 <h1>Dashboard</h1> 19 - <button class="logout" onclick={handleLogout}>Sign Out</button> 20 </header> 21 <section class="account-overview"> 22 <h2>Account Overview</h2> 23 <dl> 24 <dt>Handle</dt> 25 - <dd>@{auth.session.handle}</dd> 26 <dt>DID</dt> 27 <dd class="mono">{auth.session.did}</dd> 28 {#if auth.session.preferredChannel} ··· 63 <h3>App Passwords</h3> 64 <p>Manage passwords for third-party apps</p> 65 </a> 66 <a href="#/invite-codes" class="nav-card"> 67 <h3>Invite Codes</h3> 68 <p>View and create invite codes</p> ··· 79 <h3>Repository Explorer</h3> 80 <p>Browse and manage raw AT Protocol records</p> 81 </a> 82 </nav> 83 </div> 84 {:else if auth.loading} ··· 99 header h1 { 100 margin: 0; 101 } 102 - .logout { 103 padding: 0.5rem 1rem; 104 background: transparent; 105 border: 1px solid var(--border-color-light); ··· 107 cursor: pointer; 108 color: var(--text-primary); 109 } 110 - .logout:hover { 111 background: var(--bg-secondary); 112 } 113 section { 114 background: var(--bg-secondary); 115 padding: 1.5rem; ··· 153 background: var(--warning-bg); 154 color: var(--warning-text); 155 } 156 .nav-grid { 157 display: grid; 158 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); ··· 180 margin: 0; 181 color: var(--text-secondary); 182 font-size: 0.875rem; 183 } 184 .loading { 185 text-align: center;
··· 1 <script lang="ts"> 2 + import { getAuthState, logout, switchAccount } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 const auth = getAuthState() 5 + let dropdownOpen = $state(false) 6 + let switching = $state(false) 7 $effect(() => { 8 if (!auth.loading && !auth.session) { 9 navigate('/login') ··· 13 await logout() 14 navigate('/login') 15 } 16 + async function handleSwitchAccount(did: string) { 17 + switching = true 18 + dropdownOpen = false 19 + try { 20 + await switchAccount(did) 21 + } catch { 22 + navigate('/login') 23 + } finally { 24 + switching = false 25 + } 26 + } 27 + function toggleDropdown() { 28 + dropdownOpen = !dropdownOpen 29 + } 30 + function closeDropdown(e: MouseEvent) { 31 + const target = e.target as HTMLElement 32 + if (!target.closest('.account-dropdown')) { 33 + dropdownOpen = false 34 + } 35 + } 36 + $effect(() => { 37 + if (dropdownOpen) { 38 + document.addEventListener('click', closeDropdown) 39 + return () => document.removeEventListener('click', closeDropdown) 40 + } 41 + }) 42 + let otherAccounts = $derived( 43 + auth.savedAccounts.filter(a => a.did !== auth.session?.did) 44 + ) 45 </script> 46 {#if auth.session} 47 <div class="dashboard"> 48 <header> 49 <h1>Dashboard</h1> 50 + <div class="account-dropdown"> 51 + <button class="account-trigger" onclick={toggleDropdown} disabled={switching}> 52 + <span class="account-handle">@{auth.session.handle}</span> 53 + <span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span> 54 + </button> 55 + {#if dropdownOpen} 56 + <div class="dropdown-menu"> 57 + {#if otherAccounts.length > 0} 58 + <div class="dropdown-section"> 59 + <span class="dropdown-label">Switch Account</span> 60 + {#each otherAccounts as account} 61 + <button 62 + type="button" 63 + class="dropdown-item" 64 + onclick={() => handleSwitchAccount(account.did)} 65 + > 66 + @{account.handle} 67 + </button> 68 + {/each} 69 + </div> 70 + <div class="dropdown-divider"></div> 71 + {/if} 72 + <button 73 + type="button" 74 + class="dropdown-item" 75 + onclick={() => { dropdownOpen = false; navigate('/login') }} 76 + > 77 + Add another account 78 + </button> 79 + <div class="dropdown-divider"></div> 80 + <button type="button" class="dropdown-item logout-item" onclick={handleLogout}> 81 + Sign out @{auth.session.handle} 82 + </button> 83 + </div> 84 + {/if} 85 + </div> 86 </header> 87 <section class="account-overview"> 88 <h2>Account Overview</h2> 89 <dl> 90 <dt>Handle</dt> 91 + <dd> 92 + @{auth.session.handle} 93 + {#if auth.session.isAdmin} 94 + <span class="badge admin">Admin</span> 95 + {/if} 96 + </dd> 97 <dt>DID</dt> 98 <dd class="mono">{auth.session.did}</dd> 99 {#if auth.session.preferredChannel} ··· 134 <h3>App Passwords</h3> 135 <p>Manage passwords for third-party apps</p> 136 </a> 137 + <a href="#/sessions" class="nav-card"> 138 + <h3>Active Sessions</h3> 139 + <p>View and manage your login sessions</p> 140 + </a> 141 <a href="#/invite-codes" class="nav-card"> 142 <h3>Invite Codes</h3> 143 <p>View and create invite codes</p> ··· 154 <h3>Repository Explorer</h3> 155 <p>Browse and manage raw AT Protocol records</p> 156 </a> 157 + {#if auth.session.isAdmin} 158 + <a href="#/admin" class="nav-card admin-card"> 159 + <h3>Admin Panel</h3> 160 + <p>Server stats and admin operations</p> 161 + </a> 162 + {/if} 163 </nav> 164 </div> 165 {:else if auth.loading} ··· 180 header h1 { 181 margin: 0; 182 } 183 + .account-dropdown { 184 + position: relative; 185 + } 186 + .account-trigger { 187 + display: flex; 188 + align-items: center; 189 + gap: 0.5rem; 190 padding: 0.5rem 1rem; 191 background: transparent; 192 border: 1px solid var(--border-color-light); ··· 194 cursor: pointer; 195 color: var(--text-primary); 196 } 197 + .account-trigger:hover:not(:disabled) { 198 background: var(--bg-secondary); 199 } 200 + .account-trigger:disabled { 201 + opacity: 0.6; 202 + cursor: not-allowed; 203 + } 204 + .account-trigger .account-handle { 205 + font-weight: 500; 206 + } 207 + .dropdown-arrow { 208 + font-size: 0.625rem; 209 + color: var(--text-secondary); 210 + } 211 + .dropdown-menu { 212 + position: absolute; 213 + top: 100%; 214 + right: 0; 215 + margin-top: 0.25rem; 216 + min-width: 200px; 217 + background: var(--bg-card); 218 + border: 1px solid var(--border-color); 219 + border-radius: 8px; 220 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 221 + z-index: 100; 222 + overflow: hidden; 223 + } 224 + .dropdown-section { 225 + padding: 0.5rem 0; 226 + } 227 + .dropdown-label { 228 + display: block; 229 + padding: 0.25rem 1rem; 230 + font-size: 0.75rem; 231 + color: var(--text-muted); 232 + text-transform: uppercase; 233 + letter-spacing: 0.05em; 234 + } 235 + .dropdown-item { 236 + display: block; 237 + width: 100%; 238 + padding: 0.75rem 1rem; 239 + background: transparent; 240 + border: none; 241 + text-align: left; 242 + cursor: pointer; 243 + color: var(--text-primary); 244 + font-size: 0.875rem; 245 + } 246 + .dropdown-item:hover { 247 + background: var(--bg-secondary); 248 + } 249 + .dropdown-item.logout-item { 250 + color: var(--error-text); 251 + } 252 + .dropdown-divider { 253 + height: 1px; 254 + background: var(--border-color); 255 + margin: 0; 256 + } 257 section { 258 background: var(--bg-secondary); 259 padding: 1.5rem; ··· 297 background: var(--warning-bg); 298 color: var(--warning-text); 299 } 300 + .badge.admin { 301 + background: var(--accent); 302 + color: white; 303 + } 304 .nav-grid { 305 display: grid; 306 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); ··· 328 margin: 0; 329 color: var(--text-secondary); 330 font-size: 0.875rem; 331 + } 332 + .nav-card.admin-card { 333 + border-color: var(--accent); 334 + background: linear-gradient(135deg, var(--bg-card) 0%, rgba(77, 166, 255, 0.05) 100%); 335 + } 336 + .nav-card.admin-card:hover { 337 + box-shadow: 0 2px 12px rgba(77, 166, 255, 0.25); 338 } 339 .loading { 340 text-align: center;
+149 -59
frontend/src/routes/Login.svelte
··· 1 <script lang="ts"> 2 - import { login, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 - import { ApiError } from '../lib/api' 5 - let identifier = $state('') 6 - let password = $state('') 7 let submitting = $state(false) 8 - let error = $state<string | null>(null) 9 let pendingVerification = $state<{ did: string } | null>(null) 10 let verificationCode = $state('') 11 let resendingCode = $state(false) 12 let resendMessage = $state<string | null>(null) 13 const auth = getAuthState() 14 $effect(() => { 15 if (auth.session) { 16 navigate('/dashboard') 17 } 18 }) 19 - async function handleSubmit(e: Event) { 20 - e.preventDefault() 21 - if (!identifier || !password) return 22 submitting = true 23 - error = null 24 - pendingVerification = null 25 try { 26 - await login(identifier, password) 27 navigate('/dashboard') 28 - } catch (e: any) { 29 - if (e instanceof ApiError && e.error === 'AccountNotVerified') { 30 - if (e.did) { 31 - pendingVerification = { did: e.did } 32 - } else { 33 - error = 'Account not verified. Please check your verification method for a code.' 34 - } 35 - } else { 36 - error = e.message || 'Login failed' 37 - } 38 - } finally { 39 submitting = false 40 } 41 } ··· 43 e.preventDefault() 44 if (!pendingVerification || !verificationCode.trim()) return 45 submitting = true 46 - error = null 47 try { 48 await confirmSignup(pendingVerification.did, verificationCode.trim()) 49 navigate('/dashboard') 50 - } catch (e: any) { 51 - error = e.message || 'Verification failed' 52 - } finally { 53 submitting = false 54 } 55 } ··· 57 if (!pendingVerification || resendingCode) return 58 resendingCode = true 59 resendMessage = null 60 - error = null 61 try { 62 await resendVerification(pendingVerification.did) 63 resendMessage = 'Verification code resent!' 64 - } catch (e: any) { 65 - error = e.message || 'Failed to resend code' 66 } finally { 67 resendingCode = false 68 } ··· 70 function backToLogin() { 71 pendingVerification = null 72 verificationCode = '' 73 - error = null 74 resendMessage = null 75 } 76 </script> 77 <div class="login-container"> 78 - {#if error} 79 - <div class="error">{error}</div> 80 {/if} 81 {#if pendingVerification} 82 <h1>Verify Your Account</h1> ··· 111 Back to Login 112 </button> 113 </form> 114 {:else} 115 <h1>Sign In</h1> 116 <p class="subtitle">Sign in to manage your PDS account</p> 117 - <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}> 118 - <div class="field"> 119 - <label for="identifier">Handle or Email</label> 120 - <input 121 - id="identifier" 122 - type="text" 123 - bind:value={identifier} 124 - placeholder="you.bsky.social or you@example.com" 125 - disabled={submitting} 126 - required 127 - /> 128 - </div> 129 - <div class="field"> 130 - <label for="password">Password</label> 131 - <input 132 - id="password" 133 - type="password" 134 - bind:value={password} 135 - placeholder="Password" 136 - disabled={submitting} 137 - required 138 - /> 139 - </div> 140 - <button type="submit" disabled={submitting || !identifier || !password}> 141 - {submitting ? 'Signing in...' : 'Sign In'} 142 </button> 143 - </form> 144 <p class="register-link"> 145 Don't have an account? <a href="#/register">Create one</a> 146 </p> ··· 219 button.tertiary:hover:not(:disabled) { 220 color: var(--text-primary); 221 } 222 .error { 223 padding: 0.75rem; 224 background: var(--error-bg); ··· 233 border-radius: 4px; 234 color: var(--success-text); 235 } 236 .register-link { 237 text-align: center; 238 - margin-top: 1.5rem; 239 color: var(--text-secondary); 240 } 241 .register-link a { 242 color: var(--accent); 243 } 244 </style>
··· 1 <script lang="ts"> 2 + import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 let submitting = $state(false) 5 let pendingVerification = $state<{ did: string } | null>(null) 6 let verificationCode = $state('') 7 let resendingCode = $state(false) 8 let resendMessage = $state<string | null>(null) 9 + let showNewLogin = $state(false) 10 const auth = getAuthState() 11 $effect(() => { 12 if (auth.session) { 13 navigate('/dashboard') 14 } 15 }) 16 + async function handleSwitchAccount(did: string) { 17 submitting = true 18 try { 19 + await switchAccount(did) 20 navigate('/dashboard') 21 + } catch { 22 + submitting = false 23 + } 24 + } 25 + function handleForgetAccount(did: string, e: Event) { 26 + e.stopPropagation() 27 + forgetAccount(did) 28 + } 29 + async function handleOAuthLogin() { 30 + submitting = true 31 + try { 32 + await loginWithOAuth() 33 + } catch { 34 submitting = false 35 } 36 } ··· 38 e.preventDefault() 39 if (!pendingVerification || !verificationCode.trim()) return 40 submitting = true 41 try { 42 await confirmSignup(pendingVerification.did, verificationCode.trim()) 43 navigate('/dashboard') 44 + } catch { 45 submitting = false 46 } 47 } ··· 49 if (!pendingVerification || resendingCode) return 50 resendingCode = true 51 resendMessage = null 52 try { 53 await resendVerification(pendingVerification.did) 54 resendMessage = 'Verification code resent!' 55 + } catch { 56 + resendMessage = null 57 } finally { 58 resendingCode = false 59 } ··· 61 function backToLogin() { 62 pendingVerification = null 63 verificationCode = '' 64 resendMessage = null 65 } 66 </script> 67 <div class="login-container"> 68 + {#if auth.error} 69 + <div class="error">{auth.error}</div> 70 {/if} 71 {#if pendingVerification} 72 <h1>Verify Your Account</h1> ··· 101 Back to Login 102 </button> 103 </form> 104 + {:else if auth.savedAccounts.length > 0 && !showNewLogin} 105 + <h1>Sign In</h1> 106 + <p class="subtitle">Choose an account</p> 107 + <div class="saved-accounts"> 108 + {#each auth.savedAccounts as account} 109 + <div 110 + class="account-item" 111 + class:disabled={submitting} 112 + role="button" 113 + tabindex="0" 114 + onclick={() => !submitting && handleSwitchAccount(account.did)} 115 + onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)} 116 + > 117 + <div class="account-info"> 118 + <span class="account-handle">@{account.handle}</span> 119 + <span class="account-did">{account.did}</span> 120 + </div> 121 + <button 122 + type="button" 123 + class="forget-btn" 124 + onclick={(e) => handleForgetAccount(account.did, e)} 125 + title="Remove from saved accounts" 126 + > 127 + × 128 + </button> 129 + </div> 130 + {/each} 131 + </div> 132 + <button type="button" class="secondary add-account" onclick={() => showNewLogin = true}> 133 + Sign in to another account 134 + </button> 135 + <p class="register-link"> 136 + Don't have an account? <a href="#/register">Create one</a> 137 + </p> 138 {:else} 139 <h1>Sign In</h1> 140 <p class="subtitle">Sign in to manage your PDS account</p> 141 + {#if auth.savedAccounts.length > 0} 142 + <button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}> 143 + ← Back to saved accounts 144 </button> 145 + {/if} 146 + <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}> 147 + {submitting ? 'Redirecting...' : 'Sign In'} 148 + </button> 149 + <p class="forgot-link"> 150 + <a href="#/reset-password">Forgot password?</a> 151 + </p> 152 <p class="register-link"> 153 Don't have an account? <a href="#/register">Create one</a> 154 </p> ··· 227 button.tertiary:hover:not(:disabled) { 228 color: var(--text-primary); 229 } 230 + .oauth-btn { 231 + width: 100%; 232 + padding: 1rem; 233 + font-size: 1.125rem; 234 + font-weight: 500; 235 + } 236 .error { 237 padding: 0.75rem; 238 background: var(--error-bg); ··· 247 border-radius: 4px; 248 color: var(--success-text); 249 } 250 + .forgot-link { 251 + text-align: center; 252 + margin-top: 1rem; 253 + margin-bottom: 0; 254 + color: var(--text-secondary); 255 + } 256 + .forgot-link a { 257 + color: var(--accent); 258 + } 259 .register-link { 260 text-align: center; 261 + margin-top: 0.5rem; 262 color: var(--text-secondary); 263 } 264 .register-link a { 265 color: var(--accent); 266 + } 267 + .saved-accounts { 268 + display: flex; 269 + flex-direction: column; 270 + gap: 0.5rem; 271 + margin-bottom: 1rem; 272 + } 273 + .account-item { 274 + display: flex; 275 + align-items: center; 276 + justify-content: space-between; 277 + padding: 1rem; 278 + background: var(--bg-card); 279 + border: 1px solid var(--border-color); 280 + border-radius: 8px; 281 + cursor: pointer; 282 + text-align: left; 283 + width: 100%; 284 + transition: border-color 0.15s, box-shadow 0.15s; 285 + } 286 + .account-item:hover:not(.disabled) { 287 + border-color: var(--accent); 288 + box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15); 289 + } 290 + .account-item.disabled { 291 + opacity: 0.6; 292 + cursor: not-allowed; 293 + } 294 + .account-info { 295 + display: flex; 296 + flex-direction: column; 297 + gap: 0.25rem; 298 + } 299 + .account-handle { 300 + font-weight: 500; 301 + color: var(--text-primary); 302 + } 303 + .account-did { 304 + font-size: 0.75rem; 305 + color: var(--text-muted); 306 + font-family: monospace; 307 + overflow: hidden; 308 + text-overflow: ellipsis; 309 + max-width: 250px; 310 + } 311 + .forget-btn { 312 + padding: 0.25rem 0.5rem; 313 + background: transparent; 314 + border: none; 315 + color: var(--text-muted); 316 + cursor: pointer; 317 + font-size: 1.25rem; 318 + line-height: 1; 319 + border-radius: 4px; 320 + margin: 0; 321 + } 322 + .forget-btn:hover { 323 + background: var(--error-bg); 324 + color: var(--error-text); 325 + } 326 + .add-account { 327 + width: 100%; 328 + margin-bottom: 1rem; 329 + } 330 + .back-btn { 331 + margin-bottom: 1rem; 332 + padding: 0; 333 } 334 </style>
+269
frontend/src/routes/Notifications.svelte
··· 15 let telegramVerified = $state(false) 16 let signalNumber = $state('') 17 let signalVerified = $state(false) 18 $effect(() => { 19 if (!auth.loading && !auth.session) { 20 navigate('/login') ··· 66 saving = false 67 } 68 } 69 const channels = [ 70 { id: 'email', name: 'Email', description: 'Receive notifications via email' }, 71 { id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' }, ··· 77 if (channelId === 'discord') return !!discordId 78 if (channelId === 'telegram') return !!telegramUsername 79 if (channelId === 'signal') return !!signalNumber 80 return false 81 } 82 </script> ··· 157 <span class="status verified">Verified</span> 158 {:else} 159 <span class="status unverified">Not verified</span> 160 {/if} 161 {/if} 162 </div> 163 <p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p> 164 </div> 165 <div class="config-item"> 166 <label for="telegram">Telegram Username</label> ··· 177 <span class="status verified">Verified</span> 178 {:else} 179 <span class="status unverified">Not verified</span> 180 {/if} 181 {/if} 182 </div> 183 <p class="config-hint">Your Telegram username without the @ symbol</p> 184 </div> 185 <div class="config-item"> 186 <label for="signal">Signal Phone Number</label> ··· 197 <span class="status verified">Verified</span> 198 {:else} 199 <span class="status unverified">Not verified</span> 200 {/if} 201 {/if} 202 </div> 203 <p class="config-hint">Your Signal phone number with country code</p> 204 </div> 205 </div> 206 </section> 207 <div class="actions"> 208 <button type="submit" disabled={saving}> ··· 210 </button> 211 </div> 212 </form> 213 {/if} 214 </div> 215 <style> ··· 388 .actions button:disabled { 389 opacity: 0.6; 390 cursor: not-allowed; 391 } 392 </style>
··· 15 let telegramVerified = $state(false) 16 let signalNumber = $state('') 17 let signalVerified = $state(false) 18 + let verifyingChannel = $state<string | null>(null) 19 + let verificationCode = $state('') 20 + let verificationError = $state<string | null>(null) 21 + let verificationSuccess = $state<string | null>(null) 22 + let historyLoading = $state(false) 23 + let historyError = $state<string | null>(null) 24 + let notifications = $state<Array<{ 25 + createdAt: string 26 + channel: string 27 + notificationType: string 28 + status: string 29 + subject: string | null 30 + body: string 31 + }>>([]) 32 + let showHistory = $state(false) 33 $effect(() => { 34 if (!auth.loading && !auth.session) { 35 navigate('/login') ··· 81 saving = false 82 } 83 } 84 + async function handleVerify(channel: string) { 85 + if (!auth.session || !verificationCode) return 86 + verificationError = null 87 + verificationSuccess = null 88 + try { 89 + await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode) 90 + verificationSuccess = `${channel} verified successfully` 91 + verificationCode = '' 92 + verifyingChannel = null 93 + await loadPrefs() 94 + } catch (e) { 95 + verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel' 96 + } 97 + } 98 + async function loadHistory() { 99 + if (!auth.session) return 100 + historyLoading = true 101 + historyError = null 102 + try { 103 + const result = await api.getNotificationHistory(auth.session.accessJwt) 104 + notifications = result.notifications 105 + showHistory = true 106 + } catch (e) { 107 + historyError = e instanceof ApiError ? e.message : 'Failed to load notification history' 108 + } finally { 109 + historyLoading = false 110 + } 111 + } 112 + function formatDate(dateStr: string): string { 113 + return new Date(dateStr).toLocaleString() 114 + } 115 const channels = [ 116 { id: 'email', name: 'Email', description: 'Receive notifications via email' }, 117 { id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' }, ··· 123 if (channelId === 'discord') return !!discordId 124 if (channelId === 'telegram') return !!telegramUsername 125 if (channelId === 'signal') return !!signalNumber 126 + return false 127 + } 128 + function needsVerification(channelId: string): boolean { 129 + if (channelId === 'discord') return !!discordId && !discordVerified 130 + if (channelId === 'telegram') return !!telegramUsername && !telegramVerified 131 + if (channelId === 'signal') return !!signalNumber && !signalVerified 132 return false 133 } 134 </script> ··· 209 <span class="status verified">Verified</span> 210 {:else} 211 <span class="status unverified">Not verified</span> 212 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>Verify</button> 213 {/if} 214 {/if} 215 </div> 216 <p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p> 217 + {#if verifyingChannel === 'discord'} 218 + <div class="verify-form"> 219 + <input 220 + type="text" 221 + bind:value={verificationCode} 222 + placeholder="Enter verification code" 223 + maxlength="6" 224 + /> 225 + <button type="button" onclick={() => handleVerify('discord')}>Submit</button> 226 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button> 227 + </div> 228 + {/if} 229 </div> 230 <div class="config-item"> 231 <label for="telegram">Telegram Username</label> ··· 242 <span class="status verified">Verified</span> 243 {:else} 244 <span class="status unverified">Not verified</span> 245 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>Verify</button> 246 {/if} 247 {/if} 248 </div> 249 <p class="config-hint">Your Telegram username without the @ symbol</p> 250 + {#if verifyingChannel === 'telegram'} 251 + <div class="verify-form"> 252 + <input 253 + type="text" 254 + bind:value={verificationCode} 255 + placeholder="Enter verification code" 256 + maxlength="6" 257 + /> 258 + <button type="button" onclick={() => handleVerify('telegram')}>Submit</button> 259 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button> 260 + </div> 261 + {/if} 262 </div> 263 <div class="config-item"> 264 <label for="signal">Signal Phone Number</label> ··· 275 <span class="status verified">Verified</span> 276 {:else} 277 <span class="status unverified">Not verified</span> 278 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>Verify</button> 279 {/if} 280 {/if} 281 </div> 282 <p class="config-hint">Your Signal phone number with country code</p> 283 + {#if verifyingChannel === 'signal'} 284 + <div class="verify-form"> 285 + <input 286 + type="text" 287 + bind:value={verificationCode} 288 + placeholder="Enter verification code" 289 + maxlength="6" 290 + /> 291 + <button type="button" onclick={() => handleVerify('signal')}>Submit</button> 292 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button> 293 + </div> 294 + {/if} 295 </div> 296 </div> 297 + {#if verificationError} 298 + <div class="message error" style="margin-top: 1rem">{verificationError}</div> 299 + {/if} 300 + {#if verificationSuccess} 301 + <div class="message success" style="margin-top: 1rem">{verificationSuccess}</div> 302 + {/if} 303 </section> 304 <div class="actions"> 305 <button type="submit" disabled={saving}> ··· 307 </button> 308 </div> 309 </form> 310 + <section class="history-section"> 311 + <h2>Notification History</h2> 312 + <p class="section-description">View recent notifications sent to your account.</p> 313 + {#if !showHistory} 314 + <button class="load-history" onclick={loadHistory} disabled={historyLoading}> 315 + {historyLoading ? 'Loading...' : 'Load History'} 316 + </button> 317 + {:else} 318 + <button class="load-history" onclick={() => showHistory = false}>Hide History</button> 319 + {#if historyError} 320 + <div class="message error">{historyError}</div> 321 + {:else if notifications.length === 0} 322 + <p class="no-notifications">No notifications found.</p> 323 + {:else} 324 + <div class="notification-list"> 325 + {#each notifications as notification} 326 + <div class="notification-item"> 327 + <div class="notification-header"> 328 + <span class="notification-type">{notification.notificationType}</span> 329 + <span class="notification-channel">{notification.channel}</span> 330 + <span class="notification-status" class:sent={notification.status === 'sent'} class:failed={notification.status === 'failed'}>{notification.status}</span> 331 + </div> 332 + {#if notification.subject} 333 + <div class="notification-subject">{notification.subject}</div> 334 + {/if} 335 + <div class="notification-body">{notification.body}</div> 336 + <div class="notification-date">{formatDate(notification.createdAt)}</div> 337 + </div> 338 + {/each} 339 + </div> 340 + {/if} 341 + {/if} 342 + </section> 343 {/if} 344 </div> 345 <style> ··· 518 .actions button:disabled { 519 opacity: 0.6; 520 cursor: not-allowed; 521 + } 522 + .verify-btn { 523 + padding: 0.25rem 0.5rem; 524 + background: var(--accent); 525 + color: white; 526 + border: none; 527 + border-radius: 4px; 528 + font-size: 0.75rem; 529 + cursor: pointer; 530 + } 531 + .verify-btn:hover { 532 + background: var(--accent-hover); 533 + } 534 + .verify-form { 535 + display: flex; 536 + gap: 0.5rem; 537 + margin-top: 0.5rem; 538 + align-items: center; 539 + } 540 + .verify-form input { 541 + padding: 0.5rem; 542 + border: 1px solid var(--border-color-light); 543 + border-radius: 4px; 544 + font-size: 0.875rem; 545 + width: 150px; 546 + background: var(--bg-input); 547 + color: var(--text-primary); 548 + } 549 + .verify-form button { 550 + padding: 0.5rem 0.75rem; 551 + background: var(--accent); 552 + color: white; 553 + border: none; 554 + border-radius: 4px; 555 + font-size: 0.875rem; 556 + cursor: pointer; 557 + } 558 + .verify-form button:hover { 559 + background: var(--accent-hover); 560 + } 561 + .verify-form button.cancel { 562 + background: transparent; 563 + border: 1px solid var(--border-color); 564 + color: var(--text-secondary); 565 + } 566 + .verify-form button.cancel:hover { 567 + background: var(--bg-secondary); 568 + } 569 + .history-section { 570 + background: var(--bg-secondary); 571 + padding: 1.5rem; 572 + border-radius: 8px; 573 + margin-top: 1.5rem; 574 + } 575 + .history-section h2 { 576 + margin: 0 0 0.5rem 0; 577 + font-size: 1.125rem; 578 + } 579 + .load-history { 580 + padding: 0.5rem 1rem; 581 + background: transparent; 582 + border: 1px solid var(--border-color); 583 + border-radius: 4px; 584 + cursor: pointer; 585 + color: var(--text-primary); 586 + margin-top: 0.5rem; 587 + } 588 + .load-history:hover:not(:disabled) { 589 + background: var(--bg-card); 590 + border-color: var(--accent); 591 + } 592 + .load-history:disabled { 593 + opacity: 0.6; 594 + cursor: not-allowed; 595 + } 596 + .no-notifications { 597 + color: var(--text-secondary); 598 + font-style: italic; 599 + margin-top: 1rem; 600 + } 601 + .notification-list { 602 + display: flex; 603 + flex-direction: column; 604 + gap: 0.75rem; 605 + margin-top: 1rem; 606 + } 607 + .notification-item { 608 + background: var(--bg-card); 609 + border: 1px solid var(--border-color); 610 + border-radius: 4px; 611 + padding: 0.75rem; 612 + } 613 + .notification-header { 614 + display: flex; 615 + gap: 0.5rem; 616 + margin-bottom: 0.5rem; 617 + flex-wrap: wrap; 618 + align-items: center; 619 + } 620 + .notification-type { 621 + font-weight: 500; 622 + font-size: 0.875rem; 623 + } 624 + .notification-channel { 625 + font-size: 0.75rem; 626 + padding: 0.125rem 0.375rem; 627 + background: var(--bg-secondary); 628 + border-radius: 4px; 629 + color: var(--text-secondary); 630 + } 631 + .notification-status { 632 + font-size: 0.75rem; 633 + padding: 0.125rem 0.375rem; 634 + border-radius: 4px; 635 + margin-left: auto; 636 + } 637 + .notification-status.sent { 638 + background: var(--success-bg); 639 + color: var(--success-text); 640 + } 641 + .notification-status.failed { 642 + background: var(--error-bg); 643 + color: var(--error-text); 644 + } 645 + .notification-subject { 646 + font-weight: 500; 647 + font-size: 0.875rem; 648 + margin-bottom: 0.25rem; 649 + } 650 + .notification-body { 651 + font-size: 0.875rem; 652 + color: var(--text-secondary); 653 + white-space: pre-wrap; 654 + word-break: break-word; 655 + } 656 + .notification-date { 657 + font-size: 0.75rem; 658 + color: var(--text-muted); 659 + margin-top: 0.5rem; 660 } 661 </style>
+223
frontend/src/routes/ResetPassword.svelte
···
··· 1 + <script lang="ts"> 2 + import { navigate } from '../lib/router.svelte' 3 + import { api, ApiError } from '../lib/api' 4 + import { getAuthState } from '../lib/auth.svelte' 5 + const auth = getAuthState() 6 + let email = $state('') 7 + let token = $state('') 8 + let newPassword = $state('') 9 + let confirmPassword = $state('') 10 + let submitting = $state(false) 11 + let error = $state<string | null>(null) 12 + let success = $state<string | null>(null) 13 + let tokenSent = $state(false) 14 + $effect(() => { 15 + if (auth.session) { 16 + navigate('/dashboard') 17 + } 18 + }) 19 + async function handleRequestReset(e: Event) { 20 + e.preventDefault() 21 + if (!email) return 22 + submitting = true 23 + error = null 24 + success = null 25 + try { 26 + await api.requestPasswordReset(email) 27 + tokenSent = true 28 + success = 'Password reset code sent to your email' 29 + } catch (e) { 30 + error = e instanceof ApiError ? e.message : 'Failed to send reset code' 31 + } finally { 32 + submitting = false 33 + } 34 + } 35 + async function handleReset(e: Event) { 36 + e.preventDefault() 37 + if (!token || !newPassword || !confirmPassword) return 38 + if (newPassword !== confirmPassword) { 39 + error = 'Passwords do not match' 40 + return 41 + } 42 + if (newPassword.length < 8) { 43 + error = 'Password must be at least 8 characters' 44 + return 45 + } 46 + submitting = true 47 + error = null 48 + success = null 49 + try { 50 + await api.resetPassword(token, newPassword) 51 + success = 'Password reset successfully!' 52 + setTimeout(() => navigate('/login'), 2000) 53 + } catch (e) { 54 + error = e instanceof ApiError ? e.message : 'Failed to reset password' 55 + } finally { 56 + submitting = false 57 + } 58 + } 59 + </script> 60 + <div class="reset-container"> 61 + {#if error} 62 + <div class="message error">{error}</div> 63 + {/if} 64 + {#if success} 65 + <div class="message success">{success}</div> 66 + {/if} 67 + {#if tokenSent} 68 + <h1>Reset Password</h1> 69 + <p class="subtitle">Enter the code from your email and choose a new password.</p> 70 + <form onsubmit={handleReset}> 71 + <div class="field"> 72 + <label for="token">Reset Code</label> 73 + <input 74 + id="token" 75 + type="text" 76 + bind:value={token} 77 + placeholder="Enter code from email" 78 + disabled={submitting} 79 + required 80 + /> 81 + </div> 82 + <div class="field"> 83 + <label for="new-password">New Password</label> 84 + <input 85 + id="new-password" 86 + type="password" 87 + bind:value={newPassword} 88 + placeholder="At least 8 characters" 89 + disabled={submitting} 90 + required 91 + minlength="8" 92 + /> 93 + </div> 94 + <div class="field"> 95 + <label for="confirm-password">Confirm Password</label> 96 + <input 97 + id="confirm-password" 98 + type="password" 99 + bind:value={confirmPassword} 100 + placeholder="Confirm new password" 101 + disabled={submitting} 102 + required 103 + /> 104 + </div> 105 + <button type="submit" disabled={submitting || !token || !newPassword || !confirmPassword}> 106 + {submitting ? 'Resetting...' : 'Reset Password'} 107 + </button> 108 + <button type="button" class="secondary" onclick={() => { tokenSent = false; token = ''; newPassword = ''; confirmPassword = '' }}> 109 + Request New Code 110 + </button> 111 + </form> 112 + {:else} 113 + <h1>Forgot Password</h1> 114 + <p class="subtitle">Enter your email address and we'll send you a code to reset your password.</p> 115 + <form onsubmit={handleRequestReset}> 116 + <div class="field"> 117 + <label for="email">Email</label> 118 + <input 119 + id="email" 120 + type="email" 121 + bind:value={email} 122 + placeholder="you@example.com" 123 + disabled={submitting} 124 + required 125 + /> 126 + </div> 127 + <button type="submit" disabled={submitting || !email}> 128 + {submitting ? 'Sending...' : 'Send Reset Code'} 129 + </button> 130 + </form> 131 + {/if} 132 + <p class="back-link"> 133 + <a href="#/login">Back to Sign In</a> 134 + </p> 135 + </div> 136 + <style> 137 + .reset-container { 138 + max-width: 400px; 139 + margin: 4rem auto; 140 + padding: 2rem; 141 + } 142 + h1 { 143 + margin: 0 0 0.5rem 0; 144 + } 145 + .subtitle { 146 + color: var(--text-secondary); 147 + margin: 0 0 2rem 0; 148 + } 149 + form { 150 + display: flex; 151 + flex-direction: column; 152 + gap: 1rem; 153 + } 154 + .field { 155 + display: flex; 156 + flex-direction: column; 157 + gap: 0.25rem; 158 + } 159 + label { 160 + font-size: 0.875rem; 161 + font-weight: 500; 162 + } 163 + input { 164 + padding: 0.75rem; 165 + border: 1px solid var(--border-color-light); 166 + border-radius: 4px; 167 + font-size: 1rem; 168 + background: var(--bg-input); 169 + color: var(--text-primary); 170 + } 171 + input:focus { 172 + outline: none; 173 + border-color: var(--accent); 174 + } 175 + button { 176 + padding: 0.75rem; 177 + background: var(--accent); 178 + color: white; 179 + border: none; 180 + border-radius: 4px; 181 + font-size: 1rem; 182 + cursor: pointer; 183 + margin-top: 0.5rem; 184 + } 185 + button:hover:not(:disabled) { 186 + background: var(--accent-hover); 187 + } 188 + button:disabled { 189 + opacity: 0.6; 190 + cursor: not-allowed; 191 + } 192 + button.secondary { 193 + background: transparent; 194 + color: var(--text-secondary); 195 + border: 1px solid var(--border-color-light); 196 + } 197 + button.secondary:hover:not(:disabled) { 198 + background: var(--bg-secondary); 199 + } 200 + .message { 201 + padding: 0.75rem; 202 + border-radius: 4px; 203 + margin-bottom: 1rem; 204 + } 205 + .message.success { 206 + background: var(--success-bg); 207 + border: 1px solid var(--success-border); 208 + color: var(--success-text); 209 + } 210 + .message.error { 211 + background: var(--error-bg); 212 + border: 1px solid var(--error-border); 213 + color: var(--error-text); 214 + } 215 + .back-link { 216 + text-align: center; 217 + margin-top: 1.5rem; 218 + color: var(--text-secondary); 219 + } 220 + .back-link a { 221 + color: var(--accent); 222 + } 223 + </style>
+240
frontend/src/routes/Sessions.svelte
···
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { api, ApiError } from '../lib/api' 5 + const auth = getAuthState() 6 + let loading = $state(true) 7 + let error = $state<string | null>(null) 8 + let sessions = $state<Array<{ 9 + id: string 10 + createdAt: string 11 + expiresAt: string 12 + isCurrent: boolean 13 + }>>([]) 14 + $effect(() => { 15 + if (!auth.loading && !auth.session) { 16 + navigate('/login') 17 + } 18 + }) 19 + $effect(() => { 20 + if (auth.session) { 21 + loadSessions() 22 + } 23 + }) 24 + async function loadSessions() { 25 + if (!auth.session) return 26 + loading = true 27 + error = null 28 + try { 29 + const result = await api.listSessions(auth.session.accessJwt) 30 + sessions = result.sessions 31 + } catch (e) { 32 + error = e instanceof ApiError ? e.message : 'Failed to load sessions' 33 + } finally { 34 + loading = false 35 + } 36 + } 37 + async function revokeSession(sessionId: string, isCurrent: boolean) { 38 + if (!auth.session) return 39 + const msg = isCurrent 40 + ? 'This will log you out of this session. Continue?' 41 + : 'Revoke this session?' 42 + if (!confirm(msg)) return 43 + try { 44 + await api.revokeSession(auth.session.accessJwt, sessionId) 45 + if (isCurrent) { 46 + navigate('/login') 47 + } else { 48 + sessions = sessions.filter(s => s.id !== sessionId) 49 + } 50 + } catch (e) { 51 + error = e instanceof ApiError ? e.message : 'Failed to revoke session' 52 + } 53 + } 54 + function formatDate(dateStr: string): string { 55 + return new Date(dateStr).toLocaleString() 56 + } 57 + function timeAgo(dateStr: string): string { 58 + const date = new Date(dateStr) 59 + const now = new Date() 60 + const diff = now.getTime() - date.getTime() 61 + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) 62 + const hours = Math.floor(diff / (1000 * 60 * 60)) 63 + const minutes = Math.floor(diff / (1000 * 60)) 64 + if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago` 65 + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago` 66 + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago` 67 + return 'Just now' 68 + } 69 + </script> 70 + <div class="page"> 71 + <header> 72 + <a href="#/dashboard" class="back">&larr; Dashboard</a> 73 + <h1>Active Sessions</h1> 74 + </header> 75 + {#if loading} 76 + <p class="loading">Loading sessions...</p> 77 + {:else} 78 + {#if error} 79 + <div class="message error">{error}</div> 80 + {/if} 81 + {#if sessions.length === 0} 82 + <p class="empty">No active sessions found.</p> 83 + {:else} 84 + <div class="sessions-list"> 85 + {#each sessions as session} 86 + <div class="session-card" class:current={session.isCurrent}> 87 + <div class="session-info"> 88 + <div class="session-header"> 89 + {#if session.isCurrent} 90 + <span class="badge current">Current Session</span> 91 + {:else} 92 + <span class="session-label">Session</span> 93 + {/if} 94 + </div> 95 + <div class="session-details"> 96 + <div class="detail"> 97 + <span class="label">Created:</span> 98 + <span class="value">{timeAgo(session.createdAt)}</span> 99 + </div> 100 + <div class="detail"> 101 + <span class="label">Expires:</span> 102 + <span class="value">{formatDate(session.expiresAt)}</span> 103 + </div> 104 + </div> 105 + </div> 106 + <div class="session-actions"> 107 + <button 108 + class="revoke-btn" 109 + class:danger={!session.isCurrent} 110 + onclick={() => revokeSession(session.id, session.isCurrent)} 111 + > 112 + {session.isCurrent ? 'Sign Out' : 'Revoke'} 113 + </button> 114 + </div> 115 + </div> 116 + {/each} 117 + </div> 118 + <button class="refresh-btn" onclick={loadSessions}>Refresh</button> 119 + {/if} 120 + {/if} 121 + </div> 122 + <style> 123 + .page { 124 + max-width: 600px; 125 + margin: 0 auto; 126 + padding: 2rem; 127 + } 128 + header { 129 + margin-bottom: 2rem; 130 + } 131 + .back { 132 + color: var(--text-secondary); 133 + text-decoration: none; 134 + font-size: 0.875rem; 135 + } 136 + .back:hover { 137 + color: var(--accent); 138 + } 139 + h1 { 140 + margin: 0.5rem 0 0 0; 141 + } 142 + .loading, .empty { 143 + text-align: center; 144 + color: var(--text-secondary); 145 + padding: 2rem; 146 + } 147 + .message { 148 + padding: 0.75rem; 149 + border-radius: 4px; 150 + margin-bottom: 1rem; 151 + } 152 + .message.error { 153 + background: var(--error-bg); 154 + border: 1px solid var(--error-border); 155 + color: var(--error-text); 156 + } 157 + .sessions-list { 158 + display: flex; 159 + flex-direction: column; 160 + gap: 1rem; 161 + } 162 + .session-card { 163 + background: var(--bg-secondary); 164 + border: 1px solid var(--border-color); 165 + border-radius: 8px; 166 + padding: 1rem; 167 + display: flex; 168 + justify-content: space-between; 169 + align-items: center; 170 + } 171 + .session-card.current { 172 + border-color: var(--accent); 173 + background: var(--bg-card); 174 + } 175 + .session-header { 176 + margin-bottom: 0.5rem; 177 + } 178 + .session-label { 179 + font-weight: 500; 180 + color: var(--text-secondary); 181 + } 182 + .badge { 183 + display: inline-block; 184 + padding: 0.125rem 0.5rem; 185 + border-radius: 4px; 186 + font-size: 0.75rem; 187 + font-weight: 500; 188 + } 189 + .badge.current { 190 + background: var(--accent); 191 + color: white; 192 + } 193 + .session-details { 194 + display: flex; 195 + flex-direction: column; 196 + gap: 0.25rem; 197 + } 198 + .detail { 199 + font-size: 0.875rem; 200 + } 201 + .detail .label { 202 + color: var(--text-secondary); 203 + margin-right: 0.5rem; 204 + } 205 + .detail .value { 206 + color: var(--text-primary); 207 + } 208 + .revoke-btn { 209 + padding: 0.5rem 1rem; 210 + border: 1px solid var(--border-color); 211 + border-radius: 4px; 212 + background: transparent; 213 + color: var(--text-primary); 214 + cursor: pointer; 215 + font-size: 0.875rem; 216 + } 217 + .revoke-btn:hover { 218 + background: var(--bg-card); 219 + } 220 + .revoke-btn.danger { 221 + border-color: var(--error-text); 222 + color: var(--error-text); 223 + } 224 + .revoke-btn.danger:hover { 225 + background: var(--error-bg); 226 + } 227 + .refresh-btn { 228 + margin-top: 1rem; 229 + padding: 0.5rem 1rem; 230 + background: transparent; 231 + border: 1px solid var(--border-color); 232 + border-radius: 4px; 233 + cursor: pointer; 234 + color: var(--text-primary); 235 + } 236 + .refresh-btn:hover { 237 + background: var(--bg-card); 238 + border-color: var(--accent); 239 + } 240 + </style>
+110 -1
frontend/src/routes/Settings.svelte
··· 14 let deletePassword = $state('') 15 let deleteToken = $state('') 16 let deleteTokenSent = $state(false) 17 $effect(() => { 18 if (!auth.loading && !auth.session) { 19 navigate('/login') ··· 110 deleteLoading = false 111 } 112 } 113 </script> 114 <div class="page"> 115 <header> ··· 187 </button> 188 </form> 189 </section> 190 <section class="danger-zone"> 191 <h2>Delete Account</h2> 192 <p class="warning">This action is irreversible. All your data will be permanently deleted.</p> ··· 275 margin: 0 0 0.5rem 0; 276 font-size: 1.125rem; 277 } 278 - .current { 279 color: var(--text-secondary); 280 font-size: 0.875rem; 281 margin-bottom: 1rem;
··· 14 let deletePassword = $state('') 15 let deleteToken = $state('') 16 let deleteTokenSent = $state(false) 17 + let exportLoading = $state(false) 18 + let passwordLoading = $state(false) 19 + let currentPassword = $state('') 20 + let newPassword = $state('') 21 + let confirmNewPassword = $state('') 22 $effect(() => { 23 if (!auth.loading && !auth.session) { 24 navigate('/login') ··· 115 deleteLoading = false 116 } 117 } 118 + async function handleExportRepo() { 119 + if (!auth.session) return 120 + exportLoading = true 121 + message = null 122 + try { 123 + const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, { 124 + headers: { 125 + 'Authorization': `Bearer ${auth.session.accessJwt}` 126 + } 127 + }) 128 + if (!response.ok) { 129 + const err = await response.json().catch(() => ({ message: 'Export failed' })) 130 + throw new Error(err.message || 'Export failed') 131 + } 132 + const blob = await response.blob() 133 + const url = URL.createObjectURL(blob) 134 + const a = document.createElement('a') 135 + a.href = url 136 + a.download = `${auth.session.handle}-repo.car` 137 + document.body.appendChild(a) 138 + a.click() 139 + document.body.removeChild(a) 140 + URL.revokeObjectURL(url) 141 + showMessage('success', 'Repository exported successfully') 142 + } catch (e) { 143 + showMessage('error', e instanceof Error ? e.message : 'Failed to export repository') 144 + } finally { 145 + exportLoading = false 146 + } 147 + } 148 + async function handleChangePassword(e: Event) { 149 + e.preventDefault() 150 + if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return 151 + if (newPassword !== confirmNewPassword) { 152 + showMessage('error', 'Passwords do not match') 153 + return 154 + } 155 + if (newPassword.length < 8) { 156 + showMessage('error', 'Password must be at least 8 characters') 157 + return 158 + } 159 + passwordLoading = true 160 + message = null 161 + try { 162 + await api.changePassword(auth.session.accessJwt, currentPassword, newPassword) 163 + showMessage('success', 'Password changed successfully') 164 + currentPassword = '' 165 + newPassword = '' 166 + confirmNewPassword = '' 167 + } catch (e) { 168 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password') 169 + } finally { 170 + passwordLoading = false 171 + } 172 + } 173 </script> 174 <div class="page"> 175 <header> ··· 247 </button> 248 </form> 249 </section> 250 + <section> 251 + <h2>Change Password</h2> 252 + <form onsubmit={handleChangePassword}> 253 + <div class="field"> 254 + <label for="current-password">Current Password</label> 255 + <input 256 + id="current-password" 257 + type="password" 258 + bind:value={currentPassword} 259 + placeholder="Enter current password" 260 + disabled={passwordLoading} 261 + required 262 + /> 263 + </div> 264 + <div class="field"> 265 + <label for="new-password">New Password</label> 266 + <input 267 + id="new-password" 268 + type="password" 269 + bind:value={newPassword} 270 + placeholder="At least 8 characters" 271 + disabled={passwordLoading} 272 + required 273 + minlength="8" 274 + /> 275 + </div> 276 + <div class="field"> 277 + <label for="confirm-new-password">Confirm New Password</label> 278 + <input 279 + id="confirm-new-password" 280 + type="password" 281 + bind:value={confirmNewPassword} 282 + placeholder="Confirm new password" 283 + disabled={passwordLoading} 284 + required 285 + /> 286 + </div> 287 + <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 288 + {passwordLoading ? 'Changing...' : 'Change Password'} 289 + </button> 290 + </form> 291 + </section> 292 + <section> 293 + <h2>Export Data</h2> 294 + <p class="description">Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.</p> 295 + <button onclick={handleExportRepo} disabled={exportLoading}> 296 + {exportLoading ? 'Exporting...' : 'Download Repository'} 297 + </button> 298 + </section> 299 <section class="danger-zone"> 300 <h2>Delete Account</h2> 301 <p class="warning">This action is irreversible. All your data will be permanently deleted.</p> ··· 384 margin: 0 0 0.5rem 0; 385 font-size: 1.125rem; 386 } 387 + .current, .description { 388 color: var(--text-secondary); 389 font-size: 0.875rem; 390 margin-bottom: 1rem;
+52 -19
src/api/actor/profile.rs
··· 2 use crate::state::AppState; 3 use axum::{ 4 Json, 5 - extract::{Query, State}, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; ··· 15 #[derive(Deserialize)] 16 pub struct GetProfileParams { 17 pub actor: String, 18 - } 19 - 20 - #[derive(Deserialize)] 21 - pub struct GetProfilesParams { 22 - pub actors: String, 23 } 24 25 #[derive(Serialize, Deserialize, Clone)] ··· 71 } 72 73 async fn proxy_to_appview( 74 method: &str, 75 params: &HashMap<String, String>, 76 auth_did: &str, 77 auth_key_bytes: Option<&[u8]>, 78 ) -> Result<(StatusCode, Value), Response> { 79 - let appview_url = match std::env::var("APPVIEW_URL") { 80 - Ok(url) => url, 81 - Err(_) => { 82 return Err(( 83 StatusCode::BAD_GATEWAY, 84 Json( ··· 88 .into_response()); 89 } 90 }; 91 - let target_url = format!("{}/xrpc/{}", appview_url, method); 92 info!("Proxying GET request to {}", target_url); 93 let client = proxy_client(); 94 - let mut request_builder = client.get(&target_url).query(params); 95 if let Some(key_bytes) = auth_key_bytes { 96 - let appview_did = 97 - std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); 98 - match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) { 99 Ok(service_token) => { 100 request_builder = 101 request_builder.header("Authorization", format!("Bearer {}", service_token)); ··· 167 let mut query_params = HashMap::new(); 168 query_params.insert("actor".to_string(), params.actor.clone()); 169 let (status, body) = match proxy_to_appview( 170 "app.bsky.actor.getProfile", 171 &query_params, 172 auth_did.as_deref().unwrap_or(""), ··· 201 pub async fn get_profiles( 202 State(state): State<AppState>, 203 headers: axum::http::HeaderMap, 204 - Query(params): Query<GetProfilesParams>, 205 ) -> Response { 206 let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); 207 let auth_user = if let Some(h) = auth_header { ··· 217 }; 218 let auth_did = auth_user.as_ref().map(|u| u.did.clone()); 219 let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone()); 220 - let mut query_params = HashMap::new(); 221 - query_params.insert("actors".to_string(), params.actors.clone()); 222 - let (status, body) = match proxy_to_appview( 223 "app.bsky.actor.getProfiles", 224 - &query_params, 225 auth_did.as_deref().unwrap_or(""), 226 auth_key_bytes.as_deref(), 227 )
··· 2 use crate::state::AppState; 3 use axum::{ 4 Json, 5 + extract::{Query, RawQuery, State}, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; ··· 15 #[derive(Deserialize)] 16 pub struct GetProfileParams { 17 pub actor: String, 18 } 19 20 #[derive(Serialize, Deserialize, Clone)] ··· 66 } 67 68 async fn proxy_to_appview( 69 + state: &AppState, 70 method: &str, 71 params: &HashMap<String, String>, 72 auth_did: &str, 73 auth_key_bytes: Option<&[u8]>, 74 ) -> Result<(StatusCode, Value), Response> { 75 + let resolved = match state.appview_registry.get_appview_for_method(method).await { 76 + Some(r) => r, 77 + None => { 78 + return Err(( 79 + StatusCode::BAD_GATEWAY, 80 + Json( 81 + json!({"error": "UpstreamError", "message": "No upstream AppView configured"}), 82 + ), 83 + ) 84 + .into_response()); 85 + } 86 + }; 87 + let target_url = format!("{}/xrpc/{}", resolved.url, method); 88 + info!("Proxying GET request to {}", target_url); 89 + let client = proxy_client(); 90 + let request_builder = client.get(&target_url).query(params); 91 + proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await 92 + } 93 + 94 + async fn proxy_to_appview_raw( 95 + state: &AppState, 96 + method: &str, 97 + raw_query: Option<&str>, 98 + auth_did: &str, 99 + auth_key_bytes: Option<&[u8]>, 100 + ) -> Result<(StatusCode, Value), Response> { 101 + let resolved = match state.appview_registry.get_appview_for_method(method).await { 102 + Some(r) => r, 103 + None => { 104 return Err(( 105 StatusCode::BAD_GATEWAY, 106 Json( ··· 110 .into_response()); 111 } 112 }; 113 + let target_url = match raw_query { 114 + Some(q) => format!("{}/xrpc/{}?{}", resolved.url, method, q), 115 + None => format!("{}/xrpc/{}", resolved.url, method), 116 + }; 117 info!("Proxying GET request to {}", target_url); 118 let client = proxy_client(); 119 + let request_builder = client.get(&target_url); 120 + proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await 121 + } 122 + 123 + async fn proxy_request( 124 + mut request_builder: reqwest::RequestBuilder, 125 + auth_did: &str, 126 + auth_key_bytes: Option<&[u8]>, 127 + method: &str, 128 + appview_did: &str, 129 + ) -> Result<(StatusCode, Value), Response> { 130 if let Some(key_bytes) = auth_key_bytes { 131 + match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) { 132 Ok(service_token) => { 133 request_builder = 134 request_builder.header("Authorization", format!("Bearer {}", service_token)); ··· 200 let mut query_params = HashMap::new(); 201 query_params.insert("actor".to_string(), params.actor.clone()); 202 let (status, body) = match proxy_to_appview( 203 + &state, 204 "app.bsky.actor.getProfile", 205 &query_params, 206 auth_did.as_deref().unwrap_or(""), ··· 235 pub async fn get_profiles( 236 State(state): State<AppState>, 237 headers: axum::http::HeaderMap, 238 + RawQuery(raw_query): RawQuery, 239 ) -> Response { 240 let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); 241 let auth_user = if let Some(h) = auth_header { ··· 251 }; 252 let auth_did = auth_user.as_ref().map(|u| u.did.clone()); 253 let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone()); 254 + let (status, body) = match proxy_to_appview_raw( 255 + &state, 256 "app.bsky.actor.getProfiles", 257 + raw_query.as_deref(), 258 auth_did.as_deref().unwrap_or(""), 259 auth_key_bytes.as_deref(), 260 )
+21 -7
src/api/admin/account/info.rs
··· 2 use crate::state::AppState; 3 use axum::{ 4 Json, 5 - extract::{Query, State}, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; ··· 88 } 89 } 90 91 - #[derive(Deserialize)] 92 - pub struct GetAccountInfosParams { 93 - pub dids: String, 94 } 95 96 pub async fn get_account_infos( 97 State(state): State<AppState>, 98 _auth: BearerAuthAdmin, 99 - Query(params): Query<GetAccountInfosParams>, 100 ) -> Response { 101 - let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect(); 102 if dids.is_empty() { 103 return ( 104 StatusCode::BAD_REQUEST, ··· 107 .into_response(); 108 } 109 let mut infos = Vec::new(); 110 - for did in dids { 111 if did.is_empty() { 112 continue; 113 }
··· 2 use crate::state::AppState; 3 use axum::{ 4 Json, 5 + extract::{Query, RawQuery, State}, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; ··· 88 } 89 } 90 91 + fn parse_repeated_param(query: Option<&str>, key: &str) -> Vec<String> { 92 + query 93 + .map(|q| { 94 + q.split('&') 95 + .filter_map(|pair| { 96 + let mut parts = pair.splitn(2, '='); 97 + let k = parts.next()?; 98 + let v = parts.next()?; 99 + if k == key { 100 + Some(urlencoding::decode(v).ok()?.into_owned()) 101 + } else { 102 + None 103 + } 104 + }) 105 + .collect() 106 + }) 107 + .unwrap_or_default() 108 } 109 110 pub async fn get_account_infos( 111 State(state): State<AppState>, 112 _auth: BearerAuthAdmin, 113 + RawQuery(raw_query): RawQuery, 114 ) -> Response { 115 + let dids = parse_repeated_param(raw_query.as_deref(), "dids"); 116 if dids.is_empty() { 117 return ( 118 StatusCode::BAD_REQUEST, ··· 121 .into_response(); 122 } 123 let mut infos = Vec::new(); 124 + for did in &dids { 125 if did.is_empty() { 126 continue; 127 }
+3 -7
src/api/admin/account/mod.rs
··· 1 mod delete; 2 mod email; 3 mod info; 4 - mod profile; 5 mod update; 6 7 pub use delete::{DeleteAccountInput, delete_account}; 8 pub use email::{SendEmailInput, SendEmailOutput, send_email}; 9 pub use info::{ 10 - AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, GetAccountInfosParams, 11 - get_account_info, get_account_infos, 12 - }; 13 - pub use profile::{ 14 - CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput, create_profile, 15 - create_record_admin, 16 }; 17 pub use update::{ 18 UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput, 19 update_account_email, update_account_handle, update_account_password,
··· 1 mod delete; 2 mod email; 3 mod info; 4 + mod search; 5 mod update; 6 7 pub use delete::{DeleteAccountInput, delete_account}; 8 pub use email::{SendEmailInput, SendEmailOutput, send_email}; 9 pub use info::{ 10 + AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, get_account_info, get_account_infos, 11 }; 12 + pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts}; 13 pub use update::{ 14 UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput, 15 update_account_email, update_account_handle, update_account_password,
-133
src/api/admin/account/profile.rs
··· 1 - use crate::api::repo::record::create_record_internal; 2 - use crate::auth::BearerAuthAdmin; 3 - use crate::state::AppState; 4 - use axum::{ 5 - Json, 6 - extract::State, 7 - http::StatusCode, 8 - response::{IntoResponse, Response}, 9 - }; 10 - use serde::{Deserialize, Serialize}; 11 - use serde_json::json; 12 - use tracing::{error, info}; 13 - 14 - #[derive(Deserialize)] 15 - #[serde(rename_all = "camelCase")] 16 - pub struct CreateProfileInput { 17 - pub did: String, 18 - pub display_name: Option<String>, 19 - pub description: Option<String>, 20 - } 21 - 22 - #[derive(Deserialize)] 23 - #[serde(rename_all = "camelCase")] 24 - pub struct CreateRecordAdminInput { 25 - pub did: String, 26 - pub collection: String, 27 - pub rkey: Option<String>, 28 - pub record: serde_json::Value, 29 - } 30 - 31 - #[derive(Serialize)] 32 - #[serde(rename_all = "camelCase")] 33 - pub struct CreateProfileOutput { 34 - pub uri: String, 35 - pub cid: String, 36 - } 37 - 38 - pub async fn create_profile( 39 - State(state): State<AppState>, 40 - _auth: BearerAuthAdmin, 41 - Json(input): Json<CreateProfileInput>, 42 - ) -> Response { 43 - let did = input.did.trim(); 44 - if did.is_empty() { 45 - return ( 46 - StatusCode::BAD_REQUEST, 47 - Json(json!({"error": "InvalidRequest", "message": "did is required"})), 48 - ) 49 - .into_response(); 50 - } 51 - 52 - let mut profile_record = json!({ 53 - "$type": "app.bsky.actor.profile" 54 - }); 55 - 56 - if let Some(display_name) = &input.display_name { 57 - profile_record["displayName"] = json!(display_name); 58 - } 59 - if let Some(description) = &input.description { 60 - profile_record["description"] = json!(description); 61 - } 62 - 63 - match create_record_internal( 64 - &state, 65 - did, 66 - "app.bsky.actor.profile", 67 - "self", 68 - &profile_record, 69 - ) 70 - .await 71 - { 72 - Ok((uri, commit_cid)) => { 73 - info!(did = %did, uri = %uri, "Created profile for user"); 74 - ( 75 - StatusCode::OK, 76 - Json(CreateProfileOutput { 77 - uri, 78 - cid: commit_cid.to_string(), 79 - }), 80 - ) 81 - .into_response() 82 - } 83 - Err(e) => { 84 - error!("Failed to create profile for {}: {}", did, e); 85 - ( 86 - StatusCode::INTERNAL_SERVER_ERROR, 87 - Json(json!({"error": "InternalError", "message": e})), 88 - ) 89 - .into_response() 90 - } 91 - } 92 - } 93 - 94 - pub async fn create_record_admin( 95 - State(state): State<AppState>, 96 - _auth: BearerAuthAdmin, 97 - Json(input): Json<CreateRecordAdminInput>, 98 - ) -> Response { 99 - let did = input.did.trim(); 100 - if did.is_empty() { 101 - return ( 102 - StatusCode::BAD_REQUEST, 103 - Json(json!({"error": "InvalidRequest", "message": "did is required"})), 104 - ) 105 - .into_response(); 106 - } 107 - 108 - let rkey = input 109 - .rkey 110 - .unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string()); 111 - 112 - match create_record_internal(&state, did, &input.collection, &rkey, &input.record).await { 113 - Ok((uri, commit_cid)) => { 114 - info!(did = %did, uri = %uri, "Admin created record"); 115 - ( 116 - StatusCode::OK, 117 - Json(CreateProfileOutput { 118 - uri, 119 - cid: commit_cid.to_string(), 120 - }), 121 - ) 122 - .into_response() 123 - } 124 - Err(e) => { 125 - error!("Failed to create record for {}: {}", did, e); 126 - ( 127 - StatusCode::INTERNAL_SERVER_ERROR, 128 - Json(json!({"error": "InternalError", "message": e})), 129 - ) 130 - .into_response() 131 - } 132 - } 133 - }
···
+114
src/api/admin/account/search.rs
···
··· 1 + use crate::auth::BearerAuthAdmin; 2 + use crate::state::AppState; 3 + use axum::{ 4 + Json, 5 + extract::{Query, State}, 6 + http::StatusCode, 7 + response::{IntoResponse, Response}, 8 + }; 9 + use serde::{Deserialize, Serialize}; 10 + use serde_json::json; 11 + use tracing::error; 12 + 13 + #[derive(Deserialize)] 14 + pub struct SearchAccountsParams { 15 + pub handle: Option<String>, 16 + pub cursor: Option<String>, 17 + #[serde(default = "default_limit")] 18 + pub limit: i64, 19 + } 20 + 21 + fn default_limit() -> i64 { 22 + 50 23 + } 24 + 25 + #[derive(Serialize)] 26 + #[serde(rename_all = "camelCase")] 27 + pub struct AccountView { 28 + pub did: String, 29 + pub handle: String, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub email: Option<String>, 32 + pub indexed_at: String, 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + pub email_confirmed_at: Option<String>, 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub deactivated_at: Option<String>, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub invites_disabled: Option<bool>, 39 + } 40 + 41 + #[derive(Serialize)] 42 + #[serde(rename_all = "camelCase")] 43 + pub struct SearchAccountsOutput { 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub cursor: Option<String>, 46 + pub accounts: Vec<AccountView>, 47 + } 48 + 49 + pub async fn search_accounts( 50 + State(state): State<AppState>, 51 + _auth: BearerAuthAdmin, 52 + Query(params): Query<SearchAccountsParams>, 53 + ) -> Response { 54 + let limit = params.limit.clamp(1, 100); 55 + let cursor_did = params.cursor.as_deref().unwrap_or(""); 56 + let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h)); 57 + let result = sqlx::query_as::<_, (String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<chrono::DateTime<chrono::Utc>>)>( 58 + r#" 59 + SELECT did, handle, email, created_at, email_confirmed, deactivated_at 60 + FROM users 61 + WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2) 62 + ORDER BY did ASC 63 + LIMIT $3 64 + "#, 65 + ) 66 + .bind(cursor_did) 67 + .bind(&handle_filter) 68 + .bind(limit + 1) 69 + .fetch_all(&state.db) 70 + .await; 71 + match result { 72 + Ok(rows) => { 73 + let has_more = rows.len() > limit as usize; 74 + let accounts: Vec<AccountView> = rows 75 + .into_iter() 76 + .take(limit as usize) 77 + .map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView { 78 + did: did.clone(), 79 + handle, 80 + email, 81 + indexed_at: created_at.to_rfc3339(), 82 + email_confirmed_at: if email_confirmed { 83 + Some(created_at.to_rfc3339()) 84 + } else { 85 + None 86 + }, 87 + deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()), 88 + invites_disabled: None, 89 + }) 90 + .collect(); 91 + let next_cursor = if has_more { 92 + accounts.last().map(|a| a.did.clone()) 93 + } else { 94 + None 95 + }; 96 + ( 97 + StatusCode::OK, 98 + Json(SearchAccountsOutput { 99 + cursor: next_cursor, 100 + accounts, 101 + }), 102 + ) 103 + .into_response() 104 + } 105 + Err(e) => { 106 + error!("DB error in search_accounts: {:?}", e); 107 + ( 108 + StatusCode::INTERNAL_SERVER_ERROR, 109 + Json(json!({"error": "InternalError"})), 110 + ) 111 + .into_response() 112 + } 113 + } 114 + }
+2 -2
src/api/admin/mod.rs
··· 4 pub mod status; 5 6 pub use account::{ 7 - create_profile, create_record_admin, delete_account, get_account_info, get_account_infos, 8 - send_email, update_account_email, update_account_handle, update_account_password, 9 }; 10 pub use invite::{ 11 disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
··· 4 pub mod status; 5 6 pub use account::{ 7 + delete_account, get_account_info, get_account_infos, search_accounts, send_email, 8 + update_account_email, update_account_handle, update_account_password, 9 }; 10 pub use invite::{ 11 disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
+3 -2
src/api/feed/actor_likes.rs
··· 1 use crate::api::read_after_write::{ 2 FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev, 3 - format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview, 4 }; 5 use crate::state::AppState; 6 use axum::{ ··· 87 if let Some(cursor) = &params.cursor { 88 query_params.insert("cursor".to_string(), cursor.clone()); 89 } 90 - let proxy_result = match proxy_to_appview( 91 "app.bsky.feed.getActorLikes", 92 &query_params, 93 auth_did.as_deref().unwrap_or(""),
··· 1 use crate::api::read_after_write::{ 2 FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev, 3 + format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry, 4 }; 5 use crate::state::AppState; 6 use axum::{ ··· 87 if let Some(cursor) = &params.cursor { 88 query_params.insert("cursor".to_string(), cursor.clone()); 89 } 90 + let proxy_result = match proxy_to_appview_via_registry( 91 + &state, 92 "app.bsky.feed.getActorLikes", 93 &query_params, 94 auth_did.as_deref().unwrap_or(""),
+3 -2
src/api/feed/author_feed.rs
··· 1 use crate::api::read_after_write::{ 2 FeedOutput, FeedViewPost, ProfileRecord, RecordDescript, extract_repo_rev, format_local_post, 3 format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, 4 - proxy_to_appview, 5 }; 6 use crate::state::AppState; 7 use axum::{ ··· 70 if let Some(include_pins) = params.include_pins { 71 query_params.insert("includePins".to_string(), include_pins.to_string()); 72 } 73 - let proxy_result = match proxy_to_appview( 74 "app.bsky.feed.getAuthorFeed", 75 &query_params, 76 auth_did.as_deref().unwrap_or(""),
··· 1 use crate::api::read_after_write::{ 2 FeedOutput, FeedViewPost, ProfileRecord, RecordDescript, extract_repo_rev, format_local_post, 3 format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, 4 + proxy_to_appview_via_registry, 5 }; 6 use crate::state::AppState; 7 use axum::{ ··· 70 if let Some(include_pins) = params.include_pins { 71 query_params.insert("includePins".to_string(), include_pins.to_string()); 72 } 73 + let proxy_result = match proxy_to_appview_via_registry( 74 + &state, 75 "app.bsky.feed.getAuthorFeed", 76 &query_params, 77 auth_did.as_deref().unwrap_or(""),
+7 -9
src/api/feed/custom_feed.rs
··· 37 if let Err(e) = validate_at_uri(&params.feed) { 38 return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response(); 39 } 40 - let appview_url = match std::env::var("APPVIEW_URL") { 41 - Ok(url) => url, 42 - Err(_) => { 43 - return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()) 44 .into_response(); 45 } 46 }; 47 - if let Err(e) = is_ssrf_safe(&appview_url) { 48 error!("SSRF check failed for appview URL: {}", e); 49 return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)) 50 .into_response(); ··· 56 if let Some(cursor) = &params.cursor { 57 query_params.insert("cursor".to_string(), cursor.clone()); 58 } 59 - let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", appview_url); 60 info!(target = %target_url, feed = %params.feed, "Proxying getFeed request"); 61 let client = proxy_client(); 62 let mut request_builder = client.get(&target_url).query(&query_params); 63 if let Some(key_bytes) = auth_user.key_bytes.as_ref() { 64 - let appview_did = 65 - std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); 66 match crate::auth::create_service_token( 67 &auth_user.did, 68 - &appview_did, 69 "app.bsky.feed.getFeed", 70 key_bytes, 71 ) {
··· 37 if let Err(e) = validate_at_uri(&params.feed) { 38 return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response(); 39 } 40 + let resolved = match state.appview_registry.get_appview_for_method("app.bsky.feed.getFeed").await { 41 + Some(r) => r, 42 + None => { 43 + return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.feed.getFeed".to_string()) 44 .into_response(); 45 } 46 }; 47 + if let Err(e) = is_ssrf_safe(&resolved.url) { 48 error!("SSRF check failed for appview URL: {}", e); 49 return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)) 50 .into_response(); ··· 56 if let Some(cursor) = &params.cursor { 57 query_params.insert("cursor".to_string(), cursor.clone()); 58 } 59 + let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", resolved.url); 60 info!(target = %target_url, feed = %params.feed, "Proxying getFeed request"); 61 let client = proxy_client(); 62 let mut request_builder = client.get(&target_url).query(&query_params); 63 if let Some(key_bytes) = auth_user.key_bytes.as_ref() { 64 match crate::auth::create_service_token( 65 &auth_user.did, 66 + &resolved.did, 67 "app.bsky.feed.getFeed", 68 key_bytes, 69 ) {
+3 -2
src/api/feed/post_thread.rs
··· 1 use crate::api::read_after_write::{ 2 PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post, 3 - format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview, 4 }; 5 use crate::state::AppState; 6 use axum::{ ··· 153 if let Some(parent_height) = params.parent_height { 154 query_params.insert("parentHeight".to_string(), parent_height.to_string()); 155 } 156 - let proxy_result = match proxy_to_appview( 157 "app.bsky.feed.getPostThread", 158 &query_params, 159 auth_did.as_deref().unwrap_or(""),
··· 1 use crate::api::read_after_write::{ 2 PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post, 3 + format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry, 4 }; 5 use crate::state::AppState; 6 use axum::{ ··· 153 if let Some(parent_height) = params.parent_height { 154 query_params.insert("parentHeight".to_string(), parent_height.to_string()); 155 } 156 + let proxy_result = match proxy_to_appview_via_registry( 157 + &state, 158 "app.bsky.feed.getPostThread", 159 &query_params, 160 auth_did.as_deref().unwrap_or(""),
+11 -13
src/api/feed/timeline.rs
··· 1 use crate::api::read_after_write::{ 2 FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post, 3 format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, 4 - proxy_to_appview, 5 }; 6 use crate::state::AppState; 7 use axum::{ ··· 50 .into_response(); 51 } 52 }; 53 - match std::env::var("APPVIEW_URL") { 54 - Ok(url) if !url.starts_with("http://127.0.0.1") => { 55 - return get_timeline_with_appview( 56 - &state, 57 - &params, 58 - &auth_user.did, 59 - auth_user.key_bytes.as_deref(), 60 - ) 61 - .await; 62 - } 63 - _ => {} 64 } 65 get_timeline_local_only(&state, &auth_user.did).await 66 } ··· 81 if let Some(cursor) = &params.cursor { 82 query_params.insert("cursor".to_string(), cursor.clone()); 83 } 84 - let proxy_result = match proxy_to_appview( 85 "app.bsky.feed.getTimeline", 86 &query_params, 87 auth_did,
··· 1 use crate::api::read_after_write::{ 2 FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post, 3 format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, 4 + proxy_to_appview_via_registry, 5 }; 6 use crate::state::AppState; 7 use axum::{ ··· 50 .into_response(); 51 } 52 }; 53 + if state.appview_registry.get_appview_for_method("app.bsky.feed.getTimeline").await.is_some() { 54 + return get_timeline_with_appview( 55 + &state, 56 + &params, 57 + &auth_user.did, 58 + auth_user.key_bytes.as_deref(), 59 + ) 60 + .await; 61 } 62 get_timeline_local_only(&state, &auth_user.did).await 63 } ··· 78 if let Some(cursor) = &params.cursor { 79 query_params.insert("cursor".to_string(), cursor.clone()); 80 } 81 + let proxy_result = match proxy_to_appview_via_registry( 82 + state, 83 "app.bsky.feed.getTimeline", 84 &query_params, 85 auth_did,
+6 -6
src/api/notification/register_push.rs
··· 53 if input.app_id.is_empty() || input.app_id.len() > 256 { 54 return ApiError::InvalidRequest("Invalid appId".to_string()).into_response(); 55 } 56 - let appview_url = match std::env::var("APPVIEW_URL") { 57 - Ok(url) => url, 58 - Err(_) => { 59 - return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()) 60 .into_response(); 61 } 62 }; 63 - if let Err(e) = is_ssrf_safe(&appview_url) { 64 error!("SSRF check failed for appview URL: {}", e); 65 return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)) 66 .into_response(); ··· 102 return ApiError::InternalError.into_response(); 103 } 104 }; 105 - let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", appview_url); 106 info!( 107 target = %target_url, 108 service_did = %input.service_did,
··· 53 if input.app_id.is_empty() || input.app_id.len() > 256 { 54 return ApiError::InvalidRequest("Invalid appId".to_string()).into_response(); 55 } 56 + let resolved = match state.appview_registry.get_appview_for_method("app.bsky.notification.registerPush").await { 57 + Some(r) => r, 58 + None => { 59 + return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.notification.registerPush".to_string()) 60 .into_response(); 61 } 62 }; 63 + if let Err(e) = is_ssrf_safe(&resolved.url) { 64 error!("SSRF check failed for appview URL: {}", e); 65 return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)) 66 .into_response(); ··· 102 return ApiError::InternalError.into_response(); 103 } 104 }; 105 + let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", resolved.url); 106 info!( 107 target = %target_url, 108 service_did = %input.service_did,
+51 -45
src/api/proxy.rs
··· 2 use crate::state::AppState; 3 use axum::{ 4 body::Bytes, 5 - extract::{Path, Query, State}, 6 http::{HeaderMap, Method, StatusCode}, 7 response::{IntoResponse, Response}, 8 }; 9 - use std::collections::HashMap; 10 - use tracing::error; 11 - 12 - fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> { 13 - if let Some(without_prefix) = did_with_fragment.strip_prefix("did:web:") { 14 - let host = without_prefix.split('#').next()?; 15 - let url = format!("https://{}", host); 16 - let did_without_fragment = format!("did:web:{}", host); 17 - Some((url, did_without_fragment)) 18 - } else { 19 - None 20 - } 21 - } 22 23 pub async fn proxy_handler( 24 State(state): State<AppState>, 25 Path(method): Path<String>, 26 method_verb: Method, 27 headers: HeaderMap, 28 - Query(params): Query<HashMap<String, String>>, 29 body: Bytes, 30 ) -> Response { 31 let proxy_header = headers ··· 34 .map(|s| s.to_string()); 35 let (appview_url, service_aud) = match &proxy_header { 36 Some(did_str) => { 37 - let (url, did_without_fragment) = match resolve_service_did(did_str) { 38 - Some(resolved) => resolved, 39 None => { 40 error!(did = %did_str, "Could not resolve service DID"); 41 return (StatusCode::BAD_GATEWAY, "Could not resolve service DID") 42 .into_response(); 43 } 44 - }; 45 - (url, Some(did_without_fragment)) 46 } 47 None => { 48 - let url = match std::env::var("APPVIEW_URL") { 49 - Ok(url) => url, 50 - Err(_) => { 51 - return (StatusCode::BAD_GATEWAY, "No upstream AppView configured") 52 .into_response(); 53 } 54 - }; 55 - let aud = std::env::var("APPVIEW_DID").ok(); 56 - (url, aud) 57 } 58 }; 59 - let target_url = format!("{}/xrpc/{}", appview_url, method); 60 let client = proxy_client(); 61 - let mut request_builder = client.request(method_verb, &target_url).query(&params); 62 let mut auth_header_val = headers.get("Authorization").cloned(); 63 - if let Some(aud) = &service_aud 64 - && let Some(token) = crate::auth::extract_bearer_token_from_header( 65 headers.get("Authorization").and_then(|h| h.to_str().ok()), 66 - ) 67 - && let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await 68 - && let Some(key_bytes) = auth_user.key_bytes 69 - && let Ok(new_token) = 70 - crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes) 71 - && let Ok(val) = 72 - axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) 73 - { 74 - auth_header_val = Some(val); 75 } 76 if let Some(val) = auth_header_val { 77 request_builder = request_builder.header("Authorization", val); 78 } 79 - for (key, value) in headers.iter() { 80 - if key != "host" && key != "content-length" && key != "authorization" { 81 - request_builder = request_builder.header(key, value); 82 } 83 } 84 - request_builder = request_builder.body(body); 85 match request_builder.send().await { 86 Ok(resp) => { 87 let status = resp.status(); ··· 95 } 96 }; 97 let mut response_builder = Response::builder().status(status); 98 - for (key, value) in headers.iter() { 99 - response_builder = response_builder.header(key, value); 100 } 101 match response_builder.body(axum::body::Body::from(body)) { 102 Ok(r) => r,
··· 2 use crate::state::AppState; 3 use axum::{ 4 body::Bytes, 5 + extract::{Path, RawQuery, State}, 6 http::{HeaderMap, Method, StatusCode}, 7 response::{IntoResponse, Response}, 8 }; 9 + use tracing::{error, info, warn}; 10 11 pub async fn proxy_handler( 12 State(state): State<AppState>, 13 Path(method): Path<String>, 14 method_verb: Method, 15 headers: HeaderMap, 16 + RawQuery(query): RawQuery, 17 body: Bytes, 18 ) -> Response { 19 let proxy_header = headers ··· 22 .map(|s| s.to_string()); 23 let (appview_url, service_aud) = match &proxy_header { 24 Some(did_str) => { 25 + let did_without_fragment = did_str.split('#').next().unwrap_or(did_str).to_string(); 26 + match state.appview_registry.resolve_appview_did(&did_without_fragment).await { 27 + Some(resolved) => (resolved.url, Some(resolved.did)), 28 None => { 29 error!(did = %did_str, "Could not resolve service DID"); 30 return (StatusCode::BAD_GATEWAY, "Could not resolve service DID") 31 .into_response(); 32 } 33 + } 34 } 35 None => { 36 + match state.appview_registry.get_appview_for_method(&method).await { 37 + Some(resolved) => (resolved.url, Some(resolved.did)), 38 + None => { 39 + return (StatusCode::BAD_GATEWAY, "No upstream AppView configured for this method") 40 .into_response(); 41 } 42 + } 43 } 44 }; 45 + let target_url = match &query { 46 + Some(q) => format!("{}/xrpc/{}?{}", appview_url, method, q), 47 + None => format!("{}/xrpc/{}", appview_url, method), 48 + }; 49 + info!("Proxying {} request to {}", method_verb, target_url); 50 let client = proxy_client(); 51 + let mut request_builder = client.request(method_verb, &target_url); 52 let mut auth_header_val = headers.get("Authorization").cloned(); 53 + if let Some(aud) = &service_aud { 54 + if let Some(token) = crate::auth::extract_bearer_token_from_header( 55 headers.get("Authorization").and_then(|h| h.to_str().ok()), 56 + ) { 57 + match crate::auth::validate_bearer_token(&state.db, &token).await { 58 + Ok(auth_user) => { 59 + if let Some(key_bytes) = auth_user.key_bytes { 60 + match crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes) { 61 + Ok(new_token) => { 62 + if let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) { 63 + auth_header_val = Some(val); 64 + } 65 + } 66 + Err(e) => { 67 + warn!("Failed to create service token: {:?}", e); 68 + } 69 } 70 + } 71 + } 72 + Err(e) => { 73 + warn!("Token validation failed: {:?}", e); 74 + } 75 + } 76 + } 77 + } 78 if let Some(val) = auth_header_val { 79 request_builder = request_builder.header("Authorization", val); 80 } 81 + for header_name in crate::api::proxy_client::HEADERS_TO_FORWARD { 82 + if let Some(val) = headers.get(*header_name) { 83 + request_builder = request_builder.header(*header_name, val); 84 } 85 } 86 + if !body.is_empty() { 87 + request_builder = request_builder.body(body); 88 + } 89 match request_builder.send().await { 90 Ok(resp) => { 91 let status = resp.status(); ··· 99 } 100 }; 101 let mut response_builder = Response::builder().status(status); 102 + for header_name in crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD { 103 + if let Some(val) = headers.get(*header_name) { 104 + response_builder = response_builder.header(*header_name, val); 105 + } 106 } 107 match response_builder.body(axum::body::Body::from(body)) { 108 Ok(r) => r,
+3
src/api/proxy_client.rs
··· 121 "accept-language", 122 "atproto-accept-labelers", 123 "x-bsky-topics", 124 ]; 125 pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[ 126 "atproto-repo-rev", 127 "atproto-content-labelers", 128 "retry-after", 129 "content-type", 130 ]; 131 132 pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
··· 121 "accept-language", 122 "atproto-accept-labelers", 123 "x-bsky-topics", 124 + "content-type", 125 ]; 126 pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[ 127 "atproto-repo-rev", 128 "atproto-content-labelers", 129 "retry-after", 130 "content-type", 131 + "cache-control", 132 + "etag", 133 ]; 134 135 pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
+17 -7
src/api/read_after_write.rs
··· 238 } 239 } 240 241 - pub async fn proxy_to_appview( 242 method: &str, 243 params: &HashMap<String, String>, 244 auth_did: &str, 245 auth_key_bytes: Option<&[u8]>, 246 ) -> Result<ProxyResponse, Response> { 247 - let appview_url = std::env::var("APPVIEW_URL").map_err(|_| { 248 - ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()).into_response() 249 })?; 250 - if let Err(e) = is_ssrf_safe(&appview_url) { 251 error!("SSRF check failed for appview URL: {}", e); 252 return Err( 253 ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(), ··· 258 let client = proxy_client(); 259 let mut request_builder = client.get(&target_url).query(params); 260 if let Some(key_bytes) = auth_key_bytes { 261 - let appview_did = 262 - std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); 263 - match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) { 264 Ok(service_token) => { 265 request_builder = 266 request_builder.header("Authorization", format!("Bearer {}", service_token));
··· 238 } 239 } 240 241 + pub async fn proxy_to_appview_via_registry( 242 + state: &AppState, 243 method: &str, 244 params: &HashMap<String, String>, 245 auth_did: &str, 246 auth_key_bytes: Option<&[u8]>, 247 ) -> Result<ProxyResponse, Response> { 248 + let resolved = state.appview_registry.get_appview_for_method(method).await.ok_or_else(|| { 249 + ApiError::UpstreamUnavailable(format!("No AppView configured for method: {}", method)).into_response() 250 })?; 251 + proxy_to_appview_with_url(method, params, auth_did, auth_key_bytes, &resolved.url, &resolved.did).await 252 + } 253 + 254 + pub async fn proxy_to_appview_with_url( 255 + method: &str, 256 + params: &HashMap<String, String>, 257 + auth_did: &str, 258 + auth_key_bytes: Option<&[u8]>, 259 + appview_url: &str, 260 + appview_did: &str, 261 + ) -> Result<ProxyResponse, Response> { 262 + if let Err(e) = is_ssrf_safe(appview_url) { 263 error!("SSRF check failed for appview URL: {}", e); 264 return Err( 265 ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(), ··· 270 let client = proxy_client(); 271 let mut request_builder = client.get(&target_url).query(params); 272 if let Some(key_bytes) = auth_key_bytes { 273 + match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) { 274 Ok(service_token) => { 275 request_builder = 276 request_builder.header("Authorization", format!("Bearer {}", service_token));
+56 -6
src/api/repo/meta.rs
··· 1 use crate::state::AppState; 2 use axum::{ 3 Json, 4 - extract::{Query, State}, 5 http::StatusCode, 6 response::{IntoResponse, Response}, 7 }; 8 use serde::Deserialize; 9 use serde_json::json; 10 11 #[derive(Deserialize)] 12 pub struct DescribeRepoInput { 13 pub repo: String, 14 } 15 16 pub async fn describe_repo( 17 State(state): State<AppState>, 18 Query(input): Query<DescribeRepoInput>, 19 ) -> Response { 20 let user_row = if input.repo.starts_with("did:") { 21 sqlx::query!( ··· 37 let (user_id, handle, did) = match user_row { 38 Ok(Some((id, handle, did))) => (id, handle, did), 39 _ => { 40 - return ( 41 - StatusCode::NOT_FOUND, 42 - Json(json!({"error": "NotFound", "message": "Repo not found"})), 43 - ) 44 - .into_response(); 45 } 46 }; 47 let collections_query = sqlx::query!(
··· 1 + use crate::api::proxy_client::proxy_client; 2 use crate::state::AppState; 3 use axum::{ 4 Json, 5 + extract::{Query, RawQuery, State}, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; 9 use serde::Deserialize; 10 use serde_json::json; 11 + use tracing::{error, info}; 12 13 #[derive(Deserialize)] 14 pub struct DescribeRepoInput { 15 pub repo: String, 16 } 17 18 + async fn proxy_describe_repo_to_appview(state: &AppState, raw_query: Option<&str>) -> Response { 19 + let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.describeRepo").await { 20 + Some(r) => r, 21 + None => { 22 + return ( 23 + StatusCode::NOT_FOUND, 24 + Json(json!({"error": "NotFound", "message": "Repo not found"})), 25 + ) 26 + .into_response(); 27 + } 28 + }; 29 + let target_url = match raw_query { 30 + Some(q) => format!("{}/xrpc/com.atproto.repo.describeRepo?{}", resolved.url, q), 31 + None => format!("{}/xrpc/com.atproto.repo.describeRepo", resolved.url), 32 + }; 33 + info!("Proxying describeRepo to AppView: {}", target_url); 34 + let client = proxy_client(); 35 + match client.get(&target_url).send().await { 36 + Ok(resp) => { 37 + let status = 38 + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); 39 + let content_type = resp 40 + .headers() 41 + .get("content-type") 42 + .and_then(|v| v.to_str().ok()) 43 + .map(|s| s.to_string()); 44 + match resp.bytes().await { 45 + Ok(body) => { 46 + let mut builder = Response::builder().status(status); 47 + if let Some(ct) = content_type { 48 + builder = builder.header("content-type", ct); 49 + } 50 + builder 51 + .body(axum::body::Body::from(body)) 52 + .unwrap_or_else(|_| { 53 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 54 + }) 55 + } 56 + Err(e) => { 57 + error!("Error reading AppView response: {:?}", e); 58 + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() 59 + } 60 + } 61 + } 62 + Err(e) => { 63 + error!("Error proxying to AppView: {:?}", e); 64 + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() 65 + } 66 + } 67 + } 68 + 69 pub async fn describe_repo( 70 State(state): State<AppState>, 71 Query(input): Query<DescribeRepoInput>, 72 + RawQuery(raw_query): RawQuery, 73 ) -> Response { 74 let user_row = if input.repo.starts_with("did:") { 75 sqlx::query!( ··· 91 let (user_id, handle, did) = match user_row { 92 Ok(Some((id, handle, did))) => (id, handle, did), 93 _ => { 94 + return proxy_describe_repo_to_appview(&state, raw_query.as_deref()).await; 95 } 96 }; 97 let collections_query = sqlx::query!(
+118 -12
src/api/repo/record/read.rs
··· 1 use crate::state::AppState; 2 use axum::{ 3 Json, 4 - extract::{Query, State}, 5 http::StatusCode, 6 response::{IntoResponse, Response}, 7 }; ··· 11 use serde_json::json; 12 use std::collections::HashMap; 13 use std::str::FromStr; 14 - use tracing::error; 15 16 #[derive(Deserialize)] 17 pub struct GetRecordInput { ··· 21 pub cid: Option<String>, 22 } 23 24 pub async fn get_record( 25 State(state): State<AppState>, 26 Query(input): Query<GetRecordInput>, 27 ) -> Response { 28 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 29 let user_id_opt = if input.repo.starts_with("did:") { ··· 46 let user_id: uuid::Uuid = match user_id_opt { 47 Ok(Some(id)) => id, 48 _ => { 49 - return ( 50 - StatusCode::NOT_FOUND, 51 - Json(json!({"error": "NotFound", "message": "Repo not found"})), 52 - ) 53 - .into_response(); 54 } 55 }; 56 let record_row = sqlx::query!( ··· 134 pub cursor: Option<String>, 135 pub records: Vec<serde_json::Value>, 136 } 137 pub async fn list_records( 138 State(state): State<AppState>, 139 Query(input): Query<ListRecordsInput>, 140 ) -> Response { 141 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 142 let user_id_opt = if input.repo.starts_with("did:") { ··· 159 let user_id: uuid::Uuid = match user_id_opt { 160 Ok(Some(id)) => id, 161 _ => { 162 - return ( 163 - StatusCode::NOT_FOUND, 164 - Json(json!({"error": "NotFound", "message": "Repo not found"})), 165 - ) 166 - .into_response(); 167 } 168 }; 169 let limit = input.limit.unwrap_or(50).clamp(1, 100);
··· 1 + use crate::api::proxy_client::proxy_client; 2 use crate::state::AppState; 3 use axum::{ 4 Json, 5 + extract::{Query, RawQuery, State}, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; ··· 12 use serde_json::json; 13 use std::collections::HashMap; 14 use std::str::FromStr; 15 + use tracing::{error, info}; 16 17 #[derive(Deserialize)] 18 pub struct GetRecordInput { ··· 22 pub cid: Option<String>, 23 } 24 25 + async fn proxy_get_record_to_appview(state: &AppState, raw_query: Option<&str>) -> Response { 26 + let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.getRecord").await { 27 + Some(r) => r, 28 + None => { 29 + return ( 30 + StatusCode::NOT_FOUND, 31 + Json(json!({"error": "NotFound", "message": "Repo not found"})), 32 + ) 33 + .into_response(); 34 + } 35 + }; 36 + let target_url = match raw_query { 37 + Some(q) => format!("{}/xrpc/com.atproto.repo.getRecord?{}", resolved.url, q), 38 + None => format!("{}/xrpc/com.atproto.repo.getRecord", resolved.url), 39 + }; 40 + info!("Proxying getRecord to AppView: {}", target_url); 41 + let client = proxy_client(); 42 + match client.get(&target_url).send().await { 43 + Ok(resp) => { 44 + let status = 45 + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); 46 + let content_type = resp 47 + .headers() 48 + .get("content-type") 49 + .and_then(|v| v.to_str().ok()) 50 + .map(|s| s.to_string()); 51 + match resp.bytes().await { 52 + Ok(body) => { 53 + let mut builder = Response::builder().status(status); 54 + if let Some(ct) = content_type { 55 + builder = builder.header("content-type", ct); 56 + } 57 + builder 58 + .body(axum::body::Body::from(body)) 59 + .unwrap_or_else(|_| { 60 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 61 + }) 62 + } 63 + Err(e) => { 64 + error!("Error reading AppView response: {:?}", e); 65 + ( 66 + StatusCode::BAD_GATEWAY, 67 + Json(json!({"error": "UpstreamError"})), 68 + ) 69 + .into_response() 70 + } 71 + } 72 + } 73 + Err(e) => { 74 + error!("Error proxying to AppView: {:?}", e); 75 + ( 76 + StatusCode::BAD_GATEWAY, 77 + Json(json!({"error": "UpstreamError"})), 78 + ) 79 + .into_response() 80 + } 81 + } 82 + } 83 + 84 pub async fn get_record( 85 State(state): State<AppState>, 86 Query(input): Query<GetRecordInput>, 87 + RawQuery(raw_query): RawQuery, 88 ) -> Response { 89 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 90 let user_id_opt = if input.repo.starts_with("did:") { ··· 107 let user_id: uuid::Uuid = match user_id_opt { 108 Ok(Some(id)) => id, 109 _ => { 110 + return proxy_get_record_to_appview(&state, raw_query.as_deref()).await; 111 } 112 }; 113 let record_row = sqlx::query!( ··· 191 pub cursor: Option<String>, 192 pub records: Vec<serde_json::Value>, 193 } 194 + 195 + async fn proxy_list_records_to_appview(state: &AppState, raw_query: Option<&str>) -> Response { 196 + let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.listRecords").await { 197 + Some(r) => r, 198 + None => { 199 + return ( 200 + StatusCode::NOT_FOUND, 201 + Json(json!({"error": "NotFound", "message": "Repo not found"})), 202 + ) 203 + .into_response(); 204 + } 205 + }; 206 + let target_url = match raw_query { 207 + Some(q) => format!("{}/xrpc/com.atproto.repo.listRecords?{}", resolved.url, q), 208 + None => format!("{}/xrpc/com.atproto.repo.listRecords", resolved.url), 209 + }; 210 + info!("Proxying listRecords to AppView: {}", target_url); 211 + let client = proxy_client(); 212 + match client.get(&target_url).send().await { 213 + Ok(resp) => { 214 + let status = 215 + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); 216 + let content_type = resp 217 + .headers() 218 + .get("content-type") 219 + .and_then(|v| v.to_str().ok()) 220 + .map(|s| s.to_string()); 221 + match resp.bytes().await { 222 + Ok(body) => { 223 + let mut builder = Response::builder().status(status); 224 + if let Some(ct) = content_type { 225 + builder = builder.header("content-type", ct); 226 + } 227 + builder 228 + .body(axum::body::Body::from(body)) 229 + .unwrap_or_else(|_| { 230 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 231 + }) 232 + } 233 + Err(e) => { 234 + error!("Error reading AppView response: {:?}", e); 235 + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() 236 + } 237 + } 238 + } 239 + Err(e) => { 240 + error!("Error proxying to AppView: {:?}", e); 241 + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() 242 + } 243 + } 244 + } 245 + 246 pub async fn list_records( 247 State(state): State<AppState>, 248 Query(input): Query<ListRecordsInput>, 249 + RawQuery(raw_query): RawQuery, 250 ) -> Response { 251 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 252 let user_id_opt = if input.repo.starts_with("did:") { ··· 269 let user_id: uuid::Uuid = match user_id_opt { 270 Ok(Some(id)) => id, 271 _ => { 272 + return proxy_list_records_to_appview(&state, raw_query.as_deref()).await; 273 } 274 }; 275 let limit = input.limit.unwrap_or(50).clamp(1, 100);
+3 -3
src/api/server/mod.rs
··· 16 pub use email::{confirm_email, request_email_update, update_email}; 17 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 18 pub use meta::{describe_server, health, robots_txt}; 19 - pub use password::{request_password_reset, reset_password}; 20 pub use service_auth::get_service_auth; 21 pub use session::{ 22 - confirm_signup, create_session, delete_session, get_session, refresh_session, 23 - resend_verification, 24 }; 25 pub use signing_key::reserve_signing_key;
··· 16 pub use email::{confirm_email, request_email_update, update_email}; 17 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 18 pub use meta::{describe_server, health, robots_txt}; 19 + pub use password::{change_password, request_password_reset, reset_password}; 20 pub use service_auth::get_service_auth; 21 pub use session::{ 22 + confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session, 23 + resend_verification, revoke_session, 24 }; 25 pub use signing_key::reserve_signing_key;
+108 -1
src/api/server/password.rs
··· 1 use crate::state::{AppState, RateLimitKind}; 2 use axum::{ 3 Json, ··· 5 http::{HeaderMap, StatusCode}, 6 response::{IntoResponse, Response}, 7 }; 8 - use bcrypt::{DEFAULT_COST, hash}; 9 use chrono::{Duration, Utc}; 10 use serde::Deserialize; 11 use serde_json::json; 12 use tracing::{error, info, warn}; ··· 297 info!("Password reset completed for user {}", user_id); 298 (StatusCode::OK, Json(json!({}))).into_response() 299 }
··· 1 + use crate::auth::BearerAuth; 2 use crate::state::{AppState, RateLimitKind}; 3 use axum::{ 4 Json, ··· 6 http::{HeaderMap, StatusCode}, 7 response::{IntoResponse, Response}, 8 }; 9 + use bcrypt::{DEFAULT_COST, hash, verify}; 10 use chrono::{Duration, Utc}; 11 + use uuid::Uuid; 12 use serde::Deserialize; 13 use serde_json::json; 14 use tracing::{error, info, warn}; ··· 299 info!("Password reset completed for user {}", user_id); 300 (StatusCode::OK, Json(json!({}))).into_response() 301 } 302 + 303 + #[derive(Deserialize)] 304 + #[serde(rename_all = "camelCase")] 305 + pub struct ChangePasswordInput { 306 + pub current_password: String, 307 + pub new_password: String, 308 + } 309 + 310 + pub async fn change_password( 311 + State(state): State<AppState>, 312 + auth: BearerAuth, 313 + Json(input): Json<ChangePasswordInput>, 314 + ) -> Response { 315 + let current_password = &input.current_password; 316 + let new_password = &input.new_password; 317 + if current_password.is_empty() { 318 + return ( 319 + StatusCode::BAD_REQUEST, 320 + Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})), 321 + ) 322 + .into_response(); 323 + } 324 + if new_password.is_empty() { 325 + return ( 326 + StatusCode::BAD_REQUEST, 327 + Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})), 328 + ) 329 + .into_response(); 330 + } 331 + if new_password.len() < 8 { 332 + return ( 333 + StatusCode::BAD_REQUEST, 334 + Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})), 335 + ) 336 + .into_response(); 337 + } 338 + let user = sqlx::query_as::<_, (Uuid, String)>( 339 + "SELECT id, password_hash FROM users WHERE did = $1", 340 + ) 341 + .bind(&auth.0.did) 342 + .fetch_optional(&state.db) 343 + .await; 344 + let (user_id, password_hash) = match user { 345 + Ok(Some(row)) => row, 346 + Ok(None) => { 347 + return ( 348 + StatusCode::NOT_FOUND, 349 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 350 + ) 351 + .into_response(); 352 + } 353 + Err(e) => { 354 + error!("DB error in change_password: {:?}", e); 355 + return ( 356 + StatusCode::INTERNAL_SERVER_ERROR, 357 + Json(json!({"error": "InternalError"})), 358 + ) 359 + .into_response(); 360 + } 361 + }; 362 + let valid = match verify(current_password, &password_hash) { 363 + Ok(v) => v, 364 + Err(e) => { 365 + error!("Password verification error: {:?}", e); 366 + return ( 367 + StatusCode::INTERNAL_SERVER_ERROR, 368 + Json(json!({"error": "InternalError"})), 369 + ) 370 + .into_response(); 371 + } 372 + }; 373 + if !valid { 374 + return ( 375 + StatusCode::UNAUTHORIZED, 376 + Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})), 377 + ) 378 + .into_response(); 379 + } 380 + let new_hash = match hash(new_password, DEFAULT_COST) { 381 + Ok(h) => h, 382 + Err(e) => { 383 + error!("Failed to hash password: {:?}", e); 384 + return ( 385 + StatusCode::INTERNAL_SERVER_ERROR, 386 + Json(json!({"error": "InternalError"})), 387 + ) 388 + .into_response(); 389 + } 390 + }; 391 + if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") 392 + .bind(&new_hash) 393 + .bind(user_id) 394 + .execute(&state.db) 395 + .await 396 + { 397 + error!("DB error updating password: {:?}", e); 398 + return ( 399 + StatusCode::INTERNAL_SERVER_ERROR, 400 + Json(json!({"error": "InternalError"})), 401 + ) 402 + .into_response(); 403 + } 404 + info!(did = %auth.0.did, "Password changed successfully"); 405 + (StatusCode::OK, Json(json!({}))).into_response() 406 + }
+130 -2
src/api/server/session.rs
··· 185 ) -> Response { 186 match sqlx::query!( 187 r#"SELECT 188 - handle, email, email_confirmed, 189 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 190 discord_verified, telegram_verified, signal_verified 191 FROM users WHERE did = $1"#, ··· 210 "emailConfirmed": row.email_confirmed, 211 "preferredChannel": preferred_channel, 212 "preferredChannelVerified": preferred_channel_verified, 213 "active": true, 214 "didDoc": {} 215 })).into_response() ··· 406 } 407 match sqlx::query!( 408 r#"SELECT 409 - handle, email, email_confirmed, 410 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 411 discord_verified, telegram_verified, signal_verified 412 FROM users WHERE did = $1"#, ··· 433 "emailConfirmed": u.email_confirmed, 434 "preferredChannel": preferred_channel, 435 "preferredChannelVerified": preferred_channel_verified, 436 "active": true 437 })).into_response() 438 } ··· 702 } 703 Json(json!({"success": true})).into_response() 704 }
··· 185 ) -> Response { 186 match sqlx::query!( 187 r#"SELECT 188 + handle, email, email_confirmed, is_admin, 189 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 190 discord_verified, telegram_verified, signal_verified 191 FROM users WHERE did = $1"#, ··· 210 "emailConfirmed": row.email_confirmed, 211 "preferredChannel": preferred_channel, 212 "preferredChannelVerified": preferred_channel_verified, 213 + "isAdmin": row.is_admin, 214 "active": true, 215 "didDoc": {} 216 })).into_response() ··· 407 } 408 match sqlx::query!( 409 r#"SELECT 410 + handle, email, email_confirmed, is_admin, 411 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 412 discord_verified, telegram_verified, signal_verified 413 FROM users WHERE did = $1"#, ··· 434 "emailConfirmed": u.email_confirmed, 435 "preferredChannel": preferred_channel, 436 "preferredChannelVerified": preferred_channel_verified, 437 + "isAdmin": u.is_admin, 438 "active": true 439 })).into_response() 440 } ··· 704 } 705 Json(json!({"success": true})).into_response() 706 } 707 + 708 + #[derive(Serialize)] 709 + #[serde(rename_all = "camelCase")] 710 + pub struct SessionInfo { 711 + pub id: String, 712 + pub created_at: String, 713 + pub expires_at: String, 714 + pub is_current: bool, 715 + } 716 + 717 + #[derive(Serialize)] 718 + #[serde(rename_all = "camelCase")] 719 + pub struct ListSessionsOutput { 720 + pub sessions: Vec<SessionInfo>, 721 + } 722 + 723 + pub async fn list_sessions( 724 + State(state): State<AppState>, 725 + headers: HeaderMap, 726 + auth: BearerAuth, 727 + ) -> Response { 728 + let current_jti = headers 729 + .get("authorization") 730 + .and_then(|v| v.to_str().ok()) 731 + .and_then(|v| v.strip_prefix("Bearer ")) 732 + .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 733 + let result = sqlx::query_as::<_, (i32, String, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>( 734 + r#" 735 + SELECT id, access_jti, created_at, refresh_expires_at 736 + FROM session_tokens 737 + WHERE did = $1 AND refresh_expires_at > NOW() 738 + ORDER BY created_at DESC 739 + "#, 740 + ) 741 + .bind(&auth.0.did) 742 + .fetch_all(&state.db) 743 + .await; 744 + match result { 745 + Ok(rows) => { 746 + let sessions: Vec<SessionInfo> = rows 747 + .into_iter() 748 + .map(|(id, access_jti, created_at, expires_at)| SessionInfo { 749 + id: id.to_string(), 750 + created_at: created_at.to_rfc3339(), 751 + expires_at: expires_at.to_rfc3339(), 752 + is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti), 753 + }) 754 + .collect(); 755 + (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() 756 + } 757 + Err(e) => { 758 + error!("DB error in list_sessions: {:?}", e); 759 + ( 760 + StatusCode::INTERNAL_SERVER_ERROR, 761 + Json(json!({"error": "InternalError"})), 762 + ) 763 + .into_response() 764 + } 765 + } 766 + } 767 + 768 + #[derive(Deserialize)] 769 + #[serde(rename_all = "camelCase")] 770 + pub struct RevokeSessionInput { 771 + pub session_id: String, 772 + } 773 + 774 + pub async fn revoke_session( 775 + State(state): State<AppState>, 776 + auth: BearerAuth, 777 + Json(input): Json<RevokeSessionInput>, 778 + ) -> Response { 779 + let session_id: i32 = match input.session_id.parse() { 780 + Ok(id) => id, 781 + Err(_) => { 782 + return ( 783 + StatusCode::BAD_REQUEST, 784 + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), 785 + ) 786 + .into_response(); 787 + } 788 + }; 789 + let session = sqlx::query_as::<_, (String,)>( 790 + "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 791 + ) 792 + .bind(session_id) 793 + .bind(&auth.0.did) 794 + .fetch_optional(&state.db) 795 + .await; 796 + let access_jti = match session { 797 + Ok(Some((jti,))) => jti, 798 + Ok(None) => { 799 + return ( 800 + StatusCode::NOT_FOUND, 801 + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), 802 + ) 803 + .into_response(); 804 + } 805 + Err(e) => { 806 + error!("DB error in revoke_session: {:?}", e); 807 + return ( 808 + StatusCode::INTERNAL_SERVER_ERROR, 809 + Json(json!({"error": "InternalError"})), 810 + ) 811 + .into_response(); 812 + } 813 + }; 814 + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") 815 + .bind(session_id) 816 + .execute(&state.db) 817 + .await 818 + { 819 + error!("DB error deleting session: {:?}", e); 820 + return ( 821 + StatusCode::INTERNAL_SERVER_ERROR, 822 + Json(json!({"error": "InternalError"})), 823 + ) 824 + .into_response(); 825 + } 826 + let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); 827 + if let Err(e) = state.cache.delete(&cache_key).await { 828 + warn!("Failed to invalidate session cache: {:?}", e); 829 + } 830 + info!(did = %auth.0.did, session_id = %session_id, "Session revoked"); 831 + (StatusCode::OK, Json(json!({}))).into_response() 832 + }
+413
src/appview/mod.rs
···
··· 1 + use reqwest::Client; 2 + use serde::{Deserialize, Serialize}; 3 + use std::collections::HashMap; 4 + use std::time::{Duration, Instant}; 5 + use tokio::sync::RwLock; 6 + use tracing::{debug, error, info, warn}; 7 + 8 + #[derive(Debug, Clone, Serialize, Deserialize)] 9 + pub struct DidDocument { 10 + pub id: String, 11 + #[serde(default)] 12 + pub service: Vec<DidService>, 13 + } 14 + 15 + #[derive(Debug, Clone, Serialize, Deserialize)] 16 + #[serde(rename_all = "camelCase")] 17 + pub struct DidService { 18 + pub id: String, 19 + #[serde(rename = "type")] 20 + pub service_type: String, 21 + pub service_endpoint: String, 22 + } 23 + 24 + #[derive(Clone)] 25 + struct CachedAppView { 26 + url: String, 27 + did: String, 28 + resolved_at: Instant, 29 + } 30 + 31 + pub struct AppViewRegistry { 32 + namespace_to_did: HashMap<String, String>, 33 + did_cache: RwLock<HashMap<String, CachedAppView>>, 34 + client: Client, 35 + cache_ttl: Duration, 36 + plc_directory_url: String, 37 + } 38 + 39 + impl Clone for AppViewRegistry { 40 + fn clone(&self) -> Self { 41 + Self { 42 + namespace_to_did: self.namespace_to_did.clone(), 43 + did_cache: RwLock::new(HashMap::new()), 44 + client: self.client.clone(), 45 + cache_ttl: self.cache_ttl, 46 + plc_directory_url: self.plc_directory_url.clone(), 47 + } 48 + } 49 + } 50 + 51 + #[derive(Debug, Clone)] 52 + pub struct ResolvedAppView { 53 + pub url: String, 54 + pub did: String, 55 + } 56 + 57 + impl AppViewRegistry { 58 + pub fn new() -> Self { 59 + let mut namespace_to_did = HashMap::new(); 60 + 61 + let bsky_did = std::env::var("APPVIEW_DID_BSKY") 62 + .unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); 63 + namespace_to_did.insert("app.bsky".to_string(), bsky_did.clone()); 64 + namespace_to_did.insert("com.atproto".to_string(), bsky_did); 65 + 66 + for (key, value) in std::env::vars() { 67 + if let Some(namespace) = key.strip_prefix("APPVIEW_DID_") { 68 + let namespace = namespace.to_lowercase().replace('_', "."); 69 + if namespace != "bsky" { 70 + namespace_to_did.insert(namespace, value); 71 + } 72 + } 73 + } 74 + 75 + let cache_ttl_secs: u64 = std::env::var("APPVIEW_CACHE_TTL_SECS") 76 + .ok() 77 + .and_then(|v| v.parse().ok()) 78 + .unwrap_or(300); 79 + 80 + let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 81 + .unwrap_or_else(|_| "https://plc.directory".to_string()); 82 + 83 + let client = Client::builder() 84 + .timeout(Duration::from_secs(10)) 85 + .connect_timeout(Duration::from_secs(5)) 86 + .pool_max_idle_per_host(10) 87 + .build() 88 + .unwrap_or_else(|_| Client::new()); 89 + 90 + info!( 91 + "AppView registry initialized with {} namespace mappings", 92 + namespace_to_did.len() 93 + ); 94 + for (ns, did) in &namespace_to_did { 95 + debug!(" {} -> {}", ns, did); 96 + } 97 + 98 + Self { 99 + namespace_to_did, 100 + did_cache: RwLock::new(HashMap::new()), 101 + client, 102 + cache_ttl: Duration::from_secs(cache_ttl_secs), 103 + plc_directory_url, 104 + } 105 + } 106 + 107 + pub fn register_namespace(&mut self, namespace: &str, did: &str) { 108 + info!("Registering AppView: {} -> {}", namespace, did); 109 + self.namespace_to_did 110 + .insert(namespace.to_string(), did.to_string()); 111 + } 112 + 113 + pub async fn get_appview_for_method(&self, method: &str) -> Option<ResolvedAppView> { 114 + let namespace = self.extract_namespace(method)?; 115 + self.get_appview_for_namespace(&namespace).await 116 + } 117 + 118 + pub async fn get_appview_for_namespace(&self, namespace: &str) -> Option<ResolvedAppView> { 119 + let did = self.get_did_for_namespace(namespace)?; 120 + self.resolve_appview_did(&did).await 121 + } 122 + 123 + pub fn get_did_for_namespace(&self, namespace: &str) -> Option<String> { 124 + if let Some(did) = self.namespace_to_did.get(namespace) { 125 + return Some(did.clone()); 126 + } 127 + 128 + let mut parts: Vec<&str> = namespace.split('.').collect(); 129 + while !parts.is_empty() { 130 + let prefix = parts.join("."); 131 + if let Some(did) = self.namespace_to_did.get(&prefix) { 132 + return Some(did.clone()); 133 + } 134 + parts.pop(); 135 + } 136 + 137 + None 138 + } 139 + 140 + pub async fn resolve_appview_did(&self, did: &str) -> Option<ResolvedAppView> { 141 + { 142 + let cache = self.did_cache.read().await; 143 + if let Some(cached) = cache.get(did) { 144 + if cached.resolved_at.elapsed() < self.cache_ttl { 145 + return Some(ResolvedAppView { 146 + url: cached.url.clone(), 147 + did: cached.did.clone(), 148 + }); 149 + } 150 + } 151 + } 152 + 153 + let resolved = self.resolve_did_internal(did).await?; 154 + 155 + { 156 + let mut cache = self.did_cache.write().await; 157 + cache.insert( 158 + did.to_string(), 159 + CachedAppView { 160 + url: resolved.url.clone(), 161 + did: resolved.did.clone(), 162 + resolved_at: Instant::now(), 163 + }, 164 + ); 165 + } 166 + 167 + Some(resolved) 168 + } 169 + 170 + async fn resolve_did_internal(&self, did: &str) -> Option<ResolvedAppView> { 171 + let did_doc = if did.starts_with("did:web:") { 172 + self.resolve_did_web(did).await 173 + } else if did.starts_with("did:plc:") { 174 + self.resolve_did_plc(did).await 175 + } else { 176 + warn!("Unsupported DID method: {}", did); 177 + return None; 178 + }; 179 + 180 + let doc = match did_doc { 181 + Ok(doc) => doc, 182 + Err(e) => { 183 + error!("Failed to resolve DID {}: {}", did, e); 184 + return None; 185 + } 186 + }; 187 + 188 + self.extract_appview_endpoint(&doc) 189 + } 190 + 191 + async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> { 192 + let host = did 193 + .strip_prefix("did:web:") 194 + .ok_or("Invalid did:web format")?; 195 + 196 + let (host, path) = if host.contains(':') { 197 + let decoded = host.replace("%3A", ":"); 198 + let parts: Vec<&str> = decoded.splitn(2, '/').collect(); 199 + if parts.len() > 1 { 200 + (parts[0].to_string(), format!("/{}", parts[1])) 201 + } else { 202 + (decoded, String::new()) 203 + } 204 + } else { 205 + let parts: Vec<&str> = host.splitn(2, ':').collect(); 206 + if parts.len() > 1 && parts[1].contains('/') { 207 + let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); 208 + if path_parts.len() > 1 { 209 + ( 210 + format!("{}:{}", parts[0], path_parts[0]), 211 + format!("/{}", path_parts[1]), 212 + ) 213 + } else { 214 + (host.to_string(), String::new()) 215 + } 216 + } else { 217 + (host.to_string(), String::new()) 218 + } 219 + }; 220 + 221 + let scheme = 222 + if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') 223 + { 224 + "http" 225 + } else { 226 + "https" 227 + }; 228 + 229 + let url = if path.is_empty() { 230 + format!("{}://{}/.well-known/did.json", scheme, host) 231 + } else { 232 + format!("{}://{}{}/did.json", scheme, host, path) 233 + }; 234 + 235 + debug!("Resolving did:web {} via {}", did, url); 236 + 237 + let resp = self 238 + .client 239 + .get(&url) 240 + .send() 241 + .await 242 + .map_err(|e| format!("HTTP request failed: {}", e))?; 243 + 244 + if !resp.status().is_success() { 245 + return Err(format!("HTTP {}", resp.status())); 246 + } 247 + 248 + resp.json::<DidDocument>() 249 + .await 250 + .map_err(|e| format!("Failed to parse DID document: {}", e)) 251 + } 252 + 253 + async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> { 254 + let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); 255 + 256 + debug!("Resolving did:plc {} via {}", did, url); 257 + 258 + let resp = self 259 + .client 260 + .get(&url) 261 + .send() 262 + .await 263 + .map_err(|e| format!("HTTP request failed: {}", e))?; 264 + 265 + if resp.status() == reqwest::StatusCode::NOT_FOUND { 266 + return Err("DID not found".to_string()); 267 + } 268 + 269 + if !resp.status().is_success() { 270 + return Err(format!("HTTP {}", resp.status())); 271 + } 272 + 273 + resp.json::<DidDocument>() 274 + .await 275 + .map_err(|e| format!("Failed to parse DID document: {}", e)) 276 + } 277 + 278 + fn extract_appview_endpoint(&self, doc: &DidDocument) -> Option<ResolvedAppView> { 279 + for service in &doc.service { 280 + if service.service_type == "AtprotoAppView" 281 + || service.id.contains("atproto_appview") 282 + || service.id.ends_with("#bsky_appview") 283 + { 284 + return Some(ResolvedAppView { 285 + url: service.service_endpoint.clone(), 286 + did: doc.id.clone(), 287 + }); 288 + } 289 + } 290 + 291 + for service in &doc.service { 292 + if service.service_type.contains("AppView") || service.id.contains("appview") { 293 + return Some(ResolvedAppView { 294 + url: service.service_endpoint.clone(), 295 + did: doc.id.clone(), 296 + }); 297 + } 298 + } 299 + 300 + if let Some(service) = doc.service.first() { 301 + if service.service_endpoint.starts_with("http") { 302 + warn!( 303 + "No explicit AppView service found for {}, using first service: {}", 304 + doc.id, service.service_endpoint 305 + ); 306 + return Some(ResolvedAppView { 307 + url: service.service_endpoint.clone(), 308 + did: doc.id.clone(), 309 + }); 310 + } 311 + } 312 + 313 + if doc.id.starts_with("did:web:") { 314 + let host = doc.id.strip_prefix("did:web:")?; 315 + let decoded_host = host.replace("%3A", ":"); 316 + let base_host = decoded_host.split('/').next()?; 317 + let scheme = if base_host.starts_with("localhost") 318 + || base_host.starts_with("127.0.0.1") 319 + || base_host.contains(':') 320 + { 321 + "http" 322 + } else { 323 + "https" 324 + }; 325 + warn!( 326 + "No service found for {}, deriving URL from DID: {}://{}", 327 + doc.id, scheme, base_host 328 + ); 329 + return Some(ResolvedAppView { 330 + url: format!("{}://{}", scheme, base_host), 331 + did: doc.id.clone(), 332 + }); 333 + } 334 + 335 + None 336 + } 337 + 338 + fn extract_namespace(&self, method: &str) -> Option<String> { 339 + let parts: Vec<&str> = method.split('.').collect(); 340 + if parts.len() >= 2 { 341 + Some(format!("{}.{}", parts[0], parts[1])) 342 + } else { 343 + None 344 + } 345 + } 346 + 347 + pub fn list_namespaces(&self) -> Vec<(String, String)> { 348 + self.namespace_to_did 349 + .iter() 350 + .map(|(k, v)| (k.clone(), v.clone())) 351 + .collect() 352 + } 353 + 354 + pub async fn invalidate_cache(&self, did: &str) { 355 + let mut cache = self.did_cache.write().await; 356 + cache.remove(did); 357 + } 358 + 359 + pub async fn invalidate_all_cache(&self) { 360 + let mut cache = self.did_cache.write().await; 361 + cache.clear(); 362 + } 363 + } 364 + 365 + impl Default for AppViewRegistry { 366 + fn default() -> Self { 367 + Self::new() 368 + } 369 + } 370 + 371 + pub async fn get_appview_url_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> { 372 + registry.get_appview_for_method(method).await.map(|r| r.url) 373 + } 374 + 375 + pub async fn get_appview_did_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> { 376 + registry.get_appview_for_method(method).await.map(|r| r.did) 377 + } 378 + 379 + #[cfg(test)] 380 + mod tests { 381 + use super::*; 382 + 383 + #[test] 384 + fn test_extract_namespace() { 385 + let registry = AppViewRegistry::new(); 386 + assert_eq!( 387 + registry.extract_namespace("app.bsky.actor.getProfile"), 388 + Some("app.bsky".to_string()) 389 + ); 390 + assert_eq!( 391 + registry.extract_namespace("com.atproto.repo.createRecord"), 392 + Some("com.atproto".to_string()) 393 + ); 394 + assert_eq!( 395 + registry.extract_namespace("com.whtwnd.blog.getPost"), 396 + Some("com.whtwnd".to_string()) 397 + ); 398 + assert_eq!(registry.extract_namespace("invalid"), None); 399 + } 400 + 401 + #[test] 402 + fn test_get_did_for_namespace() { 403 + let mut registry = AppViewRegistry::new(); 404 + registry.register_namespace("com.whtwnd", "did:web:whtwnd.com"); 405 + 406 + assert!(registry.get_did_for_namespace("app.bsky").is_some()); 407 + assert_eq!( 408 + registry.get_did_for_namespace("com.whtwnd"), 409 + Some("did:web:whtwnd.com".to_string()) 410 + ); 411 + assert!(registry.get_did_for_namespace("unknown.namespace").is_none()); 412 + } 413 + }
+19 -6
src/lib.rs
··· 1 pub mod api; 2 pub mod auth; 3 pub mod cache; 4 pub mod circuit_breaker; ··· 48 .route( 49 "/xrpc/com.atproto.server.getSession", 50 get(api::server::get_session), 51 ) 52 .route( 53 "/xrpc/com.atproto.server.deleteSession", ··· 161 get(api::admin::get_account_infos), 162 ) 163 .route( 164 - "/xrpc/com.bspds.admin.createProfile", 165 - post(api::admin::create_profile), 166 - ) 167 - .route( 168 - "/xrpc/com.bspds.admin.createRecord", 169 - post(api::admin::create_record_admin), 170 ) 171 .route( 172 "/xrpc/com.atproto.server.activateAccount", ··· 191 .route( 192 "/xrpc/com.atproto.server.resetPassword", 193 post(api::server::reset_password), 194 ) 195 .route( 196 "/xrpc/com.atproto.server.requestEmailUpdate", ··· 352 get(oauth::endpoints::oauth_authorization_server), 353 ) 354 .route("/oauth/jwks", get(oauth::endpoints::oauth_jwks)) 355 .route( 356 "/oauth/par", 357 post(oauth::endpoints::pushed_authorization_request),
··· 1 pub mod api; 2 + pub mod appview; 3 pub mod auth; 4 pub mod cache; 5 pub mod circuit_breaker; ··· 49 .route( 50 "/xrpc/com.atproto.server.getSession", 51 get(api::server::get_session), 52 + ) 53 + .route( 54 + "/xrpc/com.bspds.account.listSessions", 55 + get(api::server::list_sessions), 56 + ) 57 + .route( 58 + "/xrpc/com.bspds.account.revokeSession", 59 + post(api::server::revoke_session), 60 ) 61 .route( 62 "/xrpc/com.atproto.server.deleteSession", ··· 170 get(api::admin::get_account_infos), 171 ) 172 .route( 173 + "/xrpc/com.atproto.admin.searchAccounts", 174 + get(api::admin::search_accounts), 175 ) 176 .route( 177 "/xrpc/com.atproto.server.activateAccount", ··· 196 .route( 197 "/xrpc/com.atproto.server.resetPassword", 198 post(api::server::reset_password), 199 + ) 200 + .route( 201 + "/xrpc/com.bspds.account.changePassword", 202 + post(api::server::change_password), 203 ) 204 .route( 205 "/xrpc/com.atproto.server.requestEmailUpdate", ··· 361 get(oauth::endpoints::oauth_authorization_server), 362 ) 363 .route("/oauth/jwks", get(oauth::endpoints::oauth_jwks)) 364 + .route( 365 + "/oauth/client-metadata.json", 366 + get(oauth::endpoints::frontend_client_metadata), 367 + ) 368 .route( 369 "/oauth/par", 370 post(oauth::endpoints::pushed_authorization_request),
+17 -5
src/oauth/endpoints/authorize.rs
··· 1 use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; 2 use crate::oauth::{ 3 - Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates, 4 }; 5 use crate::state::{AppState, RateLimitKind}; 6 use axum::{ ··· 196 ) 197 .into_response(); 198 } 199 if wants_json(&headers) { 200 return Json(AuthorizeResponse { 201 client_id: request_data.parameters.client_id.clone(), 202 - client_name: None, 203 scope: request_data.parameters.scope.clone(), 204 redirect_uri: request_data.parameters.redirect_uri.clone(), 205 state: request_data.parameters.state.clone(), ··· 223 .collect(); 224 return Html(templates::account_selector_page( 225 &request_data.parameters.client_id, 226 - None, 227 &request_uri, 228 &device_accounts, 229 )) ··· 231 } 232 Html(templates::login_page( 233 &request_data.parameters.client_id, 234 - None, 235 request_data.parameters.scope.as_deref(), 236 &request_uri, 237 None, ··· 352 )) 353 .into_response(); 354 } 355 let show_login_error = |error_msg: &str, json: bool| -> Response { 356 if json { 357 return ( ··· 365 } 366 Html(templates::login_page( 367 &request_data.parameters.client_id, 368 - None, 369 request_data.parameters.scope.as_deref(), 370 &form.request_uri, 371 Some(error_msg),
··· 1 use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; 2 use crate::oauth::{ 3 + Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates, 4 }; 5 use crate::state::{AppState, RateLimitKind}; 6 use axum::{ ··· 196 ) 197 .into_response(); 198 } 199 + let client_cache = ClientMetadataCache::new(3600); 200 + let client_name = client_cache 201 + .get(&request_data.parameters.client_id) 202 + .await 203 + .ok() 204 + .and_then(|m| m.client_name); 205 if wants_json(&headers) { 206 return Json(AuthorizeResponse { 207 client_id: request_data.parameters.client_id.clone(), 208 + client_name: client_name.clone(), 209 scope: request_data.parameters.scope.clone(), 210 redirect_uri: request_data.parameters.redirect_uri.clone(), 211 state: request_data.parameters.state.clone(), ··· 229 .collect(); 230 return Html(templates::account_selector_page( 231 &request_data.parameters.client_id, 232 + client_name.as_deref(), 233 &request_uri, 234 &device_accounts, 235 )) ··· 237 } 238 Html(templates::login_page( 239 &request_data.parameters.client_id, 240 + client_name.as_deref(), 241 request_data.parameters.scope.as_deref(), 242 &request_uri, 243 None, ··· 358 )) 359 .into_response(); 360 } 361 + let client_cache = ClientMetadataCache::new(3600); 362 + let client_name = client_cache 363 + .get(&request_data.parameters.client_id) 364 + .await 365 + .ok() 366 + .and_then(|m| m.client_name); 367 let show_login_error = |error_msg: &str, json: bool| -> Response { 368 if json { 369 return ( ··· 377 } 378 Html(templates::login_page( 379 &request_data.parameters.client_id, 380 + client_name.as_deref(), 381 request_data.parameters.scope.as_deref(), 382 &form.request_uri, 383 Some(error_msg),
+37
src/oauth/endpoints/metadata.rs
··· 127 }; 128 Json(create_jwk_set(vec![server_key])) 129 }
··· 127 }; 128 Json(create_jwk_set(vec![server_key])) 129 } 130 + 131 + #[derive(Debug, Serialize, Deserialize)] 132 + pub struct FrontendClientMetadata { 133 + pub client_id: String, 134 + pub client_name: String, 135 + pub client_uri: String, 136 + pub redirect_uris: Vec<String>, 137 + pub grant_types: Vec<String>, 138 + pub response_types: Vec<String>, 139 + pub scope: String, 140 + pub token_endpoint_auth_method: String, 141 + pub application_type: String, 142 + pub dpop_bound_access_tokens: bool, 143 + } 144 + 145 + pub async fn frontend_client_metadata( 146 + State(_state): State<AppState>, 147 + ) -> Json<FrontendClientMetadata> { 148 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 149 + let base_url = format!("https://{}", pds_hostname); 150 + let client_id = format!("{}/oauth/client-metadata.json", base_url); 151 + Json(FrontendClientMetadata { 152 + client_id, 153 + client_name: "PDS Account Manager".to_string(), 154 + client_uri: base_url.clone(), 155 + redirect_uris: vec![format!("{}/", base_url)], 156 + grant_types: vec![ 157 + "authorization_code".to_string(), 158 + "refresh_token".to_string(), 159 + ], 160 + response_types: vec!["code".to_string()], 161 + scope: "atproto transition:generic".to_string(), 162 + token_endpoint_auth_method: "none".to_string(), 163 + application_type: "web".to_string(), 164 + dpop_bound_access_tokens: false, 165 + }) 166 + }
+199 -253
src/oauth/templates.rs
··· 1 use chrono::{DateTime, Utc}; 2 3 fn base_styles() -> &'static str { 4 r#" 5 :root { 6 - --primary: #0085ff; 7 - --primary-hover: #0077e6; 8 - --primary-contrast: #ffffff; 9 - --primary-100: #dbeafe; 10 - --primary-400: #60a5fa; 11 - --primary-600-30: rgba(37, 99, 235, 0.3); 12 - --contrast-0: #ffffff; 13 - --contrast-25: #f8f9fa; 14 - --contrast-50: #f1f3f5; 15 - --contrast-100: #e9ecef; 16 - --contrast-200: #dee2e6; 17 - --contrast-300: #ced4da; 18 - --contrast-400: #adb5bd; 19 - --contrast-500: #6b7280; 20 - --contrast-600: #4b5563; 21 - --contrast-700: #374151; 22 - --contrast-800: #1f2937; 23 - --contrast-900: #111827; 24 - --error: #dc2626; 25 - --error-bg: #fef2f2; 26 - --success: #059669; 27 - --success-bg: #ecfdf5; 28 } 29 @media (prefers-color-scheme: dark) { 30 :root { 31 - --contrast-0: #111827; 32 - --contrast-25: #1f2937; 33 - --contrast-50: #374151; 34 - --contrast-100: #4b5563; 35 - --contrast-200: #6b7280; 36 - --contrast-300: #9ca3af; 37 - --contrast-400: #d1d5db; 38 - --contrast-500: #e5e7eb; 39 - --contrast-600: #f3f4f6; 40 - --contrast-700: #f9fafb; 41 - --contrast-800: #ffffff; 42 - --contrast-900: #ffffff; 43 - --error-bg: #451a1a; 44 - --success-bg: #064e3b; 45 } 46 } 47 * { ··· 50 padding: 0; 51 } 52 body { 53 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 54 - background: var(--contrast-50); 55 - color: var(--contrast-900); 56 min-height: 100vh; 57 - display: flex; 58 - align-items: center; 59 - justify-content: center; 60 - padding: 1rem; 61 line-height: 1.5; 62 } 63 .container { 64 - width: 100%; 65 max-width: 400px; 66 - } 67 - .card { 68 - background: var(--contrast-0); 69 - border: 1px solid var(--contrast-100); 70 - border-radius: 0.75rem; 71 - padding: 1.5rem; 72 - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); 73 - } 74 - @media (prefers-color-scheme: dark) { 75 - .card { 76 - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3); 77 - } 78 } 79 h1 { 80 - font-size: 1.5rem; 81 font-weight: 600; 82 - color: var(--contrast-900); 83 - margin-bottom: 0.5rem; 84 } 85 .subtitle { 86 - color: var(--contrast-500); 87 - font-size: 0.875rem; 88 - margin-bottom: 1.5rem; 89 } 90 .subtitle strong { 91 - color: var(--contrast-700); 92 } 93 .client-info { 94 - background: var(--contrast-25); 95 - border-radius: 0.5rem; 96 padding: 1rem; 97 margin-bottom: 1.5rem; 98 } 99 .client-info .client-name { 100 font-weight: 500; 101 - color: var(--contrast-900); 102 display: block; 103 margin-bottom: 0.25rem; 104 } 105 .client-info .scope { 106 - color: var(--contrast-500); 107 font-size: 0.875rem; 108 } 109 .error-banner { 110 background: var(--error-bg); 111 - color: var(--error); 112 - border-radius: 0.5rem; 113 - padding: 0.75rem 1rem; 114 margin-bottom: 1rem; 115 - font-size: 0.875rem; 116 } 117 .form-group { 118 - margin-bottom: 1.25rem; 119 } 120 label { 121 display: block; 122 font-size: 0.875rem; 123 font-weight: 500; 124 - color: var(--contrast-700); 125 - margin-bottom: 0.375rem; 126 } 127 input[type="text"], 128 input[type="email"], 129 input[type="password"] { 130 width: 100%; 131 - padding: 0.625rem 0.875rem; 132 - border: 2px solid var(--contrast-200); 133 - border-radius: 0.375rem; 134 font-size: 1rem; 135 - color: var(--contrast-900); 136 - background: var(--contrast-0); 137 - transition: border-color 0.15s, box-shadow 0.15s; 138 } 139 input[type="text"]:focus, 140 input[type="email"]:focus, 141 input[type="password"]:focus { 142 outline: none; 143 - border-color: var(--primary); 144 - box-shadow: 0 0 0 3px var(--primary-600-30); 145 } 146 input[type="text"]::placeholder, 147 input[type="email"]::placeholder, 148 input[type="password"]::placeholder { 149 - color: var(--contrast-400); 150 } 151 .checkbox-group { 152 display: flex; ··· 155 margin-bottom: 1.5rem; 156 } 157 .checkbox-group input[type="checkbox"] { 158 - width: 1.125rem; 159 - height: 1.125rem; 160 - accent-color: var(--primary); 161 } 162 .checkbox-group label { 163 margin-bottom: 0; 164 font-weight: normal; 165 - color: var(--contrast-600); 166 cursor: pointer; 167 } 168 .buttons { ··· 171 } 172 .btn { 173 flex: 1; 174 - padding: 0.625rem 1.25rem; 175 - border-radius: 0.375rem; 176 font-size: 1rem; 177 - font-weight: 500; 178 cursor: pointer; 179 - transition: background-color 0.15s, transform 0.1s; 180 border: none; 181 text-align: center; 182 text-decoration: none; 183 - display: inline-flex; 184 - align-items: center; 185 - justify-content: center; 186 - } 187 - .btn:active { 188 - transform: scale(0.98); 189 } 190 .btn-primary { 191 - background: var(--primary); 192 - color: var(--primary-contrast); 193 } 194 .btn-primary:hover { 195 - background: var(--primary-hover); 196 } 197 .btn-primary:disabled { 198 - background: var(--primary-400); 199 cursor: not-allowed; 200 } 201 .btn-secondary { 202 - background: var(--contrast-200); 203 - color: var(--contrast-800); 204 } 205 .btn-secondary:hover { 206 - background: var(--contrast-300); 207 } 208 .footer { 209 text-align: center; 210 margin-top: 1.5rem; 211 font-size: 0.75rem; 212 - color: var(--contrast-400); 213 } 214 .accounts { 215 display: flex; ··· 220 .account-item { 221 display: flex; 222 align-items: center; 223 - gap: 0.75rem; 224 width: 100%; 225 - padding: 0.75rem; 226 - background: var(--contrast-25); 227 - border: 1px solid var(--contrast-100); 228 - border-radius: 0.5rem; 229 cursor: pointer; 230 - transition: background-color 0.15s, border-color 0.15s; 231 text-align: left; 232 } 233 .account-item:hover { 234 - background: var(--contrast-50); 235 - border-color: var(--contrast-200); 236 - } 237 - .avatar { 238 - width: 2.5rem; 239 - height: 2.5rem; 240 - border-radius: 50%; 241 - background: var(--primary); 242 - color: var(--primary-contrast); 243 - display: flex; 244 - align-items: center; 245 - justify-content: center; 246 - font-weight: 600; 247 - font-size: 0.875rem; 248 - flex-shrink: 0; 249 } 250 .account-info { 251 flex: 1; 252 min-width: 0; 253 } 254 .account-info .handle { 255 - display: block; 256 font-weight: 500; 257 - color: var(--contrast-900); 258 overflow: hidden; 259 text-overflow: ellipsis; 260 white-space: nowrap; 261 } 262 - .account-info .email { 263 - display: block; 264 - font-size: 0.875rem; 265 - color: var(--contrast-500); 266 overflow: hidden; 267 text-overflow: ellipsis; 268 - white-space: nowrap; 269 } 270 .chevron { 271 - color: var(--contrast-400); 272 font-size: 1.25rem; 273 flex-shrink: 0; 274 } 275 .divider { 276 height: 1px; 277 - background: var(--contrast-100); 278 margin: 1rem 0; 279 } 280 - .link-button { 281 - background: none; 282 - border: none; 283 - color: var(--primary); 284 - cursor: pointer; 285 - font-size: inherit; 286 - padding: 0; 287 - text-decoration: underline; 288 - } 289 - .link-button:hover { 290 - color: var(--primary-hover); 291 - } 292 .new-account-link { 293 display: block; 294 text-align: center; 295 - color: var(--primary); 296 text-decoration: none; 297 font-size: 0.875rem; 298 } ··· 303 text-align: center; 304 margin-top: 1rem; 305 font-size: 0.875rem; 306 - color: var(--contrast-500); 307 } 308 .icon { 309 font-size: 3rem; ··· 311 } 312 .error-code { 313 background: var(--error-bg); 314 - color: var(--error); 315 padding: 0.5rem 1rem; 316 - border-radius: 0.375rem; 317 font-family: monospace; 318 display: inline-block; 319 margin-bottom: 1rem; ··· 323 height: 3rem; 324 border-radius: 50%; 325 background: var(--success-bg); 326 - color: var(--success); 327 display: flex; 328 align-items: center; 329 justify-content: center; ··· 351 login_hint: Option<&str>, 352 ) -> String { 353 let client_display = client_name.unwrap_or(client_id); 354 - let scope_display = scope.unwrap_or("access your account"); 355 let error_html = error_message 356 .map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg))) 357 .unwrap_or_default(); ··· 368 </head> 369 <body> 370 <div class="container"> 371 - <div class="card"> 372 - <h1>Sign in</h1> 373 - <p class="subtitle">to continue to <strong>{client_display}</strong></p> 374 - <div class="client-info"> 375 - <span class="client-name">{client_display}</span> 376 - <span class="scope">wants to {scope_display}</span> 377 </div> 378 - {error_html} 379 - <form method="POST" action="/oauth/authorize"> 380 - <input type="hidden" name="request_uri" value="{request_uri}"> 381 - <div class="form-group"> 382 - <label for="username">Handle or Email</label> 383 - <input type="text" id="username" name="username" value="{login_hint_value}" 384 - required autocomplete="username" autofocus 385 - placeholder="you@example.com"> 386 - </div> 387 - <div class="form-group"> 388 - <label for="password">Password</label> 389 - <input type="password" id="password" name="password" required 390 - autocomplete="current-password" placeholder="Enter your password"> 391 - </div> 392 - <div class="checkbox-group"> 393 - <input type="checkbox" id="remember_device" name="remember_device" value="true"> 394 - <label for="remember_device">Remember this device</label> 395 - </div> 396 - <div class="buttons"> 397 - <button type="submit" class="btn btn-primary">Sign in</button> 398 - <button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button> 399 - </div> 400 - </form> 401 - <div class="footer"> 402 - By signing in, you agree to share your account information with this application. 403 </div> 404 - </div> 405 </div> 406 </body> 407 </html>"#, 408 styles = base_styles(), 409 client_display = html_escape(client_display), 410 - scope_display = html_escape(scope_display), 411 request_uri = html_escape(request_uri), 412 error_html = error_html, 413 login_hint_value = html_escape(login_hint_value), ··· 431 let accounts_html: String = accounts 432 .iter() 433 .map(|account| { 434 - let initials = get_initials(&account.handle); 435 - let email_display = account.email.as_deref().unwrap_or(""); 436 format!( 437 r#"<form method="POST" action="/oauth/authorize/select" style="margin:0"> 438 <input type="hidden" name="request_uri" value="{request_uri}"> 439 <input type="hidden" name="did" value="{did}"> 440 <button type="submit" class="account-item"> 441 - <div class="avatar">{initials}</div> 442 <div class="account-info"> 443 <span class="handle">@{handle}</span> 444 - <span class="email">{email}</span> 445 </div> 446 <span class="chevron">›</span> 447 </button> 448 </form>"#, 449 request_uri = html_escape(request_uri), 450 did = html_escape(&account.did), 451 - initials = html_escape(&initials), 452 handle = html_escape(&account.handle), 453 - email = html_escape(email_display), 454 ) 455 }) 456 .collect(); ··· 466 </head> 467 <body> 468 <div class="container"> 469 - <div class="card"> 470 - <h1>Choose an account</h1> 471 - <p class="subtitle">to continue to <strong>{client_display}</strong></p> 472 - <div class="accounts"> 473 - {accounts_html} 474 - </div> 475 - <div class="divider"></div> 476 - <a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link"> 477 - Sign in with another account 478 - </a> 479 </div> 480 </div> 481 </body> 482 </html>"#, ··· 493 .unwrap_or_default(); 494 let (title, subtitle) = match channel { 495 "email" => ( 496 - "Check your email", 497 "We sent a verification code to your email", 498 ), 499 "Discord" => ( ··· 505 "We sent a verification code to your Telegram", 506 ), 507 "Signal" => ("Check Signal", "We sent a verification code to your Signal"), 508 - _ => ("Check your messages", "We sent you a verification code"), 509 }; 510 format!( 511 r#"<!DOCTYPE html> ··· 519 </head> 520 <body> 521 <div class="container"> 522 - <div class="card"> 523 - <h1>{title}</h1> 524 - <p class="subtitle">{subtitle}</p> 525 - {error_html} 526 - <form method="POST" action="/oauth/authorize/2fa"> 527 - <input type="hidden" name="request_uri" value="{request_uri}"> 528 - <div class="form-group"> 529 - <label for="code">Verification code</label> 530 - <input type="text" id="code" name="code" class="code-input" 531 - placeholder="000000" 532 - pattern="[0-9]{{6}}" maxlength="6" 533 - inputmode="numeric" autocomplete="one-time-code" 534 - autofocus required> 535 - </div> 536 - <button type="submit" class="btn btn-primary" style="width:100%">Verify</button> 537 - </form> 538 - <p class="help-text"> 539 - Code expires in 10 minutes. 540 - </p> 541 - </div> 542 </div> 543 </body> 544 </html>"#, ··· 564 <style>{styles}</style> 565 </head> 566 <body> 567 - <div class="container"> 568 - <div class="card text-center"> 569 - <div class="icon">⚠️</div> 570 - <h1>Authorization Failed</h1> 571 - <div class="error-code">{error}</div> 572 - <p class="subtitle" style="margin-bottom:0">{description}</p> 573 - <div style="margin-top:1.5rem"> 574 - <button onclick="window.close()" class="btn btn-secondary">Close this window</button> 575 - </div> 576 </div> 577 </div> 578 </body> ··· 596 <style>{styles}</style> 597 </head> 598 <body> 599 - <div class="container"> 600 - <div class="card text-center"> 601 - <div class="success-icon">✓</div> 602 - <h1 style="color:var(--success)">Authorization Successful</h1> 603 - <p class="subtitle">{client_display} has been granted access to your account.</p> 604 - <p class="help-text">You can close this window and return to the application.</p> 605 - </div> 606 </div> 607 </body> 608 </html>"#, ··· 617 .replace('>', "&gt;") 618 .replace('"', "&quot;") 619 .replace('\'', "&#39;") 620 - } 621 - 622 - fn get_initials(handle: &str) -> String { 623 - let clean = handle.trim_start_matches('@'); 624 - if clean.is_empty() { 625 - return "?".to_string(); 626 - } 627 - clean 628 - .chars() 629 - .next() 630 - .unwrap_or('?') 631 - .to_uppercase() 632 - .to_string() 633 } 634 635 pub fn mask_email(email: &str) -> String {
··· 1 use chrono::{DateTime, Utc}; 2 3 + fn format_scope_for_display(scope: Option<&str>) -> String { 4 + let scope = scope.unwrap_or(""); 5 + if scope.is_empty() || scope.contains("atproto") || scope.contains("transition:generic") { 6 + return "access your account".to_string(); 7 + } 8 + let parts: Vec<&str> = scope.split_whitespace().collect(); 9 + let friendly: Vec<&str> = parts 10 + .iter() 11 + .filter_map(|s| { 12 + match *s { 13 + "atproto" | "transition:generic" | "transition:chat.bsky" => None, 14 + "read" => Some("read your data"), 15 + "write" => Some("write data"), 16 + other => Some(other), 17 + } 18 + }) 19 + .collect(); 20 + if friendly.is_empty() { 21 + "access your account".to_string() 22 + } else { 23 + friendly.join(", ") 24 + } 25 + } 26 + 27 fn base_styles() -> &'static str { 28 r#" 29 :root { 30 + --bg-primary: #fafafa; 31 + --bg-secondary: #f9f9f9; 32 + --bg-card: #ffffff; 33 + --bg-input: #ffffff; 34 + --text-primary: #333333; 35 + --text-secondary: #666666; 36 + --text-muted: #999999; 37 + --border-color: #dddddd; 38 + --border-color-light: #cccccc; 39 + --accent: #0066cc; 40 + --accent-hover: #0052a3; 41 + --success-bg: #dfd; 42 + --success-border: #8c8; 43 + --success-text: #060; 44 + --error-bg: #fee; 45 + --error-border: #fcc; 46 + --error-text: #c00; 47 } 48 @media (prefers-color-scheme: dark) { 49 :root { 50 + --bg-primary: #1a1a1a; 51 + --bg-secondary: #242424; 52 + --bg-card: #2a2a2a; 53 + --bg-input: #333333; 54 + --text-primary: #e0e0e0; 55 + --text-secondary: #a0a0a0; 56 + --text-muted: #707070; 57 + --border-color: #404040; 58 + --border-color-light: #505050; 59 + --accent: #4da6ff; 60 + --accent-hover: #7abbff; 61 + --success-bg: #1a3d1a; 62 + --success-border: #2d5a2d; 63 + --success-text: #7bc67b; 64 + --error-bg: #3d1a1a; 65 + --error-border: #5a2d2d; 66 + --error-text: #ff7b7b; 67 } 68 } 69 * { ··· 72 padding: 0; 73 } 74 body { 75 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 76 + background: var(--bg-primary); 77 + color: var(--text-primary); 78 min-height: 100vh; 79 line-height: 1.5; 80 } 81 .container { 82 max-width: 400px; 83 + margin: 4rem auto; 84 + padding: 2rem; 85 } 86 h1 { 87 + margin: 0 0 0.5rem 0; 88 font-weight: 600; 89 } 90 .subtitle { 91 + color: var(--text-secondary); 92 + margin: 0 0 2rem 0; 93 } 94 .subtitle strong { 95 + color: var(--text-primary); 96 } 97 .client-info { 98 + background: var(--bg-secondary); 99 + border: 1px solid var(--border-color); 100 + border-radius: 8px; 101 padding: 1rem; 102 margin-bottom: 1.5rem; 103 } 104 .client-info .client-name { 105 font-weight: 500; 106 + color: var(--text-primary); 107 display: block; 108 margin-bottom: 0.25rem; 109 } 110 .client-info .scope { 111 + color: var(--text-secondary); 112 font-size: 0.875rem; 113 } 114 .error-banner { 115 background: var(--error-bg); 116 + border: 1px solid var(--error-border); 117 + color: var(--error-text); 118 + border-radius: 4px; 119 + padding: 0.75rem; 120 margin-bottom: 1rem; 121 } 122 .form-group { 123 + margin-bottom: 1rem; 124 } 125 label { 126 display: block; 127 font-size: 0.875rem; 128 font-weight: 500; 129 + margin-bottom: 0.25rem; 130 } 131 input[type="text"], 132 input[type="email"], 133 input[type="password"] { 134 width: 100%; 135 + padding: 0.75rem; 136 + border: 1px solid var(--border-color-light); 137 + border-radius: 4px; 138 font-size: 1rem; 139 + color: var(--text-primary); 140 + background: var(--bg-input); 141 } 142 input[type="text"]:focus, 143 input[type="email"]:focus, 144 input[type="password"]:focus { 145 outline: none; 146 + border-color: var(--accent); 147 } 148 input[type="text"]::placeholder, 149 input[type="email"]::placeholder, 150 input[type="password"]::placeholder { 151 + color: var(--text-muted); 152 } 153 .checkbox-group { 154 display: flex; ··· 157 margin-bottom: 1.5rem; 158 } 159 .checkbox-group input[type="checkbox"] { 160 + width: 1rem; 161 + height: 1rem; 162 + accent-color: var(--accent); 163 } 164 .checkbox-group label { 165 margin-bottom: 0; 166 font-weight: normal; 167 + color: var(--text-secondary); 168 cursor: pointer; 169 } 170 .buttons { ··· 173 } 174 .btn { 175 flex: 1; 176 + padding: 0.75rem; 177 + border-radius: 4px; 178 font-size: 1rem; 179 cursor: pointer; 180 border: none; 181 text-align: center; 182 text-decoration: none; 183 } 184 .btn-primary { 185 + background: var(--accent); 186 + color: white; 187 } 188 .btn-primary:hover { 189 + background: var(--accent-hover); 190 } 191 .btn-primary:disabled { 192 + opacity: 0.6; 193 cursor: not-allowed; 194 } 195 .btn-secondary { 196 + background: transparent; 197 + color: var(--accent); 198 + border: 1px solid var(--accent); 199 } 200 .btn-secondary:hover { 201 + background: var(--accent); 202 + color: white; 203 } 204 .footer { 205 text-align: center; 206 margin-top: 1.5rem; 207 font-size: 0.75rem; 208 + color: var(--text-muted); 209 } 210 .accounts { 211 display: flex; ··· 216 .account-item { 217 display: flex; 218 align-items: center; 219 + justify-content: space-between; 220 width: 100%; 221 + padding: 1rem; 222 + background: var(--bg-card); 223 + border: 1px solid var(--border-color); 224 + border-radius: 8px; 225 cursor: pointer; 226 + transition: border-color 0.15s, box-shadow 0.15s; 227 text-align: left; 228 } 229 .account-item:hover { 230 + border-color: var(--accent); 231 + box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15); 232 } 233 .account-info { 234 + display: flex; 235 + flex-direction: column; 236 + gap: 0.25rem; 237 flex: 1; 238 min-width: 0; 239 } 240 .account-info .handle { 241 font-weight: 500; 242 + color: var(--text-primary); 243 overflow: hidden; 244 text-overflow: ellipsis; 245 white-space: nowrap; 246 } 247 + .account-info .did { 248 + font-size: 0.75rem; 249 + color: var(--text-muted); 250 + font-family: monospace; 251 overflow: hidden; 252 text-overflow: ellipsis; 253 } 254 .chevron { 255 + color: var(--text-muted); 256 font-size: 1.25rem; 257 flex-shrink: 0; 258 + margin-left: 0.5rem; 259 } 260 .divider { 261 height: 1px; 262 + background: var(--border-color); 263 margin: 1rem 0; 264 } 265 .new-account-link { 266 display: block; 267 text-align: center; 268 + color: var(--accent); 269 text-decoration: none; 270 font-size: 0.875rem; 271 } ··· 276 text-align: center; 277 margin-top: 1rem; 278 font-size: 0.875rem; 279 + color: var(--text-secondary); 280 } 281 .icon { 282 font-size: 3rem; ··· 284 } 285 .error-code { 286 background: var(--error-bg); 287 + border: 1px solid var(--error-border); 288 + color: var(--error-text); 289 padding: 0.5rem 1rem; 290 + border-radius: 4px; 291 font-family: monospace; 292 display: inline-block; 293 margin-bottom: 1rem; ··· 297 height: 3rem; 298 border-radius: 50%; 299 background: var(--success-bg); 300 + border: 1px solid var(--success-border); 301 + color: var(--success-text); 302 display: flex; 303 align-items: center; 304 justify-content: center; ··· 326 login_hint: Option<&str>, 327 ) -> String { 328 let client_display = client_name.unwrap_or(client_id); 329 + let scope_display = format_scope_for_display(scope); 330 let error_html = error_message 331 .map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg))) 332 .unwrap_or_default(); ··· 343 </head> 344 <body> 345 <div class="container"> 346 + <h1>Sign In</h1> 347 + <p class="subtitle">Sign in to continue to <strong>{client_display}</strong></p> 348 + <div class="client-info"> 349 + <span class="client-name">{client_display}</span> 350 + <span class="scope">wants to {scope_display}</span> 351 + </div> 352 + {error_html} 353 + <form method="POST" action="/oauth/authorize"> 354 + <input type="hidden" name="request_uri" value="{request_uri}"> 355 + <div class="form-group"> 356 + <label for="username">Handle</label> 357 + <input type="text" id="username" name="username" value="{login_hint_value}" 358 + required autocomplete="username" autofocus 359 + placeholder="your.handle"> 360 </div> 361 + <div class="form-group"> 362 + <label for="password">Password</label> 363 + <input type="password" id="password" name="password" required 364 + autocomplete="current-password" placeholder="Enter your password"> 365 </div> 366 + <div class="checkbox-group"> 367 + <input type="checkbox" id="remember_device" name="remember_device" value="true"> 368 + <label for="remember_device">Remember this device</label> 369 + </div> 370 + <div class="buttons"> 371 + <button type="submit" class="btn btn-primary">Sign In</button> 372 + <button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button> 373 + </div> 374 + </form> 375 + <p class="help-text"> 376 + By signing in, you agree to share your account information with this application. 377 + </p> 378 </div> 379 </body> 380 </html>"#, 381 styles = base_styles(), 382 client_display = html_escape(client_display), 383 + scope_display = html_escape(&scope_display), 384 request_uri = html_escape(request_uri), 385 error_html = error_html, 386 login_hint_value = html_escape(login_hint_value), ··· 404 let accounts_html: String = accounts 405 .iter() 406 .map(|account| { 407 format!( 408 r#"<form method="POST" action="/oauth/authorize/select" style="margin:0"> 409 <input type="hidden" name="request_uri" value="{request_uri}"> 410 <input type="hidden" name="did" value="{did}"> 411 <button type="submit" class="account-item"> 412 <div class="account-info"> 413 <span class="handle">@{handle}</span> 414 + <span class="did">{did}</span> 415 </div> 416 <span class="chevron">›</span> 417 </button> 418 </form>"#, 419 request_uri = html_escape(request_uri), 420 did = html_escape(&account.did), 421 handle = html_escape(&account.handle), 422 ) 423 }) 424 .collect(); ··· 434 </head> 435 <body> 436 <div class="container"> 437 + <h1>Sign In</h1> 438 + <p class="subtitle">Choose an account to continue to <strong>{client_display}</strong></p> 439 + <div class="accounts"> 440 + {accounts_html} 441 </div> 442 + <div class="divider"></div> 443 + <a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link"> 444 + Sign in to another account 445 + </a> 446 </div> 447 </body> 448 </html>"#, ··· 459 .unwrap_or_default(); 460 let (title, subtitle) = match channel { 461 "email" => ( 462 + "Check Your Email", 463 "We sent a verification code to your email", 464 ), 465 "Discord" => ( ··· 471 "We sent a verification code to your Telegram", 472 ), 473 "Signal" => ("Check Signal", "We sent a verification code to your Signal"), 474 + _ => ("Check Your Messages", "We sent you a verification code"), 475 }; 476 format!( 477 r#"<!DOCTYPE html> ··· 485 </head> 486 <body> 487 <div class="container"> 488 + <h1>{title}</h1> 489 + <p class="subtitle">{subtitle}</p> 490 + {error_html} 491 + <form method="POST" action="/oauth/authorize/2fa"> 492 + <input type="hidden" name="request_uri" value="{request_uri}"> 493 + <div class="form-group"> 494 + <label for="code">Verification Code</label> 495 + <input type="text" id="code" name="code" class="code-input" 496 + placeholder="000000" 497 + pattern="[0-9]{{6}}" maxlength="6" 498 + inputmode="numeric" autocomplete="one-time-code" 499 + autofocus required> 500 + </div> 501 + <button type="submit" class="btn btn-primary" style="width:100%">Verify</button> 502 + </form> 503 + <p class="help-text"> 504 + Code expires in 10 minutes. 505 + </p> 506 </div> 507 </body> 508 </html>"#, ··· 528 <style>{styles}</style> 529 </head> 530 <body> 531 + <div class="container text-center"> 532 + <h1>Authorization Failed</h1> 533 + <div class="error-code">{error}</div> 534 + <p class="subtitle" style="margin-bottom:0">{description}</p> 535 + <div style="margin-top:1.5rem"> 536 + <button onclick="window.close()" class="btn btn-secondary" style="width:100%">Close this window</button> 537 </div> 538 </div> 539 </body> ··· 557 <style>{styles}</style> 558 </head> 559 <body> 560 + <div class="container text-center"> 561 + <div class="success-icon">✓</div> 562 + <h1 style="color:var(--success-text)">Authorization Successful</h1> 563 + <p class="subtitle">{client_display} has been granted access to your account.</p> 564 + <p class="help-text">You can close this window and return to the application.</p> 565 </div> 566 </body> 567 </html>"#, ··· 576 .replace('>', "&gt;") 577 .replace('"', "&quot;") 578 .replace('\'', "&#39;") 579 } 580 581 pub fn mask_email(email: &str) -> String {
+4
src/state.rs
··· 1 use crate::cache::{Cache, DistributedRateLimiter, create_cache}; 2 use crate::circuit_breaker::CircuitBreakers; 3 use crate::config::AuthConfig; ··· 19 pub circuit_breakers: Arc<CircuitBreakers>, 20 pub cache: Arc<dyn Cache>, 21 pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>, 22 } 23 24 pub enum RateLimitKind { ··· 85 let rate_limiters = Arc::new(RateLimiters::new()); 86 let circuit_breakers = Arc::new(CircuitBreakers::new()); 87 let (cache, distributed_rate_limiter) = create_cache().await; 88 89 Self { 90 db, ··· 95 circuit_breakers, 96 cache, 97 distributed_rate_limiter, 98 } 99 } 100
··· 1 + use crate::appview::AppViewRegistry; 2 use crate::cache::{Cache, DistributedRateLimiter, create_cache}; 3 use crate::circuit_breaker::CircuitBreakers; 4 use crate::config::AuthConfig; ··· 20 pub circuit_breakers: Arc<CircuitBreakers>, 21 pub cache: Arc<dyn Cache>, 22 pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>, 23 + pub appview_registry: Arc<AppViewRegistry>, 24 } 25 26 pub enum RateLimitKind { ··· 87 let rate_limiters = Arc::new(RateLimiters::new()); 88 let circuit_breakers = Arc::new(CircuitBreakers::new()); 89 let (cache, distributed_rate_limiter) = create_cache().await; 90 + let appview_registry = Arc::new(AppViewRegistry::new()); 91 92 Self { 93 db, ··· 98 circuit_breakers, 99 cache, 100 distributed_rate_limiter, 101 + appview_registry, 102 } 103 } 104
+163
tests/admin_search.rs
···
··· 1 + mod common; 2 + mod helpers; 3 + use common::*; 4 + use helpers::*; 5 + use reqwest::StatusCode; 6 + use serde_json::Value; 7 + 8 + #[tokio::test] 9 + async fn test_search_accounts_as_admin() { 10 + let client = client(); 11 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 12 + let (user_did, _) = setup_new_user("search-target").await; 13 + let res = client 14 + .get(format!( 15 + "{}/xrpc/com.atproto.admin.searchAccounts", 16 + base_url().await 17 + )) 18 + .bearer_auth(&admin_jwt) 19 + .send() 20 + .await 21 + .expect("Failed to send request"); 22 + assert_eq!(res.status(), StatusCode::OK); 23 + let body: Value = res.json().await.unwrap(); 24 + let accounts = body["accounts"].as_array().expect("accounts should be array"); 25 + assert!(!accounts.is_empty(), "Should return some accounts"); 26 + let found = accounts.iter().any(|a| a["did"].as_str() == Some(&user_did)); 27 + assert!(found, "Should find the created user in results"); 28 + } 29 + 30 + #[tokio::test] 31 + async fn test_search_accounts_with_handle_filter() { 32 + let client = client(); 33 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 34 + let ts = chrono::Utc::now().timestamp_millis(); 35 + let unique_handle = format!("unique-handle-{}.test", ts); 36 + let create_payload = serde_json::json!({ 37 + "handle": unique_handle, 38 + "email": format!("unique-{}@searchtest.com", ts), 39 + "password": "test-password-123" 40 + }); 41 + let create_res = client 42 + .post(format!( 43 + "{}/xrpc/com.atproto.server.createAccount", 44 + base_url().await 45 + )) 46 + .json(&create_payload) 47 + .send() 48 + .await 49 + .expect("Failed to create account"); 50 + assert_eq!(create_res.status(), StatusCode::OK); 51 + let res = client 52 + .get(format!( 53 + "{}/xrpc/com.atproto.admin.searchAccounts?handle={}", 54 + base_url().await, 55 + unique_handle 56 + )) 57 + .bearer_auth(&admin_jwt) 58 + .send() 59 + .await 60 + .expect("Failed to send request"); 61 + assert_eq!(res.status(), StatusCode::OK); 62 + let body: Value = res.json().await.unwrap(); 63 + let accounts = body["accounts"].as_array().unwrap(); 64 + assert_eq!(accounts.len(), 1, "Should find exactly one account with this handle"); 65 + assert_eq!(accounts[0]["handle"].as_str(), Some(unique_handle.as_str())); 66 + } 67 + 68 + #[tokio::test] 69 + async fn test_search_accounts_pagination() { 70 + let client = client(); 71 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 72 + for i in 0..3 { 73 + let _ = setup_new_user(&format!("search-page-{}", i)).await; 74 + } 75 + let res = client 76 + .get(format!( 77 + "{}/xrpc/com.atproto.admin.searchAccounts?limit=2", 78 + base_url().await 79 + )) 80 + .bearer_auth(&admin_jwt) 81 + .send() 82 + .await 83 + .expect("Failed to send request"); 84 + assert_eq!(res.status(), StatusCode::OK); 85 + let body: Value = res.json().await.unwrap(); 86 + let accounts = body["accounts"].as_array().unwrap(); 87 + assert_eq!(accounts.len(), 2, "Should return exactly 2 accounts"); 88 + let cursor = body["cursor"].as_str(); 89 + assert!(cursor.is_some(), "Should have cursor for more results"); 90 + let res2 = client 91 + .get(format!( 92 + "{}/xrpc/com.atproto.admin.searchAccounts?limit=2&cursor={}", 93 + base_url().await, 94 + cursor.unwrap() 95 + )) 96 + .bearer_auth(&admin_jwt) 97 + .send() 98 + .await 99 + .expect("Failed to send request"); 100 + assert_eq!(res2.status(), StatusCode::OK); 101 + let body2: Value = res2.json().await.unwrap(); 102 + let accounts2 = body2["accounts"].as_array().unwrap(); 103 + assert!(!accounts2.is_empty(), "Should return more accounts after cursor"); 104 + let first_page_dids: Vec<&str> = accounts.iter().map(|a| a["did"].as_str().unwrap()).collect(); 105 + let second_page_dids: Vec<&str> = accounts2.iter().map(|a| a["did"].as_str().unwrap()).collect(); 106 + for did in &second_page_dids { 107 + assert!(!first_page_dids.contains(did), "Second page should not repeat first page DIDs"); 108 + } 109 + } 110 + 111 + #[tokio::test] 112 + async fn test_search_accounts_requires_admin() { 113 + let client = client(); 114 + let (_, user_jwt) = setup_new_user("search-nonadmin").await; 115 + let res = client 116 + .get(format!( 117 + "{}/xrpc/com.atproto.admin.searchAccounts", 118 + base_url().await 119 + )) 120 + .bearer_auth(&user_jwt) 121 + .send() 122 + .await 123 + .expect("Failed to send request"); 124 + assert_eq!(res.status(), StatusCode::FORBIDDEN); 125 + } 126 + 127 + #[tokio::test] 128 + async fn test_search_accounts_requires_auth() { 129 + let client = client(); 130 + let res = client 131 + .get(format!( 132 + "{}/xrpc/com.atproto.admin.searchAccounts", 133 + base_url().await 134 + )) 135 + .send() 136 + .await 137 + .expect("Failed to send request"); 138 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 139 + } 140 + 141 + #[tokio::test] 142 + async fn test_search_accounts_returns_expected_fields() { 143 + let client = client(); 144 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 145 + let _ = setup_new_user("search-fields").await; 146 + let res = client 147 + .get(format!( 148 + "{}/xrpc/com.atproto.admin.searchAccounts?limit=1", 149 + base_url().await 150 + )) 151 + .bearer_auth(&admin_jwt) 152 + .send() 153 + .await 154 + .expect("Failed to send request"); 155 + assert_eq!(res.status(), StatusCode::OK); 156 + let body: Value = res.json().await.unwrap(); 157 + let accounts = body["accounts"].as_array().unwrap(); 158 + assert!(!accounts.is_empty()); 159 + let account = &accounts[0]; 160 + assert!(account["did"].as_str().is_some(), "Should have did"); 161 + assert!(account["handle"].as_str().is_some(), "Should have handle"); 162 + assert!(account["indexedAt"].as_str().is_some(), "Should have indexedAt"); 163 + }
+197
tests/change_password.rs
···
··· 1 + mod common; 2 + mod helpers; 3 + use common::*; 4 + use helpers::*; 5 + use reqwest::StatusCode; 6 + use serde_json::{Value, json}; 7 + 8 + #[tokio::test] 9 + async fn test_change_password_success() { 10 + let client = client(); 11 + let ts = chrono::Utc::now().timestamp_millis(); 12 + let handle = format!("change-pw-{}.test", ts); 13 + let email = format!("change-pw-{}@test.com", ts); 14 + let old_password = "old-password-123"; 15 + let new_password = "new-password-456"; 16 + let create_payload = json!({ 17 + "handle": handle, 18 + "email": email, 19 + "password": old_password 20 + }); 21 + let create_res = client 22 + .post(format!( 23 + "{}/xrpc/com.atproto.server.createAccount", 24 + base_url().await 25 + )) 26 + .json(&create_payload) 27 + .send() 28 + .await 29 + .expect("Failed to create account"); 30 + assert_eq!(create_res.status(), StatusCode::OK); 31 + let create_body: Value = create_res.json().await.unwrap(); 32 + let did = create_body["did"].as_str().unwrap(); 33 + let jwt = verify_new_account(&client, did).await; 34 + let change_res = client 35 + .post(format!( 36 + "{}/xrpc/com.bspds.account.changePassword", 37 + base_url().await 38 + )) 39 + .bearer_auth(&jwt) 40 + .json(&json!({ 41 + "currentPassword": old_password, 42 + "newPassword": new_password 43 + })) 44 + .send() 45 + .await 46 + .expect("Failed to change password"); 47 + assert_eq!(change_res.status(), StatusCode::OK); 48 + let login_old = client 49 + .post(format!( 50 + "{}/xrpc/com.atproto.server.createSession", 51 + base_url().await 52 + )) 53 + .json(&json!({ 54 + "identifier": handle, 55 + "password": old_password 56 + })) 57 + .send() 58 + .await 59 + .expect("Failed to try old password"); 60 + assert_eq!(login_old.status(), StatusCode::UNAUTHORIZED, "Old password should not work"); 61 + let login_new = client 62 + .post(format!( 63 + "{}/xrpc/com.atproto.server.createSession", 64 + base_url().await 65 + )) 66 + .json(&json!({ 67 + "identifier": handle, 68 + "password": new_password 69 + })) 70 + .send() 71 + .await 72 + .expect("Failed to try new password"); 73 + assert_eq!(login_new.status(), StatusCode::OK, "New password should work"); 74 + } 75 + 76 + #[tokio::test] 77 + async fn test_change_password_wrong_current() { 78 + let client = client(); 79 + let (_, jwt) = setup_new_user("change-pw-wrong").await; 80 + let res = client 81 + .post(format!( 82 + "{}/xrpc/com.bspds.account.changePassword", 83 + base_url().await 84 + )) 85 + .bearer_auth(&jwt) 86 + .json(&json!({ 87 + "currentPassword": "wrong-password", 88 + "newPassword": "new-password-123" 89 + })) 90 + .send() 91 + .await 92 + .expect("Failed to send request"); 93 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 94 + let body: Value = res.json().await.unwrap(); 95 + assert_eq!(body["error"].as_str(), Some("InvalidPassword")); 96 + } 97 + 98 + #[tokio::test] 99 + async fn test_change_password_too_short() { 100 + let client = client(); 101 + let ts = chrono::Utc::now().timestamp_millis(); 102 + let handle = format!("change-pw-short-{}.test", ts); 103 + let email = format!("change-pw-short-{}@test.com", ts); 104 + let password = "correct-password"; 105 + let create_payload = json!({ 106 + "handle": handle, 107 + "email": email, 108 + "password": password 109 + }); 110 + let create_res = client 111 + .post(format!( 112 + "{}/xrpc/com.atproto.server.createAccount", 113 + base_url().await 114 + )) 115 + .json(&create_payload) 116 + .send() 117 + .await 118 + .expect("Failed to create account"); 119 + assert_eq!(create_res.status(), StatusCode::OK); 120 + let create_body: Value = create_res.json().await.unwrap(); 121 + let did = create_body["did"].as_str().unwrap(); 122 + let jwt = verify_new_account(&client, did).await; 123 + let res = client 124 + .post(format!( 125 + "{}/xrpc/com.bspds.account.changePassword", 126 + base_url().await 127 + )) 128 + .bearer_auth(&jwt) 129 + .json(&json!({ 130 + "currentPassword": password, 131 + "newPassword": "short" 132 + })) 133 + .send() 134 + .await 135 + .expect("Failed to send request"); 136 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 137 + let body: Value = res.json().await.unwrap(); 138 + assert!(body["message"].as_str().unwrap().contains("8 characters")); 139 + } 140 + 141 + #[tokio::test] 142 + async fn test_change_password_empty_current() { 143 + let client = client(); 144 + let (_, jwt) = setup_new_user("change-pw-empty").await; 145 + let res = client 146 + .post(format!( 147 + "{}/xrpc/com.bspds.account.changePassword", 148 + base_url().await 149 + )) 150 + .bearer_auth(&jwt) 151 + .json(&json!({ 152 + "currentPassword": "", 153 + "newPassword": "new-password-123" 154 + })) 155 + .send() 156 + .await 157 + .expect("Failed to send request"); 158 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 159 + } 160 + 161 + #[tokio::test] 162 + async fn test_change_password_empty_new() { 163 + let client = client(); 164 + let (_, jwt) = setup_new_user("change-pw-emptynew").await; 165 + let res = client 166 + .post(format!( 167 + "{}/xrpc/com.bspds.account.changePassword", 168 + base_url().await 169 + )) 170 + .bearer_auth(&jwt) 171 + .json(&json!({ 172 + "currentPassword": "e2e-password-123", 173 + "newPassword": "" 174 + })) 175 + .send() 176 + .await 177 + .expect("Failed to send request"); 178 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 179 + } 180 + 181 + #[tokio::test] 182 + async fn test_change_password_requires_auth() { 183 + let client = client(); 184 + let res = client 185 + .post(format!( 186 + "{}/xrpc/com.bspds.account.changePassword", 187 + base_url().await 188 + )) 189 + .json(&json!({ 190 + "currentPassword": "old", 191 + "newPassword": "new-password-123" 192 + })) 193 + .send() 194 + .await 195 + .expect("Failed to send request"); 196 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 197 + }
+25 -2
tests/common/mod.rs
··· 137 } 138 let mock_server = MockServer::start().await; 139 setup_mock_appview(&mock_server).await; 140 unsafe { 141 - std::env::set_var("APPVIEW_URL", mock_server.uri()); 142 } 143 MOCK_APPVIEW.set(mock_server).ok(); 144 spawn_app(database_url).await ··· 186 let _ = s3_client.create_bucket().bucket("test-bucket").send().await; 187 let mock_server = MockServer::start().await; 188 setup_mock_appview(&mock_server).await; 189 unsafe { 190 - std::env::set_var("APPVIEW_URL", mock_server.uri()); 191 } 192 MOCK_APPVIEW.set(mock_server).ok(); 193 S3_CONTAINER.set(s3_container).ok(); ··· 213 panic!( 214 "Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT." 215 ); 216 } 217 218 async fn setup_mock_appview(mock_server: &MockServer) {
··· 137 } 138 let mock_server = MockServer::start().await; 139 setup_mock_appview(&mock_server).await; 140 + let mock_uri = mock_server.uri(); 141 + let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri); 142 + let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A")); 143 + setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await; 144 unsafe { 145 + std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did); 146 } 147 MOCK_APPVIEW.set(mock_server).ok(); 148 spawn_app(database_url).await ··· 190 let _ = s3_client.create_bucket().bucket("test-bucket").send().await; 191 let mock_server = MockServer::start().await; 192 setup_mock_appview(&mock_server).await; 193 + let mock_uri = mock_server.uri(); 194 + let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri); 195 + let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A")); 196 + setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await; 197 unsafe { 198 + std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did); 199 } 200 MOCK_APPVIEW.set(mock_server).ok(); 201 S3_CONTAINER.set(s3_container).ok(); ··· 221 panic!( 222 "Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT." 223 ); 224 + } 225 + 226 + async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_endpoint: &str) { 227 + Mock::given(method("GET")) 228 + .and(path("/.well-known/did.json")) 229 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 230 + "id": did, 231 + "service": [{ 232 + "id": "#atproto_appview", 233 + "type": "AtprotoAppView", 234 + "serviceEndpoint": service_endpoint 235 + }] 236 + }))) 237 + .mount(mock_server) 238 + .await; 239 } 240 241 async fn setup_mock_appview(mock_server: &MockServer) {
+75
tests/oauth_client_metadata.rs
···
··· 1 + mod common; 2 + use common::*; 3 + use reqwest::StatusCode; 4 + use serde_json::Value; 5 + 6 + #[tokio::test] 7 + async fn test_frontend_client_metadata_returns_valid_json() { 8 + let client = client(); 9 + let res = client 10 + .get(format!( 11 + "{}/oauth/client-metadata.json", 12 + base_url().await 13 + )) 14 + .send() 15 + .await 16 + .expect("Failed to send request"); 17 + assert_eq!(res.status(), StatusCode::OK); 18 + let body: Value = res.json().await.expect("Should return valid JSON"); 19 + assert!(body["client_id"].as_str().is_some(), "Should have client_id"); 20 + assert!(body["client_name"].as_str().is_some(), "Should have client_name"); 21 + assert!(body["redirect_uris"].as_array().is_some(), "Should have redirect_uris"); 22 + assert!(body["grant_types"].as_array().is_some(), "Should have grant_types"); 23 + assert!(body["response_types"].as_array().is_some(), "Should have response_types"); 24 + assert!(body["scope"].as_str().is_some(), "Should have scope"); 25 + assert!(body["token_endpoint_auth_method"].as_str().is_some(), "Should have token_endpoint_auth_method"); 26 + } 27 + 28 + #[tokio::test] 29 + async fn test_frontend_client_metadata_correct_values() { 30 + let client = client(); 31 + let res = client 32 + .get(format!( 33 + "{}/oauth/client-metadata.json", 34 + base_url().await 35 + )) 36 + .send() 37 + .await 38 + .expect("Failed to send request"); 39 + assert_eq!(res.status(), StatusCode::OK); 40 + let body: Value = res.json().await.unwrap(); 41 + let client_id = body["client_id"].as_str().unwrap(); 42 + assert!(client_id.ends_with("/oauth/client-metadata.json"), "client_id should end with /oauth/client-metadata.json"); 43 + let grant_types = body["grant_types"].as_array().unwrap(); 44 + let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect(); 45 + assert!(grant_strs.contains(&"authorization_code"), "Should support authorization_code grant"); 46 + assert!(grant_strs.contains(&"refresh_token"), "Should support refresh_token grant"); 47 + let response_types = body["response_types"].as_array().unwrap(); 48 + let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect(); 49 + assert!(response_strs.contains(&"code"), "Should support code response type"); 50 + assert_eq!(body["token_endpoint_auth_method"].as_str(), Some("none"), "Should be public client (none auth)"); 51 + assert_eq!(body["application_type"].as_str(), Some("web"), "Should be web application"); 52 + assert_eq!(body["dpop_bound_access_tokens"].as_bool(), Some(false), "Should not require DPoP"); 53 + let scope = body["scope"].as_str().unwrap(); 54 + assert!(scope.contains("atproto"), "Scope should include atproto"); 55 + } 56 + 57 + #[tokio::test] 58 + async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() { 59 + let client = client(); 60 + let res = client 61 + .get(format!( 62 + "{}/oauth/client-metadata.json", 63 + base_url().await 64 + )) 65 + .send() 66 + .await 67 + .expect("Failed to send request"); 68 + assert_eq!(res.status(), StatusCode::OK); 69 + let body: Value = res.json().await.unwrap(); 70 + let client_uri = body["client_uri"].as_str().unwrap(); 71 + let redirect_uris = body["redirect_uris"].as_array().unwrap(); 72 + assert!(!redirect_uris.is_empty(), "Should have at least one redirect URI"); 73 + let redirect_uri = redirect_uris[0].as_str().unwrap(); 74 + assert!(redirect_uri.starts_with(client_uri), "Redirect URI should be on same origin as client_uri"); 75 + }
+234
tests/session_management.rs
···
··· 1 + mod common; 2 + mod helpers; 3 + use common::*; 4 + use helpers::*; 5 + use reqwest::StatusCode; 6 + use serde_json::{Value, json}; 7 + 8 + #[tokio::test] 9 + async fn test_list_sessions_returns_current_session() { 10 + let client = client(); 11 + let (did, jwt) = setup_new_user("list-sessions").await; 12 + let res = client 13 + .get(format!( 14 + "{}/xrpc/com.bspds.account.listSessions", 15 + base_url().await 16 + )) 17 + .bearer_auth(&jwt) 18 + .send() 19 + .await 20 + .expect("Failed to send request"); 21 + assert_eq!(res.status(), StatusCode::OK); 22 + let body: Value = res.json().await.unwrap(); 23 + let sessions = body["sessions"].as_array().expect("sessions should be array"); 24 + assert!(!sessions.is_empty(), "Should have at least one session"); 25 + let current = sessions.iter().find(|s| s["isCurrent"].as_bool() == Some(true)); 26 + assert!(current.is_some(), "Should have a current session marked"); 27 + let session = current.unwrap(); 28 + assert!(session["id"].as_str().is_some(), "Session should have id"); 29 + assert!(session["createdAt"].as_str().is_some(), "Session should have createdAt"); 30 + assert!(session["expiresAt"].as_str().is_some(), "Session should have expiresAt"); 31 + let _ = did; 32 + } 33 + 34 + #[tokio::test] 35 + async fn test_list_sessions_multiple_sessions() { 36 + let client = client(); 37 + let ts = chrono::Utc::now().timestamp_millis(); 38 + let handle = format!("multi-list-{}.test", ts); 39 + let email = format!("multi-list-{}@test.com", ts); 40 + let password = "test-password-123"; 41 + let create_payload = json!({ 42 + "handle": handle, 43 + "email": email, 44 + "password": password 45 + }); 46 + let create_res = client 47 + .post(format!( 48 + "{}/xrpc/com.atproto.server.createAccount", 49 + base_url().await 50 + )) 51 + .json(&create_payload) 52 + .send() 53 + .await 54 + .expect("Failed to create account"); 55 + assert_eq!(create_res.status(), StatusCode::OK); 56 + let create_body: Value = create_res.json().await.unwrap(); 57 + let did = create_body["did"].as_str().unwrap(); 58 + let jwt1 = verify_new_account(&client, did).await; 59 + let login_payload = json!({ 60 + "identifier": handle, 61 + "password": password 62 + }); 63 + let login_res = client 64 + .post(format!( 65 + "{}/xrpc/com.atproto.server.createSession", 66 + base_url().await 67 + )) 68 + .json(&login_payload) 69 + .send() 70 + .await 71 + .expect("Failed to login"); 72 + assert_eq!(login_res.status(), StatusCode::OK); 73 + let login_body: Value = login_res.json().await.unwrap(); 74 + let jwt2 = login_body["accessJwt"].as_str().unwrap(); 75 + let list_res = client 76 + .get(format!( 77 + "{}/xrpc/com.bspds.account.listSessions", 78 + base_url().await 79 + )) 80 + .bearer_auth(jwt2) 81 + .send() 82 + .await 83 + .expect("Failed to list sessions"); 84 + assert_eq!(list_res.status(), StatusCode::OK); 85 + let list_body: Value = list_res.json().await.unwrap(); 86 + let sessions = list_body["sessions"].as_array().unwrap(); 87 + assert!(sessions.len() >= 2, "Should have at least 2 sessions, got {}", sessions.len()); 88 + let _ = jwt1; 89 + } 90 + 91 + #[tokio::test] 92 + async fn test_list_sessions_requires_auth() { 93 + let client = client(); 94 + let res = client 95 + .get(format!( 96 + "{}/xrpc/com.bspds.account.listSessions", 97 + base_url().await 98 + )) 99 + .send() 100 + .await 101 + .expect("Failed to send request"); 102 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 103 + } 104 + 105 + #[tokio::test] 106 + async fn test_revoke_session_success() { 107 + let client = client(); 108 + let ts = chrono::Utc::now().timestamp_millis(); 109 + let handle = format!("revoke-sess-{}.test", ts); 110 + let email = format!("revoke-sess-{}@test.com", ts); 111 + let password = "test-password-123"; 112 + let create_payload = json!({ 113 + "handle": handle, 114 + "email": email, 115 + "password": password 116 + }); 117 + let create_res = client 118 + .post(format!( 119 + "{}/xrpc/com.atproto.server.createAccount", 120 + base_url().await 121 + )) 122 + .json(&create_payload) 123 + .send() 124 + .await 125 + .expect("Failed to create account"); 126 + assert_eq!(create_res.status(), StatusCode::OK); 127 + let create_body: Value = create_res.json().await.unwrap(); 128 + let did = create_body["did"].as_str().unwrap(); 129 + let jwt1 = verify_new_account(&client, did).await; 130 + let login_payload = json!({ 131 + "identifier": handle, 132 + "password": password 133 + }); 134 + let login_res = client 135 + .post(format!( 136 + "{}/xrpc/com.atproto.server.createSession", 137 + base_url().await 138 + )) 139 + .json(&login_payload) 140 + .send() 141 + .await 142 + .expect("Failed to login"); 143 + assert_eq!(login_res.status(), StatusCode::OK); 144 + let login_body: Value = login_res.json().await.unwrap(); 145 + let jwt2 = login_body["accessJwt"].as_str().unwrap(); 146 + let list_res = client 147 + .get(format!( 148 + "{}/xrpc/com.bspds.account.listSessions", 149 + base_url().await 150 + )) 151 + .bearer_auth(jwt2) 152 + .send() 153 + .await 154 + .expect("Failed to list sessions"); 155 + let list_body: Value = list_res.json().await.unwrap(); 156 + let sessions = list_body["sessions"].as_array().unwrap(); 157 + let other_session = sessions.iter().find(|s| s["isCurrent"].as_bool() != Some(true)); 158 + assert!(other_session.is_some(), "Should have another session to revoke"); 159 + let session_id = other_session.unwrap()["id"].as_str().unwrap(); 160 + let revoke_res = client 161 + .post(format!( 162 + "{}/xrpc/com.bspds.account.revokeSession", 163 + base_url().await 164 + )) 165 + .bearer_auth(jwt2) 166 + .json(&json!({"sessionId": session_id})) 167 + .send() 168 + .await 169 + .expect("Failed to revoke session"); 170 + assert_eq!(revoke_res.status(), StatusCode::OK); 171 + let list_after_res = client 172 + .get(format!( 173 + "{}/xrpc/com.bspds.account.listSessions", 174 + base_url().await 175 + )) 176 + .bearer_auth(jwt2) 177 + .send() 178 + .await 179 + .expect("Failed to list sessions after revoke"); 180 + let list_after_body: Value = list_after_res.json().await.unwrap(); 181 + let sessions_after = list_after_body["sessions"].as_array().unwrap(); 182 + let revoked_still_exists = sessions_after.iter().any(|s| s["id"].as_str() == Some(session_id)); 183 + assert!(!revoked_still_exists, "Revoked session should not appear in list"); 184 + let _ = jwt1; 185 + } 186 + 187 + #[tokio::test] 188 + async fn test_revoke_session_invalid_id() { 189 + let client = client(); 190 + let (_, jwt) = setup_new_user("revoke-invalid").await; 191 + let res = client 192 + .post(format!( 193 + "{}/xrpc/com.bspds.account.revokeSession", 194 + base_url().await 195 + )) 196 + .bearer_auth(&jwt) 197 + .json(&json!({"sessionId": "not-a-number"})) 198 + .send() 199 + .await 200 + .expect("Failed to send request"); 201 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 202 + } 203 + 204 + #[tokio::test] 205 + async fn test_revoke_session_not_found() { 206 + let client = client(); 207 + let (_, jwt) = setup_new_user("revoke-notfound").await; 208 + let res = client 209 + .post(format!( 210 + "{}/xrpc/com.bspds.account.revokeSession", 211 + base_url().await 212 + )) 213 + .bearer_auth(&jwt) 214 + .json(&json!({"sessionId": "999999999"})) 215 + .send() 216 + .await 217 + .expect("Failed to send request"); 218 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 219 + } 220 + 221 + #[tokio::test] 222 + async fn test_revoke_session_requires_auth() { 223 + let client = client(); 224 + let res = client 225 + .post(format!( 226 + "{}/xrpc/com.bspds.account.revokeSession", 227 + base_url().await 228 + )) 229 + .json(&json!({"sessionId": "1"})) 230 + .send() 231 + .await 232 + .expect("Failed to send request"); 233 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 234 + }