mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-09 13:50:09 +00:00
Better oauth, appview groundwork
This commit is contained in:
21
.env.example
21
.env.example
@@ -48,10 +48,25 @@ AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
# Optional: rotation key for PLC operations (defaults to user's key)
|
||||
# PLC_ROTATION_KEY=did:key:...
|
||||
# =============================================================================
|
||||
# Federation
|
||||
# AppView Federation
|
||||
# =============================================================================
|
||||
# Appview URL for proxying app.bsky.* requests
|
||||
# APPVIEW_URL=https://api.bsky.app
|
||||
# AppViews are resolved via DID-based discovery. Configure by mapping lexicon
|
||||
# namespaces to AppView DIDs. The DID document is fetched and the service
|
||||
# endpoint is extracted automatically.
|
||||
#
|
||||
# Format: APPVIEW_DID_<NAMESPACE>=<did>
|
||||
# Where <NAMESPACE> uses underscores instead of dots (e.g., APP_BSKY for app.bsky)
|
||||
#
|
||||
# Default: app.bsky and com.atproto -> did:web:api.bsky.app
|
||||
#
|
||||
# Examples:
|
||||
# APPVIEW_DID_APP_BSKY=did:web:api.bsky.app
|
||||
# APPVIEW_DID_COM_WHTWND=did:web:whtwnd.com
|
||||
# APPVIEW_DID_BLUE_ZIO=did:plc:some-custom-appview
|
||||
#
|
||||
# Cache TTL for resolved AppView endpoints (default: 300 seconds)
|
||||
# APPVIEW_CACHE_TTL_SECS=300
|
||||
#
|
||||
# Comma-separated list of relay URLs to notify via requestCrawl
|
||||
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,6 +20,11 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "preferred_channel: crate::notifications::NotificationChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
@@ -36,17 +41,17 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 6,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -63,8 +68,9 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b"
|
||||
"hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1"
|
||||
}
|
||||
366
TODO.md
366
TODO.md
@@ -1,284 +1,82 @@
|
||||
# PDS Implementation TODOs
|
||||
Lewis' corrected big boy todofile
|
||||
## Server Infrastructure & Proxying
|
||||
- [x] Health Check
|
||||
- [x] Implement `GET /health` endpoint (returns "OK").
|
||||
- [x] Implement `GET /xrpc/_health` endpoint (returns "OK").
|
||||
- [x] Server Description
|
||||
- [x] Implement `com.atproto.server.describeServer` (returns available user domains).
|
||||
- [x] XRPC Proxying
|
||||
- [x] Implement strict forwarding for all `app.bsky.*` and `chat.bsky.*` requests to an appview.
|
||||
- [x] Forward auth headers correctly.
|
||||
- [x] Handle appview errors/timeouts gracefully.
|
||||
## Authentication & Account Management (`com.atproto.server`)
|
||||
- [x] Account Creation
|
||||
- [x] Implement `com.atproto.server.createAccount`.
|
||||
- [x] Validate handle format (reject invalid characters).
|
||||
- [x] Create DID for new user (PLC directory).
|
||||
- [x] Initialize user repository (Root commit).
|
||||
- [x] Return access JWT and DID.
|
||||
- [x] Create DID for new user (did:web).
|
||||
- [x] Session Management
|
||||
- [x] Implement `com.atproto.server.createSession` (Login).
|
||||
- [x] Implement `com.atproto.server.getSession`.
|
||||
- [x] Implement `com.atproto.server.refreshSession`.
|
||||
- [x] Implement `com.atproto.server.deleteSession` (Logout).
|
||||
- [x] Implement `com.atproto.server.activateAccount`.
|
||||
- [x] Implement `com.atproto.server.checkAccountStatus`.
|
||||
- [x] Implement `com.atproto.server.createAppPassword`.
|
||||
- [x] Implement `com.atproto.server.createInviteCode`.
|
||||
- [x] Implement `com.atproto.server.createInviteCodes`.
|
||||
- [x] Implement `com.atproto.server.deactivateAccount`.
|
||||
- [x] Implement `com.atproto.server.deleteAccount` (user-initiated, requires password + email token).
|
||||
- [x] Implement `com.atproto.server.getAccountInviteCodes`.
|
||||
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
|
||||
- [x] Implement `com.atproto.server.listAppPasswords`.
|
||||
- [x] Implement `com.atproto.server.requestAccountDelete`.
|
||||
- [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
|
||||
- [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
|
||||
- [x] Implement `com.atproto.server.reserveSigningKey`.
|
||||
- [x] Implement `com.atproto.server.revokeAppPassword`.
|
||||
- [x] Implement `com.atproto.server.updateEmail`.
|
||||
- [x] Implement `com.atproto.server.confirmEmail`.
|
||||
## Repository Operations (`com.atproto.repo`)
|
||||
- [x] Record CRUD
|
||||
- [x] Implement `com.atproto.repo.createRecord`.
|
||||
- [x] Generate `rkey` (TID) if not provided.
|
||||
- [x] Handle MST (Merkle Search Tree) insertion.
|
||||
- [x] **Trigger Firehose Event**.
|
||||
- [x] Implement `com.atproto.repo.putRecord`.
|
||||
- [x] Implement `com.atproto.repo.getRecord`.
|
||||
- [x] Implement `com.atproto.repo.deleteRecord`.
|
||||
- [x] Implement `com.atproto.repo.listRecords`.
|
||||
- [x] Implement `com.atproto.repo.describeRepo`.
|
||||
- [x] Implement `com.atproto.repo.applyWrites` (Batch writes).
|
||||
- [x] Implement `com.atproto.repo.importRepo` (Migration).
|
||||
- [x] Implement `com.atproto.repo.listMissingBlobs`.
|
||||
- [x] Blob Management
|
||||
- [x] Implement `com.atproto.repo.uploadBlob`.
|
||||
- [x] Store blob (S3).
|
||||
- [x] return `blob` ref (CID + MimeType).
|
||||
## Sync & Federation (`com.atproto.sync`)
|
||||
- [x] The Firehose (WebSocket)
|
||||
- [x] Implement `com.atproto.sync.subscribeRepos`.
|
||||
- [x] Broadcast real-time commit events.
|
||||
- [x] Handle cursor replay (backfill).
|
||||
- [x] Bulk Export
|
||||
- [x] Implement `com.atproto.sync.getRepo` (Return full CAR file of repo).
|
||||
- [x] Implement `com.atproto.sync.getBlocks` (Return specific blocks via CIDs).
|
||||
- [x] Implement `com.atproto.sync.getLatestCommit`.
|
||||
- [x] Implement `com.atproto.sync.getRecord` (Sync version, distinct from repo.getRecord).
|
||||
- [x] Implement `com.atproto.sync.getRepoStatus`.
|
||||
- [x] Implement `com.atproto.sync.listRepos`.
|
||||
- [x] Implement `com.atproto.sync.notifyOfUpdate`.
|
||||
- [x] Blob Sync
|
||||
- [x] Implement `com.atproto.sync.getBlob`.
|
||||
- [x] Implement `com.atproto.sync.listBlobs`.
|
||||
- [x] Crawler Interaction
|
||||
- [x] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us).
|
||||
- [x] Deprecated Sync Endpoints (for compatibility)
|
||||
- [x] Implement `com.atproto.sync.getCheckout` (deprecated).
|
||||
- [x] Implement `com.atproto.sync.getHead` (deprecated).
|
||||
## Identity (`com.atproto.identity`)
|
||||
- [x] Resolution
|
||||
- [x] Implement `com.atproto.identity.resolveHandle` (Can be internal or proxy to PLC).
|
||||
- [x] Implement `com.atproto.identity.updateHandle`.
|
||||
- [x] Implement `com.atproto.identity.submitPlcOperation` / `signPlcOperation` / `requestPlcOperationSignature`.
|
||||
- [x] Implement `com.atproto.identity.getRecommendedDidCredentials`.
|
||||
- [x] Implement `/.well-known/did.json` (Depends on supporting did:web).
|
||||
## Admin Management (`com.atproto.admin`)
|
||||
- [x] Implement `com.atproto.admin.deleteAccount`.
|
||||
- [x] Implement `com.atproto.admin.disableAccountInvites`.
|
||||
- [x] Implement `com.atproto.admin.disableInviteCodes`.
|
||||
- [x] Implement `com.atproto.admin.enableAccountInvites`.
|
||||
- [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`.
|
||||
- [x] Implement `com.atproto.admin.getInviteCodes`.
|
||||
- [x] Implement `com.atproto.admin.getSubjectStatus`.
|
||||
- [x] Implement `com.atproto.admin.sendEmail`.
|
||||
- [x] Implement `com.atproto.admin.updateAccountEmail`.
|
||||
- [x] Implement `com.atproto.admin.updateAccountHandle`.
|
||||
- [x] Implement `com.atproto.admin.updateAccountPassword`.
|
||||
- [x] Implement `com.atproto.admin.updateSubjectStatus`.
|
||||
## Moderation (`com.atproto.moderation`)
|
||||
- [x] Implement `com.atproto.moderation.createReport`.
|
||||
## Temp Namespace (`com.atproto.temp`)
|
||||
- [x] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups).
|
||||
## Misc HTTP Endpoints
|
||||
- [x] Implement `/robots.txt` endpoint.
|
||||
## OAuth 2.1 Support
|
||||
Full OAuth 2.1 provider for ATProto native app authentication.
|
||||
- [x] OAuth Provider Core
|
||||
- [x] Implement `/.well-known/oauth-protected-resource` metadata endpoint.
|
||||
- [x] Implement `/.well-known/oauth-authorization-server` metadata endpoint.
|
||||
- [x] Implement `/oauth/authorize` authorization endpoint (with login UI).
|
||||
- [x] Implement `/oauth/par` Pushed Authorization Request endpoint.
|
||||
- [x] Implement `/oauth/token` token endpoint (authorization_code + refresh_token grants).
|
||||
- [x] Implement `/oauth/jwks` JSON Web Key Set endpoint.
|
||||
- [x] Implement `/oauth/revoke` token revocation endpoint.
|
||||
- [x] Implement `/oauth/introspect` token introspection endpoint.
|
||||
- [x] OAuth Database Tables
|
||||
- [x] Device table for tracking authorized devices.
|
||||
- [x] Authorization request table.
|
||||
- [x] Authorized client table.
|
||||
- [x] Token table for OAuth tokens.
|
||||
- [x] Used refresh token table (replay protection).
|
||||
- [x] DPoP JTI tracking table.
|
||||
- [x] DPoP (Demonstrating Proof-of-Possession) support.
|
||||
- [x] Client metadata fetching and validation.
|
||||
- [x] PKCE (S256) enforcement.
|
||||
- [x] OAuth token verification extractor for protected resources.
|
||||
- [x] Authorization UI templates (HTML login form).
|
||||
- [x] Implement `private_key_jwt` signature verification with async JWKS fetching.
|
||||
- [x] HS256 JWT support (matches reference PDS).
|
||||
## OAuth Security Notes
|
||||
Security measures implemented:
|
||||
- Constant-time comparison for signature verification (prevents timing attacks)
|
||||
- HMAC-SHA256 for access token signing with configurable secret
|
||||
- Production secrets require 32+ character minimum
|
||||
- DPoP JTI replay protection via database
|
||||
- DPoP nonce validation with HMAC-based timestamps (5 min validity)
|
||||
- Refresh token rotation with reuse detection (revokes token family on reuse)
|
||||
- PKCE S256 enforced (plain not allowed)
|
||||
- Authorization code single-use enforcement
|
||||
- URL encoding for redirect parameters (prevents injection)
|
||||
- All database queries use parameterized statements (no SQL injection)
|
||||
- Deactivated/taken-down accounts blocked from OAuth authorization
|
||||
- Client ID validation on token exchange (defense-in-depth against cross-client attacks)
|
||||
- HTML escaping in OAuth templates (XSS prevention)
|
||||
### Auth Notes
|
||||
- Dual algorithm support: ES256K (secp256k1 ECDSA) with per-user keys AND HS256 (HMAC) for compatibility with reference PDS.
|
||||
- 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.
|
||||
- Key encryption: User signing keys encrypted at rest using AES-256-GCM with keys derived via HKDF from KEY_ENCRYPTION_KEY environment variable.
|
||||
## PDS-Level App Endpoints
|
||||
These endpoints need to be implemented at the PDS level (not just proxied to appview).
|
||||
### Actor (`app.bsky.actor`)
|
||||
- [x] Implement `app.bsky.actor.getPreferences` (user preferences storage).
|
||||
- [x] Implement `app.bsky.actor.putPreferences` (update user preferences).
|
||||
- [x] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback).
|
||||
- [x] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback).
|
||||
### Feed (`app.bsky.feed`)
|
||||
These are implemented at PDS level to enable local-first reads (read-after-write pattern):
|
||||
- [x] Implement `app.bsky.feed.getTimeline` (PDS-level with proxy + RAW).
|
||||
- [x] Implement `app.bsky.feed.getAuthorFeed` (PDS-level with proxy + RAW).
|
||||
- [x] Implement `app.bsky.feed.getActorLikes` (PDS-level with proxy + RAW).
|
||||
- [x] Implement `app.bsky.feed.getPostThread` (PDS-level with proxy + RAW + NotFound handling).
|
||||
- [x] Implement `app.bsky.feed.getFeed` (proxy to feed generator).
|
||||
### Notification (`app.bsky.notification`)
|
||||
- [x] Implement `app.bsky.notification.registerPush` (push notification registration, proxied).
|
||||
## Infrastructure & Core Components
|
||||
- [x] Sequencer (Event Log)
|
||||
- [x] Implement a `Sequencer` (backed by `repo_seq` table).
|
||||
- [x] Implement event formatting (`commit`, `handle`, `identity`, `account`).
|
||||
- [x] Implement database polling / event emission mechanism.
|
||||
- [x] Implement cursor-based event replay (`requestSeqRange`).
|
||||
- [x] Repo Storage & Consistency (in postgres)
|
||||
- [x] Implement `RepoStorage` for postgres (replaces per-user SQLite).
|
||||
- [x] Read/Write IPLD blocks to `blocks` table (global deduplication).
|
||||
- [x] Manage Repo Root in `repos` table.
|
||||
- [x] Implement Atomic Repo Transactions.
|
||||
- [x] Ensure `blocks` write, `repo_root` update, `records` index update, and `sequencer` event are committed in a single transaction.
|
||||
- [x] Implement concurrency control (row-level locking via FOR UPDATE).
|
||||
- [x] DID Cache
|
||||
- [x] Implement caching layer for DID resolution (valkey).
|
||||
- [x] Handle cache invalidation/expiry.
|
||||
- [x] Graceful fallback to no-cache when valkey unavailable.
|
||||
- [x] Crawlers Service
|
||||
- [x] Implement `Crawlers` service (debounce notifications to relays).
|
||||
- [x] 20-minute notification debounce.
|
||||
- [x] Circuit breaker for relay failures.
|
||||
- [x] Notification Service
|
||||
- [x] Queue-based notification system with database table
|
||||
- [x] Background worker polling for pending notifications
|
||||
- [x] Extensible sender trait for multiple channels
|
||||
- [x] Email sender via OS sendmail/msmtp
|
||||
- [x] Discord webhook sender
|
||||
- [x] Telegram bot sender
|
||||
- [x] Signal CLI sender
|
||||
- [x] Helper functions for common notification types (welcome, password reset, email verification, etc.)
|
||||
- [x] Respect user's `preferred_notification_channel` setting for non-email-specific notifications
|
||||
- [x] Image Processing
|
||||
- [x] Implement image resize/formatting pipeline (for blob uploads).
|
||||
- [x] WebP conversion for thumbnails.
|
||||
- [x] EXIF stripping.
|
||||
- [x] File size limits (10MB default).
|
||||
- [x] IPLD & MST
|
||||
- [x] Implement Merkle Search Tree logic for repo signing.
|
||||
- [x] Implement CAR (Content Addressable Archive) encoding/decoding.
|
||||
- [x] Cycle detection in CAR export.
|
||||
- [x] Rate Limiting
|
||||
- [x] Per-IP rate limiting on login (10/min).
|
||||
- [x] Per-IP rate limiting on OAuth token endpoint (30/min).
|
||||
- [x] Per-IP rate limiting on password reset (5/hour).
|
||||
- [x] Per-IP rate limiting on account creation (10/hour).
|
||||
- [x] Per-IP rate limiting on refreshSession (60/min).
|
||||
- [x] Per-IP rate limiting on OAuth authorize POST (10/min).
|
||||
- [x] Per-IP rate limiting on OAuth 2FA POST (10/min).
|
||||
- [x] Per-IP rate limiting on OAuth PAR (30/min).
|
||||
- [x] Per-IP rate limiting on OAuth revoke/introspect (30/min).
|
||||
- [x] Per-IP rate limiting on createAppPassword (10/min).
|
||||
- [x] Per-IP rate limiting on email endpoints (5/hour).
|
||||
- [x] Distributed rate limiting via valkey (with in-memory fallback).
|
||||
- [x] Circuit Breakers
|
||||
- [x] PLC directory circuit breaker (5 failures → open, 60s timeout).
|
||||
- [x] Relay notification circuit breaker (10 failures → open, 30s timeout).
|
||||
- [x] Security Hardening
|
||||
- [x] Email header injection prevention (CRLF sanitization).
|
||||
- [x] Signal command injection prevention (phone number validation).
|
||||
- [x] Constant-time signature comparison.
|
||||
- [x] SSRF protection for outbound requests.
|
||||
- [x] Timing attack protection (dummy bcrypt on user-not-found prevents account enumeration).
|
||||
## Lewis' fabulous mini-list of remaining TODOs
|
||||
- [x] The OAuth authorize POST endpoint has no rate limiting, allowing password brute-forcing. Fix this and audit all oauth and 2fa surface again.
|
||||
- [x] DID resolution caching (valkey).
|
||||
- [x] Record schema validation (generic validation framework).
|
||||
- [x] Fix any remaining TODOs in the code.
|
||||
## Future: Web Management UI
|
||||
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.
|
||||
### Architecture
|
||||
- [x] Static SPA served from PDS (or separate static host)
|
||||
- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
|
||||
- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
|
||||
- [x] No server-side sessions or CSRF - pure API client
|
||||
### PDS-Specific XRPC Endpoints (new)
|
||||
Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
|
||||
Anyway... endpoints for PDS settings not covered by standard ATProto:
|
||||
- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
|
||||
- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
|
||||
- [x] `com.bspds.account.getNotificationHistory` - list past notifications
|
||||
- [x] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
|
||||
- [x] `com.bspds.account.confirmChannelVerification` - confirm with code
|
||||
- [x] `com.bspds.admin.getServerStats` - user count, storage usage, etc.
|
||||
### Frontend Views
|
||||
Uses existing ATProto endpoints where possible:
|
||||
Authentication
|
||||
- [x] Login page (uses `com.atproto.server.createSession`)
|
||||
- [x] Registration page (uses `com.atproto.server.createAccount`)
|
||||
- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`)
|
||||
- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
|
||||
User Dashboard
|
||||
- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
|
||||
- [ ] Active sessions view (needs new endpoint or extend existing)
|
||||
- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
|
||||
- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
|
||||
Notification Preferences
|
||||
- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
|
||||
- [x] Verification flows for Discord/Telegram/Signal
|
||||
- [ ] Notification history view
|
||||
Account Settings
|
||||
- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
|
||||
- [ ] Password change while logged in (needs new endpoint - change password with current password)
|
||||
- [x] Handle change (uses `com.atproto.identity.updateHandle`)
|
||||
- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
|
||||
Data Management
|
||||
- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`)
|
||||
- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`)
|
||||
Admin Dashboard (privileged users only)
|
||||
- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
|
||||
- [ ] User detail/actions (uses `com.atproto.admin.*` endpoints)
|
||||
- [ ] Invite management (uses `com.atproto.admin.getInviteCodes`, `disableInviteCodes`)
|
||||
- [ ] Server stats (uses `com.bspds.admin.getServerStats`)
|
||||
## Future: private data
|
||||
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.
|
||||
Then when they come out with theirs, I can make adjustments to mine and be ready on day 1. Or 2.
|
||||
We want records that only authorized parties can see and decrypt. This requires some sort of federation of keys and communication between PDSes?
|
||||
Gotta figure all of this out as a first step.
|
||||
# Lewis' Big Boy TODO list
|
||||
|
||||
## Active development
|
||||
|
||||
### OAuth scope authorization UI
|
||||
Display and manage OAuth scopes during authorization flows.
|
||||
|
||||
- [ ] Parse and display requested scopes from authorization request
|
||||
- [ ] Human-readable scope descriptions (e.g., "Read your posts" not "app.bsky.feed.read")
|
||||
- [ ] Group scopes by category (read, write, admin, etc.)
|
||||
- [ ] Allow users to uncheck optional scopes before authorizing
|
||||
- [ ] Distinguish required vs optional scopes in UI
|
||||
- [ ] Remember scope preferences per client (don't ask again for same scopes)
|
||||
- [ ] Token endpoint respects user's scope selections
|
||||
- [ ] Protected endpoints check token scopes before allowing operations
|
||||
|
||||
### Frontend
|
||||
So like... make the thing unique, make it cool.
|
||||
|
||||
- [ ] Frontpage that explains what this thing is
|
||||
- [ ] Unique "brand" style both unauthed and authed
|
||||
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
|
||||
|
||||
### Delegated accounts
|
||||
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.
|
||||
|
||||
- [ ] Account type flag in actors table (personal | delegated)
|
||||
- [ ] account_delegations table (delegated_did, controller_did, granted_scopes[], granted_at, granted_by, revoked_at)
|
||||
- [ ] Detect delegated account during authorize flow
|
||||
- [ ] Redirect to "authenticate as controller" instead of password prompt
|
||||
- [ ] Validate controller has delegation grant for this account
|
||||
- [ ] Issue token with intersection of (requested scopes :intersection-emoji: granted scopes)
|
||||
- [ ] Token includes act_as claim indicating delegation
|
||||
- [ ] Define standard scope sets (owner, admin, editor, viewer)
|
||||
- [ ] Create delegated account flow (no password, must add initial controller)
|
||||
- [ ] Controller management page (add/remove controllers, modify scopes)
|
||||
- [ ] "Act as" account switcher for users with delegation grants
|
||||
- [ ] Log all actions with both actor DID and controller DID
|
||||
- [ ] Audit log view for delegated account owners
|
||||
|
||||
### Passkey support
|
||||
Modern passwordless authentication using WebAuthn/FIDO2, alongside or instead of passwords.
|
||||
|
||||
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
|
||||
- [ ] Generate WebAuthn registration challenge
|
||||
- [ ] Verify attestation response and store credential
|
||||
- [ ] UI for registering new passkey from settings
|
||||
- [ ] Detect if account has passkeys during OAuth authorize
|
||||
- [ ] Offer passkey option alongside password
|
||||
- [ ] Generate authentication challenge and verify assertion
|
||||
- [ ] Update sign count (replay protection)
|
||||
- [ ] Allow creating account with passkey instead of password
|
||||
- [ ] List/rename/remove passkeys in settings
|
||||
|
||||
### Private/encrypted data
|
||||
Records that only authorized parties can see and decrypt. Requires key federation between PDSes.
|
||||
|
||||
- [ ] Survey current ATProto discourse on private data
|
||||
- [ ] Document Bluesky team's likely approach
|
||||
- [ ] Design key management strategy
|
||||
- [ ] Per-user encryption keys (separate from signing keys)
|
||||
- [ ] Key derivation for per-record or per-collection encryption
|
||||
- [ ] Encrypted record storage format
|
||||
- [ ] Transparent encryption/decryption in repo operations
|
||||
- [ ] Protocol for sharing decryption keys between PDSes
|
||||
- [ ] Handle key rotation and revocation
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
import { initAuth, getAuthState } from './lib/auth.svelte'
|
||||
import Login from './routes/Login.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
import ResetPassword from './routes/ResetPassword.svelte'
|
||||
import Dashboard from './routes/Dashboard.svelte'
|
||||
import AppPasswords from './routes/AppPasswords.svelte'
|
||||
import InviteCodes from './routes/InviteCodes.svelte'
|
||||
import Settings from './routes/Settings.svelte'
|
||||
import Sessions from './routes/Sessions.svelte'
|
||||
import Notifications from './routes/Notifications.svelte'
|
||||
import RepoExplorer from './routes/RepoExplorer.svelte'
|
||||
import Admin from './routes/Admin.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
@@ -22,6 +25,8 @@
|
||||
return Login
|
||||
case '/register':
|
||||
return Register
|
||||
case '/reset-password':
|
||||
return ResetPassword
|
||||
case '/dashboard':
|
||||
return Dashboard
|
||||
case '/app-passwords':
|
||||
@@ -30,10 +35,14 @@
|
||||
return InviteCodes
|
||||
case '/settings':
|
||||
return Settings
|
||||
case '/sessions':
|
||||
return Sessions
|
||||
case '/notifications':
|
||||
return Notifications
|
||||
case '/repo':
|
||||
return RepoExplorer
|
||||
case '/admin':
|
||||
return Admin
|
||||
default:
|
||||
return auth.session ? Dashboard : Login
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface Session {
|
||||
emailConfirmed?: boolean
|
||||
preferredChannel?: string
|
||||
preferredChannelVerified?: boolean
|
||||
isAdmin?: boolean
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
}
|
||||
@@ -270,6 +271,152 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.bspds.account.confirmChannelVerification', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { channel, code },
|
||||
})
|
||||
},
|
||||
|
||||
async getNotificationHistory(token: string): Promise<{
|
||||
notifications: Array<{
|
||||
createdAt: string
|
||||
channel: string
|
||||
notificationType: string
|
||||
status: string
|
||||
subject: string | null
|
||||
body: string
|
||||
}>
|
||||
}> {
|
||||
return xrpc('com.bspds.account.getNotificationHistory', { token })
|
||||
},
|
||||
|
||||
async getServerStats(token: string): Promise<{
|
||||
userCount: number
|
||||
repoCount: number
|
||||
recordCount: number
|
||||
blobStorageBytes: number
|
||||
}> {
|
||||
return xrpc('com.bspds.admin.getServerStats', { token })
|
||||
},
|
||||
|
||||
async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> {
|
||||
await xrpc('com.bspds.account.changePassword', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { currentPassword, newPassword },
|
||||
})
|
||||
},
|
||||
|
||||
async listSessions(token: string): Promise<{
|
||||
sessions: Array<{
|
||||
id: string
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
isCurrent: boolean
|
||||
}>
|
||||
}> {
|
||||
return xrpc('com.bspds.account.listSessions', { token })
|
||||
},
|
||||
|
||||
async revokeSession(token: string, sessionId: string): Promise<void> {
|
||||
await xrpc('com.bspds.account.revokeSession', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { sessionId },
|
||||
})
|
||||
},
|
||||
|
||||
async searchAccounts(token: string, options?: {
|
||||
handle?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}): Promise<{
|
||||
cursor?: string
|
||||
accounts: Array<{
|
||||
did: string
|
||||
handle: string
|
||||
email?: string
|
||||
indexedAt: string
|
||||
emailConfirmedAt?: string
|
||||
deactivatedAt?: string
|
||||
}>
|
||||
}> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.handle) params.handle = options.handle
|
||||
if (options?.cursor) params.cursor = options.cursor
|
||||
if (options?.limit) params.limit = String(options.limit)
|
||||
return xrpc('com.atproto.admin.searchAccounts', { token, params })
|
||||
},
|
||||
|
||||
async getInviteCodes(token: string, options?: {
|
||||
sort?: 'recent' | 'usage'
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}): Promise<{
|
||||
cursor?: string
|
||||
codes: Array<{
|
||||
code: string
|
||||
available: number
|
||||
disabled: boolean
|
||||
forAccount: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
uses: Array<{ usedBy: string; usedAt: string }>
|
||||
}>
|
||||
}> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.sort) params.sort = options.sort
|
||||
if (options?.cursor) params.cursor = options.cursor
|
||||
if (options?.limit) params.limit = String(options.limit)
|
||||
return xrpc('com.atproto.admin.getInviteCodes', { token, params })
|
||||
},
|
||||
|
||||
async disableInviteCodes(token: string, codes?: string[], accounts?: string[]): Promise<void> {
|
||||
await xrpc('com.atproto.admin.disableInviteCodes', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { codes, accounts },
|
||||
})
|
||||
},
|
||||
|
||||
async getAccountInfo(token: string, did: string): Promise<{
|
||||
did: string
|
||||
handle: string
|
||||
email?: string
|
||||
indexedAt: string
|
||||
emailConfirmedAt?: string
|
||||
invitesDisabled?: boolean
|
||||
deactivatedAt?: string
|
||||
}> {
|
||||
return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } })
|
||||
},
|
||||
|
||||
async disableAccountInvites(token: string, account: string): Promise<void> {
|
||||
await xrpc('com.atproto.admin.disableAccountInvites', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { account },
|
||||
})
|
||||
},
|
||||
|
||||
async enableAccountInvites(token: string, account: string): Promise<void> {
|
||||
await xrpc('com.atproto.admin.enableAccountInvites', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { account },
|
||||
})
|
||||
},
|
||||
|
||||
async adminDeleteAccount(token: string, did: string): Promise<void> {
|
||||
await xrpc('com.atproto.admin.deleteAccount', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { did },
|
||||
})
|
||||
},
|
||||
|
||||
async describeRepo(token: string, repo: string): Promise<{
|
||||
handle: string
|
||||
did: string
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
|
||||
import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth'
|
||||
|
||||
const STORAGE_KEY = 'bspds_session'
|
||||
const ACCOUNTS_KEY = 'bspds_accounts'
|
||||
|
||||
export interface SavedAccount {
|
||||
did: string
|
||||
handle: string
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
savedAccounts: SavedAccount[]
|
||||
}
|
||||
|
||||
let state = $state<AuthState>({
|
||||
session: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
savedAccounts: [],
|
||||
})
|
||||
|
||||
function saveSession(session: Session | null) {
|
||||
@@ -34,20 +45,93 @@ function loadSession(): Session | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function loadSavedAccounts(): SavedAccount[] {
|
||||
const stored = localStorage.getItem(ACCOUNTS_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function saveSavedAccounts(accounts: SavedAccount[]) {
|
||||
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts))
|
||||
}
|
||||
|
||||
function addOrUpdateSavedAccount(session: Session) {
|
||||
const accounts = loadSavedAccounts()
|
||||
const existing = accounts.findIndex(a => a.did === session.did)
|
||||
const savedAccount: SavedAccount = {
|
||||
did: session.did,
|
||||
handle: session.handle,
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
}
|
||||
if (existing >= 0) {
|
||||
accounts[existing] = savedAccount
|
||||
} else {
|
||||
accounts.push(savedAccount)
|
||||
}
|
||||
saveSavedAccounts(accounts)
|
||||
state.savedAccounts = accounts
|
||||
}
|
||||
|
||||
function removeSavedAccount(did: string) {
|
||||
const accounts = loadSavedAccounts().filter(a => a.did !== did)
|
||||
saveSavedAccounts(accounts)
|
||||
state.savedAccounts = accounts
|
||||
}
|
||||
|
||||
export async function initAuth() {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
state.savedAccounts = loadSavedAccounts()
|
||||
|
||||
const oauthCallback = checkForOAuthCallback()
|
||||
if (oauthCallback) {
|
||||
clearOAuthCallbackParams()
|
||||
try {
|
||||
const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || '',
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
state.loading = false
|
||||
return
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'OAuth login failed'
|
||||
state.loading = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const stored = loadSession()
|
||||
if (stored) {
|
||||
try {
|
||||
const session = await api.getSession(stored.accessJwt)
|
||||
state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
|
||||
addOrUpdateSavedAccount(state.session)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
try {
|
||||
const refreshed = await api.refreshSession(stored.refreshJwt)
|
||||
state.session = refreshed
|
||||
saveSession(refreshed)
|
||||
const tokens = await refreshOAuthToken(stored.refreshJwt)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || stored.refreshJwt,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
} catch {
|
||||
saveSession(null)
|
||||
state.session = null
|
||||
@@ -68,6 +152,7 @@ export async function login(identifier: string, password: string): Promise<void>
|
||||
const session = await api.createSession(identifier, password)
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
@@ -80,6 +165,18 @@ export async function login(identifier: string, password: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithOAuth(): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
await startOAuthLogin()
|
||||
} catch (e) {
|
||||
state.loading = false
|
||||
state.error = e instanceof Error ? e.message : 'Failed to start OAuth login'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
|
||||
try {
|
||||
const result = await api.createAccount(params)
|
||||
@@ -111,6 +208,7 @@ export async function confirmSignup(did: string, verificationCode: string): Prom
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
@@ -146,6 +244,49 @@ export async function logout(): Promise<void> {
|
||||
saveSession(null)
|
||||
}
|
||||
|
||||
export async function switchAccount(did: string): Promise<void> {
|
||||
const account = state.savedAccounts.find(a => a.did === did)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
const session = await api.getSession(account.accessJwt)
|
||||
state.session = { ...session, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt }
|
||||
saveSession(state.session)
|
||||
addOrUpdateSavedAccount(state.session)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
try {
|
||||
const tokens = await refreshOAuthToken(account.refreshJwt)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || account.refreshJwt,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
} catch {
|
||||
removeSavedAccount(did)
|
||||
state.error = 'Session expired. Please log in again.'
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
} else {
|
||||
state.error = 'Failed to switch account'
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export function forgetAccount(did: string): void {
|
||||
removeSavedAccount(did)
|
||||
}
|
||||
|
||||
export function getAuthState() {
|
||||
return state
|
||||
}
|
||||
@@ -158,15 +299,18 @@ export function isAuthenticated(): boolean {
|
||||
return state.session !== null
|
||||
}
|
||||
|
||||
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
|
||||
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) {
|
||||
state.session = newState.session
|
||||
state.loading = newState.loading
|
||||
state.error = newState.error
|
||||
state.savedAccounts = newState.savedAccounts ?? []
|
||||
}
|
||||
|
||||
export function _testReset() {
|
||||
state.session = null
|
||||
state.loading = true
|
||||
state.error = null
|
||||
state.savedAccounts = []
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem(ACCOUNTS_KEY)
|
||||
}
|
||||
|
||||
181
frontend/src/lib/oauth.ts
Normal file
181
frontend/src/lib/oauth.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
const OAUTH_STATE_KEY = 'bspds_oauth_state'
|
||||
const OAUTH_VERIFIER_KEY = 'bspds_oauth_verifier'
|
||||
|
||||
interface OAuthState {
|
||||
state: string
|
||||
codeVerifier: string
|
||||
returnTo?: string
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const array = new Uint8Array(length)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(plain)
|
||||
return crypto.subtle.digest('SHA-256', data)
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const hash = await sha256(verifier)
|
||||
return base64UrlEncode(hash)
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return generateRandomString(32)
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return generateRandomString(32)
|
||||
}
|
||||
|
||||
function saveOAuthState(state: OAuthState): void {
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state.state)
|
||||
sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier)
|
||||
}
|
||||
|
||||
function getOAuthState(): OAuthState | null {
|
||||
const state = sessionStorage.getItem(OAUTH_STATE_KEY)
|
||||
const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY)
|
||||
if (!state || !codeVerifier) return null
|
||||
return { state, codeVerifier }
|
||||
}
|
||||
|
||||
function clearOAuthState(): void {
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY)
|
||||
sessionStorage.removeItem(OAUTH_VERIFIER_KEY)
|
||||
}
|
||||
|
||||
export async function startOAuthLogin(): Promise<void> {
|
||||
const state = generateState()
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
|
||||
saveOAuthState({ state, codeVerifier })
|
||||
|
||||
const clientId = `${window.location.origin}/oauth/client-metadata.json`
|
||||
const redirectUri = `${window.location.origin}/`
|
||||
|
||||
const parResponse = await fetch('/oauth/par', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'atproto transition:generic',
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!parResponse.ok) {
|
||||
const error = await parResponse.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(error.error_description || error.error || 'Failed to start OAuth flow')
|
||||
}
|
||||
|
||||
const { request_uri } = await parResponse.json()
|
||||
|
||||
const authorizeUrl = new URL('/oauth/authorize', window.location.origin)
|
||||
authorizeUrl.searchParams.set('client_id', clientId)
|
||||
authorizeUrl.searchParams.set('request_uri', request_uri)
|
||||
|
||||
window.location.href = authorizeUrl.toString()
|
||||
}
|
||||
|
||||
export interface OAuthTokens {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
sub: string
|
||||
}
|
||||
|
||||
export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> {
|
||||
const savedState = getOAuthState()
|
||||
if (!savedState) {
|
||||
throw new Error('No OAuth state found. Please try logging in again.')
|
||||
}
|
||||
|
||||
if (savedState.state !== state) {
|
||||
clearOAuthState()
|
||||
throw new Error('OAuth state mismatch. Please try logging in again.')
|
||||
}
|
||||
|
||||
const clientId = `${window.location.origin}/oauth/client-metadata.json`
|
||||
const redirectUri = `${window.location.origin}/`
|
||||
|
||||
const tokenResponse = await fetch('/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: savedState.codeVerifier,
|
||||
}),
|
||||
})
|
||||
|
||||
clearOAuthState()
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens')
|
||||
}
|
||||
|
||||
return tokenResponse.json()
|
||||
}
|
||||
|
||||
export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> {
|
||||
const clientId = `${window.location.origin}/oauth/client-metadata.json`
|
||||
|
||||
const tokenResponse = await fetch('/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: clientId,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(error.error_description || error.error || 'Failed to refresh token')
|
||||
}
|
||||
|
||||
return tokenResponse.json()
|
||||
}
|
||||
|
||||
export function checkForOAuthCallback(): { code: string; state: string } | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get('code')
|
||||
const state = params.get('state')
|
||||
|
||||
if (code && state) {
|
||||
return { code, state }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function clearOAuthCallbackParams(): void {
|
||||
const url = new URL(window.location.href)
|
||||
url.search = ''
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
744
frontend/src/routes/Admin.svelte
Normal file
744
frontend/src/routes/Admin.svelte
Normal file
@@ -0,0 +1,744 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
const auth = getAuthState()
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let stats = $state<{
|
||||
userCount: number
|
||||
repoCount: number
|
||||
recordCount: number
|
||||
blobStorageBytes: number
|
||||
} | null>(null)
|
||||
let usersLoading = $state(false)
|
||||
let usersError = $state<string | null>(null)
|
||||
let users = $state<Array<{
|
||||
did: string
|
||||
handle: string
|
||||
email?: string
|
||||
indexedAt: string
|
||||
emailConfirmedAt?: string
|
||||
deactivatedAt?: string
|
||||
}>>([])
|
||||
let usersCursor = $state<string | undefined>(undefined)
|
||||
let handleSearchQuery = $state('')
|
||||
let showUsers = $state(false)
|
||||
let invitesLoading = $state(false)
|
||||
let invitesError = $state<string | null>(null)
|
||||
let invites = $state<Array<{
|
||||
code: string
|
||||
available: number
|
||||
disabled: boolean
|
||||
forAccount: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
uses: Array<{ usedBy: string; usedAt: string }>
|
||||
}>>([])
|
||||
let invitesCursor = $state<string | undefined>(undefined)
|
||||
let showInvites = $state(false)
|
||||
let selectedUser = $state<{
|
||||
did: string
|
||||
handle: string
|
||||
email?: string
|
||||
indexedAt: string
|
||||
emailConfirmedAt?: string
|
||||
invitesDisabled?: boolean
|
||||
deactivatedAt?: string
|
||||
} | null>(null)
|
||||
let userDetailLoading = $state(false)
|
||||
let userActionLoading = $state(false)
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
} else if (!auth.loading && auth.session && !auth.session.isAdmin) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
if (auth.session?.isAdmin) {
|
||||
loadStats()
|
||||
}
|
||||
})
|
||||
async function loadStats() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
stats = await api.getServerStats(auth.session.accessJwt)
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load server stats'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
async function loadUsers(reset = false) {
|
||||
if (!auth.session) return
|
||||
usersLoading = true
|
||||
usersError = null
|
||||
if (reset) {
|
||||
users = []
|
||||
usersCursor = undefined
|
||||
}
|
||||
try {
|
||||
const result = await api.searchAccounts(auth.session.accessJwt, {
|
||||
handle: handleSearchQuery || undefined,
|
||||
cursor: reset ? undefined : usersCursor,
|
||||
limit: 25,
|
||||
})
|
||||
users = reset ? result.accounts : [...users, ...result.accounts]
|
||||
usersCursor = result.cursor
|
||||
showUsers = true
|
||||
} catch (e) {
|
||||
usersError = e instanceof ApiError ? e.message : 'Failed to load users'
|
||||
} finally {
|
||||
usersLoading = false
|
||||
}
|
||||
}
|
||||
function handleSearch(e: Event) {
|
||||
e.preventDefault()
|
||||
loadUsers(true)
|
||||
}
|
||||
async function loadInvites(reset = false) {
|
||||
if (!auth.session) return
|
||||
invitesLoading = true
|
||||
invitesError = null
|
||||
if (reset) {
|
||||
invites = []
|
||||
invitesCursor = undefined
|
||||
}
|
||||
try {
|
||||
const result = await api.getInviteCodes(auth.session.accessJwt, {
|
||||
cursor: reset ? undefined : invitesCursor,
|
||||
limit: 25,
|
||||
})
|
||||
invites = reset ? result.codes : [...invites, ...result.codes]
|
||||
invitesCursor = result.cursor
|
||||
showInvites = true
|
||||
} catch (e) {
|
||||
invitesError = e instanceof ApiError ? e.message : 'Failed to load invites'
|
||||
} finally {
|
||||
invitesLoading = false
|
||||
}
|
||||
}
|
||||
async function disableInvite(code: string) {
|
||||
if (!auth.session) return
|
||||
if (!confirm(`Disable invite code ${code}?`)) return
|
||||
try {
|
||||
await api.disableInviteCodes(auth.session.accessJwt, [code])
|
||||
invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
|
||||
} catch (e) {
|
||||
invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite'
|
||||
}
|
||||
}
|
||||
async function selectUser(did: string) {
|
||||
if (!auth.session) return
|
||||
userDetailLoading = true
|
||||
try {
|
||||
selectedUser = await api.getAccountInfo(auth.session.accessJwt, did)
|
||||
} catch (e) {
|
||||
usersError = e instanceof ApiError ? e.message : 'Failed to load user details'
|
||||
} finally {
|
||||
userDetailLoading = false
|
||||
}
|
||||
}
|
||||
function closeUserDetail() {
|
||||
selectedUser = null
|
||||
}
|
||||
async function toggleUserInvites() {
|
||||
if (!auth.session || !selectedUser) return
|
||||
userActionLoading = true
|
||||
try {
|
||||
if (selectedUser.invitesDisabled) {
|
||||
await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did)
|
||||
selectedUser = { ...selectedUser, invitesDisabled: false }
|
||||
} else {
|
||||
await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did)
|
||||
selectedUser = { ...selectedUser, invitesDisabled: true }
|
||||
}
|
||||
} catch (e) {
|
||||
usersError = e instanceof ApiError ? e.message : 'Failed to update user'
|
||||
} finally {
|
||||
userActionLoading = false
|
||||
}
|
||||
}
|
||||
async function deleteUser() {
|
||||
if (!auth.session || !selectedUser) return
|
||||
if (!confirm(`Delete account @${selectedUser.handle}? This cannot be undone.`)) return
|
||||
userActionLoading = true
|
||||
try {
|
||||
await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did)
|
||||
users = users.filter(u => u.did !== selectedUser!.did)
|
||||
selectedUser = null
|
||||
} catch (e) {
|
||||
usersError = e instanceof ApiError ? e.message : 'Failed to delete user'
|
||||
} finally {
|
||||
userActionLoading = false
|
||||
}
|
||||
}
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
</script>
|
||||
{#if auth.session?.isAdmin}
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
<h1>Admin Panel</h1>
|
||||
</header>
|
||||
{#if loading}
|
||||
<p class="loading">Loading...</p>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
{#if stats}
|
||||
<section>
|
||||
<h2>Server Statistics</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{formatNumber(stats.userCount)}</div>
|
||||
<div class="stat-label">Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{formatNumber(stats.repoCount)}</div>
|
||||
<div class="stat-label">Repositories</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{formatNumber(stats.recordCount)}</div>
|
||||
<div class="stat-label">Records</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
|
||||
<div class="stat-label">Blob Storage</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick={loadStats}>Refresh Stats</button>
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
<h2>User Management</h2>
|
||||
<form class="search-form" onsubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={handleSearchQuery}
|
||||
placeholder="Search by handle (optional)"
|
||||
disabled={usersLoading}
|
||||
/>
|
||||
<button type="submit" disabled={usersLoading}>
|
||||
{usersLoading ? 'Loading...' : 'Search Users'}
|
||||
</button>
|
||||
</form>
|
||||
{#if usersError}
|
||||
<div class="message error">{usersError}</div>
|
||||
{/if}
|
||||
{#if showUsers}
|
||||
<div class="user-list">
|
||||
{#if users.length === 0}
|
||||
<p class="no-results">No users found</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Handle</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user}
|
||||
<tr class="clickable" onclick={() => selectUser(user.did)}>
|
||||
<td class="handle">@{user.handle}</td>
|
||||
<td class="email">{user.email || '-'}</td>
|
||||
<td>
|
||||
{#if user.deactivatedAt}
|
||||
<span class="badge deactivated">Deactivated</span>
|
||||
{:else if user.emailConfirmedAt}
|
||||
<span class="badge verified">Verified</span>
|
||||
{:else}
|
||||
<span class="badge unverified">Unverified</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="date">{new Date(user.indexedAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if usersCursor}
|
||||
<button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
|
||||
{usersLoading ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<section>
|
||||
<h2>Invite Codes</h2>
|
||||
<div class="section-actions">
|
||||
<button onclick={() => loadInvites(true)} disabled={invitesLoading}>
|
||||
{invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'}
|
||||
</button>
|
||||
</div>
|
||||
{#if invitesError}
|
||||
<div class="message error">{invitesError}</div>
|
||||
{/if}
|
||||
{#if showInvites}
|
||||
<div class="invite-list">
|
||||
{#if invites.length === 0}
|
||||
<p class="no-results">No invite codes found</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Available</th>
|
||||
<th>Uses</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each invites as invite}
|
||||
<tr class:disabled-row={invite.disabled}>
|
||||
<td class="code">{invite.code}</td>
|
||||
<td>{invite.available}</td>
|
||||
<td>{invite.uses.length}</td>
|
||||
<td>
|
||||
{#if invite.disabled}
|
||||
<span class="badge deactivated">Disabled</span>
|
||||
{:else if invite.available === 0}
|
||||
<span class="badge unverified">Exhausted</span>
|
||||
{:else}
|
||||
<span class="badge verified">Active</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="date">{new Date(invite.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{#if !invite.disabled}
|
||||
<button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
|
||||
Disable
|
||||
</button>
|
||||
{:else}
|
||||
<span class="muted">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if invitesCursor}
|
||||
<button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
|
||||
{invitesLoading ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedUser}
|
||||
<div class="modal-overlay" onclick={closeUserDetail} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>User Details</h2>
|
||||
<button class="close-btn" onclick={closeUserDetail}>×</button>
|
||||
</div>
|
||||
{#if userDetailLoading}
|
||||
<p class="loading">Loading...</p>
|
||||
{:else}
|
||||
<div class="modal-body">
|
||||
<dl class="user-details">
|
||||
<dt>Handle</dt>
|
||||
<dd>@{selectedUser.handle}</dd>
|
||||
<dt>DID</dt>
|
||||
<dd class="mono">{selectedUser.did}</dd>
|
||||
<dt>Email</dt>
|
||||
<dd>{selectedUser.email || '-'}</dd>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
{#if selectedUser.deactivatedAt}
|
||||
<span class="badge deactivated">Deactivated</span>
|
||||
{:else if selectedUser.emailConfirmedAt}
|
||||
<span class="badge verified">Verified</span>
|
||||
{:else}
|
||||
<span class="badge unverified">Unverified</span>
|
||||
{/if}
|
||||
</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{new Date(selectedUser.indexedAt).toLocaleString()}</dd>
|
||||
<dt>Invites</dt>
|
||||
<dd>
|
||||
{#if selectedUser.invitesDisabled}
|
||||
<span class="badge deactivated">Disabled</span>
|
||||
{:else}
|
||||
<span class="badge verified">Enabled</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={toggleUserInvites}
|
||||
disabled={userActionLoading}
|
||||
>
|
||||
{selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={deleteUser}
|
||||
disabled={userActionLoading}
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if auth.loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{/if}
|
||||
<style>
|
||||
.page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
h1 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.refresh-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.search-form input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.search-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.search-form button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.search-form button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.search-form button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.user-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.no-results {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
th, td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.handle {
|
||||
font-weight: 500;
|
||||
}
|
||||
.email {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.date {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.badge.verified {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
.badge.unverified {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
.badge.deactivated {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
.load-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.load-more:hover:not(:disabled) {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.load-more:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.section-actions {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.section-actions button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.section-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.invite-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.code {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.disabled-row {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-btn.danger {
|
||||
background: var(--error-text);
|
||||
color: white;
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
background: #900;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.clickable:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.user-details {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
.user-details dt {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.user-details dd {
|
||||
margin: 0;
|
||||
}
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.modal-actions .action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.modal-actions .action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.modal-actions .action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.modal-actions .action-btn.danger {
|
||||
border-color: var(--error-text);
|
||||
color: var(--error-text);
|
||||
}
|
||||
.modal-actions .action-btn.danger:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState, logout } from '../lib/auth.svelte'
|
||||
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
const auth = getAuthState()
|
||||
let dropdownOpen = $state(false)
|
||||
let switching = $state(false)
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
@@ -11,18 +13,87 @@
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
async function handleSwitchAccount(did: string) {
|
||||
switching = true
|
||||
dropdownOpen = false
|
||||
try {
|
||||
await switchAccount(did)
|
||||
} catch {
|
||||
navigate('/login')
|
||||
} finally {
|
||||
switching = false
|
||||
}
|
||||
}
|
||||
function toggleDropdown() {
|
||||
dropdownOpen = !dropdownOpen
|
||||
}
|
||||
function closeDropdown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.account-dropdown')) {
|
||||
dropdownOpen = false
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener('click', closeDropdown)
|
||||
return () => document.removeEventListener('click', closeDropdown)
|
||||
}
|
||||
})
|
||||
let otherAccounts = $derived(
|
||||
auth.savedAccounts.filter(a => a.did !== auth.session?.did)
|
||||
)
|
||||
</script>
|
||||
{#if auth.session}
|
||||
<div class="dashboard">
|
||||
<header>
|
||||
<h1>Dashboard</h1>
|
||||
<button class="logout" onclick={handleLogout}>Sign Out</button>
|
||||
<div class="account-dropdown">
|
||||
<button class="account-trigger" onclick={toggleDropdown} disabled={switching}>
|
||||
<span class="account-handle">@{auth.session.handle}</span>
|
||||
<span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{#if dropdownOpen}
|
||||
<div class="dropdown-menu">
|
||||
{#if otherAccounts.length > 0}
|
||||
<div class="dropdown-section">
|
||||
<span class="dropdown-label">Switch Account</span>
|
||||
{#each otherAccounts as account}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
onclick={() => handleSwitchAccount(account.did)}
|
||||
>
|
||||
@{account.handle}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
onclick={() => { dropdownOpen = false; navigate('/login') }}
|
||||
>
|
||||
Add another account
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item logout-item" onclick={handleLogout}>
|
||||
Sign out @{auth.session.handle}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<section class="account-overview">
|
||||
<h2>Account Overview</h2>
|
||||
<dl>
|
||||
<dt>Handle</dt>
|
||||
<dd>@{auth.session.handle}</dd>
|
||||
<dd>
|
||||
@{auth.session.handle}
|
||||
{#if auth.session.isAdmin}
|
||||
<span class="badge admin">Admin</span>
|
||||
{/if}
|
||||
</dd>
|
||||
<dt>DID</dt>
|
||||
<dd class="mono">{auth.session.did}</dd>
|
||||
{#if auth.session.preferredChannel}
|
||||
@@ -63,6 +134,10 @@
|
||||
<h3>App Passwords</h3>
|
||||
<p>Manage passwords for third-party apps</p>
|
||||
</a>
|
||||
<a href="#/sessions" class="nav-card">
|
||||
<h3>Active Sessions</h3>
|
||||
<p>View and manage your login sessions</p>
|
||||
</a>
|
||||
<a href="#/invite-codes" class="nav-card">
|
||||
<h3>Invite Codes</h3>
|
||||
<p>View and create invite codes</p>
|
||||
@@ -79,6 +154,12 @@
|
||||
<h3>Repository Explorer</h3>
|
||||
<p>Browse and manage raw AT Protocol records</p>
|
||||
</a>
|
||||
{#if auth.session.isAdmin}
|
||||
<a href="#/admin" class="nav-card admin-card">
|
||||
<h3>Admin Panel</h3>
|
||||
<p>Server stats and admin operations</p>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
{:else if auth.loading}
|
||||
@@ -99,7 +180,13 @@
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.logout {
|
||||
.account-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
.account-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color-light);
|
||||
@@ -107,9 +194,66 @@
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.logout:hover {
|
||||
.account-trigger:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.account-trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.account-trigger .account-handle {
|
||||
font-weight: 500;
|
||||
}
|
||||
.dropdown-arrow {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
min-width: 200px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dropdown-section {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.dropdown-label {
|
||||
display: block;
|
||||
padding: 0.25rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.dropdown-item.logout-item {
|
||||
color: var(--error-text);
|
||||
}
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0;
|
||||
}
|
||||
section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
@@ -153,6 +297,10 @@
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
.badge.admin {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
@@ -181,6 +329,13 @@
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.nav-card.admin-card {
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(135deg, var(--bg-card) 0%, rgba(77, 166, 255, 0.05) 100%);
|
||||
}
|
||||
.nav-card.admin-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(77, 166, 255, 0.25);
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
|
||||
@@ -1,41 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { login, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
|
||||
import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { ApiError } from '../lib/api'
|
||||
let identifier = $state('')
|
||||
let password = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let pendingVerification = $state<{ did: string } | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let resendingCode = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
let showNewLogin = $state(false)
|
||||
const auth = getAuthState()
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!identifier || !password) return
|
||||
async function handleSwitchAccount(did: string) {
|
||||
submitting = true
|
||||
error = null
|
||||
pendingVerification = null
|
||||
try {
|
||||
await login(identifier, password)
|
||||
await switchAccount(did)
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError && e.error === 'AccountNotVerified') {
|
||||
if (e.did) {
|
||||
pendingVerification = { did: e.did }
|
||||
} else {
|
||||
error = 'Account not verified. Please check your verification method for a code.'
|
||||
}
|
||||
} else {
|
||||
error = e.message || 'Login failed'
|
||||
}
|
||||
} finally {
|
||||
} catch {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
function handleForgetAccount(did: string, e: Event) {
|
||||
e.stopPropagation()
|
||||
forgetAccount(did)
|
||||
}
|
||||
async function handleOAuthLogin() {
|
||||
submitting = true
|
||||
try {
|
||||
await loginWithOAuth()
|
||||
} catch {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
@@ -43,13 +38,10 @@
|
||||
e.preventDefault()
|
||||
if (!pendingVerification || !verificationCode.trim()) return
|
||||
submitting = true
|
||||
error = null
|
||||
try {
|
||||
await confirmSignup(pendingVerification.did, verificationCode.trim())
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Verification failed'
|
||||
} finally {
|
||||
} catch {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
@@ -57,12 +49,11 @@
|
||||
if (!pendingVerification || resendingCode) return
|
||||
resendingCode = true
|
||||
resendMessage = null
|
||||
error = null
|
||||
try {
|
||||
await resendVerification(pendingVerification.did)
|
||||
resendMessage = 'Verification code resent!'
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to resend code'
|
||||
} catch {
|
||||
resendMessage = null
|
||||
} finally {
|
||||
resendingCode = false
|
||||
}
|
||||
@@ -70,13 +61,12 @@
|
||||
function backToLogin() {
|
||||
pendingVerification = null
|
||||
verificationCode = ''
|
||||
error = null
|
||||
resendMessage = null
|
||||
}
|
||||
</script>
|
||||
<div class="login-container">
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{#if auth.error}
|
||||
<div class="error">{auth.error}</div>
|
||||
{/if}
|
||||
{#if pendingVerification}
|
||||
<h1>Verify Your Account</h1>
|
||||
@@ -111,36 +101,54 @@
|
||||
Back to Login
|
||||
</button>
|
||||
</form>
|
||||
{:else if auth.savedAccounts.length > 0 && !showNewLogin}
|
||||
<h1>Sign In</h1>
|
||||
<p class="subtitle">Choose an account</p>
|
||||
<div class="saved-accounts">
|
||||
{#each auth.savedAccounts as account}
|
||||
<div
|
||||
class="account-item"
|
||||
class:disabled={submitting}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => !submitting && handleSwitchAccount(account.did)}
|
||||
onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
|
||||
>
|
||||
<div class="account-info">
|
||||
<span class="account-handle">@{account.handle}</span>
|
||||
<span class="account-did">{account.did}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="forget-btn"
|
||||
onclick={(e) => handleForgetAccount(account.did, e)}
|
||||
title="Remove from saved accounts"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="secondary add-account" onclick={() => showNewLogin = true}>
|
||||
Sign in to another account
|
||||
</button>
|
||||
<p class="register-link">
|
||||
Don't have an account? <a href="#/register">Create one</a>
|
||||
</p>
|
||||
{:else}
|
||||
<h1>Sign In</h1>
|
||||
<p class="subtitle">Sign in to manage your PDS account</p>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
||||
<div class="field">
|
||||
<label for="identifier">Handle or Email</label>
|
||||
<input
|
||||
id="identifier"
|
||||
type="text"
|
||||
bind:value={identifier}
|
||||
placeholder="you.bsky.social or you@example.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={submitting || !identifier || !password}>
|
||||
{submitting ? 'Signing in...' : 'Sign In'}
|
||||
{#if auth.savedAccounts.length > 0}
|
||||
<button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}>
|
||||
← Back to saved accounts
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
|
||||
{submitting ? 'Redirecting...' : 'Sign In'}
|
||||
</button>
|
||||
<p class="forgot-link">
|
||||
<a href="#/reset-password">Forgot password?</a>
|
||||
</p>
|
||||
<p class="register-link">
|
||||
Don't have an account? <a href="#/register">Create one</a>
|
||||
</p>
|
||||
@@ -219,6 +227,12 @@
|
||||
button.tertiary:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.oauth-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
@@ -233,12 +247,88 @@
|
||||
border-radius: 4px;
|
||||
color: var(--success-text);
|
||||
}
|
||||
.forgot-link {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.forgot-link a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.register-link a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.saved-accounts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.account-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.account-item:hover:not(.disabled) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
|
||||
}
|
||||
.account-item.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.account-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.account-handle {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.account-did {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 250px;
|
||||
}
|
||||
.forget-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
.forget-btn:hover {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
.add-account {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.back-btn {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,21 @@
|
||||
let telegramVerified = $state(false)
|
||||
let signalNumber = $state('')
|
||||
let signalVerified = $state(false)
|
||||
let verifyingChannel = $state<string | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let verificationError = $state<string | null>(null)
|
||||
let verificationSuccess = $state<string | null>(null)
|
||||
let historyLoading = $state(false)
|
||||
let historyError = $state<string | null>(null)
|
||||
let notifications = $state<Array<{
|
||||
createdAt: string
|
||||
channel: string
|
||||
notificationType: string
|
||||
status: string
|
||||
subject: string | null
|
||||
body: string
|
||||
}>>([])
|
||||
let showHistory = $state(false)
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
@@ -66,6 +81,37 @@
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
async function handleVerify(channel: string) {
|
||||
if (!auth.session || !verificationCode) return
|
||||
verificationError = null
|
||||
verificationSuccess = null
|
||||
try {
|
||||
await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode)
|
||||
verificationSuccess = `${channel} verified successfully`
|
||||
verificationCode = ''
|
||||
verifyingChannel = null
|
||||
await loadPrefs()
|
||||
} catch (e) {
|
||||
verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel'
|
||||
}
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (!auth.session) return
|
||||
historyLoading = true
|
||||
historyError = null
|
||||
try {
|
||||
const result = await api.getNotificationHistory(auth.session.accessJwt)
|
||||
notifications = result.notifications
|
||||
showHistory = true
|
||||
} catch (e) {
|
||||
historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
|
||||
} finally {
|
||||
historyLoading = false
|
||||
}
|
||||
}
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
const channels = [
|
||||
{ id: 'email', name: 'Email', description: 'Receive notifications via email' },
|
||||
{ id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
|
||||
@@ -79,6 +125,12 @@
|
||||
if (channelId === 'signal') return !!signalNumber
|
||||
return false
|
||||
}
|
||||
function needsVerification(channelId: string): boolean {
|
||||
if (channelId === 'discord') return !!discordId && !discordVerified
|
||||
if (channelId === 'telegram') return !!telegramUsername && !telegramVerified
|
||||
if (channelId === 'signal') return !!signalNumber && !signalVerified
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
@@ -157,10 +209,23 @@
|
||||
<span class="status verified">Verified</span>
|
||||
{:else}
|
||||
<span class="status unverified">Not verified</span>
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>Verify</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
|
||||
{#if verifyingChannel === 'discord'}
|
||||
<div class="verify-form">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter verification code"
|
||||
maxlength="6"
|
||||
/>
|
||||
<button type="button" onclick={() => handleVerify('discord')}>Submit</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="telegram">Telegram Username</label>
|
||||
@@ -177,10 +242,23 @@
|
||||
<span class="status verified">Verified</span>
|
||||
{:else}
|
||||
<span class="status unverified">Not verified</span>
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>Verify</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">Your Telegram username without the @ symbol</p>
|
||||
{#if verifyingChannel === 'telegram'}
|
||||
<div class="verify-form">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter verification code"
|
||||
maxlength="6"
|
||||
/>
|
||||
<button type="button" onclick={() => handleVerify('telegram')}>Submit</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="signal">Signal Phone Number</label>
|
||||
@@ -197,12 +275,31 @@
|
||||
<span class="status verified">Verified</span>
|
||||
{:else}
|
||||
<span class="status unverified">Not verified</span>
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>Verify</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">Your Signal phone number with country code</p>
|
||||
{#if verifyingChannel === 'signal'}
|
||||
<div class="verify-form">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter verification code"
|
||||
maxlength="6"
|
||||
/>
|
||||
<button type="button" onclick={() => handleVerify('signal')}>Submit</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if verificationError}
|
||||
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
|
||||
{/if}
|
||||
{#if verificationSuccess}
|
||||
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
|
||||
{/if}
|
||||
</section>
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving}>
|
||||
@@ -210,6 +307,39 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<section class="history-section">
|
||||
<h2>Notification History</h2>
|
||||
<p class="section-description">View recent notifications sent to your account.</p>
|
||||
{#if !showHistory}
|
||||
<button class="load-history" onclick={loadHistory} disabled={historyLoading}>
|
||||
{historyLoading ? 'Loading...' : 'Load History'}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="load-history" onclick={() => showHistory = false}>Hide History</button>
|
||||
{#if historyError}
|
||||
<div class="message error">{historyError}</div>
|
||||
{:else if notifications.length === 0}
|
||||
<p class="no-notifications">No notifications found.</p>
|
||||
{:else}
|
||||
<div class="notification-list">
|
||||
{#each notifications as notification}
|
||||
<div class="notification-item">
|
||||
<div class="notification-header">
|
||||
<span class="notification-type">{notification.notificationType}</span>
|
||||
<span class="notification-channel">{notification.channel}</span>
|
||||
<span class="notification-status" class:sent={notification.status === 'sent'} class:failed={notification.status === 'failed'}>{notification.status}</span>
|
||||
</div>
|
||||
{#if notification.subject}
|
||||
<div class="notification-subject">{notification.subject}</div>
|
||||
{/if}
|
||||
<div class="notification-body">{notification.body}</div>
|
||||
<div class="notification-date">{formatDate(notification.createdAt)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
@@ -389,4 +519,143 @@
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.verify-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.verify-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.verify-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.verify-form input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
width: 150px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.verify-form button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.verify-form button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.verify-form button.cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.verify-form button.cancel:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.history-section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.history-section h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.load-history {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.load-history:hover:not(:disabled) {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.load-history:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.no-notifications {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.notification-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.notification-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.notification-type {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.notification-channel {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.notification-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.notification-status.sent {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
.notification-status.failed {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
.notification-subject {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.notification-body {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.notification-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
223
frontend/src/routes/ResetPassword.svelte
Normal file
223
frontend/src/routes/ResetPassword.svelte
Normal file
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
const auth = getAuthState()
|
||||
let email = $state('')
|
||||
let token = $state('')
|
||||
let newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let success = $state<string | null>(null)
|
||||
let tokenSent = $state(false)
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
async function handleRequestReset(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!email) return
|
||||
submitting = true
|
||||
error = null
|
||||
success = null
|
||||
try {
|
||||
await api.requestPasswordReset(email)
|
||||
tokenSent = true
|
||||
success = 'Password reset code sent to your email'
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to send reset code'
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
async function handleReset(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!token || !newPassword || !confirmPassword) return
|
||||
if (newPassword !== confirmPassword) {
|
||||
error = 'Passwords do not match'
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
error = 'Password must be at least 8 characters'
|
||||
return
|
||||
}
|
||||
submitting = true
|
||||
error = null
|
||||
success = null
|
||||
try {
|
||||
await api.resetPassword(token, newPassword)
|
||||
success = 'Password reset successfully!'
|
||||
setTimeout(() => navigate('/login'), 2000)
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to reset password'
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<div class="reset-container">
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="message success">{success}</div>
|
||||
{/if}
|
||||
{#if tokenSent}
|
||||
<h1>Reset Password</h1>
|
||||
<p class="subtitle">Enter the code from your email and choose a new password.</p>
|
||||
<form onsubmit={handleReset}>
|
||||
<div class="field">
|
||||
<label for="token">Reset Code</label>
|
||||
<input
|
||||
id="token"
|
||||
type="text"
|
||||
bind:value={token}
|
||||
placeholder="Enter code from email"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="At least 8 characters"
|
||||
disabled={submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={submitting || !token || !newPassword || !confirmPassword}>
|
||||
{submitting ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={() => { tokenSent = false; token = ''; newPassword = ''; confirmPassword = '' }}>
|
||||
Request New Code
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<h1>Forgot Password</h1>
|
||||
<p class="subtitle">Enter your email address and we'll send you a code to reset your password.</p>
|
||||
<form onsubmit={handleRequestReset}>
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="you@example.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={submitting || !email}>
|
||||
{submitting ? 'Sending...' : 'Send Reset Code'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
<p class="back-link">
|
||||
<a href="#/login">Back to Sign In</a>
|
||||
</p>
|
||||
</div>
|
||||
<style>
|
||||
.reset-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color-light);
|
||||
}
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.back-link a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
240
frontend/src/routes/Sessions.svelte
Normal file
240
frontend/src/routes/Sessions.svelte
Normal file
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
const auth = getAuthState()
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let sessions = $state<Array<{
|
||||
id: string
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
isCurrent: boolean
|
||||
}>>([])
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadSessions()
|
||||
}
|
||||
})
|
||||
async function loadSessions() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
const result = await api.listSessions(auth.session.accessJwt)
|
||||
sessions = result.sessions
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load sessions'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
async function revokeSession(sessionId: string, isCurrent: boolean) {
|
||||
if (!auth.session) return
|
||||
const msg = isCurrent
|
||||
? 'This will log you out of this session. Continue?'
|
||||
: 'Revoke this session?'
|
||||
if (!confirm(msg)) return
|
||||
try {
|
||||
await api.revokeSession(auth.session.accessJwt, sessionId)
|
||||
if (isCurrent) {
|
||||
navigate('/login')
|
||||
} else {
|
||||
sessions = sessions.filter(s => s.id !== sessionId)
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to revoke session'
|
||||
}
|
||||
}
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
function timeAgo(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
|
||||
return 'Just now'
|
||||
}
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
<h1>Active Sessions</h1>
|
||||
</header>
|
||||
{#if loading}
|
||||
<p class="loading">Loading sessions...</p>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
{#if sessions.length === 0}
|
||||
<p class="empty">No active sessions found.</p>
|
||||
{:else}
|
||||
<div class="sessions-list">
|
||||
{#each sessions as session}
|
||||
<div class="session-card" class:current={session.isCurrent}>
|
||||
<div class="session-info">
|
||||
<div class="session-header">
|
||||
{#if session.isCurrent}
|
||||
<span class="badge current">Current Session</span>
|
||||
{:else}
|
||||
<span class="session-label">Session</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="session-details">
|
||||
<div class="detail">
|
||||
<span class="label">Created:</span>
|
||||
<span class="value">{timeAgo(session.createdAt)}</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="label">Expires:</span>
|
||||
<span class="value">{formatDate(session.expiresAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button
|
||||
class="revoke-btn"
|
||||
class:danger={!session.isCurrent}
|
||||
onclick={() => revokeSession(session.id, session.isCurrent)}
|
||||
>
|
||||
{session.isCurrent ? 'Sign Out' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="refresh-btn" onclick={loadSessions}>Refresh</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
h1 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
.sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.session-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.session-card.current {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.session-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.session-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge.current {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.session-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.detail {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.detail .label {
|
||||
color: var(--text-secondary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.detail .value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.revoke-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.revoke-btn:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.revoke-btn.danger {
|
||||
border-color: var(--error-text);
|
||||
color: var(--error-text);
|
||||
}
|
||||
.revoke-btn.danger:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
.refresh-btn {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,11 @@
|
||||
let deletePassword = $state('')
|
||||
let deleteToken = $state('')
|
||||
let deleteTokenSent = $state(false)
|
||||
let exportLoading = $state(false)
|
||||
let passwordLoading = $state(false)
|
||||
let currentPassword = $state('')
|
||||
let newPassword = $state('')
|
||||
let confirmNewPassword = $state('')
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
@@ -110,6 +115,61 @@
|
||||
deleteLoading = false
|
||||
}
|
||||
}
|
||||
async function handleExportRepo() {
|
||||
if (!auth.session) return
|
||||
exportLoading = true
|
||||
message = null
|
||||
try {
|
||||
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: 'Export failed' }))
|
||||
throw new Error(err.message || 'Export failed')
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${auth.session.handle}-repo.car`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showMessage('success', 'Repository exported successfully')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof Error ? e.message : 'Failed to export repository')
|
||||
} finally {
|
||||
exportLoading = false
|
||||
}
|
||||
}
|
||||
async function handleChangePassword(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
showMessage('error', 'Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
showMessage('error', 'Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
passwordLoading = true
|
||||
message = null
|
||||
try {
|
||||
await api.changePassword(auth.session.accessJwt, currentPassword, newPassword)
|
||||
showMessage('success', 'Password changed successfully')
|
||||
currentPassword = ''
|
||||
newPassword = ''
|
||||
confirmNewPassword = ''
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password')
|
||||
} finally {
|
||||
passwordLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
@@ -187,6 +247,55 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Change Password</h2>
|
||||
<form onsubmit={handleChangePassword}>
|
||||
<div class="field">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
placeholder="Enter current password"
|
||||
disabled={passwordLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="At least 8 characters"
|
||||
disabled={passwordLoading}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm-new-password">Confirm New Password</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
type="password"
|
||||
bind:value={confirmNewPassword}
|
||||
placeholder="Confirm new password"
|
||||
disabled={passwordLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
|
||||
{passwordLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Export Data</h2>
|
||||
<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>
|
||||
<button onclick={handleExportRepo} disabled={exportLoading}>
|
||||
{exportLoading ? 'Exporting...' : 'Download Repository'}
|
||||
</button>
|
||||
</section>
|
||||
<section class="danger-zone">
|
||||
<h2>Delete Account</h2>
|
||||
<p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
|
||||
@@ -275,7 +384,7 @@
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.current {
|
||||
.current, .description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
extract::{Query, RawQuery, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -17,11 +17,6 @@ pub struct GetProfileParams {
|
||||
pub actor: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetProfilesParams {
|
||||
pub actors: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileViewDetailed {
|
||||
@@ -71,14 +66,15 @@ fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Va
|
||||
}
|
||||
|
||||
async fn proxy_to_appview(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
params: &HashMap<String, String>,
|
||||
auth_did: &str,
|
||||
auth_key_bytes: Option<&[u8]>,
|
||||
) -> Result<(StatusCode, Value), Response> {
|
||||
let appview_url = match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
let resolved = match state.appview_registry.get_appview_for_method(method).await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(
|
||||
@@ -88,14 +84,51 @@ async fn proxy_to_appview(
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
let target_url = format!("{}/xrpc/{}", appview_url, method);
|
||||
let target_url = format!("{}/xrpc/{}", resolved.url, method);
|
||||
info!("Proxying GET request to {}", target_url);
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.get(&target_url).query(params);
|
||||
let request_builder = client.get(&target_url).query(params);
|
||||
proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await
|
||||
}
|
||||
|
||||
async fn proxy_to_appview_raw(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
raw_query: Option<&str>,
|
||||
auth_did: &str,
|
||||
auth_key_bytes: Option<&[u8]>,
|
||||
) -> Result<(StatusCode, Value), Response> {
|
||||
let resolved = match state.appview_registry.get_appview_for_method(method).await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(
|
||||
json!({"error": "UpstreamError", "message": "No upstream AppView configured"}),
|
||||
),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
let target_url = match raw_query {
|
||||
Some(q) => format!("{}/xrpc/{}?{}", resolved.url, method, q),
|
||||
None => format!("{}/xrpc/{}", resolved.url, method),
|
||||
};
|
||||
info!("Proxying GET request to {}", target_url);
|
||||
let client = proxy_client();
|
||||
let request_builder = client.get(&target_url);
|
||||
proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await
|
||||
}
|
||||
|
||||
async fn proxy_request(
|
||||
mut request_builder: reqwest::RequestBuilder,
|
||||
auth_did: &str,
|
||||
auth_key_bytes: Option<&[u8]>,
|
||||
method: &str,
|
||||
appview_did: &str,
|
||||
) -> Result<(StatusCode, Value), Response> {
|
||||
if let Some(key_bytes) = auth_key_bytes {
|
||||
let appview_did =
|
||||
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
|
||||
match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) {
|
||||
Ok(service_token) => {
|
||||
request_builder =
|
||||
request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
@@ -167,6 +200,7 @@ pub async fn get_profile(
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("actor".to_string(), params.actor.clone());
|
||||
let (status, body) = match proxy_to_appview(
|
||||
&state,
|
||||
"app.bsky.actor.getProfile",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
@@ -201,7 +235,7 @@ pub async fn get_profile(
|
||||
pub async fn get_profiles(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<GetProfilesParams>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Response {
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let auth_user = if let Some(h) = auth_header {
|
||||
@@ -217,11 +251,10 @@ pub async fn get_profiles(
|
||||
};
|
||||
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
|
||||
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("actors".to_string(), params.actors.clone());
|
||||
let (status, body) = match proxy_to_appview(
|
||||
let (status, body) = match proxy_to_appview_raw(
|
||||
&state,
|
||||
"app.bsky.actor.getProfiles",
|
||||
&query_params,
|
||||
raw_query.as_deref(),
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
auth_key_bytes.as_deref(),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
extract::{Query, RawQuery, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -88,17 +88,31 @@ pub async fn get_account_info(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAccountInfosParams {
|
||||
pub dids: String,
|
||||
fn parse_repeated_param(query: Option<&str>, key: &str) -> Vec<String> {
|
||||
query
|
||||
.map(|q| {
|
||||
q.split('&')
|
||||
.filter_map(|pair| {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
let k = parts.next()?;
|
||||
let v = parts.next()?;
|
||||
if k == key {
|
||||
Some(urlencoding::decode(v).ok()?.into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn get_account_infos(
|
||||
State(state): State<AppState>,
|
||||
_auth: BearerAuthAdmin,
|
||||
Query(params): Query<GetAccountInfosParams>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Response {
|
||||
let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect();
|
||||
let dids = parse_repeated_param(raw_query.as_deref(), "dids");
|
||||
if dids.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -107,7 +121,7 @@ pub async fn get_account_infos(
|
||||
.into_response();
|
||||
}
|
||||
let mut infos = Vec::new();
|
||||
for did in dids {
|
||||
for did in &dids {
|
||||
if did.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
mod delete;
|
||||
mod email;
|
||||
mod info;
|
||||
mod profile;
|
||||
mod search;
|
||||
mod update;
|
||||
|
||||
pub use delete::{DeleteAccountInput, delete_account};
|
||||
pub use email::{SendEmailInput, SendEmailOutput, send_email};
|
||||
pub use info::{
|
||||
AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, GetAccountInfosParams,
|
||||
get_account_info, get_account_infos,
|
||||
};
|
||||
pub use profile::{
|
||||
CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput, create_profile,
|
||||
create_record_admin,
|
||||
AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, get_account_info, get_account_infos,
|
||||
};
|
||||
pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts};
|
||||
pub use update::{
|
||||
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
|
||||
update_account_email, update_account_handle, update_account_password,
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
use crate::api::repo::record::create_record_internal;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateProfileInput {
|
||||
pub did: String,
|
||||
pub display_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateRecordAdminInput {
|
||||
pub did: String,
|
||||
pub collection: String,
|
||||
pub rkey: Option<String>,
|
||||
pub record: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateProfileOutput {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
pub async fn create_profile(
|
||||
State(state): State<AppState>,
|
||||
_auth: BearerAuthAdmin,
|
||||
Json(input): Json<CreateProfileInput>,
|
||||
) -> Response {
|
||||
let did = input.did.trim();
|
||||
if did.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile"
|
||||
});
|
||||
|
||||
if let Some(display_name) = &input.display_name {
|
||||
profile_record["displayName"] = json!(display_name);
|
||||
}
|
||||
if let Some(description) = &input.description {
|
||||
profile_record["description"] = json!(description);
|
||||
}
|
||||
|
||||
match create_record_internal(
|
||||
&state,
|
||||
did,
|
||||
"app.bsky.actor.profile",
|
||||
"self",
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((uri, commit_cid)) => {
|
||||
info!(did = %did, uri = %uri, "Created profile for user");
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateProfileOutput {
|
||||
uri,
|
||||
cid: commit_cid.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create profile for {}: {}", did, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_record_admin(
|
||||
State(state): State<AppState>,
|
||||
_auth: BearerAuthAdmin,
|
||||
Json(input): Json<CreateRecordAdminInput>,
|
||||
) -> Response {
|
||||
let did = input.did.trim();
|
||||
if did.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let rkey = input
|
||||
.rkey
|
||||
.unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string());
|
||||
|
||||
match create_record_internal(&state, did, &input.collection, &rkey, &input.record).await {
|
||||
Ok((uri, commit_cid)) => {
|
||||
info!(did = %did, uri = %uri, "Admin created record");
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateProfileOutput {
|
||||
uri,
|
||||
cid: commit_cid.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create record for {}: {}", did, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/api/admin/account/search.rs
Normal file
114
src/api/admin/account/search.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchAccountsParams {
|
||||
pub handle: Option<String>,
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
pub indexed_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email_confirmed_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deactivated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub invites_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchAccountsOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub accounts: Vec<AccountView>,
|
||||
}
|
||||
|
||||
pub async fn search_accounts(
|
||||
State(state): State<AppState>,
|
||||
_auth: BearerAuthAdmin,
|
||||
Query(params): Query<SearchAccountsParams>,
|
||||
) -> Response {
|
||||
let limit = params.limit.clamp(1, 100);
|
||||
let cursor_did = params.cursor.as_deref().unwrap_or("");
|
||||
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
|
||||
let result = sqlx::query_as::<_, (String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
||||
r#"
|
||||
SELECT did, handle, email, created_at, email_confirmed, deactivated_at
|
||||
FROM users
|
||||
WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2)
|
||||
ORDER BY did ASC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(cursor_did)
|
||||
.bind(&handle_filter)
|
||||
.bind(limit + 1)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
let has_more = rows.len() > limit as usize;
|
||||
let accounts: Vec<AccountView> = rows
|
||||
.into_iter()
|
||||
.take(limit as usize)
|
||||
.map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView {
|
||||
did: did.clone(),
|
||||
handle,
|
||||
email,
|
||||
indexed_at: created_at.to_rfc3339(),
|
||||
email_confirmed_at: if email_confirmed {
|
||||
Some(created_at.to_rfc3339())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()),
|
||||
invites_disabled: None,
|
||||
})
|
||||
.collect();
|
||||
let next_cursor = if has_more {
|
||||
accounts.last().map(|a| a.did.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(SearchAccountsOutput {
|
||||
cursor: next_cursor,
|
||||
accounts,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in search_accounts: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ pub mod server_stats;
|
||||
pub mod status;
|
||||
|
||||
pub use account::{
|
||||
create_profile, create_record_admin, delete_account, get_account_info, get_account_infos,
|
||||
send_email, update_account_email, update_account_handle, update_account_password,
|
||||
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
|
||||
update_account_email, update_account_handle, update_account_password,
|
||||
};
|
||||
pub use invite::{
|
||||
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::api::read_after_write::{
|
||||
FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -87,7 +87,8 @@ pub async fn get_actor_likes(
|
||||
if let Some(cursor) = ¶ms.cursor {
|
||||
query_params.insert("cursor".to_string(), cursor.clone());
|
||||
}
|
||||
let proxy_result = match proxy_to_appview(
|
||||
let proxy_result = match proxy_to_appview_via_registry(
|
||||
&state,
|
||||
"app.bsky.feed.getActorLikes",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::api::read_after_write::{
|
||||
FeedOutput, FeedViewPost, ProfileRecord, RecordDescript, extract_repo_rev, format_local_post,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
|
||||
proxy_to_appview,
|
||||
proxy_to_appview_via_registry,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -70,7 +70,8 @@ pub async fn get_author_feed(
|
||||
if let Some(include_pins) = params.include_pins {
|
||||
query_params.insert("includePins".to_string(), include_pins.to_string());
|
||||
}
|
||||
let proxy_result = match proxy_to_appview(
|
||||
let proxy_result = match proxy_to_appview_via_registry(
|
||||
&state,
|
||||
"app.bsky.feed.getAuthorFeed",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
|
||||
@@ -37,14 +37,14 @@ pub async fn get_feed(
|
||||
if let Err(e) = validate_at_uri(¶ms.feed) {
|
||||
return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response();
|
||||
}
|
||||
let appview_url = match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string())
|
||||
let resolved = match state.appview_registry.get_appview_for_method("app.bsky.feed.getFeed").await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.feed.getFeed".to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = is_ssrf_safe(&appview_url) {
|
||||
if let Err(e) = is_ssrf_safe(&resolved.url) {
|
||||
error!("SSRF check failed for appview URL: {}", e);
|
||||
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
|
||||
.into_response();
|
||||
@@ -56,16 +56,14 @@ pub async fn get_feed(
|
||||
if let Some(cursor) = ¶ms.cursor {
|
||||
query_params.insert("cursor".to_string(), cursor.clone());
|
||||
}
|
||||
let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", appview_url);
|
||||
let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", resolved.url);
|
||||
info!(target = %target_url, feed = %params.feed, "Proxying getFeed request");
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.get(&target_url).query(&query_params);
|
||||
if let Some(key_bytes) = auth_user.key_bytes.as_ref() {
|
||||
let appview_did =
|
||||
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
&appview_did,
|
||||
&resolved.did,
|
||||
"app.bsky.feed.getFeed",
|
||||
key_bytes,
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::api::read_after_write::{
|
||||
PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -153,7 +153,8 @@ pub async fn get_post_thread(
|
||||
if let Some(parent_height) = params.parent_height {
|
||||
query_params.insert("parentHeight".to_string(), parent_height.to_string());
|
||||
}
|
||||
let proxy_result = match proxy_to_appview(
|
||||
let proxy_result = match proxy_to_appview_via_registry(
|
||||
&state,
|
||||
"app.bsky.feed.getPostThread",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::api::read_after_write::{
|
||||
FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
|
||||
proxy_to_appview,
|
||||
proxy_to_appview_via_registry,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -50,17 +50,14 @@ pub async fn get_timeline(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) if !url.starts_with("http://127.0.0.1") => {
|
||||
return get_timeline_with_appview(
|
||||
&state,
|
||||
¶ms,
|
||||
&auth_user.did,
|
||||
auth_user.key_bytes.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {}
|
||||
if state.appview_registry.get_appview_for_method("app.bsky.feed.getTimeline").await.is_some() {
|
||||
return get_timeline_with_appview(
|
||||
&state,
|
||||
¶ms,
|
||||
&auth_user.did,
|
||||
auth_user.key_bytes.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
get_timeline_local_only(&state, &auth_user.did).await
|
||||
}
|
||||
@@ -81,7 +78,8 @@ async fn get_timeline_with_appview(
|
||||
if let Some(cursor) = ¶ms.cursor {
|
||||
query_params.insert("cursor".to_string(), cursor.clone());
|
||||
}
|
||||
let proxy_result = match proxy_to_appview(
|
||||
let proxy_result = match proxy_to_appview_via_registry(
|
||||
state,
|
||||
"app.bsky.feed.getTimeline",
|
||||
&query_params,
|
||||
auth_did,
|
||||
|
||||
@@ -53,14 +53,14 @@ pub async fn register_push(
|
||||
if input.app_id.is_empty() || input.app_id.len() > 256 {
|
||||
return ApiError::InvalidRequest("Invalid appId".to_string()).into_response();
|
||||
}
|
||||
let appview_url = match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string())
|
||||
let resolved = match state.appview_registry.get_appview_for_method("app.bsky.notification.registerPush").await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.notification.registerPush".to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = is_ssrf_safe(&appview_url) {
|
||||
if let Err(e) = is_ssrf_safe(&resolved.url) {
|
||||
error!("SSRF check failed for appview URL: {}", e);
|
||||
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
|
||||
.into_response();
|
||||
@@ -102,7 +102,7 @@ pub async fn register_push(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", appview_url);
|
||||
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", resolved.url);
|
||||
info!(
|
||||
target = %target_url,
|
||||
service_did = %input.service_did,
|
||||
|
||||
@@ -2,30 +2,18 @@ use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, RawQuery, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tracing::error;
|
||||
|
||||
fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> {
|
||||
if let Some(without_prefix) = did_with_fragment.strip_prefix("did:web:") {
|
||||
let host = without_prefix.split('#').next()?;
|
||||
let url = format!("https://{}", host);
|
||||
let did_without_fragment = format!("did:web:{}", host);
|
||||
Some((url, did_without_fragment))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub async fn proxy_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(method): Path<String>,
|
||||
method_verb: Method,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
RawQuery(query): RawQuery,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let proxy_header = headers
|
||||
@@ -34,54 +22,70 @@ pub async fn proxy_handler(
|
||||
.map(|s| s.to_string());
|
||||
let (appview_url, service_aud) = match &proxy_header {
|
||||
Some(did_str) => {
|
||||
let (url, did_without_fragment) = match resolve_service_did(did_str) {
|
||||
Some(resolved) => resolved,
|
||||
let did_without_fragment = did_str.split('#').next().unwrap_or(did_str).to_string();
|
||||
match state.appview_registry.resolve_appview_did(&did_without_fragment).await {
|
||||
Some(resolved) => (resolved.url, Some(resolved.did)),
|
||||
None => {
|
||||
error!(did = %did_str, "Could not resolve service DID");
|
||||
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(url, Some(did_without_fragment))
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let url = match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured")
|
||||
match state.appview_registry.get_appview_for_method(&method).await {
|
||||
Some(resolved) => (resolved.url, Some(resolved.did)),
|
||||
None => {
|
||||
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured for this method")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let aud = std::env::var("APPVIEW_DID").ok();
|
||||
(url, aud)
|
||||
}
|
||||
}
|
||||
};
|
||||
let target_url = format!("{}/xrpc/{}", appview_url, method);
|
||||
let target_url = match &query {
|
||||
Some(q) => format!("{}/xrpc/{}?{}", appview_url, method, q),
|
||||
None => format!("{}/xrpc/{}", appview_url, method),
|
||||
};
|
||||
info!("Proxying {} request to {}", method_verb, target_url);
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.request(method_verb, &target_url).query(¶ms);
|
||||
let mut request_builder = client.request(method_verb, &target_url);
|
||||
let mut auth_header_val = headers.get("Authorization").cloned();
|
||||
if let Some(aud) = &service_aud
|
||||
&& let Some(token) = crate::auth::extract_bearer_token_from_header(
|
||||
if let Some(aud) = &service_aud {
|
||||
if let Some(token) = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
)
|
||||
&& let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await
|
||||
&& let Some(key_bytes) = auth_user.key_bytes
|
||||
&& let Ok(new_token) =
|
||||
crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes)
|
||||
&& let Ok(val) =
|
||||
axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token))
|
||||
{
|
||||
auth_header_val = Some(val);
|
||||
) {
|
||||
match crate::auth::validate_bearer_token(&state.db, &token).await {
|
||||
Ok(auth_user) => {
|
||||
if let Some(key_bytes) = auth_user.key_bytes {
|
||||
match crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes) {
|
||||
Ok(new_token) => {
|
||||
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) {
|
||||
auth_header_val = Some(val);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create service token: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Token validation failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(val) = auth_header_val {
|
||||
request_builder = request_builder.header("Authorization", val);
|
||||
}
|
||||
for (key, value) in headers.iter() {
|
||||
if key != "host" && key != "content-length" && key != "authorization" {
|
||||
request_builder = request_builder.header(key, value);
|
||||
for header_name in crate::api::proxy_client::HEADERS_TO_FORWARD {
|
||||
if let Some(val) = headers.get(*header_name) {
|
||||
request_builder = request_builder.header(*header_name, val);
|
||||
}
|
||||
}
|
||||
request_builder = request_builder.body(body);
|
||||
if !body.is_empty() {
|
||||
request_builder = request_builder.body(body);
|
||||
}
|
||||
match request_builder.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
@@ -95,8 +99,10 @@ pub async fn proxy_handler(
|
||||
}
|
||||
};
|
||||
let mut response_builder = Response::builder().status(status);
|
||||
for (key, value) in headers.iter() {
|
||||
response_builder = response_builder.header(key, value);
|
||||
for header_name in crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD {
|
||||
if let Some(val) = headers.get(*header_name) {
|
||||
response_builder = response_builder.header(*header_name, val);
|
||||
}
|
||||
}
|
||||
match response_builder.body(axum::body::Body::from(body)) {
|
||||
Ok(r) => r,
|
||||
|
||||
@@ -121,12 +121,15 @@ pub const HEADERS_TO_FORWARD: &[&str] = &[
|
||||
"accept-language",
|
||||
"atproto-accept-labelers",
|
||||
"x-bsky-topics",
|
||||
"content-type",
|
||||
];
|
||||
pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
|
||||
"atproto-repo-rev",
|
||||
"atproto-content-labelers",
|
||||
"retry-after",
|
||||
"content-type",
|
||||
"cache-control",
|
||||
"etag",
|
||||
];
|
||||
|
||||
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
|
||||
|
||||
@@ -238,16 +238,28 @@ impl ProxyResponse {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_to_appview(
|
||||
pub async fn proxy_to_appview_via_registry(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
params: &HashMap<String, String>,
|
||||
auth_did: &str,
|
||||
auth_key_bytes: Option<&[u8]>,
|
||||
) -> Result<ProxyResponse, Response> {
|
||||
let appview_url = std::env::var("APPVIEW_URL").map_err(|_| {
|
||||
ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()).into_response()
|
||||
let resolved = state.appview_registry.get_appview_for_method(method).await.ok_or_else(|| {
|
||||
ApiError::UpstreamUnavailable(format!("No AppView configured for method: {}", method)).into_response()
|
||||
})?;
|
||||
if let Err(e) = is_ssrf_safe(&appview_url) {
|
||||
proxy_to_appview_with_url(method, params, auth_did, auth_key_bytes, &resolved.url, &resolved.did).await
|
||||
}
|
||||
|
||||
pub async fn proxy_to_appview_with_url(
|
||||
method: &str,
|
||||
params: &HashMap<String, String>,
|
||||
auth_did: &str,
|
||||
auth_key_bytes: Option<&[u8]>,
|
||||
appview_url: &str,
|
||||
appview_did: &str,
|
||||
) -> Result<ProxyResponse, Response> {
|
||||
if let Err(e) = is_ssrf_safe(appview_url) {
|
||||
error!("SSRF check failed for appview URL: {}", e);
|
||||
return Err(
|
||||
ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(),
|
||||
@@ -258,9 +270,7 @@ pub async fn proxy_to_appview(
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.get(&target_url).query(params);
|
||||
if let Some(key_bytes) = auth_key_bytes {
|
||||
let appview_did =
|
||||
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
|
||||
match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) {
|
||||
Ok(service_token) => {
|
||||
request_builder =
|
||||
request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
|
||||
@@ -1,21 +1,75 @@
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
extract::{Query, RawQuery, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DescribeRepoInput {
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
async fn proxy_describe_repo_to_appview(state: &AppState, raw_query: Option<&str>) -> Response {
|
||||
let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.describeRepo").await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let target_url = match raw_query {
|
||||
Some(q) => format!("{}/xrpc/com.atproto.repo.describeRepo?{}", resolved.url, q),
|
||||
None => format!("{}/xrpc/com.atproto.repo.describeRepo", resolved.url),
|
||||
};
|
||||
info!("Proxying describeRepo to AppView: {}", target_url);
|
||||
let client = proxy_client();
|
||||
match client.get(&target_url).send().await {
|
||||
Ok(resp) => {
|
||||
let status =
|
||||
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
match resp.bytes().await {
|
||||
Ok(body) => {
|
||||
let mut builder = Response::builder().status(status);
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.header("content-type", ct);
|
||||
}
|
||||
builder
|
||||
.body(axum::body::Body::from(body))
|
||||
.unwrap_or_else(|_| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reading AppView response: {:?}", e);
|
||||
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error proxying to AppView: {:?}", e);
|
||||
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn describe_repo(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<DescribeRepoInput>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Response {
|
||||
let user_row = if input.repo.starts_with("did:") {
|
||||
sqlx::query!(
|
||||
@@ -37,11 +91,7 @@ pub async fn describe_repo(
|
||||
let (user_id, handle, did) = match user_row {
|
||||
Ok(Some((id, handle, did))) => (id, handle, did),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return proxy_describe_repo_to_appview(&state, raw_query.as_deref()).await;
|
||||
}
|
||||
};
|
||||
let collections_query = sqlx::query!(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
extract::{Query, RawQuery, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -11,7 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetRecordInput {
|
||||
@@ -21,9 +22,69 @@ pub struct GetRecordInput {
|
||||
pub cid: Option<String>,
|
||||
}
|
||||
|
||||
async fn proxy_get_record_to_appview(state: &AppState, raw_query: Option<&str>) -> Response {
|
||||
let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.getRecord").await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let target_url = match raw_query {
|
||||
Some(q) => format!("{}/xrpc/com.atproto.repo.getRecord?{}", resolved.url, q),
|
||||
None => format!("{}/xrpc/com.atproto.repo.getRecord", resolved.url),
|
||||
};
|
||||
info!("Proxying getRecord to AppView: {}", target_url);
|
||||
let client = proxy_client();
|
||||
match client.get(&target_url).send().await {
|
||||
Ok(resp) => {
|
||||
let status =
|
||||
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
match resp.bytes().await {
|
||||
Ok(body) => {
|
||||
let mut builder = Response::builder().status(status);
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.header("content-type", ct);
|
||||
}
|
||||
builder
|
||||
.body(axum::body::Body::from(body))
|
||||
.unwrap_or_else(|_| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reading AppView response: {:?}", e);
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": "UpstreamError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error proxying to AppView: {:?}", e);
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": "UpstreamError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_record(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<GetRecordInput>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user_id_opt = if input.repo.starts_with("did:") {
|
||||
@@ -46,11 +107,7 @@ pub async fn get_record(
|
||||
let user_id: uuid::Uuid = match user_id_opt {
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return proxy_get_record_to_appview(&state, raw_query.as_deref()).await;
|
||||
}
|
||||
};
|
||||
let record_row = sqlx::query!(
|
||||
@@ -134,9 +191,62 @@ pub struct ListRecordsOutput {
|
||||
pub cursor: Option<String>,
|
||||
pub records: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
async fn proxy_list_records_to_appview(state: &AppState, raw_query: Option<&str>) -> Response {
|
||||
let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.listRecords").await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let target_url = match raw_query {
|
||||
Some(q) => format!("{}/xrpc/com.atproto.repo.listRecords?{}", resolved.url, q),
|
||||
None => format!("{}/xrpc/com.atproto.repo.listRecords", resolved.url),
|
||||
};
|
||||
info!("Proxying listRecords to AppView: {}", target_url);
|
||||
let client = proxy_client();
|
||||
match client.get(&target_url).send().await {
|
||||
Ok(resp) => {
|
||||
let status =
|
||||
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
match resp.bytes().await {
|
||||
Ok(body) => {
|
||||
let mut builder = Response::builder().status(status);
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.header("content-type", ct);
|
||||
}
|
||||
builder
|
||||
.body(axum::body::Body::from(body))
|
||||
.unwrap_or_else(|_| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reading AppView response: {:?}", e);
|
||||
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error proxying to AppView: {:?}", e);
|
||||
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_records(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<ListRecordsInput>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user_id_opt = if input.repo.starts_with("did:") {
|
||||
@@ -159,11 +269,7 @@ pub async fn list_records(
|
||||
let user_id: uuid::Uuid = match user_id_opt {
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return proxy_list_records_to_appview(&state, raw_query.as_deref()).await;
|
||||
}
|
||||
};
|
||||
let limit = input.limit.unwrap_or(50).clamp(1, 100);
|
||||
|
||||
@@ -16,10 +16,10 @@ pub use app_password::{create_app_password, list_app_passwords, revoke_app_passw
|
||||
pub use email::{confirm_email, request_email_update, update_email};
|
||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use password::{request_password_reset, reset_password};
|
||||
pub use password::{change_password, request_password_reset, reset_password};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{
|
||||
confirm_signup, create_session, delete_session, get_session, refresh_session,
|
||||
resend_verification,
|
||||
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
|
||||
resend_verification, revoke_session,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -5,8 +6,9 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use chrono::{Duration, Utc};
|
||||
use uuid::Uuid;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -297,3 +299,108 @@ pub async fn reset_password(
|
||||
info!("Password reset completed for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChangePasswordInput {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<ChangePasswordInput>,
|
||||
) -> Response {
|
||||
let current_password = &input.current_password;
|
||||
let new_password = &input.new_password;
|
||||
if current_password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if new_password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if new_password.len() < 8 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let user = sqlx::query_as::<_, (Uuid, String)>(
|
||||
"SELECT id, password_hash FROM users WHERE did = $1",
|
||||
)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let (user_id, password_hash) = match user {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in change_password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let valid = match verify(current_password, &password_hash) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Password verification error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let new_hash = match hash(new_password, DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
.bind(&new_hash)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error updating password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %auth.0.did, "Password changed successfully");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ pub async fn get_session(
|
||||
) -> Response {
|
||||
match sqlx::query!(
|
||||
r#"SELECT
|
||||
handle, email, email_confirmed,
|
||||
handle, email, email_confirmed, is_admin,
|
||||
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
|
||||
discord_verified, telegram_verified, signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
@@ -210,6 +210,7 @@ pub async fn get_session(
|
||||
"emailConfirmed": row.email_confirmed,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"isAdmin": row.is_admin,
|
||||
"active": true,
|
||||
"didDoc": {}
|
||||
})).into_response()
|
||||
@@ -406,7 +407,7 @@ pub async fn refresh_session(
|
||||
}
|
||||
match sqlx::query!(
|
||||
r#"SELECT
|
||||
handle, email, email_confirmed,
|
||||
handle, email, email_confirmed, is_admin,
|
||||
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
|
||||
discord_verified, telegram_verified, signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
@@ -433,6 +434,7 @@ pub async fn refresh_session(
|
||||
"emailConfirmed": u.email_confirmed,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"isAdmin": u.is_admin,
|
||||
"active": true
|
||||
})).into_response()
|
||||
}
|
||||
@@ -702,3 +704,129 @@ pub async fn resend_verification(
|
||||
}
|
||||
Json(json!({"success": true})).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub created_at: String,
|
||||
pub expires_at: String,
|
||||
pub is_current: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListSessionsOutput {
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_sessions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: BearerAuth,
|
||||
) -> Response {
|
||||
let current_jti = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
|
||||
let result = sqlx::query_as::<_, (i32, String, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>(
|
||||
r#"
|
||||
SELECT id, access_jti, created_at, refresh_expires_at
|
||||
FROM session_tokens
|
||||
WHERE did = $1 AND refresh_expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
let sessions: Vec<SessionInfo> = rows
|
||||
.into_iter()
|
||||
.map(|(id, access_jti, created_at, expires_at)| SessionInfo {
|
||||
id: id.to_string(),
|
||||
created_at: created_at.to_rfc3339(),
|
||||
expires_at: expires_at.to_rfc3339(),
|
||||
is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti),
|
||||
})
|
||||
.collect();
|
||||
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in list_sessions: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RevokeSessionInput {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
pub async fn revoke_session(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RevokeSessionInput>,
|
||||
) -> Response {
|
||||
let session_id: i32 = match input.session_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let session = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let access_jti = match session {
|
||||
Ok(Some((jti,))) => jti,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in revoke_session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error deleting session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache: {:?}", e);
|
||||
}
|
||||
info!(did = %auth.0.did, session_id = %session_id, "Session revoked");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
413
src/appview/mod.rs
Normal file
413
src/appview/mod.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DidDocument {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub service: Vec<DidService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DidService {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub service_type: String,
|
||||
pub service_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedAppView {
|
||||
url: String,
|
||||
did: String,
|
||||
resolved_at: Instant,
|
||||
}
|
||||
|
||||
pub struct AppViewRegistry {
|
||||
namespace_to_did: HashMap<String, String>,
|
||||
did_cache: RwLock<HashMap<String, CachedAppView>>,
|
||||
client: Client,
|
||||
cache_ttl: Duration,
|
||||
plc_directory_url: String,
|
||||
}
|
||||
|
||||
impl Clone for AppViewRegistry {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
namespace_to_did: self.namespace_to_did.clone(),
|
||||
did_cache: RwLock::new(HashMap::new()),
|
||||
client: self.client.clone(),
|
||||
cache_ttl: self.cache_ttl,
|
||||
plc_directory_url: self.plc_directory_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedAppView {
|
||||
pub url: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
impl AppViewRegistry {
|
||||
pub fn new() -> Self {
|
||||
let mut namespace_to_did = HashMap::new();
|
||||
|
||||
let bsky_did = std::env::var("APPVIEW_DID_BSKY")
|
||||
.unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
namespace_to_did.insert("app.bsky".to_string(), bsky_did.clone());
|
||||
namespace_to_did.insert("com.atproto".to_string(), bsky_did);
|
||||
|
||||
for (key, value) in std::env::vars() {
|
||||
if let Some(namespace) = key.strip_prefix("APPVIEW_DID_") {
|
||||
let namespace = namespace.to_lowercase().replace('_', ".");
|
||||
if namespace != "bsky" {
|
||||
namespace_to_did.insert(namespace, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cache_ttl_secs: u64 = std::env::var("APPVIEW_CACHE_TTL_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(300);
|
||||
|
||||
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
|
||||
.unwrap_or_else(|_| "https://plc.directory".to_string());
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.pool_max_idle_per_host(10)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
info!(
|
||||
"AppView registry initialized with {} namespace mappings",
|
||||
namespace_to_did.len()
|
||||
);
|
||||
for (ns, did) in &namespace_to_did {
|
||||
debug!(" {} -> {}", ns, did);
|
||||
}
|
||||
|
||||
Self {
|
||||
namespace_to_did,
|
||||
did_cache: RwLock::new(HashMap::new()),
|
||||
client,
|
||||
cache_ttl: Duration::from_secs(cache_ttl_secs),
|
||||
plc_directory_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_namespace(&mut self, namespace: &str, did: &str) {
|
||||
info!("Registering AppView: {} -> {}", namespace, did);
|
||||
self.namespace_to_did
|
||||
.insert(namespace.to_string(), did.to_string());
|
||||
}
|
||||
|
||||
pub async fn get_appview_for_method(&self, method: &str) -> Option<ResolvedAppView> {
|
||||
let namespace = self.extract_namespace(method)?;
|
||||
self.get_appview_for_namespace(&namespace).await
|
||||
}
|
||||
|
||||
pub async fn get_appview_for_namespace(&self, namespace: &str) -> Option<ResolvedAppView> {
|
||||
let did = self.get_did_for_namespace(namespace)?;
|
||||
self.resolve_appview_did(&did).await
|
||||
}
|
||||
|
||||
pub fn get_did_for_namespace(&self, namespace: &str) -> Option<String> {
|
||||
if let Some(did) = self.namespace_to_did.get(namespace) {
|
||||
return Some(did.clone());
|
||||
}
|
||||
|
||||
let mut parts: Vec<&str> = namespace.split('.').collect();
|
||||
while !parts.is_empty() {
|
||||
let prefix = parts.join(".");
|
||||
if let Some(did) = self.namespace_to_did.get(&prefix) {
|
||||
return Some(did.clone());
|
||||
}
|
||||
parts.pop();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn resolve_appview_did(&self, did: &str) -> Option<ResolvedAppView> {
|
||||
{
|
||||
let cache = self.did_cache.read().await;
|
||||
if let Some(cached) = cache.get(did) {
|
||||
if cached.resolved_at.elapsed() < self.cache_ttl {
|
||||
return Some(ResolvedAppView {
|
||||
url: cached.url.clone(),
|
||||
did: cached.did.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = self.resolve_did_internal(did).await?;
|
||||
|
||||
{
|
||||
let mut cache = self.did_cache.write().await;
|
||||
cache.insert(
|
||||
did.to_string(),
|
||||
CachedAppView {
|
||||
url: resolved.url.clone(),
|
||||
did: resolved.did.clone(),
|
||||
resolved_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
async fn resolve_did_internal(&self, did: &str) -> Option<ResolvedAppView> {
|
||||
let did_doc = if did.starts_with("did:web:") {
|
||||
self.resolve_did_web(did).await
|
||||
} else if did.starts_with("did:plc:") {
|
||||
self.resolve_did_plc(did).await
|
||||
} else {
|
||||
warn!("Unsupported DID method: {}", did);
|
||||
return None;
|
||||
};
|
||||
|
||||
let doc = match did_doc {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
error!("Failed to resolve DID {}: {}", did, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
self.extract_appview_endpoint(&doc)
|
||||
}
|
||||
|
||||
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> {
|
||||
let host = did
|
||||
.strip_prefix("did:web:")
|
||||
.ok_or("Invalid did:web format")?;
|
||||
|
||||
let (host, path) = if host.contains(':') {
|
||||
let decoded = host.replace("%3A", ":");
|
||||
let parts: Vec<&str> = decoded.splitn(2, '/').collect();
|
||||
if parts.len() > 1 {
|
||||
(parts[0].to_string(), format!("/{}", parts[1]))
|
||||
} else {
|
||||
(decoded, String::new())
|
||||
}
|
||||
} else {
|
||||
let parts: Vec<&str> = host.splitn(2, ':').collect();
|
||||
if parts.len() > 1 && parts[1].contains('/') {
|
||||
let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect();
|
||||
if path_parts.len() > 1 {
|
||||
(
|
||||
format!("{}:{}", parts[0], path_parts[0]),
|
||||
format!("/{}", path_parts[1]),
|
||||
)
|
||||
} else {
|
||||
(host.to_string(), String::new())
|
||||
}
|
||||
} else {
|
||||
(host.to_string(), String::new())
|
||||
}
|
||||
};
|
||||
|
||||
let scheme =
|
||||
if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':')
|
||||
{
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
|
||||
let url = if path.is_empty() {
|
||||
format!("{}://{}/.well-known/did.json", scheme, host)
|
||||
} else {
|
||||
format!("{}://{}{}/did.json", scheme, host, path)
|
||||
};
|
||||
|
||||
debug!("Resolving did:web {} via {}", did, url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID document: {}", e))
|
||||
}
|
||||
|
||||
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> {
|
||||
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
|
||||
|
||||
debug!("Resolving did:plc {} via {}", did, url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err("DID not found".to_string());
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID document: {}", e))
|
||||
}
|
||||
|
||||
fn extract_appview_endpoint(&self, doc: &DidDocument) -> Option<ResolvedAppView> {
|
||||
for service in &doc.service {
|
||||
if service.service_type == "AtprotoAppView"
|
||||
|| service.id.contains("atproto_appview")
|
||||
|| service.id.ends_with("#bsky_appview")
|
||||
{
|
||||
return Some(ResolvedAppView {
|
||||
url: service.service_endpoint.clone(),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for service in &doc.service {
|
||||
if service.service_type.contains("AppView") || service.id.contains("appview") {
|
||||
return Some(ResolvedAppView {
|
||||
url: service.service_endpoint.clone(),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(service) = doc.service.first() {
|
||||
if service.service_endpoint.starts_with("http") {
|
||||
warn!(
|
||||
"No explicit AppView service found for {}, using first service: {}",
|
||||
doc.id, service.service_endpoint
|
||||
);
|
||||
return Some(ResolvedAppView {
|
||||
url: service.service_endpoint.clone(),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if doc.id.starts_with("did:web:") {
|
||||
let host = doc.id.strip_prefix("did:web:")?;
|
||||
let decoded_host = host.replace("%3A", ":");
|
||||
let base_host = decoded_host.split('/').next()?;
|
||||
let scheme = if base_host.starts_with("localhost")
|
||||
|| base_host.starts_with("127.0.0.1")
|
||||
|| base_host.contains(':')
|
||||
{
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
warn!(
|
||||
"No service found for {}, deriving URL from DID: {}://{}",
|
||||
doc.id, scheme, base_host
|
||||
);
|
||||
return Some(ResolvedAppView {
|
||||
url: format!("{}://{}", scheme, base_host),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_namespace(&self, method: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = method.split('.').collect();
|
||||
if parts.len() >= 2 {
|
||||
Some(format!("{}.{}", parts[0], parts[1]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_namespaces(&self) -> Vec<(String, String)> {
|
||||
self.namespace_to_did
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn invalidate_cache(&self, did: &str) {
|
||||
let mut cache = self.did_cache.write().await;
|
||||
cache.remove(did);
|
||||
}
|
||||
|
||||
pub async fn invalidate_all_cache(&self) {
|
||||
let mut cache = self.did_cache.write().await;
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppViewRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_appview_url_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> {
|
||||
registry.get_appview_for_method(method).await.map(|r| r.url)
|
||||
}
|
||||
|
||||
pub async fn get_appview_did_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> {
|
||||
registry.get_appview_for_method(method).await.map(|r| r.did)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_namespace() {
|
||||
let registry = AppViewRegistry::new();
|
||||
assert_eq!(
|
||||
registry.extract_namespace("app.bsky.actor.getProfile"),
|
||||
Some("app.bsky".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
registry.extract_namespace("com.atproto.repo.createRecord"),
|
||||
Some("com.atproto".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
registry.extract_namespace("com.whtwnd.blog.getPost"),
|
||||
Some("com.whtwnd".to_string())
|
||||
);
|
||||
assert_eq!(registry.extract_namespace("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_did_for_namespace() {
|
||||
let mut registry = AppViewRegistry::new();
|
||||
registry.register_namespace("com.whtwnd", "did:web:whtwnd.com");
|
||||
|
||||
assert!(registry.get_did_for_namespace("app.bsky").is_some());
|
||||
assert_eq!(
|
||||
registry.get_did_for_namespace("com.whtwnd"),
|
||||
Some("did:web:whtwnd.com".to_string())
|
||||
);
|
||||
assert!(registry.get_did_for_namespace("unknown.namespace").is_none());
|
||||
}
|
||||
}
|
||||
25
src/lib.rs
25
src/lib.rs
@@ -1,4 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod appview;
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod circuit_breaker;
|
||||
@@ -49,6 +50,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.server.getSession",
|
||||
get(api::server::get_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.bspds.account.listSessions",
|
||||
get(api::server::list_sessions),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.bspds.account.revokeSession",
|
||||
post(api::server::revoke_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.deleteSession",
|
||||
post(api::server::delete_session),
|
||||
@@ -161,12 +170,8 @@ pub fn app(state: AppState) -> Router {
|
||||
get(api::admin::get_account_infos),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.bspds.admin.createProfile",
|
||||
post(api::admin::create_profile),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.bspds.admin.createRecord",
|
||||
post(api::admin::create_record_admin),
|
||||
"/xrpc/com.atproto.admin.searchAccounts",
|
||||
get(api::admin::search_accounts),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.activateAccount",
|
||||
@@ -192,6 +197,10 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.server.resetPassword",
|
||||
post(api::server::reset_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.bspds.account.changePassword",
|
||||
post(api::server::change_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.requestEmailUpdate",
|
||||
post(api::server::request_email_update),
|
||||
@@ -352,6 +361,10 @@ pub fn app(state: AppState) -> Router {
|
||||
get(oauth::endpoints::oauth_authorization_server),
|
||||
)
|
||||
.route("/oauth/jwks", get(oauth::endpoints::oauth_jwks))
|
||||
.route(
|
||||
"/oauth/client-metadata.json",
|
||||
get(oauth::endpoints::frontend_client_metadata),
|
||||
)
|
||||
.route(
|
||||
"/oauth/par",
|
||||
post(oauth::endpoints::pushed_authorization_request),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
|
||||
use crate::oauth::{
|
||||
Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates,
|
||||
Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates,
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
@@ -196,10 +196,16 @@ pub async fn authorize_get(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_name = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.client_name);
|
||||
if wants_json(&headers) {
|
||||
return Json(AuthorizeResponse {
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
client_name: None,
|
||||
client_name: client_name.clone(),
|
||||
scope: request_data.parameters.scope.clone(),
|
||||
redirect_uri: request_data.parameters.redirect_uri.clone(),
|
||||
state: request_data.parameters.state.clone(),
|
||||
@@ -223,7 +229,7 @@ pub async fn authorize_get(
|
||||
.collect();
|
||||
return Html(templates::account_selector_page(
|
||||
&request_data.parameters.client_id,
|
||||
None,
|
||||
client_name.as_deref(),
|
||||
&request_uri,
|
||||
&device_accounts,
|
||||
))
|
||||
@@ -231,7 +237,7 @@ pub async fn authorize_get(
|
||||
}
|
||||
Html(templates::login_page(
|
||||
&request_data.parameters.client_id,
|
||||
None,
|
||||
client_name.as_deref(),
|
||||
request_data.parameters.scope.as_deref(),
|
||||
&request_uri,
|
||||
None,
|
||||
@@ -352,6 +358,12 @@ pub async fn authorize_post(
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_name = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.client_name);
|
||||
let show_login_error = |error_msg: &str, json: bool| -> Response {
|
||||
if json {
|
||||
return (
|
||||
@@ -365,7 +377,7 @@ pub async fn authorize_post(
|
||||
}
|
||||
Html(templates::login_page(
|
||||
&request_data.parameters.client_id,
|
||||
None,
|
||||
client_name.as_deref(),
|
||||
request_data.parameters.scope.as_deref(),
|
||||
&form.request_uri,
|
||||
Some(error_msg),
|
||||
|
||||
@@ -127,3 +127,40 @@ pub async fn oauth_jwks(State(_state): State<AppState>) -> Json<JwkSet> {
|
||||
};
|
||||
Json(create_jwk_set(vec![server_key]))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FrontendClientMetadata {
|
||||
pub client_id: String,
|
||||
pub client_name: String,
|
||||
pub client_uri: String,
|
||||
pub redirect_uris: Vec<String>,
|
||||
pub grant_types: Vec<String>,
|
||||
pub response_types: Vec<String>,
|
||||
pub scope: String,
|
||||
pub token_endpoint_auth_method: String,
|
||||
pub application_type: String,
|
||||
pub dpop_bound_access_tokens: bool,
|
||||
}
|
||||
|
||||
pub async fn frontend_client_metadata(
|
||||
State(_state): State<AppState>,
|
||||
) -> Json<FrontendClientMetadata> {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let base_url = format!("https://{}", pds_hostname);
|
||||
let client_id = format!("{}/oauth/client-metadata.json", base_url);
|
||||
Json(FrontendClientMetadata {
|
||||
client_id,
|
||||
client_name: "PDS Account Manager".to_string(),
|
||||
client_uri: base_url.clone(),
|
||||
redirect_uris: vec![format!("{}/", base_url)],
|
||||
grant_types: vec![
|
||||
"authorization_code".to_string(),
|
||||
"refresh_token".to_string(),
|
||||
],
|
||||
response_types: vec!["code".to_string()],
|
||||
scope: "atproto transition:generic".to_string(),
|
||||
token_endpoint_auth_method: "none".to_string(),
|
||||
application_type: "web".to_string(),
|
||||
dpop_bound_access_tokens: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,47 +1,69 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
fn format_scope_for_display(scope: Option<&str>) -> String {
|
||||
let scope = scope.unwrap_or("");
|
||||
if scope.is_empty() || scope.contains("atproto") || scope.contains("transition:generic") {
|
||||
return "access your account".to_string();
|
||||
}
|
||||
let parts: Vec<&str> = scope.split_whitespace().collect();
|
||||
let friendly: Vec<&str> = parts
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
match *s {
|
||||
"atproto" | "transition:generic" | "transition:chat.bsky" => None,
|
||||
"read" => Some("read your data"),
|
||||
"write" => Some("write data"),
|
||||
other => Some(other),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if friendly.is_empty() {
|
||||
"access your account".to_string()
|
||||
} else {
|
||||
friendly.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
fn base_styles() -> &'static str {
|
||||
r#"
|
||||
:root {
|
||||
--primary: #0085ff;
|
||||
--primary-hover: #0077e6;
|
||||
--primary-contrast: #ffffff;
|
||||
--primary-100: #dbeafe;
|
||||
--primary-400: #60a5fa;
|
||||
--primary-600-30: rgba(37, 99, 235, 0.3);
|
||||
--contrast-0: #ffffff;
|
||||
--contrast-25: #f8f9fa;
|
||||
--contrast-50: #f1f3f5;
|
||||
--contrast-100: #e9ecef;
|
||||
--contrast-200: #dee2e6;
|
||||
--contrast-300: #ced4da;
|
||||
--contrast-400: #adb5bd;
|
||||
--contrast-500: #6b7280;
|
||||
--contrast-600: #4b5563;
|
||||
--contrast-700: #374151;
|
||||
--contrast-800: #1f2937;
|
||||
--contrast-900: #111827;
|
||||
--error: #dc2626;
|
||||
--error-bg: #fef2f2;
|
||||
--success: #059669;
|
||||
--success-bg: #ecfdf5;
|
||||
--bg-primary: #fafafa;
|
||||
--bg-secondary: #f9f9f9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #ffffff;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #999999;
|
||||
--border-color: #dddddd;
|
||||
--border-color-light: #cccccc;
|
||||
--accent: #0066cc;
|
||||
--accent-hover: #0052a3;
|
||||
--success-bg: #dfd;
|
||||
--success-border: #8c8;
|
||||
--success-text: #060;
|
||||
--error-bg: #fee;
|
||||
--error-border: #fcc;
|
||||
--error-text: #c00;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--contrast-0: #111827;
|
||||
--contrast-25: #1f2937;
|
||||
--contrast-50: #374151;
|
||||
--contrast-100: #4b5563;
|
||||
--contrast-200: #6b7280;
|
||||
--contrast-300: #9ca3af;
|
||||
--contrast-400: #d1d5db;
|
||||
--contrast-500: #e5e7eb;
|
||||
--contrast-600: #f3f4f6;
|
||||
--contrast-700: #f9fafb;
|
||||
--contrast-800: #ffffff;
|
||||
--contrast-900: #ffffff;
|
||||
--error-bg: #451a1a;
|
||||
--success-bg: #064e3b;
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #242424;
|
||||
--bg-card: #2a2a2a;
|
||||
--bg-input: #333333;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #707070;
|
||||
--border-color: #404040;
|
||||
--border-color-light: #505050;
|
||||
--accent: #4da6ff;
|
||||
--accent-hover: #7abbff;
|
||||
--success-bg: #1a3d1a;
|
||||
--success-border: #2d5a2d;
|
||||
--success-text: #7bc67b;
|
||||
--error-bg: #3d1a1a;
|
||||
--error-border: #5a2d2d;
|
||||
--error-text: #ff7b7b;
|
||||
}
|
||||
}
|
||||
* {
|
||||
@@ -50,103 +72,83 @@ fn base_styles() -> &'static str {
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--contrast-50);
|
||||
color: var(--contrast-900);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.card {
|
||||
background: var(--contrast-0);
|
||||
border: 1px solid var(--contrast-100);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 600;
|
||||
color: var(--contrast-900);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--contrast-500);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
.subtitle strong {
|
||||
color: var(--contrast-700);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.client-info {
|
||||
background: var(--contrast-25);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.client-info .client-name {
|
||||
font-weight: 500;
|
||||
color: var(--contrast-900);
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.client-info .scope {
|
||||
color: var(--contrast-500);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.error-banner {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--contrast-700);
|
||||
margin-bottom: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 2px solid var(--contrast-200);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
color: var(--contrast-900);
|
||||
background: var(--contrast-0);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-600-30);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
input[type="text"]::placeholder,
|
||||
input[type="email"]::placeholder,
|
||||
input[type="password"]::placeholder {
|
||||
color: var(--contrast-400);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
@@ -155,14 +157,14 @@ fn base_styles() -> &'static str {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
accent-color: var(--primary);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.checkbox-group label {
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
color: var(--contrast-600);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.buttons {
|
||||
@@ -171,45 +173,39 @@ fn base_styles() -> &'static str {
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, transform 0.1s;
|
||||
border: none;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
background: var(--primary-400);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--contrast-200);
|
||||
color: var(--contrast-800);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--contrast-300);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--contrast-400);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.accounts {
|
||||
display: flex;
|
||||
@@ -220,79 +216,56 @@ fn base_styles() -> &'static str {
|
||||
.account-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--contrast-25);
|
||||
border: 1px solid var(--contrast-100);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.account-item:hover {
|
||||
background: var(--contrast-50);
|
||||
border-color: var(--contrast-200);
|
||||
}
|
||||
.avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
|
||||
}
|
||||
.account-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.account-info .handle {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--contrast-900);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.account-info .email {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--contrast-500);
|
||||
.account-info .did {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chevron {
|
||||
color: var(--contrast-400);
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--contrast-100);
|
||||
background: var(--border-color);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.link-button:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
.new-account-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: var(--primary);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -303,7 +276,7 @@ fn base_styles() -> &'static str {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--contrast-500);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.icon {
|
||||
font-size: 3rem;
|
||||
@@ -311,9 +284,10 @@ fn base_styles() -> &'static str {
|
||||
}
|
||||
.error-code {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
@@ -323,7 +297,8 @@ fn base_styles() -> &'static str {
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -351,7 +326,7 @@ pub fn login_page(
|
||||
login_hint: Option<&str>,
|
||||
) -> String {
|
||||
let client_display = client_name.unwrap_or(client_id);
|
||||
let scope_display = scope.unwrap_or("access your account");
|
||||
let scope_display = format_scope_for_display(scope);
|
||||
let error_html = error_message
|
||||
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
|
||||
.unwrap_or_default();
|
||||
@@ -368,46 +343,44 @@ pub fn login_page(
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<p class="subtitle">to continue to <strong>{client_display}</strong></p>
|
||||
<div class="client-info">
|
||||
<span class="client-name">{client_display}</span>
|
||||
<span class="scope">wants to {scope_display}</span>
|
||||
</div>
|
||||
{error_html}
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="request_uri" value="{request_uri}">
|
||||
<div class="form-group">
|
||||
<label for="username">Handle or Email</label>
|
||||
<input type="text" id="username" name="username" value="{login_hint_value}"
|
||||
required autocomplete="username" autofocus
|
||||
placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
autocomplete="current-password" placeholder="Enter your password">
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="remember_device" name="remember_device" value="true">
|
||||
<label for="remember_device">Remember this device</label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="footer">
|
||||
By signing in, you agree to share your account information with this application.
|
||||
</div>
|
||||
<h1>Sign In</h1>
|
||||
<p class="subtitle">Sign in to continue to <strong>{client_display}</strong></p>
|
||||
<div class="client-info">
|
||||
<span class="client-name">{client_display}</span>
|
||||
<span class="scope">wants to {scope_display}</span>
|
||||
</div>
|
||||
{error_html}
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="request_uri" value="{request_uri}">
|
||||
<div class="form-group">
|
||||
<label for="username">Handle</label>
|
||||
<input type="text" id="username" name="username" value="{login_hint_value}"
|
||||
required autocomplete="username" autofocus
|
||||
placeholder="your.handle">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
autocomplete="current-password" placeholder="Enter your password">
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="remember_device" name="remember_device" value="true">
|
||||
<label for="remember_device">Remember this device</label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="help-text">
|
||||
By signing in, you agree to share your account information with this application.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
styles = base_styles(),
|
||||
client_display = html_escape(client_display),
|
||||
scope_display = html_escape(scope_display),
|
||||
scope_display = html_escape(&scope_display),
|
||||
request_uri = html_escape(request_uri),
|
||||
error_html = error_html,
|
||||
login_hint_value = html_escape(login_hint_value),
|
||||
@@ -431,26 +404,21 @@ pub fn account_selector_page(
|
||||
let accounts_html: String = accounts
|
||||
.iter()
|
||||
.map(|account| {
|
||||
let initials = get_initials(&account.handle);
|
||||
let email_display = account.email.as_deref().unwrap_or("");
|
||||
format!(
|
||||
r#"<form method="POST" action="/oauth/authorize/select" style="margin:0">
|
||||
<input type="hidden" name="request_uri" value="{request_uri}">
|
||||
<input type="hidden" name="did" value="{did}">
|
||||
<button type="submit" class="account-item">
|
||||
<div class="avatar">{initials}</div>
|
||||
<div class="account-info">
|
||||
<span class="handle">@{handle}</span>
|
||||
<span class="email">{email}</span>
|
||||
<span class="did">{did}</span>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</button>
|
||||
</form>"#,
|
||||
request_uri = html_escape(request_uri),
|
||||
did = html_escape(&account.did),
|
||||
initials = html_escape(&initials),
|
||||
handle = html_escape(&account.handle),
|
||||
email = html_escape(email_display),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -466,17 +434,15 @@ pub fn account_selector_page(
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>Choose an account</h1>
|
||||
<p class="subtitle">to continue to <strong>{client_display}</strong></p>
|
||||
<div class="accounts">
|
||||
{accounts_html}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link">
|
||||
Sign in with another account
|
||||
</a>
|
||||
<h1>Sign In</h1>
|
||||
<p class="subtitle">Choose an account to continue to <strong>{client_display}</strong></p>
|
||||
<div class="accounts">
|
||||
{accounts_html}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link">
|
||||
Sign in to another account
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
@@ -493,7 +459,7 @@ pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&
|
||||
.unwrap_or_default();
|
||||
let (title, subtitle) = match channel {
|
||||
"email" => (
|
||||
"Check your email",
|
||||
"Check Your Email",
|
||||
"We sent a verification code to your email",
|
||||
),
|
||||
"Discord" => (
|
||||
@@ -505,7 +471,7 @@ pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&
|
||||
"We sent a verification code to your Telegram",
|
||||
),
|
||||
"Signal" => ("Check Signal", "We sent a verification code to your Signal"),
|
||||
_ => ("Check your messages", "We sent you a verification code"),
|
||||
_ => ("Check Your Messages", "We sent you a verification code"),
|
||||
};
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
@@ -519,26 +485,24 @@ pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>{title}</h1>
|
||||
<p class="subtitle">{subtitle}</p>
|
||||
{error_html}
|
||||
<form method="POST" action="/oauth/authorize/2fa">
|
||||
<input type="hidden" name="request_uri" value="{request_uri}">
|
||||
<div class="form-group">
|
||||
<label for="code">Verification code</label>
|
||||
<input type="text" id="code" name="code" class="code-input"
|
||||
placeholder="000000"
|
||||
pattern="[0-9]{{6}}" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code"
|
||||
autofocus required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">Verify</button>
|
||||
</form>
|
||||
<p class="help-text">
|
||||
Code expires in 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<h1>{title}</h1>
|
||||
<p class="subtitle">{subtitle}</p>
|
||||
{error_html}
|
||||
<form method="POST" action="/oauth/authorize/2fa">
|
||||
<input type="hidden" name="request_uri" value="{request_uri}">
|
||||
<div class="form-group">
|
||||
<label for="code">Verification Code</label>
|
||||
<input type="text" id="code" name="code" class="code-input"
|
||||
placeholder="000000"
|
||||
pattern="[0-9]{{6}}" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code"
|
||||
autofocus required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">Verify</button>
|
||||
</form>
|
||||
<p class="help-text">
|
||||
Code expires in 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
@@ -564,15 +528,12 @@ pub fn error_page(error: &str, error_description: Option<&str>) -> String {
|
||||
<style>{styles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card text-center">
|
||||
<div class="icon">⚠️</div>
|
||||
<h1>Authorization Failed</h1>
|
||||
<div class="error-code">{error}</div>
|
||||
<p class="subtitle" style="margin-bottom:0">{description}</p>
|
||||
<div style="margin-top:1.5rem">
|
||||
<button onclick="window.close()" class="btn btn-secondary">Close this window</button>
|
||||
</div>
|
||||
<div class="container text-center">
|
||||
<h1>Authorization Failed</h1>
|
||||
<div class="error-code">{error}</div>
|
||||
<p class="subtitle" style="margin-bottom:0">{description}</p>
|
||||
<div style="margin-top:1.5rem">
|
||||
<button onclick="window.close()" class="btn btn-secondary" style="width:100%">Close this window</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -596,13 +557,11 @@ pub fn success_page(client_name: Option<&str>) -> String {
|
||||
<style>{styles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card text-center">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1 style="color:var(--success)">Authorization Successful</h1>
|
||||
<p class="subtitle">{client_display} has been granted access to your account.</p>
|
||||
<p class="help-text">You can close this window and return to the application.</p>
|
||||
</div>
|
||||
<div class="container text-center">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1 style="color:var(--success-text)">Authorization Successful</h1>
|
||||
<p class="subtitle">{client_display} has been granted access to your account.</p>
|
||||
<p class="help-text">You can close this window and return to the application.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
@@ -619,19 +578,6 @@ fn html_escape(s: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn get_initials(handle: &str) -> String {
|
||||
let clean = handle.trim_start_matches('@');
|
||||
if clean.is_empty() {
|
||||
return "?".to_string();
|
||||
}
|
||||
clean
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn mask_email(email: &str) -> String {
|
||||
if let Some(at_pos) = email.find('@') {
|
||||
let local = &email[..at_pos];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::appview::AppViewRegistry;
|
||||
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
|
||||
use crate::circuit_breaker::CircuitBreakers;
|
||||
use crate::config::AuthConfig;
|
||||
@@ -19,6 +20,7 @@ pub struct AppState {
|
||||
pub circuit_breakers: Arc<CircuitBreakers>,
|
||||
pub cache: Arc<dyn Cache>,
|
||||
pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
|
||||
pub appview_registry: Arc<AppViewRegistry>,
|
||||
}
|
||||
|
||||
pub enum RateLimitKind {
|
||||
@@ -85,6 +87,7 @@ impl AppState {
|
||||
let rate_limiters = Arc::new(RateLimiters::new());
|
||||
let circuit_breakers = Arc::new(CircuitBreakers::new());
|
||||
let (cache, distributed_rate_limiter) = create_cache().await;
|
||||
let appview_registry = Arc::new(AppViewRegistry::new());
|
||||
|
||||
Self {
|
||||
db,
|
||||
@@ -95,6 +98,7 @@ impl AppState {
|
||||
circuit_breakers,
|
||||
cache,
|
||||
distributed_rate_limiter,
|
||||
appview_registry,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
tests/admin_search.rs
Normal file
163
tests/admin_search.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
use common::*;
|
||||
use helpers::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts_as_admin() {
|
||||
let client = client();
|
||||
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
|
||||
let (user_did, _) = setup_new_user("search-target").await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&admin_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let accounts = body["accounts"].as_array().expect("accounts should be array");
|
||||
assert!(!accounts.is_empty(), "Should return some accounts");
|
||||
let found = accounts.iter().any(|a| a["did"].as_str() == Some(&user_did));
|
||||
assert!(found, "Should find the created user in results");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts_with_handle_filter() {
|
||||
let client = client();
|
||||
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let unique_handle = format!("unique-handle-{}.test", ts);
|
||||
let create_payload = serde_json::json!({
|
||||
"handle": unique_handle,
|
||||
"email": format!("unique-{}@searchtest.com", ts),
|
||||
"password": "test-password-123"
|
||||
});
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&create_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts?handle={}",
|
||||
base_url().await,
|
||||
unique_handle
|
||||
))
|
||||
.bearer_auth(&admin_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let accounts = body["accounts"].as_array().unwrap();
|
||||
assert_eq!(accounts.len(), 1, "Should find exactly one account with this handle");
|
||||
assert_eq!(accounts[0]["handle"].as_str(), Some(unique_handle.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts_pagination() {
|
||||
let client = client();
|
||||
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
|
||||
for i in 0..3 {
|
||||
let _ = setup_new_user(&format!("search-page-{}", i)).await;
|
||||
}
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts?limit=2",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&admin_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let accounts = body["accounts"].as_array().unwrap();
|
||||
assert_eq!(accounts.len(), 2, "Should return exactly 2 accounts");
|
||||
let cursor = body["cursor"].as_str();
|
||||
assert!(cursor.is_some(), "Should have cursor for more results");
|
||||
let res2 = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts?limit=2&cursor={}",
|
||||
base_url().await,
|
||||
cursor.unwrap()
|
||||
))
|
||||
.bearer_auth(&admin_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res2.status(), StatusCode::OK);
|
||||
let body2: Value = res2.json().await.unwrap();
|
||||
let accounts2 = body2["accounts"].as_array().unwrap();
|
||||
assert!(!accounts2.is_empty(), "Should return more accounts after cursor");
|
||||
let first_page_dids: Vec<&str> = accounts.iter().map(|a| a["did"].as_str().unwrap()).collect();
|
||||
let second_page_dids: Vec<&str> = accounts2.iter().map(|a| a["did"].as_str().unwrap()).collect();
|
||||
for did in &second_page_dids {
|
||||
assert!(!first_page_dids.contains(did), "Second page should not repeat first page DIDs");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts_requires_admin() {
|
||||
let client = client();
|
||||
let (_, user_jwt) = setup_new_user("search-nonadmin").await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&user_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts_requires_auth() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts",
|
||||
base_url().await
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts_returns_expected_fields() {
|
||||
let client = client();
|
||||
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
|
||||
let _ = setup_new_user("search-fields").await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts?limit=1",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&admin_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let accounts = body["accounts"].as_array().unwrap();
|
||||
assert!(!accounts.is_empty());
|
||||
let account = &accounts[0];
|
||||
assert!(account["did"].as_str().is_some(), "Should have did");
|
||||
assert!(account["handle"].as_str().is_some(), "Should have handle");
|
||||
assert!(account["indexedAt"].as_str().is_some(), "Should have indexedAt");
|
||||
}
|
||||
197
tests/change_password.rs
Normal file
197
tests/change_password.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
use common::*;
|
||||
use helpers::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_password_success() {
|
||||
let client = client();
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("change-pw-{}.test", ts);
|
||||
let email = format!("change-pw-{}@test.com", ts);
|
||||
let old_password = "old-password-123";
|
||||
let new_password = "new-password-456";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": old_password
|
||||
});
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&create_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let create_body: Value = create_res.json().await.unwrap();
|
||||
let did = create_body["did"].as_str().unwrap();
|
||||
let jwt = verify_new_account(&client, did).await;
|
||||
let change_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.changePassword",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": old_password,
|
||||
"newPassword": new_password
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to change password");
|
||||
assert_eq!(change_res.status(), StatusCode::OK);
|
||||
let login_old = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createSession",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"identifier": handle,
|
||||
"password": old_password
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to try old password");
|
||||
assert_eq!(login_old.status(), StatusCode::UNAUTHORIZED, "Old password should not work");
|
||||
let login_new = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createSession",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"identifier": handle,
|
||||
"password": new_password
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to try new password");
|
||||
assert_eq!(login_new.status(), StatusCode::OK, "New password should work");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_password_wrong_current() {
|
||||
let client = client();
|
||||
let (_, jwt) = setup_new_user("change-pw-wrong").await;
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.changePassword",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": "wrong-password",
|
||||
"newPassword": "new-password-123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["error"].as_str(), Some("InvalidPassword"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_password_too_short() {
|
||||
let client = client();
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("change-pw-short-{}.test", ts);
|
||||
let email = format!("change-pw-short-{}@test.com", ts);
|
||||
let password = "correct-password";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": password
|
||||
});
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&create_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let create_body: Value = create_res.json().await.unwrap();
|
||||
let did = create_body["did"].as_str().unwrap();
|
||||
let jwt = verify_new_account(&client, did).await;
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.changePassword",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": password,
|
||||
"newPassword": "short"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert!(body["message"].as_str().unwrap().contains("8 characters"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_password_empty_current() {
|
||||
let client = client();
|
||||
let (_, jwt) = setup_new_user("change-pw-empty").await;
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.changePassword",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": "",
|
||||
"newPassword": "new-password-123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_password_empty_new() {
|
||||
let client = client();
|
||||
let (_, jwt) = setup_new_user("change-pw-emptynew").await;
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.changePassword",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": "e2e-password-123",
|
||||
"newPassword": ""
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_password_requires_auth() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.changePassword",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"currentPassword": "old",
|
||||
"newPassword": "new-password-123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
@@ -137,8 +137,12 @@ async fn setup_with_external_infra() -> String {
|
||||
}
|
||||
let mock_server = MockServer::start().await;
|
||||
setup_mock_appview(&mock_server).await;
|
||||
let mock_uri = mock_server.uri();
|
||||
let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri);
|
||||
let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A"));
|
||||
setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await;
|
||||
unsafe {
|
||||
std::env::set_var("APPVIEW_URL", mock_server.uri());
|
||||
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
|
||||
}
|
||||
MOCK_APPVIEW.set(mock_server).ok();
|
||||
spawn_app(database_url).await
|
||||
@@ -186,8 +190,12 @@ async fn setup_with_testcontainers() -> String {
|
||||
let _ = s3_client.create_bucket().bucket("test-bucket").send().await;
|
||||
let mock_server = MockServer::start().await;
|
||||
setup_mock_appview(&mock_server).await;
|
||||
let mock_uri = mock_server.uri();
|
||||
let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri);
|
||||
let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A"));
|
||||
setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await;
|
||||
unsafe {
|
||||
std::env::set_var("APPVIEW_URL", mock_server.uri());
|
||||
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
|
||||
}
|
||||
MOCK_APPVIEW.set(mock_server).ok();
|
||||
S3_CONTAINER.set(s3_container).ok();
|
||||
@@ -215,6 +223,21 @@ async fn setup_with_testcontainers() -> String {
|
||||
);
|
||||
}
|
||||
|
||||
async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_endpoint: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/did.json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": did,
|
||||
"service": [{
|
||||
"id": "#atproto_appview",
|
||||
"type": "AtprotoAppView",
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
})))
|
||||
.mount(mock_server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_mock_appview(mock_server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/app.bsky.actor.getProfile"))
|
||||
|
||||
75
tests/oauth_client_metadata.rs
Normal file
75
tests/oauth_client_metadata.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_frontend_client_metadata_returns_valid_json() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/oauth/client-metadata.json",
|
||||
base_url().await
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Should return valid JSON");
|
||||
assert!(body["client_id"].as_str().is_some(), "Should have client_id");
|
||||
assert!(body["client_name"].as_str().is_some(), "Should have client_name");
|
||||
assert!(body["redirect_uris"].as_array().is_some(), "Should have redirect_uris");
|
||||
assert!(body["grant_types"].as_array().is_some(), "Should have grant_types");
|
||||
assert!(body["response_types"].as_array().is_some(), "Should have response_types");
|
||||
assert!(body["scope"].as_str().is_some(), "Should have scope");
|
||||
assert!(body["token_endpoint_auth_method"].as_str().is_some(), "Should have token_endpoint_auth_method");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_frontend_client_metadata_correct_values() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/oauth/client-metadata.json",
|
||||
base_url().await
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let client_id = body["client_id"].as_str().unwrap();
|
||||
assert!(client_id.ends_with("/oauth/client-metadata.json"), "client_id should end with /oauth/client-metadata.json");
|
||||
let grant_types = body["grant_types"].as_array().unwrap();
|
||||
let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(grant_strs.contains(&"authorization_code"), "Should support authorization_code grant");
|
||||
assert!(grant_strs.contains(&"refresh_token"), "Should support refresh_token grant");
|
||||
let response_types = body["response_types"].as_array().unwrap();
|
||||
let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(response_strs.contains(&"code"), "Should support code response type");
|
||||
assert_eq!(body["token_endpoint_auth_method"].as_str(), Some("none"), "Should be public client (none auth)");
|
||||
assert_eq!(body["application_type"].as_str(), Some("web"), "Should be web application");
|
||||
assert_eq!(body["dpop_bound_access_tokens"].as_bool(), Some(false), "Should not require DPoP");
|
||||
let scope = body["scope"].as_str().unwrap();
|
||||
assert!(scope.contains("atproto"), "Scope should include atproto");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/oauth/client-metadata.json",
|
||||
base_url().await
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let client_uri = body["client_uri"].as_str().unwrap();
|
||||
let redirect_uris = body["redirect_uris"].as_array().unwrap();
|
||||
assert!(!redirect_uris.is_empty(), "Should have at least one redirect URI");
|
||||
let redirect_uri = redirect_uris[0].as_str().unwrap();
|
||||
assert!(redirect_uri.starts_with(client_uri), "Redirect URI should be on same origin as client_uri");
|
||||
}
|
||||
234
tests/session_management.rs
Normal file
234
tests/session_management.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
use common::*;
|
||||
use helpers::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_sessions_returns_current_session() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("list-sessions").await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.bspds.account.listSessions",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let sessions = body["sessions"].as_array().expect("sessions should be array");
|
||||
assert!(!sessions.is_empty(), "Should have at least one session");
|
||||
let current = sessions.iter().find(|s| s["isCurrent"].as_bool() == Some(true));
|
||||
assert!(current.is_some(), "Should have a current session marked");
|
||||
let session = current.unwrap();
|
||||
assert!(session["id"].as_str().is_some(), "Session should have id");
|
||||
assert!(session["createdAt"].as_str().is_some(), "Session should have createdAt");
|
||||
assert!(session["expiresAt"].as_str().is_some(), "Session should have expiresAt");
|
||||
let _ = did;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_sessions_multiple_sessions() {
|
||||
let client = client();
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("multi-list-{}.test", ts);
|
||||
let email = format!("multi-list-{}@test.com", ts);
|
||||
let password = "test-password-123";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": password
|
||||
});
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&create_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let create_body: Value = create_res.json().await.unwrap();
|
||||
let did = create_body["did"].as_str().unwrap();
|
||||
let jwt1 = verify_new_account(&client, did).await;
|
||||
let login_payload = json!({
|
||||
"identifier": handle,
|
||||
"password": password
|
||||
});
|
||||
let login_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createSession",
|
||||
base_url().await
|
||||
))
|
||||
.json(&login_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to login");
|
||||
assert_eq!(login_res.status(), StatusCode::OK);
|
||||
let login_body: Value = login_res.json().await.unwrap();
|
||||
let jwt2 = login_body["accessJwt"].as_str().unwrap();
|
||||
let list_res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.bspds.account.listSessions",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(jwt2)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list sessions");
|
||||
assert_eq!(list_res.status(), StatusCode::OK);
|
||||
let list_body: Value = list_res.json().await.unwrap();
|
||||
let sessions = list_body["sessions"].as_array().unwrap();
|
||||
assert!(sessions.len() >= 2, "Should have at least 2 sessions, got {}", sessions.len());
|
||||
let _ = jwt1;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_sessions_requires_auth() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.bspds.account.listSessions",
|
||||
base_url().await
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_session_success() {
|
||||
let client = client();
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("revoke-sess-{}.test", ts);
|
||||
let email = format!("revoke-sess-{}@test.com", ts);
|
||||
let password = "test-password-123";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": password
|
||||
});
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&create_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let create_body: Value = create_res.json().await.unwrap();
|
||||
let did = create_body["did"].as_str().unwrap();
|
||||
let jwt1 = verify_new_account(&client, did).await;
|
||||
let login_payload = json!({
|
||||
"identifier": handle,
|
||||
"password": password
|
||||
});
|
||||
let login_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createSession",
|
||||
base_url().await
|
||||
))
|
||||
.json(&login_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to login");
|
||||
assert_eq!(login_res.status(), StatusCode::OK);
|
||||
let login_body: Value = login_res.json().await.unwrap();
|
||||
let jwt2 = login_body["accessJwt"].as_str().unwrap();
|
||||
let list_res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.bspds.account.listSessions",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(jwt2)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list sessions");
|
||||
let list_body: Value = list_res.json().await.unwrap();
|
||||
let sessions = list_body["sessions"].as_array().unwrap();
|
||||
let other_session = sessions.iter().find(|s| s["isCurrent"].as_bool() != Some(true));
|
||||
assert!(other_session.is_some(), "Should have another session to revoke");
|
||||
let session_id = other_session.unwrap()["id"].as_str().unwrap();
|
||||
let revoke_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.revokeSession",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(jwt2)
|
||||
.json(&json!({"sessionId": session_id}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to revoke session");
|
||||
assert_eq!(revoke_res.status(), StatusCode::OK);
|
||||
let list_after_res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.bspds.account.listSessions",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(jwt2)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list sessions after revoke");
|
||||
let list_after_body: Value = list_after_res.json().await.unwrap();
|
||||
let sessions_after = list_after_body["sessions"].as_array().unwrap();
|
||||
let revoked_still_exists = sessions_after.iter().any(|s| s["id"].as_str() == Some(session_id));
|
||||
assert!(!revoked_still_exists, "Revoked session should not appear in list");
|
||||
let _ = jwt1;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_session_invalid_id() {
|
||||
let client = client();
|
||||
let (_, jwt) = setup_new_user("revoke-invalid").await;
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.revokeSession",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({"sessionId": "not-a-number"}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_session_not_found() {
|
||||
let client = client();
|
||||
let (_, jwt) = setup_new_user("revoke-notfound").await;
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.revokeSession",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({"sessionId": "999999999"}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_session_requires_auth() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.bspds.account.revokeSession",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({"sessionId": "1"}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Reference in New Issue
Block a user