Creating & posting records works. Also messed up newlines but will fix later.

This commit is contained in:
lewis
2025-12-14 23:55:04 +02:00
parent 86db6617af
commit c6f9062979
227 changed files with 2122 additions and 7842 deletions
-2
View File
@@ -1,11 +1,9 @@
[store]
dir = "target/nextest"
[profile.default]
retries = 0
fail-fast = true
test-threads = "num-cpus"
[profile.ci]
retries = 2
fail-fast = false
-31
View File
@@ -3,20 +3,16 @@
# =============================================================================
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
# The public-facing hostname of the PDS (used in DID documents, JWTs, etc.)
PDS_HOSTNAME=localhost:3000
# =============================================================================
# Database
# =============================================================================
DATABASE_URL=postgres://postgres:postgres@localhost:5432/pds
# Connection pool settings (defaults are good for most deployments)
# DATABASE_MAX_CONNECTIONS=100
# DATABASE_MIN_CONNECTIONS=10
# DATABASE_ACQUIRE_TIMEOUT_SECS=30
# =============================================================================
# Blob Storage (S3-compatible)
# =============================================================================
@@ -25,120 +21,93 @@ AWS_REGION=us-east-1
S3_BUCKET=pds-blobs
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
# =============================================================================
# Valkey (for caching and distributed rate limiting)
# =============================================================================
# If not set, falls back to in-memory caching (single-node only)
# VALKEY_URL=redis://localhost:6379
# =============================================================================
# Security Secrets
# =============================================================================
# These MUST be set in production (minimum 32 characters each)
# In development, set BSPDS_ALLOW_INSECURE_SECRETS=1 to use defaults
# Server-wide secret for OAuth token signing (HS256)
# JWT_SECRET=your-secure-random-string-at-least-32-chars
# Secret for DPoP proof validation
# DPOP_SECRET=your-secure-random-string-at-least-32-chars
# Key for encrypting user signing keys at rest (AES-256-GCM)
# MASTER_KEY=your-secure-random-string-at-least-32-chars
# Set this ONLY in development to allow default/weak secrets
# BSPDS_ALLOW_INSECURE_SECRETS=1
# =============================================================================
# PLC Directory
# =============================================================================
# PLC_DIRECTORY_URL=https://plc.directory
# PLC_TIMEOUT_SECS=10
# PLC_CONNECT_TIMEOUT_SECS=5
# Optional: rotation key for PLC operations (defaults to user's key)
# PLC_ROTATION_KEY=did:key:...
# =============================================================================
# Federation
# =============================================================================
# Appview URL for proxying app.bsky.* requests
# APPVIEW_URL=https://api.bsky.app
# Comma-separated list of relay URLs to notify via requestCrawl
# CRAWLERS=https://bsky.network
# =============================================================================
# Firehose (subscribeRepos WebSocket)
# =============================================================================
# Buffer size for firehose broadcast channel
# FIREHOSE_BUFFER_SIZE=10000
# Disconnect slow consumers after this many events of lag
# FIREHOSE_MAX_LAG=5000
# =============================================================================
# Notification Service
# =============================================================================
# Queue processing settings
# NOTIFICATION_BATCH_SIZE=100
# NOTIFICATION_POLL_INTERVAL_MS=1000
# Email notifications (via sendmail/msmtp)
# MAIL_FROM_ADDRESS=noreply@example.com
# MAIL_FROM_NAME=My PDS
# SENDMAIL_PATH=/usr/sbin/sendmail
# Discord notifications (via webhook)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Telegram notifications (via bot)
# TELEGRAM_BOT_TOKEN=your-bot-token
# Signal notifications (via signal-cli)
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# SIGNAL_SENDER_NUMBER=+1234567890
# =============================================================================
# Repository Import
# =============================================================================
# Set to "true" to accept repository imports
# ACCEPTING_REPO_IMPORTS=false
# Maximum import size in bytes (default: 50MB)
# MAX_IMPORT_SIZE=52428800
# Maximum blocks per import (default: 100000)
# MAX_IMPORT_BLOCKS=100000
# Skip verification during import (testing only)
# SKIP_IMPORT_VERIFICATION=false
# =============================================================================
# Account Registration
# =============================================================================
# Require invite codes for registration
# INVITE_CODE_REQUIRED=false
# Comma-separated list of available user domains
# AVAILABLE_USER_DOMAINS=example.com
# =============================================================================
# Rate Limiting
# =============================================================================
# Disable all rate limiting (testing only, NEVER in production)
# DISABLE_RATE_LIMITING=1
# =============================================================================
# Miscellaneous
# =============================================================================
# Allow HTTP for proxy requests (development only)
# ALLOW_HTTP_PROXY=1
# Custom frontend directory (defaults to ./frontend/dist)
# FRONTEND_DIR=/path/to/frontend/dist
CARGO_MOMMYS_LITTLE=mister
CARGO_MOMMYS_PRONOUNS=his
CARGO_MOMMYS_ROLES=daddy
+1 -3
View File
@@ -1,10 +1,8 @@
/target
.env
reference-pds-hailey/
reference-pds-bsky/
reference-relay-indigo/
# Frontend build artifacts
frontend/node_modules/
frontend/dist/
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC\n ",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2\n ",
"describe": {
"columns": [
{
@@ -35,18 +35,38 @@
},
{
"ordinal": 6,
"name": "prev_data_cid",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "ops",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "blobs",
"type_info": "TextArray"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "status",
"type_info": "Text"
}
],
"parameters": {
@@ -64,8 +84,12 @@
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "a63aed47193f06cd11d87157799c17a591e0a0be4487f718250eaf7afd4b4b07"
"hash": "1bff90667ece9e1e44e20e3477df1473bafa06610e54f10d9b3cb2cc06469854"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n ",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n ",
"describe": {
"columns": [
{
@@ -35,18 +35,38 @@
},
{
"ordinal": 6,
"name": "prev_data_cid",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "ops",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "blobs",
"type_info": "TextArray"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "status",
"type_info": "Text"
}
],
"parameters": {
@@ -63,8 +83,12 @@
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "8a7a8f0c4c0872c21c46d484219624215bdb14617b9f9a44974e394a28147f70"
"hash": "239555df14c147a09096beb28f2ff0b093523c27e9527cd1f623c9a87b05b532"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid)\n VALUES ($1, 'sync', $2)\n RETURNING seq\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "3792c455a955b8cf2c70c8aa76635083354c87330101ea0ea69d30f2a5b4b960"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq = $1\n ",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status\n FROM repo_seq\n WHERE seq = $1\n ",
"describe": {
"columns": [
{
@@ -35,18 +35,38 @@
},
{
"ordinal": 6,
"name": "prev_data_cid",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "ops",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "blobs",
"type_info": "TextArray"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "status",
"type_info": "Text"
}
],
"parameters": {
@@ -63,8 +83,12 @@
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "7b6ca5155c645f2011364594833effe41494d0c855b0c9314ab56ea8b0fb4e6d"
"hash": "44b78996f9799398f384d9aebb36d01c27738d4677b7cae7ea6697f3f5135388"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids)\n VALUES ($1, 'commit', $2, $3, $4, $5, $6, $7)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
false
]
},
"hash": "7ce9c5fb943b4217da17c041998263c0af7b77c8feecc654dde7a71fbab4e1ad"
"hash": "52196f20028eb03e8116abce050866a419c2f85244d2bb7188604c8f9c87b1b3"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, handle)\n VALUES ($1, 'identity', $2)\n RETURNING seq\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "5425a06bb6b83767fd334f70702b9c0ca99d63bf1a70fa1641b35cc0bfde1dc7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2\n ",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC\n ",
"describe": {
"columns": [
{
@@ -35,18 +35,38 @@
},
{
"ordinal": 6,
"name": "prev_data_cid",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "ops",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "blobs",
"type_info": "TextArray"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "status",
"type_info": "Text"
}
],
"parameters": {
@@ -64,8 +84,12 @@
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "b1c54d3f3e2d3031c0d926ccb0d39a0250320d41d08df65d6d9dcc640451527d"
"hash": "777386dcbf2aa2785a6c16abdccbd8f751893039fb6bc2363d9760ca0d8a8a56"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)\n VALUES ($1, 'commit', $2, $3, $4, $5, $6)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -14,14 +14,16 @@
"Text",
"Text",
"Text",
"Text",
"Jsonb",
"TextArray",
"TextArray"
"TextArray",
"Text"
]
},
"nullable": [
false
]
},
"hash": "f68a05d2c78cc060b43c81b177a24f89c71e7e00dfa5af08ff4584bbe43b4155"
"hash": "d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', $2, $3)\n RETURNING seq\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Bool",
"Text"
]
},
"nullable": [
false
]
},
"hash": "f2777a739a950b94f978e3196809ca026cc38e7839b6c567745fcd5186462cc3"
}
-4
View File
@@ -2,7 +2,6 @@
name = "bspds"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
@@ -54,14 +53,11 @@ redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
[features]
external-infra = []
[dev-dependencies]
ctor = "0.6.3"
testcontainers = "0.26.0"
testcontainers-modules = { version = "0.14.0", features = ["postgres"] }
wiremock = "0.6.5"
# urlencoding is also in dependencies, but tests use it directly
-11
View File
@@ -3,36 +3,25 @@ FROM denoland/deno:alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/ ./
RUN deno task build
# Stage 2: Build Rust backend
FROM rust:1.92-alpine AS builder
RUN apk add ca-certificates openssl openssl-dev pkgconfig
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY src ./src
COPY tests ./tests
COPY migrations ./migrations
COPY .sqlx ./.sqlx
RUN touch src/main.rs && cargo build --release
# Stage 3: Final image
FROM alpine:3.23
COPY --from=builder /app/target/release/bspds /usr/local/bin/bspds
COPY --from=builder /app/migrations /app/migrations
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
WORKDIR /app
ENV SERVER_HOST=0.0.0.0
ENV SERVER_PORT=3000
ENV FRONTEND_DIR=/app/frontend/dist
EXPOSE 3000
CMD ["bspds"]
-17
View File
@@ -1,49 +1,34 @@
# BSPDS
A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, using postgres and s3-compatible blob storage.
## Features
- Full AT Protocol support (`com.atproto.*` endpoints)
- OAuth 2.1 provider (PKCE, DPoP, PAR)
- WebSocket firehose (`subscribeRepos`)
- Multi-channel notifications (email, discord, telegram, signal)
- Built-in web UI for account management
- Per-IP rate limiting
## Quick Start
```bash
cp .env.example .env
podman compose up -d
just run
```
## Configuration
See `.env.example` for all configuration options.
## Development
Run `just` to see available commands.
```bash
just test # run tests
just lint # clippy + fmt
```
## Production Deployment
### Quick Deploy (Docker/Podman Compose)
```bash
cp .env.prod.example .env.prod
# Edit .env.prod with your values (generate secrets with: openssl rand -base64 48)
podman-compose -f docker-compose.prod.yml up -d
```
### Full Installation Guides
| Guide | Best For |
|-------|----------|
| **Native Installation** | Maximum performance, full control |
@@ -54,7 +39,5 @@ podman-compose -f docker-compose.prod.yml up -d
| [Containers](docs/install-containers.md) | Podman with quadlets (Debian) or OpenRC (Alpine) |
| **Orchestrated** | High availability, auto-scaling |
| [Kubernetes](docs/install-kubernetes.md) | Multi-node k8s cluster deployment |
## License
TBD
-34
View File
@@ -1,7 +1,5 @@
# PDS Implementation TODOs
Lewis' corrected big boy todofile
## Server Infrastructure & Proxying
- [x] Health Check
- [x] Implement `GET /health` endpoint (returns "OK").
@@ -12,7 +10,6 @@ Lewis' corrected big boy todofile
- [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`.
@@ -43,7 +40,6 @@ Lewis' corrected big boy todofile
- [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`.
@@ -62,7 +58,6 @@ Lewis' corrected big boy todofile
- [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`.
@@ -84,7 +79,6 @@ Lewis' corrected big boy todofile
- [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).
@@ -92,7 +86,6 @@ Lewis' corrected big boy todofile
- [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`.
@@ -106,16 +99,12 @@ Lewis' corrected big boy todofile
- [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
@@ -141,11 +130,8 @@ Full OAuth 2.1 provider for ATProto native app authentication.
- [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
@@ -159,21 +145,17 @@ Security measures implemented:
- 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).
@@ -181,10 +163,8 @@ These are implemented at PDS level to enable local-first reads (read-after-write
- [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).
@@ -247,22 +227,18 @@ These are implemented at PDS level to enable local-first reads (read-after-write
- [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:
@@ -272,47 +248,37 @@ Anyway... endpoints for PDS settings not covered by standard ATProto:
- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
- [ ] `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)
- [ ] 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.
-10
View File
@@ -1,49 +1,39 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 4096;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_stapling on;
ssl_stapling_verify on;
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
-3
View File
@@ -1,7 +1,6 @@
[Unit]
Description=BSPDS AT Protocol PDS
After=bspds-db.service bspds-minio.service bspds-valkey.service
[Container]
ContainerName=bspds-app
Image=localhost/bspds:latest
@@ -19,10 +18,8 @@ HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=15s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
-3
View File
@@ -1,6 +1,5 @@
[Unit]
Description=BSPDS postgres database
[Container]
ContainerName=bspds-db
Image=docker.io/library/postgres:18-alpine
@@ -14,10 +13,8 @@ HealthInterval=10s
HealthTimeout=5s
HealthRetries=5
HealthStartPeriod=10s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
-3
View File
@@ -1,6 +1,5 @@
[Unit]
Description=BSPDS minio object storage
[Container]
ContainerName=bspds-minio
Image=docker.io/minio/minio:RELEASE.2025-10-15T17-29-55Z
@@ -14,10 +13,8 @@ HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=10s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
-3
View File
@@ -1,7 +1,6 @@
[Unit]
Description=BSPDS nginx reverse proxy
After=bspds-app.service
[Container]
ContainerName=bspds-nginx
Image=docker.io/library/nginx:1.28-alpine
@@ -9,10 +8,8 @@ Pod=bspds.pod
Volume=/srv/bspds/config/nginx.conf:/etc/nginx/nginx.conf:ro,Z
Volume=/srv/bspds/certs:/etc/nginx/certs:ro,Z
Volume=/srv/bspds/acme:/var/www/acme:ro,Z
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
-3
View File
@@ -1,6 +1,5 @@
[Unit]
Description=BSPDS valkey cache
[Container]
ContainerName=bspds-valkey
Image=docker.io/valkey/valkey:9-alpine
@@ -12,10 +11,8 @@ HealthInterval=10s
HealthTimeout=5s
HealthRetries=3
HealthStartPeriod=5s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
-1
View File
@@ -2,6 +2,5 @@
PodName=bspds
PublishPort=80:80
PublishPort=443:443
[Install]
WantedBy=default.target
-8
View File
@@ -43,7 +43,6 @@ services:
memory: 1G
reservations:
memory: 256M
db:
image: postgres:18-alpine
restart: unless-stopped
@@ -65,7 +64,6 @@ services:
memory: 512M
reservations:
memory: 128M
minio:
image: minio/minio:RELEASE.2025-10-15T17-29-55Z
restart: unless-stopped
@@ -87,7 +85,6 @@ services:
memory: 512M
reservations:
memory: 128M
minio-init:
image: minio/mc:RELEASE.2025-07-16T15-35-03Z
depends_on:
@@ -103,7 +100,6 @@ services:
environment:
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}"
valkey:
image: valkey/valkey:9-alpine
restart: unless-stopped
@@ -122,7 +118,6 @@ services:
memory: 300M
reservations:
memory: 64M
nginx:
image: nginx:1.28-alpine
restart: unless-stopped
@@ -140,14 +135,12 @@ services:
interval: 30s
timeout: 10s
retries: 3
certbot:
image: certbot/certbot:v5.2.2
volumes:
- ./certs:/etc/letsencrypt
- acme_challenge:/var/www/acme
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/acme; sleep 12h & wait $${!}; done'"
prometheus:
image: prom/prometheus:v3.8.0
restart: unless-stopped
@@ -164,7 +157,6 @@ services:
resources:
limits:
memory: 256M
volumes:
postgres_data:
minio_data:
-5
View File
@@ -16,7 +16,6 @@ services:
- db
- objsto
- cache
db:
image: postgres:18-alpine
environment:
@@ -27,7 +26,6 @@ services:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql
objsto:
image: minio/minio
ports:
@@ -39,14 +37,12 @@ services:
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
cache:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
volumes:
- valkey_data:/data
prometheus:
image: prom/prometheus:v3.8.0
ports:
@@ -59,7 +55,6 @@ services:
- '--storage.tsdb.path=/prometheus'
depends_on:
- app
volumes:
postgres_data:
minio_data:
-78
View File
@@ -1,85 +1,60 @@
# BSPDS Production Installation on Alpine Linux
> **Warning**: These instructions are untested and theoretical, written from the top of Lewis' head. They may contain errors or omissions. This warning will be removed once the guide has been verified.
This guide covers installing BSPDS on Alpine Linux 3.23 (current stable as of December 2025).
## Choose Your Installation Method
| Method | Best For |
|--------|----------|
| **Native (this guide)** | Maximum performance, minimal footprint, full control |
| **[Containerized](install-containers.md)** | Easier updates, isolation, reproducible deployments |
| **[Kubernetes](install-kubernetes.md)** | Multi-node, high availability, auto-scaling |
This guide covers native installation. For containerized deployment with podman and systemd quadlets, see the [container guide](install-containers.md).
---
## Prerequisites
- A VPS with at least 2GB RAM and 20GB disk
- A domain name pointing to your server's IP
- Root access
## 1. System Setup
```sh
apk update && apk upgrade
apk add curl git build-base openssl-dev pkgconf
```
## 2. Install Rust
```sh
apk add rustup
rustup-init -y
source ~/.cargo/env
rustup default stable
```
This installs the latest stable Rust (1.92+ as of December 2025). Alpine 3.23 also ships Rust 1.91 via `apk add rust cargo` if you prefer system packages.
## 3. Install postgres
Alpine 3.23 includes PostgreSQL 18:
```sh
apk add postgresql postgresql-contrib
rc-update add postgresql
/etc/init.d/postgresql setup
rc-service postgresql start
psql -U postgres -c "CREATE USER bspds WITH PASSWORD 'your-secure-password';"
psql -U postgres -c "CREATE DATABASE pds OWNER bspds;"
psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE pds TO bspds;"
```
## 4. Install minio
```sh
curl -O https://dl.min.io/server/minio/release/linux-amd64/minio
chmod +x minio
mv minio /usr/local/bin/
mkdir -p /var/lib/minio/data
adduser -D -H -s /sbin/nologin minio-user
chown -R minio-user:minio-user /var/lib/minio
cat > /etc/conf.d/minio << 'EOF'
MINIO_ROOT_USER="minioadmin"
MINIO_ROOT_PASSWORD="your-minio-password"
MINIO_VOLUMES="/var/lib/minio/data"
MINIO_OPTS="--console-address :9001"
EOF
cat > /etc/init.d/minio << 'EOF'
#!/sbin/openrc-run
name="minio"
description="MinIO Object Storage"
command="/usr/local/bin/minio"
command_args="server ${MINIO_VOLUMES} ${MINIO_OPTS}"
command_user="minio-user"
@@ -87,116 +62,85 @@ command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/minio.log"
error_log="/var/log/minio.log"
depend() {
need net
}
start_pre() {
. /etc/conf.d/minio
export MINIO_ROOT_USER MINIO_ROOT_PASSWORD
}
EOF
chmod +x /etc/init.d/minio
rc-update add minio
rc-service minio start
```
Create the blob bucket (wait a few seconds for minio to start):
```sh
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
mv mc /usr/local/bin/
mc alias set local http://localhost:9000 minioadmin your-minio-password
mc mb local/pds-blobs
```
## 5. Install valkey
Alpine 3.23 includes Valkey 9:
```sh
apk add valkey
rc-update add valkey
rc-service valkey start
```
## 6. Install deno (for frontend build)
```sh
curl -fsSL https://deno.land/install.sh | sh
export PATH="$HOME/.deno/bin:$PATH"
echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.profile
```
## 7. Clone and Build BSPDS
```sh
mkdir -p /opt && cd /opt
git clone https://tangled.org/lewis.moe/bspds-sandbox bspds
cd bspds
cd frontend
deno task build
cd ..
cargo build --release
```
## 8. Install sqlx-cli and Run Migrations
```sh
cargo install sqlx-cli --no-default-features --features postgres
export DATABASE_URL="postgres://bspds:your-secure-password@localhost:5432/pds"
sqlx migrate run
```
## 9. Configure BSPDS
```sh
mkdir -p /etc/bspds
cp /opt/bspds/.env.example /etc/bspds/bspds.env
chmod 600 /etc/bspds/bspds.env
```
Edit `/etc/bspds/bspds.env` and fill in your values. Generate secrets with:
```sh
openssl rand -base64 48
```
## 10. Create OpenRC Service
```sh
adduser -D -H -s /sbin/nologin bspds
cp /opt/bspds/target/release/bspds /usr/local/bin/
mkdir -p /var/lib/bspds
cp -r /opt/bspds/frontend/dist /var/lib/bspds/frontend
chown -R bspds:bspds /var/lib/bspds
cat > /etc/init.d/bspds << 'EOF'
#!/sbin/openrc-run
name="bspds"
description="BSPDS - AT Protocol PDS"
command="/usr/local/bin/bspds"
command_user="bspds"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/bspds.log"
error_log="/var/log/bspds.log"
depend() {
need net postgresql minio
}
start_pre() {
export FRONTEND_DIR=/var/lib/bspds/frontend
. /etc/bspds/bspds.env
@@ -205,25 +149,19 @@ start_pre() {
export VALKEY_URL JWT_SECRET DPOP_SECRET MASTER_KEY APPVIEW_URL CRAWLERS
}
EOF
chmod +x /etc/init.d/bspds
rc-update add bspds
rc-service bspds start
```
## 11. Install and Configure nginx
Alpine 3.23 includes nginx 1.28:
```sh
apk add nginx certbot certbot-nginx
cat > /etc/nginx/http.d/bspds.conf << 'EOF'
server {
listen 80;
listen [::]:80;
server_name pds.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -237,63 +175,48 @@ server {
}
}
EOF
rc-update add nginx
rc-service nginx start
```
## 12. Obtain SSL Certificate
```sh
certbot --nginx -d pds.example.com
```
Set up auto-renewal:
```sh
echo "0 0 * * * certbot renew --quiet" | crontab -
```
## 13. Configure Firewall
```sh
apk add iptables ip6tables
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -P INPUT DROP
ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -P INPUT DROP
rc-update add iptables
rc-update add ip6tables
/etc/init.d/iptables save
/etc/init.d/ip6tables save
```
## 14. Verify Installation
```sh
rc-service bspds status
curl -s https://pds.example.com/xrpc/_health
curl -s https://pds.example.com/.well-known/atproto-did
```
## Maintenance
View logs:
```sh
tail -f /var/log/bspds.log
```
Update BSPDS:
```sh
cd /opt/bspds
@@ -306,7 +229,6 @@ cp -r frontend/dist /var/lib/bspds/frontend
DATABASE_URL="postgres://bspds:your-secure-password@localhost:5432/pds" sqlx migrate run
rc-service bspds start
```
Backup database:
```sh
pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql
-120
View File
@@ -1,164 +1,113 @@
# BSPDS Containerized Production Deployment
> **Warning**: These instructions are untested and theoretical, written from the top of Lewis' head. They may contain errors or omissions. This warning will be removed once the guide has been verified.
This guide covers deploying BSPDS using containers with podman.
- **Debian 13+**: Uses systemd quadlets (modern, declarative container management)
- **Alpine 3.23+**: Uses OpenRC service script with podman-compose
## Prerequisites
- A VPS with at least 2GB RAM and 20GB disk
- A domain name pointing to your server's IP
- Root or sudo access
## Quick Start (Docker/Podman Compose)
If you just want to get running quickly:
```sh
cp .env.example .env
# Edit .env with your values
# Generate secrets: openssl rand -base64 48
# Build and start
podman-compose -f docker-compose.prod.yml up -d
# Get initial certificate (after DNS is configured)
podman-compose -f docker-compose.prod.yml run --rm certbot certonly \
--webroot -w /var/www/acme -d pds.example.com
# Restart nginx to load certificate
podman-compose -f docker-compose.prod.yml restart nginx
```
For production setups with proper service management, continue to either the Debian or Alpine section below.
---
# Debian 13+ with Systemd Quadlets
Quadlets are the modern way to run podman containers under systemd.
## 1. Install Podman
```bash
apt update
apt install -y podman
```
## 2. Create Directory Structure
```bash
mkdir -p /etc/containers/systemd
mkdir -p /srv/bspds/{postgres,minio,valkey,certs,acme,config}
```
## 3. Create Environment File
```bash
cp /opt/bspds/.env.example /srv/bspds/config/bspds.env
chmod 600 /srv/bspds/config/bspds.env
```
Edit `/srv/bspds/config/bspds.env` and fill in your values. Generate secrets with:
```bash
openssl rand -base64 48
```
For quadlets, also add `DATABASE_URL` with the full connection string (systemd doesn't support variable expansion).
## 4. Install Quadlet Definitions
Copy the quadlet files from the repository:
```bash
cp /opt/bspds/deploy/quadlets/*.pod /etc/containers/systemd/
cp /opt/bspds/deploy/quadlets/*.container /etc/containers/systemd/
```
Note: Systemd doesn't support shell-style variable expansion in `Environment=` lines. The quadlet files expect DATABASE_URL to be set in the environment file.
## 5. Create nginx Configuration
```bash
cp /opt/bspds/deploy/nginx/nginx-quadlet.conf /srv/bspds/config/nginx.conf
```
## 6. Build BSPDS Image
```bash
cd /opt
git clone https://tangled.org/lewis.moe/bspds-sandbox bspds
cd bspds
podman build -t bspds:latest .
```
## 7. Create Podman Secrets
```bash
source /srv/bspds/config/bspds.env
echo "$DB_PASSWORD" | podman secret create bspds-db-password -
echo "$MINIO_ROOT_PASSWORD" | podman secret create bspds-minio-password -
```
## 8. Start Services and Initialize
```bash
systemctl daemon-reload
systemctl start bspds-db bspds-minio bspds-valkey
sleep 10
# Create MinIO bucket
podman run --rm --pod bspds \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=your-minio-password \
docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \
sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs"
# Run migrations
cargo install sqlx-cli --no-default-features --features postgres
DATABASE_URL="postgres://bspds:your-db-password@localhost:5432/pds" sqlx migrate run --source /opt/bspds/migrations
```
## 9. Obtain SSL Certificate
Create temporary self-signed cert:
```bash
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout /srv/bspds/certs/privkey.pem \
-out /srv/bspds/certs/fullchain.pem \
-subj "/CN=pds.example.com"
systemctl start bspds-app bspds-nginx
# Get real certificate
podman run --rm \
-v /srv/bspds/certs:/etc/letsencrypt:Z \
-v /srv/bspds/acme:/var/www/acme:Z \
docker.io/certbot/certbot:v5.2.2 certonly \
--webroot -w /var/www/acme -d pds.example.com --agree-tos --email you@example.com
# Link certificates
ln -sf /srv/bspds/certs/live/pds.example.com/fullchain.pem /srv/bspds/certs/fullchain.pem
ln -sf /srv/bspds/certs/live/pds.example.com/privkey.pem /srv/bspds/certs/privkey.pem
systemctl restart bspds-nginx
```
## 10. Enable All Services
```bash
systemctl enable bspds-db bspds-minio bspds-valkey bspds-app bspds-nginx
```
## 11. Configure Firewall
```bash
apt install -y ufw
ufw allow ssh
@@ -166,109 +115,78 @@ ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
## 12. Certificate Renewal
Add to root's crontab (`crontab -e`):
```
0 0 * * * podman run --rm -v /srv/bspds/certs:/etc/letsencrypt:Z -v /srv/bspds/acme:/var/www/acme:Z docker.io/certbot/certbot:v5.2.2 renew --quiet && systemctl reload bspds-nginx
```
---
# Alpine 3.23+ with OpenRC
Alpine uses OpenRC, not systemd. We'll use podman-compose with an OpenRC service wrapper.
## 1. Install Podman
```sh
apk update
apk add podman podman-compose fuse-overlayfs cni-plugins
rc-update add cgroups
rc-service cgroups start
```
Enable podman socket for compose:
```sh
rc-update add podman
rc-service podman start
```
## 2. Create Directory Structure
```sh
mkdir -p /srv/bspds/{data,config}
mkdir -p /srv/bspds/data/{postgres,minio,valkey,certs,acme}
```
## 3. Clone Repository and Build
```sh
cd /opt
git clone https://tangled.org/lewis.moe/bspds-sandbox bspds
cd bspds
podman build -t bspds:latest .
```
## 4. Create Environment File
```sh
cp /opt/bspds/.env.example /srv/bspds/config/bspds.env
chmod 600 /srv/bspds/config/bspds.env
```
Edit `/srv/bspds/config/bspds.env` and fill in your values. Generate secrets with:
```sh
openssl rand -base64 48
```
## 5. Set Up Compose and nginx
Copy the production compose and nginx configs:
```sh
cp /opt/bspds/docker-compose.prod.yml /srv/bspds/docker-compose.yml
cp /opt/bspds/nginx.prod.conf /srv/bspds/config/nginx.conf
```
Edit `/srv/bspds/docker-compose.yml` to adjust paths if needed:
- Update volume mounts to use `/srv/bspds/data/` paths
- Update nginx cert paths to match `/srv/bspds/data/certs/`
Edit `/srv/bspds/config/nginx.conf` to update cert paths:
- Change `/etc/nginx/certs/live/${PDS_HOSTNAME}/` to `/etc/nginx/certs/`
## 6. Create OpenRC Service
```sh
cat > /etc/init.d/bspds << 'EOF'
#!/sbin/openrc-run
name="bspds"
description="BSPDS AT Protocol PDS (containerized)"
command="/usr/bin/podman-compose"
command_args="-f /srv/bspds/docker-compose.yml up"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
directory="/srv/bspds"
depend() {
need net podman
after firewall
}
start_pre() {
set -a
. /srv/bspds/config/bspds.env
set +a
}
stop() {
ebegin "Stopping ${name}"
cd /srv/bspds
@@ -279,18 +197,13 @@ stop() {
eend $?
}
EOF
chmod +x /etc/init.d/bspds
```
## 7. Initialize Services
```sh
# Start services
rc-service bspds start
sleep 15
# Create MinIO bucket
source /srv/bspds/config/bspds.env
podman run --rm --network bspds_default \
@@ -298,30 +211,23 @@ podman run --rm --network bspds_default \
-e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \
docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \
sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs'
# Run migrations
apk add rustup
rustup-init -y
source ~/.cargo/env
cargo install sqlx-cli --no-default-features --features postgres
# Get database container IP
DB_IP=$(podman inspect bspds-db-1 --format '{{.NetworkSettings.Networks.bspds_default.IPAddress}}')
DATABASE_URL="postgres://bspds:$DB_PASSWORD@$DB_IP:5432/pds" sqlx migrate run --source /opt/bspds/migrations
```
## 8. Obtain SSL Certificate
Create temporary self-signed cert:
```sh
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout /srv/bspds/data/certs/privkey.pem \
-out /srv/bspds/data/certs/fullchain.pem \
-subj "/CN=pds.example.com"
rc-service bspds restart
# Get real certificate
podman run --rm \
-v /srv/bspds/data/certs:/etc/letsencrypt \
@@ -329,99 +235,73 @@ podman run --rm \
--network bspds_default \
docker.io/certbot/certbot:v5.2.2 certonly \
--webroot -w /var/www/acme -d pds.example.com --agree-tos --email you@example.com
# Link certificates
ln -sf /srv/bspds/data/certs/live/pds.example.com/fullchain.pem /srv/bspds/data/certs/fullchain.pem
ln -sf /srv/bspds/data/certs/live/pds.example.com/privkey.pem /srv/bspds/data/certs/privkey.pem
rc-service bspds restart
```
## 9. Enable Service at Boot
```sh
rc-update add bspds
```
## 10. Configure Firewall
```sh
apk add iptables ip6tables
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -P INPUT DROP
ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -P INPUT DROP
rc-update add iptables
rc-update add ip6tables
/etc/init.d/iptables save
/etc/init.d/ip6tables save
```
## 11. Certificate Renewal
Add to root's crontab (`crontab -e`):
```
0 0 * * * podman run --rm -v /srv/bspds/data/certs:/etc/letsencrypt -v /srv/bspds/data/acme:/var/www/acme docker.io/certbot/certbot:v5.2.2 renew --quiet && rc-service bspds restart
```
---
# Verification and Maintenance
## Verify Installation
```sh
curl -s https://pds.example.com/xrpc/_health | jq
curl -s https://pds.example.com/.well-known/atproto-did
```
## View Logs
**Debian:**
```bash
journalctl -u bspds-app -f
podman logs -f bspds-app
```
**Alpine:**
```sh
podman-compose -f /srv/bspds/docker-compose.yml logs -f
podman logs -f bspds-bspds-1
```
## Update BSPDS
```sh
cd /opt/bspds
git pull
podman build -t bspds:latest .
# Debian:
systemctl restart bspds-app
# Alpine:
rc-service bspds restart
```
## Backup Database
**Debian:**
```bash
podman exec bspds-db pg_dump -U bspds pds > /var/backups/pds-$(date +%Y%m%d).sql
```
**Alpine:**
```sh
podman exec bspds-db-1 pg_dump -U bspds pds > /var/backups/pds-$(date +%Y%m%d).sql
-70
View File
@@ -1,82 +1,58 @@
# BSPDS Production Installation on Debian
> **Warning**: These instructions are untested and theoretical, written from the top of Lewis' head. They may contain errors or omissions. This warning will be removed once the guide has been verified.
This guide covers installing BSPDS on Debian 13 "Trixie" (current stable as of December 2025).
## Choose Your Installation Method
| Method | Best For |
|--------|----------|
| **Native (this guide)** | Maximum performance, full control, simpler debugging |
| **[Containerized](install-containers.md)** | Easier updates, isolation, reproducible deployments |
| **[Kubernetes](install-kubernetes.md)** | Multi-node, high availability, auto-scaling |
This guide covers native installation. For containerized deployment with podman and systemd quadlets, see the [container guide](install-containers.md).
---
## Prerequisites
- A VPS with at least 2GB RAM and 20GB disk
- A domain name pointing to your server's IP
- Root or sudo access
## 1. System Setup
```bash
apt update && apt upgrade -y
apt install -y curl git build-essential pkg-config libssl-dev
```
## 2. Install Rust
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
rustup default stable
```
This installs the latest stable Rust (1.92+ as of December 2025).
## 3. Install postgres
Debian 13 includes PostgreSQL 17:
```bash
apt install -y postgresql postgresql-contrib
systemctl enable postgresql
systemctl start postgresql
sudo -u postgres psql -c "CREATE USER bspds WITH PASSWORD 'your-secure-password';"
sudo -u postgres psql -c "CREATE DATABASE pds OWNER bspds;"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE pds TO bspds;"
```
## 4. Install minio
```bash
curl -O https://dl.min.io/server/minio/release/linux-amd64/minio
chmod +x minio
mv minio /usr/local/bin/
mkdir -p /var/lib/minio/data
useradd -r -s /sbin/nologin minio-user
chown -R minio-user:minio-user /var/lib/minio
cat > /etc/default/minio << 'EOF'
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=your-minio-password
MINIO_VOLUMES="/var/lib/minio/data"
MINIO_OPTS="--console-address :9001"
EOF
cat > /etc/systemd/system/minio.service << 'EOF'
[Unit]
Description=MinIO Object Storage
After=network.target
[Service]
User=minio-user
Group=minio-user
@@ -84,98 +60,71 @@ EnvironmentFile=/etc/default/minio
ExecStart=/usr/local/bin/minio server $MINIO_VOLUMES $MINIO_OPTS
Restart=always
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable minio
systemctl start minio
```
Create the blob bucket (wait a few seconds for minio to start):
```bash
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
mv mc /usr/local/bin/
mc alias set local http://localhost:9000 minioadmin your-minio-password
mc mb local/pds-blobs
```
## 5. Install valkey
Debian 13 includes Valkey 8:
```bash
apt install -y valkey
systemctl enable valkey-server
systemctl start valkey-server
```
## 6. Install deno (for frontend build)
```bash
curl -fsSL https://deno.land/install.sh | sh
export PATH="$HOME/.deno/bin:$PATH"
echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc
```
## 7. Clone and Build BSPDS
```bash
cd /opt
git clone https://tangled.org/lewis.moe/bspds-sandbox bspds
cd bspds
cd frontend
deno task build
cd ..
cargo build --release
```
## 8. Install sqlx-cli and Run Migrations
```bash
cargo install sqlx-cli --no-default-features --features postgres
export DATABASE_URL="postgres://bspds:your-secure-password@localhost:5432/pds"
sqlx migrate run
```
## 9. Configure BSPDS
```bash
mkdir -p /etc/bspds
cp /opt/bspds/.env.example /etc/bspds/bspds.env
chmod 600 /etc/bspds/bspds.env
```
Edit `/etc/bspds/bspds.env` and fill in your values. Generate secrets with:
```bash
openssl rand -base64 48
```
## 10. Create Systemd Service
```bash
useradd -r -s /sbin/nologin bspds
cp /opt/bspds/target/release/bspds /usr/local/bin/
mkdir -p /var/lib/bspds
cp -r /opt/bspds/frontend/dist /var/lib/bspds/frontend
chown -R bspds:bspds /var/lib/bspds
cat > /etc/systemd/system/bspds.service << 'EOF'
[Unit]
Description=BSPDS - AT Protocol PDS
After=network.target postgresql.service minio.service
[Service]
Type=simple
User=bspds
@@ -185,29 +134,22 @@ Environment=FRONTEND_DIR=/var/lib/bspds/frontend
ExecStart=/usr/local/bin/bspds
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable bspds
systemctl start bspds
```
## 11. Install and Configure nginx
Debian 13 includes nginx 1.26:
```bash
apt install -y nginx certbot python3-certbot-nginx
cat > /etc/nginx/sites-available/bspds << 'EOF'
server {
listen 80;
listen [::]:80;
server_name pds.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -221,23 +163,17 @@ server {
}
}
EOF
ln -s /etc/nginx/sites-available/bspds /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
```
## 12. Obtain SSL Certificate
```bash
certbot --nginx -d pds.example.com
```
Certbot automatically configures nginx for HTTP/2 and sets up auto-renewal.
## 13. Configure Firewall
```bash
apt install -y ufw
ufw allow ssh
@@ -245,22 +181,17 @@ ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
## 14. Verify Installation
```bash
systemctl status bspds
curl -s https://pds.example.com/xrpc/_health | jq
curl -s https://pds.example.com/.well-known/atproto-did
```
## Maintenance
View logs:
```bash
journalctl -u bspds -f
```
Update BSPDS:
```bash
cd /opt/bspds
@@ -273,7 +204,6 @@ cp -r frontend/dist /var/lib/bspds/frontend
DATABASE_URL="postgres://bspds:your-secure-password@localhost:5432/pds" sqlx migrate run
systemctl start bspds
```
Backup database:
```bash
sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql
-97
View File
@@ -1,11 +1,7 @@
# BSPDS Production Kubernetes Deployment
> **Warning**: These instructions are untested and theoretical, written from the top of Lewis' head. They may contain errors or omissions. This warning will be removed once the guide has been verified.
This guide covers deploying BSPDS on a production multi-node Kubernetes cluster with high availability, auto-scaling, and proper secrets management.
## Architecture Overview
```
┌─────────────────────────────────────────────────┐
│ Kubernetes Cluster │
@@ -30,20 +26,15 @@ This guide covers deploying BSPDS on a production multi-node Kubernetes cluster
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
## Prerequisites
- Kubernetes cluster (1.30+) with at least 3 nodes (1.34 is current stable)
- `kubectl` configured to access your cluster
- `helm` 3.x installed
- Storage class that supports `ReadWriteOnce` (for databases)
- Ingress controller installed (nginx-ingress or traefik)
- cert-manager installed for TLS certificates
### Quick Prerequisites Setup
If you need to install prerequisites:
```bash
# Install nginx-ingress (chart v4.14.1 - December 2025)
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
@@ -51,7 +42,6 @@ helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--version 4.14.1
# Install cert-manager (v1.19.2 - December 2025)
helm repo add jetstack https://charts.jetstack.io
helm repo update
@@ -60,20 +50,14 @@ helm install cert-manager jetstack/cert-manager \
--version v1.19.2 \
--set installCRDs=true
```
---
## 1. Create Namespace
```bash
kubectl create namespace bspds
kubectl config set-context --current --namespace=bspds
```
## 2. Create Secrets
Generate secure passwords and secrets:
```bash
# Generate secrets
DB_PASSWORD=$(openssl rand -base64 32)
@@ -81,21 +65,17 @@ MINIO_PASSWORD=$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -base64 48)
DPOP_SECRET=$(openssl rand -base64 48)
MASTER_KEY=$(openssl rand -base64 48)
# Create Kubernetes secrets
kubectl create secret generic bspds-db-credentials \
--from-literal=username=bspds \
--from-literal=password="$DB_PASSWORD"
kubectl create secret generic bspds-minio-credentials \
--from-literal=root-user=minioadmin \
--from-literal=root-password="$MINIO_PASSWORD"
kubectl create secret generic bspds-secrets \
--from-literal=jwt-secret="$JWT_SECRET" \
--from-literal=dpop-secret="$DPOP_SECRET" \
--from-literal=master-key="$MASTER_KEY"
# Save secrets locally (KEEP SECURE!)
echo "DB_PASSWORD=$DB_PASSWORD" > secrets.txt
echo "MINIO_PASSWORD=$MINIO_PASSWORD" >> secrets.txt
@@ -104,21 +84,16 @@ echo "DPOP_SECRET=$DPOP_SECRET" >> secrets.txt
echo "MASTER_KEY=$MASTER_KEY" >> secrets.txt
chmod 600 secrets.txt
```
## 3. Deploy PostgreSQL
### Option A: CloudNativePG Operator (Recommended for HA)
```bash
# Install CloudNativePG operator (v1.28.0 - December 2025)
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.28/releases/cnpg-1.28.0.yaml
# Wait for operator
kubectl wait --for=condition=available --timeout=120s \
deployment/cnpg-controller-manager -n cnpg-system
```
```bash
cat <<EOF | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
@@ -128,23 +103,19 @@ metadata:
namespace: bspds
spec:
instances: 3
postgresql:
parameters:
max_connections: "200"
shared_buffers: "256MB"
bootstrap:
initdb:
database: pds
owner: bspds
secret:
name: bspds-db-credentials
storage:
size: 20Gi
storageClass: standard # adjust for your cluster
resources:
requests:
memory: "512Mi"
@@ -152,14 +123,11 @@ spec:
limits:
memory: "1Gi"
cpu: "1000m"
affinity:
podAntiAffinityType: required
EOF
```
### Option B: Simple StatefulSet (Single Instance)
```bash
cat <<EOF | kubectl apply -f -
apiVersion: v1
@@ -248,9 +216,7 @@ spec:
targetPort: 5432
EOF
```
## 4. Deploy MinIO
```bash
cat <<EOF | kubectl apply -f -
apiVersion: v1
@@ -349,9 +315,7 @@ spec:
name: console
EOF
```
### Initialize MinIO Bucket
```bash
kubectl run minio-init --rm -it --restart=Never \
--image=minio/mc:RELEASE.2025-07-16T15-35-03Z \
@@ -362,9 +326,7 @@ kubectl run minio-init --rm -it --restart=Never \
mc mb --ignore-existing local/pds-blobs
"
```
## 5. Deploy Valkey
```bash
cat <<EOF | kubectl apply -f -
apiVersion: v1
@@ -446,18 +408,14 @@ spec:
targetPort: 6379
EOF
```
## 6. Build and Push BSPDS Image
```bash
# Build image
cd /path/to/bspds
docker build -t your-registry.com/bspds:latest .
docker push your-registry.com/bspds:latest
```
If using a private registry, create an image pull secret:
```bash
kubectl create secret docker-registry regcred \
--docker-server=your-registry.com \
@@ -465,11 +423,8 @@ kubectl create secret docker-registry regcred \
--docker-password=your-password \
--docker-email=your-email
```
## 7. Run Database Migrations
BSPDS runs migrations automatically on startup. However, if you want to run migrations separately (recommended for zero-downtime deployments), you can use a Job:
```bash
cat <<'EOF' | kubectl apply -f -
apiVersion: batch/v1
@@ -496,14 +451,10 @@ spec:
- name: DATABASE_URL
value: "postgres://bspds:$(DB_PASSWORD)@bspds-db-rw:5432/pds"
EOF
kubectl wait --for=condition=complete --timeout=120s job/bspds-migrate
```
> **Note**: If your BSPDS image doesn't have a `--migrate-only` flag, you can skip this step. The app will run migrations on first startup. Alternatively, build a separate migration image with `sqlx-cli` installed.
## 8. Deploy BSPDS Application
```bash
cat <<EOF | kubectl apply -f -
apiVersion: v1
@@ -631,9 +582,7 @@ spec:
name: http
EOF
```
## 9. Configure Horizontal Pod Autoscaler
```bash
cat <<EOF | kubectl apply -f -
apiVersion: autoscaling/v2
@@ -680,9 +629,7 @@ spec:
selectPolicy: Max
EOF
```
## 10. Configure Pod Disruption Budget
```bash
cat <<EOF | kubectl apply -f -
apiVersion: policy/v1
@@ -697,9 +644,7 @@ spec:
app: bspds
EOF
```
## 11. Configure TLS with cert-manager
```bash
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
@@ -718,9 +663,7 @@ spec:
class: nginx
EOF
```
## 12. Configure Ingress
```bash
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
@@ -754,9 +697,7 @@ spec:
number: 80
EOF
```
## 13. Configure Network Policies (Optional but Recommended)
```bash
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
@@ -817,9 +758,7 @@ spec:
port: 443
EOF
```
## 14. Deploy Prometheus Monitoring (Optional)
```bash
cat <<EOF | kubectl apply -f -
apiVersion: monitoring.coreos.com/v1
@@ -839,117 +778,81 @@ spec:
interval: 30s
EOF
```
---
## Verification
```bash
# Check all pods are running
kubectl get pods -n bspds
# Check services
kubectl get svc -n bspds
# Check ingress
kubectl get ingress -n bspds
# Check certificate
kubectl get certificate -n bspds
# Test health endpoint
curl -s https://pds.example.com/xrpc/_health | jq
# Test DID endpoint
curl -s https://pds.example.com/.well-known/atproto-did
```
---
## Maintenance
### View Logs
```bash
# All BSPDS pods
kubectl logs -l app=bspds -n bspds -f
# Specific pod
kubectl logs -f deployment/bspds -n bspds
```
### Scale Manually
```bash
kubectl scale deployment bspds --replicas=5 -n bspds
```
### Update BSPDS
```bash
# Build and push new image
docker build -t your-registry.com/bspds:v1.2.3 .
docker push your-registry.com/bspds:v1.2.3
# Update deployment
kubectl set image deployment/bspds bspds=your-registry.com/bspds:v1.2.3 -n bspds
# Watch rollout
kubectl rollout status deployment/bspds -n bspds
```
### Backup Database
```bash
# For CloudNativePG
kubectl cnpg backup bspds-db -n bspds
# For StatefulSet
kubectl exec -it bspds-db-0 -n bspds -- pg_dump -U bspds pds > backup-$(date +%Y%m%d).sql
```
### Run Migrations
If you have a migration Job defined, you can re-run it:
```bash
# Delete old job first (if exists)
kubectl delete job bspds-migrate -n bspds --ignore-not-found
# Re-apply the migration job from step 7
# Or simply restart the deployment - BSPDS runs migrations on startup
kubectl rollout restart deployment/bspds -n bspds
```
---
## Troubleshooting
### Pod Won't Start
```bash
kubectl describe pod -l app=bspds -n bspds
kubectl logs -l app=bspds -n bspds --previous
```
### Database Connection Issues
```bash
# Test connectivity from a debug pod
kubectl run debug --rm -it --restart=Never --image=postgres:18-alpine -- \
psql "postgres://bspds:PASSWORD@bspds-db-rw:5432/pds" -c "SELECT 1"
```
### Certificate Issues
```bash
kubectl describe certificate bspds-tls -n bspds
kubectl describe certificaterequest -n bspds
kubectl logs -l app.kubernetes.io/name=cert-manager -n cert-manager
```
### View Resource Usage
```bash
kubectl top pods -n bspds
kubectl top nodes
-96
View File
@@ -1,196 +1,136 @@
# BSPDS Production Installation on OpenBSD
> **Warning**: These instructions are untested and theoretical, written from the top of Lewis' head. They may contain errors or omissions. This warning will be removed once the guide has been verified.
This guide covers installing BSPDS on OpenBSD 7.8 (current release as of December 2025).
## Prerequisites
- A VPS with at least 2GB RAM and 20GB disk
- A domain name pointing to your server's IP
- Root access (or doas configured)
## Why nginx over relayd?
OpenBSD's native `relayd` supports WebSockets but does **not** support HTTP/2. For a modern PDS deployment, we recommend nginx which provides HTTP/2, WebSocket support, and automatic OCSP stapling.
## 1. System Setup
```sh
pkg_add curl git
```
## 2. Install Rust
```sh
pkg_add rust
```
OpenBSD 7.8 ships Rust 1.82+. For the latest stable (1.92+), use rustup:
```sh
pkg_add rustup
rustup-init -y
source ~/.cargo/env
rustup default stable
```
## 3. Install postgres
OpenBSD 7.8 includes PostgreSQL 17 (PostgreSQL 18 may not yet be in ports):
```sh
pkg_add postgresql-server postgresql-client
mkdir -p /var/postgresql/data
chown _postgresql:_postgresql /var/postgresql/data
su - _postgresql -c "initdb -D /var/postgresql/data -U postgres -A scram-sha-256"
rcctl enable postgresql
rcctl start postgresql
psql -U postgres -c "CREATE USER bspds WITH PASSWORD 'your-secure-password';"
psql -U postgres -c "CREATE DATABASE pds OWNER bspds;"
psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE pds TO bspds;"
```
## 4. Install minio
OpenBSD doesn't have a minio package. Options:
**Option A: Use an external S3-compatible service (recommended for production)**
aws s3, backblaze b2, or upcloud managed object storage. Skip to step 5 and configure the S3 credentials in step 9.
**Option B: Build minio from source**
```sh
pkg_add go
mkdir -p /tmp/minio-build && cd /tmp/minio-build
ftp -o minio.tar.gz https://github.com/minio/minio/archive/refs/tags/RELEASE.2025-10-15T17-29-55Z.tar.gz
tar xzf minio.tar.gz
cd minio-*
go build -o minio .
cp minio /usr/local/bin/
mkdir -p /var/minio/data
useradd -d /var/minio -s /sbin/nologin _minio
chown -R _minio:_minio /var/minio
cat > /etc/minio.conf << 'EOF'
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=your-minio-password
EOF
chmod 600 /etc/minio.conf
cat > /etc/rc.d/minio << 'EOF'
#!/bin/ksh
daemon="/usr/local/bin/minio"
daemon_user="_minio"
daemon_flags="server /var/minio/data --console-address :9001"
. /etc/rc.d/rc.subr
rc_pre() {
. /etc/minio.conf
export MINIO_ROOT_USER MINIO_ROOT_PASSWORD
}
rc_cmd $1
EOF
chmod +x /etc/rc.d/minio
rcctl enable minio
rcctl start minio
```
Create the blob bucket:
```sh
ftp -o /usr/local/bin/mc https://dl.min.io/client/mc/release/openbsd-amd64/mc
chmod +x /usr/local/bin/mc
mc alias set local http://localhost:9000 minioadmin your-minio-password
mc mb local/pds-blobs
```
## 5. Install redis
OpenBSD has redis in ports (valkey may not be available yet):
```sh
pkg_add redis
rcctl enable redis
rcctl start redis
```
## 6. Install deno (for frontend build)
```sh
curl -fsSL https://deno.land/install.sh | sh
export PATH="$HOME/.deno/bin:$PATH"
echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.profile
```
## 7. Clone and Build BSPDS
```sh
mkdir -p /opt && cd /opt
git clone https://tangled.org/lewis.moe/bspds-sandbox bspds
cd bspds
cd frontend
deno task build
cd ..
cargo build --release
```
## 8. Install sqlx-cli and Run Migrations
```sh
cargo install sqlx-cli --no-default-features --features postgres
export DATABASE_URL="postgres://bspds:your-secure-password@localhost:5432/pds"
sqlx migrate run
```
## 9. Configure BSPDS
```sh
mkdir -p /etc/bspds
cp /opt/bspds/.env.example /etc/bspds/bspds.conf
chmod 600 /etc/bspds/bspds.conf
```
Edit `/etc/bspds/bspds.conf` and fill in your values. Generate secrets with:
```sh
openssl rand -base64 48
```
## 10. Create rc.d Service
```sh
useradd -d /var/empty -s /sbin/nologin _bspds
cp /opt/bspds/target/release/bspds /usr/local/bin/
mkdir -p /var/bspds
cp -r /opt/bspds/frontend/dist /var/bspds/frontend
chown -R _bspds:_bspds /var/bspds
cat > /etc/rc.d/bspds << 'EOF'
#!/bin/ksh
daemon="/usr/local/bin/bspds"
daemon_user="_bspds"
daemon_logger="daemon.info"
. /etc/rc.d/rc.subr
rc_pre() {
export FRONTEND_DIR=/var/bspds/frontend
while IFS='=' read -r key value; do
@@ -200,56 +140,43 @@ rc_pre() {
export "$key=$value"
done < /etc/bspds/bspds.conf
}
rc_cmd $1
EOF
chmod +x /etc/rc.d/bspds
rcctl enable bspds
rcctl start bspds
```
## 11. Install and Configure nginx
```sh
pkg_add nginx
cat > /etc/nginx/nginx.conf << 'EOF'
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
server {
listen 80;
listen [::]:80;
server_name pds.example.com;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name pds.example.com;
ssl_certificate /etc/ssl/pds.example.com.fullchain.pem;
ssl_certificate_key /etc/ssl/private/pds.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -264,77 +191,55 @@ http {
}
}
EOF
mkdir -p /var/www/acme
rcctl enable nginx
```
## 12. Obtain SSL Certificate with acme-client
OpenBSD's native acme-client works well:
```sh
cat >> /etc/acme-client.conf << 'EOF'
authority letsencrypt {
api url "https://acme-v02.api.letsencrypt.org/directory"
account key "/etc/acme/letsencrypt-privkey.pem"
}
domain pds.example.com {
domain key "/etc/ssl/private/pds.example.com.key"
domain full chain certificate "/etc/ssl/pds.example.com.fullchain.pem"
sign with letsencrypt
}
EOF
mkdir -p /etc/acme
rcctl start nginx
acme-client -v pds.example.com
rcctl restart nginx
```
Set up auto-renewal in root's crontab:
```sh
crontab -e
```
Add:
```
0 0 * * * acme-client pds.example.com && rcctl reload nginx
```
## 13. Configure Packet Filter (pf)
```sh
cat >> /etc/pf.conf << 'EOF'
# BSPDS rules
pass in on egress proto tcp from any to any port { 22, 80, 443 }
EOF
pfctl -f /etc/pf.conf
```
## 14. Verify Installation
```sh
rcctl check bspds
ftp -o - https://pds.example.com/xrpc/_health
ftp -o - https://pds.example.com/.well-known/atproto-did
```
## Maintenance
View logs:
```sh
tail -f /var/log/daemon
```
Update BSPDS:
```sh
cd /opt/bspds
@@ -347,7 +252,6 @@ cp -r frontend/dist /var/bspds/frontend
DATABASE_URL="postgres://bspds:your-secure-password@localhost:5432/pds" sqlx migrate run
rcctl start bspds
```
Backup database:
```sh
pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql
-11
View File
@@ -9,13 +9,10 @@
import Settings from './routes/Settings.svelte'
import Notifications from './routes/Notifications.svelte'
import RepoExplorer from './routes/RepoExplorer.svelte'
const auth = getAuthState()
$effect(() => {
initAuth()
})
function getComponent(path: string) {
switch (path) {
case '/login':
@@ -38,11 +35,9 @@
return auth.session ? Dashboard : Login
}
}
let currentPath = $derived(getCurrentPath())
let CurrentComponent = $derived(getComponent(currentPath))
</script>
<main>
{#if auth.loading}
<div class="loading">
@@ -52,7 +47,6 @@
<CurrentComponent />
{/if}
</main>
<style>
:global(:root) {
--bg-primary: #fafafa;
@@ -76,7 +70,6 @@
--warning-bg: #ffd;
--warning-text: #660;
}
@media (prefers-color-scheme: dark) {
:global(:root) {
--bg-primary: #1a1a1a;
@@ -101,7 +94,6 @@
--warning-text: #c6c67b;
}
}
:global(body) {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -109,16 +101,13 @@
color: var(--text-primary);
background: var(--bg-primary);
}
:global(*) {
box-sizing: border-box;
}
main {
min-height: 100vh;
background: var(--bg-primary);
}
.loading {
display: flex;
align-items: center;
-43
View File
@@ -1,15 +1,12 @@
const API_BASE = '/xrpc'
export class ApiError extends Error {
public did?: string
constructor(public status: number, public error: string, message: string, did?: string) {
super(message)
this.name = 'ApiError'
this.did = did
}
}
async function xrpc<T>(method: string, options?: {
method?: 'GET' | 'POST'
params?: Record<string, string>
@@ -17,13 +14,11 @@ async function xrpc<T>(method: string, options?: {
token?: string
}): Promise<T> {
const { method: httpMethod = 'GET', params, body, token } = options ?? {}
let url = `${API_BASE}/${method}`
if (params) {
const searchParams = new URLSearchParams(params)
url += `?${searchParams}`
}
const headers: Record<string, string> = {}
if (token) {
headers['Authorization'] = `Bearer ${token}`
@@ -31,21 +26,17 @@ async function xrpc<T>(method: string, options?: {
if (body) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(url, {
method: httpMethod,
headers,
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
throw new ApiError(res.status, err.error, err.message, err.did)
}
return res.json()
}
export interface Session {
did: string
handle: string
@@ -56,12 +47,10 @@ export interface Session {
accessJwt: string
refreshJwt: string
}
export interface AppPassword {
name: string
createdAt: string
}
export interface InviteCode {
code: string
available: number
@@ -71,9 +60,7 @@ export interface InviteCode {
createdAt: string
uses: { usedBy: string; usedAt: string }[]
}
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
export interface CreateAccountParams {
handle: string
email: string
@@ -84,14 +71,12 @@ export interface CreateAccountParams {
telegramUsername?: string
signalNumber?: string
}
export interface CreateAccountResult {
handle: string
did: string
verificationRequired: boolean
verificationChannel: string
}
export interface ConfirmSignupResult {
accessJwt: string
refreshJwt: string
@@ -102,7 +87,6 @@ export interface ConfirmSignupResult {
preferredChannel?: string
preferredChannelVerified?: boolean
}
export const api = {
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
return xrpc('com.atproto.server.createAccount', {
@@ -119,50 +103,42 @@ export const api = {
},
})
},
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
return xrpc('com.atproto.server.confirmSignup', {
method: 'POST',
body: { did, verificationCode },
})
},
async resendVerification(did: string): Promise<{ success: boolean }> {
return xrpc('com.atproto.server.resendVerification', {
method: 'POST',
body: { did },
})
},
async createSession(identifier: string, password: string): Promise<Session> {
return xrpc('com.atproto.server.createSession', {
method: 'POST',
body: { identifier, password },
})
},
async getSession(token: string): Promise<Session> {
return xrpc('com.atproto.server.getSession', { token })
},
async refreshSession(refreshJwt: string): Promise<Session> {
return xrpc('com.atproto.server.refreshSession', {
method: 'POST',
token: refreshJwt,
})
},
async deleteSession(token: string): Promise<void> {
await xrpc('com.atproto.server.deleteSession', {
method: 'POST',
token,
})
},
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
return xrpc('com.atproto.server.listAppPasswords', { token })
},
async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> {
return xrpc('com.atproto.server.createAppPassword', {
method: 'POST',
@@ -170,7 +146,6 @@ export const api = {
body: { name },
})
},
async revokeAppPassword(token: string, name: string): Promise<void> {
await xrpc('com.atproto.server.revokeAppPassword', {
method: 'POST',
@@ -178,11 +153,9 @@ export const api = {
body: { name },
})
},
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
},
async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> {
return xrpc('com.atproto.server.createInviteCode', {
method: 'POST',
@@ -190,28 +163,24 @@ export const api = {
body: { useCount },
})
},
async requestPasswordReset(email: string): Promise<void> {
await xrpc('com.atproto.server.requestPasswordReset', {
method: 'POST',
body: { email },
})
},
async resetPassword(token: string, password: string): Promise<void> {
await xrpc('com.atproto.server.resetPassword', {
method: 'POST',
body: { token, password },
})
},
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
return xrpc('com.atproto.server.requestEmailUpdate', {
method: 'POST',
token,
})
},
async updateEmail(token: string, email: string, emailToken?: string): Promise<void> {
await xrpc('com.atproto.server.updateEmail', {
method: 'POST',
@@ -219,7 +188,6 @@ export const api = {
body: { email, token: emailToken },
})
},
async updateHandle(token: string, handle: string): Promise<void> {
await xrpc('com.atproto.identity.updateHandle', {
method: 'POST',
@@ -227,21 +195,18 @@ export const api = {
body: { handle },
})
},
async requestAccountDelete(token: string): Promise<void> {
await xrpc('com.atproto.server.requestAccountDelete', {
method: 'POST',
token,
})
},
async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> {
await xrpc('com.atproto.server.deleteAccount', {
method: 'POST',
body: { did, password, token: deleteToken },
})
},
async describeServer(): Promise<{
availableUserDomains: string[]
inviteCodeRequired: boolean
@@ -249,7 +214,6 @@ export const api = {
}> {
return xrpc('com.atproto.server.describeServer')
},
async getNotificationPrefs(token: string): Promise<{
preferredChannel: string
email: string
@@ -262,7 +226,6 @@ export const api = {
}> {
return xrpc('com.bspds.account.getNotificationPrefs', { token })
},
async updateNotificationPrefs(token: string, prefs: {
preferredChannel?: string
discordId?: string
@@ -275,7 +238,6 @@ export const api = {
body: prefs,
})
},
async describeRepo(token: string, repo: string): Promise<{
handle: string
did: string
@@ -288,7 +250,6 @@ export const api = {
params: { repo },
})
},
async listRecords(token: string, repo: string, collection: string, options?: {
limit?: number
cursor?: string
@@ -303,7 +264,6 @@ export const api = {
if (options?.reverse) params.reverse = 'true'
return xrpc('com.atproto.repo.listRecords', { token, params })
},
async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{
uri: string
cid: string
@@ -314,7 +274,6 @@ export const api = {
params: { repo, collection, rkey },
})
},
async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{
uri: string
cid: string
@@ -325,7 +284,6 @@ export const api = {
body: { repo, collection, record, rkey },
})
},
async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{
uri: string
cid: string
@@ -336,7 +294,6 @@ export const api = {
body: { repo, collection, rkey, record },
})
},
async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> {
await xrpc('com.atproto.repo.deleteRecord', {
method: 'POST',
-20
View File
@@ -1,19 +1,15 @@
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
const STORAGE_KEY = 'bspds_session'
interface AuthState {
session: Session | null
loading: boolean
error: string | null
}
let state = $state<AuthState>({
session: null,
loading: true,
error: null,
})
function saveSession(session: Session | null) {
if (session) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
@@ -21,7 +17,6 @@ function saveSession(session: Session | null) {
localStorage.removeItem(STORAGE_KEY)
}
}
function loadSession(): Session | null {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
@@ -33,11 +28,9 @@ function loadSession(): Session | null {
}
return null
}
export async function initAuth() {
state.loading = true
state.error = null
const stored = loadSession()
if (stored) {
try {
@@ -59,14 +52,11 @@ export async function initAuth() {
}
}
}
state.loading = false
}
export async function login(identifier: string, password: string): Promise<void> {
state.loading = true
state.error = null
try {
const session = await api.createSession(identifier, password)
state.session = session
@@ -82,7 +72,6 @@ export async function login(identifier: string, password: string): Promise<void>
state.loading = false
}
}
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
try {
const result = await api.createAccount(params)
@@ -96,11 +85,9 @@ export async function register(params: CreateAccountParams): Promise<CreateAccou
throw e
}
}
export async function confirmSignup(did: string, verificationCode: string): Promise<void> {
state.loading = true
state.error = null
try {
const result = await api.confirmSignup(did, verificationCode)
const session: Session = {
@@ -126,7 +113,6 @@ export async function confirmSignup(did: string, verificationCode: string): Prom
state.loading = false
}
}
export async function resendVerification(did: string): Promise<void> {
try {
await api.resendVerification(did)
@@ -137,7 +123,6 @@ export async function resendVerification(did: string): Promise<void> {
throw new Error('Failed to resend verification code')
}
}
export async function logout(): Promise<void> {
if (state.session) {
try {
@@ -149,25 +134,20 @@ export async function logout(): Promise<void> {
state.session = null
saveSession(null)
}
export function getAuthState() {
return state
}
export function getToken(): string | null {
return state.session?.accessJwt ?? null
}
export function isAuthenticated(): boolean {
return state.session !== null
}
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
state.session = newState.session
state.loading = newState.loading
state.error = newState.error
}
export function _testReset() {
state.session = null
state.loading = true
-3
View File
@@ -1,13 +1,10 @@
let currentPath = $state(window.location.hash.slice(1) || '/')
window.addEventListener('hashchange', () => {
currentPath = window.location.hash.slice(1) || '/'
})
export function navigate(path: string) {
window.location.hash = path
}
export function getCurrentPath() {
return currentPath
}
-2
View File
@@ -1,8 +1,6 @@
import App from './App.svelte'
import { mount } from 'svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
-51
View File
@@ -2,36 +2,28 @@
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, type AppPassword, ApiError } from '../lib/api'
const auth = getAuthState()
let passwords = $state<AppPassword[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
let newPasswordName = $state('')
let creating = $state(false)
let createdPassword = $state<{ name: string; password: string } | null>(null)
let revoking = $state<string | null>(null)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
$effect(() => {
if (auth.session) {
loadPasswords()
}
})
async function loadPasswords() {
if (!auth.session) return
loading = true
error = null
try {
const result = await api.listAppPasswords(auth.session.accessJwt)
passwords = result.passwords
@@ -41,14 +33,11 @@
loading = false
}
}
async function handleCreate(e: Event) {
e.preventDefault()
if (!auth.session || !newPasswordName.trim()) return
creating = true
error = null
try {
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim())
createdPassword = { name: result.name, password: result.password }
@@ -60,16 +49,13 @@
creating = false
}
}
async function handleRevoke(name: string) {
if (!auth.session) return
if (!confirm(`Revoke app password "${name}"? Apps using this password will no longer be able to access your account.`)) {
return
}
revoking = name
error = null
try {
await api.revokeAppPassword(auth.session.accessJwt, name)
await loadPasswords()
@@ -79,27 +65,22 @@
revoking = null
}
}
function dismissCreated() {
createdPassword = null
}
</script>
<div class="page">
<header>
<a href="#/dashboard" class="back">&larr; Dashboard</a>
<h1>App Passwords</h1>
</header>
<p class="description">
App passwords let you sign in to third-party apps without giving them your main password.
Each app password can be revoked individually.
</p>
{#if error}
<div class="error">{error}</div>
{/if}
{#if createdPassword}
<div class="created-password">
<h3>App Password Created</h3>
@@ -111,7 +92,6 @@
<button onclick={dismissCreated}>Done</button>
</div>
{/if}
<section class="create-section">
<h2>Create New App Password</h2>
<form onsubmit={handleCreate}>
@@ -127,10 +107,8 @@
</button>
</form>
</section>
<section class="list-section">
<h2>Your App Passwords</h2>
{#if loading}
<p class="empty">Loading...</p>
{:else if passwords.length === 0}
@@ -156,37 +134,30 @@
{/if}
</section>
</div>
<style>
.page {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 1rem;
}
.back {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
}
.back:hover {
color: var(--accent);
}
h1 {
margin: 0.5rem 0 0 0;
}
.description {
color: var(--text-secondary);
margin-bottom: 2rem;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
@@ -195,7 +166,6 @@
color: var(--error-text);
margin-bottom: 1rem;
}
.created-password {
padding: 1.5rem;
background: var(--success-bg);
@@ -203,45 +173,37 @@
border-radius: 8px;
margin-bottom: 2rem;
}
.created-password h3 {
margin: 0 0 0.5rem 0;
color: var(--success-text);
}
.password-display {
background: var(--bg-card);
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.password-display code {
font-size: 1.25rem;
font-family: monospace;
word-break: break-all;
}
.password-name {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1rem;
}
section {
margin-bottom: 2rem;
}
section h2 {
font-size: 1.125rem;
margin: 0 0 1rem 0;
}
.create-section form {
display: flex;
gap: 0.5rem;
}
.create-section input {
flex: 1;
padding: 0.75rem;
@@ -251,12 +213,10 @@
background: var(--bg-input);
color: var(--text-primary);
}
.create-section input:focus {
outline: none;
border-color: var(--accent);
}
.create-section button {
padding: 0.75rem 1.5rem;
background: var(--accent);
@@ -265,22 +225,18 @@
border-radius: 4px;
cursor: pointer;
}
.create-section button:hover:not(:disabled) {
background: var(--accent-hover);
}
.create-section button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.password-list {
list-style: none;
padding: 0;
margin: 0;
}
.password-list li {
display: flex;
justify-content: space-between;
@@ -291,22 +247,18 @@
margin-bottom: 0.5rem;
background: var(--bg-card);
}
.password-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.name {
font-weight: 500;
}
.date {
font-size: 0.875rem;
color: var(--text-secondary);
}
.revoke {
padding: 0.5rem 1rem;
background: transparent;
@@ -315,16 +267,13 @@
color: var(--error-text);
cursor: pointer;
}
.revoke:hover:not(:disabled) {
background: var(--error-bg);
}
.revoke:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.empty {
color: var(--text-secondary);
text-align: center;
-32
View File
@@ -1,37 +1,30 @@
<script lang="ts">
import { getAuthState, logout } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
const auth = getAuthState()
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
async function handleLogout() {
await logout()
navigate('/login')
}
</script>
{#if auth.session}
<div class="dashboard">
<header>
<h1>Dashboard</h1>
<button class="logout" onclick={handleLogout}>Sign Out</button>
</header>
<section class="account-overview">
<h2>Account Overview</h2>
<dl>
<dt>Handle</dt>
<dd>@{auth.session.handle}</dd>
<dt>DID</dt>
<dd class="mono">{auth.session.did}</dd>
{#if auth.session.preferredChannel}
<dt>Primary Contact</dt>
<dd>
@@ -65,28 +58,23 @@
{/if}
</dl>
</section>
<nav class="nav-grid">
<a href="#/app-passwords" class="nav-card">
<h3>App Passwords</h3>
<p>Manage passwords for third-party apps</p>
</a>
<a href="#/invite-codes" class="nav-card">
<h3>Invite Codes</h3>
<p>View and create invite codes</p>
</a>
<a href="#/settings" class="nav-card">
<h3>Account Settings</h3>
<p>Email, password, handle, and more</p>
</a>
<a href="#/notifications" class="nav-card">
<h3>Notification Preferences</h3>
<p>Discord, Telegram, Signal channels</p>
</a>
<a href="#/repo" class="nav-card">
<h3>Repository Explorer</h3>
<p>Browse and manage raw AT Protocol records</p>
@@ -96,25 +84,21 @@
{:else if auth.loading}
<div class="loading">Loading...</div>
{/if}
<style>
.dashboard {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
header h1 {
margin: 0;
}
.logout {
padding: 0.5rem 1rem;
background: transparent;
@@ -123,45 +107,37 @@
cursor: pointer;
color: var(--text-primary);
}
.logout:hover {
background: var(--bg-secondary);
}
section {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
section h2 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
dt {
font-weight: 500;
color: var(--text-secondary);
}
dd {
margin: 0;
}
.mono {
font-family: monospace;
font-size: 0.875rem;
word-break: break-all;
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
@@ -169,23 +145,19 @@
font-size: 0.75rem;
margin-left: 0.5rem;
}
.badge.success {
background: var(--success-bg);
color: var(--success-text);
}
.badge.warning {
background: var(--warning-bg);
color: var(--warning-text);
}
.nav-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.nav-card {
display: block;
padding: 1.5rem;
@@ -196,23 +168,19 @@
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.nav-card:hover {
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
}
.nav-card h3 {
margin: 0 0 0.5rem 0;
color: var(--accent);
}
.nav-card p {
margin: 0;
color: var(--text-secondary);
font-size: 0.875rem;
}
.loading {
text-align: center;
padding: 4rem;
-52
View File
@@ -2,33 +2,26 @@
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, type InviteCode, ApiError } from '../lib/api'
const auth = getAuthState()
let codes = $state<InviteCode[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
let creating = $state(false)
let createdCode = $state<string | null>(null)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
$effect(() => {
if (auth.session) {
loadCodes()
}
})
async function loadCodes() {
if (!auth.session) return
loading = true
error = null
try {
const result = await api.getAccountInviteCodes(auth.session.accessJwt)
codes = result.codes
@@ -38,13 +31,10 @@
loading = false
}
}
async function handleCreate() {
if (!auth.session) return
creating = true
error = null
try {
const result = await api.createInviteCode(auth.session.accessJwt, 1)
createdCode = result.code
@@ -55,30 +45,24 @@
creating = false
}
}
function dismissCreated() {
createdCode = null
}
function copyCode(code: string) {
navigator.clipboard.writeText(code)
}
</script>
<div class="page">
<header>
<a href="#/dashboard" class="back">&larr; Dashboard</a>
<h1>Invite Codes</h1>
</header>
<p class="description">
Invite codes let you invite friends to join. Each code can be used once.
</p>
{#if error}
<div class="error">{error}</div>
{/if}
{#if createdCode}
<div class="created-code">
<h3>Invite Code Created</h3>
@@ -89,16 +73,13 @@
<button onclick={dismissCreated}>Done</button>
</div>
{/if}
<section class="create-section">
<button onclick={handleCreate} disabled={creating}>
{creating ? 'Creating...' : 'Create New Invite Code'}
</button>
</section>
<section class="list-section">
<h2>Your Invite Codes</h2>
{#if loading}
<p class="empty">Loading...</p>
{:else if codes.length === 0}
@@ -129,37 +110,30 @@
{/if}
</section>
</div>
<style>
.page {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 1rem;
}
.back {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
}
.back:hover {
color: var(--accent);
}
h1 {
margin: 0.5rem 0 0 0;
}
.description {
color: var(--text-secondary);
margin-bottom: 2rem;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
@@ -168,7 +142,6 @@
color: var(--error-text);
margin-bottom: 1rem;
}
.created-code {
padding: 1.5rem;
background: var(--success-bg);
@@ -176,12 +149,10 @@
border-radius: 8px;
margin-bottom: 2rem;
}
.created-code h3 {
margin: 0 0 1rem 0;
color: var(--success-text);
}
.code-display {
display: flex;
align-items: center;
@@ -191,13 +162,11 @@
border-radius: 4px;
margin-bottom: 1rem;
}
.code-display code {
font-size: 1.125rem;
font-family: monospace;
flex: 1;
}
.copy {
padding: 0.5rem 1rem;
background: var(--accent);
@@ -206,15 +175,12 @@
border-radius: 4px;
cursor: pointer;
}
.copy:hover {
background: var(--accent-hover);
}
.create-section {
margin-bottom: 2rem;
}
.create-section button {
padding: 0.75rem 1.5rem;
background: var(--accent);
@@ -224,27 +190,22 @@
cursor: pointer;
font-size: 1rem;
}
.create-section button:hover:not(:disabled) {
background: var(--accent-hover);
}
.create-section button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
section h2 {
font-size: 1.125rem;
margin: 0 0 1rem 0;
}
.code-list {
list-style: none;
padding: 0;
margin: 0;
}
.code-list li {
padding: 1rem;
border: 1px solid var(--border-color);
@@ -252,27 +213,22 @@
margin-bottom: 0.5rem;
background: var(--bg-card);
}
.code-list li.disabled {
opacity: 0.6;
}
.code-list li.used {
background: var(--bg-secondary);
}
.code-main {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.code-main code {
font-family: monospace;
font-size: 0.9rem;
}
.copy-small {
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
@@ -282,42 +238,34 @@
cursor: pointer;
color: var(--text-primary);
}
.copy-small:hover {
background: var(--bg-input-disabled);
}
.code-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
}
.date {
color: var(--text-secondary);
}
.status {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.status.available {
background: var(--success-bg);
color: var(--success-text);
}
.status.used {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.status.disabled {
background: var(--error-bg);
color: var(--error-text);
}
.empty {
color: var(--text-secondary);
text-align: center;
-45
View File
@@ -2,33 +2,26 @@
import { login, confirmSignup, resendVerification, getAuthState } 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)
const auth = getAuthState()
$effect(() => {
if (auth.session) {
navigate('/dashboard')
}
})
async function handleSubmit(e: Event) {
e.preventDefault()
if (!identifier || !password) return
submitting = true
error = null
pendingVerification = null
try {
await login(identifier, password)
navigate('/dashboard')
@@ -46,15 +39,11 @@
submitting = false
}
}
async function handleVerification(e: Event) {
e.preventDefault()
if (!pendingVerification || !verificationCode.trim()) return
submitting = true
error = null
try {
await confirmSignup(pendingVerification.did, verificationCode.trim())
navigate('/dashboard')
@@ -64,14 +53,11 @@
submitting = false
}
}
async function handleResendCode() {
if (!pendingVerification || resendingCode) return
resendingCode = true
resendMessage = null
error = null
try {
await resendVerification(pendingVerification.did)
resendMessage = 'Verification code resent!'
@@ -81,7 +67,6 @@
resendingCode = false
}
}
function backToLogin() {
pendingVerification = null
verificationCode = ''
@@ -89,22 +74,18 @@
resendMessage = null
}
</script>
<div class="login-container">
{#if error}
<div class="error">{error}</div>
{/if}
{#if pendingVerification}
<h1>Verify Your Account</h1>
<p class="subtitle">
Your account needs verification. Enter the code sent to your verification method.
</p>
{#if resendMessage}
<div class="success">{resendMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
<div class="field">
<label for="verification-code">Verification Code</label>
@@ -120,15 +101,12 @@
autocomplete="one-time-code"
/>
</div>
<button type="submit" disabled={submitting || !verificationCode.trim()}>
{submitting ? 'Verifying...' : 'Verify Account'}
</button>
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
{resendingCode ? 'Resending...' : 'Resend Code'}
</button>
<button type="button" class="tertiary" onclick={backToLogin}>
Back to Login
</button>
@@ -136,7 +114,6 @@
{: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>
@@ -149,7 +126,6 @@
required
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
@@ -161,51 +137,42 @@
required
/>
</div>
<button type="submit" disabled={submitting || !identifier || !password}>
{submitting ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p class="register-link">
Don't have an account? <a href="#/register">Create one</a>
</p>
{/if}
</div>
<style>
.login-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);
@@ -214,12 +181,10 @@
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
padding: 0.75rem;
background: var(--accent);
@@ -230,37 +195,30 @@
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(--accent);
border: 1px solid var(--accent);
}
button.secondary:hover:not(:disabled) {
background: var(--accent);
color: white;
}
button.tertiary {
background: transparent;
color: var(--text-secondary);
border: none;
}
button.tertiary:hover:not(:disabled) {
color: var(--text-primary);
}
.error {
padding: 0.75rem;
background: var(--error-bg);
@@ -268,7 +226,6 @@
border-radius: 4px;
color: var(--error-text);
}
.success {
padding: 0.75rem;
background: var(--success-bg);
@@ -276,13 +233,11 @@
border-radius: 4px;
color: var(--success-text);
}
.register-link {
text-align: center;
margin-top: 1.5rem;
color: var(--text-secondary);
}
.register-link a {
color: var(--accent);
}
-61
View File
@@ -2,14 +2,11 @@
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 saving = $state(false)
let error = $state<string | null>(null)
let success = $state<string | null>(null)
let preferredChannel = $state('email')
let email = $state('')
let discordId = $state('')
@@ -18,24 +15,20 @@
let telegramVerified = $state(false)
let signalNumber = $state('')
let signalVerified = $state(false)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
$effect(() => {
if (auth.session) {
loadPrefs()
}
})
async function loadPrefs() {
if (!auth.session) return
loading = true
error = null
try {
const prefs = await api.getNotificationPrefs(auth.session.accessJwt)
preferredChannel = prefs.preferredChannel
@@ -52,15 +45,12 @@
loading = false
}
}
async function handleSave(e: Event) {
e.preventDefault()
if (!auth.session) return
saving = true
error = null
success = null
try {
await api.updateNotificationPrefs(auth.session.accessJwt, {
preferredChannel,
@@ -76,14 +66,12 @@
saving = false
}
}
const channels = [
{ id: 'email', name: 'Email', description: 'Receive notifications via email' },
{ id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
{ id: 'telegram', name: 'Telegram', description: 'Receive notifications via Telegram' },
{ id: 'signal', name: 'Signal', description: 'Receive notifications via Signal' },
]
function canSelectChannel(channelId: string): boolean {
if (channelId === 'email') return true
if (channelId === 'discord') return !!discordId
@@ -92,36 +80,30 @@
return false
}
</script>
<div class="page">
<header>
<a href="#/dashboard" class="back">&larr; Dashboard</a>
<h1>Notification Preferences</h1>
</header>
<p class="description">
Choose how you want to receive important notifications like password resets,
security alerts, and account updates.
</p>
{#if loading}
<p class="loading">Loading...</p>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
{#if success}
<div class="message success">{success}</div>
{/if}
<form onsubmit={handleSave}>
<section>
<h2>Preferred Channel</h2>
<p class="section-description">
Select your preferred way to receive notifications. You must configure a channel before you can select it.
</p>
<div class="channel-options">
{#each channels as channel}
<label class="channel-option" class:disabled={!canSelectChannel(channel.id)}>
@@ -143,10 +125,8 @@
{/each}
</div>
</section>
<section>
<h2>Channel Configuration</h2>
<div class="channel-config">
<div class="config-item">
<label for="email">Email</label>
@@ -162,7 +142,6 @@
</div>
<p class="config-hint">Your email is managed in Account Settings</p>
</div>
<div class="config-item">
<label for="discord">Discord User ID</label>
<div class="config-input">
@@ -183,7 +162,6 @@
</div>
<p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
</div>
<div class="config-item">
<label for="telegram">Telegram Username</label>
<div class="config-input">
@@ -204,7 +182,6 @@
</div>
<p class="config-hint">Your Telegram username without the @ symbol</p>
</div>
<div class="config-item">
<label for="signal">Signal Phone Number</label>
<div class="config-input">
@@ -227,7 +204,6 @@
</div>
</div>
</section>
<div class="actions">
<button type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save Preferences'}
@@ -236,85 +212,70 @@
</form>
{/if}
</div>
<style>
.page {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 1rem;
}
.back {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
}
.back:hover {
color: var(--accent);
}
h1 {
margin: 0.5rem 0 0 0;
}
.description {
color: var(--text-secondary);
margin-bottom: 2rem;
}
.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);
}
.message.success {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
section {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
section h2 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
}
.section-description {
color: var(--text-secondary);
font-size: 0.875rem;
margin: 0 0 1rem 0;
}
.channel-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.channel-option {
display: flex;
align-items: flex-start;
@@ -326,64 +287,52 @@
cursor: pointer;
transition: border-color 0.15s;
}
.channel-option:hover:not(.disabled) {
border-color: var(--accent);
}
.channel-option.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.channel-option input {
margin-top: 0.25rem;
}
.channel-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.channel-name {
font-weight: 500;
}
.channel-description {
font-size: 0.875rem;
color: var(--text-secondary);
}
.channel-hint {
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
}
.channel-config {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.config-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.config-item label {
font-size: 0.875rem;
font-weight: 500;
}
.config-input {
display: flex;
align-items: center;
gap: 0.5rem;
}
.config-input input {
flex: 1;
padding: 0.75rem;
@@ -393,45 +342,37 @@
background: var(--bg-input);
color: var(--text-primary);
}
.config-input input:focus {
outline: none;
border-color: var(--accent);
}
.config-input input.readonly {
background: var(--bg-input-disabled);
color: var(--text-secondary);
}
.status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
}
.status.verified {
background: var(--success-bg);
color: var(--success-text);
}
.status.unverified {
background: var(--warning-bg);
color: var(--warning-text);
}
.config-hint {
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0;
}
.actions {
display: flex;
justify-content: flex-end;
}
.actions button {
padding: 0.75rem 2rem;
background: var(--accent);
@@ -441,11 +382,9 @@
font-size: 1rem;
cursor: pointer;
}
.actions button:hover:not(:disabled) {
background: var(--accent-hover);
}
.actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
-63
View File
@@ -2,7 +2,6 @@
import { register, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError, type VerificationChannel } from '../lib/api'
let handle = $state('')
let email = $state('')
let password = $state('')
@@ -14,34 +13,28 @@
let signalNumber = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
let pendingVerification = $state<{ did: string; handle: string; channel: string } | null>(null)
let verificationCode = $state('')
let resendingCode = $state(false)
let resendMessage = $state<string | null>(null)
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
const auth = getAuthState()
$effect(() => {
if (auth.session) {
navigate('/dashboard')
}
})
$effect(() => {
if (!serverInfoLoaded) {
serverInfoLoaded = true
loadServerInfo()
}
})
async function loadServerInfo() {
try {
serverInfo = await api.describeServer()
@@ -51,7 +44,6 @@
loadingServerInfo = false
}
}
function validateForm(): string | null {
if (!handle.trim()) return 'Handle is required'
if (!password) return 'Password is required'
@@ -76,22 +68,18 @@
}
return null
}
async function handleSubmit(e: Event) {
e.preventDefault()
console.log('[Register] handleSubmit called')
const validationError = validateForm()
if (validationError) {
console.log('[Register] validation error:', validationError)
error = validationError
return
}
submitting = true
error = null
console.log('[Register] starting registration...')
try {
const result = await register({
handle: handle.trim(),
@@ -104,7 +92,6 @@
signalNumber: signalNumber.trim() || undefined,
})
console.log('[Register] registration result:', result)
if (result.verificationRequired) {
console.log('[Register] setting pendingVerification')
pendingVerification = {
@@ -131,15 +118,11 @@
console.log('[Register] finished, submitting=false')
}
}
async function handleVerification(e: Event) {
e.preventDefault()
if (!pendingVerification || !verificationCode.trim()) return
submitting = true
error = null
try {
await confirmSignup(pendingVerification.did, verificationCode.trim())
navigate('/dashboard')
@@ -149,14 +132,11 @@
submitting = false
}
}
async function handleResendCode() {
if (!pendingVerification || resendingCode) return
resendingCode = true
resendMessage = null
error = null
try {
await resendVerification(pendingVerification.did)
resendMessage = 'Verification code resent!'
@@ -166,7 +146,6 @@
resendingCode = false
}
}
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
if (handle.includes('.')) return handle.trim()
@@ -174,7 +153,6 @@
if (domain) return `${handle.trim()}.${domain}`
return handle.trim()
})
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return 'Email'
@@ -185,25 +163,20 @@
}
}
</script>
<div class="register-container">
{#if error}
<div class="error">{error}</div>
{/if}
{#if pendingVerification}
<h1>Verify Your Account</h1>
<p class="subtitle">
We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
Enter it below to complete registration.
</p>
{#if resendMessage}
<div class="success">{resendMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
<div class="field">
<label for="verification-code">Verification Code</label>
<input
@@ -218,11 +191,9 @@
autocomplete="one-time-code"
/>
</div>
<button type="submit" disabled={submitting || !verificationCode.trim()}>
{submitting ? 'Verifying...' : 'Verify Account'}
</button>
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
{resendingCode ? 'Resending...' : 'Resend Code'}
</button>
@@ -230,7 +201,6 @@
{:else}
<h1>Create Account</h1>
<p class="subtitle">Create a new account on this PDS</p>
{#if loadingServerInfo}
<p class="loading">Loading...</p>
{:else}
@@ -249,7 +219,6 @@
<p class="hint">Your full handle will be: @{fullHandle()}</p>
{/if}
</div>
<div class="field">
<label for="password">Password</label>
<input
@@ -262,7 +231,6 @@
minlength="8"
/>
</div>
<div class="field">
<label for="confirm-password">Confirm Password</label>
<input
@@ -274,11 +242,9 @@
required
/>
</div>
<fieldset class="verification-section">
<legend>Contact Method</legend>
<p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
<div class="field">
<label for="verification-channel">Verification Method</label>
<select
@@ -292,7 +258,6 @@
<option value="signal">Signal</option>
</select>
</div>
{#if verificationChannel === 'email'}
<div class="field">
<label for="email">Email Address</label>
@@ -345,7 +310,6 @@
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">Invite Code <span class="required">*</span></label>
@@ -370,70 +334,57 @@
/>
</div>
{/if}
<button type="submit" disabled={submitting}>
{submitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p class="login-link">
Already have an account? <a href="#/login">Sign in</a>
</p>
{/if}
{/if}
</div>
<style>
.register-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;
}
.loading {
text-align: center;
color: var(--text-secondary);
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field.optional {
opacity: 0.8;
}
label {
font-size: 0.875rem;
font-weight: 500;
}
.required {
color: var(--error-text);
}
.optional-label {
color: var(--text-secondary);
font-weight: normal;
}
input, select {
padding: 0.75rem;
border: 1px solid var(--border-color-light);
@@ -442,37 +393,31 @@
background: var(--bg-input);
color: var(--text-primary);
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
}
.hint {
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0.25rem 0 0 0;
}
.verification-section {
border: 1px solid var(--border-color-light);
border-radius: 6px;
padding: 1rem;
margin: 0.5rem 0;
}
.verification-section legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text-primary);
}
.section-hint {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0 0 1rem 0;
}
button {
padding: 0.75rem;
background: var(--accent);
@@ -483,27 +428,22 @@
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(--accent);
border: 1px solid var(--accent);
}
button.secondary:hover:not(:disabled) {
background: var(--accent);
color: white;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
@@ -511,7 +451,6 @@
border-radius: 4px;
color: var(--error-text);
}
.success {
padding: 0.75rem;
background: var(--success-bg);
@@ -519,13 +458,11 @@
border-radius: 4px;
color: var(--success-text);
}
.login-link {
text-align: center;
margin-top: 1.5rem;
color: var(--text-secondary);
}
.login-link a {
color: var(--accent);
}
-118
View File
@@ -2,23 +2,18 @@
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
const auth = getAuthState()
type View = 'collections' | 'records' | 'record' | 'create'
let view = $state<View>('collections')
let collections = $state<string[]>([])
let selectedCollection = $state<string | null>(null)
let records = $state<Array<{ uri: string; cid: string; value: unknown; rkey: string }>>([])
let recordsCursor = $state<string | undefined>(undefined)
let selectedRecord = $state<{ uri: string; cid: string; value: unknown; rkey: string } | null>(null)
let loading = $state(true)
let loadingMore = $state(false)
let error = $state<{ code?: string; message: string } | null>(null)
let success = $state<string | null>(null)
function setError(e: unknown) {
if (e instanceof ApiError) {
error = { code: e.error, message: e.message }
@@ -28,32 +23,26 @@
error = { message: 'An unknown error occurred' }
}
}
let newCollection = $state('')
let newRkey = $state('')
let recordJson = $state('')
let jsonError = $state<string | null>(null)
let saving = $state(false)
let filter = $state('')
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
$effect(() => {
if (auth.session) {
loadCollections()
}
})
async function loadCollections() {
if (!auth.session) return
loading = true
error = null
try {
const result = await api.describeRepo(auth.session.accessJwt, auth.session.did)
collections = result.collections.sort()
@@ -63,7 +52,6 @@
loading = false
}
}
async function selectCollection(collection: string) {
if (!auth.session) return
selectedCollection = collection
@@ -72,7 +60,6 @@
view = 'records'
loading = true
error = null
try {
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 })
records = result.records.map(r => ({
@@ -86,11 +73,9 @@
loading = false
}
}
async function loadMoreRecords() {
if (!auth.session || !selectedCollection || !recordsCursor) return
loadingMore = true
try {
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
limit: 50,
@@ -107,18 +92,15 @@
loadingMore = false
}
}
async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) {
selectedRecord = record
recordJson = JSON.stringify(record.value, null, 2)
jsonError = null
view = 'record'
}
function startCreate(collection?: string) {
newCollection = collection || 'app.bsky.feed.post'
newRkey = ''
const exampleRecords: Record<string, unknown> = {
'app.bsky.feed.post': {
$type: 'app.bsky.feed.post',
@@ -144,16 +126,13 @@
createdAt: new Date().toISOString(),
},
}
const example = exampleRecords[collection || 'app.bsky.feed.post'] || {
$type: collection || 'app.bsky.feed.post',
}
recordJson = JSON.stringify(example, null, 2)
jsonError = null
view = 'create'
}
function validateJson(): unknown | null {
try {
const parsed = JSON.parse(recordJson)
@@ -164,22 +143,17 @@
return null
}
}
async function handleCreate(e: Event) {
e.preventDefault()
if (!auth.session) return
const record = validateJson()
if (!record) return
if (!newCollection.trim()) {
error = { message: 'Collection is required' }
return
}
saving = true
error = null
try {
const result = await api.createRecord(
auth.session.accessJwt,
@@ -197,17 +171,13 @@
saving = false
}
}
async function handleUpdate(e: Event) {
e.preventDefault()
if (!auth.session || !selectedRecord || !selectedCollection) return
const record = validateJson()
if (!record) return
saving = true
error = null
try {
await api.putRecord(
auth.session.accessJwt,
@@ -231,14 +201,11 @@
saving = false
}
}
async function handleDelete() {
if (!auth.session || !selectedRecord || !selectedCollection) return
if (!confirm(`Delete record ${selectedRecord.rkey}? This cannot be undone.`)) return
saving = true
error = null
try {
await api.deleteRecord(
auth.session.accessJwt,
@@ -255,7 +222,6 @@
saving = false
}
}
function goBack() {
if (view === 'record' || view === 'create') {
if (selectedCollection) {
@@ -270,13 +236,11 @@
error = null
success = null
}
let filteredCollections = $derived(
filter
? collections.filter(c => c.toLowerCase().includes(filter.toLowerCase()))
: collections
)
let filteredRecords = $derived(
filter
? records.filter(r =>
@@ -285,7 +249,6 @@
)
: records
)
function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>()
for (const col of cols) {
@@ -299,10 +262,8 @@
}
return groups
}
let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections))
</script>
<div class="page">
<header>
<div class="breadcrumb">
@@ -337,7 +298,6 @@
<p class="did">{auth.session.did}</p>
{/if}
</header>
{#if error}
<div class="message error">
{#if error.code}
@@ -346,11 +306,9 @@
<span class="error-message">{error.message}</span>
</div>
{/if}
{#if success}
<div class="message success">{success}</div>
{/if}
{#if loading}
<p class="loading-text">Loading...</p>
{:else if view === 'collections'}
@@ -363,7 +321,6 @@
/>
<button class="primary" onclick={() => startCreate()}>Create Record</button>
</div>
{#if collections.length === 0}
<p class="empty">No collections yet. Create your first record to get started.</p>
{:else}
@@ -385,7 +342,6 @@
{/each}
</div>
{/if}
{:else if view === 'records'}
<div class="toolbar">
<input
@@ -396,7 +352,6 @@
/>
<button class="primary" onclick={() => startCreate(selectedCollection!)}>Create Record</button>
</div>
{#if records.length === 0}
<p class="empty">No records in this collection.</p>
{:else}
@@ -413,7 +368,6 @@
</li>
{/each}
</ul>
{#if recordsCursor}
<div class="load-more">
<button onclick={loadMoreRecords} disabled={loadingMore}>
@@ -422,7 +376,6 @@
</div>
{/if}
{/if}
{:else if view === 'record' && selectedRecord}
<div class="record-detail">
<div class="record-meta">
@@ -433,7 +386,6 @@
<dd class="mono">{selectedRecord.cid}</dd>
</dl>
</div>
<form onsubmit={handleUpdate}>
<div class="editor-container">
<label for="record-json">Record JSON</label>
@@ -448,7 +400,6 @@
<p class="json-error">{jsonError}</p>
{/if}
</div>
<div class="actions">
<button type="submit" class="primary" disabled={saving || !!jsonError}>
{saving ? 'Saving...' : 'Update Record'}
@@ -459,7 +410,6 @@
</div>
</form>
</div>
{:else if view === 'create'}
<form class="create-form" onsubmit={handleCreate}>
<div class="field">
@@ -473,7 +423,6 @@
required
/>
</div>
<div class="field">
<label for="rkey">Record Key (optional)</label>
<input
@@ -485,7 +434,6 @@
/>
<p class="hint">Leave empty to auto-generate a TID-based key</p>
</div>
<div class="editor-container">
<label for="new-record-json">Record JSON</label>
<textarea
@@ -499,7 +447,6 @@
<p class="json-error">{jsonError}</p>
{/if}
</div>
<div class="actions">
<button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}>
{saving ? 'Creating...' : 'Create Record'}
@@ -511,18 +458,15 @@
</form>
{/if}
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 1.5rem;
}
.breadcrumb {
display: flex;
align-items: center;
@@ -530,20 +474,16 @@
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.back {
color: var(--text-secondary);
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
.sep {
color: var(--text-muted);
}
.breadcrumb-link {
background: none;
border: none;
@@ -552,20 +492,16 @@
cursor: pointer;
font-size: inherit;
}
.breadcrumb-link:hover {
text-decoration: underline;
}
.current {
color: var(--text-secondary);
}
h1 {
margin: 0;
font-size: 1.5rem;
}
.did {
margin: 0.25rem 0 0 0;
font-family: monospace;
@@ -573,13 +509,11 @@
color: var(--text-muted);
word-break: break-all;
}
.message {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.message.error {
background: var(--error-bg);
border: 1px solid var(--error-border);
@@ -588,36 +522,30 @@
flex-direction: column;
gap: 0.25rem;
}
.error-code {
font-family: monospace;
font-size: 0.875rem;
opacity: 0.9;
}
.error-message {
font-size: 0.9375rem;
line-height: 1.5;
}
.message.success {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.loading-text {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filter-input {
flex: 1;
padding: 0.5rem 0.75rem;
@@ -627,12 +555,10 @@
background: var(--bg-input);
color: var(--text-primary);
}
.filter-input:focus {
outline: none;
border-color: var(--accent);
}
button.primary {
padding: 0.5rem 1rem;
background: var(--accent);
@@ -642,16 +568,13 @@
cursor: pointer;
font-size: 0.875rem;
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.secondary {
padding: 0.5rem 1rem;
background: transparent;
@@ -661,11 +584,9 @@
cursor: pointer;
font-size: 0.875rem;
}
button.secondary:hover:not(:disabled) {
background: var(--bg-secondary);
}
button.danger {
padding: 0.5rem 1rem;
background: transparent;
@@ -675,11 +596,9 @@
cursor: pointer;
font-size: 0.875rem;
}
button.danger:hover:not(:disabled) {
background: var(--error-bg);
}
.empty {
text-align: center;
color: var(--text-secondary);
@@ -687,26 +606,22 @@
background: var(--bg-secondary);
border-radius: 8px;
}
.collections {
display: flex;
flex-direction: column;
gap: 1rem;
}
.collection-group {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1rem;
}
.authority {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.nsid-list {
list-style: none;
padding: 0;
@@ -715,7 +630,6 @@
flex-direction: column;
gap: 0.25rem;
}
.collection-link {
display: flex;
justify-content: space-between;
@@ -730,20 +644,16 @@
color: var(--text-primary);
transition: border-color 0.15s;
}
.collection-link:hover {
border-color: var(--accent);
}
.nsid {
font-weight: 500;
color: var(--accent);
}
.arrow {
color: var(--text-muted);
}
.record-list {
list-style: none;
padding: 0;
@@ -752,7 +662,6 @@
flex-direction: column;
gap: 0.5rem;
}
.record-item {
display: block;
width: 100%;
@@ -765,29 +674,24 @@
color: var(--text-primary);
transition: border-color 0.15s;
}
.record-item:hover {
border-color: var(--accent);
}
.record-info {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.rkey {
font-family: monospace;
font-weight: 500;
color: var(--accent);
}
.cid {
font-family: monospace;
font-size: 0.75rem;
color: var(--text-muted);
}
.record-preview {
margin: 0;
padding: 0.5rem;
@@ -801,12 +705,10 @@
max-height: 100px;
overflow: hidden;
}
.load-more {
text-align: center;
padding: 1rem;
}
.load-more button {
padding: 0.5rem 2rem;
background: var(--bg-secondary);
@@ -815,56 +717,46 @@
cursor: pointer;
color: var(--text-primary);
}
.load-more button:hover:not(:disabled) {
background: var(--bg-card);
}
.record-detail {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.record-meta {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
}
.record-meta dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
.record-meta dt {
font-weight: 500;
color: var(--text-secondary);
}
.record-meta dd {
margin: 0;
}
.mono {
font-family: monospace;
font-size: 0.75rem;
word-break: break-all;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.field input {
width: 100%;
padding: 0.75rem;
@@ -875,29 +767,24 @@
color: var(--text-primary);
box-sizing: border-box;
}
.field input:focus {
outline: none;
border-color: var(--accent);
}
.hint {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0.25rem 0 0 0;
}
.editor-container {
margin-bottom: 1rem;
}
.editor-container label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
textarea {
width: 100%;
min-height: 300px;
@@ -911,27 +798,22 @@
resize: vertical;
box-sizing: border-box;
}
textarea:focus {
outline: none;
border-color: var(--accent);
}
textarea.has-error {
border-color: var(--error-text);
}
.json-error {
margin: 0.25rem 0 0 0;
font-size: 0.75rem;
color: var(--error-text);
}
.actions {
display: flex;
gap: 0.5rem;
}
.create-form {
background: var(--bg-secondary);
padding: 1.5rem;
-57
View File
@@ -2,44 +2,34 @@
import { getAuthState, logout } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
const auth = getAuthState()
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let emailLoading = $state(false)
let newEmail = $state('')
let emailToken = $state('')
let emailTokenRequired = $state(false)
let handleLoading = $state(false)
let newHandle = $state('')
let deleteLoading = $state(false)
let deletePassword = $state('')
let deleteToken = $state('')
let deleteTokenSent = $state(false)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
async function handleRequestEmailUpdate(e: Event) {
e.preventDefault()
if (!auth.session || !newEmail) return
emailLoading = true
message = null
try {
const result = await api.requestEmailUpdate(auth.session.accessJwt)
emailTokenRequired = result.tokenRequired
@@ -56,14 +46,11 @@
emailLoading = false
}
}
async function handleConfirmEmailUpdate(e: Event) {
e.preventDefault()
if (!auth.session || !newEmail || !emailToken) return
emailLoading = true
message = null
try {
await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
showMessage('success', 'Email updated successfully')
@@ -76,14 +63,11 @@
emailLoading = false
}
}
async function handleUpdateHandle(e: Event) {
e.preventDefault()
if (!auth.session || !newHandle) return
handleLoading = true
message = null
try {
await api.updateHandle(auth.session.accessJwt, newHandle)
showMessage('success', 'Handle updated successfully')
@@ -94,13 +78,10 @@
handleLoading = false
}
}
async function handleRequestDelete() {
if (!auth.session) return
deleteLoading = true
message = null
try {
await api.requestAccountDelete(auth.session.accessJwt)
deleteTokenSent = true
@@ -111,18 +92,14 @@
deleteLoading = false
}
}
async function handleConfirmDelete(e: Event) {
e.preventDefault()
if (!auth.session || !deletePassword || !deleteToken) return
if (!confirm('Are you absolutely sure you want to delete your account? This cannot be undone.')) {
return
}
deleteLoading = true
message = null
try {
await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
await logout()
@@ -134,23 +111,19 @@
}
}
</script>
<div class="page">
<header>
<a href="#/dashboard" class="back">&larr; Dashboard</a>
<h1>Account Settings</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
<section>
<h2>Change Email</h2>
{#if auth.session?.email}
<p class="current">Current: {auth.session.email}</p>
{/if}
{#if emailTokenRequired}
<form onsubmit={handleConfirmEmailUpdate}>
<div class="field">
@@ -192,13 +165,11 @@
</form>
{/if}
</section>
<section>
<h2>Change Handle</h2>
{#if auth.session}
<p class="current">Current: @{auth.session.handle}</p>
{/if}
<form onsubmit={handleUpdateHandle}>
<div class="field">
<label for="new-handle">New Handle</label>
@@ -216,11 +187,9 @@
</button>
</form>
</section>
<section class="danger-zone">
<h2>Delete Account</h2>
<p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
{#if deleteTokenSent}
<form onsubmit={handleConfirmDelete}>
<div class="field">
@@ -261,79 +230,65 @@
{/if}
</section>
</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;
}
.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);
}
section {
padding: 1.5rem;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 1.5rem;
}
section h2 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
}
.current {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
input {
width: 100%;
padding: 0.75rem;
@@ -344,12 +299,10 @@
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
padding: 0.75rem 1.5rem;
background: var(--accent);
@@ -359,48 +312,38 @@
cursor: pointer;
font-size: 1rem;
}
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);
}
button.danger {
background: var(--error-text);
}
button.danger:hover:not(:disabled) {
background: #900;
}
.actions {
display: flex;
gap: 0.5rem;
}
.danger-zone {
background: var(--error-bg);
border: 1px solid var(--error-border);
}
.danger-zone h2 {
color: var(--error-text);
}
.warning {
color: var(--error-text);
font-size: 0.875rem;
-101
View File
@@ -11,25 +11,21 @@ import {
setupAuthenticatedUser,
setupUnauthenticatedUser,
} from './mocks'
describe('AppPasswords', () => {
beforeEach(() => {
clearMocks()
setupFetchMock()
window.confirm = vi.fn(() => true)
})
describe('authentication guard', () => {
it('redirects to login when not authenticated', async () => {
setupUnauthenticatedUser()
render(AppPasswords)
await waitFor(() => {
expect(window.location.hash).toBe('#/login')
})
})
})
describe('page structure', () => {
beforeEach(() => {
setupAuthenticatedUser()
@@ -37,10 +33,8 @@ describe('AppPasswords', () => {
jsonResponse({ passwords: [] })
)
})
it('displays all page elements', async () => {
render(AppPasswords)
await waitFor(() => {
expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
@@ -48,24 +42,19 @@ describe('AppPasswords', () => {
})
})
})
describe('loading state', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('shows loading text while fetching passwords', async () => {
mockEndpoint('com.atproto.server.listAppPasswords', async () => {
await new Promise(resolve => setTimeout(resolve, 100))
return jsonResponse({ passwords: [] })
})
render(AppPasswords)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
describe('empty state', () => {
beforeEach(() => {
setupAuthenticatedUser()
@@ -73,32 +62,26 @@ describe('AppPasswords', () => {
jsonResponse({ passwords: [] })
)
})
it('shows empty message when no passwords exist', async () => {
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
})
})
})
describe('password list', () => {
const testPasswords = [
mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }),
mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }),
]
beforeEach(() => {
setupAuthenticatedUser()
mockEndpoint('com.atproto.server.listAppPasswords', () =>
jsonResponse({ passwords: testPasswords })
)
})
it('displays all app passwords with dates and revoke buttons', async () => {
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText('Graysky')).toBeInTheDocument()
expect(screen.getByText('Skeets')).toBeInTheDocument()
@@ -108,7 +91,6 @@ describe('AppPasswords', () => {
})
})
})
describe('create app password', () => {
beforeEach(() => {
setupAuthenticatedUser()
@@ -116,39 +98,29 @@ describe('AppPasswords', () => {
jsonResponse({ passwords: [] })
)
})
it('displays create form with input and button', async () => {
render(AppPasswords)
await waitFor(() => {
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument()
})
})
it('disables create button when input is empty', async () => {
render(AppPasswords)
await waitFor(() => {
expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
})
})
it('enables create button when input has value', async () => {
render(AppPasswords)
await waitFor(() => {
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } })
expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
})
it('calls createAppPassword with correct name', async () => {
let capturedName: string | null = null
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
const body = JSON.parse((options?.body as string) || '{}')
capturedName = body.name
@@ -158,21 +130,16 @@ describe('AppPasswords', () => {
createdAt: new Date().toISOString(),
})
})
render(AppPasswords)
await waitFor(() => {
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } })
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => {
expect(capturedName).toBe('Graysky')
})
})
it('shows loading state while creating', async () => {
mockEndpoint('com.atproto.server.createAppPassword', async () => {
await new Promise(resolve => setTimeout(resolve, 100))
@@ -182,20 +149,15 @@ describe('AppPasswords', () => {
createdAt: new Date().toISOString(),
})
})
render(AppPasswords)
await waitFor(() => {
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
})
it('displays created password in success box and clears input', async () => {
mockEndpoint('com.atproto.server.createAppPassword', () =>
jsonResponse({
@@ -204,17 +166,13 @@ describe('AppPasswords', () => {
createdAt: new Date().toISOString(),
})
)
render(AppPasswords)
await waitFor(() => {
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
})
const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement
await fireEvent.input(input, { target: { value: 'MyApp' } })
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => {
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument()
@@ -222,7 +180,6 @@ describe('AppPasswords', () => {
expect(input.value).toBe('')
})
})
it('dismisses created password box when clicking Done', async () => {
mockEndpoint('com.atproto.server.createAppPassword', () =>
jsonResponse({
@@ -231,155 +188,113 @@ describe('AppPasswords', () => {
createdAt: new Date().toISOString(),
})
)
render(AppPasswords)
await waitFor(() => {
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => {
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /done/i }))
await waitFor(() => {
expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument()
})
})
it('shows error when creation fails', async () => {
mockEndpoint('com.atproto.server.createAppPassword', () =>
errorResponse('InvalidRequest', 'Name already exists', 400)
)
render(AppPasswords)
await waitFor(() => {
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } })
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => {
expect(screen.getByText(/name already exists/i)).toBeInTheDocument()
expect(screen.getByText(/name already exists/i)).toHaveClass('error')
})
})
})
describe('revoke app password', () => {
const testPassword = mockData.appPassword({ name: 'TestApp' })
beforeEach(() => {
setupAuthenticatedUser()
})
it('shows confirmation dialog before revoking', async () => {
const confirmSpy = vi.fn(() => false)
window.confirm = confirmSpy
mockEndpoint('com.atproto.server.listAppPasswords', () =>
jsonResponse({ passwords: [testPassword] })
)
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText('TestApp')).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
expect(confirmSpy).toHaveBeenCalledWith(
expect.stringContaining('TestApp')
)
})
it('does not revoke when confirmation is cancelled', async () => {
window.confirm = vi.fn(() => false)
let revokeCalled = false
mockEndpoint('com.atproto.server.listAppPasswords', () =>
jsonResponse({ passwords: [testPassword] })
)
mockEndpoint('com.atproto.server.revokeAppPassword', () => {
revokeCalled = true
return jsonResponse({})
})
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText('TestApp')).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
expect(revokeCalled).toBe(false)
})
it('calls revokeAppPassword with correct name', async () => {
window.confirm = vi.fn(() => true)
let capturedName: string | null = null
mockEndpoint('com.atproto.server.listAppPasswords', () =>
jsonResponse({ passwords: [testPassword] })
)
mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => {
const body = JSON.parse((options?.body as string) || '{}')
capturedName = body.name
return jsonResponse({})
})
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText('TestApp')).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
await waitFor(() => {
expect(capturedName).toBe('TestApp')
})
})
it('shows loading state while revoking', async () => {
window.confirm = vi.fn(() => true)
mockEndpoint('com.atproto.server.listAppPasswords', () =>
jsonResponse({ passwords: [testPassword] })
)
mockEndpoint('com.atproto.server.revokeAppPassword', async () => {
await new Promise(resolve => setTimeout(resolve, 100))
return jsonResponse({})
})
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText('TestApp')).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled()
})
it('reloads password list after successful revocation', async () => {
window.confirm = vi.fn(() => true)
let listCallCount = 0
mockEndpoint('com.atproto.server.listAppPasswords', () => {
listCallCount++
if (listCallCount === 1) {
@@ -387,63 +302,47 @@ describe('AppPasswords', () => {
}
return jsonResponse({ passwords: [] })
})
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
jsonResponse({})
)
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText('TestApp')).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
await waitFor(() => {
expect(screen.queryByText('TestApp')).not.toBeInTheDocument()
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
})
})
it('shows error when revocation fails', async () => {
window.confirm = vi.fn(() => true)
mockEndpoint('com.atproto.server.listAppPasswords', () =>
jsonResponse({ passwords: [testPassword] })
)
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
errorResponse('InternalError', 'Server error', 500)
)
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText('TestApp')).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
await waitFor(() => {
expect(screen.getByText(/server error/i)).toBeInTheDocument()
expect(screen.getByText(/server error/i)).toHaveClass('error')
})
})
})
describe('error handling', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('shows error when loading passwords fails', async () => {
mockEndpoint('com.atproto.server.listAppPasswords', () =>
errorResponse('InternalError', 'Database connection failed', 500)
)
render(AppPasswords)
await waitFor(() => {
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
expect(screen.getByText(/database connection failed/i)).toHaveClass('error')
-27
View File
@@ -10,40 +10,31 @@ import {
setupAuthenticatedUser,
setupUnauthenticatedUser,
} from './mocks'
const STORAGE_KEY = 'bspds_session'
describe('Dashboard', () => {
beforeEach(() => {
clearMocks()
setupFetchMock()
})
describe('authentication guard', () => {
it('redirects to login when not authenticated', async () => {
setupUnauthenticatedUser()
render(Dashboard)
await waitFor(() => {
expect(window.location.hash).toBe('#/login')
})
})
it('shows loading state while checking auth', () => {
render(Dashboard)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
describe('authenticated view', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('displays user account info and page structure', async () => {
render(Dashboard)
await waitFor(() => {
expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument()
@@ -54,20 +45,16 @@ describe('Dashboard', () => {
expect(screen.getByText('Verified')).toHaveClass('badge', 'success')
})
})
it('displays unverified badge when email not confirmed', async () => {
setupAuthenticatedUser({ emailConfirmed: false })
render(Dashboard)
await waitFor(() => {
expect(screen.getByText('Unverified')).toBeInTheDocument()
expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning')
})
})
it('displays all navigation cards', async () => {
render(Dashboard)
await waitFor(() => {
const navCards = [
{ name: /app passwords/i, href: '#/app-passwords' },
@@ -76,7 +63,6 @@ describe('Dashboard', () => {
{ name: /notification preferences/i, href: '#/notifications' },
{ name: /repository explorer/i, href: '#/repo' },
]
for (const { name, href } of navCards) {
const card = screen.getByRole('link', { name })
expect(card).toBeInTheDocument()
@@ -85,51 +71,38 @@ describe('Dashboard', () => {
})
})
})
describe('logout functionality', () => {
beforeEach(() => {
setupAuthenticatedUser()
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()))
mockEndpoint('com.atproto.server.deleteSession', () =>
jsonResponse({})
)
})
it('calls deleteSession and navigates to login on logout', async () => {
let deleteSessionCalled = false
mockEndpoint('com.atproto.server.deleteSession', () => {
deleteSessionCalled = true
return jsonResponse({})
})
render(Dashboard)
await waitFor(() => {
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
await waitFor(() => {
expect(deleteSessionCalled).toBe(true)
expect(window.location.hash).toBe('#/login')
})
})
it('clears session from localStorage after logout', async () => {
const storedSession = localStorage.getItem(STORAGE_KEY)
expect(storedSession).not.toBeNull()
render(Dashboard)
await waitFor(() => {
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
await waitFor(() => {
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
-31
View File
@@ -9,18 +9,15 @@ import {
mockData,
clearMocks,
} from './mocks'
describe('Login', () => {
beforeEach(() => {
clearMocks()
setupFetchMock()
window.location.hash = ''
})
describe('initial render', () => {
it('renders login form with all elements and correct initial state', () => {
render(Login)
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
@@ -30,42 +27,32 @@ describe('Login', () => {
expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register')
})
})
describe('form validation', () => {
it('enables submit button only when both fields are filled', async () => {
render(Login)
const identifierInput = screen.getByLabelText(/handle or email/i)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
expect(submitButton).toBeDisabled()
await fireEvent.input(identifierInput, { target: { value: '' } })
await fireEvent.input(passwordInput, { target: { value: 'password123' } })
expect(submitButton).toBeDisabled()
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
expect(submitButton).not.toBeDisabled()
})
})
describe('login submission', () => {
it('calls createSession with correct credentials', async () => {
let capturedBody: Record<string, string> | null = null
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
capturedBody = JSON.parse((options?.body as string) || '{}')
return jsonResponse(mockData.session())
})
render(Login)
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } })
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } })
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(capturedBody).toEqual({
identifier: 'testuser@example.com',
@@ -73,42 +60,33 @@ describe('Login', () => {
})
})
})
it('shows styled error message on invalid credentials', async () => {
mockEndpoint('com.atproto.server.createSession', () =>
errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
)
render(Login)
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } })
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } })
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
const errorDiv = screen.getByText(/invalid identifier or password/i)
expect(errorDiv).toBeInTheDocument()
expect(errorDiv).toHaveClass('error')
})
})
it('navigates to dashboard on successful login', async () => {
mockEndpoint('com.atproto.server.createSession', () =>
jsonResponse(mockData.session())
)
render(Login)
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(window.location.hash).toBe('#/dashboard')
})
})
})
describe('account verification flow', () => {
it('shows verification form with all controls when account is not verified', async () => {
mockEndpoint('com.atproto.server.createSession', () => ({
@@ -120,13 +98,10 @@ describe('Login', () => {
did: 'did:web:test.bspds.dev:u:testuser',
}),
}))
render(Login)
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } })
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument()
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
@@ -134,7 +109,6 @@ describe('Login', () => {
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
})
})
it('returns to login form when clicking back', async () => {
mockEndpoint('com.atproto.server.createSession', () => ({
ok: false,
@@ -145,19 +119,14 @@ describe('Login', () => {
did: 'did:web:test.bspds.dev:u:testuser',
}),
}))
render(Login)
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /back to login/i }))
await waitFor(() => {
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
-97
View File
@@ -11,24 +11,20 @@ import {
setupAuthenticatedUser,
setupUnauthenticatedUser,
} from './mocks'
describe('Notifications', () => {
beforeEach(() => {
clearMocks()
setupFetchMock()
})
describe('authentication guard', () => {
it('redirects to login when not authenticated', async () => {
setupUnauthenticatedUser()
render(Notifications)
await waitFor(() => {
expect(window.location.hash).toBe('#/login')
})
})
})
describe('page structure', () => {
beforeEach(() => {
setupAuthenticatedUser()
@@ -36,10 +32,8 @@ describe('Notifications', () => {
jsonResponse(mockData.notificationPrefs())
)
})
it('displays all page elements and sections', async () => {
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
@@ -49,36 +43,28 @@ describe('Notifications', () => {
})
})
})
describe('loading state', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('shows loading text while fetching preferences', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', async () => {
await new Promise(resolve => setTimeout(resolve, 100))
return jsonResponse(mockData.notificationPrefs())
})
render(Notifications)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
describe('channel options', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('displays all four channel options', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument()
@@ -86,91 +72,71 @@ describe('Notifications', () => {
expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument()
})
})
it('email channel is always selectable', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
render(Notifications)
await waitFor(() => {
const emailRadio = screen.getByRole('radio', { name: /email/i })
expect(emailRadio).not.toBeDisabled()
})
})
it('discord channel is disabled when not configured', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs({ discordId: null }))
)
render(Notifications)
await waitFor(() => {
const discordRadio = screen.getByRole('radio', { name: /discord/i })
expect(discordRadio).toBeDisabled()
})
})
it('discord channel is enabled when configured', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs({ discordId: '123456789' }))
)
render(Notifications)
await waitFor(() => {
const discordRadio = screen.getByRole('radio', { name: /discord/i })
expect(discordRadio).not.toBeDisabled()
})
})
it('shows hint for disabled channels', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
render(Notifications)
await waitFor(() => {
expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0)
})
})
it('selects current preferred channel', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' }))
)
render(Notifications)
await waitFor(() => {
const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement
expect(emailRadio.checked).toBe(true)
})
})
})
describe('channel configuration', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('displays email as readonly with current value', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
render(Notifications)
await waitFor(() => {
const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement
expect(emailInput).toBeDisabled()
expect(emailInput.value).toBe('test@example.com')
})
})
it('displays all channel inputs with current values', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs({
@@ -179,9 +145,7 @@ describe('Notifications', () => {
signalNumber: '+1234567890',
}))
)
render(Notifications)
await waitFor(() => {
expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789')
expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser')
@@ -189,24 +153,19 @@ describe('Notifications', () => {
})
})
})
describe('verification status badges', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('shows Primary badge for email', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
render(Notifications)
await waitFor(() => {
expect(screen.getByText('Primary')).toBeInTheDocument()
})
})
it('shows Verified badge for verified discord', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs({
@@ -214,15 +173,12 @@ describe('Notifications', () => {
discordVerified: true,
}))
)
render(Notifications)
await waitFor(() => {
const verifiedBadges = screen.getAllByText('Verified')
expect(verifiedBadges.length).toBeGreaterThan(0)
})
})
it('shows Not verified badge for unverified discord', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs({
@@ -230,178 +186,133 @@ describe('Notifications', () => {
discordVerified: false,
}))
)
render(Notifications)
await waitFor(() => {
expect(screen.getByText('Not verified')).toBeInTheDocument()
})
})
it('does not show badge when channel not configured', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
render(Notifications)
await waitFor(() => {
expect(screen.getByText('Primary')).toBeInTheDocument()
expect(screen.queryByText('Not verified')).not.toBeInTheDocument()
})
})
})
describe('save preferences', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('calls updateNotificationPrefs with correct data', async () => {
let capturedBody: Record<string, unknown> | null = null
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
mockEndpoint('com.bspds.account.updateNotificationPrefs', (_url, options) => {
capturedBody = JSON.parse((options?.body as string) || '{}')
return jsonResponse({ success: true })
})
render(Notifications)
await waitFor(() => {
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } })
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
await waitFor(() => {
expect(capturedBody).not.toBeNull()
expect(capturedBody?.discordId).toBe('999888777')
expect(capturedBody?.preferredChannel).toBe('email')
})
})
it('shows loading state while saving', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
mockEndpoint('com.bspds.account.updateNotificationPrefs', async () => {
await new Promise(resolve => setTimeout(resolve, 100))
return jsonResponse({ success: true })
})
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled()
})
it('shows success message after saving', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
jsonResponse({ success: true })
)
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
await waitFor(() => {
expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument()
})
})
it('shows error when save fails', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
errorResponse('InvalidRequest', 'Invalid channel configuration', 400)
)
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
await waitFor(() => {
expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument()
expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error')
})
})
it('reloads preferences after successful save', async () => {
let loadCount = 0
mockEndpoint('com.bspds.account.getNotificationPrefs', () => {
loadCount++
return jsonResponse(mockData.notificationPrefs())
})
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
jsonResponse({ success: true })
)
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
})
const initialLoadCount = loadCount
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
await waitFor(() => {
expect(loadCount).toBeGreaterThan(initialLoadCount)
})
})
})
describe('channel selection interaction', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('enables discord channel after entering discord ID', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled()
})
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } })
await waitFor(() => {
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
})
})
it('allows selecting a configured channel', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs({
@@ -409,32 +320,24 @@ describe('Notifications', () => {
discordVerified: true,
}))
)
render(Notifications)
await waitFor(() => {
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
})
await fireEvent.click(screen.getByRole('radio', { name: /discord/i }))
const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement
expect(discordRadio.checked).toBe(true)
})
})
describe('error handling', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('shows error when loading preferences fails', async () => {
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
errorResponse('InternalError', 'Database connection failed', 500)
)
render(Notifications)
await waitFor(() => {
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
})
-126
View File
@@ -10,33 +10,27 @@ import {
setupAuthenticatedUser,
setupUnauthenticatedUser,
} from './mocks'
describe('Settings', () => {
beforeEach(() => {
clearMocks()
setupFetchMock()
window.confirm = vi.fn(() => true)
})
describe('authentication guard', () => {
it('redirects to login when not authenticated', async () => {
setupUnauthenticatedUser()
render(Settings)
await waitFor(() => {
expect(window.location.hash).toBe('#/login')
})
})
})
describe('page structure', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('displays all page elements and sections', async () => {
render(Settings)
await waitFor(() => {
expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
@@ -46,256 +40,191 @@ describe('Settings', () => {
})
})
})
describe('email change', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('displays current email and input field', async () => {
render(Settings)
await waitFor(() => {
expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
})
})
it('calls requestEmailUpdate when submitting', async () => {
let requestCalled = false
mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
requestCalled = true
return jsonResponse({ tokenRequired: true })
})
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
await waitFor(() => {
expect(requestCalled).toBe(true)
})
})
it('shows verification code input when token is required', async () => {
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
jsonResponse({ tokenRequired: true })
)
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
await waitFor(() => {
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
})
})
it('calls updateEmail with token when confirming', async () => {
let updateCalled = false
let capturedBody: Record<string, string> | null = null
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
jsonResponse({ tokenRequired: true })
)
mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
updateCalled = true
capturedBody = JSON.parse((options?.body as string) || '{}')
return jsonResponse({})
})
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
await waitFor(() => {
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
await waitFor(() => {
expect(updateCalled).toBe(true)
expect(capturedBody?.email).toBe('newemail@example.com')
expect(capturedBody?.token).toBe('123456')
})
})
it('shows success message after email update', async () => {
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
jsonResponse({ tokenRequired: true })
)
mockEndpoint('com.atproto.server.updateEmail', () =>
jsonResponse({})
)
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
await waitFor(() => {
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
await waitFor(() => {
expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
})
})
it('shows cancel button to return to email form', async () => {
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
jsonResponse({ tokenRequired: true })
)
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
await waitFor(() => {
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
})
})
it('shows error when email update fails', async () => {
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
errorResponse('InvalidEmail', 'Invalid email format', 400)
)
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
await waitFor(() => {
expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
})
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
await waitFor(() => {
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
})
})
})
describe('handle change', () => {
beforeEach(() => {
setupAuthenticatedUser()
})
it('displays current handle', async () => {
render(Settings)
await waitFor(() => {
expect(screen.getByText(/current: @testuser\.test\.bspds\.dev/i)).toBeInTheDocument()
})
})
it('calls updateHandle with new handle', async () => {
let capturedHandle: string | null = null
mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
const body = JSON.parse((options?.body as string) || '{}')
capturedHandle = body.handle
return jsonResponse({})
})
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
await waitFor(() => {
expect(capturedHandle).toBe('newhandle.bsky.social')
})
})
it('shows success message after handle change', async () => {
mockEndpoint('com.atproto.identity.updateHandle', () =>
jsonResponse({})
)
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
await waitFor(() => {
expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
})
})
it('shows error when handle change fails', async () => {
mockEndpoint('com.atproto.identity.updateHandle', () =>
errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
)
render(Settings)
await waitFor(() => {
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
await waitFor(() => {
expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
})
})
})
describe('account deletion', () => {
beforeEach(() => {
setupAuthenticatedUser()
@@ -303,211 +232,156 @@ describe('Settings', () => {
jsonResponse({})
)
})
it('displays delete section with warning and request button', async () => {
render(Settings)
await waitFor(() => {
expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
})
it('calls requestAccountDelete when clicking request', async () => {
let requestCalled = false
mockEndpoint('com.atproto.server.requestAccountDelete', () => {
requestCalled = true
return jsonResponse({})
})
render(Settings)
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
await waitFor(() => {
expect(requestCalled).toBe(true)
})
})
it('shows confirmation form after requesting deletion', async () => {
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
jsonResponse({})
)
render(Settings)
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
await waitFor(() => {
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
})
})
it('shows confirmation dialog before final deletion', async () => {
const confirmSpy = vi.fn(() => false)
window.confirm = confirmSpy
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
jsonResponse({})
)
render(Settings)
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
await waitFor(() => {
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
expect(confirmSpy).toHaveBeenCalledWith(
expect.stringContaining('absolutely sure')
)
})
it('calls deleteAccount with correct parameters', async () => {
window.confirm = vi.fn(() => true)
let capturedBody: Record<string, string> | null = null
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
capturedBody = JSON.parse((options?.body as string) || '{}')
return jsonResponse({})
})
render(Settings)
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
await waitFor(() => {
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
await waitFor(() => {
expect(capturedBody?.token).toBe('DEL123')
expect(capturedBody?.password).toBe('mypassword')
expect(capturedBody?.did).toBe('did:web:test.bspds.dev:u:testuser')
})
})
it('navigates to login after successful deletion', async () => {
window.confirm = vi.fn(() => true)
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.deleteAccount', () =>
jsonResponse({})
)
render(Settings)
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
await waitFor(() => {
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
await waitFor(() => {
expect(window.location.hash).toBe('#/login')
})
})
it('shows cancel button to return to request state', async () => {
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
jsonResponse({})
)
render(Settings)
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
await waitFor(() => {
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
expect(cancelButtons.length).toBeGreaterThan(0)
})
const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
const deleteSection = deleteHeading.closest('section')
const cancelButton = deleteSection?.querySelector('button.secondary')
if (cancelButton) {
await fireEvent.click(cancelButton)
}
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
})
it('shows error when deletion fails', async () => {
window.confirm = vi.fn(() => true)
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.deleteAccount', () =>
errorResponse('InvalidToken', 'Invalid confirmation code', 400)
)
render(Settings)
await waitFor(() => {
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
})
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
await waitFor(() => {
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
})
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
await waitFor(() => {
expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
})
-40
View File
@@ -1,21 +1,16 @@
import { vi } from 'vitest'
import type { Session, AppPassword, InviteCode } from '../lib/api'
import { _testSetState } from '../lib/auth.svelte'
export interface MockResponse {
ok: boolean
status: number
json: () => Promise<unknown>
}
export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise<MockResponse>
const mockHandlers: Map<string, MockHandler> = new Map()
export function mockEndpoint(endpoint: string, handler: MockHandler): void {
mockHandlers.set(endpoint, handler)
}
export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
const originalHandler = mockHandlers.get(endpoint)
mockHandlers.set(endpoint, (url, options) => {
@@ -23,21 +18,17 @@ export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
return handler(url, options)
})
}
export function clearMocks(): void {
mockHandlers.clear()
}
function extractEndpoint(url: string): string {
const match = url.match(/\/xrpc\/([^?]+)/)
return match ? match[1] : url
}
export function setupFetchMock(): void {
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string' ? input : input.toString()
const endpoint = extractEndpoint(url)
const handler = mockHandlers.get(endpoint)
if (handler) {
const result = await handler(url, init)
@@ -59,7 +50,6 @@ export function setupFetchMock(): void {
formData: async () => new FormData(),
} as Response
}
return {
ok: false,
status: 404,
@@ -79,7 +69,6 @@ export function setupFetchMock(): void {
} as Response
})
}
export function jsonResponse<T>(data: T, status = 200): MockResponse {
return {
ok: status >= 200 && status < 300,
@@ -87,7 +76,6 @@ export function jsonResponse<T>(data: T, status = 200): MockResponse {
json: async () => data,
}
}
export function errorResponse(error: string, message: string, status = 400): MockResponse {
return {
ok: false,
@@ -95,7 +83,6 @@ export function errorResponse(error: string, message: string, status = 400): Moc
json: async () => ({ error, message }),
}
}
export const mockData = {
session: (overrides?: Partial<Session>): Session => ({
did: 'did:web:test.bspds.dev:u:testuser',
@@ -106,13 +93,11 @@ export const mockData = {
refreshJwt: 'mock-refresh-jwt-token',
...overrides,
}),
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
name: 'Test App',
createdAt: new Date().toISOString(),
...overrides,
}),
inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
code: 'test-invite-123',
available: 1,
@@ -123,7 +108,6 @@ export const mockData = {
uses: [],
...overrides,
}),
notificationPrefs: (overrides?: Record<string, unknown>) => ({
preferredChannel: 'email',
email: 'test@example.com',
@@ -135,7 +119,6 @@ export const mockData = {
signalVerified: false,
...overrides,
}),
describeServer: () => ({
availableUserDomains: ['test.bspds.dev'],
inviteCodeRequired: false,
@@ -144,7 +127,6 @@ export const mockData = {
termsOfService: 'https://example.com/tos',
},
}),
describeRepo: (did: string) => ({
handle: 'testuser.test.bspds.dev',
did,
@@ -153,14 +135,11 @@ export const mockData = {
handleIsCorrect: true,
}),
}
export function setupDefaultMocks(): void {
setupFetchMock()
mockEndpoint('com.atproto.server.getSession', () =>
jsonResponse(mockData.session())
)
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
const body = JSON.parse((options?.body as string) || '{}')
if (body.identifier && body.password === 'correctpassword') {
@@ -168,19 +147,15 @@ export function setupDefaultMocks(): void {
}
return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
})
mockEndpoint('com.atproto.server.refreshSession', () =>
jsonResponse(mockData.session())
)
mockEndpoint('com.atproto.server.deleteSession', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.listAppPasswords', () =>
jsonResponse({ passwords: [mockData.appPassword()] })
)
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
const body = JSON.parse((options?.body as string) || '{}')
return jsonResponse({
@@ -189,62 +164,48 @@ export function setupDefaultMocks(): void {
createdAt: new Date().toISOString(),
})
})
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.getAccountInviteCodes', () =>
jsonResponse({ codes: [mockData.inviteCode()] })
)
mockEndpoint('com.atproto.server.createInviteCode', () =>
jsonResponse({ code: 'new-invite-' + Date.now() })
)
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
jsonResponse(mockData.notificationPrefs())
)
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
jsonResponse({ success: true })
)
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
jsonResponse({ tokenRequired: true })
)
mockEndpoint('com.atproto.server.updateEmail', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.identity.updateHandle', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.deleteAccount', () =>
jsonResponse({})
)
mockEndpoint('com.atproto.server.describeServer', () =>
jsonResponse(mockData.describeServer())
)
mockEndpoint('com.atproto.repo.describeRepo', (url) => {
const params = new URLSearchParams(url.split('?')[1])
const repo = params.get('repo') || 'did:web:test'
return jsonResponse(mockData.describeRepo(repo))
})
mockEndpoint('com.atproto.repo.listRecords', () =>
jsonResponse({ records: [] })
)
}
export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Session {
const session = mockData.session(sessionOverrides)
_testSetState({
@@ -254,7 +215,6 @@ export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Ses
})
return session
}
export function setupUnauthenticatedUser(): void {
_testSetState({
session: null,
-4
View File
@@ -1,9 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { vi, beforeEach, afterEach } from 'vitest'
import { _testReset } from '../lib/auth.svelte'
let locationHash = ''
Object.defineProperty(window, 'location', {
value: {
get hash() { return locationHash },
@@ -21,7 +19,6 @@ Object.defineProperty(window, 'location', {
writable: true,
configurable: true,
})
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
@@ -29,7 +26,6 @@ beforeEach(() => {
locationHash = ''
_testReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
-8
View File
@@ -1,7 +1,6 @@
import { render, type RenderResult } from '@testing-library/svelte'
import { tick } from 'svelte'
import type { ComponentType } from 'svelte'
export async function renderAndWait<T extends ComponentType>(
component: T,
options?: Parameters<typeof render>[1]
@@ -11,7 +10,6 @@ export async function renderAndWait<T extends ComponentType>(
await new Promise(resolve => setTimeout(resolve, 0))
return result
}
export async function waitForElement(
queryFn: () => HTMLElement | null,
timeout = 1000
@@ -24,7 +22,6 @@ export async function waitForElement(
}
throw new Error('Element not found within timeout')
}
export async function waitForElementToDisappear(
queryFn: () => HTMLElement | null,
timeout = 1000
@@ -37,7 +34,6 @@ export async function waitForElementToDisappear(
}
throw new Error('Element still present after timeout')
}
export async function waitForText(
container: HTMLElement,
text: string | RegExp,
@@ -53,10 +49,8 @@ export async function waitForText(
}
throw new Error(`Text "${text}" not found within timeout`)
}
export function mockLocalStorage(initialData: Record<string, string> = {}): void {
const store: Record<string, string> = { ...initialData }
Object.defineProperty(window, 'localStorage', {
value: {
getItem: (key: string) => store[key] || null,
@@ -69,7 +63,6 @@ export function mockLocalStorage(initialData: Record<string, string> = {}): void
writable: true,
})
}
export function setAuthState(session: {
did: string
handle: string
@@ -80,7 +73,6 @@ export function setAuthState(session: {
}): void {
localStorage.setItem('session', JSON.stringify(session))
}
export function clearAuthState(): void {
localStorage.removeItem('session')
}
-2
View File
@@ -1,7 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
const isTest = process.env.VITEST === 'true' || process.env.VITEST === true
export default {
preprocess: isTest ? [] : vitePreprocess(),
}
-1
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: {
-1
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'vitest/config'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [
svelte({
-33
View File
@@ -1,108 +1,75 @@
default:
@just --list
run:
cargo run
run-release:
cargo run --release
build:
cargo build
build-release:
cargo build --release
check:
cargo check
clippy:
cargo clippy -- -D warnings
fmt:
cargo fmt
fmt-check:
cargo fmt -- --check
lint: fmt-check clippy
# Run tests (auto-starts and auto-cleans containers)
test *args:
./scripts/run-tests.sh {{args}}
# Run a specific test file
test-file file:
./scripts/run-tests.sh --test {{file}}
# Run tests with testcontainers (slower, no shared infra)
test-standalone:
BSPDS_ALLOW_INSECURE_SECRETS=1 cargo test
# Manually manage test infrastructure (for debugging)
test-infra-start:
./scripts/test-infra.sh start
test-infra-stop:
./scripts/test-infra.sh stop
test-infra-status:
./scripts/test-infra.sh status
clean:
cargo clean
doc:
cargo doc --open
db-create:
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx database create
db-migrate:
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx migrate run
db-reset:
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx database drop -y
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx database create
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx migrate run
docker-up:
docker compose up -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f
docker-build:
docker compose build
# Frontend commands (Deno)
frontend-dev:
. ~/.deno/env && cd frontend && deno task dev
frontend-build:
. ~/.deno/env && cd frontend && deno task build
frontend-clean:
rm -rf frontend/dist frontend/node_modules
# Frontend tests
frontend-test *args:
. ~/.deno/env && cd frontend && VITEST=true deno task test:run {{args}}
frontend-test-watch:
. ~/.deno/env && cd frontend && VITEST=true deno task test:watch
frontend-test-ui:
. ~/.deno/env && cd frontend && VITEST=true deno task test:ui
frontend-test-coverage:
. ~/.deno/env && cd frontend && VITEST=true deno task test:run --coverage
# Build all (frontend + backend)
build-all: frontend-build build
# Test all (backend + frontend)
test-all: test frontend-test
-48
View File
@@ -10,7 +10,6 @@ CREATE TYPE notification_type AS ENUM (
'plc_operation',
'two_factor_code'
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
handle TEXT NOT NULL UNIQUE,
@@ -19,39 +18,29 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ,
invites_disabled BOOLEAN DEFAULT FALSE,
takedown_ref TEXT,
preferred_notification_channel notification_channel NOT NULL DEFAULT 'email',
password_reset_code TEXT,
password_reset_code_expires_at TIMESTAMPTZ,
email_pending_verification TEXT,
email_confirmation_code TEXT,
email_confirmation_code_expires_at TIMESTAMPTZ,
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
discord_id TEXT,
discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
telegram_username TEXT,
telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
signal_number TEXT,
signal_verified BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
available_uses INT NOT NULL DEFAULT 1,
@@ -59,7 +48,6 @@ CREATE TABLE IF NOT EXISTS invite_codes (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
disabled BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS invite_code_uses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL REFERENCES invite_codes(code),
@@ -67,7 +55,6 @@ CREATE TABLE IF NOT EXISTS invite_code_uses (
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(code, used_by_user)
);
CREATE TABLE IF NOT EXISTS user_keys (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
key_bytes BYTEA NOT NULL,
@@ -75,7 +62,6 @@ CREATE TABLE IF NOT EXISTS user_keys (
encrypted_at TIMESTAMPTZ,
encryption_version INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS repos (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
repo_root_cid TEXT NOT NULL,
@@ -83,13 +69,11 @@ CREATE TABLE IF NOT EXISTS repos (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS blocks (
cid BYTEA PRIMARY KEY,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE,
@@ -101,9 +85,7 @@ CREATE TABLE IF NOT EXISTS records (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(repo_id, collection, rkey)
);
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
CREATE TABLE IF NOT EXISTS blobs (
cid TEXT PRIMARY KEY,
mime_type TEXT NOT NULL,
@@ -113,7 +95,6 @@ CREATE TABLE IF NOT EXISTS blobs (
takedown_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS app_passwords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -123,7 +104,6 @@ CREATE TABLE IF NOT EXISTS app_passwords (
privileged BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE(user_id, name)
);
CREATE TABLE reports (
id BIGINT PRIMARY KEY,
reason_type TEXT NOT NULL,
@@ -132,14 +112,12 @@ CREATE TABLE reports (
reported_by_did TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS account_deletion_requests (
token TEXT PRIMARY KEY,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS notification_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -158,13 +136,10 @@ CREATE TABLE IF NOT EXISTS notification_queue (
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
CREATE INDEX idx_notification_queue_status_scheduled
ON notification_queue(status, scheduled_for)
WHERE status = 'pending';
CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id);
CREATE TABLE IF NOT EXISTS reserved_signing_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT,
@@ -174,10 +149,8 @@ CREATE TABLE IF NOT EXISTS reserved_signing_keys (
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL;
CREATE TABLE repo_seq (
seq BIGSERIAL PRIMARY KEY,
did TEXT NOT NULL,
@@ -189,10 +162,8 @@ CREATE TABLE repo_seq (
blobs TEXT[],
blocks_cids TEXT[]
);
CREATE INDEX idx_repo_seq_seq ON repo_seq(seq);
CREATE INDEX idx_repo_seq_did ON repo_seq(did);
CREATE TABLE IF NOT EXISTS session_tokens (
id SERIAL PRIMARY KEY,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
@@ -203,19 +174,15 @@ CREATE TABLE IF NOT EXISTS session_tokens (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_session_tokens_did ON session_tokens(did);
CREATE INDEX idx_session_tokens_access_jti ON session_tokens(access_jti);
CREATE INDEX idx_session_tokens_refresh_jti ON session_tokens(refresh_jti);
CREATE TABLE IF NOT EXISTS used_refresh_tokens (
refresh_jti TEXT PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES session_tokens(id) ON DELETE CASCADE,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_used_refresh_tokens_session_id ON used_refresh_tokens(session_id);
CREATE TABLE IF NOT EXISTS oauth_device (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE,
@@ -223,7 +190,6 @@ CREATE TABLE IF NOT EXISTS oauth_device (
ip_address TEXT NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oauth_authorization_request (
id TEXT PRIMARY KEY,
did TEXT REFERENCES users(did) ON DELETE CASCADE,
@@ -234,10 +200,8 @@ CREATE TABLE IF NOT EXISTS oauth_authorization_request (
expires_at TIMESTAMPTZ NOT NULL,
code TEXT UNIQUE
);
CREATE INDEX idx_oauth_auth_request_expires ON oauth_authorization_request(expires_at);
CREATE INDEX idx_oauth_auth_request_code ON oauth_authorization_request(code) WHERE code IS NOT NULL;
CREATE TABLE IF NOT EXISTS oauth_token (
id SERIAL PRIMARY KEY,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
@@ -254,10 +218,8 @@ CREATE TABLE IF NOT EXISTS oauth_token (
current_refresh_token TEXT UNIQUE,
scope TEXT
);
CREATE INDEX idx_oauth_token_did ON oauth_token(did);
CREATE INDEX idx_oauth_token_code ON oauth_token(code) WHERE code IS NOT NULL;
CREATE TABLE IF NOT EXISTS oauth_account_device (
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES oauth_device(id) ON DELETE CASCADE,
@@ -265,7 +227,6 @@ CREATE TABLE IF NOT EXISTS oauth_account_device (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (did, device_id)
);
CREATE TABLE IF NOT EXISTS oauth_authorized_client (
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
client_id TEXT NOT NULL,
@@ -274,19 +235,15 @@ CREATE TABLE IF NOT EXISTS oauth_authorized_client (
data JSONB NOT NULL,
PRIMARY KEY (did, client_id)
);
CREATE TABLE IF NOT EXISTS oauth_used_refresh_token (
refresh_token TEXT PRIMARY KEY,
token_id INTEGER NOT NULL REFERENCES oauth_token(id) ON DELETE CASCADE
);
CREATE TABLE oauth_dpop_jti (
jti TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
CREATE TABLE plc_operation_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -294,10 +251,8 @@ CREATE TABLE plc_operation_tokens (
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
CREATE TABLE IF NOT EXISTS account_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -307,10 +262,8 @@ CREATE TABLE IF NOT EXISTS account_preferences (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
CREATE TABLE oauth_2fa_challenge (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
@@ -320,6 +273,5 @@ CREATE TABLE oauth_2fa_challenge (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
);
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
@@ -1,21 +1,15 @@
CREATE INDEX IF NOT EXISTS idx_records_repo_collection
ON records(repo_id, collection);
CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created
ON records(repo_id, collection, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_users_email
ON users(email)
WHERE email IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user
ON blobs(created_by_user, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq
ON repo_seq(did, seq DESC);
CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
ON app_passwords(user_id);
CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by
ON invite_codes(created_by_user);
@@ -0,0 +1 @@
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS prev_data_cid TEXT;
@@ -0,0 +1,3 @@
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS handle TEXT;
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS active BOOLEAN;
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS status TEXT;
-16
View File
@@ -1,38 +1,31 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml application/xml+rss text/javascript application/activity+json;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
@@ -41,36 +34,28 @@ http {
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
upstream bspds {
server bspds:3000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
ssl_certificate /etc/nginx/certs/live/${PDS_HOSTNAME}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${PDS_HOSTNAME}/privkey.pem;
client_max_body_size 100M;
location / {
proxy_pass http://bspds;
proxy_http_version 1.1;
@@ -85,7 +70,6 @@ http {
proxy_buffering off;
proxy_request_buffering off;
}
location /xrpc/com.atproto.sync.subscribeRepos {
proxy_pass http://bspds;
proxy_http_version 1.1;
-3
View File
@@ -1,9 +1,7 @@
#!/bin/bash
echo "Downloading haileyok/cocoon"
git clone --depth 1 https://github.com/haileyok/cocoon reference-pds-hailey
rm -rf reference-pds-hailey/.git
echo "Downloading bluesky-social/atproto pds package"
mkdir reference-pds-bsky
cd reference-pds-bsky
@@ -15,5 +13,4 @@ git pull --depth 1 origin main
mv packages/pds/* .
rm -rf packages .git
cd ..
echo "Downloads complete!"
-95
View File
@@ -1,38 +1,31 @@
#!/bin/bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
if [[ $EUID -ne 0 ]]; then
log_error "This script must be run as root"
exit 1
fi
if ! grep -qi "debian" /etc/os-release 2>/dev/null; then
log_warn "This script is designed for Debian. Proceed with caution on other distros."
fi
nuke_installation() {
echo -e "${RED}"
echo "╔═══════════════════════════════════════════════════════════════════╗"
echo "║ NUKING EXISTING INSTALLATION ║"
echo "╚═══════════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
log_info "Stopping services..."
systemctl stop bspds 2>/dev/null || true
systemctl disable bspds 2>/dev/null || true
log_info "Removing BSPDS files..."
rm -rf /opt/bspds
rm -rf /var/lib/bspds
@@ -42,14 +35,11 @@ nuke_installation() {
rm -rf /var/spool/bspds-mail
rm -f /etc/systemd/system/bspds.service
systemctl daemon-reload
log_info "Removing BSPDS configuration..."
rm -rf /etc/bspds
log_info "Dropping postgres database and user..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true
sudo -u postgres psql -c "DROP USER IF EXISTS bspds;" 2>/dev/null || true
log_info "Removing minio bucket and resetting minio..."
if command -v mc &>/dev/null; then
mc rb local/pds-blobs --force 2>/dev/null || true
@@ -58,16 +48,13 @@ nuke_installation() {
systemctl stop minio 2>/dev/null || true
rm -rf /var/lib/minio/data/.minio.sys 2>/dev/null || true
rm -f /etc/default/minio 2>/dev/null || true
log_info "Removing nginx config..."
rm -f /etc/nginx/sites-enabled/bspds
rm -f /etc/nginx/sites-available/bspds
systemctl reload nginx 2>/dev/null || true
log_success "Previous installation nuked!"
echo ""
}
if [[ -f /etc/bspds/bspds.env ]] || [[ -d /opt/bspds ]] || [[ -f /usr/local/bin/bspds ]]; then
echo -e "${YELLOW}"
echo "╔═══════════════════════════════════════════════════════════════════╗"
@@ -81,7 +68,6 @@ if [[ -f /etc/bspds/bspds.env ]] || [[ -d /opt/bspds ]] || [[ -f /usr/local/bin/
echo " 3) Exit"
echo ""
read -p "Choose an option [1/2/3]: " INSTALL_CHOICE
case "$INSTALL_CHOICE" in
1)
echo ""
@@ -113,40 +99,33 @@ if [[ -f /etc/bspds/bspds.env ]] || [[ -d /opt/bspds ]] || [[ -f /usr/local/bin/
;;
esac
fi
echo -e "${CYAN}"
echo "╔═══════════════════════════════════════════════════════════════════╗"
echo "║ BSPDS Installation Script for Debian ║"
echo "║ AT Protocol Personal Data Server in Rust ║"
echo "╚═══════════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
get_public_ips() {
IPV4=$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || curl -4 -s --max-time 5 icanhazip.com 2>/dev/null || echo "Could not detect")
IPV6=$(curl -6 -s --max-time 5 ifconfig.me 2>/dev/null || curl -6 -s --max-time 5 icanhazip.com 2>/dev/null || echo "Not available")
}
log_info "Detecting public IP addresses..."
get_public_ips
echo ""
echo -e "${CYAN}Your server's public IPs:${NC}"
echo -e " IPv4: ${GREEN}${IPV4}${NC}"
echo -e " IPv6: ${GREEN}${IPV6}${NC}"
echo ""
read -p "Enter your PDS domain (e.g., pds.example.com): " PDS_DOMAIN
if [[ -z "$PDS_DOMAIN" ]]; then
log_error "Domain cannot be empty"
exit 1
fi
read -p "Enter your email for Let's Encrypt notifications: " CERTBOT_EMAIL
if [[ -z "$CERTBOT_EMAIL" ]]; then
log_error "Email cannot be empty"
exit 1
fi
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW}DNS RECORDS REQUIRED${NC}"
@@ -185,9 +164,7 @@ if [[ ! "$DNS_CONFIRMED" =~ ^[Yy]$ ]]; then
log_warn "Please create the DNS records and run this script again."
exit 0
fi
CREDENTIALS_FILE="/etc/bspds/.credentials"
if [[ -f "$CREDENTIALS_FILE" ]]; then
log_info "Loading existing credentials from previous installation..."
source "$CREDENTIALS_FILE"
@@ -199,7 +176,6 @@ else
MASTER_KEY=$(openssl rand -base64 48)
DB_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
MINIO_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
mkdir -p /etc/bspds
cat > "$CREDENTIALS_FILE" << EOF
JWT_SECRET="$JWT_SECRET"
@@ -211,11 +187,9 @@ EOF
chmod 600 "$CREDENTIALS_FILE"
log_success "Secrets generated and saved"
fi
log_info "Checking swap space..."
TOTAL_MEM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')
TOTAL_SWAP_KB=$(grep SwapTotal /proc/meminfo | awk '{print $2}')
if [[ $TOTAL_SWAP_KB -lt 2000000 ]]; then
log_info "Adding swap space (needed for compilation)..."
if [[ ! -f /swapfile ]]; then
@@ -238,26 +212,21 @@ if [[ $TOTAL_SWAP_KB -lt 2000000 ]]; then
else
log_success "Sufficient swap already configured"
fi
log_info "Updating system packages..."
apt update && apt upgrade -y
log_success "System updated"
log_info "Installing build dependencies..."
apt install -y curl git build-essential pkg-config libssl-dev ca-certificates gnupg lsb-release unzip xxd
log_success "Build dependencies installed"
log_info "Installing postgres..."
apt install -y postgresql postgresql-contrib
systemctl enable postgresql
systemctl start postgresql
sudo -u postgres psql -c "CREATE USER bspds WITH PASSWORD '${DB_PASSWORD}';" 2>/dev/null || \
sudo -u postgres psql -c "ALTER USER bspds WITH PASSWORD '${DB_PASSWORD}';"
sudo -u postgres psql -c "CREATE DATABASE pds OWNER bspds;" 2>/dev/null || true
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE pds TO bspds;"
log_success "postgres installed and configured"
log_info "Installing valkey..."
apt install -y valkey || {
log_warn "valkey not in repos, trying redis..."
@@ -268,7 +237,6 @@ apt install -y valkey || {
systemctl enable valkey-server 2>/dev/null || true
systemctl start valkey-server 2>/dev/null || true
log_success "valkey/redis installed"
log_info "Installing minio..."
if [[ ! -f /usr/local/bin/minio ]]; then
ARCH=$(dpkg --print-architecture)
@@ -283,11 +251,9 @@ if [[ ! -f /usr/local/bin/minio ]]; then
chmod +x /tmp/minio
mv /tmp/minio /usr/local/bin/
fi
mkdir -p /var/lib/minio/data
id -u minio-user &>/dev/null || useradd -r -s /sbin/nologin minio-user
chown -R minio-user:minio-user /var/lib/minio
cat > /etc/default/minio << EOF
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}
@@ -295,12 +261,10 @@ MINIO_VOLUMES="/var/lib/minio/data"
MINIO_OPTS="--console-address :9001"
EOF
chmod 600 /etc/default/minio
cat > /etc/systemd/system/minio.service << 'EOF'
[Unit]
Description=MinIO Object Storage
After=network.target
[Service]
User=minio-user
Group=minio-user
@@ -308,19 +272,15 @@ EnvironmentFile=/etc/default/minio
ExecStart=/usr/local/bin/minio server $MINIO_VOLUMES $MINIO_OPTS
Restart=always
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable minio
systemctl start minio
log_success "minio installed"
log_info "Waiting for minio to start..."
sleep 5
log_info "Installing minio client and creating bucket..."
if [[ ! -f /usr/local/bin/mc ]]; then
ARCH=$(dpkg --print-architecture)
@@ -332,12 +292,10 @@ if [[ ! -f /usr/local/bin/mc ]]; then
chmod +x /tmp/mc
mv /tmp/mc /usr/local/bin/
fi
mc alias remove local 2>/dev/null || true
mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4
mc mb local/pds-blobs --ignore-existing
log_success "minio bucket created"
log_info "Installing rust..."
if [[ -f "$HOME/.cargo/env" ]]; then
source "$HOME/.cargo/env"
@@ -347,7 +305,6 @@ if ! command -v rustc &>/dev/null; then
source "$HOME/.cargo/env"
fi
log_success "rust installed"
log_info "Installing deno..."
export PATH="$HOME/.deno/bin:$PATH"
if ! command -v deno &>/dev/null && [[ ! -f "$HOME/.deno/bin/deno" ]]; then
@@ -355,7 +312,6 @@ if ! command -v deno &>/dev/null && [[ ! -f "$HOME/.deno/bin/deno" ]]; then
grep -q 'deno/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc
fi
log_success "deno installed"
log_info "Cloning BSPDS..."
if [[ ! -d /opt/bspds ]]; then
git clone https://tangled.org/lewis.moe/bspds-sandbox /opt/bspds
@@ -365,13 +321,11 @@ else
fi
cd /opt/bspds
log_success "BSPDS cloned"
log_info "Building frontend..."
cd /opt/bspds/frontend
"$HOME/.deno/bin/deno" task build
cd /opt/bspds
log_success "Frontend built"
log_info "Building BSPDS (this may take a while)..."
source "$HOME/.cargo/env"
NPROC=$(nproc)
@@ -382,40 +336,33 @@ else
cargo build --release
fi
log_success "BSPDS built"
log_info "Installing sqlx-cli and running migrations..."
cargo install sqlx-cli --no-default-features --features postgres
export DATABASE_URL="postgres://bspds:${DB_PASSWORD}@localhost:5432/pds"
"$HOME/.cargo/bin/sqlx" migrate run
log_success "Migrations complete"
log_info "Setting up mail trap for testing..."
mkdir -p /var/spool/bspds-mail
chown root:root /var/spool/bspds-mail
chmod 1777 /var/spool/bspds-mail
cat > /usr/local/bin/bspds-sendmail << 'SENDMAIL_EOF'
#!/bin/bash
MAIL_DIR="/var/spool/bspds-mail"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
RANDOM_ID=$(head -c 4 /dev/urandom | xxd -p)
MAIL_FILE="${MAIL_DIR}/${TIMESTAMP}-${RANDOM_ID}.eml"
mkdir -p "$MAIL_DIR"
{
echo "X-BSPDS-Received: $(date -Iseconds)"
echo "X-BSPDS-Args: $*"
echo ""
cat
} > "$MAIL_FILE"
chmod 644 "$MAIL_FILE"
echo "Mail saved to: $MAIL_FILE" >&2
exit 0
SENDMAIL_EOF
chmod +x /usr/local/bin/bspds-sendmail
cat > /usr/local/bin/bspds-mailq << 'MAILQ_EOF'
#!/bin/bash
MAIL_DIR="/var/spool/bspds-mail"
@@ -425,7 +372,6 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
show_help() {
echo "bspds-mailq - View captured emails from BSPDS mail trap"
echo ""
@@ -439,25 +385,21 @@ show_help() {
echo " bspds-mailq count Show count of emails in queue"
echo ""
}
list_emails() {
if [[ ! -d "$MAIL_DIR" ]] || [[ -z "$(ls -A "$MAIL_DIR" 2>/dev/null)" ]]; then
echo -e "${YELLOW}No emails in queue.${NC}"
return
fi
echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} BSPDS Mail Queue${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}"
echo ""
local i=1
for f in $(ls -t "$MAIL_DIR"/*.eml 2>/dev/null); do
local filename=$(basename "$f")
local received=$(grep "^X-BSPDS-Received:" "$f" 2>/dev/null | cut -d' ' -f2-)
local to=$(grep -i "^To:" "$f" 2>/dev/null | head -1 | cut -d' ' -f2-)
local subject=$(grep -i "^Subject:" "$f" 2>/dev/null | head -1 | sed 's/^Subject: *//')
echo -e "${BLUE}[$i]${NC} ${filename}"
echo -e " To: ${GREEN}${to:-unknown}${NC}"
echo -e " Subject: ${YELLOW}${subject:-<no subject>}${NC}"
@@ -465,14 +407,11 @@ list_emails() {
echo ""
((i++))
done
echo -e "${CYAN}Total: $((i-1)) email(s)${NC}"
}
view_email() {
local target="$1"
local file=""
if [[ "$target" == "latest" ]]; then
file=$(ls -t "$MAIL_DIR"/*.eml 2>/dev/null | head -1)
elif [[ "$target" =~ ^[0-9]+$ ]]; then
@@ -482,12 +421,10 @@ view_email() {
elif [[ -f "$target" ]]; then
file="$target"
fi
if [[ -z "$file" ]] || [[ ! -f "$file" ]]; then
echo -e "${RED}Email not found: $target${NC}"
return 1
fi
echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} $(basename "$file")${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}"
@@ -495,22 +432,18 @@ view_email() {
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}"
}
clear_queue() {
local count=$(ls -1 "$MAIL_DIR"/*.eml 2>/dev/null | wc -l)
if [[ "$count" -eq 0 ]]; then
echo -e "${YELLOW}Queue is already empty.${NC}"
return
fi
rm -f "$MAIL_DIR"/*.eml
echo -e "${GREEN}Cleared $count email(s) from queue.${NC}"
}
watch_queue() {
echo -e "${CYAN}Watching for new emails... (Ctrl+C to stop)${NC}"
echo ""
local last_count=0
while true; do
local current_count=$(ls -1 "$MAIL_DIR"/*.eml 2>/dev/null | wc -l)
@@ -522,12 +455,10 @@ watch_queue() {
sleep 1
done
}
count_queue() {
local count=$(ls -1 "$MAIL_DIR"/*.eml 2>/dev/null | wc -l)
echo "$count"
}
case "${1:-}" in
""|list)
list_emails
@@ -560,59 +491,46 @@ esac
MAILQ_EOF
chmod +x /usr/local/bin/bspds-mailq
log_success "Mail trap configured"
log_info "Creating BSPDS configuration..."
mkdir -p /etc/bspds
cat > /etc/bspds/bspds.env << EOF
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
PDS_HOSTNAME=${PDS_DOMAIN}
DATABASE_URL=postgres://bspds:${DB_PASSWORD}@localhost:5432/pds
DATABASE_MAX_CONNECTIONS=100
DATABASE_MIN_CONNECTIONS=10
S3_ENDPOINT=http://localhost:9000
AWS_REGION=us-east-1
S3_BUCKET=pds-blobs
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
VALKEY_URL=redis://localhost:6379
JWT_SECRET=${JWT_SECRET}
DPOP_SECRET=${DPOP_SECRET}
MASTER_KEY=${MASTER_KEY}
PLC_DIRECTORY_URL=https://plc.directory
APPVIEW_URL=https://api.bsky.app
CRAWLERS=https://bsky.network
AVAILABLE_USER_DOMAINS=${PDS_DOMAIN}
MAIL_FROM_ADDRESS=noreply@${PDS_DOMAIN}
MAIL_FROM_NAME=BSPDS
SENDMAIL_PATH=/usr/local/bin/bspds-sendmail
EOF
chmod 600 /etc/bspds/bspds.env
log_success "Configuration created"
log_info "Creating BSPDS service user..."
id -u bspds &>/dev/null || useradd -r -s /sbin/nologin bspds
cp /opt/bspds/target/release/bspds /usr/local/bin/
mkdir -p /var/lib/bspds
cp -r /opt/bspds/frontend/dist /var/lib/bspds/frontend
chown -R bspds:bspds /var/lib/bspds
log_success "BSPDS binary installed"
log_info "Creating systemd service..."
cat > /etc/systemd/system/bspds.service << 'EOF'
[Unit]
Description=BSPDS - AT Protocol PDS
After=network.target postgresql.service minio.service
[Service]
Type=simple
User=bspds
@@ -622,27 +540,22 @@ Environment=FRONTEND_DIR=/var/lib/bspds/frontend
ExecStart=/usr/local/bin/bspds
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable bspds
systemctl start bspds
log_success "BSPDS service created and started"
log_info "Installing nginx..."
apt install -y nginx certbot python3-certbot-nginx
log_success "nginx installed"
log_info "Configuring nginx..."
cat > /etc/nginx/sites-available/bspds << EOF
server {
listen 80;
listen [::]:80;
server_name ${PDS_DOMAIN} *.${PDS_DOMAIN};
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -658,34 +571,27 @@ server {
}
}
EOF
ln -sf /etc/nginx/sites-available/bspds /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
log_success "nginx configured"
log_info "Configuring firewall (ufw)..."
apt install -y ufw
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable
log_success "Firewall configured"
log_info "Obtaining SSL certificate..."
certbot --nginx -d "${PDS_DOMAIN}" -d "*.${PDS_DOMAIN}" --email "${CERTBOT_EMAIL}" --agree-tos --non-interactive || {
log_warn "Wildcard cert failed (requires DNS challenge). Trying single domain..."
certbot --nginx -d "${PDS_DOMAIN}" --email "${CERTBOT_EMAIL}" --agree-tos --non-interactive
}
log_success "SSL certificate obtained"
log_info "Verifying installation..."
sleep 3
if curl -s "http://localhost:3000/xrpc/_health" | grep -q "version"; then
@@ -693,7 +599,6 @@ if curl -s "http://localhost:3000/xrpc/_health" | grep -q "version"; then
else
log_warn "BSPDS may still be starting up. Check: journalctl -u bspds -f"
fi
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} INSTALLATION COMPLETE!${NC}"
-8
View File
@@ -1,29 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INFRA_SCRIPT="$SCRIPT_DIR/test-infra.sh"
cleanup() {
echo ""
echo "Cleaning up test infrastructure..."
"$INFRA_SCRIPT" stop
}
trap cleanup EXIT
"$INFRA_SCRIPT" start
source "${TMPDIR:-/tmp}/bspds_test_infra.env"
echo ""
echo "Running database migrations..."
sqlx database create 2>/dev/null || true
sqlx migrate run --source "$PROJECT_DIR/migrations"
echo ""
echo "Running tests..."
echo ""
cargo nextest run "$@"
+2 -24
View File
@@ -1,13 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
INFRA_FILE="${TMPDIR:-/tmp}/bspds_test_infra.env"
CONTAINER_PREFIX="bspds-test"
command_exists() {
command -v "$1" >/dev/null 2>&1
}
if command_exists podman; then
CONTAINER_CMD="podman"
if [[ -z "${DOCKER_HOST:-}" ]]; then
@@ -23,10 +20,8 @@ else
echo "Error: Neither podman nor docker found" >&2
exit 1
fi
start_infra() {
echo "Starting test infrastructure..."
if [[ -f "$INFRA_FILE" ]]; then
source "$INFRA_FILE"
if $CONTAINER_CMD ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_PREFIX}-postgres$"; then
@@ -37,9 +32,7 @@ start_infra() {
echo "Stale infra file found, cleaning up..."
rm -f "$INFRA_FILE"
fi
$CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" "${CONTAINER_PREFIX}-valkey" 2>/dev/null || true
echo "Starting PostgreSQL..."
$CONTAINER_CMD run -d \
--name "${CONTAINER_PREFIX}-postgres" \
@@ -49,7 +42,6 @@ start_infra() {
-P \
--label bspds_test=true \
postgres:18-alpine >/dev/null
echo "Starting MinIO..."
$CONTAINER_CMD run -d \
--name "${CONTAINER_PREFIX}-minio" \
@@ -57,22 +49,18 @@ start_infra() {
-e MINIO_ROOT_PASSWORD=minioadmin \
-P \
--label bspds_test=true \
minio/minio:RELEASE.2025-10-15T17-29-55Z server /data >/dev/null
minio/minio:latest server /data >/dev/null
echo "Starting Valkey..."
$CONTAINER_CMD run -d \
--name "${CONTAINER_PREFIX}-valkey" \
-P \
--label bspds_test=true \
valkey/valkey:8-alpine >/dev/null
echo "Waiting for services to be ready..."
sleep 2
PG_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-postgres" 5432 | head -1 | cut -d: -f2)
MINIO_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-minio" 9000 | head -1 | cut -d: -f2)
VALKEY_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-valkey" 6379 | head -1 | cut -d: -f2)
for i in {1..30}; do
if $CONTAINER_CMD exec "${CONTAINER_PREFIX}-postgres" pg_isready -U postgres >/dev/null 2>&1; then
break
@@ -80,7 +68,6 @@ start_infra() {
echo "Waiting for PostgreSQL... ($i/30)"
sleep 1
done
for i in {1..30}; do
if curl -s "http://127.0.0.1:${MINIO_PORT}/minio/health/live" >/dev/null 2>&1; then
break
@@ -88,7 +75,6 @@ start_infra() {
echo "Waiting for MinIO... ($i/30)"
sleep 1
done
for i in {1..30}; do
if $CONTAINER_CMD exec "${CONTAINER_PREFIX}-valkey" valkey-cli ping 2>/dev/null | grep -q PONG; then
break
@@ -96,12 +82,10 @@ start_infra() {
echo "Waiting for Valkey... ($i/30)"
sleep 1
done
echo "Creating MinIO bucket..."
$CONTAINER_CMD run --rm --network host \
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
minio/mc:RELEASE.2025-07-16T15-35-03Z mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
cat > "$INFRA_FILE" << EOF
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres"
export TEST_DB_PORT="${PG_PORT}"
@@ -116,25 +100,21 @@ export BSPDS_ALLOW_INSECURE_SECRETS="1"
export SKIP_IMPORT_VERIFICATION="true"
export DISABLE_RATE_LIMITING="1"
EOF
echo ""
echo "Infrastructure ready!"
echo "Config written to: $INFRA_FILE"
echo ""
cat "$INFRA_FILE"
}
stop_infra() {
echo "Stopping test infrastructure..."
$CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" "${CONTAINER_PREFIX}-valkey" 2>/dev/null || true
rm -f "$INFRA_FILE"
echo "Infrastructure stopped."
}
status_infra() {
echo "Test Infrastructure Status:"
echo "============================"
if [[ -f "$INFRA_FILE" ]]; then
echo "Config file: $INFRA_FILE"
source "$INFRA_FILE"
@@ -143,12 +123,10 @@ status_infra() {
else
echo "Config file: NOT FOUND"
fi
echo ""
echo "Containers:"
$CONTAINER_CMD ps -a --filter "label=bspds_test=true" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (none)"
}
case "${1:-}" in
start)
start_infra
-1
View File
@@ -1,5 +1,4 @@
mod preferences;
mod profile;
pub use preferences::{get_preferences, put_preferences};
pub use profile::{get_profile, get_profiles};
-26
View File
@@ -7,16 +7,13 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
const APP_BSKY_NAMESPACE: &str = "app.bsky";
const MAX_PREFERENCES_COUNT: usize = 100;
const MAX_PREFERENCE_SIZE: usize = 10_000;
#[derive(Serialize)]
pub struct GetPreferencesOutput {
pub preferences: Vec<Value>,
}
pub async fn get_preferences(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -33,7 +30,6 @@ pub async fn get_preferences(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
@@ -44,7 +40,6 @@ pub async fn get_preferences(
.into_response();
}
};
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
@@ -59,14 +54,12 @@ pub async fn get_preferences(
.into_response();
}
};
let prefs_result = sqlx::query!(
"SELECT name, value_json FROM account_preferences WHERE user_id = $1",
user_id
)
.fetch_all(&state.db)
.await;
let prefs = match prefs_result {
Ok(rows) => rows,
Err(_) => {
@@ -77,7 +70,6 @@ pub async fn get_preferences(
.into_response();
}
};
let preferences: Vec<Value> = prefs
.into_iter()
.filter(|row| {
@@ -90,15 +82,12 @@ pub async fn get_preferences(
serde_json::from_value(row.value_json).ok()
})
.collect();
(StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response()
}
#[derive(Deserialize)]
pub struct PutPreferencesInput {
pub preferences: Vec<Value>,
}
pub async fn put_preferences(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -116,7 +105,6 @@ pub async fn put_preferences(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
@@ -127,7 +115,6 @@ pub async fn put_preferences(
.into_response();
}
};
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
@@ -142,7 +129,6 @@ pub async fn put_preferences(
.into_response();
}
};
if input.preferences.len() > MAX_PREFERENCES_COUNT {
return (
StatusCode::BAD_REQUEST,
@@ -150,7 +136,6 @@ pub async fn put_preferences(
)
.into_response();
}
for pref in &input.preferences {
let pref_str = serde_json::to_string(pref).unwrap_or_default();
if pref_str.len() > MAX_PREFERENCE_SIZE {
@@ -160,7 +145,6 @@ pub async fn put_preferences(
)
.into_response();
}
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
Some(t) => t,
None => {
@@ -171,7 +155,6 @@ pub async fn put_preferences(
.into_response();
}
};
if !pref_type.starts_with(APP_BSKY_NAMESPACE) {
return (
StatusCode::BAD_REQUEST,
@@ -179,7 +162,6 @@ pub async fn put_preferences(
)
.into_response();
}
if pref_type == "app.bsky.actor.defs#declaredAgePref" {
return (
StatusCode::BAD_REQUEST,
@@ -188,7 +170,6 @@ pub async fn put_preferences(
.into_response();
}
}
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(_) => {
@@ -199,7 +180,6 @@ pub async fn put_preferences(
.into_response();
}
};
let delete_result = sqlx::query!(
"DELETE FROM account_preferences WHERE user_id = $1 AND (name = $2 OR name LIKE $3)",
user_id,
@@ -208,7 +188,6 @@ pub async fn put_preferences(
)
.execute(&mut *tx)
.await;
if delete_result.is_err() {
let _ = tx.rollback().await;
return (
@@ -217,13 +196,11 @@ pub async fn put_preferences(
)
.into_response();
}
for pref in input.preferences {
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
Some(t) => t,
None => continue,
};
let insert_result = sqlx::query!(
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)",
user_id,
@@ -232,7 +209,6 @@ pub async fn put_preferences(
)
.execute(&mut *tx)
.await;
if insert_result.is_err() {
let _ = tx.rollback().await;
return (
@@ -242,7 +218,6 @@ pub async fn put_preferences(
.into_response();
}
}
if let Err(_) = tx.commit().await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -250,6 +225,5 @@ pub async fn put_preferences(
)
.into_response();
}
StatusCode::OK.into_response()
}
-29
View File
@@ -11,17 +11,14 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::{error, info};
#[derive(Deserialize)]
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 {
@@ -38,18 +35,15 @@ pub struct ProfileViewDetailed {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Serialize, Deserialize)]
pub struct GetProfilesOutput {
pub profiles: Vec<ProfileViewDetailed>,
}
async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value> {
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
.ok()??;
let record_row = sqlx::query!(
"SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.actor.profile' AND rkey = 'self'",
user_id
@@ -57,12 +51,10 @@ async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value>
.fetch_optional(&state.db)
.await
.ok()??;
let cid: cid::Cid = record_row.record_cid.parse().ok()?;
let block_bytes = state.block_store.get(&cid).await.ok()??;
serde_ipld_dagcbor::from_slice(&block_bytes).ok()
}
fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Value) {
if let Some(display_name) = local_record.get("displayName").and_then(|v| v.as_str()) {
profile.display_name = Some(display_name.to_string());
@@ -71,7 +63,6 @@ fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Va
profile.description = Some(description.to_string());
}
}
async fn proxy_to_appview(
method: &str,
params: &HashMap<String, String>,
@@ -85,17 +76,13 @@ async fn proxy_to_appview(
);
}
};
let target_url = format!("{}/xrpc/{}", appview_url, method);
info!("Proxying GET request to {}", target_url);
let client = proxy_client();
let mut request_builder = client.get(&target_url).query(params);
if let Some(auth) = auth_header {
request_builder = request_builder.header("Authorization", auth);
}
match request_builder.send().await {
Ok(resp) => {
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
@@ -117,14 +104,12 @@ async fn proxy_to_appview(
}
}
}
pub async fn get_profile(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(params): Query<GetProfileParams>,
) -> Response {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_did = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
match crate::auth::validate_bearer_token(&state.db, &token).await {
@@ -137,26 +122,21 @@ pub async fn get_profile(
} else {
None
};
let mut query_params = HashMap::new();
query_params.insert("actor".to_string(), params.actor.clone());
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_header).await {
Ok(r) => r,
Err(e) => return e,
};
if !status.is_success() {
return (status, Json(body)).into_response();
}
let mut profile: ProfileViewDetailed = match serde_json::from_value(body) {
Ok(p) => p,
Err(_) => {
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profile response"}))).into_response();
}
};
if let Some(ref did) = auth_did {
if profile.did == *did {
if let Some(local_record) = get_local_profile_record(&state, did).await {
@@ -164,17 +144,14 @@ pub async fn get_profile(
}
}
}
(StatusCode::OK, Json(profile)).into_response()
}
pub async fn get_profiles(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(params): Query<GetProfilesParams>,
) -> Response {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_did = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
match crate::auth::validate_bearer_token(&state.db, &token).await {
@@ -187,26 +164,21 @@ pub async fn get_profiles(
} else {
None
};
let mut query_params = HashMap::new();
query_params.insert("actors".to_string(), params.actors.clone());
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_header).await {
Ok(r) => r,
Err(e) => return e,
};
if !status.is_success() {
return (status, Json(body)).into_response();
}
let mut output: GetProfilesOutput = match serde_json::from_value(body) {
Ok(p) => p,
Err(_) => {
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"}))).into_response();
}
};
if let Some(ref did) = auth_did {
for profile in &mut output.profiles {
if profile.did == *did {
@@ -217,6 +189,5 @@ pub async fn get_profiles(
}
}
}
(StatusCode::OK, Json(output)).into_response()
}
+4 -20
View File
@@ -7,13 +7,11 @@ use axum::{
};
use serde::Deserialize;
use serde_json::json;
use tracing::error;
use tracing::{error, warn};
#[derive(Deserialize)]
pub struct DeleteAccountInput {
pub did: String,
}
pub async fn delete_account(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -27,7 +25,6 @@ pub async fn delete_account(
)
.into_response();
}
let did = input.did.trim();
if did.is_empty() {
return (
@@ -36,11 +33,9 @@ pub async fn delete_account(
)
.into_response();
}
let user = sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
let (user_id, handle) = match user {
Ok(Some(row)) => (row.id, row.handle),
Ok(None) => {
@@ -59,7 +54,6 @@ pub async fn delete_account(
.into_response();
}
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
@@ -71,7 +65,6 @@ pub async fn delete_account(
.into_response();
}
};
if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did)
.execute(&mut *tx)
.await
@@ -83,14 +76,12 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did)
.execute(&mut *tx)
.await
{
error!("Failed to delete used refresh tokens for {}: {:?}", did, e);
}
if let Err(e) = sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id)
.execute(&mut *tx)
.await
@@ -102,7 +93,6 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id)
.execute(&mut *tx)
.await
@@ -114,7 +104,6 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
.execute(&mut *tx)
.await
@@ -126,7 +115,6 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id)
.execute(&mut *tx)
.await
@@ -138,21 +126,18 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM invite_code_uses WHERE used_by_user = $1", user_id)
.execute(&mut *tx)
.await
{
error!("Failed to delete invite code uses for user {}: {:?}", user_id, e);
}
if let Err(e) = sqlx::query!("DELETE FROM invite_codes WHERE created_by_user = $1", user_id)
.execute(&mut *tx)
.await
{
error!("Failed to delete invite codes for user {}: {:?}", user_id, e);
}
if let Err(e) = sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id)
.execute(&mut *tx)
.await
@@ -164,7 +149,6 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM users WHERE id = $1", user_id)
.execute(&mut *tx)
.await
@@ -176,7 +160,6 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = tx.commit().await {
error!("Failed to commit account deletion transaction: {:?}", e);
return (
@@ -185,8 +168,9 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await {
warn!("Failed to sequence account deletion event for {}: {}", did, e);
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
(StatusCode::OK, Json(json!({}))).into_response()
}
-12
View File
@@ -8,7 +8,6 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, warn};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendEmailInput {
@@ -18,12 +17,10 @@ pub struct SendEmailInput {
pub subject: Option<String>,
pub comment: Option<String>,
}
#[derive(Serialize)]
pub struct SendEmailOutput {
pub sent: bool,
}
pub async fn send_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -37,10 +34,8 @@ pub async fn send_email(
)
.into_response();
}
let recipient_did = input.recipient_did.trim();
let content = input.content.trim();
if recipient_did.is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -48,7 +43,6 @@ pub async fn send_email(
)
.into_response();
}
if content.is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -56,14 +50,12 @@ pub async fn send_email(
)
.into_response();
}
let user = sqlx::query!(
"SELECT id, email, handle FROM users WHERE did = $1",
recipient_did
)
.fetch_optional(&state.db)
.await;
let (user_id, email, handle) = match user {
Ok(Some(row)) => {
let email = match row.email {
@@ -94,13 +86,11 @@ pub async fn send_email(
.into_response();
}
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let subject = input
.subject
.clone()
.unwrap_or_else(|| format!("Message from {}", hostname));
let notification = crate::notifications::NewNotification::email(
user_id,
crate::notifications::NotificationType::AdminEmail,
@@ -108,9 +98,7 @@ pub async fn send_email(
subject,
content.to_string(),
);
let result = crate::notifications::enqueue_notification(&state.db, notification).await;
match result {
Ok(_) => {
tracing::info!(
-15
View File
@@ -8,12 +8,10 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
pub struct GetAccountInfoParams {
pub did: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountInfo {
@@ -26,13 +24,11 @@ pub struct AccountInfo {
pub email_confirmed_at: Option<String>,
pub deactivated_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetAccountInfosOutput {
pub infos: Vec<AccountInfo>,
}
pub async fn get_account_info(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -46,7 +42,6 @@ pub async fn get_account_info(
)
.into_response();
}
let did = params.did.trim();
if did.is_empty() {
return (
@@ -55,7 +50,6 @@ pub async fn get_account_info(
)
.into_response();
}
let result = sqlx::query!(
r#"
SELECT did, handle, email, created_at
@@ -66,7 +60,6 @@ pub async fn get_account_info(
)
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(row)) => {
(
@@ -99,12 +92,10 @@ pub async fn get_account_info(
}
}
}
#[derive(Deserialize)]
pub struct GetAccountInfosParams {
pub dids: String,
}
pub async fn get_account_infos(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -118,7 +109,6 @@ pub async fn get_account_infos(
)
.into_response();
}
let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect();
if dids.is_empty() {
return (
@@ -127,14 +117,11 @@ pub async fn get_account_infos(
)
.into_response();
}
let mut infos = Vec::new();
for did in dids {
if did.is_empty() {
continue;
}
let result = sqlx::query!(
r#"
SELECT did, handle, email, created_at
@@ -145,7 +132,6 @@ pub async fn get_account_infos(
)
.fetch_optional(&state.db)
.await;
if let Ok(Some(row)) = result {
infos.push(AccountInfo {
did: row.did,
@@ -159,6 +145,5 @@ pub async fn get_account_infos(
});
}
}
(StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response()
}
+2 -1
View File
@@ -1,14 +1,15 @@
mod delete;
mod email;
mod info;
mod profile;
mod update;
pub use delete::{delete_account, DeleteAccountInput};
pub use email::{send_email, SendEmailInput, SendEmailOutput};
pub use info::{
get_account_info, get_account_infos, AccountInfo, GetAccountInfoParams, GetAccountInfosOutput,
GetAccountInfosParams,
};
pub use profile::{create_profile, create_record_admin, CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput};
pub use update::{
update_account_email, update_account_handle, update_account_password, UpdateAccountEmailInput,
UpdateAccountHandleInput, UpdateAccountPasswordInput,
+154
View File
@@ -0,0 +1,154 @@
use crate::api::repo::record::create_record_internal;
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>,
headers: axum::http::HeaderMap,
Json(input): Json<CreateProfileInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_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>,
headers: axum::http::HeaderMap,
Json(input): Json<CreateRecordAdminInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_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()
}
}
}
-23
View File
@@ -8,13 +8,11 @@ use axum::{
use serde::Deserialize;
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
pub struct UpdateAccountEmailInput {
pub account: String,
pub email: String,
}
pub async fn update_account_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -28,10 +26,8 @@ pub async fn update_account_email(
)
.into_response();
}
let account = input.account.trim();
let email = input.email.trim();
if account.is_empty() || email.is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -39,11 +35,9 @@ pub async fn update_account_email(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET email = $1 WHERE did = $2", email, account)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
@@ -65,13 +59,11 @@ pub async fn update_account_email(
}
}
}
#[derive(Deserialize)]
pub struct UpdateAccountHandleInput {
pub did: String,
pub handle: String,
}
pub async fn update_account_handle(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -85,10 +77,8 @@ pub async fn update_account_handle(
)
.into_response();
}
let did = input.did.trim();
let handle = input.handle.trim();
if did.is_empty() || handle.is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -96,7 +86,6 @@ pub async fn update_account_handle(
)
.into_response();
}
if !handle
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
@@ -107,17 +96,14 @@ pub async fn update_account_handle(
)
.into_response();
}
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND did != $2", handle, did)
.fetch_optional(&state.db)
.await;
if let Ok(Some(_)) = existing {
return (
StatusCode::BAD_REQUEST,
@@ -125,11 +111,9 @@ pub async fn update_account_handle(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
@@ -155,13 +139,11 @@ pub async fn update_account_handle(
}
}
}
#[derive(Deserialize)]
pub struct UpdateAccountPasswordInput {
pub did: String,
pub password: String,
}
pub async fn update_account_password(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -175,10 +157,8 @@ pub async fn update_account_password(
)
.into_response();
}
let did = input.did.trim();
let password = input.password.trim();
if did.is_empty() || password.is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -186,7 +166,6 @@ pub async fn update_account_password(
)
.into_response();
}
let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
@@ -198,11 +177,9 @@ pub async fn update_account_password(
.into_response();
}
};
let result = sqlx::query!("UPDATE users SET password_hash = $1 WHERE did = $2", password_hash, did)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
-31
View File
@@ -8,14 +8,12 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DisableInviteCodesInput {
pub codes: Option<Vec<String>>,
pub accounts: Option<Vec<String>>,
}
pub async fn disable_invite_codes(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -29,7 +27,6 @@ pub async fn disable_invite_codes(
)
.into_response();
}
if let Some(codes) = &input.codes {
for code in codes {
let _ = sqlx::query!("UPDATE invite_codes SET disabled = TRUE WHERE code = $1", code)
@@ -37,13 +34,11 @@ pub async fn disable_invite_codes(
.await;
}
}
if let Some(accounts) = &input.accounts {
for account in accounts {
let user = sqlx::query!("SELECT id FROM users WHERE did = $1", account)
.fetch_optional(&state.db)
.await;
if let Ok(Some(user_row)) = user {
let _ = sqlx::query!(
"UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1",
@@ -54,17 +49,14 @@ pub async fn disable_invite_codes(
}
}
}
(StatusCode::OK, Json(json!({}))).into_response()
}
#[derive(Deserialize)]
pub struct GetInviteCodesParams {
pub sort: Option<String>,
pub limit: Option<i64>,
pub cursor: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeInfo {
@@ -76,20 +68,17 @@ pub struct InviteCodeInfo {
pub created_at: String,
pub uses: Vec<InviteCodeUseInfo>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeUseInfo {
pub used_by: String,
pub used_at: String,
}
#[derive(Serialize)]
pub struct GetInviteCodesOutput {
pub cursor: Option<String>,
pub codes: Vec<InviteCodeInfo>,
}
pub async fn get_invite_codes(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -103,15 +92,12 @@ pub async fn get_invite_codes(
)
.into_response();
}
let limit = params.limit.unwrap_or(100).clamp(1, 500);
let sort = params.sort.as_deref().unwrap_or("recent");
let order_clause = match sort {
"usage" => "available_uses DESC",
_ => "created_at DESC",
};
let codes_result = if let Some(cursor) = &params.cursor {
sqlx::query_as::<_, (String, i32, Option<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&format!(
r#"
@@ -141,7 +127,6 @@ pub async fn get_invite_codes(
.fetch_all(&state.db)
.await
};
let codes_rows = match codes_result {
Ok(rows) => rows,
Err(e) => {
@@ -153,7 +138,6 @@ pub async fn get_invite_codes(
.into_response();
}
};
let mut codes = Vec::new();
for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows {
let creator_did = sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user)
@@ -162,7 +146,6 @@ pub async fn get_invite_codes(
.ok()
.flatten()
.unwrap_or_else(|| "unknown".to_string());
let uses_result = sqlx::query!(
r#"
SELECT u.did, icu.used_at
@@ -175,7 +158,6 @@ pub async fn get_invite_codes(
)
.fetch_all(&state.db)
.await;
let uses = match uses_result {
Ok(use_rows) => use_rows
.iter()
@@ -186,7 +168,6 @@ pub async fn get_invite_codes(
.collect(),
Err(_) => Vec::new(),
};
codes.push(InviteCodeInfo {
code: code.clone(),
available: *available_uses,
@@ -197,13 +178,11 @@ pub async fn get_invite_codes(
uses,
});
}
let next_cursor = if codes_rows.len() == limit as usize {
codes_rows.last().map(|(code, _, _, _, _)| code.clone())
} else {
None
};
(
StatusCode::OK,
Json(GetInviteCodesOutput {
@@ -213,12 +192,10 @@ pub async fn get_invite_codes(
)
.into_response()
}
#[derive(Deserialize)]
pub struct DisableAccountInvitesInput {
pub account: String,
}
pub async fn disable_account_invites(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -232,7 +209,6 @@ pub async fn disable_account_invites(
)
.into_response();
}
let account = input.account.trim();
if account.is_empty() {
return (
@@ -241,11 +217,9 @@ pub async fn disable_account_invites(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET invites_disabled = TRUE WHERE did = $1", account)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
@@ -267,12 +241,10 @@ pub async fn disable_account_invites(
}
}
}
#[derive(Deserialize)]
pub struct EnableAccountInvitesInput {
pub account: String,
}
pub async fn enable_account_invites(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -286,7 +258,6 @@ pub async fn enable_account_invites(
)
.into_response();
}
let account = input.account.trim();
if account.is_empty() {
return (
@@ -295,11 +266,9 @@ pub async fn enable_account_invites(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET invites_disabled = FALSE WHERE did = $1", account)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
+2 -3
View File
@@ -1,10 +1,9 @@
pub mod account;
pub mod invite;
pub mod status;
pub use account::{
delete_account, get_account_info, get_account_infos, send_email, update_account_email,
update_account_handle, update_account_password,
create_profile, create_record_admin, delete_account, get_account_info, get_account_infos,
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,
+13 -30
View File
@@ -7,29 +7,25 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
use tracing::{error, warn};
#[derive(Deserialize)]
pub struct GetSubjectStatusParams {
pub did: Option<String>,
pub uri: Option<String>,
pub blob: Option<String>,
}
#[derive(Serialize)]
pub struct SubjectStatus {
pub subject: serde_json::Value,
pub takedown: Option<StatusAttr>,
pub deactivated: Option<StatusAttr>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusAttr {
pub applied: bool,
pub r#ref: Option<String>,
}
pub async fn get_subject_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -43,7 +39,6 @@ pub async fn get_subject_status(
)
.into_response();
}
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
return (
StatusCode::BAD_REQUEST,
@@ -51,7 +46,6 @@ pub async fn get_subject_status(
)
.into_response();
}
if let Some(did) = &params.did {
let user = sqlx::query!(
"SELECT did, deactivated_at, takedown_ref FROM users WHERE did = $1",
@@ -59,7 +53,6 @@ pub async fn get_subject_status(
)
.fetch_optional(&state.db)
.await;
match user {
Ok(Some(row)) => {
let deactivated = row.deactivated_at.map(|_| StatusAttr {
@@ -70,7 +63,6 @@ pub async fn get_subject_status(
applied: true,
r#ref: Some(r.clone()),
});
return (
StatusCode::OK,
Json(SubjectStatus {
@@ -101,7 +93,6 @@ pub async fn get_subject_status(
}
}
}
if let Some(uri) = &params.uri {
let record = sqlx::query!(
"SELECT r.id, r.takedown_ref FROM records r WHERE r.record_cid = $1",
@@ -109,14 +100,12 @@ pub async fn get_subject_status(
)
.fetch_optional(&state.db)
.await;
match record {
Ok(Some(row)) => {
let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
r#ref: Some(r.clone()),
});
return (
StatusCode::OK,
Json(SubjectStatus {
@@ -148,19 +137,16 @@ pub async fn get_subject_status(
}
}
}
if let Some(blob_cid) = &params.blob {
let blob = sqlx::query!("SELECT cid, takedown_ref FROM blobs WHERE cid = $1", blob_cid)
.fetch_optional(&state.db)
.await;
match blob {
Ok(Some(row)) => {
let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
r#ref: Some(r.clone()),
});
return (
StatusCode::OK,
Json(SubjectStatus {
@@ -192,14 +178,12 @@ pub async fn get_subject_status(
}
}
}
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
)
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSubjectStatusInput {
@@ -207,13 +191,11 @@ pub struct UpdateSubjectStatusInput {
pub takedown: Option<StatusAttrInput>,
pub deactivated: Option<StatusAttrInput>,
}
#[derive(Deserialize)]
pub struct StatusAttrInput {
pub apply: bool,
pub r#ref: Option<String>,
}
pub async fn update_subject_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -227,9 +209,7 @@ pub async fn update_subject_status(
)
.into_response();
}
let subject_type = input.subject.get("$type").and_then(|t| t.as_str());
match subject_type {
Some("com.atproto.admin.defs#repoRef") => {
let did = input.subject.get("did").and_then(|d| d.as_str());
@@ -245,7 +225,6 @@ pub async fn update_subject_status(
.into_response();
}
};
if let Some(takedown) = &input.takedown {
let takedown_ref = if takedown.apply {
takedown.r#ref.clone()
@@ -268,7 +247,6 @@ pub async fn update_subject_status(
.into_response();
}
}
if let Some(deactivated) = &input.deactivated {
let result = if deactivated.apply {
sqlx::query!(
@@ -285,7 +263,6 @@ pub async fn update_subject_status(
.execute(&mut *tx)
.await
};
if let Err(e) = result {
error!("Failed to update user deactivation status for {}: {:?}", did, e);
return (
@@ -295,7 +272,6 @@ pub async fn update_subject_status(
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return (
@@ -304,14 +280,24 @@ pub async fn update_subject_status(
)
.into_response();
}
if let Some(takedown) = &input.takedown {
let status = if takedown.apply { Some("takendown") } else { None };
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !takedown.apply, status).await {
warn!("Failed to sequence account event for takedown: {}", e);
}
}
if let Some(deactivated) = &input.deactivated {
let status = if deactivated.apply { Some("deactivated") } else { None };
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !deactivated.apply, status).await {
warn!("Failed to sequence account event for deactivation: {}", e);
}
}
if let Ok(Some(handle)) = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
}
return (
StatusCode::OK,
Json(json!({
@@ -353,7 +339,6 @@ pub async fn update_subject_status(
.into_response();
}
}
return (
StatusCode::OK,
Json(json!({
@@ -392,7 +377,6 @@ pub async fn update_subject_status(
.into_response();
}
}
return (
StatusCode::OK,
Json(json!({
@@ -408,7 +392,6 @@ pub async fn update_subject_status(
}
_ => {}
}
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
-10
View File
@@ -4,14 +4,12 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct ErrorBody {
error: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
#[derive(Debug)]
pub enum ApiError {
InternalError,
@@ -48,7 +46,6 @@ pub enum ApiError {
UpstreamUnavailable(String),
UpstreamError { status: u16, error: Option<String>, message: Option<String> },
}
impl ApiError {
fn status_code(&self) -> StatusCode {
match self {
@@ -86,7 +83,6 @@ impl ApiError {
| Self::InvalidSwap => StatusCode::BAD_REQUEST,
}
}
fn error_name(&self) -> &'static str {
match self {
Self::InternalError | Self::DatabaseError => "InternalError",
@@ -124,7 +120,6 @@ impl ApiError {
Self::InvalidSwap => "InvalidSwap",
}
}
fn message(&self) -> Option<String> {
match self {
Self::AuthenticationFailedMsg(msg)
@@ -137,7 +132,6 @@ impl ApiError {
_ => None,
}
}
pub fn from_upstream_response(
status: u16,
body: &[u8],
@@ -150,7 +144,6 @@ impl ApiError {
Self::UpstreamError { status, error: None, message: None }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = ErrorBody {
@@ -160,14 +153,12 @@ impl IntoResponse for ApiError {
(self.status_code(), Json(body)).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
tracing::error!("Database error: {:?}", e);
Self::DatabaseError
}
}
impl From<crate::auth::TokenValidationError> for ApiError {
fn from(e: crate::auth::TokenValidationError) -> Self {
match e {
@@ -178,7 +169,6 @@ impl From<crate::auth::TokenValidationError> for ApiError {
}
}
}
impl From<crate::util::DbLookupError> for ApiError {
fn from(e: crate::util::DbLookupError) -> Self {
match e {
-18
View File
@@ -13,14 +13,12 @@ use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetActorLikesParams {
pub actor: String,
pub limit: Option<u32>,
pub cursor: Option<String>,
}
fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<LikeRecord>]) {
for like in likes {
let like_time = &like.indexed_at.to_rfc3339();
@@ -28,7 +26,6 @@ fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<
.iter()
.position(|fi| &fi.post.indexed_at < like_time)
.unwrap_or(feed.len());
let placeholder_post = PostView {
uri: like.record.subject.uri.clone(),
cid: like.record.subject.cid.clone(),
@@ -48,7 +45,6 @@ fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<
quote_count: 0,
extra: HashMap::new(),
};
feed.insert(
idx,
FeedViewPost {
@@ -61,14 +57,12 @@ fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<
);
}
}
pub async fn get_actor_likes(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(params): Query<GetActorLikesParams>,
) -> Response {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_did = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
match crate::auth::validate_bearer_token(&state.db, &token).await {
@@ -81,7 +75,6 @@ pub async fn get_actor_likes(
} else {
None
};
let mut query_params = HashMap::new();
query_params.insert("actor".to_string(), params.actor.clone());
if let Some(limit) = params.limit {
@@ -90,22 +83,18 @@ 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("app.bsky.feed.getActorLikes", &query_params, auth_header).await {
Ok(r) => r,
Err(e) => return e,
};
if !proxy_result.status.is_success() {
return (proxy_result.status, proxy_result.body).into_response();
}
let rev = match extract_repo_rev(&proxy_result.headers) {
Some(r) => r,
None => return (proxy_result.status, proxy_result.body).into_response(),
};
let mut feed_output: FeedOutput = match serde_json::from_slice(&proxy_result.body) {
Ok(f) => f,
Err(e) => {
@@ -113,12 +102,10 @@ pub async fn get_actor_likes(
return (proxy_result.status, proxy_result.body).into_response();
}
};
let requester_did = match auth_did {
Some(d) => d,
None => return (StatusCode::OK, Json(feed_output)).into_response(),
};
let actor_did = if params.actor.starts_with("did:") {
params.actor.clone()
} else {
@@ -141,11 +128,9 @@ pub async fn get_actor_likes(
}
}
};
if actor_did != requester_did {
return (StatusCode::OK, Json(feed_output)).into_response();
}
let local_records = match get_records_since_rev(&state, &requester_did, &rev).await {
Ok(r) => r,
Err(e) => {
@@ -153,13 +138,10 @@ pub async fn get_actor_likes(
return (proxy_result.status, proxy_result.body).into_response();
}
};
if local_records.likes.is_empty() {
return (StatusCode::OK, Json(feed_output)).into_response();
}
insert_likes_into_feed(&mut feed_output.feed, &local_records.likes);
let lag = get_local_lag(&local_records);
format_munged_response(feed_output, lag)
}
-19
View File
@@ -13,7 +13,6 @@ use axum::{
use serde::Deserialize;
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetAuthorFeedParams {
pub actor: String,
@@ -23,7 +22,6 @@ pub struct GetAuthorFeedParams {
#[serde(rename = "includePins")]
pub include_pins: Option<bool>,
}
fn update_author_profile_in_feed(
feed: &mut [FeedViewPost],
author_did: &str,
@@ -37,14 +35,12 @@ fn update_author_profile_in_feed(
}
}
}
pub async fn get_author_feed(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(params): Query<GetAuthorFeedParams>,
) -> Response {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_did = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
match crate::auth::validate_bearer_token(&state.db, &token).await {
@@ -57,7 +53,6 @@ pub async fn get_author_feed(
} else {
None
};
let mut query_params = HashMap::new();
query_params.insert("actor".to_string(), params.actor.clone());
if let Some(limit) = params.limit {
@@ -72,22 +67,18 @@ 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("app.bsky.feed.getAuthorFeed", &query_params, auth_header).await {
Ok(r) => r,
Err(e) => return e,
};
if !proxy_result.status.is_success() {
return (proxy_result.status, proxy_result.body).into_response();
}
let rev = match extract_repo_rev(&proxy_result.headers) {
Some(r) => r,
None => return (proxy_result.status, proxy_result.body).into_response(),
};
let mut feed_output: FeedOutput = match serde_json::from_slice(&proxy_result.body) {
Ok(f) => f,
Err(e) => {
@@ -95,12 +86,10 @@ pub async fn get_author_feed(
return (proxy_result.status, proxy_result.body).into_response();
}
};
let requester_did = match auth_did {
Some(d) => d,
None => return (StatusCode::OK, Json(feed_output)).into_response(),
};
let actor_did = if params.actor.starts_with("did:") {
params.actor.clone()
} else {
@@ -123,11 +112,9 @@ pub async fn get_author_feed(
}
}
};
if actor_did != requester_did {
return (StatusCode::OK, Json(feed_output)).into_response();
}
let local_records = match get_records_since_rev(&state, &requester_did, &rev).await {
Ok(r) => r,
Err(e) => {
@@ -135,11 +122,9 @@ pub async fn get_author_feed(
return (proxy_result.status, proxy_result.body).into_response();
}
};
if local_records.count == 0 {
return (StatusCode::OK, Json(feed_output)).into_response();
}
let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", requester_did)
.fetch_optional(&state.db)
.await
@@ -151,11 +136,9 @@ pub async fn get_author_feed(
requester_did.clone()
}
};
if let Some(ref local_profile) = local_records.profile {
update_author_profile_in_feed(&mut feed_output.feed, &requester_did, local_profile);
}
let local_posts: Vec<_> = local_records
.posts
.iter()
@@ -168,9 +151,7 @@ pub async fn get_author_feed(
)
})
.collect();
insert_posts_into_feed(&mut feed_output.feed, local_posts);
let lag = get_local_lag(&local_records);
format_munged_response(feed_output, lag)
}
-16
View File
@@ -11,14 +11,12 @@ use axum::{
use serde::Deserialize;
use std::collections::HashMap;
use tracing::{error, info};
#[derive(Deserialize)]
pub struct GetFeedParams {
pub feed: String,
pub limit: Option<u32>,
pub cursor: Option<String>,
}
pub async fn get_feed(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -30,17 +28,13 @@ pub async fn get_feed(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
if let Err(e) = crate::auth::validate_bearer_token(&state.db, &token).await {
return ApiError::from(e).into_response();
};
if let Err(e) = validate_at_uri(&params.feed) {
return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response();
}
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let appview_url = match std::env::var("APPVIEW_URL") {
Ok(url) => url,
Err(_) => {
@@ -48,13 +42,11 @@ pub async fn get_feed(
.into_response();
}
};
if let Err(e) = is_ssrf_safe(&appview_url) {
error!("SSRF check failed for appview URL: {}", e);
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
.into_response();
}
let limit = validate_limit(params.limit, 50, 100);
let mut query_params = HashMap::new();
query_params.insert("feed".to_string(), params.feed.clone());
@@ -62,22 +54,17 @@ 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);
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(auth) = auth_header {
request_builder = request_builder.header("Authorization", auth);
}
match request_builder.send().await {
Ok(resp) => {
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let content_length = resp.content_length().unwrap_or(0);
if content_length > MAX_RESPONSE_SIZE {
error!(
@@ -87,7 +74,6 @@ pub async fn get_feed(
);
return ApiError::UpstreamFailure.into_response();
}
let resp_headers = resp.headers().clone();
let body = match resp.bytes().await {
Ok(b) => {
@@ -102,12 +88,10 @@ pub async fn get_feed(
return ApiError::UpstreamFailure.into_response();
}
};
let mut response_builder = axum::response::Response::builder().status(status);
if let Some(ct) = resp_headers.get("content-type") {
response_builder = response_builder.header("content-type", ct);
}
match response_builder.body(axum::body::Body::from(body)) {
Ok(r) => r,
Err(e) => {
-1
View File
@@ -3,7 +3,6 @@ mod author_feed;
mod custom_feed;
mod post_thread;
mod timeline;
pub use actor_likes::get_actor_likes;
pub use author_feed::get_author_feed;
pub use custom_feed::get_feed;
-37
View File
@@ -13,7 +13,6 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetPostThreadParams {
pub uri: String,
@@ -21,7 +20,6 @@ pub struct GetPostThreadParams {
#[serde(rename = "parentHeight")]
pub parent_height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadViewPost {
@@ -35,7 +33,6 @@ pub struct ThreadViewPost {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ThreadNode {
@@ -43,7 +40,6 @@ pub enum ThreadNode {
NotFound(ThreadNotFound),
Blocked(ThreadBlocked),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadNotFound {
@@ -52,7 +48,6 @@ pub struct ThreadNotFound {
pub uri: String,
pub not_found: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadBlocked {
@@ -62,16 +57,13 @@ pub struct ThreadBlocked {
pub blocked: bool,
pub author: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostThreadOutput {
pub thread: ThreadNode,
#[serde(skip_serializing_if = "Option::is_none")]
pub threadgate: Option<Value>,
}
const MAX_THREAD_DEPTH: usize = 10;
fn add_replies_to_thread(
thread: &mut ThreadViewPost,
local_posts: &[RecordDescript<PostRecord>],
@@ -82,9 +74,7 @@ fn add_replies_to_thread(
if depth >= MAX_THREAD_DEPTH {
return;
}
let thread_uri = &thread.post.uri;
let replies: Vec<_> = local_posts
.iter()
.filter(|p| {
@@ -107,14 +97,12 @@ fn add_replies_to_thread(
})
})
.collect();
if !replies.is_empty() {
match &mut thread.replies {
Some(existing) => existing.extend(replies),
None => thread.replies = Some(replies),
}
}
if let Some(ref mut existing_replies) = thread.replies {
for reply in existing_replies.iter_mut() {
if let ThreadNode::Post(reply_thread) = reply {
@@ -123,14 +111,12 @@ fn add_replies_to_thread(
}
}
}
pub async fn get_post_thread(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(params): Query<GetPostThreadParams>,
) -> Response {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_did = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
match crate::auth::validate_bearer_token(&state.db, &token).await {
@@ -143,7 +129,6 @@ pub async fn get_post_thread(
} else {
None
};
let mut query_params = HashMap::new();
query_params.insert("uri".to_string(), params.uri.clone());
if let Some(depth) = params.depth {
@@ -152,26 +137,21 @@ 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("app.bsky.feed.getPostThread", &query_params, auth_header).await {
Ok(r) => r,
Err(e) => return e,
};
if proxy_result.status == StatusCode::NOT_FOUND {
return handle_not_found(&state, &params.uri, auth_did, &proxy_result.headers).await;
}
if !proxy_result.status.is_success() {
return (proxy_result.status, proxy_result.body).into_response();
}
let rev = match extract_repo_rev(&proxy_result.headers) {
Some(r) => r,
None => return (proxy_result.status, proxy_result.body).into_response(),
};
let mut thread_output: PostThreadOutput = match serde_json::from_slice(&proxy_result.body) {
Ok(t) => t,
Err(e) => {
@@ -179,12 +159,10 @@ pub async fn get_post_thread(
return (proxy_result.status, proxy_result.body).into_response();
}
};
let requester_did = match auth_did {
Some(d) => d,
None => return (StatusCode::OK, Json(thread_output)).into_response(),
};
let local_records = match get_records_since_rev(&state, &requester_did, &rev).await {
Ok(r) => r,
Err(e) => {
@@ -192,11 +170,9 @@ pub async fn get_post_thread(
return (proxy_result.status, proxy_result.body).into_response();
}
};
if local_records.posts.is_empty() {
return (StatusCode::OK, Json(thread_output)).into_response();
}
let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", requester_did)
.fetch_optional(&state.db)
.await
@@ -208,15 +184,12 @@ pub async fn get_post_thread(
requester_did.clone()
}
};
if let ThreadNode::Post(ref mut thread_post) = thread_output.thread {
add_replies_to_thread(thread_post, &local_records.posts, &requester_did, &handle, 0);
}
let lag = get_local_lag(&local_records);
format_munged_response(thread_output, lag)
}
async fn handle_not_found(
state: &AppState,
uri: &str,
@@ -233,7 +206,6 @@ async fn handle_not_found(
.into_response()
}
};
let requester_did = match auth_did {
Some(d) => d,
None => {
@@ -244,7 +216,6 @@ async fn handle_not_found(
.into_response()
}
};
let uri_parts: Vec<&str> = uri.trim_start_matches("at://").split('/').collect();
if uri_parts.len() != 3 {
return (
@@ -253,7 +224,6 @@ async fn handle_not_found(
)
.into_response();
}
let post_did = uri_parts[0];
if post_did != requester_did {
return (
@@ -262,7 +232,6 @@ async fn handle_not_found(
)
.into_response();
}
let local_records = match get_records_since_rev(state, &requester_did, &rev).await {
Ok(r) => r,
Err(_) => {
@@ -273,9 +242,7 @@ async fn handle_not_found(
.into_response()
}
};
let local_post = local_records.posts.iter().find(|p| p.uri == uri);
let local_post = match local_post {
Some(p) => p,
None => {
@@ -286,7 +253,6 @@ async fn handle_not_found(
.into_response()
}
};
let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", requester_did)
.fetch_optional(&state.db)
.await
@@ -298,14 +264,12 @@ async fn handle_not_found(
requester_did.clone()
}
};
let post_view = format_local_post(
local_post,
&requester_did,
&handle,
local_records.profile.as_ref(),
);
let thread = PostThreadOutput {
thread: ThreadNode::Post(ThreadViewPost {
thread_type: Some("app.bsky.feed.defs#threadViewPost".to_string()),
@@ -316,7 +280,6 @@ async fn handle_not_found(
}),
threadgate: None,
};
let lag = get_local_lag(&local_records);
format_munged_response(thread, lag)
}
-35
View File
@@ -15,14 +15,12 @@ use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetTimelineParams {
pub algorithm: Option<String>,
pub limit: Option<u32>,
pub cursor: Option<String>,
}
pub async fn get_timeline(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -40,7 +38,6 @@ pub async fn get_timeline(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
@@ -51,17 +48,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, &headers, &params, &auth_user.did).await;
}
_ => {}
}
get_timeline_local_only(&state, &auth_user.did).await
}
async fn get_timeline_with_appview(
state: &AppState,
headers: &axum::http::HeaderMap,
@@ -69,7 +63,6 @@ async fn get_timeline_with_appview(
auth_did: &str,
) -> Response {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let mut query_params = HashMap::new();
if let Some(algo) = &params.algorithm {
query_params.insert("algorithm".to_string(), algo.clone());
@@ -80,23 +73,19 @@ 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("app.bsky.feed.getTimeline", &query_params, auth_header).await {
Ok(r) => r,
Err(e) => return e,
};
if !proxy_result.status.is_success() {
return (proxy_result.status, proxy_result.body).into_response();
}
let rev = extract_repo_rev(&proxy_result.headers);
if rev.is_none() {
return (proxy_result.status, proxy_result.body).into_response();
}
let rev = rev.unwrap();
let mut feed_output: FeedOutput = match serde_json::from_slice(&proxy_result.body) {
Ok(f) => f,
Err(e) => {
@@ -104,7 +93,6 @@ async fn get_timeline_with_appview(
return (proxy_result.status, proxy_result.body).into_response();
}
};
let local_records = match get_records_since_rev(state, auth_did, &rev).await {
Ok(r) => r,
Err(e) => {
@@ -112,11 +100,9 @@ async fn get_timeline_with_appview(
return (proxy_result.status, proxy_result.body).into_response();
}
};
if local_records.count == 0 {
return (proxy_result.status, proxy_result.body).into_response();
}
let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth_did)
.fetch_optional(&state.db)
.await
@@ -128,19 +114,15 @@ async fn get_timeline_with_appview(
auth_did.to_string()
}
};
let local_posts: Vec<_> = local_records
.posts
.iter()
.map(|p| format_local_post(p, auth_did, &handle, local_records.profile.as_ref()))
.collect();
insert_posts_into_feed(&mut feed_output.feed, local_posts);
let lag = get_local_lag(&local_records);
format_munged_response(feed_output, lag)
}
async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
let user_id: uuid::Uuid = match sqlx::query_scalar!(
"SELECT id FROM users WHERE did = $1",
@@ -166,14 +148,12 @@ async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
.into_response();
}
};
let follows_query = sqlx::query!(
"SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.graph.follow' LIMIT 5000",
user_id
)
.fetch_all(&state.db)
.await;
let follow_cids: Vec<String> = match follows_query {
Ok(rows) => rows.iter().map(|r| r.record_cid.clone()).collect(),
Err(_) => {
@@ -184,29 +164,24 @@ async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
.into_response();
}
};
let mut followed_dids: Vec<String> = Vec::new();
for cid_str in follow_cids {
let cid = match cid_str.parse::<cid::Cid>() {
Ok(c) => c,
Err(_) => continue,
};
let block_bytes = match state.block_store.get(&cid).await {
Ok(Some(b)) => b,
_ => continue,
};
let record: Value = match serde_ipld_dagcbor::from_slice(&block_bytes) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(subject) = record.get("subject").and_then(|s| s.as_str()) {
followed_dids.push(subject.to_string());
}
}
if followed_dids.is_empty() {
return (
StatusCode::OK,
@@ -217,7 +192,6 @@ async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
)
.into_response();
}
let posts_result = sqlx::query!(
"SELECT r.record_cid, r.rkey, r.created_at, u.did, u.handle
FROM records r
@@ -230,7 +204,6 @@ async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
)
.fetch_all(&state.db)
.await;
let posts = match posts_result {
Ok(rows) => rows,
Err(_) => {
@@ -241,33 +214,26 @@ async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
.into_response();
}
};
let mut feed: Vec<FeedViewPost> = Vec::new();
for row in posts {
let record_cid: String = row.record_cid;
let rkey: String = row.rkey;
let created_at: chrono::DateTime<chrono::Utc> = row.created_at;
let author_did: String = row.did;
let author_handle: String = row.handle;
let cid = match record_cid.parse::<cid::Cid>() {
Ok(c) => c,
Err(_) => continue,
};
let block_bytes = match state.block_store.get(&cid).await {
Ok(Some(b)) => b,
_ => continue,
};
let record: Value = match serde_ipld_dagcbor::from_slice(&block_bytes) {
Ok(v) => v,
Err(_) => continue,
};
let uri = format!("at://{}/app.bsky.feed.post/{}", author_did, rkey);
feed.push(FeedViewPost {
post: PostView {
uri,
@@ -294,6 +260,5 @@ async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
extra: HashMap::new(),
});
}
(StatusCode::OK, Json(FeedOutput { feed, cursor: None })).into_response()
}
+29 -54
View File
@@ -16,7 +16,6 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
@@ -32,7 +31,6 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
}
"unknown".to_string()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountInput {
@@ -47,7 +45,6 @@ pub struct CreateAccountInput {
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountOutput {
@@ -56,14 +53,12 @@ pub struct CreateAccountOutput {
pub verification_required: bool,
pub verification_channel: String,
}
pub async fn create_account(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<CreateAccountInput>,
) -> Response {
info!("create_account called");
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::AccountCreation, &client_ip).await {
warn!(ip = %client_ip, "Account creation rate limit exceeded");
@@ -76,7 +71,6 @@ pub async fn create_account(
)
.into_response();
}
if input.handle.contains('!') || input.handle.contains('@') {
return (
StatusCode::BAD_REQUEST,
@@ -86,7 +80,6 @@ pub async fn create_account(
)
.into_response();
}
let email: Option<String> = input.email.as_ref()
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty());
@@ -99,7 +92,6 @@ pub async fn create_account(
.into_response();
}
}
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&verification_channel) {
@@ -109,7 +101,6 @@ pub async fn create_account(
)
.into_response();
}
let verification_recipient = match verification_channel {
"email" => match &input.email {
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
@@ -144,11 +135,15 @@ pub async fn create_account(
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
).into_response(),
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_endpoint = format!("https://{}", hostname);
let full_handle = format!("{}.{}", input.handle, hostname);
let suffix = format!(".{}", hostname);
let short_handle = if input.handle.ends_with(&suffix) {
input.handle.strip_suffix(&suffix).unwrap_or(&input.handle)
} else {
&input.handle
};
let full_handle = format!("{}.{}", short_handle, hostname);
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
if let Some(signing_key_did) = &input.signing_key {
let reserved = sqlx::query!(
@@ -164,7 +159,6 @@ pub async fn create_account(
)
.fetch_optional(&state.db)
.await;
match reserved {
Ok(Some(row)) => (row.private_key_bytes, Some(row.id)),
Ok(None) => {
@@ -190,7 +184,6 @@ pub async fn create_account(
let secret_key = SecretKey::random(&mut OsRng);
(secret_key.to_bytes().to_vec(), None)
};
let signing_key = match SigningKey::from_slice(&secret_key_bytes) {
Ok(k) => k,
Err(e) => {
@@ -202,12 +195,10 @@ pub async fn create_account(
.into_response();
}
};
let did = if let Some(d) = &input.did {
if d.trim().is_empty() {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
@@ -224,7 +215,6 @@ pub async fn create_account(
.into_response();
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await {
error!("Failed to submit PLC genesis operation: {:?}", e);
@@ -237,7 +227,6 @@ pub async fn create_account(
)
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
} else if d.starts_with("did:web:") {
@@ -259,7 +248,6 @@ pub async fn create_account(
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
@@ -276,7 +264,6 @@ pub async fn create_account(
.into_response();
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await {
error!("Failed to submit PLC genesis operation: {:?}", e);
@@ -289,11 +276,9 @@ pub async fn create_account(
)
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
@@ -305,11 +290,9 @@ pub async fn create_account(
.into_response();
}
};
let exists_query = sqlx::query!("SELECT 1 as one FROM users WHERE handle = $1", input.handle)
let exists_query = sqlx::query!("SELECT 1 as one FROM users WHERE handle = $1", short_handle)
.fetch_optional(&mut *tx)
.await;
match exists_query {
Ok(Some(_)) => {
return (
@@ -328,26 +311,22 @@ pub async fn create_account(
}
Ok(None) => {}
}
if let Some(code) = &input.invite_code {
let invite_query =
sqlx::query!("SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", code)
.fetch_optional(&mut *tx)
.await;
match invite_query {
Ok(Some(row)) => {
if row.available_uses <= 0 {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
}
let update_invite = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
if let Err(e) = update_invite {
error!("Error updating invite code: {:?}", e);
return (
@@ -374,7 +353,6 @@ pub async fn create_account(
}
}
}
let password_hash = match hash(&input.password, DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
@@ -386,10 +364,8 @@ pub async fn create_account(
.into_response();
}
};
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
r#"INSERT INTO users (
handle, email, did, password_hash,
@@ -398,7 +374,7 @@ pub async fn create_account(
discord_id, telegram_username, signal_number
) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#,
)
.bind(&input.handle)
.bind(short_handle)
.bind(&email)
.bind(&did)
.bind(&password_hash)
@@ -410,7 +386,6 @@ pub async fn create_account(
.bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
.fetch_one(&mut *tx)
.await;
let user_id = match user_insert {
Ok((id,)) => id,
Err(e) => {
@@ -455,7 +430,6 @@ pub async fn create_account(
.into_response();
}
};
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
Ok(enc) => enc,
Err(e) => {
@@ -467,7 +441,6 @@ pub async fn create_account(
.into_response();
}
};
let key_insert = sqlx::query!(
"INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
user_id,
@@ -476,7 +449,6 @@ pub async fn create_account(
)
.execute(&mut *tx)
.await;
if let Err(e) = key_insert {
error!("Error inserting user key: {:?}", e);
return (
@@ -485,7 +457,6 @@ pub async fn create_account(
)
.into_response();
}
if let Some(key_id) = reserved_key_id {
let mark_used = sqlx::query!(
"UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1",
@@ -493,7 +464,6 @@ pub async fn create_account(
)
.execute(&mut *tx)
.await;
if let Err(e) = mark_used {
error!("Error marking reserved key as used: {:?}", e);
return (
@@ -503,7 +473,6 @@ pub async fn create_account(
.into_response();
}
}
let mst = Mst::new(Arc::new(state.block_store.clone()));
let mst_root = match mst.persist().await {
Ok(c) => c,
@@ -516,7 +485,6 @@ pub async fn create_account(
.into_response();
}
};
let did_obj = match Did::new(&did) {
Ok(d) => d,
Err(_) => {
@@ -527,11 +495,8 @@ pub async fn create_account(
.into_response();
}
};
let rev = Tid::now(LimitedU32::MIN);
let unsigned_commit = Commit::new_unsigned(did_obj, mst_root, rev, None);
let signed_commit = match unsigned_commit.sign(&signing_key) {
Ok(c) => c,
Err(e) => {
@@ -543,7 +508,6 @@ pub async fn create_account(
.into_response();
}
};
let commit_bytes = match signed_commit.to_cbor() {
Ok(b) => b,
Err(e) => {
@@ -555,7 +519,6 @@ pub async fn create_account(
.into_response();
}
};
let commit_cid = match state.block_store.put(&commit_bytes).await {
Ok(c) => c,
Err(e) => {
@@ -567,12 +530,10 @@ pub async fn create_account(
.into_response();
}
};
let commit_cid_str = commit_cid.to_string();
let repo_insert = sqlx::query!("INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", user_id, commit_cid_str)
.execute(&mut *tx)
.await;
if let Err(e) = repo_insert {
error!("Error initializing repo: {:?}", e);
return (
@@ -581,13 +542,11 @@ pub async fn create_account(
)
.into_response();
}
if let Some(code) = &input.invite_code {
let use_insert =
sqlx::query!("INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", code, user_id)
.execute(&mut *tx)
.await;
if let Err(e) = use_insert {
error!("Error recording invite usage: {:?}", e);
return (
@@ -597,7 +556,6 @@ pub async fn create_account(
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Error committing transaction: {:?}", e);
return (
@@ -606,7 +564,25 @@ pub async fn create_account(
)
.into_response();
}
if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await {
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await {
warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": input.handle
});
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did,
"app.bsky.actor.profile",
"self",
&profile_record,
).await {
warn!("Failed to create default profile for {}: {}", did, e);
}
if let Err(e) = crate::notifications::enqueue_signup_verification(
&state.db,
user_id,
@@ -616,11 +592,10 @@ pub async fn create_account(
).await {
warn!("Failed to enqueue signup verification notification: {:?}", e);
}
(
StatusCode::OK,
Json(CreateAccountOutput {
handle: input.handle,
handle: short_handle.to_string(),
did,
verification_required: true,
verification_channel: verification_channel.to_string(),
+6 -62
View File
@@ -12,19 +12,16 @@ use k256::elliptic_curve::sec1::ToEncodedPoint;
use reqwest;
use serde::Deserialize;
use serde_json::json;
use tracing::error;
use tracing::{error, warn};
#[derive(Deserialize)]
pub struct ResolveHandleParams {
pub handle: String,
}
pub async fn resolve_handle(
State(state): State<AppState>,
Query(params): Query<ResolveHandleParams>,
) -> Response {
let handle = params.handle.trim();
if handle.is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -32,12 +29,10 @@ pub async fn resolve_handle(
)
.into_response();
}
let cache_key = format!("handle:{}", handle);
if let Some(did) = state.cache.get(&cache_key).await {
return (StatusCode::OK, Json(json!({ "did": did }))).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let suffix = format!(".{}", hostname);
let short_handle = if handle.ends_with(&suffix) {
@@ -45,11 +40,9 @@ pub async fn resolve_handle(
} else {
handle
};
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle)
.fetch_optional(&state.db)
.await;
match user {
Ok(Some(row)) => {
let _ = state.cache.set(&cache_key, &row.did, std::time::Duration::from_secs(300)).await;
@@ -70,7 +63,6 @@ pub async fn resolve_handle(
}
}
}
pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
let public_key = secret_key.public_key();
@@ -79,7 +71,6 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
let y = encoded.y().ok_or("Missing y coordinate")?;
let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(x);
let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(y);
Ok(json!({
"kty": "EC",
"crv": "secp256k1",
@@ -87,7 +78,6 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
"y": y_b64
}))
}
pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
// Kinda for local dev, encode hostname if it contains port
@@ -96,7 +86,6 @@ pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse
} else {
format!("did:web:{}", hostname)
};
Json(json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
@@ -107,14 +96,11 @@ pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse
}]
}))
}
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
.await;
let (user_id, did) = match user {
Ok(Some(row)) => (row.id, row.did),
Ok(None) => {
@@ -129,7 +115,6 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response();
}
};
if !did.starts_with("did:web:") {
return (
StatusCode::NOT_FOUND,
@@ -137,11 +122,9 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
)
.into_response();
}
let key_row = sqlx::query!("SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user_id)
.fetch_optional(&state.db)
.await;
let key_bytes: Vec<u8> = match key_row {
Ok(Some(row)) => {
match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
@@ -163,7 +146,6 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response();
}
};
let jwk = match get_jwk(&key_bytes) {
Ok(j) => j,
Err(e) => {
@@ -175,7 +157,6 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response();
}
};
Json(json!({
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
"id": did,
@@ -193,14 +174,12 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}]
})).into_response()
}
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
let expected_prefix = if hostname.contains(':') {
format!("did:web:{}", hostname.replace(':', "%3A"))
} else {
format!("did:web:{}", hostname)
};
if did.starts_with(&expected_prefix) {
let suffix = &did[expected_prefix.len()..];
let expected_suffix = format!(":u:{}", handle);
@@ -217,53 +196,42 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
return Err("Invalid did:web format".into());
}
let domain_segment = parts[2];
let domain = domain_segment.replace("%3A", ":");
let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
"http"
} else {
"https"
};
let url = if parts.len() == 3 {
format!("{}://{}/.well-known/did.json", scheme, domain)
} else {
let path = parts[3..].join("/");
format!("{}://{}/{}/did.json", scheme, domain, path)
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create client: {}", e))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
}
let doc: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
let services = doc["service"]
.as_array()
.ok_or("No services found in DID doc")?;
let pds_endpoint = format!("https://{}", hostname);
let has_valid_service = services.iter().any(|s| {
s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint
});
if has_valid_service {
Ok(())
} else {
@@ -274,7 +242,6 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
}
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetRecommendedDidCredentialsOutput {
@@ -283,19 +250,16 @@ pub struct GetRecommendedDidCredentialsOutput {
pub verification_methods: VerificationMethods,
pub services: Services,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerificationMethods {
pub atproto: String,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Services {
pub atproto_pds: AtprotoPds,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AtprotoPds {
@@ -303,7 +267,6 @@ pub struct AtprotoPds {
pub service_type: String,
pub endpoint: String,
}
pub async fn get_recommended_did_credentials(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -320,12 +283,10 @@ pub async fn get_recommended_did_credentials(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let user = match sqlx::query!("SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", auth_user.did)
.fetch_optional(&state.db)
.await
@@ -333,20 +294,16 @@ pub async fn get_recommended_did_credentials(
Ok(Some(row)) => row,
_ => return ApiError::InternalError.into_response(),
};
let key_bytes = match auth_user.key_bytes {
Some(kb) => kb,
None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot get DID credentials".into()).into_response(),
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_endpoint = format!("https://{}", hostname);
let secret_key = match k256::SecretKey::from_slice(&key_bytes) {
Ok(k) => k,
Err(_) => return ApiError::InternalError.into_response(),
};
let public_key = secret_key.public_key();
let encoded = public_key.to_encoded_point(true);
let did_key = format!(
@@ -356,7 +313,6 @@ pub async fn get_recommended_did_credentials(
.skip(1)
.collect::<String>()
);
(
StatusCode::OK,
Json(GetRecommendedDidCredentialsOutput {
@@ -373,12 +329,10 @@ pub async fn get_recommended_did_credentials(
)
.into_response()
}
#[derive(Deserialize)]
pub struct UpdateHandleInput {
pub handle: String,
}
pub async fn update_handle(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -390,12 +344,10 @@ pub async fn update_handle(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let did = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -403,12 +355,10 @@ pub async fn update_handle(
Ok(Some(id)) => id,
_ => return ApiError::InternalError.into_response(),
};
let new_handle = input.handle.trim();
if new_handle.is_empty() {
return ApiError::InvalidRequest("handle is required".into()).into_response();
}
if !new_handle
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
@@ -419,17 +369,14 @@ pub async fn update_handle(
)
.into_response();
}
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND id != $2", new_handle, user_id)
.fetch_optional(&state.db)
.await;
if let Ok(Some(_)) = existing {
return (
StatusCode::BAD_REQUEST,
@@ -437,17 +384,20 @@ pub async fn update_handle(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE id = $2", new_handle, user_id)
.execute(&state.db)
.await;
match result {
Ok(_) => {
if let Some(old) = old_handle {
let _ = state.cache.delete(&format!("handle:{}", old)).await;
}
let _ = state.cache.delete(&format!("handle:{}", new_handle)).await;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let full_handle = format!("{}.{}", new_handle, hostname);
if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await {
warn!("Failed to sequence identity event for handle update: {}", e);
}
(StatusCode::OK, Json(json!({}))).into_response()
}
Err(e) => {
@@ -460,7 +410,6 @@ pub async fn update_handle(
}
}
}
pub async fn well_known_atproto_did(
State(state): State<AppState>,
headers: HeaderMap,
@@ -469,22 +418,17 @@ pub async fn well_known_atproto_did(
Some(h) => h,
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let suffix = format!(".{}", hostname);
let handle = host.split(':').next().unwrap_or(host);
let short_handle = if handle.ends_with(&suffix) {
handle.strip_suffix(&suffix).unwrap_or(handle)
} else {
return (StatusCode::NOT_FOUND, "Handle not found").into_response();
};
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle)
.fetch_optional(&state.db)
.await;
match user {
Ok(Some(row)) => row.did.into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Handle not found").into_response(),
-1
View File
@@ -1,7 +1,6 @@
pub mod account;
pub mod did;
pub mod plc;
pub use account::create_account;
pub use did::{
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_did,
-1
View File
@@ -1,7 +1,6 @@
mod request;
mod sign;
mod submit;
pub use request::request_plc_operation_signature;
pub use sign::{sign_plc_operation, ServiceInput, SignPlcOperationInput, SignPlcOperationOutput};
pub use submit::{submit_plc_operation, SubmitPlcOperationInput};
-11
View File
@@ -9,11 +9,9 @@ use axum::{
use chrono::{Duration, Utc};
use serde_json::json;
use tracing::{error, info, warn};
fn generate_plc_token() -> String {
crate::util::generate_token_code()
}
pub async fn request_plc_operation_signature(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -24,12 +22,10 @@ pub async fn request_plc_operation_signature(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
.await
@@ -41,17 +37,14 @@ pub async fn request_plc_operation_signature(
return ApiError::InternalError.into_response();
}
};
let _ = sqlx::query!(
"DELETE FROM plc_operation_tokens WHERE user_id = $1 OR expires_at < NOW()",
user.id
)
.execute(&state.db)
.await;
let plc_token = generate_plc_token();
let expires_at = Utc::now() + Duration::minutes(10);
if let Err(e) = sqlx::query!(
r#"
INSERT INTO plc_operation_tokens (user_id, token, expires_at)
@@ -71,9 +64,7 @@ pub async fn request_plc_operation_signature(
)
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::notifications::enqueue_plc_operation(
&state.db,
user.id,
@@ -84,8 +75,6 @@ pub async fn request_plc_operation_signature(
{
warn!("Failed to enqueue PLC operation notification: {:?}", e);
}
info!("PLC operation signature requested for user {}", auth_user.did);
(StatusCode::OK, Json(json!({}))).into_response()
}
-22
View File
@@ -16,7 +16,6 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::{error, info, warn};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignPlcOperationInput {
@@ -26,19 +25,16 @@ pub struct SignPlcOperationInput {
pub verification_methods: Option<HashMap<String, String>>,
pub services: Option<HashMap<String, ServiceInput>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceInput {
#[serde(rename = "type")]
pub service_type: String,
pub endpoint: String,
}
#[derive(Debug, Serialize)]
pub struct SignPlcOperationOutput {
pub operation: Value,
}
pub async fn sign_plc_operation(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -50,14 +46,11 @@ pub async fn sign_plc_operation(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let did = &auth_user.did;
let token = match &input.token {
Some(t) => t,
None => {
@@ -66,7 +59,6 @@ pub async fn sign_plc_operation(
).into_response();
}
};
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -80,7 +72,6 @@ pub async fn sign_plc_operation(
.into_response();
}
};
let token_row = match sqlx::query!(
"SELECT id, expires_at FROM plc_operation_tokens WHERE user_id = $1 AND token = $2",
user.id,
@@ -109,7 +100,6 @@ pub async fn sign_plc_operation(
.into_response();
}
};
if Utc::now() > token_row.expires_at {
let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id)
.execute(&state.db)
@@ -123,7 +113,6 @@ pub async fn sign_plc_operation(
)
.into_response();
}
let key_row = match sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user.id
@@ -140,7 +129,6 @@ pub async fn sign_plc_operation(
.into_response();
}
};
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
{
Ok(k) => k,
@@ -153,7 +141,6 @@ pub async fn sign_plc_operation(
.into_response();
}
};
let signing_key = match SigningKey::from_slice(&key_bytes) {
Ok(k) => k,
Err(e) => {
@@ -165,7 +152,6 @@ pub async fn sign_plc_operation(
.into_response();
}
};
let plc_client = PlcClient::new(None);
let did_clone = did.clone();
let result: Result<PlcOpOrTombstone, CircuitBreakerError<PlcError>> = with_circuit_breaker(
@@ -173,7 +159,6 @@ pub async fn sign_plc_operation(
|| async { plc_client.get_last_op(&did_clone).await },
)
.await;
let last_op = match result {
Ok(op) => op,
Err(CircuitBreakerError::CircuitOpen(e)) => {
@@ -209,7 +194,6 @@ pub async fn sign_plc_operation(
.into_response();
}
};
if last_op.is_tombstone() {
return (
StatusCode::BAD_REQUEST,
@@ -220,7 +204,6 @@ pub async fn sign_plc_operation(
)
.into_response();
}
let services = input.services.map(|s| {
s.into_iter()
.map(|(k, v)| {
@@ -234,7 +217,6 @@ pub async fn sign_plc_operation(
})
.collect()
});
let unsigned_op = match create_update_op(
&last_op,
input.rotation_keys,
@@ -262,7 +244,6 @@ pub async fn sign_plc_operation(
.into_response();
}
};
let signed_op = match sign_operation(&unsigned_op, &signing_key) {
Ok(op) => op,
Err(e) => {
@@ -274,13 +255,10 @@ pub async fn sign_plc_operation(
.into_response();
}
};
let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id)
.execute(&state.db)
.await;
info!("Signed PLC operation for user {}", did);
(
StatusCode::OK,
Json(SignPlcOperationOutput {
-25
View File
@@ -12,12 +12,10 @@ use k256::ecdsa::SigningKey;
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::{error, info, warn};
#[derive(Debug, Deserialize)]
pub struct SubmitPlcOperationInput {
pub operation: Value,
}
pub async fn submit_plc_operation(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -29,22 +27,17 @@ pub async fn submit_plc_operation(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let did = &auth_user.did;
if let Err(e) = validate_plc_operation(&input.operation) {
return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response();
}
let op = &input.operation;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let public_url = format!("https://{}", hostname);
let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -58,7 +51,6 @@ pub async fn submit_plc_operation(
.into_response();
}
};
let key_row = match sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user.id
@@ -75,7 +67,6 @@ pub async fn submit_plc_operation(
.into_response();
}
};
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
{
Ok(k) => k,
@@ -88,7 +79,6 @@ pub async fn submit_plc_operation(
.into_response();
}
};
let signing_key = match SigningKey::from_slice(&key_bytes) {
Ok(k) => k,
Err(e) => {
@@ -100,17 +90,13 @@ pub async fn submit_plc_operation(
.into_response();
}
};
let user_did_key = signing_key_to_did_key(&signing_key);
if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) {
let server_rotation_key =
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
let has_server_key = rotation_keys
.iter()
.any(|k| k.as_str() == Some(&server_rotation_key));
if !has_server_key {
return (
StatusCode::BAD_REQUEST,
@@ -122,12 +108,10 @@ pub async fn submit_plc_operation(
.into_response();
}
}
if let Some(services) = op.get("services").and_then(|v| v.as_object()) {
if let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) {
let service_type = pds.get("type").and_then(|v| v.as_str());
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
if service_type != Some("AtprotoPersonalDataServer") {
return (
StatusCode::BAD_REQUEST,
@@ -138,7 +122,6 @@ pub async fn submit_plc_operation(
)
.into_response();
}
if endpoint != Some(&public_url) {
return (
StatusCode::BAD_REQUEST,
@@ -151,7 +134,6 @@ pub async fn submit_plc_operation(
}
}
}
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) {
if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) {
if atproto_key != user_did_key {
@@ -166,11 +148,9 @@ pub async fn submit_plc_operation(
}
}
}
if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) {
let expected_handle = format!("at://{}", user.handle);
let first_aka = also_known_as.first().and_then(|v| v.as_str());
if first_aka != Some(&expected_handle) {
return (
StatusCode::BAD_REQUEST,
@@ -182,7 +162,6 @@ pub async fn submit_plc_operation(
.into_response();
}
}
let plc_client = PlcClient::new(None);
let operation_clone = input.operation.clone();
let did_clone = did.clone();
@@ -191,7 +170,6 @@ pub async fn submit_plc_operation(
|| async { plc_client.send_operation(&did_clone, &operation_clone).await },
)
.await;
match result {
Ok(()) => {}
Err(CircuitBreakerError::CircuitOpen(e)) => {
@@ -217,7 +195,6 @@ pub async fn submit_plc_operation(
.into_response();
}
}
match sqlx::query!(
"INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity') RETURNING seq",
did
@@ -237,8 +214,6 @@ pub async fn submit_plc_operation(
warn!("Failed to sequence identity event: {:?}", e);
}
}
info!("Submitted PLC operation for user {}", did);
(StatusCode::OK, Json(json!({}))).into_response()
}
-1
View File
@@ -13,6 +13,5 @@ pub mod repo;
pub mod server;
pub mod temp;
pub mod validation;
pub use error::ApiError;
pub use proxy_client::{proxy_client, validate_at_uri, validate_did, validate_limit, AtUriParts};
-10
View File
@@ -9,7 +9,6 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::error;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateReportInput {
@@ -17,7 +16,6 @@ pub struct CreateReportInput {
pub reason: Option<String>,
pub subject: Value,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateReportOutput {
@@ -28,7 +26,6 @@ pub struct CreateReportOutput {
pub reported_by: String,
pub created_at: String,
}
pub async fn create_report(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -40,12 +37,10 @@ pub async fn create_report(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let did = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
let valid_reason_types = [
"com.atproto.moderation.defs#reasonSpam",
"com.atproto.moderation.defs#reasonViolation",
@@ -55,7 +50,6 @@ pub async fn create_report(
"com.atproto.moderation.defs#reasonOther",
"com.atproto.moderation.defs#reasonAppeal",
];
if !valid_reason_types.contains(&input.reason_type.as_str()) {
return (
StatusCode::BAD_REQUEST,
@@ -63,10 +57,8 @@ pub async fn create_report(
)
.into_response();
}
let created_at = chrono::Utc::now();
let report_id = created_at.timestamp_millis();
let subject_json = json!(input.subject);
let insert = sqlx::query!(
"INSERT INTO reports (id, reason_type, reason, subject_json, reported_by_did, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
@@ -79,7 +71,6 @@ pub async fn create_report(
)
.execute(&state.db)
.await;
if let Err(e) = insert {
error!("Failed to insert report: {:?}", e);
return (
@@ -88,7 +79,6 @@ pub async fn create_report(
)
.into_response();
}
(
StatusCode::OK,
Json(CreateReportOutput {
-1
View File
@@ -1,3 +1,2 @@
mod register_push;
pub use register_push::register_push;
-16
View File
@@ -10,7 +10,6 @@ use axum::{
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterPushInput {
@@ -19,9 +18,7 @@ pub struct RegisterPushInput {
pub platform: String,
pub app_id: String,
}
const VALID_PLATFORMS: &[&str] = &["ios", "android", "web"];
pub async fn register_push(
State(state): State<AppState>,
headers: HeaderMap,
@@ -33,20 +30,16 @@ pub async fn register_push(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = validate_did(&input.service_did) {
return ApiError::InvalidRequest(format!("Invalid serviceDid: {}", e)).into_response();
}
if input.token.is_empty() || input.token.len() > 4096 {
return ApiError::InvalidRequest("Invalid push token".to_string()).into_response();
}
if !VALID_PLATFORMS.contains(&input.platform.as_str()) {
return ApiError::InvalidRequest(format!(
"Invalid platform. Must be one of: {}",
@@ -54,11 +47,9 @@ pub async fn register_push(
))
.into_response();
}
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(_) => {
@@ -66,13 +57,11 @@ pub async fn register_push(
.into_response();
}
};
if let Err(e) = is_ssrf_safe(&appview_url) {
error!("SSRF check failed for appview URL: {}", e);
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
.into_response();
}
let key_row = match sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1",
auth_user.did
@@ -90,7 +79,6 @@ pub async fn register_push(
return ApiError::DatabaseError.into_response();
}
};
let decrypted_key =
match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) {
Ok(k) => k,
@@ -99,7 +87,6 @@ pub async fn register_push(
return ApiError::InternalError.into_response();
}
};
let service_token = match crate::auth::create_service_token(
&auth_user.did,
&input.service_did,
@@ -112,7 +99,6 @@ pub async fn register_push(
return ApiError::InternalError.into_response();
}
};
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", appview_url);
info!(
target = %target_url,
@@ -120,7 +106,6 @@ pub async fn register_push(
platform = %input.platform,
"Proxying registerPush request"
);
let client = proxy_client();
let request_body = json!({
"serviceDid": input.service_did,
@@ -128,7 +113,6 @@ pub async fn register_push(
"platform": input.platform,
"appId": input.app_id
});
match client
.post(&target_url)
.header("Authorization", format!("Bearer {}", service_token))
-23
View File
@@ -8,10 +8,8 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::Row;
use tracing::info;
use crate::auth::validate_bearer_token;
use crate::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPrefsResponse {
@@ -24,7 +22,6 @@ pub struct NotificationPrefsResponse {
pub signal_number: Option<String>,
pub signal_verified: bool,
}
pub async fn get_notification_prefs(
State(state): State<AppState>,
headers: HeaderMap,
@@ -41,7 +38,6 @@ pub async fn get_notification_prefs(
.into_response()
}
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
Err(_) => {
@@ -52,7 +48,6 @@ pub async fn get_notification_prefs(
.into_response()
}
};
let row = match sqlx::query(
r#"
SELECT
@@ -81,7 +76,6 @@ pub async fn get_notification_prefs(
.into_response()
}
};
let email: String = row.get("email");
let channel: String = row.get("channel");
let discord_id: Option<String> = row.get("discord_id");
@@ -90,7 +84,6 @@ pub async fn get_notification_prefs(
let telegram_verified: bool = row.get("telegram_verified");
let signal_number: Option<String> = row.get("signal_number");
let signal_verified: bool = row.get("signal_verified");
Json(NotificationPrefsResponse {
preferred_channel: channel,
email,
@@ -103,7 +96,6 @@ pub async fn get_notification_prefs(
})
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNotificationPrefsInput {
@@ -112,7 +104,6 @@ pub struct UpdateNotificationPrefsInput {
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
}
pub async fn update_notification_prefs(
State(state): State<AppState>,
headers: HeaderMap,
@@ -130,7 +121,6 @@ pub async fn update_notification_prefs(
.into_response()
}
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
Err(_) => {
@@ -141,7 +131,6 @@ pub async fn update_notification_prefs(
.into_response()
}
};
if let Some(ref channel) = input.preferred_channel {
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&channel.as_str()) {
@@ -154,7 +143,6 @@ pub async fn update_notification_prefs(
)
.into_response();
}
if let Err(e) = sqlx::query(
r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"#
)
@@ -169,17 +157,14 @@ pub async fn update_notification_prefs(
)
.into_response();
}
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
}
if let Some(ref discord_id) = input.discord_id {
let discord_id_clean: Option<&str> = if discord_id.is_empty() {
None
} else {
Some(discord_id.as_str())
};
if let Err(e) = sqlx::query(
r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"#
)
@@ -194,17 +179,14 @@ pub async fn update_notification_prefs(
)
.into_response();
}
info!(did = %user.did, "Updated Discord ID");
}
if let Some(ref telegram) = input.telegram_username {
let telegram_clean: Option<&str> = if telegram.is_empty() {
None
} else {
Some(telegram.trim_start_matches('@'))
};
if let Err(e) = sqlx::query(
r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"#
)
@@ -219,13 +201,10 @@ pub async fn update_notification_prefs(
)
.into_response();
}
info!(did = %user.did, "Updated Telegram username");
}
if let Some(ref signal) = input.signal_number {
let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) };
if let Err(e) = sqlx::query(
r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"#
)
@@ -240,9 +219,7 @@ pub async fn update_notification_prefs(
)
.into_response();
}
info!(did = %user.did, "Updated Signal number");
}
Json(json!({"success": true})).into_response()
}
-15
View File
@@ -8,7 +8,6 @@ use axum::{
use crate::api::proxy_client::proxy_client;
use std::collections::HashMap;
use tracing::{error, info};
pub async fn proxy_handler(
State(state): State<AppState>,
Path(method): Path<String>,
@@ -21,7 +20,6 @@ pub async fn proxy_handler(
.get("atproto-proxy")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let appview_url = match &proxy_header {
Some(url) => url.clone(),
None => match std::env::var("APPVIEW_URL") {
@@ -31,17 +29,11 @@ pub async fn proxy_handler(
}
},
};
let target_url = 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 auth_header_val = headers.get("Authorization").map(|h| h.clone());
if let Some(aud) = &proxy_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
@@ -61,19 +53,15 @@ pub async fn proxy_handler(
}
}
}
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);
}
}
request_builder = request_builder.body(body);
match request_builder.send().await {
Ok(resp) => {
let status = resp.status();
@@ -86,13 +74,10 @@ pub async fn proxy_handler(
.into_response();
}
};
let mut response_builder = Response::builder().status(status);
for (key, value) in headers.iter() {
response_builder = response_builder.header(key, value);
}
match response_builder.body(axum::body::Body::from(body)) {
Ok(r) => r,
Err(e) => {
-39
View File
@@ -3,14 +3,11 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::OnceLock;
use std::time::Duration;
use tracing::warn;
pub const DEFAULT_HEADERS_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_BODY_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024;
static PROXY_CLIENT: OnceLock<Client> = OnceLock::new();
pub fn proxy_client() -> &'static Client {
PROXY_CLIENT.get_or_init(|| {
ClientBuilder::new()
@@ -23,27 +20,21 @@ pub fn proxy_client() -> &'static Client {
.expect("Failed to build HTTP client - this indicates a TLS or system configuration issue")
})
}
pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
let scheme = parsed.scheme();
if scheme != "https" {
let allow_http = std::env::var("ALLOW_HTTP_PROXY").is_ok()
|| url.starts_with("http://127.0.0.1")
|| url.starts_with("http://localhost");
if !allow_http {
return Err(SsrfError::InsecureProtocol(scheme.to_string()));
}
}
let host = parsed.host_str().ok_or(SsrfError::NoHost)?;
if host == "localhost" {
return Ok(());
}
if let Ok(ip) = host.parse::<IpAddr>() {
if ip.is_loopback() {
return Ok(());
@@ -53,13 +44,11 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
}
return Ok(());
}
let port = parsed.port().unwrap_or(if scheme == "https" { 443 } else { 80 });
let socket_addrs: Vec<SocketAddr> = match (host, port).to_socket_addrs() {
Ok(addrs) => addrs.collect(),
Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())),
};
for addr in &socket_addrs {
if !is_unicast_ip(&addr.ip()) {
warn!(
@@ -70,10 +59,8 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
return Err(SsrfError::NonUnicastIp(addr.ip().to_string()));
}
}
Ok(())
}
fn is_unicast_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
@@ -87,7 +74,6 @@ fn is_unicast_ip(ip: &IpAddr) -> bool {
IpAddr::V6(v6) => !v6.is_loopback() && !v6.is_multicast() && !v6.is_unspecified(),
}
}
fn is_private_v4(ip: &std::net::Ipv4Addr) -> bool {
let octets = ip.octets();
octets[0] == 10
@@ -95,7 +81,6 @@ fn is_private_v4(ip: &std::net::Ipv4Addr) -> bool {
|| (octets[0] == 192 && octets[1] == 168)
|| (octets[0] == 169 && octets[1] == 254)
}
#[derive(Debug, Clone)]
pub enum SsrfError {
InvalidUrl,
@@ -104,7 +89,6 @@ pub enum SsrfError {
NonUnicastIp(String),
DnsResolutionFailed(String),
}
impl std::fmt::Display for SsrfError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -116,60 +100,49 @@ impl std::fmt::Display for SsrfError {
}
}
}
impl std::error::Error for SsrfError {}
pub const HEADERS_TO_FORWARD: &[&str] = &[
"accept-language",
"atproto-accept-labelers",
"x-bsky-topics",
];
pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
"atproto-repo-rev",
"atproto-content-labelers",
"retry-after",
"content-type",
];
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
if !uri.starts_with("at://") {
return Err("URI must start with at://");
}
let path = uri.trim_start_matches("at://");
let parts: Vec<&str> = path.split('/').collect();
if parts.is_empty() {
return Err("URI missing DID");
}
let did = parts[0];
if !did.starts_with("did:") {
return Err("Invalid DID in URI");
}
if parts.len() > 1 {
let collection = parts[1];
if collection.is_empty() || !collection.contains('.') {
return Err("Invalid collection NSID");
}
}
Ok(AtUriParts {
did: did.to_string(),
collection: parts.get(1).map(|s| s.to_string()),
rkey: parts.get(2).map(|s| s.to_string()),
})
}
#[derive(Debug, Clone)]
pub struct AtUriParts {
pub did: String,
pub collection: Option<String>,
pub rkey: Option<String>,
}
pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
match limit {
Some(l) if l == 0 => default,
@@ -178,46 +151,37 @@ pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
None => default,
}
}
pub fn validate_did(did: &str) -> Result<(), &'static str> {
if !did.starts_with("did:") {
return Err("Invalid DID format");
}
let parts: Vec<&str> = did.split(':').collect();
if parts.len() < 3 {
return Err("DID must have at least method and identifier");
}
let method = parts[1];
if method != "plc" && method != "web" {
return Err("Unsupported DID method");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssrf_safe_https() {
assert!(is_ssrf_safe("https://api.bsky.app/xrpc/test").is_ok());
}
#[test]
fn test_ssrf_blocks_http_by_default() {
let result = is_ssrf_safe("http://external.example.com/xrpc/test");
assert!(matches!(result, Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_))));
}
#[test]
fn test_ssrf_allows_localhost_http() {
assert!(is_ssrf_safe("http://127.0.0.1:8080/test").is_ok());
assert!(is_ssrf_safe("http://localhost:8080/test").is_ok());
}
#[test]
fn test_validate_at_uri() {
let result = validate_at_uri("at://did:plc:test/app.bsky.feed.post/abc123");
@@ -227,13 +191,11 @@ mod tests {
assert_eq!(parts.collection, Some("app.bsky.feed.post".to_string()));
assert_eq!(parts.rkey, Some("abc123".to_string()));
}
#[test]
fn test_validate_at_uri_invalid() {
assert!(validate_at_uri("https://example.com").is_err());
assert!(validate_at_uri("at://notadid/collection/rkey").is_err());
}
#[test]
fn test_validate_limit() {
assert_eq!(validate_limit(None, 50, 100), 50);
@@ -241,7 +203,6 @@ mod tests {
assert_eq!(validate_limit(Some(200), 50, 100), 100);
assert_eq!(validate_limit(Some(75), 50, 100), 75);
}
#[test]
fn test_validate_did() {
assert!(validate_did("did:plc:abc123").is_ok());
-50
View File
@@ -17,10 +17,8 @@ use serde_json::Value;
use std::collections::HashMap;
use tracing::{error, info, warn};
use uuid::Uuid;
pub const REPO_REV_HEADER: &str = "atproto-repo-rev";
pub const UPSTREAM_LAG_HEADER: &str = "atproto-upstream-lag";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostRecord {
@@ -41,7 +39,6 @@ pub struct PostRecord {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRecord {
@@ -58,7 +55,6 @@ pub struct ProfileRecord {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone)]
pub struct RecordDescript<T> {
pub uri: String,
@@ -66,7 +62,6 @@ pub struct RecordDescript<T> {
pub indexed_at: DateTime<Utc>,
pub record: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeRecord {
@@ -77,14 +72,12 @@ pub struct LikeRecord {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeSubject {
pub uri: String,
pub cid: String,
}
#[derive(Debug, Default)]
pub struct LocalRecords {
pub count: usize,
@@ -92,20 +85,17 @@ pub struct LocalRecords {
pub posts: Vec<RecordDescript<PostRecord>>,
pub likes: Vec<RecordDescript<LikeRecord>>,
}
pub async fn get_records_since_rev(
state: &AppState,
did: &str,
rev: &str,
) -> Result<LocalRecords, String> {
let mut result = LocalRecords::default();
let user_id: Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
.map_err(|e| format!("DB error: {}", e))?
.ok_or_else(|| "User not found".to_string())?;
let rows = sqlx::query!(
r#"
SELECT record_cid, collection, rkey, created_at, repo_rev
@@ -120,11 +110,9 @@ pub async fn get_records_since_rev(
.fetch_all(&state.db)
.await
.map_err(|e| format!("DB error fetching records: {}", e))?;
if rows.is_empty() {
return Ok(result);
}
let sanity_check = sqlx::query_scalar!(
"SELECT 1 as val FROM records WHERE repo_id = $1 AND repo_rev <= $2 LIMIT 1",
user_id,
@@ -133,22 +121,18 @@ pub async fn get_records_since_rev(
.fetch_optional(&state.db)
.await
.map_err(|e| format!("DB error sanity check: {}", e))?;
if sanity_check.is_none() {
warn!("Sanity check failed: no records found before rev {}", rev);
return Ok(result);
}
struct RowData {
cid_str: String,
collection: String,
rkey: String,
created_at: DateTime<Utc>,
}
let mut row_data: Vec<RowData> = Vec::with_capacity(rows.len());
let mut cids: Vec<Cid> = Vec::with_capacity(rows.len());
for row in &rows {
if let Ok(cid) = row.record_cid.parse::<Cid>() {
cids.push(cid);
@@ -160,22 +144,18 @@ pub async fn get_records_since_rev(
});
}
}
let blocks: Vec<Option<Bytes>> = state
.block_store
.get_many(&cids)
.await
.map_err(|e| format!("Error fetching blocks: {}", e))?;
for (data, block_opt) in row_data.into_iter().zip(blocks.into_iter()) {
let block_bytes = match block_opt {
Some(b) => b,
None => continue,
};
result.count += 1;
let uri = format!("at://{}/{}/{}", did, data.collection, data.rkey);
if data.collection == "app.bsky.actor.profile" && data.rkey == "self" {
if let Ok(record) = serde_ipld_dagcbor::from_slice::<ProfileRecord>(&block_bytes) {
result.profile = Some(RecordDescript {
@@ -205,13 +185,10 @@ pub async fn get_records_since_rev(
}
}
}
Ok(result)
}
pub fn get_local_lag(local: &LocalRecords) -> Option<i64> {
let mut oldest: Option<DateTime<Utc>> = local.profile.as_ref().map(|p| p.indexed_at);
for post in &local.posts {
match oldest {
None => oldest = Some(post.indexed_at),
@@ -219,7 +196,6 @@ pub fn get_local_lag(local: &LocalRecords) -> Option<i64> {
_ => {}
}
}
for like in &local.likes {
match oldest {
None => oldest = Some(like.indexed_at),
@@ -227,24 +203,20 @@ pub fn get_local_lag(local: &LocalRecords) -> Option<i64> {
_ => {}
}
}
oldest.map(|o| (Utc::now() - o).num_milliseconds())
}
pub fn extract_repo_rev(headers: &HeaderMap) -> Option<String> {
headers
.get(REPO_REV_HEADER)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
}
#[derive(Debug)]
pub struct ProxyResponse {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: bytes::Bytes,
}
pub async fn proxy_to_appview(
method: &str,
params: &HashMap<String, String>,
@@ -253,28 +225,22 @@ pub async fn proxy_to_appview(
let appview_url = std::env::var("APPVIEW_URL").map_err(|_| {
ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()).into_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());
}
let target_url = format!("{}/xrpc/{}", appview_url, method);
info!(target = %target_url, "Proxying request to appview");
let client = proxy_client();
let mut request_builder = client.get(&target_url).query(params);
if let Some(auth) = auth_header {
request_builder = request_builder.header("Authorization", auth);
}
match request_builder.send().await {
Ok(resp) => {
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let headers: HeaderMap = resp
.headers()
.iter()
@@ -289,7 +255,6 @@ pub async fn proxy_to_appview(
Some((name, value))
})
.collect();
let content_length = resp
.content_length()
.unwrap_or(0);
@@ -301,12 +266,10 @@ pub async fn proxy_to_appview(
);
return Err(ApiError::UpstreamFailure.into_response());
}
let body = resp.bytes().await.map_err(|e| {
error!(error = ?e, "Error reading proxy response body");
ApiError::UpstreamFailure.into_response()
})?;
if body.len() as u64 > MAX_RESPONSE_SIZE {
error!(
len = body.len(),
@@ -315,7 +278,6 @@ pub async fn proxy_to_appview(
);
return Err(ApiError::UpstreamFailure.into_response());
}
Ok(ProxyResponse {
status,
headers,
@@ -335,10 +297,8 @@ pub async fn proxy_to_appview(
}
}
}
pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Response {
let mut response = (StatusCode::OK, Json(data)).into_response();
if let Some(lag_ms) = lag {
if let Ok(header_val) = HeaderValue::from_str(&lag_ms.to_string()) {
response
@@ -346,10 +306,8 @@ pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Respon
.insert(UPSTREAM_LAG_HEADER, header_val);
}
}
response
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorView {
@@ -362,7 +320,6 @@ pub struct AuthorView {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostView {
@@ -384,7 +341,6 @@ pub struct PostView {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeedViewPost {
@@ -398,14 +354,12 @@ pub struct FeedViewPost {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedOutput {
pub feed: Vec<FeedViewPost>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
pub fn format_local_post(
descript: &RecordDescript<PostRecord>,
author_did: &str,
@@ -413,7 +367,6 @@ pub fn format_local_post(
profile: Option<&RecordDescript<ProfileRecord>>,
) -> PostView {
let display_name = profile.and_then(|p| p.record.display_name.clone());
PostView {
uri: descript.uri.clone(),
cid: descript.cid.clone(),
@@ -434,12 +387,10 @@ pub fn format_local_post(
extra: HashMap::new(),
}
}
pub fn insert_posts_into_feed(feed: &mut Vec<FeedViewPost>, posts: Vec<PostView>) {
if posts.is_empty() {
return;
}
let new_items: Vec<FeedViewPost> = posts
.into_iter()
.map(|post| FeedViewPost {
@@ -450,7 +401,6 @@ pub fn insert_posts_into_feed(feed: &mut Vec<FeedViewPost>, posts: Vec<PostView>
extra: HashMap::new(),
})
.collect();
feed.extend(new_items);
feed.sort_by(|a, b| b.post.indexed_at.cmp(&a.post.indexed_at));
}
-39
View File
@@ -14,9 +14,7 @@ use serde_json::json;
use sha2::{Digest, Sha256};
use std::str::FromStr;
use tracing::error;
const MAX_BLOB_SIZE: usize = 1_000_000;
pub async fn upload_blob(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -29,7 +27,6 @@ pub async fn upload_blob(
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
@@ -42,7 +39,6 @@ pub async fn upload_blob(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
@@ -54,16 +50,13 @@ pub async fn upload_blob(
}
};
let did = auth_user.did;
let mime_type = headers
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let size = body.len() as i64;
let data = body.to_vec();
let mut hasher = Sha256::new();
hasher.update(&data);
let hash = hasher.finalize();
@@ -80,13 +73,10 @@ pub async fn upload_blob(
};
let cid = Cid::new_v1(0x55, multihash);
let cid_str = cid.to_string();
let storage_key = format!("blobs/{}", cid_str);
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
let user_id = match user_query {
Ok(Some(row)) => row.id,
_ => {
@@ -97,7 +87,6 @@ pub async fn upload_blob(
.into_response();
}
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
@@ -109,7 +98,6 @@ pub async fn upload_blob(
.into_response();
}
};
let insert = sqlx::query!(
"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cid) DO NOTHING RETURNING cid",
cid_str,
@@ -120,7 +108,6 @@ pub async fn upload_blob(
)
.fetch_optional(&mut *tx)
.await;
let was_inserted = match insert {
Ok(Some(_)) => true,
Ok(None) => false,
@@ -133,7 +120,6 @@ pub async fn upload_blob(
.into_response();
}
};
if was_inserted {
if let Err(e) = state.blob_store.put_bytes(&storage_key, bytes::Bytes::from(data)).await {
error!("Failed to upload blob to storage: {:?}", e);
@@ -144,7 +130,6 @@ pub async fn upload_blob(
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit blob transaction: {:?}", e);
if was_inserted {
@@ -158,7 +143,6 @@ pub async fn upload_blob(
)
.into_response();
}
Json(json!({
"blob": {
"ref": {
@@ -170,26 +154,22 @@ pub async fn upload_blob(
}))
.into_response()
}
#[derive(Deserialize)]
pub struct ListMissingBlobsParams {
pub limit: Option<i64>,
pub cursor: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordBlob {
pub cid: String,
pub record_uri: String,
}
#[derive(Serialize)]
pub struct ListMissingBlobsOutput {
pub cursor: Option<String>,
pub blobs: Vec<RecordBlob>,
}
fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
if let Some(obj) = val.as_object() {
if let Some(type_val) = obj.get("$type") {
@@ -212,7 +192,6 @@ fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
}
}
}
pub async fn list_missing_blobs(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -230,7 +209,6 @@ pub async fn list_missing_blobs(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
@@ -241,13 +219,10 @@ pub async fn list_missing_blobs(
.into_response();
}
};
let did = auth_user.did;
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
let user_id = match user_query {
Ok(Some(row)) => row.id,
_ => {
@@ -258,7 +233,6 @@ pub async fn list_missing_blobs(
.into_response();
}
};
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
let cursor_str = params.cursor.unwrap_or_default();
let (cursor_collection, cursor_rkey) = if cursor_str.contains('|') {
@@ -267,7 +241,6 @@ pub async fn list_missing_blobs(
} else {
(String::new(), String::new())
};
let records_query = sqlx::query!(
"SELECT collection, rkey, record_cid FROM records WHERE repo_id = $1 AND (collection, rkey) > ($2, $3) ORDER BY collection, rkey LIMIT $4",
user_id,
@@ -277,7 +250,6 @@ pub async fn list_missing_blobs(
)
.fetch_all(&state.db)
.await;
let records = match records_query {
Ok(r) => r,
Err(e) => {
@@ -289,40 +261,31 @@ pub async fn list_missing_blobs(
.into_response();
}
};
let mut missing_blobs = Vec::new();
let mut last_cursor = None;
for row in &records {
let collection = &row.collection;
let rkey = &row.rkey;
let record_cid_str = &row.record_cid;
last_cursor = Some(format!("{}|{}", collection, rkey));
let record_cid = match Cid::from_str(&record_cid_str) {
Ok(c) => c,
Err(_) => continue,
};
let block_bytes = match state.block_store.get(&record_cid).await {
Ok(Some(b)) => b,
_ => continue,
};
let record_val: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block_bytes) {
Ok(v) => v,
Err(_) => continue,
};
let mut blobs = Vec::new();
find_blobs(&record_val, &mut blobs);
for blob_cid_str in blobs {
let exists = sqlx::query!("SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2", blob_cid_str, user_id)
.fetch_optional(&state.db)
.await;
match exists {
Ok(None) => {
missing_blobs.push(RecordBlob {
@@ -337,7 +300,6 @@ pub async fn list_missing_blobs(
}
}
}
// if we fetched fewer records than limit, we are done, so cursor is None.
// otherwise, cursor is the last one we saw.
// ...right?
@@ -346,7 +308,6 @@ pub async fn list_missing_blobs(
} else {
last_cursor
};
(
StatusCode::OK,
Json(ListMissingBlobsOutput {
+4 -30
View File
@@ -11,10 +11,8 @@ use axum::{
};
use serde_json::json;
use tracing::{debug, error, info, warn};
const DEFAULT_MAX_IMPORT_SIZE: usize = 100 * 1024 * 1024;
const DEFAULT_MAX_BLOCKS: usize = 50000;
pub async fn import_repo(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -23,7 +21,6 @@ pub async fn import_repo(
let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
if !accepting_imports {
return (
StatusCode::BAD_REQUEST,
@@ -34,12 +31,10 @@ pub async fn import_repo(
)
.into_response();
}
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
if body.len() > max_size {
return (
StatusCode::PAYLOAD_TOO_LARGE,
@@ -50,21 +45,17 @@ pub async fn import_repo(
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let did = &auth_user.did;
let user = match sqlx::query!(
"SELECT id, deactivated_at, takedown_ref FROM users WHERE did = $1",
did
@@ -89,7 +80,6 @@ pub async fn import_repo(
.into_response();
}
};
if user.deactivated_at.is_some() {
return (
StatusCode::FORBIDDEN,
@@ -100,7 +90,6 @@ pub async fn import_repo(
)
.into_response();
}
if user.takedown_ref.is_some() {
return (
StatusCode::FORBIDDEN,
@@ -111,9 +100,7 @@ pub async fn import_repo(
)
.into_response();
}
let user_id = user.id;
let (root, blocks) = match parse_car(&body).await {
Ok((r, b)) => (r, b),
Err(ImportError::InvalidRootCount) => {
@@ -148,14 +135,12 @@ pub async fn import_repo(
.into_response();
}
};
info!(
"Importing repo for user {}: {} blocks, root {}",
did,
blocks.len(),
root
);
let root_block = match blocks.get(&root) {
Some(b) => b,
None => {
@@ -169,7 +154,6 @@ pub async fn import_repo(
.into_response();
}
};
let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
Ok(commit) => commit.did().to_string(),
Err(e) => {
@@ -183,7 +167,6 @@ pub async fn import_repo(
.into_response();
}
};
if commit_did != *did {
return (
StatusCode::FORBIDDEN,
@@ -197,15 +180,12 @@ pub async fn import_repo(
)
.into_response();
}
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if !skip_verification {
debug!("Verifying CAR file signature and structure for DID {}", did);
let verifier = CarVerifier::new();
match verifier.verify_car(did, &root, &blocks).await {
Ok(verified) => {
debug!(
@@ -285,12 +265,10 @@ pub async fn import_repo(
} else {
warn!("Skipping CAR signature verification for import (SKIP_IMPORT_VERIFICATION=true)");
}
let max_blocks: usize = std::env::var("MAX_IMPORT_BLOCKS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_BLOCKS);
match apply_import(&state.db, user_id, root, blocks, max_blocks).await {
Ok(records) => {
info!(
@@ -298,11 +276,9 @@ pub async fn import_repo(
records.len(),
did
);
if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await {
warn!("Failed to sequence import event: {:?}", e);
}
(StatusCode::OK, Json(json!({}))).into_response()
}
Err(ImportError::SizeLimitExceeded) => (
@@ -379,36 +355,34 @@ pub async fn import_repo(
}
}
}
async fn sequence_import_event(
state: &AppState,
did: &str,
commit_cid: &str,
) -> Result<(), sqlx::Error> {
let prev_cid: Option<String> = None;
let prev_data_cid: Option<String> = None;
let ops = serde_json::json!([]);
let blobs: Vec<String> = vec![];
let blocks_cids: Vec<String> = vec![];
let seq_row = sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)
VALUES ($1, 'commit', $2, $3, $4, $5, $6)
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids)
VALUES ($1, 'commit', $2, $3, $4, $5, $6, $7)
RETURNING seq
"#,
did,
commit_cid,
prev_cid,
prev_data_cid,
ops,
&blobs,
&blocks_cids
)
.fetch_one(&state.db)
.await?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq))
.execute(&state.db)
.await?;
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More