mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-17 06:46:07 +00:00
Creating & posting records works. Also messed up newlines but will fix later.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -1,10 +1,8 @@
|
||||
/target
|
||||
|
||||
.env
|
||||
|
||||
reference-pds-hailey/
|
||||
reference-pds-bsky/
|
||||
|
||||
reference-relay-indigo/
|
||||
# Frontend build artifacts
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
+28
-4
@@ -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"
|
||||
}
|
||||
+28
-4
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+28
-4
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+28
-4
@@ -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"
|
||||
}
|
||||
+5
-3
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,5 @@
|
||||
PodName=bspds
|
||||
PublishPort=80:80
|
||||
PublishPort=443:443
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import App from './App.svelte'
|
||||
import { mount } from 'svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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">← 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">← 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">← 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">← 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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
svelte({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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,5 +1,4 @@
|
||||
mod preferences;
|
||||
mod profile;
|
||||
|
||||
pub use preferences::{get_preferences, put_preferences};
|
||||
pub use profile::{get_profile, get_profiles};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) = ¶ms.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 {
|
||||
|
||||
@@ -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
@@ -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) = ¶ms.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) = ¶ms.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) = ¶ms.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"})),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) = ¶ms.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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(¶ms.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) = ¶ms.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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶ms.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)
|
||||
}
|
||||
|
||||
@@ -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, ¶ms, &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) = ¶ms.algorithm {
|
||||
query_params.insert("algorithm".to_string(), algo.clone());
|
||||
@@ -80,23 +73,19 @@ async fn get_timeline_with_appview(
|
||||
if let Some(cursor) = ¶ms.cursor {
|
||||
query_params.insert("cursor".to_string(), cursor.clone());
|
||||
}
|
||||
|
||||
let proxy_result =
|
||||
match proxy_to_appview("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
@@ -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
@@ -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,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,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};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,3 +1,2 @@
|
||||
mod register_push;
|
||||
|
||||
pub use register_push::register_push;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(¶ms);
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user