Better oauth, appview groundwork

This commit is contained in:
lewis
2025-12-17 23:29:48 +02:00
parent dea6c09aa0
commit 2cf87e2cfb
45 changed files with 4586 additions and 906 deletions

View File

@@ -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
# =============================================================================

View File

@@ -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
View File

@@ -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.

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
View 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())
}

View 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">&larr; 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}>&times;</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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View 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>

View 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">&larr; 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>

View File

@@ -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;

View File

@@ -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(),
)

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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()
}
}
}

View 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()
}
}
}

View File

@@ -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,

View File

@@ -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) = &params.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(""),

View File

@@ -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(""),

View File

@@ -37,14 +37,14 @@ pub async fn get_feed(
if let Err(e) = validate_at_uri(&params.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) = &params.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,
) {

View File

@@ -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(""),

View File

@@ -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,
&params,
&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,
&params,
&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) = &params.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,

View File

@@ -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,

View File

@@ -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(&params);
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,

View File

@@ -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> {

View File

@@ -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));

View File

@@ -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!(

View File

@@ -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);

View File

@@ -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;

View File

@@ -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()
}

View File

@@ -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
View 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());
}
}

View File

@@ -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),

View File

@@ -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),

View File

@@ -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,
})
}

View File

@@ -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('\'', "&#39;")
}
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];

View File

@@ -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
View 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
View 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);
}

View File

@@ -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"))

View 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
View 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);
}