mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 10:46:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f78b004df3 | ||
|
|
e02e8c9e8c | ||
|
|
5450467011 | ||
|
|
6200697ce0 | ||
|
|
4005041ee2 | ||
|
|
1c75668b18 | ||
|
|
2c470f77a5 | ||
|
|
844ba0eb70 | ||
|
|
34beff2553 | ||
|
|
898c6a2c6e | ||
|
|
dcdef508de | ||
|
|
1e02c5803f | ||
|
|
e2f26c259f | ||
|
|
4e277eb7b2 | ||
|
|
28ca66624a | ||
|
|
3913bf5c1a | ||
|
|
dd53262a03 | ||
|
|
61a60a3163 | ||
|
|
09f8040135 | ||
|
|
dfc1ce3ddf | ||
|
|
5964601a11 | ||
|
|
1521f98b2e | ||
|
|
a7840e2ac5 | ||
|
|
a2cea49b0f | ||
|
|
66eb9b7dbb | ||
|
|
cbd3b79f41 | ||
|
|
a83a67d219 | ||
|
|
91cfc536c6 | ||
|
|
1ff22c3dee | ||
|
|
ea27772a47 | ||
|
|
ec36b8ddc7 |
@@ -1,2 +0,0 @@
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
@@ -21,6 +21,10 @@ heavy-load-tests = { max-threads = 4 }
|
||||
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(handle_domains)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(ripple_cluster)"
|
||||
test-group = "serial-env-tests"
|
||||
@@ -29,10 +33,22 @@ test-group = "serial-env-tests"
|
||||
filter = "binary(whole_story)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "test(/two_node_stress_concurrent_load/)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(repo_lifecycle)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(handle_domains)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(ripple_cluster)"
|
||||
test-group = "serial-env-tests"
|
||||
@@ -40,3 +56,11 @@ test-group = "serial-env-tests"
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(whole_story)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "test(/two_node_stress_concurrent_load/)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(repo_lifecycle)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
# =============================================================================
|
||||
# Server
|
||||
# =============================================================================
|
||||
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
|
||||
# =============================================================================
|
||||
# Backend: "filesystem" (default) or "s3"
|
||||
# BLOB_STORAGE_BACKEND=filesystem
|
||||
# For filesystem backend:
|
||||
BLOB_STORAGE_PATH=/var/lib/tranquil/blobs
|
||||
# For S3 backend:
|
||||
# S3_ENDPOINT=http://localhost:9000
|
||||
# AWS_REGION=us-east-1
|
||||
# S3_BUCKET=pds-blobs
|
||||
# AWS_ACCESS_KEY_ID=minioadmin
|
||||
# AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
# =============================================================================
|
||||
# Backups
|
||||
# =============================================================================
|
||||
# Enable/disable automatic repo backups
|
||||
# BACKUP_ENABLED=true
|
||||
# Backend: "filesystem" (default) or "s3"
|
||||
# BACKUP_STORAGE_BACKEND=filesystem
|
||||
# For filesystem backend:
|
||||
BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
|
||||
# For S3 backend:
|
||||
# BACKUP_S3_BUCKET=pds-backups
|
||||
# Backup schedule and retention
|
||||
# BACKUP_RETENTION_COUNT=7
|
||||
# BACKUP_INTERVAL_SECS=86400
|
||||
# =============================================================================
|
||||
# Cache & Rate Limiting
|
||||
# =============================================================================
|
||||
# Ripple (in-process CRDT cache) is the default. No config needed for single-node.
|
||||
# Set VALKEY_URL to use valkey instead (disables ripple).
|
||||
# VALKEY_URL=redis://localhost:6379
|
||||
#
|
||||
# Ripple multi-node settings (only needed when clustering):
|
||||
# RIPPLE_BIND=0.0.0.0:7890
|
||||
# RIPPLE_PEERS=10.0.0.2:7890,10.0.0.3:7890
|
||||
# RIPPLE_MACHINE_ID=1
|
||||
# RIPPLE_GOSSIP_INTERVAL_MS=200
|
||||
# RIPPLE_CACHE_MAX_MB=256
|
||||
# =============================================================================
|
||||
# Security Secrets
|
||||
# =============================================================================
|
||||
# These MUST be set in production (minimum 32 characters each)
|
||||
# In development, set TRANQUIL_PDS_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
|
||||
# TRANQUIL_PDS_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:...
|
||||
# =============================================================================
|
||||
# DID Resolution
|
||||
# =============================================================================
|
||||
# Cache TTL for resolved DID documents (default: 300 seconds)
|
||||
# DID_CACHE_TTL_SECS=300
|
||||
# =============================================================================
|
||||
# Relays
|
||||
# =============================================================================
|
||||
# Comma-separated list of relay URLs to notify via requestCrawl
|
||||
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
|
||||
# =============================================================================
|
||||
# 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 bot DM)
|
||||
# DISCORD_BOT_TOKEN=bot-token
|
||||
# Telegram notifications (via bot)
|
||||
# TELEGRAM_BOT_TOKEN=bot-token
|
||||
# TELEGRAM_WEBHOOK_SECRET=random-secret
|
||||
# Signal notifications (via signal-cli)
|
||||
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
|
||||
# SIGNAL_SENDER_NUMBER=+1234567890
|
||||
# =============================================================================
|
||||
# Upload Limits
|
||||
# =============================================================================
|
||||
# Maximum blob/body size in bytes (default: 10GB)
|
||||
# This controls both the Axum body limit and blob upload limits.
|
||||
# Make sure your nginx client_max_body_size matches or exceeds this value.
|
||||
# MAX_BLOB_SIZE=10737418240
|
||||
# =============================================================================
|
||||
# Repository Import
|
||||
# =============================================================================
|
||||
# Set to "true" to accept repository imports
|
||||
# ACCEPTING_REPO_IMPORTS=false
|
||||
# Maximum import size in bytes (default: 100MB)
|
||||
# MAX_IMPORT_SIZE=104857600
|
||||
# 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
|
||||
# Enable self-hosted did:web identities (default: true)
|
||||
# Hosting did:web requires a long-term commitment to serve DID documents.
|
||||
# Set to false if you don't want to offer this option.
|
||||
# ENABLE_SELF_HOSTED_DID_WEB=true
|
||||
# =============================================================================
|
||||
# Server Metadata (returned by describeServer)
|
||||
# =============================================================================
|
||||
# Privacy policy URL (optional)
|
||||
# PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
# Terms of service URL (optional)
|
||||
# TERMS_OF_SERVICE_URL=https://example.com/terms
|
||||
# Contact email address (optional)
|
||||
# CONTACT_EMAIL=admin@example.com
|
||||
# =============================================================================
|
||||
# Rate Limiting
|
||||
# =============================================================================
|
||||
# Disable all rate limiting (testing only, NEVER in production)
|
||||
# DISABLE_RATE_LIMITING=1
|
||||
# =============================================================================
|
||||
# Account Deletion
|
||||
# =============================================================================
|
||||
# How often to check for scheduled account deletions (default: 3600 = 1 hour)
|
||||
# SCHEDULED_DELETE_CHECK_INTERVAL_SECS=3600
|
||||
# =============================================================================
|
||||
# Moderation / Report Service
|
||||
# =============================================================================
|
||||
# If configured, moderation reports will be proxied to this service
|
||||
# instead of being stored locally. The service should implement the
|
||||
# com.atproto.moderation.createReport endpoint (eg., Bluesky's Ozone).
|
||||
# Both URL and DID must be set for proxying to be enabled.
|
||||
# REPORT_SERVICE_URL=https://mod.bsky.app
|
||||
# REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
|
||||
# =============================================================================
|
||||
# Age Assurance Override
|
||||
# =============================================================================
|
||||
# Enable this if you have separately assured the ages of your users
|
||||
# (eg., through your own age verification process). When enabled, the PDS
|
||||
# will return "assured" status for age assurance checks instead of proxying
|
||||
# to the appview. This helps migrated users avoid the age assurance
|
||||
# catch-22 on bsky.app.
|
||||
# PDS_AGE_ASSURANCE_OVERRIDE=1
|
||||
# =============================================================================
|
||||
# Miscellaneous
|
||||
# =============================================================================
|
||||
# Allow HTTP for proxy requests (development only)
|
||||
# ALLOW_HTTP_PROXY=1
|
||||
# =============================================================================
|
||||
# SSO / Social Login
|
||||
# =============================================================================
|
||||
# Each provider requires ENABLED=true plus CLIENT_ID and CLIENT_SECRET.
|
||||
# Register your PDS as an OAuth application with each provider to get credentials.
|
||||
|
||||
# GitHub
|
||||
# SSO_GITHUB_ENABLED=true
|
||||
# SSO_GITHUB_CLIENT_ID=
|
||||
# SSO_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Discord
|
||||
# SSO_DISCORD_ENABLED=true
|
||||
# SSO_DISCORD_CLIENT_ID=
|
||||
# SSO_DISCORD_CLIENT_SECRET=
|
||||
|
||||
# Google
|
||||
# SSO_GOOGLE_ENABLED=true
|
||||
# SSO_GOOGLE_CLIENT_ID=
|
||||
# SSO_GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# GitLab (set ISSUER for self-hosted instances)
|
||||
# SSO_GITLAB_ENABLED=false
|
||||
# SSO_GITLAB_CLIENT_ID=
|
||||
# SSO_GITLAB_CLIENT_SECRET=
|
||||
# SSO_GITLAB_ISSUER=https://gitlab.com
|
||||
|
||||
# Generic OIDC
|
||||
# SSO_OIDC_ENABLED=false
|
||||
# SSO_OIDC_CLIENT_ID=
|
||||
# SSO_OIDC_CLIENT_SECRET=
|
||||
# SSO_OIDC_ISSUER=https://your-identity-provider.com
|
||||
# SSO_OIDC_NAME=Custom Provider
|
||||
|
||||
# Apple Sign-in
|
||||
# SSO_APPLE_ENABLED=true
|
||||
# SSO_APPLE_CLIENT_ID=com.example.signin # Services ID from Apple Developer Portal
|
||||
# SSO_APPLE_TEAM_ID=XXXXXXXXXX # 10-character Team ID
|
||||
# SSO_APPLE_KEY_ID=XXXXXXXXXX # Key ID from portal
|
||||
# SSO_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
CARGO_MOMMYS_LITTLE=mister
|
||||
CARGO_MOMMYS_PRONOUNS=his
|
||||
CARGO_MOMMYS_ROLES=daddy
|
||||
CARGO_MOMMYS_EMOTES="🚛/🧱/🚜/🔩/🦺"
|
||||
CARGO_MOMMYS_MOODS=ominous
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "recovery_token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "recovery_token_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea"
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "recovery_token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "recovery_token_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE oauth_authorization_request\n SET expires_at = $2\n WHERE id = $1 AND did IS NOT NULL AND code IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "password_reset_code_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_reset_code_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b"
|
||||
}
|
||||
Generated
+267
-98
@@ -104,6 +104,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
@@ -1216,6 +1266,46 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
@@ -1240,6 +1330,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -1280,6 +1376,29 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "confique"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
|
||||
dependencies = [
|
||||
"confique-macro",
|
||||
"serde",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "confique-macro"
|
||||
version = "0.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d1754680cd218e7bcb4c960cc9bae3444b5197d64563dccccfdf83cab9e1a7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
@@ -2624,6 +2743,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -2744,22 +2869,6 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.19"
|
||||
@@ -2778,7 +2887,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -3075,6 +3184,12 @@ dependencies = [
|
||||
"unsigned-varint 0.7.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -3504,6 +3619,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -3585,23 +3710,6 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -3748,6 +3856,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@@ -4242,7 +4356,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.35",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4279,7 +4393,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -4402,7 +4516,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"ryu",
|
||||
"sha1_smol",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.2",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
@@ -4498,12 +4612,10 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-rustls 0.27.7",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -4514,7 +4626,6 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4661,7 +4772,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 3.5.1",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4800,19 +4911,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.1"
|
||||
@@ -4958,6 +5056,15 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -5134,9 +5241,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -5525,19 +5632,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "testcontainers"
|
||||
version = "0.26.2"
|
||||
@@ -5693,7 +5787,7 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -5709,16 +5803,6 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
@@ -5758,10 +5842,12 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rustls 0.23.35",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5778,6 +5864,45 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.12.1",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.7+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.2"
|
||||
@@ -5797,7 +5922,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -5867,13 +5992,19 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5963,7 +6094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -5978,6 +6109,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"subtle",
|
||||
"totp-rs",
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@@ -5985,20 +6117,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"redis",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tranquil-config",
|
||||
"tranquil-infra",
|
||||
"tranquil-ripple",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6006,13 +6139,22 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tranquil-config",
|
||||
"tranquil-db-traits",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -6028,7 +6170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -6045,7 +6187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6061,17 +6203,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"thiserror 2.0.17",
|
||||
"tranquil-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6094,7 +6237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -6111,6 +6254,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"ciborium",
|
||||
"cid",
|
||||
"clap",
|
||||
"ctor",
|
||||
"dotenvy",
|
||||
"ed25519-dalek",
|
||||
@@ -6161,6 +6305,7 @@ dependencies = [
|
||||
"tranquil-auth",
|
||||
"tranquil-cache",
|
||||
"tranquil-comms",
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
@@ -6179,7 +6324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -6191,7 +6336,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -6199,21 +6344,24 @@ dependencies = [
|
||||
"bytes",
|
||||
"foca",
|
||||
"futures",
|
||||
"metrics",
|
||||
"parking_lot",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"socket2 0.6.2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tranquil-config",
|
||||
"tranquil-infra",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -6221,6 +6369,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
@@ -6228,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -6238,13 +6387,14 @@ dependencies = [
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-config",
|
||||
"tranquil-infra",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -6272,8 +6422,9 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.9.2",
|
||||
"rustls 0.23.35",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
@@ -6285,6 +6436,12 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -6422,6 +6579,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.19.0"
|
||||
@@ -6996,6 +7159,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.50.0"
|
||||
|
||||
+14
-5
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/tranquil-types",
|
||||
"crates/tranquil-config",
|
||||
"crates/tranquil-infra",
|
||||
"crates/tranquil-crypto",
|
||||
"crates/tranquil-storage",
|
||||
@@ -18,12 +19,13 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tranquil-types = { path = "crates/tranquil-types" }
|
||||
tranquil-config = { path = "crates/tranquil-config" }
|
||||
tranquil-infra = { path = "crates/tranquil-infra" }
|
||||
tranquil-crypto = { path = "crates/tranquil-crypto" }
|
||||
tranquil-storage = { path = "crates/tranquil-storage" }
|
||||
@@ -52,6 +54,8 @@ bs58 = "0.5"
|
||||
bytes = "1.11"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cid = "0.11"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
confique = { version = "0.4", features = ["toml"] }
|
||||
dotenvy = "0.15"
|
||||
ed25519-dalek = { version = "2.1", features = ["pkcs8"] }
|
||||
foca = { version = "1", features = ["bincode-codec", "tracing"] }
|
||||
@@ -81,7 +85,7 @@ p384 = { version = "0.13", features = ["ecdsa"] }
|
||||
rand = "0.8"
|
||||
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
|
||||
regex = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_ipld_dagcbor = "0.6"
|
||||
@@ -91,12 +95,12 @@ sha2 = "0.10"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||
subtle = "2.5"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process"] }
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] }
|
||||
tokio-util = "0.7.18"
|
||||
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
||||
totp-rs = { version = "5", features = ["qr"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
tower-layer = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
@@ -111,3 +115,8 @@ ctor = "0.6"
|
||||
testcontainers = "0.26"
|
||||
testcontainers-modules = { version = "0.14", features = ["postgres"] }
|
||||
wiremock = "0.6"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
+14
-3
@@ -1,18 +1,29 @@
|
||||
FROM rust:1.92-alpine AS builder
|
||||
RUN apk add --no-cache ca-certificates openssl openssl-dev openssl-libs-static pkgconfig musl-dev
|
||||
FROM denoland/deno:alpine AS frontend
|
||||
WORKDIR /app
|
||||
COPY frontend/ ./
|
||||
RUN deno task build
|
||||
|
||||
FROM rust:1.92-alpine AS builder
|
||||
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||
WORKDIR /app
|
||||
ARG SLIM="false"
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
COPY .sqlx ./.sqlx
|
||||
COPY migrations ./crates/tranquil-pds/migrations
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
SQLX_OFFLINE=true cargo build --release -p tranquil-pds && \
|
||||
if [ "$SLIM" = "true" ]; then \
|
||||
SQLX_OFFLINE=true cargo build --release -p tranquil-pds --no-default-features; \
|
||||
else \
|
||||
SQLX_OFFLINE=true cargo build --release -p tranquil-pds; \
|
||||
fi && \
|
||||
cp target/release/tranquil-pds /tmp/tranquil-pds
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail
|
||||
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
|
||||
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
|
||||
COPY migrations /app/migrations
|
||||
WORKDIR /app
|
||||
ENV SERVER_HOST=0.0.0.0
|
||||
|
||||
@@ -6,27 +6,28 @@ Bluesky runs on a federated protocol called AT Protocol. Your account lives on a
|
||||
|
||||
This particular PDS thrives under harsh conditions. It is a dandelion growing through the cracks in the sidewalk concrete.
|
||||
|
||||
It has full compatibility with Bluesky's reference PDS: same endpoints, same behavior, same client compatibility. Everything works: repo operations, blob storage, firehose, OAuth, handle resolution, account migration, the lot.
|
||||
|
||||
Another excellent PDS is [Cocoon](https://tangled.org/hailey.at/cocoon), written in go.
|
||||
It has full compatibility with Bluesky's reference PDS.
|
||||
|
||||
## What's different about Tranquil PDS
|
||||
|
||||
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
|
||||
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, repo browsing, and admin.
|
||||
|
||||
The PDS itself is a single small binary with no node/npm runtime. It requires postgres. Blobs are stored on the local filesystem by default (S3 optional). Valkey is optional (supported as an alternative to the built-in cache).
|
||||
The PDS itself is a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately. Blobs are stored on the local filesystem by default (S3 optional). Valkey is also optional (as an alternative to the built-in cache).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
podman compose up -d
|
||||
cp example.toml config.toml
|
||||
podman compose up db -d
|
||||
just run
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See `.env.example` for all configuration options.
|
||||
See `example.toml` for all configuration options.
|
||||
|
||||
> [!NOTE]
|
||||
> The order of configuration precedence is: environment variables, then a config file passed via `--config`, then `/etc/tranquil-pds/config.toml`, then the built-in defaults. So you can use environment variables, or a config file, or both.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -41,20 +42,18 @@ just lint
|
||||
|
||||
### Quick Deploy (Docker/Podman Compose)
|
||||
|
||||
Edit `.env.prod` with your values. Generate secrets with `openssl rand -base64 48`.
|
||||
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
|
||||
|
||||
```bash
|
||||
cp .env.prod.example .env.prod
|
||||
cp example.toml config.toml
|
||||
podman-compose -f docker-compose.prod.yaml up -d
|
||||
```
|
||||
|
||||
### Installation Guides
|
||||
|
||||
| Guide | Best For |
|
||||
|-------|----------|
|
||||
| [Debian](docs/install-debian.md) | Debian 13+ with systemd |
|
||||
| [Containers](docs/install-containers.md) | Podman with quadlets or OpenRC |
|
||||
| [Kubernetes](docs/install-kubernetes.md) | You know what you're doing |
|
||||
- [Debian](docs/install-debian.md)
|
||||
- [Containers](docs/install-containers.md)
|
||||
- [Kubernetes](docs/install-kubernetes.md)
|
||||
|
||||
## Maintainers to ping
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# Lewis' Big Boy TODO list
|
||||
|
||||
## Active development
|
||||
|
||||
### Storage backend abstraction
|
||||
Make storage layers swappable via traits.
|
||||
|
||||
filesystem blob storage
|
||||
- [ ] FilesystemBlobStorage implementation
|
||||
- [ ] directory structure (content-addressed like blobs/{cid} already used in objsto)
|
||||
- [ ] atomic writes (write to temp, rename)
|
||||
- [ ] config option to choose backend (env var or config flag)
|
||||
- [ ] also traitify BackupStorage (currently hardcoded to objsto)
|
||||
|
||||
sqlite database backend
|
||||
- [ ] abstract db layer behind trait (queries, transactions, migrations)
|
||||
- [ ] sqlite implementation matching postgres behavior
|
||||
- [ ] handle sqlite's single-writer limitation (connection pooling strategy)
|
||||
- [ ] migrations system that works for both
|
||||
- [ ] testing: run full test suite against both backends
|
||||
- [ ] config option to choose backend (postgres vs sqlite)
|
||||
- [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale)
|
||||
|
||||
### Plugin system
|
||||
WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions.
|
||||
|
||||
WIT interface
|
||||
- [ ] record hooks before/after create, update, delete
|
||||
- [ ] blob hooks before/after upload, validate
|
||||
- [ ] xrpc hooks before/after (middleware), custom endpoint handler
|
||||
- [ ] firehose hook on_commit
|
||||
- [ ] host imports http client, kv store, logging, read records
|
||||
|
||||
wasmtime host
|
||||
- [ ] engine with epoch interruption (kill runaway plugins)
|
||||
- [ ] plugin manifest (plugin.toml): id, version, capabilities, hooks
|
||||
- [ ] capability enforcement at runtime
|
||||
- [ ] plugin loader, lifecycle (enable/disable/reload)
|
||||
- [ ] resource limits (memory, time)
|
||||
- [ ] per-plugin fs sandbox
|
||||
|
||||
capabilities
|
||||
- [ ] http:fetch with domain allowlist
|
||||
- [ ] kv:read, kv:write
|
||||
- [ ] record:read, blob:read
|
||||
- [ ] xrpc:register
|
||||
- [ ] firehose:subscribe
|
||||
|
||||
pds-plugin-api (rust), MVP for plugin system
|
||||
- [ ] plugin trait with default impls
|
||||
- [ ] register_plugin! macro
|
||||
- [ ] typed host import wrappers
|
||||
- [ ] publish to crates.io
|
||||
- [ ] docs + example
|
||||
|
||||
pds-plugin-api in golang, nice to have after the fact
|
||||
- [ ] wit-bindgen-go bindings
|
||||
- [ ] go wrappers
|
||||
- [ ] tinygo build instructions
|
||||
- [ ] example
|
||||
|
||||
@pds/plugin-api in typescript, nice to have after the fact
|
||||
- [ ] jco/componentize-js bindings
|
||||
- [ ] typeScript types
|
||||
- [ ] build tooling
|
||||
- [ ] example
|
||||
|
||||
example plugins
|
||||
- [ ] content filter
|
||||
- [ ] webhook notifier
|
||||
- [ ] objsto backup mirror
|
||||
- [ ] custom lexicon handler
|
||||
- [ ] better audit logger
|
||||
|
||||
### Misc
|
||||
|
||||
cross-pds delegation
|
||||
when a client (eg. tangled.org) tries to log into a delegated account:
|
||||
- [ ] client starts oauth flow to delegated account's pds
|
||||
- [ ] delegated pds sees account is externally controlled, launches oauth to controller's pds (delegated pds acts as oauth client)
|
||||
- [ ] controller authenticates at their own pds
|
||||
- [ ] delegated pds verifies controller perms and scope from its local delegation grants
|
||||
- [ ] delegated pds issues session to client within the intersection of controller's granted scope and client's requested scope
|
||||
|
||||
per-request "act as"
|
||||
- [ ] authed as user X, perform action as delegated user Y in single request
|
||||
- [ ] approach decision
|
||||
- [ ] option 1: `X-Act-As` header with target did, server verifies delegation grant
|
||||
- [ ] option 2: token exchange (RFC 8693) for short-lived delegated token
|
||||
- [ ] option 3 (lewis fav): extend existing `act` claim to support on-demand minting
|
||||
- [ ] something else?
|
||||
|
||||
### Private/encrypted data
|
||||
Records only authorized parties can see and decrypt.
|
||||
|
||||
research
|
||||
- [ ] survey atproto discourse on private data
|
||||
- [ ] document bluesky team's likely approach. wait.. are they even gonna do this? whatever
|
||||
- [ ] look at matrix/signal for federated e2ee patterns
|
||||
|
||||
key management
|
||||
- [ ] db schema for encryption keys (user_keys, key_grants, key_rotations)
|
||||
- [ ] per-user encryption keypair generation (separate from signing keys)
|
||||
- [ ] key derivation scheme (per-collection? per-record? both?)
|
||||
- [ ] key storage (encrypted at rest, hsm option?)
|
||||
- [ ] rotation and revocation flow
|
||||
|
||||
storage layer
|
||||
- [ ] encrypted record format (encrypted cbor blob + metadata)
|
||||
- [ ] collection-level vs per-record encryption flag
|
||||
- [ ] how encrypted records appear in mst (hash of ciphertext? separate tree?)
|
||||
- [ ] blob encryption (same keys? separate?)
|
||||
|
||||
api surface
|
||||
- [ ] xrpc getPublicKey, grantAccess, revokeAccess, listGrants
|
||||
- [ ] xrpc getEncryptedRecord (ciphertext for client-side decrypt)
|
||||
- [ ] or transparent server-side decrypt if requester has grant?
|
||||
- [ ] lexicon for key grant records
|
||||
|
||||
sync/federation
|
||||
- [ ] how encrypted records appear on firehose (ciphertext? omitted? placeholder?)
|
||||
- [ ] pds-to-pds key exchange protocol
|
||||
- [ ] appview behavior (can't index without grants)
|
||||
- [ ] relay behavior with encrypted commits
|
||||
|
||||
client integration
|
||||
- [ ] client-side encryption (pds never sees plaintext) vs server-side with trust
|
||||
- [ ] key backup/recovery (lose key = lose data)
|
||||
|
||||
plugin hooks (once core exists)
|
||||
- [ ] on_access_grant_request for custom authorization
|
||||
- [ ] on_key_rotation to notify interested parties
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, full admin API, moderation reports.
|
||||
|
||||
did:web support: Self-hosted did:web (subdomain format `did:web:handle.pds.com`), external/BYOD did:web, DID document serving via `/.well-known/did.json`, clear registration warnings about did:web trade-offs vs did:plc.
|
||||
|
||||
OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification.
|
||||
|
||||
OAuth Scope Enforcement: Full granular scope system with consent UI, human-readable scope descriptions, per-client scope preferences, scope parsing (repo/blob/rpc/account/identity), endpoint-level scope checks, DPoP token support in auth extractors, token revocation on re-authorization, response_mode support (query/fragment).
|
||||
|
||||
App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback).
|
||||
|
||||
Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening.
|
||||
|
||||
Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection.
|
||||
|
||||
Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
|
||||
|
||||
Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods.
|
||||
|
||||
App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
|
||||
|
||||
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
|
||||
|
||||
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management, handle preservation (keep existing external handle via DNS/HTTP verification or create new PDS-subdomain handle).
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
|
||||
@@ -4,22 +4,22 @@ mod types;
|
||||
mod verify;
|
||||
|
||||
pub use token::{
|
||||
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
|
||||
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token, create_access_token_hs256,
|
||||
create_access_token_hs256_with_metadata, create_access_token_with_delegation,
|
||||
create_access_token_with_metadata, create_access_token_with_scope_metadata,
|
||||
create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata,
|
||||
create_refresh_token_with_metadata, create_service_token, create_service_token_hs256,
|
||||
create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata,
|
||||
create_access_token_with_delegation, create_access_token_with_metadata,
|
||||
create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256,
|
||||
create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata,
|
||||
create_service_token, create_service_token_hs256,
|
||||
};
|
||||
|
||||
pub use totp::{
|
||||
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
|
||||
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
|
||||
verify_backup_code, verify_totp_code,
|
||||
TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
||||
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
||||
is_backup_code_format, verify_backup_code, verify_totp_code,
|
||||
};
|
||||
|
||||
pub use types::{
|
||||
ActClaim, Claims, Header, TokenData, TokenVerifyError, TokenWithMetadata, UnsafeClaims,
|
||||
ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
|
||||
TokenVerifyError, TokenWithMetadata, UnsafeClaims,
|
||||
};
|
||||
|
||||
pub use verify::{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::types::{ActClaim, Claims, Header, TokenWithMetadata};
|
||||
use super::types::{
|
||||
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
@@ -9,14 +11,6 @@ use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub const TOKEN_TYPE_ACCESS: &str = "at+jwt";
|
||||
pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt";
|
||||
pub const TOKEN_TYPE_SERVICE: &str = "jwt";
|
||||
pub const SCOPE_ACCESS: &str = "com.atproto.access";
|
||||
pub const SCOPE_REFRESH: &str = "com.atproto.refresh";
|
||||
pub const SCOPE_APP_PASS: &str = "com.atproto.appPass";
|
||||
pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged";
|
||||
|
||||
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
|
||||
}
|
||||
@@ -35,11 +29,11 @@ pub fn create_access_token_with_scope_metadata(
|
||||
scopes: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let scope = scopes.unwrap_or(SCOPE_ACCESS);
|
||||
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
|
||||
create_signed_token_with_metadata(
|
||||
did,
|
||||
scope,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
hostname,
|
||||
@@ -53,12 +47,12 @@ pub fn create_access_token_with_delegation(
|
||||
controller_did: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let scope = scopes.unwrap_or(SCOPE_ACCESS);
|
||||
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
|
||||
create_signed_token_with_act(
|
||||
did,
|
||||
scope,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
act,
|
||||
@@ -72,8 +66,8 @@ pub fn create_refresh_token_with_metadata(
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(
|
||||
did,
|
||||
SCOPE_REFRESH,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
Duration::days(14),
|
||||
None,
|
||||
@@ -92,8 +86,8 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
aud: aud.to_owned(),
|
||||
exp: expiration as usize,
|
||||
iat: Utc::now().timestamp() as usize,
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: None,
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -106,7 +100,7 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
|
||||
fn create_signed_token_with_metadata(
|
||||
did: &str,
|
||||
scope: &str,
|
||||
typ: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
duration: Duration,
|
||||
hostname: Option<&str>,
|
||||
@@ -117,7 +111,7 @@ fn create_signed_token_with_metadata(
|
||||
fn create_signed_token_with_act(
|
||||
did: &str,
|
||||
scope: &str,
|
||||
typ: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
duration: Duration,
|
||||
act: Option<ActClaim>,
|
||||
@@ -133,15 +127,17 @@ fn create_signed_token_with_act(
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| {
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
tranquil_config::try_get()
|
||||
.map(|c| c.server.hostname.clone())
|
||||
.unwrap_or_else(|| "localhost".to_string())
|
||||
});
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
aud: format!("did:web:{}", aud_hostname),
|
||||
exp: expiration as usize,
|
||||
iat: Utc::now().timestamp() as usize,
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: Some(scope.to_string()),
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
@@ -158,13 +154,13 @@ fn create_signed_token_with_act(
|
||||
}
|
||||
|
||||
fn sign_claims(claims: Claims, key: &SigningKey) -> Result<String> {
|
||||
sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE)
|
||||
sign_claims_with_type(claims, key, TokenType::Service)
|
||||
}
|
||||
|
||||
fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result<String> {
|
||||
fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: TokenType) -> Result<String> {
|
||||
let header = Header {
|
||||
alg: "ES256K".to_string(),
|
||||
typ: typ.to_string(),
|
||||
alg: SigningAlgorithm::ES256K,
|
||||
typ,
|
||||
};
|
||||
|
||||
let header_json = serde_json::to_string(&header)?;
|
||||
@@ -194,8 +190,8 @@ pub fn create_access_token_hs256_with_metadata(
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
did,
|
||||
SCOPE_ACCESS,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
TokenScope::Access.as_str(),
|
||||
TokenType::Access,
|
||||
secret,
|
||||
Duration::minutes(15),
|
||||
)
|
||||
@@ -207,8 +203,8 @@ pub fn create_refresh_token_hs256_with_metadata(
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
did,
|
||||
SCOPE_REFRESH,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
secret,
|
||||
Duration::days(14),
|
||||
)
|
||||
@@ -229,21 +225,21 @@ pub fn create_service_token_hs256(
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
aud: aud.to_owned(),
|
||||
exp: expiration as usize,
|
||||
iat: Utc::now().timestamp() as usize,
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: None,
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
act: None,
|
||||
};
|
||||
|
||||
sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret)
|
||||
sign_claims_hs256(claims, TokenType::Service, secret)
|
||||
}
|
||||
|
||||
fn create_hs256_token_with_metadata(
|
||||
did: &str,
|
||||
scope: &str,
|
||||
typ: &str,
|
||||
typ: TokenType,
|
||||
secret: &[u8],
|
||||
duration: Duration,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
@@ -259,10 +255,12 @@ fn create_hs256_token_with_metadata(
|
||||
sub: did.to_owned(),
|
||||
aud: format!(
|
||||
"did:web:{}",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
tranquil_config::try_get()
|
||||
.map(|c| c.server.hostname.clone())
|
||||
.unwrap_or_else(|| "localhost".to_string())
|
||||
),
|
||||
exp: expiration as usize,
|
||||
iat: Utc::now().timestamp() as usize,
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: Some(scope.to_string()),
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
@@ -278,10 +276,10 @@ fn create_hs256_token_with_metadata(
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_claims_hs256(claims: Claims, typ: &str, secret: &[u8]) -> Result<String> {
|
||||
fn sign_claims_hs256(claims: Claims, typ: TokenType, secret: &[u8]) -> Result<String> {
|
||||
let header = Header {
|
||||
alg: "HS256".to_string(),
|
||||
typ: typ.to_string(),
|
||||
alg: SigningAlgorithm::HS256,
|
||||
typ,
|
||||
};
|
||||
|
||||
let header_json = serde_json::to_string(&header)?;
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
use base32::Alphabet;
|
||||
use rand::RngCore;
|
||||
use rand::{Rng, RngCore};
|
||||
use subtle::ConstantTimeEq;
|
||||
use totp_rs::{Algorithm, TOTP};
|
||||
|
||||
const TOTP_DIGITS: usize = 6;
|
||||
const TOTP_STEP: u64 = 30;
|
||||
const TOTP_STEP_SIGNED: i64 = TOTP_STEP as i64;
|
||||
const TOTP_SECRET_LENGTH: usize = 20;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TotpError {
|
||||
CreationFailed(String),
|
||||
QrGenerationFailed(String),
|
||||
HashFailed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TotpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::CreationFailed(e) => write!(f, "TOTP creation failed: {}", e),
|
||||
Self::QrGenerationFailed(e) => write!(f, "QR generation failed: {}", e),
|
||||
Self::HashFailed(e) => write!(f, "Hash failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TotpError {}
|
||||
|
||||
pub fn generate_totp_secret() -> Vec<u8> {
|
||||
let mut secret = vec![0u8; TOTP_SECRET_LENGTH];
|
||||
rand::thread_rng().fill_bytes(&mut secret);
|
||||
@@ -31,7 +51,7 @@ fn create_totp(
|
||||
secret: Vec<u8>,
|
||||
issuer: Option<String>,
|
||||
account_name: String,
|
||||
) -> Result<TOTP, String> {
|
||||
) -> Result<TOTP, TotpError> {
|
||||
TOTP::new(
|
||||
Algorithm::SHA1,
|
||||
TOTP_DIGITS,
|
||||
@@ -41,7 +61,7 @@ fn create_totp(
|
||||
issuer,
|
||||
account_name,
|
||||
)
|
||||
.map_err(|e| format!("Failed to create TOTP: {}", e))
|
||||
.map_err(|e| TotpError::CreationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn verify_totp_code(secret: &[u8], code: &str) -> bool {
|
||||
@@ -60,7 +80,7 @@ pub fn verify_totp_code(secret: &[u8], code: &str) -> bool {
|
||||
.unwrap_or(0);
|
||||
|
||||
[-1i64, 0, 1].iter().any(|&offset| {
|
||||
let time = (now as i64 + offset * TOTP_STEP as i64) as u64;
|
||||
let time = now.wrapping_add_signed(offset * TOTP_STEP_SIGNED);
|
||||
let expected = totp.generate(time);
|
||||
let is_valid: bool = code.as_bytes().ct_eq(expected.as_bytes()).into();
|
||||
is_valid
|
||||
@@ -84,7 +104,7 @@ pub fn generate_qr_png_base64(
|
||||
secret: &[u8],
|
||||
account_name: &str,
|
||||
issuer: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, TotpError> {
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
|
||||
let totp = create_totp(
|
||||
@@ -95,7 +115,7 @@ pub fn generate_qr_png_base64(
|
||||
|
||||
let qr_png = totp
|
||||
.get_qr_png()
|
||||
.map_err(|e| format!("Failed to generate QR code: {}", e))?;
|
||||
.map_err(|e| TotpError::QrGenerationFailed(e.to_string()))?;
|
||||
|
||||
Ok(STANDARD.encode(qr_png))
|
||||
}
|
||||
@@ -112,7 +132,7 @@ pub fn generate_backup_codes() -> Vec<String> {
|
||||
(0..BACKUP_CODE_COUNT).for_each(|_| {
|
||||
let code: String = (0..BACKUP_CODE_LENGTH)
|
||||
.map(|_| {
|
||||
let idx = (rng.next_u32() as usize) % BACKUP_CODE_ALPHABET.len();
|
||||
let idx = rng.gen_range(0..BACKUP_CODE_ALPHABET.len());
|
||||
BACKUP_CODE_ALPHABET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
@@ -122,8 +142,8 @@ pub fn generate_backup_codes() -> Vec<String> {
|
||||
codes
|
||||
}
|
||||
|
||||
pub fn hash_backup_code(code: &str) -> Result<String, String> {
|
||||
bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| format!("Failed to hash code: {}", e))
|
||||
pub fn hash_backup_code(code: &str) -> Result<String, TotpError> {
|
||||
bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| TotpError::HashFailed(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn verify_backup_code(code: &str, hash: &str) -> bool {
|
||||
|
||||
@@ -1,6 +1,203 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Serialize, de, ser};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenType {
|
||||
Access,
|
||||
Refresh,
|
||||
Service,
|
||||
}
|
||||
|
||||
impl TokenType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Access => "at+jwt",
|
||||
Self::Refresh => "refresh+jwt",
|
||||
Self::Service => "jwt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TokenType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TokenType {
|
||||
type Err = TokenTypeParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"at+jwt" => Ok(Self::Access),
|
||||
"refresh+jwt" => Ok(Self::Refresh),
|
||||
"jwt" => Ok(Self::Service),
|
||||
_ => Err(TokenTypeParseError(s.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenTypeParseError(pub String);
|
||||
|
||||
impl fmt::Display for TokenTypeParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "unknown token type: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TokenTypeParseError {}
|
||||
|
||||
impl Serialize for TokenType {
|
||||
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TokenType {
|
||||
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::from_str(&s).map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SigningAlgorithm {
|
||||
ES256K,
|
||||
HS256,
|
||||
}
|
||||
|
||||
impl SigningAlgorithm {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ES256K => "ES256K",
|
||||
Self::HS256 => "HS256",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SigningAlgorithm {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SigningAlgorithm {
|
||||
type Err = SigningAlgorithmParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"ES256K" => Ok(Self::ES256K),
|
||||
"HS256" => Ok(Self::HS256),
|
||||
_ => Err(SigningAlgorithmParseError(s.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SigningAlgorithmParseError(pub String);
|
||||
|
||||
impl fmt::Display for SigningAlgorithmParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "unknown signing algorithm: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SigningAlgorithmParseError {}
|
||||
|
||||
impl Serialize for SigningAlgorithm {
|
||||
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SigningAlgorithm {
|
||||
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::from_str(&s).map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TokenScope {
|
||||
Access,
|
||||
Refresh,
|
||||
AppPass,
|
||||
AppPassPrivileged,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl TokenScope {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Access => "com.atproto.access",
|
||||
Self::Refresh => "com.atproto.refresh",
|
||||
Self::AppPass => "com.atproto.appPass",
|
||||
Self::AppPassPrivileged => "com.atproto.appPassPrivileged",
|
||||
Self::Custom(s) => s,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_access_like(&self) -> bool {
|
||||
matches!(self, Self::Access | Self::AppPass | Self::AppPassPrivileged)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TokenScope {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TokenScope {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match s {
|
||||
"com.atproto.access" => Self::Access,
|
||||
"com.atproto.refresh" => Self::Refresh,
|
||||
"com.atproto.appPass" => Self::AppPass,
|
||||
"com.atproto.appPassPrivileged" => Self::AppPassPrivileged,
|
||||
other => Self::Custom(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for TokenScope {
|
||||
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TokenScope {
|
||||
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(Self::from_str(&s).unwrap_or_else(|e| match e {}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenDecodeError {
|
||||
InvalidFormat,
|
||||
Base64DecodeFailed,
|
||||
JsonDecodeFailed,
|
||||
MissingClaim,
|
||||
}
|
||||
|
||||
impl fmt::Display for TokenDecodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidFormat => write!(f, "Invalid token format"),
|
||||
Self::Base64DecodeFailed => write!(f, "Base64 decode failed"),
|
||||
Self::JsonDecodeFailed => write!(f, "JSON decode failed"),
|
||||
Self::MissingClaim => write!(f, "Missing required claim"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TokenDecodeError {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActClaim {
|
||||
@@ -12,8 +209,8 @@ pub struct Claims {
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub aud: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -25,8 +222,8 @@ pub struct Claims {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub alg: String,
|
||||
pub typ: String,
|
||||
pub alg: SigningAlgorithm,
|
||||
pub typ: TokenType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -61,3 +258,51 @@ impl fmt::Display for TokenVerifyError {
|
||||
}
|
||||
|
||||
impl std::error::Error for TokenVerifyError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_type_accepts_bluesky_uppercase_jwt() {
|
||||
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#);
|
||||
let header = result.expect("should parse uppercase JWT from bluesky reference pds");
|
||||
assert_eq!(header.typ, TokenType::Service);
|
||||
assert_eq!(header.alg, SigningAlgorithm::ES256K);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_type_accepts_lowercase_jwt() {
|
||||
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#);
|
||||
let header = result.expect("should parse lowercase jwt");
|
||||
assert_eq!(header.typ, TokenType::Service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_type_accepts_mixed_case_access() {
|
||||
assert_eq!(TokenType::from_str("AT+JWT").unwrap(), TokenType::Access);
|
||||
assert_eq!(TokenType::from_str("at+jwt").unwrap(), TokenType::Access);
|
||||
assert_eq!(TokenType::from_str("At+Jwt").unwrap(), TokenType::Access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_type_rejects_unknown() {
|
||||
assert!(TokenType::from_str("bearer").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_algorithm_case_insensitive() {
|
||||
assert_eq!(
|
||||
SigningAlgorithm::from_str("ES256K").unwrap(),
|
||||
SigningAlgorithm::ES256K
|
||||
);
|
||||
assert_eq!(
|
||||
SigningAlgorithm::from_str("es256k").unwrap(),
|
||||
SigningAlgorithm::ES256K
|
||||
);
|
||||
assert_eq!(
|
||||
SigningAlgorithm::from_str("hs256").unwrap(),
|
||||
SigningAlgorithm::HS256
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::token::{
|
||||
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
use super::types::{
|
||||
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
|
||||
TokenVerifyError, UnsafeClaims,
|
||||
};
|
||||
use super::types::{Claims, Header, TokenData, TokenVerifyError, UnsafeClaims};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
@@ -14,54 +13,54 @@ use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn get_did_from_token(token: &str) -> Result<String, String> {
|
||||
pub fn get_did_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Invalid token format".to_string());
|
||||
return Err(TokenDecodeError::InvalidFormat);
|
||||
}
|
||||
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|e| format!("Base64 decode failed: {}", e))?;
|
||||
.map_err(|_| TokenDecodeError::Base64DecodeFailed)?;
|
||||
|
||||
let claims: UnsafeClaims =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
|
||||
serde_json::from_slice(&payload_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?;
|
||||
|
||||
Ok(claims.sub.unwrap_or(claims.iss))
|
||||
}
|
||||
|
||||
pub fn get_jti_from_token(token: &str) -> Result<String, String> {
|
||||
pub fn get_jti_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Invalid token format".to_string());
|
||||
return Err(TokenDecodeError::InvalidFormat);
|
||||
}
|
||||
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|e| format!("Base64 decode failed: {}", e))?;
|
||||
.map_err(|_| TokenDecodeError::Base64DecodeFailed)?;
|
||||
|
||||
let claims: serde_json::Value =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
|
||||
serde_json::from_slice(&payload_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?;
|
||||
|
||||
claims
|
||||
.get("jti")
|
||||
.and_then(|j| j.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No jti claim in token".to_string())
|
||||
.ok_or(TokenDecodeError::MissingClaim)
|
||||
}
|
||||
|
||||
pub fn get_algorithm_from_token(token: &str) -> Result<String, String> {
|
||||
pub fn get_algorithm_from_token(token: &str) -> Result<SigningAlgorithm, TokenDecodeError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Invalid token format".to_string());
|
||||
return Err(TokenDecodeError::InvalidFormat);
|
||||
}
|
||||
|
||||
let header_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[0])
|
||||
.map_err(|e| format!("Base64 decode failed: {}", e))?;
|
||||
.map_err(|_| TokenDecodeError::Base64DecodeFailed)?;
|
||||
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
|
||||
serde_json::from_slice(&header_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?;
|
||||
|
||||
Ok(header.alg)
|
||||
}
|
||||
@@ -74,8 +73,12 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Cl
|
||||
verify_token_internal(
|
||||
token,
|
||||
key_bytes,
|
||||
Some(TOKEN_TYPE_ACCESS),
|
||||
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
|
||||
Some(TokenType::Access),
|
||||
Some(&[
|
||||
TokenScope::Access,
|
||||
TokenScope::AppPass,
|
||||
TokenScope::AppPassPrivileged,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,8 +86,8 @@ pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<C
|
||||
verify_token_internal(
|
||||
token,
|
||||
key_bytes,
|
||||
Some(TOKEN_TYPE_REFRESH),
|
||||
Some(&[SCOPE_REFRESH]),
|
||||
Some(TokenType::Refresh),
|
||||
Some(&[TokenScope::Refresh]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,8 +95,12 @@ pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData
|
||||
verify_token_hs256_internal(
|
||||
token,
|
||||
secret,
|
||||
Some(TOKEN_TYPE_ACCESS),
|
||||
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
|
||||
Some(TokenType::Access),
|
||||
Some(&[
|
||||
TokenScope::Access,
|
||||
TokenScope::AppPass,
|
||||
TokenScope::AppPassPrivileged,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,16 +108,16 @@ pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenDat
|
||||
verify_token_hs256_internal(
|
||||
token,
|
||||
secret,
|
||||
Some(TOKEN_TYPE_REFRESH),
|
||||
Some(&[SCOPE_REFRESH]),
|
||||
Some(TokenType::Refresh),
|
||||
Some(&[TokenScope::Refresh]),
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_token_internal(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
expected_typ: Option<&str>,
|
||||
allowed_scopes: Option<&[&str]>,
|
||||
expected_typ: Option<TokenType>,
|
||||
allowed_scopes: Option<&[TokenScope]>,
|
||||
) -> Result<TokenData<Claims>> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
@@ -160,13 +167,18 @@ fn verify_token_internal(
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(anyhow!("Token expired"));
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
let token_scope = claims.scope.as_deref().unwrap_or("");
|
||||
let token_scope: TokenScope = claims
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.unwrap_or_else(|e| match e {});
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(anyhow!("Invalid token scope: {}", token_scope));
|
||||
}
|
||||
@@ -178,8 +190,8 @@ fn verify_token_internal(
|
||||
fn verify_token_hs256_internal(
|
||||
token: &str,
|
||||
secret: &[u8],
|
||||
expected_typ: Option<&str>,
|
||||
allowed_scopes: Option<&[&str]>,
|
||||
expected_typ: Option<TokenType>,
|
||||
allowed_scopes: Option<&[TokenScope]>,
|
||||
) -> Result<TokenData<Claims>> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
@@ -197,7 +209,7 @@ fn verify_token_hs256_internal(
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
|
||||
|
||||
if header.alg != "HS256" {
|
||||
if header.alg != SigningAlgorithm::HS256 {
|
||||
return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg));
|
||||
}
|
||||
|
||||
@@ -235,13 +247,18 @@ fn verify_token_hs256_internal(
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(anyhow!("Token expired"));
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
let token_scope = claims.scope.as_deref().unwrap_or("");
|
||||
let token_scope: TokenScope = claims
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.unwrap_or_else(|e| match e {});
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(anyhow!("Invalid token scope: {}", token_scope));
|
||||
}
|
||||
@@ -254,14 +271,14 @@ pub fn verify_access_token_typed(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
verify_token_typed_internal(token, key_bytes, Some(TOKEN_TYPE_ACCESS), None)
|
||||
verify_token_typed_internal(token, key_bytes, Some(TokenType::Access), None)
|
||||
}
|
||||
|
||||
fn verify_token_typed_internal(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
expected_typ: Option<&str>,
|
||||
allowed_scopes: Option<&[&str]>,
|
||||
expected_typ: Option<TokenType>,
|
||||
allowed_scopes: Option<&[TokenScope]>,
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
@@ -315,13 +332,18 @@ fn verify_token_typed_internal(
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(TokenVerifyError::Expired);
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
let token_scope = claims.scope.as_deref().unwrap_or("");
|
||||
let token_scope: TokenScope = claims
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.unwrap_or_else(|e| match e {});
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
valkey = ["dep:redis"]
|
||||
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-infra = { workspace = true }
|
||||
tranquil-ripple = { workspace = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
redis = { workspace = true }
|
||||
redis = { workspace = true, optional = true }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
+148
-118
@@ -1,72 +1,135 @@
|
||||
pub use tranquil_infra::{Cache, CacheError, DistributedRateLimiter};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ValkeyCache {
|
||||
conn: redis::aio::ConnectionManager,
|
||||
}
|
||||
#[cfg(feature = "valkey")]
|
||||
mod valkey {
|
||||
use super::*;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
impl ValkeyCache {
|
||||
pub async fn new(url: &str) -> Result<Self, CacheError> {
|
||||
let client = redis::Client::open(url).map_err(|e| CacheError::Connection(e.to_string()))?;
|
||||
let manager = client
|
||||
.get_connection_manager()
|
||||
.await
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))?;
|
||||
Ok(Self { conn: manager })
|
||||
#[derive(Clone)]
|
||||
pub struct ValkeyCache {
|
||||
conn: redis::aio::ConnectionManager,
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> redis::aio::ConnectionManager {
|
||||
self.conn.clone()
|
||||
impl ValkeyCache {
|
||||
pub async fn new(url: &str) -> Result<Self, CacheError> {
|
||||
let client =
|
||||
redis::Client::open(url).map_err(|e| CacheError::Connection(e.to_string()))?;
|
||||
let manager = client
|
||||
.get_connection_manager()
|
||||
.await
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))?;
|
||||
Ok(Self { conn: manager })
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> redis::aio::ConnectionManager {
|
||||
self.conn.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for ValkeyCache {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("GET")
|
||||
.arg(key)
|
||||
.query_async::<Option<String>>(&mut conn)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("SET")
|
||||
.arg(key)
|
||||
.arg(value)
|
||||
.arg("PX")
|
||||
.arg(i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX))
|
||||
.query_async::<()>(&mut conn)
|
||||
.await
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("DEL")
|
||||
.arg(key)
|
||||
.query_async::<()>(&mut conn)
|
||||
.await
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))
|
||||
}
|
||||
|
||||
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.get(key).await.and_then(|s| BASE64.decode(&s).ok())
|
||||
}
|
||||
|
||||
async fn set_bytes(
|
||||
&self,
|
||||
key: &str,
|
||||
value: &[u8],
|
||||
ttl: Duration,
|
||||
) -> Result<(), CacheError> {
|
||||
let encoded = BASE64.encode(value);
|
||||
self.set(key, &encoded, ttl).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RedisRateLimiter {
|
||||
conn: redis::aio::ConnectionManager,
|
||||
}
|
||||
|
||||
impl RedisRateLimiter {
|
||||
pub fn new(conn: redis::aio::ConnectionManager) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DistributedRateLimiter for RedisRateLimiter {
|
||||
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool {
|
||||
let mut conn = self.conn.clone();
|
||||
let full_key = format!("rl:{}", key);
|
||||
let window_secs = i64::try_from(window_ms.div_ceil(1000).max(1)).unwrap_or(i64::MAX);
|
||||
let result: Result<i64, _> = redis::Script::new(
|
||||
r"local c = redis.call('INCR', KEYS[1])
|
||||
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
if redis.call('TTL', KEYS[1]) == -1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
return c",
|
||||
)
|
||||
.key(&full_key)
|
||||
.arg(window_secs)
|
||||
.invoke_async(&mut conn)
|
||||
.await;
|
||||
match result {
|
||||
Ok(count) => count <= i64::from(limit),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "redis rate limit script failed, allowing request");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn peek_rate_limit_count(&self, key: &str, _window_ms: u64) -> u64 {
|
||||
let mut conn = self.conn.clone();
|
||||
let full_key = format!("rl:{}", key);
|
||||
redis::cmd("GET")
|
||||
.arg(&full_key)
|
||||
.query_async::<Option<u64>>(&mut conn)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for ValkeyCache {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("GET")
|
||||
.arg(key)
|
||||
.query_async::<Option<String>>(&mut conn)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("SET")
|
||||
.arg(key)
|
||||
.arg(value)
|
||||
.arg("PX")
|
||||
.arg(ttl.as_millis().min(i64::MAX as u128) as i64)
|
||||
.query_async::<()>(&mut conn)
|
||||
.await
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("DEL")
|
||||
.arg(key)
|
||||
.query_async::<()>(&mut conn)
|
||||
.await
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))
|
||||
}
|
||||
|
||||
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.get(key).await.and_then(|s| BASE64.decode(&s).ok())
|
||||
}
|
||||
|
||||
async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
|
||||
let encoded = BASE64.encode(value);
|
||||
self.set(key, &encoded, ttl).await
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "valkey")]
|
||||
pub use valkey::{RedisRateLimiter, ValkeyCache};
|
||||
|
||||
pub struct NoOpCache;
|
||||
|
||||
@@ -97,55 +160,6 @@ impl Cache for NoOpCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RedisRateLimiter {
|
||||
conn: redis::aio::ConnectionManager,
|
||||
}
|
||||
|
||||
impl RedisRateLimiter {
|
||||
pub fn new(conn: redis::aio::ConnectionManager) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DistributedRateLimiter for RedisRateLimiter {
|
||||
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool {
|
||||
let mut conn = self.conn.clone();
|
||||
let full_key = format!("rl:{}", key);
|
||||
let window_secs = window_ms.div_ceil(1000).max(1) as i64;
|
||||
let result: Result<i64, _> = redis::Script::new(
|
||||
r"local c = redis.call('INCR', KEYS[1])
|
||||
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
if redis.call('TTL', KEYS[1]) == -1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
return c"
|
||||
)
|
||||
.key(&full_key)
|
||||
.arg(window_secs)
|
||||
.invoke_async(&mut conn)
|
||||
.await;
|
||||
match result {
|
||||
Ok(count) => count <= limit as i64,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "redis rate limit script failed, allowing request");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn peek_rate_limit_count(&self, key: &str, _window_ms: u64) -> u64 {
|
||||
let mut conn = self.conn.clone();
|
||||
let full_key = format!("rl:{}", key);
|
||||
redis::cmd("GET")
|
||||
.arg(&full_key)
|
||||
.query_async::<Option<u64>>(&mut conn)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOpRateLimiter;
|
||||
|
||||
#[async_trait]
|
||||
@@ -158,20 +172,36 @@ impl DistributedRateLimiter for NoOpRateLimiter {
|
||||
pub async fn create_cache(
|
||||
shutdown: tokio_util::sync::CancellationToken,
|
||||
) -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
|
||||
if let Ok(url) = std::env::var("VALKEY_URL") {
|
||||
match ValkeyCache::new(&url).await {
|
||||
Ok(cache) => {
|
||||
tracing::info!("using valkey cache at {url}");
|
||||
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
|
||||
return (Arc::new(cache), rate_limiter);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
|
||||
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
|
||||
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
|
||||
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
|
||||
|
||||
#[cfg(feature = "valkey")]
|
||||
if backend == "valkey" {
|
||||
if let Some(url) = valkey_url {
|
||||
match ValkeyCache::new(url).await {
|
||||
Ok(cache) => {
|
||||
tracing::info!("using valkey cache at {url}");
|
||||
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
|
||||
return (Arc::new(cache), rate_limiter);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("cache.backend is \"valkey\" but VALKEY_URL is not set. using ripple.");
|
||||
}
|
||||
}
|
||||
|
||||
match tranquil_ripple::RippleConfig::from_env() {
|
||||
#[cfg(not(feature = "valkey"))]
|
||||
if backend == "valkey" {
|
||||
tracing::warn!(
|
||||
"cache.backend is \"valkey\" but binary was compiled without valkey feature. using ripple."
|
||||
);
|
||||
}
|
||||
|
||||
match tranquil_ripple::RippleConfig::from_config() {
|
||||
Ok(config) => {
|
||||
let peer_count = config.seed_peers.len();
|
||||
match tranquil_ripple::RippleEngine::start(config, shutdown).await {
|
||||
@@ -183,13 +213,13 @@ pub async fn create_cache(
|
||||
(cache, rate_limiter)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("ripple engine failed to start: {e}. running without cache.");
|
||||
tracing::error!("ripple engine failed to start: {e:#}. running without cache.");
|
||||
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("ripple config error: {e}. running without cache.");
|
||||
tracing::error!("ripple config error: {e:#}. running without cache.");
|
||||
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
@@ -112,20 +112,19 @@ pub struct EmailSender {
|
||||
}
|
||||
|
||||
impl EmailSender {
|
||||
pub fn new(from_address: String, from_name: String) -> Self {
|
||||
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
|
||||
Self {
|
||||
from_address,
|
||||
from_name,
|
||||
sendmail_path: std::env::var("SENDMAIL_PATH")
|
||||
.unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
|
||||
sendmail_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?;
|
||||
let from_name =
|
||||
std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string());
|
||||
Some(Self::new(from_address, from_name))
|
||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
|
||||
let from_address = cfg.email.from_address.clone()?;
|
||||
let from_name = cfg.email.from_name.clone();
|
||||
let sendmail_path = cfg.email.sendmail_path.clone();
|
||||
Some(Self::new(from_address, from_name, sendmail_path))
|
||||
}
|
||||
|
||||
pub fn format_email(&self, notification: &QueuedComms) -> String {
|
||||
@@ -190,8 +189,8 @@ impl DiscordSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?;
|
||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
|
||||
let bot_token = cfg.discord.bot_token.clone()?;
|
||||
Some(Self::new(bot_token))
|
||||
}
|
||||
|
||||
@@ -454,8 +453,8 @@ impl TelegramSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
|
||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
|
||||
let bot_token = cfg.telegram.bot_token.clone()?;
|
||||
Some(Self::new(bot_token))
|
||||
}
|
||||
|
||||
@@ -586,10 +585,9 @@ impl SignalSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let signal_cli_path = std::env::var("SIGNAL_CLI_PATH")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/signal-cli".to_string());
|
||||
let sender_number = std::env::var("SIGNAL_SENDER_NUMBER").ok()?;
|
||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
|
||||
let signal_cli_path = cfg.signal.cli_path.clone();
|
||||
let sender_number = cfg.signal.sender_number.clone()?;
|
||||
Some(Self::new(signal_cli_path, sender_number))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "tranquil-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
confique = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,922 @@
|
||||
use confique::Config;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
|
||||
|
||||
/// Errors discovered during configuration validation.
|
||||
#[derive(Debug)]
|
||||
pub struct ConfigError {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "configuration validation failed:")?;
|
||||
for err in &self.errors {
|
||||
writeln!(f, " - {err}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
/// Initialize the global configuration. Must be called once at startup before
|
||||
/// any other code accesses the configuration. Panics if called more than once.
|
||||
pub fn init(config: TranquilConfig) {
|
||||
CONFIG
|
||||
.set(config)
|
||||
.expect("tranquil-config: configuration already initialized");
|
||||
}
|
||||
|
||||
/// Returns a reference to the global configuration.
|
||||
/// Panics if [`init`] has not been called yet.
|
||||
pub fn get() -> &'static TranquilConfig {
|
||||
CONFIG
|
||||
.get()
|
||||
.expect("tranquil-config: not initialized - call tranquil_config::init() first")
|
||||
}
|
||||
|
||||
/// Returns a reference to the global configuration if it has been initialized.
|
||||
pub fn try_get() -> Option<&'static TranquilConfig> {
|
||||
CONFIG.get()
|
||||
}
|
||||
|
||||
/// Initialize with minimal defaults for unit tests.
|
||||
/// Noop if already initialized.
|
||||
pub fn ensure_test_defaults() {
|
||||
use std::env;
|
||||
let _ = CONFIG.get_or_init(|| {
|
||||
unsafe {
|
||||
if env::var("PDS_HOSTNAME").is_err() {
|
||||
env::set_var("PDS_HOSTNAME", "test.local");
|
||||
}
|
||||
if env::var("DATABASE_URL").is_err() {
|
||||
env::set_var("DATABASE_URL", "postgres://localhost/test");
|
||||
}
|
||||
if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() {
|
||||
env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1");
|
||||
}
|
||||
if env::var("INVITE_CODE_REQUIRED").is_err() {
|
||||
env::set_var("INVITE_CODE_REQUIRED", "false");
|
||||
}
|
||||
if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() {
|
||||
env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true");
|
||||
}
|
||||
}
|
||||
TranquilConfig::builder()
|
||||
.env()
|
||||
.load()
|
||||
.expect("failed to load test config defaults")
|
||||
});
|
||||
}
|
||||
|
||||
/// Load configuration from an optional TOML file path, with environment
|
||||
/// variable overrides applied on top. Fields annotated with `#[config(env)]`
|
||||
/// are read from the corresponding environment variables when the `.env()`
|
||||
/// layer is active.
|
||||
///
|
||||
/// Precedence (highest to lowest):
|
||||
/// 1. Environment variables
|
||||
/// 2. TOML config file (if provided)
|
||||
/// 3. Built-in defaults
|
||||
pub fn load(config_path: Option<&PathBuf>) -> Result<TranquilConfig, confique::Error> {
|
||||
let mut builder = TranquilConfig::builder().env();
|
||||
if let Some(path) = config_path {
|
||||
builder = builder.file(path);
|
||||
}
|
||||
builder.file("/etc/tranquil-pds/config.toml").load()
|
||||
}
|
||||
|
||||
// Root configuration
|
||||
#[derive(Debug, Config)]
|
||||
pub struct TranquilConfig {
|
||||
#[config(nested)]
|
||||
pub server: ServerConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub frontend: FrontendConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub database: DatabaseConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub secrets: SecretsConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub storage: StorageConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub backup: BackupConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub cache: CacheConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub plc: PlcConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub firehose: FirehoseConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub email: EmailConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub discord: DiscordConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub telegram: TelegramConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub signal: SignalConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub notifications: NotificationConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub sso: SsoConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub moderation: ModerationConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub import: ImportConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub scheduled: ScheduledConfig,
|
||||
}
|
||||
|
||||
impl TranquilConfig {
|
||||
/// Validate cross-field constraints that cannot be expressed through
|
||||
/// confique's declarative defaults alone. Call this once after loading
|
||||
/// the configuration and before [`init`].
|
||||
///
|
||||
/// Returns `Ok(())` when the configuration is consistent, or a
|
||||
/// [`ConfigError`] listing every problem found.
|
||||
pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// -- secrets ----------------------------------------------------------
|
||||
if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) {
|
||||
if let Some(ref s) = self.secrets.jwt_secret {
|
||||
if s.len() < 32 {
|
||||
errors.push(
|
||||
"secrets.jwt_secret (JWT_SECRET) must be at least 32 characters"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
errors.push(
|
||||
"secrets.jwt_secret (JWT_SECRET) is required in production \
|
||||
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref s) = self.secrets.dpop_secret {
|
||||
if s.len() < 32 {
|
||||
errors.push(
|
||||
"secrets.dpop_secret (DPOP_SECRET) must be at least 32 characters"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
errors.push(
|
||||
"secrets.dpop_secret (DPOP_SECRET) is required in production \
|
||||
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref s) = self.secrets.master_key {
|
||||
if s.len() < 32 {
|
||||
errors.push(
|
||||
"secrets.master_key (MASTER_KEY) must be at least 32 characters"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
errors.push(
|
||||
"secrets.master_key (MASTER_KEY) is required in production \
|
||||
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- telegram ---------------------------------------------------------
|
||||
if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() {
|
||||
errors.push(
|
||||
"telegram.bot_token is set but telegram.webhook_secret is missing; \
|
||||
both are required for secure Telegram integration"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// -- blob storage -----------------------------------------------------
|
||||
match self.storage.backend.as_str() {
|
||||
"s3" => {
|
||||
if self.storage.s3_bucket.is_none() {
|
||||
errors.push(
|
||||
"storage.backend is \"s3\" but storage.s3_bucket (S3_BUCKET) \
|
||||
is not set"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
"filesystem" => {}
|
||||
other => {
|
||||
errors.push(format!(
|
||||
"storage.backend must be \"filesystem\" or \"s3\", got \"{other}\""
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// -- backup storage ---------------------------------------------------
|
||||
if self.backup.enabled {
|
||||
match self.backup.backend.as_str() {
|
||||
"s3" => {
|
||||
if self.backup.s3_bucket.is_none() {
|
||||
errors.push(
|
||||
"backup.backend is \"s3\" but backup.s3_bucket \
|
||||
(BACKUP_S3_BUCKET) is not set"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
"filesystem" => {}
|
||||
other => {
|
||||
errors.push(format!(
|
||||
"backup.backend must be \"filesystem\" or \"s3\", got \"{other}\""
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- SSO providers ----------------------------------------------------
|
||||
self.validate_sso_provider("sso.github", &self.sso.github, &mut errors);
|
||||
self.validate_sso_provider("sso.google", &self.sso.google, &mut errors);
|
||||
self.validate_sso_discord(&mut errors);
|
||||
self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors);
|
||||
self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors);
|
||||
self.validate_sso_apple(&mut errors);
|
||||
|
||||
// -- moderation -------------------------------------------------------
|
||||
let has_url = self.moderation.report_service_url.is_some();
|
||||
let has_did = self.moderation.report_service_did.is_some();
|
||||
if has_url != has_did {
|
||||
errors.push(
|
||||
"moderation.report_service_url and moderation.report_service_did \
|
||||
must both be set or both be unset"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// -- cache ------------------------------------------------------------
|
||||
match self.cache.backend.as_str() {
|
||||
"valkey" => {
|
||||
if self.cache.valkey_url.is_none() {
|
||||
errors.push(
|
||||
"cache.backend is \"valkey\" but cache.valkey_url (VALKEY_URL) \
|
||||
is not set"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
"ripple" => {}
|
||||
other => {
|
||||
errors.push(format!(
|
||||
"cache.backend must be \"ripple\" or \"valkey\", got \"{other}\""
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConfigError { errors })
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sso_provider(&self, prefix: &str, p: &SsoProviderConfig, errors: &mut Vec<String>) {
|
||||
if p.enabled {
|
||||
if p.client_id.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_id is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
if p.client_secret.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_secret is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sso_discord(&self, errors: &mut Vec<String>) {
|
||||
let p = &self.sso.discord;
|
||||
if p.enabled {
|
||||
if p.client_id.is_none() {
|
||||
errors.push(
|
||||
"sso.discord.client_id is required when sso.discord.enabled = true".to_string(),
|
||||
);
|
||||
}
|
||||
if p.client_secret.is_none() {
|
||||
errors.push(
|
||||
"sso.discord.client_secret is required when sso.discord.enabled = true"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sso_with_issuer(
|
||||
&self,
|
||||
prefix: &str,
|
||||
p: &SsoProviderWithIssuerConfig,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if p.enabled {
|
||||
if p.client_id.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_id is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
if p.client_secret.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_secret is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
if p.issuer.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.issuer is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sso_apple(&self, errors: &mut Vec<String>) {
|
||||
let p = &self.sso.apple;
|
||||
if p.enabled {
|
||||
if p.client_id.is_none() {
|
||||
errors.push(
|
||||
"sso.apple.client_id is required when sso.apple.enabled = true".to_string(),
|
||||
);
|
||||
}
|
||||
if p.team_id.is_none() {
|
||||
errors.push(
|
||||
"sso.apple.team_id is required when sso.apple.enabled = true".to_string(),
|
||||
);
|
||||
}
|
||||
if p.key_id.is_none() {
|
||||
errors
|
||||
.push("sso.apple.key_id is required when sso.apple.enabled = true".to_string());
|
||||
}
|
||||
if p.private_key.is_none() {
|
||||
errors.push(
|
||||
"sso.apple.private_key is required when sso.apple.enabled = true".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct ServerConfig {
|
||||
/// Public hostname of the PDS (e.g. `pds.example.com`).
|
||||
#[config(env = "PDS_HOSTNAME")]
|
||||
pub hostname: String,
|
||||
|
||||
/// Address to bind the HTTP server to.
|
||||
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
|
||||
pub host: String,
|
||||
|
||||
/// Port to bind the HTTP server to.
|
||||
#[config(env = "SERVER_PORT", default = 3000)]
|
||||
pub port: u16,
|
||||
|
||||
/// List of domains for user handles.
|
||||
/// Defaults to the PDS hostname when not set.
|
||||
#[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)]
|
||||
pub user_handle_domains: Option<Vec<String>>,
|
||||
|
||||
/// Enable PDS-hosted did:web identities. Hosting did:web requires a
|
||||
/// long-term commitment to serve DID documents; opt-in only.
|
||||
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
|
||||
pub enable_pds_hosted_did_web: bool,
|
||||
|
||||
/// When set to true, skip age-assurance birthday prompt for all accounts.
|
||||
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
|
||||
pub age_assurance_override: bool,
|
||||
|
||||
/// Require an invite code for new account registration.
|
||||
#[config(env = "INVITE_CODE_REQUIRED", default = true)]
|
||||
pub invite_code_required: bool,
|
||||
|
||||
/// Allow HTTP (non-TLS) proxy requests. Only useful during development.
|
||||
#[config(env = "ALLOW_HTTP_PROXY", default = false)]
|
||||
pub allow_http_proxy: bool,
|
||||
|
||||
/// Disable all rate limiting. Should only be used in testing.
|
||||
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
|
||||
pub disable_rate_limiting: bool,
|
||||
|
||||
/// List of additional banned words for handle validation.
|
||||
#[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)]
|
||||
pub banned_words: Option<Vec<String>>,
|
||||
|
||||
/// URL to a privacy policy page.
|
||||
#[config(env = "PRIVACY_POLICY_URL")]
|
||||
pub privacy_policy_url: Option<String>,
|
||||
|
||||
/// URL to terms of service page.
|
||||
#[config(env = "TERMS_OF_SERVICE_URL")]
|
||||
pub terms_of_service_url: Option<String>,
|
||||
|
||||
/// Operator contact email address.
|
||||
#[config(env = "CONTACT_EMAIL")]
|
||||
pub contact_email: Option<String>,
|
||||
|
||||
/// Maximum allowed blob size in bytes (default 10 GiB).
|
||||
#[config(env = "MAX_BLOB_SIZE", default = 10_737_418_240u64)]
|
||||
pub max_blob_size: u64,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
/// The public HTTPS URL for this PDS.
|
||||
pub fn public_url(&self) -> String {
|
||||
format!("https://{}", self.hostname)
|
||||
}
|
||||
|
||||
/// Hostname without port suffix (e.g. `pds.example.com` from
|
||||
/// `pds.example.com:443`).
|
||||
pub fn hostname_without_port(&self) -> &str {
|
||||
self.hostname.split(':').next().unwrap_or(&self.hostname)
|
||||
}
|
||||
|
||||
/// Returns the extra banned words list, or an empty vec when unset.
|
||||
pub fn banned_word_list(&self) -> Vec<String> {
|
||||
self.banned_words.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
|
||||
pub fn user_handle_domain_list(&self) -> Vec<String> {
|
||||
self.user_handle_domains
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| v.to_vec())
|
||||
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
|
||||
}
|
||||
|
||||
/// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field).
|
||||
pub fn available_user_domain_list(&self) -> Vec<String> {
|
||||
self.user_handle_domain_list()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct FrontendConfig {
|
||||
/// Whether to enable the built in serving of the frontend.
|
||||
#[config(env = "FRONTEND_ENABLED", default = true)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Directory to serve as the frontend. The oauth_client_metadata.json will have any references to
|
||||
/// the frontend hostname replaced by the configured frontend hostname.
|
||||
#[config(env = "FRONTEND_DIR", default = "/var/lib/tranquil-pds/frontend")]
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct DatabaseConfig {
|
||||
/// PostgreSQL connection URL.
|
||||
#[config(env = "DATABASE_URL")]
|
||||
pub url: String,
|
||||
|
||||
/// Maximum number of connections in the pool.
|
||||
#[config(env = "DATABASE_MAX_CONNECTIONS", default = 100)]
|
||||
pub max_connections: u32,
|
||||
|
||||
/// Minimum number of idle connections kept in the pool.
|
||||
#[config(env = "DATABASE_MIN_CONNECTIONS", default = 10)]
|
||||
pub min_connections: u32,
|
||||
|
||||
/// Timeout in seconds when acquiring a connection from the pool.
|
||||
#[config(env = "DATABASE_ACQUIRE_TIMEOUT_SECS", default = 10)]
|
||||
pub acquire_timeout_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Config)]
|
||||
pub struct SecretsConfig {
|
||||
/// Secret used for signing JWTs. Must be at least 32 characters in
|
||||
/// production.
|
||||
#[config(env = "JWT_SECRET")]
|
||||
pub jwt_secret: Option<String>,
|
||||
|
||||
/// Secret used for DPoP proof validation. Must be at least 32 characters
|
||||
/// in production.
|
||||
#[config(env = "DPOP_SECRET")]
|
||||
pub dpop_secret: Option<String>,
|
||||
|
||||
/// Master key used for key-encryption and HKDF derivation. Must be at
|
||||
/// least 32 characters in production.
|
||||
#[config(env = "MASTER_KEY")]
|
||||
pub master_key: Option<String>,
|
||||
|
||||
/// PLC rotation key (DID key). If not set, user-level keys are used.
|
||||
#[config(env = "PLC_ROTATION_KEY")]
|
||||
pub plc_rotation_key: Option<String>,
|
||||
|
||||
/// Allow insecure/test secrets. NEVER enable in production.
|
||||
#[config(env = "TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", default = false)]
|
||||
pub allow_insecure: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SecretsConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SecretsConfig")
|
||||
.field(
|
||||
"jwt_secret",
|
||||
&self.jwt_secret.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field(
|
||||
"dpop_secret",
|
||||
&self.dpop_secret.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field(
|
||||
"master_key",
|
||||
&self.master_key.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field(
|
||||
"plc_rotation_key",
|
||||
&self.plc_rotation_key.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("allow_insecure", &self.allow_insecure)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsConfig {
|
||||
/// Resolve the JWT secret, falling back to an insecure default if
|
||||
/// `allow_insecure` is true.
|
||||
pub fn jwt_secret_or_default(&self) -> String {
|
||||
self.jwt_secret.clone().unwrap_or_else(|| {
|
||||
if cfg!(test) || self.allow_insecure {
|
||||
"test-jwt-secret-not-for-production".to_string()
|
||||
} else {
|
||||
panic!(
|
||||
"JWT_SECRET must be set in production. \
|
||||
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the DPoP secret, falling back to an insecure default if
|
||||
/// `allow_insecure` is true.
|
||||
pub fn dpop_secret_or_default(&self) -> String {
|
||||
self.dpop_secret.clone().unwrap_or_else(|| {
|
||||
if cfg!(test) || self.allow_insecure {
|
||||
"test-dpop-secret-not-for-production".to_string()
|
||||
} else {
|
||||
panic!(
|
||||
"DPOP_SECRET must be set in production. \
|
||||
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the master key, falling back to an insecure default if
|
||||
/// `allow_insecure` is true.
|
||||
pub fn master_key_or_default(&self) -> String {
|
||||
self.master_key.clone().unwrap_or_else(|| {
|
||||
if cfg!(test) || self.allow_insecure {
|
||||
"test-master-key-not-for-production".to_string()
|
||||
} else {
|
||||
panic!(
|
||||
"MASTER_KEY must be set in production. \
|
||||
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct StorageConfig {
|
||||
/// Storage backend: `filesystem` or `s3`.
|
||||
#[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")]
|
||||
pub backend: String,
|
||||
|
||||
/// Path on disk for the filesystem blob backend.
|
||||
#[config(env = "BLOB_STORAGE_PATH", default = "/var/lib/tranquil-pds/blobs")]
|
||||
pub path: String,
|
||||
|
||||
/// S3 bucket name for blob storage.
|
||||
#[config(env = "S3_BUCKET")]
|
||||
pub s3_bucket: Option<String>,
|
||||
|
||||
/// Custom S3 endpoint URL (for MinIO, R2, etc.).
|
||||
#[config(env = "S3_ENDPOINT")]
|
||||
pub s3_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct BackupConfig {
|
||||
/// Enable automatic backups.
|
||||
#[config(env = "BACKUP_ENABLED", default = true)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Backup storage backend: `filesystem` or `s3`.
|
||||
#[config(env = "BACKUP_STORAGE_BACKEND", default = "filesystem")]
|
||||
pub backend: String,
|
||||
|
||||
/// Path on disk for the filesystem backup backend.
|
||||
#[config(env = "BACKUP_STORAGE_PATH", default = "/var/lib/tranquil-pds/backups")]
|
||||
pub path: String,
|
||||
|
||||
/// S3 bucket name for backups.
|
||||
#[config(env = "BACKUP_S3_BUCKET")]
|
||||
pub s3_bucket: Option<String>,
|
||||
|
||||
/// Number of backup revisions to keep per account.
|
||||
#[config(env = "BACKUP_RETENTION_COUNT", default = 7)]
|
||||
pub retention_count: u32,
|
||||
|
||||
/// Seconds between backup runs.
|
||||
#[config(env = "BACKUP_INTERVAL_SECS", default = 86400)]
|
||||
pub interval_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct CacheConfig {
|
||||
/// Cache backend: `ripple` (default, built-in gossip) or `valkey`.
|
||||
#[config(env = "CACHE_BACKEND", default = "ripple")]
|
||||
pub backend: String,
|
||||
|
||||
/// Valkey / Redis connection URL. Required when `backend = "valkey"`.
|
||||
#[config(env = "VALKEY_URL")]
|
||||
pub valkey_url: Option<String>,
|
||||
|
||||
#[config(nested)]
|
||||
pub ripple: RippleCacheConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct PlcConfig {
|
||||
/// Base URL of the PLC directory.
|
||||
#[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")]
|
||||
pub directory_url: String,
|
||||
|
||||
/// HTTP request timeout in seconds.
|
||||
#[config(env = "PLC_TIMEOUT_SECS", default = 10)]
|
||||
pub timeout_secs: u64,
|
||||
|
||||
/// TCP connect timeout in seconds.
|
||||
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
|
||||
pub connect_timeout_secs: u64,
|
||||
|
||||
/// Seconds to cache DID documents in memory.
|
||||
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
|
||||
pub did_cache_ttl_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct FirehoseConfig {
|
||||
/// Size of the in-memory broadcast buffer for firehose events.
|
||||
#[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)]
|
||||
pub buffer_size: usize,
|
||||
|
||||
/// How many hours of historical events to replay for cursor-based
|
||||
/// firehose connections.
|
||||
#[config(env = "FIREHOSE_BACKFILL_HOURS", default = 72)]
|
||||
pub backfill_hours: i64,
|
||||
|
||||
/// Maximum number of lagged events before disconnecting a slow consumer.
|
||||
#[config(env = "FIREHOSE_MAX_LAG", default = 5000)]
|
||||
pub max_lag: u64,
|
||||
|
||||
/// List of relay / crawler notification URLs.
|
||||
#[config(env = "CRAWLERS", parse_env = split_comma_list)]
|
||||
pub crawlers: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FirehoseConfig {
|
||||
/// Returns the list of crawler URLs, falling back to `["https://bsky.network"]`
|
||||
/// when none are configured.
|
||||
pub fn crawler_list(&self) -> Vec<String> {
|
||||
self.crawlers
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec!["https://bsky.network".to_string()])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct EmailConfig {
|
||||
/// Sender email address. When unset, email sending is disabled.
|
||||
#[config(env = "MAIL_FROM_ADDRESS")]
|
||||
pub from_address: Option<String>,
|
||||
|
||||
/// Display name used in the `From` header.
|
||||
#[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")]
|
||||
pub from_name: String,
|
||||
|
||||
/// Path to the `sendmail` binary.
|
||||
#[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")]
|
||||
pub sendmail_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct DiscordConfig {
|
||||
/// Discord bot token. When unset, Discord integration is disabled.
|
||||
#[config(env = "DISCORD_BOT_TOKEN")]
|
||||
pub bot_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct TelegramConfig {
|
||||
/// Telegram bot token. When unset, Telegram integration is disabled.
|
||||
#[config(env = "TELEGRAM_BOT_TOKEN")]
|
||||
pub bot_token: Option<String>,
|
||||
|
||||
/// Secret token for incoming webhook verification.
|
||||
#[config(env = "TELEGRAM_WEBHOOK_SECRET")]
|
||||
pub webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SignalConfig {
|
||||
/// Path to the `signal-cli` binary.
|
||||
#[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")]
|
||||
pub cli_path: String,
|
||||
|
||||
/// Sender phone number. When unset, Signal integration is disabled.
|
||||
#[config(env = "SIGNAL_SENDER_NUMBER")]
|
||||
pub sender_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct NotificationConfig {
|
||||
/// Polling interval in milliseconds for the comms queue.
|
||||
#[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)]
|
||||
pub poll_interval_ms: u64,
|
||||
|
||||
/// Number of notifications to process per batch.
|
||||
#[config(env = "NOTIFICATION_BATCH_SIZE", default = 100)]
|
||||
pub batch_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoConfig {
|
||||
#[config(nested)]
|
||||
pub github: SsoProviderConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub discord: SsoDiscordProviderConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub google: SsoProviderConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub gitlab: SsoProviderWithIssuerConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub oidc: SsoProviderWithIssuerConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub apple: SsoAppleConfig,
|
||||
}
|
||||
|
||||
// Generic SSO provider (GitHub, Google)
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoProviderConfig {
|
||||
#[config(default = false)]
|
||||
pub enabled: bool,
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
// SSO provider with custom env prefixes for Discord
|
||||
// (since the nested TOML key is `sso.discord` but env vars are `SSO_DISCORD_*`)
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoDiscordProviderConfig {
|
||||
#[config(default = false)]
|
||||
pub enabled: bool,
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
// SSO providers that require an issuer URL (GitLab, OIDC)
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoProviderWithIssuerConfig {
|
||||
#[config(default = false)]
|
||||
pub enabled: bool,
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
pub issuer: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoAppleConfig {
|
||||
#[config(env = "SSO_APPLE_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
|
||||
#[config(env = "SSO_APPLE_CLIENT_ID")]
|
||||
pub client_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_APPLE_TEAM_ID")]
|
||||
pub team_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_APPLE_KEY_ID")]
|
||||
pub key_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_APPLE_PRIVATE_KEY")]
|
||||
pub private_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct ModerationConfig {
|
||||
/// External report-handling service URL.
|
||||
#[config(env = "REPORT_SERVICE_URL")]
|
||||
pub report_service_url: Option<String>,
|
||||
|
||||
/// DID of the external report-handling service.
|
||||
#[config(env = "REPORT_SERVICE_DID")]
|
||||
pub report_service_did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct ImportConfig {
|
||||
/// Whether the PDS accepts repo imports.
|
||||
#[config(env = "ACCEPTING_REPO_IMPORTS", default = true)]
|
||||
pub accepting: bool,
|
||||
|
||||
/// Maximum allowed import archive size in bytes (default 1 GiB).
|
||||
#[config(env = "MAX_IMPORT_SIZE", default = 1_073_741_824)]
|
||||
pub max_size: u64,
|
||||
|
||||
/// Maximum number of blocks allowed in an import.
|
||||
#[config(env = "MAX_IMPORT_BLOCKS", default = 500000)]
|
||||
pub max_blocks: u64,
|
||||
|
||||
/// Skip CAR verification during import. Only for development/debugging.
|
||||
#[config(env = "SKIP_IMPORT_VERIFICATION", default = false)]
|
||||
pub skip_verification: bool,
|
||||
}
|
||||
|
||||
/// Parse a comma-separated environment variable into a `Vec<String>`,
|
||||
/// trimming whitespace and dropping empty entries.
|
||||
///
|
||||
/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.
|
||||
fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> {
|
||||
Ok(value
|
||||
.split(',')
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct RippleCacheConfig {
|
||||
/// Address to bind the Ripple gossip protocol listener.
|
||||
#[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")]
|
||||
pub bind_addr: String,
|
||||
|
||||
/// List of seed peer addresses.
|
||||
#[config(env = "RIPPLE_PEERS", parse_env = split_comma_list)]
|
||||
pub peers: Option<Vec<String>>,
|
||||
|
||||
/// Unique machine identifier. Auto-derived from hostname when not set.
|
||||
#[config(env = "RIPPLE_MACHINE_ID")]
|
||||
pub machine_id: Option<u64>,
|
||||
|
||||
/// Gossip protocol interval in milliseconds.
|
||||
#[config(env = "RIPPLE_GOSSIP_INTERVAL_MS", default = 200)]
|
||||
pub gossip_interval_ms: u64,
|
||||
|
||||
/// Maximum cache size in megabytes.
|
||||
#[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)]
|
||||
pub cache_max_mb: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct ScheduledConfig {
|
||||
/// Interval in seconds between scheduled delete checks.
|
||||
#[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)]
|
||||
pub delete_check_interval_secs: u64,
|
||||
}
|
||||
|
||||
/// Generate a TOML configuration template with all available options,
|
||||
/// defaults, and documentation comments.
|
||||
pub fn template() -> String {
|
||||
confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default())
|
||||
}
|
||||
@@ -1,13 +1,60 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tranquil_types::{AtUri, Nsid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BacklinkPath {
|
||||
Subject,
|
||||
SubjectUri,
|
||||
}
|
||||
|
||||
impl BacklinkPath {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Subject => "subject",
|
||||
Self::SubjectUri => "subject.uri",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BacklinkPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacklinkPathParseError(String);
|
||||
|
||||
impl fmt::Display for BacklinkPathParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "unknown backlink path: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BacklinkPathParseError {}
|
||||
|
||||
impl FromStr for BacklinkPath {
|
||||
type Err = BacklinkPathParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"subject" => Ok(Self::Subject),
|
||||
"subject.uri" => Ok(Self::SubjectUri),
|
||||
_ => Err(BacklinkPathParseError(s.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backlink {
|
||||
pub uri: AtUri,
|
||||
pub path: String,
|
||||
pub path: BacklinkPath,
|
||||
pub link_to: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct ChannelVerificationStatus {
|
||||
}
|
||||
|
||||
impl ChannelVerificationStatus {
|
||||
pub fn new(email: bool, discord: bool, telegram: bool, signal: bool) -> Self {
|
||||
pub fn from_db_row(email: bool, discord: bool, telegram: bool, signal: bool) -> Self {
|
||||
Self {
|
||||
email,
|
||||
discord,
|
||||
@@ -19,6 +19,15 @@ impl ChannelVerificationStatus {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_verified_channels(channels: &[CommsChannel]) -> Self {
|
||||
Self {
|
||||
email: channels.contains(&CommsChannel::Email),
|
||||
discord: channels.contains(&CommsChannel::Discord),
|
||||
telegram: channels.contains(&CommsChannel::Telegram),
|
||||
signal: channels.contains(&CommsChannel::Signal),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_any_verified(&self) -> bool {
|
||||
self.email || self.discord || self.telegram || self.signal
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ pub enum DbError {
|
||||
#[error("Resource busy, try again")]
|
||||
LockContention,
|
||||
|
||||
#[error("Corrupt data in column: {0}")]
|
||||
CorruptData(&'static str),
|
||||
|
||||
#[error("Other database error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -31,25 +31,16 @@ impl InviteCodeState {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for InviteCodeState {
|
||||
fn from(disabled: bool) -> Self {
|
||||
if disabled {
|
||||
Self::Disabled
|
||||
} else {
|
||||
Self::Active
|
||||
impl InviteCodeState {
|
||||
pub fn from_disabled_flag(disabled: bool) -> Self {
|
||||
match disabled {
|
||||
true => Self::Disabled,
|
||||
false => Self::Active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<bool>> for InviteCodeState {
|
||||
fn from(disabled: Option<bool>) -> Self {
|
||||
Self::from(disabled.unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InviteCodeState> for bool {
|
||||
fn from(state: InviteCodeState) -> Self {
|
||||
matches!(state, InviteCodeState::Disabled)
|
||||
pub fn from_optional_disabled_flag(disabled: Option<bool>) -> Self {
|
||||
Self::from_disabled_flag(disabled.unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +54,51 @@ pub enum CommsChannel {
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl CommsChannel {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Email => "email",
|
||||
Self::Discord => "discord",
|
||||
Self::Telegram => "telegram",
|
||||
Self::Signal => "signal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Email => "email",
|
||||
Self::Discord => "Discord",
|
||||
Self::Telegram => "Telegram",
|
||||
Self::Signal => "Signal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for CommsChannel {
|
||||
type Err = InvalidCommsChannel;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"email" => Ok(Self::Email),
|
||||
"discord" => Ok(Self::Discord),
|
||||
"telegram" => Ok(Self::Telegram),
|
||||
"signal" => Ok(Self::Signal),
|
||||
_ => Err(InvalidCommsChannel),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvalidCommsChannel;
|
||||
|
||||
impl std::fmt::Display for InvalidCommsChannel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("invalid comms channel")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidCommsChannel {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "comms_type", rename_all = "snake_case")]
|
||||
pub enum CommsType {
|
||||
@@ -139,7 +175,7 @@ pub struct InviteCodeRow {
|
||||
|
||||
impl InviteCodeRow {
|
||||
pub fn state(&self) -> InviteCodeState {
|
||||
InviteCodeState::from(self.disabled)
|
||||
InviteCodeState::from_optional_disabled_flag(self.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ mod session;
|
||||
mod sso;
|
||||
mod user;
|
||||
|
||||
pub use backlink::{Backlink, BacklinkRepository};
|
||||
pub use backlink::{Backlink, BacklinkPath, BacklinkRepository};
|
||||
pub use backup::{
|
||||
BackupForDeletion, BackupRepository, BackupRow, BackupStorageInfo, BlobExportInfo,
|
||||
OldBackupInfo, UserBackupInfo,
|
||||
@@ -68,5 +68,5 @@ pub use user::{
|
||||
UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck,
|
||||
UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification,
|
||||
UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey,
|
||||
VerifiedTotpRecord,
|
||||
VerifiedTotpRecord, WebauthnChallengeType,
|
||||
};
|
||||
|
||||
@@ -192,6 +192,11 @@ pub trait OAuthRepository: Send + Sync {
|
||||
) -> Result<Option<RequestData>, DbError>;
|
||||
async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>;
|
||||
async fn delete_expired_authorization_requests(&self) -> Result<u64, DbError>;
|
||||
async fn extend_authorization_request_expiry(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
new_expires_at: DateTime<Utc>,
|
||||
) -> Result<bool, DbError>;
|
||||
async fn mark_request_authenticated(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
|
||||
@@ -57,6 +57,13 @@ impl AccountStatus {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_firehose_typed(&self) -> Option<Self> {
|
||||
match self {
|
||||
Self::Active => None,
|
||||
other => Some(*other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"active" => Some(Self::Active),
|
||||
|
||||
@@ -20,17 +20,12 @@ impl LoginType {
|
||||
pub fn is_modern(self) -> bool {
|
||||
matches!(self, Self::Modern)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for LoginType {
|
||||
fn from(legacy: bool) -> Self {
|
||||
if legacy { Self::Legacy } else { Self::Modern }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoginType> for bool {
|
||||
fn from(lt: LoginType) -> Self {
|
||||
matches!(lt, LoginType::Legacy)
|
||||
pub fn from_legacy_flag(legacy: bool) -> Self {
|
||||
match legacy {
|
||||
true => Self::Legacy,
|
||||
false => Self::Modern,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,24 +40,15 @@ impl AppPasswordPrivilege {
|
||||
pub fn is_privileged(self) -> bool {
|
||||
matches!(self, Self::Privileged)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for AppPasswordPrivilege {
|
||||
fn from(privileged: bool) -> Self {
|
||||
if privileged {
|
||||
Self::Privileged
|
||||
} else {
|
||||
Self::Standard
|
||||
pub fn from_privileged_flag(privileged: bool) -> Self {
|
||||
match privileged {
|
||||
true => Self::Privileged,
|
||||
false => Self::Standard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppPasswordPrivilege> for bool {
|
||||
fn from(p: AppPasswordPrivilege) -> Self {
|
||||
matches!(p, AppPasswordPrivilege::Privileged)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct SessionId(i32);
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ impl From<ExternalEmail> for String {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SsoProviderType {
|
||||
Github,
|
||||
Discord,
|
||||
@@ -141,7 +142,7 @@ impl SsoAction {
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
match s {
|
||||
"login" => Some(Self::Login),
|
||||
"link" => Some(Self::Link),
|
||||
"register" => Some(Self::Register),
|
||||
@@ -156,6 +157,44 @@ impl std::fmt::Display for SsoAction {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for SsoProviderType {
|
||||
type Err = InvalidSsoProviderType;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s).ok_or(InvalidSsoProviderType)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvalidSsoProviderType;
|
||||
|
||||
impl std::fmt::Display for InvalidSsoProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("invalid SSO provider type")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidSsoProviderType {}
|
||||
|
||||
impl std::str::FromStr for SsoAction {
|
||||
type Err = InvalidSsoAction;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s).ok_or(InvalidSsoAction)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvalidSsoAction;
|
||||
|
||||
impl std::fmt::Display for InvalidSsoAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("invalid SSO action")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidSsoAction {}
|
||||
|
||||
impl SsoProviderType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -169,7 +208,7 @@ impl SsoProviderType {
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
match s {
|
||||
"github" => Some(Self::Github),
|
||||
"discord" => Some(Self::Discord),
|
||||
"google" => Some(Self::Google),
|
||||
|
||||
@@ -6,6 +6,21 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WebauthnChallengeType {
|
||||
Registration,
|
||||
Authentication,
|
||||
}
|
||||
|
||||
impl WebauthnChallengeType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Registration => "registration",
|
||||
Self::Authentication => "authentication",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "account_type", rename_all = "snake_case")]
|
||||
pub enum AccountType {
|
||||
@@ -325,20 +340,20 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn save_webauthn_challenge(
|
||||
&self,
|
||||
did: &Did,
|
||||
challenge_type: &str,
|
||||
challenge_type: WebauthnChallengeType,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError>;
|
||||
|
||||
async fn load_webauthn_challenge(
|
||||
&self,
|
||||
did: &Did,
|
||||
challenge_type: &str,
|
||||
challenge_type: WebauthnChallengeType,
|
||||
) -> Result<Option<String>, DbError>;
|
||||
|
||||
async fn delete_webauthn_challenge(
|
||||
&self,
|
||||
did: &Did,
|
||||
challenge_type: &str,
|
||||
challenge_type: WebauthnChallengeType,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>;
|
||||
@@ -871,6 +886,8 @@ pub struct UserResendVerification {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserResetCodeInfo {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -941,6 +958,7 @@ pub struct UserForPasskeyRecovery {
|
||||
pub struct UserForRecovery {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub recovery_token: Option<String>,
|
||||
pub recovery_token_expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: None,
|
||||
@@ -438,7 +438,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
@@ -461,7 +461,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
Ok(result.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
@@ -492,7 +492,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
|
||||
@@ -615,6 +615,26 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn extend_authorization_request_expiry(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
new_expires_at: DateTime<Utc>,
|
||||
) -> Result<bool, DbError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE oauth_authorization_request
|
||||
SET expires_at = $2
|
||||
WHERE id = $1 AND did IS NOT NULL AND code IS NULL
|
||||
"#,
|
||||
request_id.as_str(),
|
||||
new_expires_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn mark_request_authenticated(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
|
||||
@@ -37,7 +37,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
data.refresh_jti,
|
||||
data.access_expires_at,
|
||||
data.refresh_expires_at,
|
||||
bool::from(data.login_type),
|
||||
data.login_type.is_legacy(),
|
||||
data.mfa_verified,
|
||||
data.scope,
|
||||
data.controller_did.as_ref().map(|d| d.as_str()),
|
||||
@@ -75,7 +75,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
refresh_jti: r.refresh_jti,
|
||||
access_expires_at: r.access_expires_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
login_type: LoginType::from(r.legacy_login),
|
||||
login_type: LoginType::from_legacy_flag(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
@@ -325,7 +325,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from(r.privileged),
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
})
|
||||
@@ -358,7 +358,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from(r.privileged),
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
})
|
||||
@@ -389,7 +389,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from(r.privileged),
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
}))
|
||||
@@ -405,7 +405,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
data.user_id,
|
||||
data.name,
|
||||
data.password_hash,
|
||||
bool::from(data.privilege),
|
||||
data.privilege.is_privileged(),
|
||||
data.scopes,
|
||||
data.created_by_controller_did.as_ref().map(|d| d.as_str())
|
||||
)
|
||||
@@ -486,7 +486,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionMfaStatus {
|
||||
login_type: LoginType::from(r.legacy_login),
|
||||
login_type: LoginType::from_legacy_flag(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
last_reauth_at: r.last_reauth_at,
|
||||
}))
|
||||
|
||||
@@ -68,17 +68,20 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| ExternalIdentity {
|
||||
id: r.id,
|
||||
did: unsafe { Did::new_unchecked(&r.did) },
|
||||
provider: r.provider,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(ExternalIdentity {
|
||||
id: r.id,
|
||||
did: r.did.parse().map_err(|_| DbError::CorruptData("DID"))?,
|
||||
provider: r.provider,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_external_identities_by_did(
|
||||
@@ -99,20 +102,21 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ExternalIdentity {
|
||||
id: r.id,
|
||||
did: unsafe { Did::new_unchecked(&r.did) },
|
||||
provider: r.provider,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(ExternalIdentity {
|
||||
id: r.id,
|
||||
did: r.did.parse().map_err(|_| DbError::CorruptData("DID"))?,
|
||||
provider: r.provider,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn update_external_identity_login(
|
||||
@@ -202,7 +206,10 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
row.map(|r| {
|
||||
let action = SsoAction::parse(&r.action).ok_or(DbError::NotFound)?;
|
||||
let action: SsoAction = r
|
||||
.action
|
||||
.parse()
|
||||
.map_err(|_| DbError::CorruptData("sso_action"))?;
|
||||
Ok(SsoAuthState {
|
||||
state: r.state,
|
||||
request_uri: r.request_uri,
|
||||
@@ -210,7 +217,11 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
action,
|
||||
nonce: r.nonce,
|
||||
code_verifier: r.code_verifier,
|
||||
did: r.did.map(|d| unsafe { Did::new_unchecked(&d) }),
|
||||
did: r
|
||||
.did
|
||||
.map(|d| d.parse::<Did>())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::CorruptData("DID"))?,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ use tranquil_db_traits::{
|
||||
UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref,
|
||||
UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository,
|
||||
UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus,
|
||||
UserVerificationInfo, UserWithKey,
|
||||
UserVerificationInfo, UserWithKey, WebauthnChallengeType,
|
||||
};
|
||||
|
||||
pub struct PostgresUserRepository {
|
||||
@@ -281,7 +281,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
password_hash: r.password_hash,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
@@ -746,7 +746,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
@@ -1029,7 +1029,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
async fn save_webauthn_challenge(
|
||||
&self,
|
||||
did: &Did,
|
||||
challenge_type: &str,
|
||||
challenge_type: WebauthnChallengeType,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let id = Uuid::new_v4();
|
||||
@@ -1041,7 +1041,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
id,
|
||||
did.as_str(),
|
||||
challenge,
|
||||
challenge_type,
|
||||
challenge_type.as_str(),
|
||||
state_json,
|
||||
expires_at,
|
||||
)
|
||||
@@ -1055,14 +1055,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
async fn load_webauthn_challenge(
|
||||
&self,
|
||||
did: &Did,
|
||||
challenge_type: &str,
|
||||
challenge_type: WebauthnChallengeType,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
r#"SELECT state_json FROM webauthn_challenges
|
||||
WHERE did = $1 AND challenge_type = $2 AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1"#,
|
||||
did.as_str(),
|
||||
challenge_type
|
||||
challenge_type.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -1074,12 +1074,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
async fn delete_webauthn_challenge(
|
||||
&self,
|
||||
did: &Did,
|
||||
challenge_type: &str,
|
||||
challenge_type: WebauthnChallengeType,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = $2",
|
||||
did.as_str(),
|
||||
challenge_type
|
||||
challenge_type.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -1365,7 +1365,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
deactivated_at: row.deactivated_at,
|
||||
takedown_ref: row.takedown_ref,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
@@ -1395,7 +1395,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
id: row.id,
|
||||
two_factor_enabled: row.two_factor_enabled,
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
@@ -1432,7 +1432,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
takedown_ref: row.takedown_ref,
|
||||
preferred_locale: row.preferred_locale,
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
@@ -1525,7 +1525,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email: row.email,
|
||||
deactivated_at: row.deactivated_at,
|
||||
takedown_ref: row.takedown_ref,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
@@ -1602,7 +1602,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
discord_username: row.discord_username,
|
||||
telegram_username: row.telegram_username,
|
||||
signal_username: row.signal_username,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
@@ -1715,7 +1715,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
code: &str,
|
||||
) -> Result<Option<UserResetCodeInfo>, DbError> {
|
||||
sqlx::query!(
|
||||
"SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
code
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -1724,6 +1724,8 @@ impl UserRepository for PostgresUserRepository {
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserResetCodeInfo {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
expires_at: row.password_reset_code_expires_at,
|
||||
})
|
||||
})
|
||||
@@ -2202,7 +2204,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn get_user_for_recovery(&self, did: &Did) -> Result<Option<UserForRecovery>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
did.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -2212,6 +2214,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(row.map(|r| UserForRecovery {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
preferred_comms_channel: r.preferred_comms_channel,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
}))
|
||||
|
||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -45,16 +45,14 @@ pub trait BackupStorage: Send + Sync {
|
||||
}
|
||||
|
||||
pub fn backup_retention_count() -> u32 {
|
||||
std::env::var("BACKUP_RETENTION_COUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
tranquil_config::try_get()
|
||||
.map(|c| c.backup.retention_count)
|
||||
.unwrap_or(7)
|
||||
}
|
||||
|
||||
pub fn backup_interval_secs() -> u64 {
|
||||
std::env::var("BACKUP_INTERVAL_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
tranquil_config::try_get()
|
||||
.map(|c| c.backup.interval_secs)
|
||||
.unwrap_or(86400)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
tranquil-storage = { workspace = true }
|
||||
tranquil-cache = { workspace = true }
|
||||
@@ -21,8 +22,6 @@ aes-gcm = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
backon = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
aws-config = { workspace = true }
|
||||
aws-sdk-s3 = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
@@ -31,6 +30,7 @@ bs58 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
cid = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
@@ -55,7 +55,7 @@ multibase = { workspace = true }
|
||||
multihash = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
redis = { workspace = true }
|
||||
redis = { workspace = true, optional = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -79,10 +79,16 @@ urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-sdk-s3 = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["frontend", "s3", "valkey"]
|
||||
external-infra = []
|
||||
s3-storage = []
|
||||
s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
|
||||
s3 = ["s3-storage"]
|
||||
valkey = ["tranquil-cache/valkey", "dep:redis"]
|
||||
frontend = []
|
||||
|
||||
[dev-dependencies]
|
||||
ciborium = { workspace = true }
|
||||
|
||||
@@ -21,7 +21,7 @@ fn get_age_from_datestring(birth_date: &str) -> Option<i32> {
|
||||
let bday = NaiveDate::parse_from_str(birth_date, "%Y-%m-%d").ok()?;
|
||||
let today = Utc::now().date_naive();
|
||||
let mut age = today.year() - bday.year();
|
||||
let m = today.month() as i32 - bday.month() as i32;
|
||||
let m = i32::try_from(today.month()).unwrap_or(0) - i32::try_from(bday.month()).unwrap_or(0);
|
||||
if m < 0 || (m == 0 && today.day() < bday.day()) {
|
||||
age -= 1;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ pub async fn delete_account(
|
||||
did, e
|
||||
);
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::api::error::{ApiError, AtpJson, DbResultExt};
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -30,7 +29,7 @@ pub struct SendEmailOutput {
|
||||
pub async fn send_email(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
AtpJson(input): AtpJson<SendEmailInput>,
|
||||
Json(input): Json<SendEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let content = input.content.trim();
|
||||
if content.is_empty() {
|
||||
@@ -45,7 +44,7 @@ pub async fn send_email(
|
||||
|
||||
let email = user.email.ok_or(ApiError::NoEmail)?;
|
||||
let (user_id, handle) = (user.id, user.handle);
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let subject = input
|
||||
.subject
|
||||
.clone()
|
||||
|
||||
@@ -67,10 +67,11 @@ pub async fn search_accounts(
|
||||
.await
|
||||
.log_db_err("in search_accounts")?;
|
||||
|
||||
let has_more = rows.len() > limit as usize;
|
||||
let limit_usize = usize::try_from(limit).unwrap_or(0);
|
||||
let has_more = rows.len() > limit_usize;
|
||||
let accounts: Vec<AccountView> = rows
|
||||
.into_iter()
|
||||
.take(limit as usize)
|
||||
.take(limit_usize)
|
||||
.map(|row| AccountView {
|
||||
did: row.did.clone(),
|
||||
handle: row.handle,
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::api::error::ApiError;
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, PlainPassword};
|
||||
use crate::util::pds_hostname_without_port;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -70,9 +69,9 @@ pub async fn update_account_handle(
|
||||
{
|
||||
return Err(ApiError::InvalidHandle(None));
|
||||
}
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let handle = if !input_handle.contains('.') {
|
||||
format!("{}.{}", input_handle, hostname_for_handles)
|
||||
format!("{}.{}", input_handle, &available_domains[0])
|
||||
} else {
|
||||
input_handle.to_string()
|
||||
};
|
||||
@@ -84,7 +83,7 @@ pub async fn update_account_handle(
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
let handle_for_check = unsafe { Handle::new_unchecked(&handle) };
|
||||
let handle_for_check: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
|
||||
if let Ok(true) = state
|
||||
.user_repo
|
||||
.check_handle_exists(&handle_for_check, user_id)
|
||||
@@ -100,9 +99,15 @@ pub async fn update_account_handle(
|
||||
Ok(0) => Err(ApiError::AccountNotFound),
|
||||
Ok(_) => {
|
||||
if let Some(old) = old_handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", old)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::handle_key(&old))
|
||||
.await;
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
if let Err(e) = crate::api::repo::record::sequence_identity_event(
|
||||
&state,
|
||||
did,
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::error;
|
||||
use tracing::{error, warn};
|
||||
use tranquil_types::CidLink;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -187,15 +187,24 @@ pub async fn update_server_config(
|
||||
};
|
||||
|
||||
if let Some(old_cid_str) = should_delete_old {
|
||||
let old_cid = unsafe { CidLink::new_unchecked(old_cid_str) };
|
||||
if let Ok(Some(storage_key)) =
|
||||
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
|
||||
{
|
||||
if let Err(e) = state.blob_store.delete(&storage_key).await {
|
||||
error!("Failed to delete old logo blob from storage: {:?}", e);
|
||||
match CidLink::new(old_cid_str) {
|
||||
Ok(old_cid) => {
|
||||
if let Ok(Some(storage_key)) =
|
||||
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
|
||||
{
|
||||
if let Err(e) = state.blob_store.delete(&storage_key).await {
|
||||
error!("Failed to delete old logo blob from storage: {:?}", e);
|
||||
}
|
||||
if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await {
|
||||
error!("Failed to delete old logo blob record: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await {
|
||||
error!("Failed to delete old logo blob record: {:?}", e);
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Old logo CID in database is invalid, skipping cleanup: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ pub async fn get_invite_codes(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if codes_rows.len() == limit as usize {
|
||||
let next_cursor = if codes_rows.len() == usize::try_from(limit).unwrap_or(0) {
|
||||
codes_rows.last().map(|r| r.code.clone())
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -175,7 +175,10 @@ pub async fn update_subject_status(
|
||||
Some("com.atproto.admin.defs#repoRef") => {
|
||||
let did_str = input.subject.get("did").and_then(|d| d.as_str());
|
||||
if let Some(did_str) = did_str {
|
||||
let did = unsafe { Did::new_unchecked(did_str) };
|
||||
let did: Did = match did_str.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Err(ApiError::InvalidDid("Invalid DID format".into())),
|
||||
};
|
||||
if let Some(takedown) = &input.takedown {
|
||||
let takedown_ref = if takedown.applied {
|
||||
takedown.r#ref.as_deref()
|
||||
@@ -230,7 +233,10 @@ pub async fn update_subject_status(
|
||||
}
|
||||
}
|
||||
if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await {
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
}
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop};
|
||||
use crate::auth::{AccountRequirement, extract_auth_token_from_header, validate_token_with_dpop};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
@@ -33,25 +33,24 @@ pub async fn get_age_assurance_state() -> Response {
|
||||
}
|
||||
|
||||
async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = crate::util::get_header_str(headers, "Authorization");
|
||||
let auth_header = crate::util::get_header_str(headers, http::header::AUTHORIZATION);
|
||||
tracing::debug!(?auth_header, "age assurance: extracting token");
|
||||
|
||||
let extracted = extract_auth_token_from_header(auth_header)?;
|
||||
tracing::debug!("age assurance: got token, validating");
|
||||
|
||||
let dpop_proof = crate::util::get_header_str(headers, "DPoP");
|
||||
let dpop_proof = crate::util::get_header_str(headers, crate::util::HEADER_DPOP);
|
||||
let http_uri = "/";
|
||||
|
||||
let auth_user = match validate_token_with_dpop(
|
||||
state.user_repo.as_ref(),
|
||||
state.oauth_repo.as_ref(),
|
||||
&extracted.token,
|
||||
extracted.is_dpop,
|
||||
extracted.scheme,
|
||||
dpop_proof,
|
||||
"GET",
|
||||
Method::GET.as_str(),
|
||||
http_uri,
|
||||
false,
|
||||
false,
|
||||
AccountRequirement::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::auth::{Active, Auth};
|
||||
use crate::scheduled::generate_full_backup;
|
||||
use crate::state::AppState;
|
||||
use crate::storage::{BackupStorage, backup_retention_count};
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -213,7 +214,7 @@ pub async fn create_backup(
|
||||
};
|
||||
|
||||
let block_count = crate::scheduled::count_car_blocks(&car_bytes);
|
||||
let size_bytes = car_bytes.len() as i64;
|
||||
let size_bytes = i64::try_from(car_bytes.len()).unwrap_or(i64::MAX);
|
||||
|
||||
let storage_key = match backup_storage
|
||||
.put_backup(&user.did, &repo_rev, &car_bytes)
|
||||
@@ -292,11 +293,11 @@ async fn cleanup_old_backups(
|
||||
backup_storage: &dyn BackupStorage,
|
||||
user_id: uuid::Uuid,
|
||||
retention_count: u32,
|
||||
) -> Result<(), String> {
|
||||
) -> anyhow::Result<()> {
|
||||
let old_backups: Vec<OldBackupInfo> = backup_repo
|
||||
.get_old_backups(user_id, retention_count as i64)
|
||||
.get_old_backups(user_id, i64::from(retention_count))
|
||||
.await
|
||||
.map_err(|e| format!("DB error fetching old backups: {}", e))?;
|
||||
.context("DB error fetching old backups")?;
|
||||
|
||||
for backup in old_backups {
|
||||
if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await {
|
||||
@@ -311,7 +312,7 @@ async fn cleanup_old_backups(
|
||||
backup_repo
|
||||
.delete_backup(backup.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete old backup record: {}", e))?;
|
||||
.context("Failed to delete old backup record")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -7,8 +7,7 @@ use crate::delegation::{
|
||||
};
|
||||
use crate::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, Nsid, Rkey};
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use crate::types::{Did, Handle};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -164,7 +163,9 @@ pub async fn remove_controller(
|
||||
.session_repo
|
||||
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
|
||||
.await
|
||||
.unwrap_or(0) as usize;
|
||||
.unwrap_or(0)
|
||||
.try_into()
|
||||
.unwrap_or(0usize);
|
||||
|
||||
let revoked_oauth_tokens = state
|
||||
.oauth_repo
|
||||
@@ -433,21 +434,23 @@ pub async fn create_delegated_account(
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let pds_suffix = format!(".{}", hostname_for_handles);
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
|
||||
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
|
||||
input
|
||||
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
.strip_suffix(&pds_suffix)
|
||||
.unwrap_or(&input.handle)
|
||||
} else {
|
||||
&input.handle
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(&input.handle),
|
||||
None => &input.handle,
|
||||
};
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, hostname_for_handles),
|
||||
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
|
||||
Err(e) => {
|
||||
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
|
||||
}
|
||||
@@ -473,9 +476,7 @@ pub async fn create_delegated_account(
|
||||
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
|
||||
}
|
||||
} else {
|
||||
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let invite_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_required {
|
||||
return Ok(ApiError::InviteCodeRequired.into_response());
|
||||
}
|
||||
@@ -497,8 +498,11 @@ pub async fn create_delegated_account(
|
||||
}
|
||||
};
|
||||
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key));
|
||||
|
||||
let genesis_result = match crate::plc::create_genesis_operation(
|
||||
&signing_key,
|
||||
@@ -529,8 +533,11 @@ pub async fn create_delegated_account(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let did = unsafe { Did::new_unchecked(&genesis_result.did) };
|
||||
let handle = unsafe { Handle::new_unchecked(&handle) };
|
||||
let did: Did = genesis_result
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?;
|
||||
let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
|
||||
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
|
||||
|
||||
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
|
||||
@@ -627,13 +634,11 @@ pub async fn create_delegated_account(
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
|
||||
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
&profile_collection,
|
||||
&profile_rkey,
|
||||
&crate::types::PROFILE_COLLECTION,
|
||||
&crate::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -12,7 +12,7 @@ use tranquil_types::Handle;
|
||||
|
||||
use crate::comms::comms_repo;
|
||||
use crate::state::AppState;
|
||||
use crate::util::{discord_public_key, pds_hostname};
|
||||
use crate::util::discord_public_key;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Interaction {
|
||||
@@ -183,9 +183,9 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
"discord",
|
||||
tranquil_db_traits::CommsChannel::Discord,
|
||||
&discord_user_id,
|
||||
pds_hostname(),
|
||||
&tranquil_config::get().server.hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{FromRequest, Request, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
http::{HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -103,7 +102,7 @@ pub enum ApiError {
|
||||
UpstreamTimeout,
|
||||
UpstreamUnavailable(String),
|
||||
UpstreamError {
|
||||
status: u16,
|
||||
status: StatusCode,
|
||||
error: Option<String>,
|
||||
message: Option<String>,
|
||||
},
|
||||
@@ -127,9 +126,7 @@ impl ApiError {
|
||||
}
|
||||
Self::ServiceUnavailable(_) | Self::BackupsDisabled => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
Self::UpstreamError { status, .. } => {
|
||||
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
|
||||
}
|
||||
Self::UpstreamError { status, .. } => *status,
|
||||
Self::AuthenticationRequired
|
||||
| Self::AuthenticationFailed(_)
|
||||
| Self::AccountDeactivated
|
||||
@@ -451,7 +448,7 @@ impl ApiError {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn from_upstream_response(status: u16, body: &[u8]) -> Self {
|
||||
pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self {
|
||||
if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(body) {
|
||||
let error = parsed
|
||||
.get("error")
|
||||
@@ -485,18 +482,18 @@ impl IntoResponse for ApiError {
|
||||
match &self {
|
||||
Self::ExpiredToken(_) => {
|
||||
response.headers_mut().insert(
|
||||
"WWW-Authenticate",
|
||||
"Bearer error=\"invalid_token\", error_description=\"Token has expired\""
|
||||
.parse()
|
||||
.unwrap(),
|
||||
http::header::WWW_AUTHENTICATE,
|
||||
HeaderValue::from_static(
|
||||
"Bearer error=\"invalid_token\", error_description=\"Token has expired\"",
|
||||
),
|
||||
);
|
||||
}
|
||||
Self::OAuthExpiredToken(_) => {
|
||||
response.headers_mut().insert(
|
||||
"WWW-Authenticate",
|
||||
"DPoP error=\"invalid_token\", error_description=\"Token has expired\""
|
||||
.parse()
|
||||
.unwrap(),
|
||||
http::header::WWW_AUTHENTICATE,
|
||||
HeaderValue::from_static(
|
||||
"DPoP error=\"invalid_token\", error_description=\"Token has expired\"",
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
@@ -723,58 +720,6 @@ pub fn parse_did_option(s: Option<&str>) -> Result<Option<tranquil_types::Did>,
|
||||
s.map(parse_did).transpose()
|
||||
}
|
||||
|
||||
pub struct AtpJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for AtpJson<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, Json<serde_json::Value>);
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Json::<T>::from_request(req, state).await {
|
||||
Ok(Json(value)) => Ok(AtpJson(value)),
|
||||
Err(rejection) => {
|
||||
let message = extract_json_error_message(&rejection);
|
||||
Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": message
|
||||
})),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_json_error_message(rejection: &JsonRejection) -> String {
|
||||
match rejection {
|
||||
JsonRejection::JsonDataError(e) => {
|
||||
let inner = e.body_text();
|
||||
if inner.contains("missing field") {
|
||||
let field = inner
|
||||
.split("missing field `")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('`').next())
|
||||
.unwrap_or("unknown");
|
||||
format!("Missing required field: {}", field)
|
||||
} else if inner.contains("invalid type") {
|
||||
format!("Invalid field type: {}", inner)
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
}
|
||||
JsonRejection::JsonSyntaxError(_) => "Invalid JSON syntax".to_string(),
|
||||
JsonRejection::MissingJsonContentType(_) => {
|
||||
"Content-Type must be application/json".to_string()
|
||||
}
|
||||
JsonRejection::BytesRejection(_) => "Failed to read request body".to_string(),
|
||||
_ => "Invalid request body".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DbResultExt<T> {
|
||||
fn log_db_err(self, ctx: &str) -> Result<T, ApiError>;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_servi
|
||||
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
|
||||
use crate::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use crate::types::{Did, Handle, PlainPassword};
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -34,7 +33,7 @@ pub struct CreateAccountInput {
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub verification_channel: Option<String>,
|
||||
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
@@ -50,7 +49,7 @@ pub struct CreateAccountOutput {
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub verification_required: bool,
|
||||
pub verification_channel: String,
|
||||
pub verification_channel: tranquil_db_traits::CommsChannel,
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
@@ -73,9 +72,9 @@ pub async fn create_account(
|
||||
info!("create_account called");
|
||||
}
|
||||
|
||||
let migration_auth = if let Some(extracted) =
|
||||
extract_auth_token_from_header(crate::util::get_header_str(&headers, "Authorization"))
|
||||
{
|
||||
let migration_auth = if let Some(extracted) = extract_auth_token_from_header(
|
||||
crate::util::get_header_str(&headers, http::header::AUTHORIZATION),
|
||||
) {
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
@@ -141,19 +140,21 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
|
||||
let hostname_for_validation = pds_hostname_without_port();
|
||||
let pds_suffix = format!(".{}", hostname_for_validation);
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let validated_short_handle = if !input.handle.contains('.')
|
||||
|| input.handle.ends_with(&pds_suffix)
|
||||
|| matched_domain.is_some()
|
||||
{
|
||||
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
|
||||
input
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
.strip_suffix(&pds_suffix)
|
||||
.unwrap_or(&input.handle)
|
||||
} else {
|
||||
&input.handle
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(&input.handle),
|
||||
None => &input.handle,
|
||||
};
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => h,
|
||||
@@ -190,20 +191,18 @@ pub async fn create_account(
|
||||
{
|
||||
return ApiError::InvalidEmail.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) && !is_migration {
|
||||
return ApiError::InvalidVerificationChannel.into_response();
|
||||
}
|
||||
let verification_channel = input
|
||||
.verification_channel
|
||||
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
|
||||
let verification_recipient = if is_migration {
|
||||
None
|
||||
} else {
|
||||
Some(match verification_channel {
|
||||
"email" => match &input.email {
|
||||
tranquil_db_traits::CommsChannel::Email => match &input.email {
|
||||
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
|
||||
_ => return ApiError::MissingEmail.into_response(),
|
||||
},
|
||||
"discord" => match &input.discord_username {
|
||||
tranquil_db_traits::CommsChannel::Discord => match &input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !crate::api::validation::is_valid_discord_username(&clean) {
|
||||
@@ -215,7 +214,7 @@ pub async fn create_account(
|
||||
}
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !crate::api::validation::is_valid_telegram_username(clean) {
|
||||
@@ -227,25 +226,20 @@ pub async fn create_account(
|
||||
}
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_username {
|
||||
tranquil_db_traits::CommsChannel::Signal => match &input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
username.trim().trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
_ => return ApiError::MissingSignalNumber.into_response(),
|
||||
},
|
||||
_ => return ApiError::InvalidVerificationChannel.into_response(),
|
||||
})
|
||||
};
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let suffix = format!(".{}", hostname_for_handles);
|
||||
let handle = if input.handle.ends_with(&suffix) {
|
||||
format!("{}.{}", validated_short_handle, hostname_for_handles)
|
||||
} else if input.handle.contains('.') {
|
||||
validated_short_handle.clone()
|
||||
} else {
|
||||
format!("{}.{}", validated_short_handle, hostname_for_handles)
|
||||
let handle = match matched_domain {
|
||||
Some(domain) => format!("{}.{}", validated_short_handle, domain),
|
||||
None if input.handle.contains('.') => validated_short_handle.clone(),
|
||||
None => format!("{}.{}", validated_short_handle, &available_domains[0]),
|
||||
};
|
||||
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
|
||||
if let Some(signing_key_did) = &input.signing_key {
|
||||
@@ -280,7 +274,8 @@ pub async fn create_account(
|
||||
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
|
||||
return ApiError::SelfHostedDidWebDisabled.into_response();
|
||||
}
|
||||
let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles);
|
||||
let pds_hostname = tranquil_config::get().server.hostname_without_port();
|
||||
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
|
||||
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
|
||||
@@ -304,7 +299,7 @@ pub async fn create_account(
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
{
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
return ApiError::InvalidDid(e.to_string()).into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web account");
|
||||
d.clone()
|
||||
@@ -320,7 +315,7 @@ pub async fn create_account(
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref())
|
||||
.await
|
||||
{
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
return ApiError::InvalidDid(e.to_string()).into_response();
|
||||
}
|
||||
d.clone()
|
||||
} else if !d.trim().is_empty() {
|
||||
@@ -329,8 +324,11 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
@@ -362,8 +360,11 @@ pub async fn create_account(
|
||||
genesis_result.did
|
||||
}
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
@@ -397,9 +398,17 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
if is_migration {
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
|
||||
};
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
};
|
||||
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
new_handle: unsafe { Handle::new_unchecked(&handle) },
|
||||
did: did_typed.clone(),
|
||||
new_handle: handle_typed.clone(),
|
||||
new_email: email.clone(),
|
||||
};
|
||||
match state
|
||||
@@ -453,7 +462,7 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
did: did_typed.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
@@ -468,10 +477,11 @@ pub async fn create_account(
|
||||
error!("Error creating session: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let verification_required = if let Some(ref user_email) = email {
|
||||
let token =
|
||||
crate::auth::verification_token::generate_migration_token(&did, user_email);
|
||||
let token = crate::auth::verification_token::generate_migration_token(
|
||||
&did_typed, user_email,
|
||||
);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&token);
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_migration_verification(
|
||||
@@ -494,12 +504,12 @@ pub async fn create_account(
|
||||
axum::http::StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
did: did_typed.clone(),
|
||||
did_doc: state.did_resolver.resolve_did_document(&did).await,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
verification_required,
|
||||
verification_channel: "email".to_string(),
|
||||
verification_channel: tranquil_db_traits::CommsChannel::Email,
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -518,7 +528,10 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
};
|
||||
let handle_available = match state
|
||||
.user_repo
|
||||
.check_handle_available_for_new_account(&handle_typed)
|
||||
@@ -534,30 +547,38 @@ pub async fn create_account(
|
||||
return ApiError::HandleTaken.into_response();
|
||||
}
|
||||
|
||||
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
if invite_code_required
|
||||
&& input
|
||||
.invite_code
|
||||
.as_ref()
|
||||
.map(|c| c.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code
|
||||
&& !code.trim().is_empty()
|
||||
{
|
||||
let valid = match state.user_repo.check_and_consume_invite_code(code).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Error checking invite code: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
let is_bootstrap = state.bootstrap_invite_code.is_some()
|
||||
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
|
||||
|
||||
if is_bootstrap {
|
||||
match input.invite_code.as_deref() {
|
||||
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => {}
|
||||
_ => return ApiError::InvalidInviteCode.into_response(),
|
||||
}
|
||||
} else {
|
||||
let invite_code_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_code_required
|
||||
&& input
|
||||
.invite_code
|
||||
.as_ref()
|
||||
.map(|c| c.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code
|
||||
&& !code.trim().is_empty()
|
||||
{
|
||||
let valid = match state.user_repo.check_and_consume_invite_code(code).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Error checking invite code: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +623,10 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let did_for_commit = unsafe { Did::new_unchecked(&did) };
|
||||
let did_for_commit: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
|
||||
};
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) {
|
||||
Ok(result) => result,
|
||||
@@ -622,25 +646,21 @@ pub async fn create_account(
|
||||
let rev_str = rev.as_ref().to_string();
|
||||
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
|
||||
|
||||
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
|
||||
json!({
|
||||
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
|
||||
Some(json!({
|
||||
"$type": "app.bsky.actor.defs#personalDetailsPref",
|
||||
"birthDate": "1998-05-06T00:00:00.000Z"
|
||||
})
|
||||
});
|
||||
|
||||
let preferred_comms_channel = match verification_channel {
|
||||
"email" => tranquil_db_traits::CommsChannel::Email,
|
||||
"discord" => tranquil_db_traits::CommsChannel::Discord,
|
||||
"telegram" => tranquil_db_traits::CommsChannel::Telegram,
|
||||
"signal" => tranquil_db_traits::CommsChannel::Signal,
|
||||
_ => tranquil_db_traits::CommsChannel::Email,
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let preferred_comms_channel = verification_channel;
|
||||
|
||||
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
|
||||
handle: unsafe { Handle::new_unchecked(&handle) },
|
||||
handle: handle_typed.clone(),
|
||||
email: email.clone(),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
did: did_for_commit.clone(),
|
||||
password_hash,
|
||||
preferred_comms_channel,
|
||||
discord_username: input
|
||||
@@ -667,7 +687,11 @@ pub async fn create_account(
|
||||
commit_cid: commit_cid_str.clone(),
|
||||
repo_rev: rev_str.clone(),
|
||||
genesis_block_cids,
|
||||
invite_code: input.invite_code.clone(),
|
||||
invite_code: if is_bootstrap {
|
||||
None
|
||||
} else {
|
||||
input.invite_code.clone()
|
||||
},
|
||||
birthdate_pref,
|
||||
};
|
||||
|
||||
@@ -689,11 +713,9 @@ pub async fn create_account(
|
||||
};
|
||||
let user_id = create_result.user_id;
|
||||
if !is_migration && !is_did_web_byod {
|
||||
let did_typed = unsafe { Did::new_unchecked(&did) };
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
|
||||
if let Err(e) = crate::api::repo::record::sequence_identity_event(
|
||||
&state,
|
||||
&did_typed,
|
||||
&did_for_commit,
|
||||
Some(&handle_typed),
|
||||
)
|
||||
.await
|
||||
@@ -702,7 +724,7 @@ pub async fn create_account(
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did_typed,
|
||||
&did_for_commit,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
)
|
||||
.await
|
||||
@@ -711,7 +733,7 @@ pub async fn create_account(
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_genesis_commit(
|
||||
&state,
|
||||
&did_typed,
|
||||
&did_for_commit,
|
||||
&commit_cid,
|
||||
&mst_root,
|
||||
&rev_str,
|
||||
@@ -722,7 +744,7 @@ pub async fn create_account(
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_sync_event(
|
||||
&state,
|
||||
&did_typed,
|
||||
&did_for_commit,
|
||||
&commit_cid_str,
|
||||
Some(rev.as_ref()),
|
||||
)
|
||||
@@ -734,13 +756,11 @@ pub async fn create_account(
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": input.handle
|
||||
});
|
||||
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
|
||||
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did_typed,
|
||||
&profile_collection,
|
||||
&profile_rkey,
|
||||
&did_for_commit,
|
||||
&crate::types::PROFILE_COLLECTION,
|
||||
&crate::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
@@ -748,11 +768,11 @@ pub async fn create_account(
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
}
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if !is_migration {
|
||||
if let Some(ref recipient) = verification_recipient {
|
||||
let verification_token = crate::auth::verification_token::generate_signup_token(
|
||||
&did,
|
||||
&did_for_commit,
|
||||
verification_channel,
|
||||
recipient,
|
||||
);
|
||||
@@ -776,7 +796,8 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
} else if let Some(ref user_email) = email {
|
||||
let token = crate::auth::verification_token::generate_migration_token(&did, user_email);
|
||||
let token =
|
||||
crate::auth::verification_token::generate_migration_token(&did_for_commit, user_email);
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_migration_verification(
|
||||
state.user_repo.as_ref(),
|
||||
@@ -809,7 +830,7 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
did: did_for_commit.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
@@ -838,12 +859,12 @@ pub async fn create_account(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
did: did_for_commit,
|
||||
did_doc,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
verification_required: !is_migration,
|
||||
verification_channel: verification_channel.to_string(),
|
||||
verification_channel,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::rate_limit::{
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Handle;
|
||||
use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port};
|
||||
use crate::util::get_header_str;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
@@ -42,7 +42,7 @@ pub async fn resolve_handle(
|
||||
if handle_str.is_empty() {
|
||||
return ApiError::InvalidRequest("handle is required".into()).into_response();
|
||||
}
|
||||
let cache_key = format!("handle:{}", handle_str);
|
||||
let cache_key = crate::cache_keys::handle_key(handle_str);
|
||||
if let Some(did) = state.cache.get(&cache_key).await {
|
||||
return DidResponse::response(did).into_response();
|
||||
}
|
||||
@@ -78,12 +78,29 @@ 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")?;
|
||||
#[derive(Debug)]
|
||||
pub enum KeyError {
|
||||
InvalidKeyLength,
|
||||
MissingCoordinate,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KeyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidKeyLength => write!(f, "invalid key length"),
|
||||
Self::MissingCoordinate => write!(f, "missing elliptic curve coordinate"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for KeyError {}
|
||||
|
||||
pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, KeyError> {
|
||||
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| KeyError::InvalidKeyLength)?;
|
||||
let public_key = secret_key.public_key();
|
||||
let encoded = public_key.to_encoded_point(false);
|
||||
let x = encoded.x().ok_or("Missing x coordinate")?;
|
||||
let y = encoded.y().ok_or("Missing y coordinate")?;
|
||||
let x = encoded.x().ok_or(KeyError::MissingCoordinate)?;
|
||||
let y = encoded.y().ok_or(KeyError::MissingCoordinate)?;
|
||||
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!({
|
||||
@@ -94,8 +111,8 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str> {
|
||||
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
|
||||
pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, KeyError> {
|
||||
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| KeyError::InvalidKeyLength)?;
|
||||
let public_key = secret_key.public_key();
|
||||
let compressed = public_key.to_encoded_point(true);
|
||||
let compressed_bytes = compressed.as_bytes();
|
||||
@@ -105,9 +122,9 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str
|
||||
}
|
||||
|
||||
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let hostname = pds_hostname();
|
||||
let hostname_without_port = pds_hostname_without_port();
|
||||
let host_header = get_header_str(&headers, "host").unwrap_or(hostname);
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let hostname_without_port = tranquil_config::get().server.hostname_without_port();
|
||||
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
|
||||
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
|
||||
if host_without_port != hostname_without_port
|
||||
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
|
||||
@@ -127,7 +144,7 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
|
||||
"id": did,
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"type": crate::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": format!("https://{}", hostname)
|
||||
}]
|
||||
}))
|
||||
@@ -197,7 +214,7 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
|
||||
})).collect::<Vec<_>>(),
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"type": crate::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
@@ -250,7 +267,7 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"type": crate::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
@@ -258,8 +275,8 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
|
||||
}
|
||||
|
||||
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let current_handle = format!("{}.{}", handle, hostname_for_handles);
|
||||
let current_handle_typed: Handle = match current_handle.parse() {
|
||||
Ok(h) => h,
|
||||
@@ -332,7 +349,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
})).collect::<Vec<_>>(),
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"type": crate::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
@@ -385,19 +402,43 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"type": crate::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DidWebVerifyError {
|
||||
#[error("Invalid did:web format")]
|
||||
InvalidFormat,
|
||||
#[error("Invalid DID path for this PDS. Expected {0}")]
|
||||
InvalidPath(String),
|
||||
#[error(
|
||||
"External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount."
|
||||
)]
|
||||
MissingSigningKey,
|
||||
#[error("Failed to fetch DID doc: {0}")]
|
||||
FetchFailed(String),
|
||||
#[error("Invalid DID document: {0}")]
|
||||
InvalidDocument(String),
|
||||
#[error("DID document does not list this PDS ({0}) as AtprotoPersonalDataServer")]
|
||||
PdsNotListed(String),
|
||||
#[error(
|
||||
"DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {0}"
|
||||
)]
|
||||
KeyMismatch(String),
|
||||
#[error("Invalid signing key format")]
|
||||
InvalidSigningKey,
|
||||
}
|
||||
|
||||
pub async fn verify_did_web(
|
||||
did: &str,
|
||||
hostname: &str,
|
||||
handle: &str,
|
||||
expected_signing_key: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), DidWebVerifyError> {
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname);
|
||||
let subdomain_host = format!("{}.{}", handle, hostname_for_handles);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
@@ -413,21 +454,16 @@ pub async fn verify_did_web(
|
||||
if did.starts_with(&expected_prefix) {
|
||||
let suffix = &did[expected_prefix.len()..];
|
||||
let expected_suffix = format!(":u:{}", handle);
|
||||
if suffix == expected_suffix {
|
||||
return Ok(());
|
||||
return if suffix == expected_suffix {
|
||||
Ok(())
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Invalid DID path for this PDS. Expected {}",
|
||||
expected_suffix
|
||||
));
|
||||
}
|
||||
Err(DidWebVerifyError::InvalidPath(expected_suffix))
|
||||
};
|
||||
}
|
||||
let expected_signing_key = expected_signing_key.ok_or_else(|| {
|
||||
"External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string()
|
||||
})?;
|
||||
let expected_signing_key = expected_signing_key.ok_or(DidWebVerifyError::MissingSigningKey)?;
|
||||
let parts: Vec<&str> = did.split(':').collect();
|
||||
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
|
||||
return Err("Invalid did:web format".into());
|
||||
return Err(DidWebVerifyError::InvalidFormat);
|
||||
}
|
||||
let domain_segment = parts[2];
|
||||
let domain = domain_segment.replace("%3A", ":");
|
||||
@@ -447,43 +483,46 @@ pub async fn verify_did_web(
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
|
||||
.map_err(|e| DidWebVerifyError::FetchFailed(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
|
||||
return Err(DidWebVerifyError::FetchFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let doc: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
|
||||
.map_err(|e| DidWebVerifyError::InvalidDocument(e.to_string()))?;
|
||||
let services = doc["service"]
|
||||
.as_array()
|
||||
.ok_or("No services found in DID doc")?;
|
||||
.ok_or(DidWebVerifyError::InvalidDocument(
|
||||
"No services found".to_string(),
|
||||
))?;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let has_valid_service = services
|
||||
.iter()
|
||||
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
|
||||
let has_valid_service = services.iter().any(|s| {
|
||||
s["type"] == crate::plc::ServiceType::Pds.as_str() && s["serviceEndpoint"] == pds_endpoint
|
||||
});
|
||||
if !has_valid_service {
|
||||
return Err(format!(
|
||||
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
|
||||
pds_endpoint
|
||||
));
|
||||
return Err(DidWebVerifyError::PdsNotListed(pds_endpoint));
|
||||
}
|
||||
let verification_methods = doc["verificationMethod"]
|
||||
.as_array()
|
||||
.ok_or("No verificationMethod found in DID doc")?;
|
||||
let verification_methods =
|
||||
doc["verificationMethod"]
|
||||
.as_array()
|
||||
.ok_or(DidWebVerifyError::InvalidDocument(
|
||||
"No verificationMethod found".to_string(),
|
||||
))?;
|
||||
let expected_multibase = expected_signing_key
|
||||
.strip_prefix("did:key:")
|
||||
.ok_or("Invalid signing key format")?;
|
||||
.ok_or(DidWebVerifyError::InvalidSigningKey)?;
|
||||
let has_matching_key = verification_methods.iter().any(|vm| {
|
||||
vm["publicKeyMultibase"]
|
||||
.as_str()
|
||||
.map(|pk| pk == expected_multibase)
|
||||
.unwrap_or(false)
|
||||
.is_some_and(|pk| pk == expected_multibase)
|
||||
});
|
||||
if !has_matching_key {
|
||||
return Err(format!(
|
||||
"DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}",
|
||||
expected_multibase
|
||||
return Err(DidWebVerifyError::KeyMismatch(
|
||||
expected_multibase.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -532,7 +571,7 @@ pub async fn get_recommended_did_credentials(
|
||||
ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into()))
|
||||
})?;
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)
|
||||
.map_err(|_| ApiError::InternalError(None))?;
|
||||
@@ -540,9 +579,9 @@ pub async fn get_recommended_did_credentials(
|
||||
let rotation_keys = if auth.did.starts_with("did:web:") {
|
||||
vec![]
|
||||
} else {
|
||||
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
|
||||
Ok(key) => key,
|
||||
Err(_) => {
|
||||
let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key {
|
||||
Some(key) => key.clone(),
|
||||
None => {
|
||||
warn!(
|
||||
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
|
||||
);
|
||||
@@ -559,7 +598,7 @@ pub async fn get_recommended_did_credentials(
|
||||
verification_methods: VerificationMethods { atproto: did_key },
|
||||
services: Services {
|
||||
atproto_pds: AtprotoPds {
|
||||
service_type: "AtprotoPersonalDataServer".to_string(),
|
||||
service_type: crate::plc::ServiceType::Pds.as_str().to_string(),
|
||||
endpoint: pds_endpoint,
|
||||
},
|
||||
},
|
||||
@@ -579,7 +618,7 @@ pub async fn update_handle(
|
||||
Json(input): Json<UpdateHandleInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_identity_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::IdentityAttr::Handle,
|
||||
) {
|
||||
@@ -636,23 +675,30 @@ pub async fn update_handle(
|
||||
"Inappropriate language in handle".into(),
|
||||
)));
|
||||
}
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let suffix = format!(".{}", hostname_for_handles);
|
||||
let is_service_domain =
|
||||
crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles);
|
||||
let handle = if is_service_domain && new_handle != hostname_for_handles {
|
||||
let short_part = if new_handle.ends_with(&suffix) {
|
||||
new_handle.strip_suffix(&suffix).unwrap_or(&new_handle)
|
||||
} else {
|
||||
&new_handle
|
||||
};
|
||||
let full_handle = if new_handle.ends_with(&suffix) {
|
||||
new_handle.clone()
|
||||
} else {
|
||||
format!("{}.{}", new_handle, hostname_for_handles)
|
||||
let handle_domains = tranquil_config::get().server.user_handle_domain_list();
|
||||
let matched_handle_domain = handle_domains
|
||||
.iter()
|
||||
.filter(|d| new_handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len())
|
||||
.cloned();
|
||||
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
|
||||
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself {
|
||||
let (short_part, full_handle) = match &matched_handle_domain {
|
||||
Some(domain) => {
|
||||
let suffix = format!(".{}", domain);
|
||||
let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle);
|
||||
(short.to_string(), new_handle.clone())
|
||||
}
|
||||
None => {
|
||||
let primary = &handle_domains[0];
|
||||
(new_handle.clone(), format!("{}.{}", new_handle, primary))
|
||||
}
|
||||
};
|
||||
if full_handle == current_handle {
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&full_handle) };
|
||||
let handle_typed: Handle = match full_handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
@@ -675,7 +721,10 @@ pub async fn update_handle(
|
||||
full_handle
|
||||
} else {
|
||||
if new_handle == current_handle {
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&new_handle) };
|
||||
let handle_typed: Handle = match new_handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
@@ -728,10 +777,13 @@ pub async fn update_handle(
|
||||
if !current_handle.is_empty() {
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&format!("handle:{}", current_handle))
|
||||
.delete(&crate::cache_keys::handle_key(¤t_handle))
|
||||
.await;
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)).await
|
||||
{
|
||||
@@ -768,7 +820,7 @@ pub async fn update_plc_handle(
|
||||
}
|
||||
|
||||
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let host = match crate::util::get_header_str(&headers, "host") {
|
||||
let host = match crate::util::get_header_str(&headers, http::header::HOST) {
|
||||
Some(h) => h,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::rate_limit::{HandleVerificationLimit, RateLimited};
|
||||
use crate::types::{Did, Handle};
|
||||
use axum::{
|
||||
@@ -9,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VerifyHandleOwnershipInput {
|
||||
pub handle: String,
|
||||
pub handle: Handle,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
@@ -27,14 +26,7 @@ pub async fn verify_handle_ownership(
|
||||
_rate_limit: RateLimited<HandleVerificationLimit>,
|
||||
Json(input): Json<VerifyHandleOwnershipInput>,
|
||||
) -> Response {
|
||||
let handle: Handle = match input.handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let handle_str = handle.as_str();
|
||||
let handle_str = input.handle.as_str();
|
||||
let did_str = input.did.as_str();
|
||||
|
||||
let dns_mismatch = match crate::handle::resolve_handle_dns(handle_str).await {
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::api::EmptyResponse;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Auth, Permissive};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
@@ -19,7 +18,7 @@ pub async fn request_plc_operation_signature(
|
||||
auth: Auth<Permissive>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_identity_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::IdentityAttr::Wildcard,
|
||||
) {
|
||||
@@ -41,7 +40,7 @@ pub async fn request_plc_operation_signature(
|
||||
.await
|
||||
.log_db_err("creating PLC token")?;
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::api::ApiError;
|
||||
use crate::api::error::DbResultExt;
|
||||
use crate::auth::{Auth, Permissive};
|
||||
use crate::circuit_breaker::with_circuit_breaker;
|
||||
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
|
||||
use crate::plc::{PlcClient, PlcError, PlcService, ServiceType, create_update_op, sign_operation};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -30,7 +30,7 @@ pub struct SignPlcOperationInput {
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServiceInput {
|
||||
#[serde(rename = "type")]
|
||||
pub service_type: String,
|
||||
pub service_type: ServiceType,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub async fn sign_plc_operation(
|
||||
Json(input): Json<SignPlcOperationInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_identity_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::IdentityAttr::Wildcard,
|
||||
) {
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::auth::{Auth, Permissive};
|
||||
use crate::circuit_breaker::with_circuit_breaker;
|
||||
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -26,7 +25,7 @@ pub async fn submit_plc_operation(
|
||||
Json(input): Json<SubmitPlcOperationInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_identity_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::IdentityAttr::Wildcard,
|
||||
) {
|
||||
@@ -42,7 +41,7 @@ pub async fn submit_plc_operation(
|
||||
.map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?;
|
||||
|
||||
let op = &input.operation;
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let public_url = format!("https://{}", hostname);
|
||||
let user = state
|
||||
.user_repo
|
||||
@@ -70,8 +69,11 @@ pub async fn submit_plc_operation(
|
||||
})?;
|
||||
|
||||
let user_did_key = signing_key_to_did_key(&signing_key);
|
||||
let server_rotation_key =
|
||||
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
|
||||
let server_rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| user_did_key.clone());
|
||||
if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) {
|
||||
let has_server_key = rotation_keys
|
||||
.iter()
|
||||
@@ -87,7 +89,7 @@ pub async fn submit_plc_operation(
|
||||
{
|
||||
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") {
|
||||
if service_type != Some(crate::plc::ServiceType::Pds.as_str()) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Incorrect type on atproto_pds service".into(),
|
||||
));
|
||||
@@ -143,9 +145,18 @@ pub async fn submit_plc_operation(
|
||||
warn!("Failed to sequence identity event: {:?}", e);
|
||||
}
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", user.handle)).await;
|
||||
let _ = state.cache.delete(&format!("plc:doc:{}", did)).await;
|
||||
let _ = state.cache.delete(&format!("plc:data:{}", did)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::handle_key(&user.handle))
|
||||
.await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::plc_doc_key(did))
|
||||
.await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::plc_data_key(did))
|
||||
.await;
|
||||
if state.did_resolver.refresh_did(did).await.is_none() {
|
||||
warn!(did = %did, "Failed to refresh DID cache after PLC update");
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub mod validation;
|
||||
pub mod verification;
|
||||
|
||||
pub use error::ApiError;
|
||||
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
|
||||
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit};
|
||||
pub use responses::{
|
||||
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
|
||||
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
|
||||
|
||||
@@ -12,10 +12,42 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReportReasonType {
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonSpam")]
|
||||
Spam,
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonViolation")]
|
||||
Violation,
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonMisleading")]
|
||||
Misleading,
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonSexual")]
|
||||
Sexual,
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonRude")]
|
||||
Rude,
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonOther")]
|
||||
Other,
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonAppeal")]
|
||||
Appeal,
|
||||
}
|
||||
|
||||
impl ReportReasonType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Spam => "com.atproto.moderation.defs#reasonSpam",
|
||||
Self::Violation => "com.atproto.moderation.defs#reasonViolation",
|
||||
Self::Misleading => "com.atproto.moderation.defs#reasonMisleading",
|
||||
Self::Sexual => "com.atproto.moderation.defs#reasonSexual",
|
||||
Self::Rude => "com.atproto.moderation.defs#reasonRude",
|
||||
Self::Other => "com.atproto.moderation.defs#reasonOther",
|
||||
Self::Appeal => "com.atproto.moderation.defs#reasonAppeal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateReportInput {
|
||||
pub reason_type: String,
|
||||
pub reason_type: ReportReasonType,
|
||||
pub reason: Option<String>,
|
||||
pub subject: Value,
|
||||
}
|
||||
@@ -24,20 +56,26 @@ pub struct CreateReportInput {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateReportOutput {
|
||||
pub id: i64,
|
||||
pub reason_type: String,
|
||||
pub reason_type: ReportReasonType,
|
||||
pub reason: Option<String>,
|
||||
pub subject: Value,
|
||||
pub reported_by: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
fn get_report_service_config() -> Option<(String, String)> {
|
||||
let url = std::env::var("REPORT_SERVICE_URL").ok()?;
|
||||
let did = std::env::var("REPORT_SERVICE_DID").ok()?;
|
||||
struct ReportServiceConfig {
|
||||
url: String,
|
||||
did: String,
|
||||
}
|
||||
|
||||
fn get_report_service_config() -> Option<ReportServiceConfig> {
|
||||
let cfg = tranquil_config::get();
|
||||
let url = cfg.moderation.report_service_url.clone()?;
|
||||
let did = cfg.moderation.report_service_did.clone()?;
|
||||
if url.is_empty() || did.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((url, did))
|
||||
Some(ReportServiceConfig { url, did })
|
||||
}
|
||||
|
||||
pub async fn create_report(
|
||||
@@ -47,8 +85,8 @@ pub async fn create_report(
|
||||
) -> Response {
|
||||
let did = &auth.did;
|
||||
|
||||
if let Some((service_url, service_did)) = get_report_service_config() {
|
||||
return proxy_to_report_service(&state, &auth, &service_url, &service_did, &input).await;
|
||||
if let Some(config) = get_report_service_config() {
|
||||
return proxy_to_report_service(&state, &auth, &config.url, &config.did, &input).await;
|
||||
}
|
||||
|
||||
create_report_locally(&state, did, auth.status.is_takendown(), input).await
|
||||
@@ -177,36 +215,21 @@ async fn create_report_locally(
|
||||
is_takendown: bool,
|
||||
input: CreateReportInput,
|
||||
) -> Response {
|
||||
const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal";
|
||||
|
||||
if is_takendown && input.reason_type != REASON_APPEAL {
|
||||
if is_takendown && input.reason_type != ReportReasonType::Appeal {
|
||||
return ApiError::InvalidRequest("Report not accepted from takendown account".into())
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let valid_reason_types = [
|
||||
"com.atproto.moderation.defs#reasonSpam",
|
||||
"com.atproto.moderation.defs#reasonViolation",
|
||||
"com.atproto.moderation.defs#reasonMisleading",
|
||||
"com.atproto.moderation.defs#reasonSexual",
|
||||
"com.atproto.moderation.defs#reasonRude",
|
||||
"com.atproto.moderation.defs#reasonOther",
|
||||
REASON_APPEAL,
|
||||
];
|
||||
|
||||
if !valid_reason_types.contains(&input.reason_type.as_str()) {
|
||||
return ApiError::InvalidRequest("Invalid reasonType".into()).into_response();
|
||||
}
|
||||
|
||||
let created_at = chrono::Utc::now();
|
||||
let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64;
|
||||
let report_id = i64::try_from(uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF)
|
||||
.expect("masked to 63 bits, always fits i64");
|
||||
let subject_json = json!(input.subject);
|
||||
|
||||
if let Err(e) = state
|
||||
.infra_repo
|
||||
.insert_report(
|
||||
report_id,
|
||||
&input.reason_type,
|
||||
input.reason_type.as_str(),
|
||||
input.reason.as_deref(),
|
||||
subject_json,
|
||||
did,
|
||||
@@ -221,7 +244,7 @@ async fn create_report_locally(
|
||||
info!(
|
||||
report_id = %report_id,
|
||||
reported_by = %did,
|
||||
reason_type = %input.reason_type,
|
||||
reason_type = input.reason_type.as_str(),
|
||||
"Report created locally (no report service configured)"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -11,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
|
||||
use tranquil_types::Did;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -130,91 +130,95 @@ pub struct UpdateNotificationPrefsInput {
|
||||
pub struct UpdateNotificationPrefsResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub verification_required: Vec<String>,
|
||||
pub verification_required: Vec<CommsChannel>,
|
||||
}
|
||||
|
||||
pub async fn request_channel_verification(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
identifier: &str,
|
||||
handle: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, ApiError> {
|
||||
let token =
|
||||
crate::auth::verification_token::generate_channel_update_token(did, channel, identifier);
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
|
||||
if channel == "email" {
|
||||
let hostname = pds_hostname();
|
||||
let handle_str = handle.unwrap_or("user");
|
||||
crate::comms::comms_repo::enqueue_email_update(
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
identifier,
|
||||
handle_str,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
|
||||
} else {
|
||||
let comms_channel = match channel {
|
||||
"discord" => tranquil_db_traits::CommsChannel::Discord,
|
||||
"telegram" => tranquil_db_traits::CommsChannel::Telegram,
|
||||
"signal" => tranquil_db_traits::CommsChannel::Signal,
|
||||
_ => return Err("Invalid channel".to_string()),
|
||||
};
|
||||
let hostname = pds_hostname();
|
||||
let encoded_token = urlencoding::encode(&formatted_token);
|
||||
let encoded_identifier = urlencoding::encode(identifier);
|
||||
let verify_link = format!(
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_identifier
|
||||
);
|
||||
let prefs = state
|
||||
.user_repo
|
||||
.get_comms_prefs(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let locale = prefs
|
||||
.as_ref()
|
||||
.and_then(|p| p.preferred_locale.as_deref())
|
||||
.unwrap_or("en");
|
||||
let strings = crate::comms::get_strings(locale);
|
||||
let body = crate::comms::format_message(
|
||||
strings.channel_verification_body,
|
||||
&[("code", &formatted_token), ("verify_link", &verify_link)],
|
||||
);
|
||||
let subject = crate::comms::format_message(
|
||||
strings.channel_verification_subject,
|
||||
&[("hostname", hostname)],
|
||||
);
|
||||
let recipient = match comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Telegram => state
|
||||
.user_repo
|
||||
.get_telegram_chat_id(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| identifier.to_string()),
|
||||
_ => identifier.to_string(),
|
||||
};
|
||||
state
|
||||
.infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
comms_channel,
|
||||
tranquil_db_traits::CommsType::ChannelVerification,
|
||||
&recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
Some(json!({"code": formatted_token})),
|
||||
match channel {
|
||||
CommsChannel::Email => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let handle_str = handle.unwrap_or("user");
|
||||
crate::comms::comms_repo::enqueue_email_update(
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
identifier,
|
||||
handle_str,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
|
||||
.map_err(|e| {
|
||||
ApiError::InternalError(Some(format!(
|
||||
"Failed to enqueue email notification: {}",
|
||||
e
|
||||
)))
|
||||
})?;
|
||||
}
|
||||
_ => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let encoded_token = urlencoding::encode(&formatted_token);
|
||||
let encoded_identifier = urlencoding::encode(identifier);
|
||||
let verify_link = format!(
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_identifier
|
||||
);
|
||||
let prefs = state
|
||||
.user_repo
|
||||
.get_comms_prefs(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let locale = prefs
|
||||
.as_ref()
|
||||
.and_then(|p| p.preferred_locale.as_deref())
|
||||
.unwrap_or("en");
|
||||
let strings = crate::comms::get_strings(locale);
|
||||
let body = crate::comms::format_message(
|
||||
strings.channel_verification_body,
|
||||
&[("code", &formatted_token), ("verify_link", &verify_link)],
|
||||
);
|
||||
let subject = crate::comms::format_message(
|
||||
strings.channel_verification_subject,
|
||||
&[("hostname", hostname)],
|
||||
);
|
||||
let recipient = match channel {
|
||||
CommsChannel::Telegram => state
|
||||
.user_repo
|
||||
.get_telegram_chat_id(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| identifier.to_string()),
|
||||
_ => identifier.to_string(),
|
||||
};
|
||||
state
|
||||
.infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
channel,
|
||||
tranquil_db_traits::CommsType::ChannelVerification,
|
||||
&recipient,
|
||||
Some(&subject),
|
||||
&body,
|
||||
Some(json!({"code": formatted_token})),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ApiError::InternalError(Some(format!("Failed to enqueue notification: {}", e)))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
@@ -246,38 +250,25 @@ pub async fn update_notification_prefs(
|
||||
let effective_channel = input
|
||||
.preferred_channel
|
||||
.as_deref()
|
||||
.map(|ch| match ch {
|
||||
"email" => Ok(CommsChannel::Email),
|
||||
"discord" => Ok(CommsChannel::Discord),
|
||||
"telegram" => Ok(CommsChannel::Telegram),
|
||||
"signal" => Ok(CommsChannel::Signal),
|
||||
_ => Err(ApiError::InvalidRequest(
|
||||
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
|
||||
)),
|
||||
.map(|ch| {
|
||||
ch.parse::<CommsChannel>().map_err(|_| {
|
||||
ApiError::InvalidRequest(
|
||||
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(current_prefs.preferred_channel);
|
||||
|
||||
let mut verification_required: Vec<String> = Vec::new();
|
||||
let mut verification_required: Vec<CommsChannel> = Vec::new();
|
||||
|
||||
if let Some(ref channel_str) = input.preferred_channel {
|
||||
let channel = match channel_str.as_str() {
|
||||
"email" => CommsChannel::Email,
|
||||
"discord" => CommsChannel::Discord,
|
||||
"telegram" => CommsChannel::Telegram,
|
||||
"signal" => CommsChannel::Signal,
|
||||
_ => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if input.preferred_channel.is_some() {
|
||||
state
|
||||
.user_repo
|
||||
.update_preferred_comms_channel(&auth.did, channel)
|
||||
.update_preferred_comms_channel(&auth.did, effective_channel)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel");
|
||||
info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel");
|
||||
}
|
||||
|
||||
if let Some(ref new_email) = input.email {
|
||||
@@ -295,13 +286,12 @@ pub async fn update_notification_prefs(
|
||||
&state,
|
||||
user_id,
|
||||
&auth.did,
|
||||
"email",
|
||||
CommsChannel::Email,
|
||||
&email_clean,
|
||||
Some(&handle),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(e)))?;
|
||||
verification_required.push("email".to_string());
|
||||
.await?;
|
||||
verification_required.push(CommsChannel::Email);
|
||||
info!(did = %auth.did, "Requested email verification");
|
||||
}
|
||||
}
|
||||
@@ -331,7 +321,7 @@ pub async fn update_notification_prefs(
|
||||
.set_unverified_discord(user_id, &discord_clean)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
verification_required.push("discord".to_string());
|
||||
verification_required.push(CommsChannel::Discord);
|
||||
info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username");
|
||||
}
|
||||
}
|
||||
@@ -361,7 +351,7 @@ pub async fn update_notification_prefs(
|
||||
.set_unverified_telegram(user_id, telegram_clean)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
verification_required.push("telegram".to_string());
|
||||
verification_required.push(CommsChannel::Telegram);
|
||||
info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username");
|
||||
}
|
||||
}
|
||||
@@ -391,10 +381,16 @@ pub async fn update_notification_prefs(
|
||||
.set_unverified_signal(user_id, &signal_clean)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(e)))?;
|
||||
verification_required.push("signal".to_string());
|
||||
request_channel_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&auth.did,
|
||||
CommsChannel::Signal,
|
||||
&signal_clean,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
verification_required.push(CommsChannel::Signal);
|
||||
info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
@@ -15,92 +17,96 @@ use futures_util::future::Either;
|
||||
use tower::{Service, util::BoxCloneSyncService};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const PROTECTED_METHODS: &[&str] = &[
|
||||
"app.bsky.actor.getPreferences",
|
||||
"app.bsky.actor.putPreferences",
|
||||
"com.atproto.admin.deleteAccount",
|
||||
"com.atproto.admin.disableAccountInvites",
|
||||
"com.atproto.admin.disableInviteCodes",
|
||||
"com.atproto.admin.enableAccountInvites",
|
||||
"com.atproto.admin.getAccountInfo",
|
||||
"com.atproto.admin.getAccountInfos",
|
||||
"com.atproto.admin.getInviteCodes",
|
||||
"com.atproto.admin.getSubjectStatus",
|
||||
"com.atproto.admin.searchAccounts",
|
||||
"com.atproto.admin.sendEmail",
|
||||
"com.atproto.admin.updateAccountEmail",
|
||||
"com.atproto.admin.updateAccountHandle",
|
||||
"com.atproto.admin.updateAccountPassword",
|
||||
"com.atproto.admin.updateSubjectStatus",
|
||||
"com.atproto.identity.getRecommendedDidCredentials",
|
||||
"com.atproto.identity.requestPlcOperationSignature",
|
||||
"com.atproto.identity.signPlcOperation",
|
||||
"com.atproto.identity.submitPlcOperation",
|
||||
"com.atproto.identity.updateHandle",
|
||||
"com.atproto.repo.applyWrites",
|
||||
"com.atproto.repo.createRecord",
|
||||
"com.atproto.repo.deleteRecord",
|
||||
"com.atproto.repo.importRepo",
|
||||
"com.atproto.repo.putRecord",
|
||||
"com.atproto.repo.uploadBlob",
|
||||
"com.atproto.server.activateAccount",
|
||||
"com.atproto.server.checkAccountStatus",
|
||||
"com.atproto.server.confirmEmail",
|
||||
"com.atproto.server.confirmSignup",
|
||||
"com.atproto.server.createAccount",
|
||||
"com.atproto.server.createAppPassword",
|
||||
"com.atproto.server.createInviteCode",
|
||||
"com.atproto.server.createInviteCodes",
|
||||
"com.atproto.server.createSession",
|
||||
"com.atproto.server.createTotpSecret",
|
||||
"com.atproto.server.deactivateAccount",
|
||||
"com.atproto.server.deleteAccount",
|
||||
"com.atproto.server.deletePasskey",
|
||||
"com.atproto.server.deleteSession",
|
||||
"com.atproto.server.describeServer",
|
||||
"com.atproto.server.disableTotp",
|
||||
"com.atproto.server.enableTotp",
|
||||
"com.atproto.server.finishPasskeyRegistration",
|
||||
"com.atproto.server.getAccountInviteCodes",
|
||||
"com.atproto.server.getServiceAuth",
|
||||
"com.atproto.server.getSession",
|
||||
"com.atproto.server.getTotpStatus",
|
||||
"com.atproto.server.listAppPasswords",
|
||||
"com.atproto.server.listPasskeys",
|
||||
"com.atproto.server.refreshSession",
|
||||
"com.atproto.server.regenerateBackupCodes",
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
"com.atproto.server.requestEmailConfirmation",
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
"com.atproto.server.requestPasswordReset",
|
||||
"com.atproto.server.resendMigrationVerification",
|
||||
"com.atproto.server.resendVerification",
|
||||
"com.atproto.server.reserveSigningKey",
|
||||
"com.atproto.server.resetPassword",
|
||||
"com.atproto.server.revokeAppPassword",
|
||||
"com.atproto.server.startPasskeyRegistration",
|
||||
"com.atproto.server.updateEmail",
|
||||
"com.atproto.server.updatePasskey",
|
||||
"com.atproto.server.verifyMigrationEmail",
|
||||
"com.atproto.sync.getBlob",
|
||||
"com.atproto.sync.getBlocks",
|
||||
"com.atproto.sync.getCheckout",
|
||||
"com.atproto.sync.getHead",
|
||||
"com.atproto.sync.getLatestCommit",
|
||||
"com.atproto.sync.getRecord",
|
||||
"com.atproto.sync.getRepo",
|
||||
"com.atproto.sync.getRepoStatus",
|
||||
"com.atproto.sync.listBlobs",
|
||||
"com.atproto.sync.listRepos",
|
||||
"com.atproto.sync.notifyOfUpdate",
|
||||
"com.atproto.sync.requestCrawl",
|
||||
"com.atproto.sync.subscribeRepos",
|
||||
"com.atproto.temp.checkSignupQueue",
|
||||
"com.atproto.temp.dereferenceScope",
|
||||
];
|
||||
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
[
|
||||
"app.bsky.actor.getPreferences",
|
||||
"app.bsky.actor.putPreferences",
|
||||
"com.atproto.admin.deleteAccount",
|
||||
"com.atproto.admin.disableAccountInvites",
|
||||
"com.atproto.admin.disableInviteCodes",
|
||||
"com.atproto.admin.enableAccountInvites",
|
||||
"com.atproto.admin.getAccountInfo",
|
||||
"com.atproto.admin.getAccountInfos",
|
||||
"com.atproto.admin.getInviteCodes",
|
||||
"com.atproto.admin.getSubjectStatus",
|
||||
"com.atproto.admin.searchAccounts",
|
||||
"com.atproto.admin.sendEmail",
|
||||
"com.atproto.admin.updateAccountEmail",
|
||||
"com.atproto.admin.updateAccountHandle",
|
||||
"com.atproto.admin.updateAccountPassword",
|
||||
"com.atproto.admin.updateSubjectStatus",
|
||||
"com.atproto.identity.getRecommendedDidCredentials",
|
||||
"com.atproto.identity.requestPlcOperationSignature",
|
||||
"com.atproto.identity.signPlcOperation",
|
||||
"com.atproto.identity.submitPlcOperation",
|
||||
"com.atproto.identity.updateHandle",
|
||||
"com.atproto.repo.applyWrites",
|
||||
"com.atproto.repo.createRecord",
|
||||
"com.atproto.repo.deleteRecord",
|
||||
"com.atproto.repo.importRepo",
|
||||
"com.atproto.repo.putRecord",
|
||||
"com.atproto.repo.uploadBlob",
|
||||
"com.atproto.server.activateAccount",
|
||||
"com.atproto.server.checkAccountStatus",
|
||||
"com.atproto.server.confirmEmail",
|
||||
"com.atproto.server.confirmSignup",
|
||||
"com.atproto.server.createAccount",
|
||||
"com.atproto.server.createAppPassword",
|
||||
"com.atproto.server.createInviteCode",
|
||||
"com.atproto.server.createInviteCodes",
|
||||
"com.atproto.server.createSession",
|
||||
"com.atproto.server.createTotpSecret",
|
||||
"com.atproto.server.deactivateAccount",
|
||||
"com.atproto.server.deleteAccount",
|
||||
"com.atproto.server.deletePasskey",
|
||||
"com.atproto.server.deleteSession",
|
||||
"com.atproto.server.describeServer",
|
||||
"com.atproto.server.disableTotp",
|
||||
"com.atproto.server.enableTotp",
|
||||
"com.atproto.server.finishPasskeyRegistration",
|
||||
"com.atproto.server.getAccountInviteCodes",
|
||||
"com.atproto.server.getServiceAuth",
|
||||
"com.atproto.server.getSession",
|
||||
"com.atproto.server.getTotpStatus",
|
||||
"com.atproto.server.listAppPasswords",
|
||||
"com.atproto.server.listPasskeys",
|
||||
"com.atproto.server.refreshSession",
|
||||
"com.atproto.server.regenerateBackupCodes",
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
"com.atproto.server.requestEmailConfirmation",
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
"com.atproto.server.requestPasswordReset",
|
||||
"com.atproto.server.resendMigrationVerification",
|
||||
"com.atproto.server.resendVerification",
|
||||
"com.atproto.server.reserveSigningKey",
|
||||
"com.atproto.server.resetPassword",
|
||||
"com.atproto.server.revokeAppPassword",
|
||||
"com.atproto.server.startPasskeyRegistration",
|
||||
"com.atproto.server.updateEmail",
|
||||
"com.atproto.server.updatePasskey",
|
||||
"com.atproto.server.verifyMigrationEmail",
|
||||
"com.atproto.sync.getBlob",
|
||||
"com.atproto.sync.getBlocks",
|
||||
"com.atproto.sync.getCheckout",
|
||||
"com.atproto.sync.getHead",
|
||||
"com.atproto.sync.getLatestCommit",
|
||||
"com.atproto.sync.getRecord",
|
||||
"com.atproto.sync.getRepo",
|
||||
"com.atproto.sync.getRepoStatus",
|
||||
"com.atproto.sync.listBlobs",
|
||||
"com.atproto.sync.listRepos",
|
||||
"com.atproto.sync.notifyOfUpdate",
|
||||
"com.atproto.sync.requestCrawl",
|
||||
"com.atproto.sync.subscribeRepos",
|
||||
"com.atproto.temp.checkSignupQueue",
|
||||
"com.atproto.temp.dereferenceScope",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn is_protected_method(method: &str) -> bool {
|
||||
PROTECTED_METHODS.contains(&method)
|
||||
PROTECTED_METHODS.contains(method)
|
||||
}
|
||||
|
||||
pub struct XrpcProxyLayer {
|
||||
@@ -162,7 +168,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
|
||||
}
|
||||
|
||||
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
|
||||
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok()
|
||||
if tranquil_config::get().server.age_assurance_override
|
||||
&& (path.ends_with("app.bsky.ageassurance.getState")
|
||||
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
|
||||
{
|
||||
@@ -192,7 +198,9 @@ async fn proxy_handler(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let Some(proxy_header) = get_header_str(&headers, "atproto-proxy").map(String::from) else {
|
||||
let Some(proxy_header) =
|
||||
get_header_str(&headers, crate::util::HEADER_ATPROTO_PROXY).map(String::from)
|
||||
else {
|
||||
return ApiError::InvalidRequest("Missing required atproto-proxy header".into())
|
||||
.into_response();
|
||||
};
|
||||
@@ -212,30 +220,29 @@ async fn proxy_handler(
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.request(method_verb.clone(), &target_url);
|
||||
|
||||
let mut auth_header_val = headers.get("Authorization").cloned();
|
||||
let mut auth_header_val = headers.get(http::header::AUTHORIZATION).cloned();
|
||||
if let Some(extracted) = crate::auth::extract_auth_token_from_header(
|
||||
crate::util::get_header_str(&headers, "Authorization"),
|
||||
crate::util::get_header_str(&headers, http::header::AUTHORIZATION),
|
||||
) {
|
||||
let token = extracted.token;
|
||||
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
|
||||
let dpop_proof = crate::util::get_header_str(&headers, crate::util::HEADER_DPOP);
|
||||
let http_uri = crate::util::build_full_url(&format!("/xrpc{}", uri));
|
||||
|
||||
match crate::auth::validate_token_with_dpop(
|
||||
state.user_repo.as_ref(),
|
||||
state.oauth_repo.as_ref(),
|
||||
&token,
|
||||
extracted.is_dpop,
|
||||
extracted.scheme,
|
||||
dpop_proof,
|
||||
method_verb.as_str(),
|
||||
&http_uri,
|
||||
false,
|
||||
false,
|
||||
crate::auth::AccountRequirement::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(auth_user) => {
|
||||
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
|
||||
auth_user.is_oauth(),
|
||||
&auth_user.auth_source,
|
||||
auth_user.scope.as_deref(),
|
||||
&resolved.did,
|
||||
method,
|
||||
@@ -298,7 +305,9 @@ async fn proxy_handler(
|
||||
info!(error = ?e, "Proxy token validation failed, returning error to client");
|
||||
let mut response = ApiError::from(e).into_response();
|
||||
if let Ok(nonce_val) = crate::oauth::verify::generate_dpop_nonce().parse() {
|
||||
response.headers_mut().insert("DPoP-Nonce", nonce_val);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(crate::util::HEADER_DPOP_NONCE, nonce_val);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -306,13 +315,13 @@ async fn proxy_handler(
|
||||
}
|
||||
|
||||
if let Some(val) = auth_header_val {
|
||||
request_builder = request_builder.header("Authorization", val);
|
||||
request_builder = request_builder.header(http::header::AUTHORIZATION, val);
|
||||
}
|
||||
request_builder = crate::api::proxy_client::HEADERS_TO_FORWARD
|
||||
.iter()
|
||||
.filter_map(|name| headers.get(*name).map(|val| (*name, val)))
|
||||
.filter_map(|name| headers.get(name).map(|val| (name, val)))
|
||||
.fold(request_builder, |builder, (name, val)| {
|
||||
builder.header(name, val)
|
||||
builder.header(name.as_str(), val)
|
||||
});
|
||||
if !body.is_empty() {
|
||||
request_builder = request_builder.body(body);
|
||||
@@ -333,7 +342,7 @@ async fn proxy_handler(
|
||||
let mut response_builder = Response::builder().status(status);
|
||||
response_builder = crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD
|
||||
.iter()
|
||||
.filter_map(|name| headers.get(*name).map(|val| (*name, val)))
|
||||
.filter_map(|name| headers.get(name).map(|val| (name, val)))
|
||||
.fold(response_builder, |builder, (name, val)| {
|
||||
builder.header(name, val)
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use axum::http::HeaderName;
|
||||
use reqwest::{Client, ClientBuilder, Url};
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
use tranquil_types::{Did, Nsid, Rkey};
|
||||
|
||||
pub const DEFAULT_HEADERS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub const DEFAULT_BODY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -61,7 +63,7 @@ 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()
|
||||
let allow_http = tranquil_config::try_get().is_some_and(|c| c.server.allow_http_proxy)
|
||||
|| url.starts_with("http://127.0.0.1")
|
||||
|| url.starts_with("http://localhost");
|
||||
if !allow_http {
|
||||
@@ -146,20 +148,24 @@ 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",
|
||||
"content-type",
|
||||
];
|
||||
pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
|
||||
"atproto-repo-rev",
|
||||
"atproto-content-labelers",
|
||||
"retry-after",
|
||||
"content-type",
|
||||
"cache-control",
|
||||
"etag",
|
||||
];
|
||||
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
|
||||
[
|
||||
HeaderName::from_static("accept-language"),
|
||||
crate::util::HEADER_ATPROTO_ACCEPT_LABELERS,
|
||||
crate::util::HEADER_X_BSKY_TOPICS,
|
||||
http::header::CONTENT_TYPE,
|
||||
]
|
||||
});
|
||||
pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| {
|
||||
[
|
||||
crate::util::HEADER_ATPROTO_REPO_REV,
|
||||
crate::util::HEADER_ATPROTO_CONTENT_LABELERS,
|
||||
HeaderName::from_static("retry-after"),
|
||||
http::header::CONTENT_TYPE,
|
||||
http::header::CACHE_CONTROL,
|
||||
http::header::ETAG,
|
||||
]
|
||||
});
|
||||
|
||||
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
|
||||
if !uri.starts_with("at://") {
|
||||
@@ -170,28 +176,29 @@ pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
let did: Did = parts[0].parse().map_err(|_| "Invalid DID in URI")?;
|
||||
let collection = parts
|
||||
.get(1)
|
||||
.map(|s| s.parse::<Nsid>())
|
||||
.transpose()
|
||||
.map_err(|_| "Invalid collection NSID")?;
|
||||
let rkey = parts
|
||||
.get(2)
|
||||
.map(|s| s.parse::<Rkey>())
|
||||
.transpose()
|
||||
.map_err(|_| "Invalid rkey")?;
|
||||
Ok(AtUriParts {
|
||||
did: did.to_string(),
|
||||
collection: parts.get(1).map(|s| s.to_string()),
|
||||
rkey: parts.get(2).map(|s| s.to_string()),
|
||||
did,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtUriParts {
|
||||
pub did: String,
|
||||
pub collection: Option<String>,
|
||||
pub rkey: Option<String>,
|
||||
pub did: Did,
|
||||
pub collection: Option<Nsid>,
|
||||
pub rkey: Option<Rkey>,
|
||||
}
|
||||
|
||||
pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
|
||||
@@ -203,21 +210,6 @@ pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
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::*;
|
||||
@@ -243,9 +235,12 @@ mod tests {
|
||||
let result = validate_at_uri("at://did:plc:test/app.bsky.feed.post/abc123");
|
||||
assert!(result.is_ok());
|
||||
let parts = result.unwrap();
|
||||
assert_eq!(parts.did, "did:plc:test");
|
||||
assert_eq!(parts.collection, Some("app.bsky.feed.post".to_string()));
|
||||
assert_eq!(parts.rkey, Some("abc123".to_string()));
|
||||
assert_eq!(parts.did, "did:plc:test".parse::<Did>().unwrap());
|
||||
assert_eq!(
|
||||
parts.collection,
|
||||
Some("app.bsky.feed.post".parse::<Nsid>().unwrap())
|
||||
);
|
||||
assert_eq!(parts.rkey, Some("abc123".parse::<Rkey>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_validate_at_uri_invalid() {
|
||||
@@ -259,11 +254,4 @@ 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());
|
||||
assert!(validate_did("did:web:example.com").is_ok());
|
||||
assert!(validate_did("notadid").is_err());
|
||||
assert!(validate_did("did:unknown:test").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
|
||||
use crate::delegation::DelegationActionType;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{CidLink, Did};
|
||||
use crate::util::{get_header_str, get_max_blob_size};
|
||||
use crate::util::get_header_str;
|
||||
use axum::body::Body;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -56,8 +56,8 @@ pub async fn upload_blob(
|
||||
if user.status.is_takendown() {
|
||||
return Err(ApiError::AccountTakedown);
|
||||
}
|
||||
let mime_type_for_check =
|
||||
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
|
||||
let mime_type_for_check = get_header_str(&headers, http::header::CONTENT_TYPE)
|
||||
.unwrap_or("application/octet-stream");
|
||||
let scope_proof = match user.verify_blob_upload(mime_type_for_check) {
|
||||
Ok(proof) => proof,
|
||||
Err(e) => return Ok(e.into_response()),
|
||||
@@ -79,7 +79,7 @@ pub async fn upload_blob(
|
||||
}
|
||||
|
||||
let client_mime_hint =
|
||||
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
|
||||
get_header_str(&headers, http::header::CONTENT_TYPE).unwrap_or("application/octet-stream");
|
||||
|
||||
let user_id = state
|
||||
.user_repo
|
||||
@@ -89,7 +89,7 @@ pub async fn upload_blob(
|
||||
.ok_or(ApiError::InternalError(None))?;
|
||||
|
||||
let temp_key = format!("temp/{}", uuid::Uuid::new_v4());
|
||||
let max_size = get_max_blob_size() as u64;
|
||||
let max_size = tranquil_config::get().server.max_blob_size;
|
||||
|
||||
let body_stream = body.into_data_stream();
|
||||
let mapped_stream =
|
||||
@@ -148,7 +148,13 @@ pub async fn upload_blob(
|
||||
|
||||
match state
|
||||
.blob_repo
|
||||
.insert_blob(&cid_link, &mime_type, size as i64, user_id, &storage_key)
|
||||
.insert_blob(
|
||||
&cid_link,
|
||||
&mime_type,
|
||||
i64::try_from(size).unwrap_or(i64::MAX),
|
||||
user_id,
|
||||
&storage_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
@@ -162,7 +168,10 @@ pub async fn upload_blob(
|
||||
if let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
if let Err(db_err) = state.blob_repo.delete_blob_by_cid(&cid_link).await {
|
||||
error!("Failed to clean up orphaned blob record after copy failure: {:?}", db_err);
|
||||
error!(
|
||||
"Failed to clean up orphaned blob record after copy failure: {:?}",
|
||||
db_err
|
||||
);
|
||||
}
|
||||
error!("Failed to copy blob to final location: {:?}", e);
|
||||
return Err(ApiError::InternalError(Some("Failed to store blob".into())));
|
||||
@@ -170,8 +179,8 @@ pub async fn upload_blob(
|
||||
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
if let Err(e) = state
|
||||
if let Some(ref controller) = controller_did
|
||||
&& let Err(e) = state
|
||||
.delegation_repo
|
||||
.log_delegation_action(
|
||||
&did,
|
||||
@@ -187,9 +196,8 @@ pub async fn upload_blob(
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to log delegation action for blob upload: {:?}", e);
|
||||
}
|
||||
{
|
||||
warn!("Failed to log delegation action for blob upload: {:?}", e);
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -246,10 +254,11 @@ pub async fn list_missing_blobs(
|
||||
.await
|
||||
.log_db_err("fetching missing blobs")?;
|
||||
|
||||
let has_more = missing.len() > limit as usize;
|
||||
let limit_usize = usize::try_from(limit).unwrap_or(0);
|
||||
let has_more = missing.len() > limit_usize;
|
||||
let blobs: Vec<RecordBlob> = missing
|
||||
.into_iter()
|
||||
.take(limit as usize)
|
||||
.take(limit_usize)
|
||||
.map(|m| RecordBlob {
|
||||
cid: m.blob_cid.to_string(),
|
||||
record_uri: m.record_uri.to_string(),
|
||||
|
||||
@@ -18,26 +18,18 @@ use serde_json::json;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_types::{AtUri, CidLink};
|
||||
|
||||
const DEFAULT_MAX_IMPORT_SIZE: usize = 1024 * 1024 * 1024;
|
||||
const DEFAULT_MAX_BLOCKS: usize = 500000;
|
||||
|
||||
pub async fn import_repo(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
body: Bytes,
|
||||
) -> Result<Response, ApiError> {
|
||||
let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true);
|
||||
let accepting_imports = tranquil_config::get().import.accepting;
|
||||
if !accepting_imports {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Service is not accepting repo imports".into(),
|
||||
));
|
||||
}
|
||||
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
|
||||
let max_size = tranquil_config::get().import.max_size as usize;
|
||||
if body.len() > max_size {
|
||||
return Err(ApiError::PayloadTooLarge(format!(
|
||||
"Import size exceeds limit of {} bytes",
|
||||
@@ -92,8 +84,12 @@ pub async fn import_repo(
|
||||
"Root block not found in CAR file".into(),
|
||||
));
|
||||
};
|
||||
let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
|
||||
Ok(commit) => commit.did().to_string(),
|
||||
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
|
||||
Ok(commit) => commit
|
||||
.did()
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
|
||||
Err(e) => {
|
||||
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
|
||||
}
|
||||
@@ -105,8 +101,13 @@ pub async fn import_repo(
|
||||
)));
|
||||
}
|
||||
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
|
||||
.ok()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
.unwrap_or_else(|| {
|
||||
tranquil_config::try_get()
|
||||
.map(|c| c.import.skip_verification)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let is_migration = user.deactivated_at.is_some();
|
||||
if skip_verification {
|
||||
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
@@ -194,10 +195,7 @@ pub async fn import_repo(
|
||||
}
|
||||
}
|
||||
}
|
||||
let max_blocks: usize = std::env::var("MAX_IMPORT_BLOCKS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_BLOCKS);
|
||||
let max_blocks = tranquil_config::get().import.max_blocks as usize;
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
match apply_import(
|
||||
&state.repo_repo,
|
||||
@@ -221,10 +219,14 @@ pub async fn import_repo(
|
||||
.flat_map(|record| {
|
||||
let record_uri =
|
||||
AtUri::from_parts(did.as_str(), &record.collection, &record.rkey);
|
||||
record.blob_refs.iter().map(move |blob_ref| {
|
||||
(record_uri.clone(), unsafe {
|
||||
CidLink::new_unchecked(blob_ref.cid.clone())
|
||||
})
|
||||
record.blob_refs.iter().filter_map(move |blob_ref| {
|
||||
match CidLink::new(&blob_ref.cid) {
|
||||
Ok(cid_link) => Some((record_uri.clone(), cid_link)),
|
||||
Err(_) => {
|
||||
tracing::warn!(cid = %blob_ref.cid, "skipping unparseable blob CID reference during import");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -289,7 +291,7 @@ pub async fn import_repo(
|
||||
error!("Failed to store new commit block: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid_link = unsafe { CidLink::new_unchecked(new_root_cid.to_string()) };
|
||||
let new_root_cid_link = CidLink::from(&new_root_cid);
|
||||
state
|
||||
.repo_repo
|
||||
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
|
||||
@@ -313,11 +315,12 @@ pub async fn import_repo(
|
||||
"Created new commit for imported repo: cid={}, rev={}",
|
||||
new_root_str, new_rev_str
|
||||
);
|
||||
if !is_migration && let Err(e) = sequence_import_event(&state, did, &new_root_str).await
|
||||
if !is_migration
|
||||
&& let Err(e) = sequence_import_event(&state, did, &new_root_cid_link).await
|
||||
{
|
||||
warn!("Failed to sequence import event: {:?}", e);
|
||||
}
|
||||
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() {
|
||||
if tranquil_config::get().server.age_assurance_override {
|
||||
let birthdate_pref = json!({
|
||||
"$type": "app.bsky.actor.defs#personalDetailsPref",
|
||||
"birthDate": "1998-05-06T00:00:00.000Z"
|
||||
@@ -378,12 +381,12 @@ pub async fn import_repo(
|
||||
async fn sequence_import_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
commit_cid: &str,
|
||||
commit_cid: &CidLink,
|
||||
) -> Result<(), tranquil_db::DbError> {
|
||||
let data = tranquil_db::CommitEventData {
|
||||
did: did.clone(),
|
||||
event_type: tranquil_db::RepoEventType::Commit,
|
||||
commit_cid: Some(unsafe { CidLink::new_unchecked(commit_cid) }),
|
||||
commit_cid: Some(commit_cid.clone()),
|
||||
prev_cid: None,
|
||||
ops: Some(serde_json::json!([])),
|
||||
blobs: Some(vec![]),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::AtIdentifier;
|
||||
use crate::util::pds_hostname_without_port;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -19,7 +18,7 @@ pub async fn describe_repo(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<DescribeRepoInput>,
|
||||
) -> Response {
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let user_row = if input.repo.is_did() {
|
||||
let did: crate::types::Did = match input.repo.as_str().parse() {
|
||||
Ok(d) => d,
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::delegation::DelegationActionType;
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use crate::validation::ValidationStatus;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -23,7 +24,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
use tracing::info;
|
||||
|
||||
const MAX_BATCH_WRITES: usize = 200;
|
||||
|
||||
@@ -87,7 +88,7 @@ async fn process_single_write(
|
||||
results.push(WriteResult::CreateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
validation_status,
|
||||
});
|
||||
ops.push(RecordOp::Create {
|
||||
collection: collection.clone(),
|
||||
@@ -138,7 +139,7 @@ async fn process_single_write(
|
||||
results.push(WriteResult::UpdateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
validation_status,
|
||||
});
|
||||
ops.push(RecordOp::Update {
|
||||
collection: collection.clone(),
|
||||
@@ -237,14 +238,14 @@ pub enum WriteResult {
|
||||
uri: AtUri,
|
||||
cid: String,
|
||||
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
|
||||
validation_status: Option<String>,
|
||||
validation_status: Option<ValidationStatus>,
|
||||
},
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#updateResult")]
|
||||
UpdateResult {
|
||||
uri: AtUri,
|
||||
cid: String,
|
||||
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
|
||||
validation_status: Option<String>,
|
||||
validation_status: Option<ValidationStatus>,
|
||||
},
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#deleteResult")]
|
||||
DeleteResult {},
|
||||
@@ -441,15 +442,7 @@ pub async fn apply_writes(
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) if e.contains("ConcurrentModification") => {
|
||||
return Err(ApiError::InvalidSwap(Some("Repo has been modified".into())));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Commit failed: {}", e);
|
||||
return Err(ApiError::InternalError(Some(
|
||||
"Failed to commit changes".into(),
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(ApiError::from(e)),
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::{
|
||||
CommitParams, RecordOp, commit_and_log, get_current_root_cid,
|
||||
CommitError, CommitParams, RecordOp, commit_and_log, get_current_root_cid,
|
||||
};
|
||||
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
|
||||
use crate::auth::{Active, Auth, VerifyScope};
|
||||
@@ -186,10 +186,7 @@ pub async fn delete_record(
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) if e.contains("ConcurrentModification") => {
|
||||
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
|
||||
}
|
||||
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
|
||||
Err(e) => return Ok(ApiError::from(e).into_response()),
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -241,28 +238,30 @@ pub async fn delete_record_internal(
|
||||
user_id: Uuid,
|
||||
collection: &Nsid,
|
||||
rkey: &Rkey,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), CommitError> {
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
let root_cid_str = state
|
||||
.repo_repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.map_err(|e| format!("DB error: {}", e))?
|
||||
.ok_or_else(|| "Repo root not found".to_string())?;
|
||||
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
|
||||
.ok_or(CommitError::RepoNotFound)?;
|
||||
|
||||
let current_root_cid =
|
||||
Cid::from_str(root_cid_str.as_str()).map_err(|_| "Invalid repo root CID".to_string())?;
|
||||
Cid::from_str(root_cid_str.as_str()).map_err(|e| CommitError::InvalidCid(e.to_string()))?;
|
||||
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = tracking_store
|
||||
.get(¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch commit: {:?}", e))?
|
||||
.ok_or_else(|| "Commit block not found".to_string())?;
|
||||
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?
|
||||
.ok_or(CommitError::BlockStoreFailed(
|
||||
"Commit block not found".into(),
|
||||
))?;
|
||||
|
||||
let commit =
|
||||
Commit::from_cbor(&commit_bytes).map_err(|e| format!("Failed to parse commit: {:?}", e))?;
|
||||
let commit = Commit::from_cbor(&commit_bytes)
|
||||
.map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?;
|
||||
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
@@ -270,7 +269,7 @@ pub async fn delete_record_internal(
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| format!("MST get error: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
|
||||
let Some(prev_cid) = prev_record_cid else {
|
||||
return Ok(());
|
||||
@@ -279,12 +278,12 @@ pub async fn delete_record_internal(
|
||||
let new_mst = mst
|
||||
.delete(&key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete from MST: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
|
||||
let new_mst_root = new_mst
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to persist MST: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
|
||||
let op = RecordOp::Delete {
|
||||
collection: collection.clone(),
|
||||
@@ -298,11 +297,11 @@ pub async fn delete_record_internal(
|
||||
new_mst
|
||||
.blocks_for_path(&key, &mut new_mst_blocks)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get new MST blocks: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
|
||||
mst.blocks_for_path(&key, &mut old_mst_blocks)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get old MST blocks: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
|
||||
let mut relevant_blocks = new_mst_blocks.clone();
|
||||
relevant_blocks.extend(old_mst_blocks.iter().map(|(k, v)| (*k, v.clone())));
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::pagination::{PaginationDirection, deserialize_pagination_direction};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AtIdentifier, Nsid, Rkey};
|
||||
use crate::util::pds_hostname_without_port;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -60,7 +59,7 @@ pub async fn get_record(
|
||||
_headers: HeaderMap,
|
||||
Query(input): Query<GetRecordInput>,
|
||||
) -> Response {
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let user_id_opt = if input.repo.is_did() {
|
||||
let did: crate::types::Did = match input.repo.as_str().parse() {
|
||||
Ok(d) => d,
|
||||
@@ -159,7 +158,7 @@ pub async fn list_records(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<ListRecordsInput>,
|
||||
) -> Response {
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let user_id_opt = if input.repo.is_did() {
|
||||
let did: crate::types::Did = match input.repo.as_str().parse() {
|
||||
Ok(d) => d,
|
||||
@@ -195,7 +194,7 @@ pub async fn list_records(
|
||||
}
|
||||
};
|
||||
let limit = input.limit.unwrap_or(50).clamp(1, 100);
|
||||
let limit_i64 = limit as i64;
|
||||
let limit_i64 = i64::from(limit);
|
||||
let cursor_rkey = input
|
||||
.cursor
|
||||
.as_ref()
|
||||
|
||||
@@ -14,6 +14,71 @@ use tracing::error;
|
||||
use tranquil_db_traits::SequenceNumber;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommitError {
|
||||
InvalidDid(String),
|
||||
InvalidTid(String),
|
||||
SigningFailed(String),
|
||||
SerializationFailed(String),
|
||||
KeyNotFound,
|
||||
KeyDecryptionFailed(String),
|
||||
InvalidKey(String),
|
||||
BlockStoreFailed(String),
|
||||
RepoNotFound,
|
||||
ConcurrentModification,
|
||||
DatabaseError(String),
|
||||
UserNotFound,
|
||||
CommitParseFailed(String),
|
||||
MstOperationFailed(String),
|
||||
RecordSerializationFailed(String),
|
||||
InvalidCid(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommitError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidDid(e) => write!(f, "Invalid DID: {}", e),
|
||||
Self::InvalidTid(e) => write!(f, "Invalid TID: {}", e),
|
||||
Self::SigningFailed(e) => write!(f, "Failed to sign commit: {}", e),
|
||||
Self::SerializationFailed(e) => write!(f, "Failed to serialize signed commit: {}", e),
|
||||
Self::KeyNotFound => write!(f, "Signing key not found"),
|
||||
Self::KeyDecryptionFailed(e) => write!(f, "Failed to decrypt signing key: {}", e),
|
||||
Self::InvalidKey(e) => write!(f, "Invalid signing key: {}", e),
|
||||
Self::BlockStoreFailed(e) => write!(f, "Block store operation failed: {}", e),
|
||||
Self::RepoNotFound => write!(f, "Repo not found"),
|
||||
Self::ConcurrentModification => {
|
||||
write!(f, "Repo has been modified since last read")
|
||||
}
|
||||
Self::DatabaseError(e) => write!(f, "Database error: {}", e),
|
||||
Self::UserNotFound => write!(f, "User not found"),
|
||||
Self::CommitParseFailed(e) => write!(f, "Failed to parse commit: {}", e),
|
||||
Self::MstOperationFailed(e) => write!(f, "MST operation failed: {}", e),
|
||||
Self::RecordSerializationFailed(e) => {
|
||||
write!(f, "Failed to serialize record: {}", e)
|
||||
}
|
||||
Self::InvalidCid(e) => write!(f, "Invalid CID: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommitError {}
|
||||
|
||||
impl From<CommitError> for ApiError {
|
||||
fn from(err: CommitError) -> Self {
|
||||
match err {
|
||||
CommitError::ConcurrentModification => {
|
||||
ApiError::InvalidSwap(Some("Repo has been modified".into()))
|
||||
}
|
||||
CommitError::RepoNotFound => ApiError::RepoNotFound(None),
|
||||
CommitError::UserNotFound => ApiError::RepoNotFound(Some("User not found".into())),
|
||||
other => {
|
||||
error!("Commit failed: {}", other);
|
||||
ApiError::InternalError(Some("Failed to commit changes".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_current_root_cid(state: &AppState, user_id: Uuid) -> Result<CommitCid, ApiError> {
|
||||
let root_cid_str = state
|
||||
.repo_repo
|
||||
@@ -55,7 +120,7 @@ fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec<String>) {
|
||||
}
|
||||
|
||||
use crate::types::AtUri;
|
||||
use tranquil_db_traits::Backlink;
|
||||
use tranquil_db_traits::{Backlink, BacklinkPath};
|
||||
|
||||
pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
|
||||
let record_type = record
|
||||
@@ -71,7 +136,7 @@ pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
|
||||
.map(|subject| {
|
||||
vec![Backlink {
|
||||
uri: uri.clone(),
|
||||
path: "subject".to_string(),
|
||||
path: BacklinkPath::Subject,
|
||||
link_to: subject.to_string(),
|
||||
}]
|
||||
})
|
||||
@@ -84,7 +149,7 @@ pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
|
||||
.map(|subject_uri| {
|
||||
vec![Backlink {
|
||||
uri: uri.clone(),
|
||||
path: "subject.uri".to_string(),
|
||||
path: BacklinkPath::SubjectUri,
|
||||
link_to: subject_uri.to_string(),
|
||||
}]
|
||||
})
|
||||
@@ -99,19 +164,19 @@ pub fn create_signed_commit(
|
||||
rev: &str,
|
||||
prev: Option<Cid>,
|
||||
signing_key: &SigningKey,
|
||||
) -> Result<(Vec<u8>, Bytes), String> {
|
||||
) -> Result<(Vec<u8>, Bytes), CommitError> {
|
||||
let did = jacquard_common::types::string::Did::new(did.as_str())
|
||||
.map_err(|e| format!("Invalid DID: {:?}", e))?;
|
||||
.map_err(|e| CommitError::InvalidDid(format!("{:?}", e)))?;
|
||||
let rev = jacquard_common::types::string::Tid::from_str(rev)
|
||||
.map_err(|e| format!("Invalid TID: {:?}", e))?;
|
||||
.map_err(|e| CommitError::InvalidTid(format!("{:?}", e)))?;
|
||||
let unsigned = Commit::new_unsigned(did, data, rev, prev);
|
||||
let signed = unsigned
|
||||
.sign(signing_key)
|
||||
.map_err(|e| format!("Failed to sign commit: {:?}", e))?;
|
||||
.map_err(|e| CommitError::SigningFailed(format!("{:?}", e)))?;
|
||||
let sig_bytes = signed.sig().clone();
|
||||
let signed_bytes = signed
|
||||
.to_cbor()
|
||||
.map_err(|e| format!("Failed to serialize signed commit: {:?}", e))?;
|
||||
.map_err(|e| CommitError::SerializationFailed(format!("{:?}", e)))?;
|
||||
Ok((signed_bytes, sig_bytes))
|
||||
}
|
||||
|
||||
@@ -154,7 +219,7 @@ pub struct CommitParams<'a> {
|
||||
pub async fn commit_and_log(
|
||||
state: &AppState,
|
||||
params: CommitParams<'_>,
|
||||
) -> Result<CommitResult, String> {
|
||||
) -> Result<CommitResult, CommitError> {
|
||||
use tranquil_db_traits::{
|
||||
ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert,
|
||||
RepoEventType,
|
||||
@@ -175,12 +240,12 @@ pub async fn commit_and_log(
|
||||
.user_repo
|
||||
.get_user_key_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch signing key: {}", e))?
|
||||
.ok_or_else(|| "Signing key not found".to_string())?;
|
||||
.map_err(|e| CommitError::DatabaseError(format!("Failed to fetch signing key: {}", e)))?
|
||||
.ok_or(CommitError::KeyNotFound)?;
|
||||
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
|
||||
.map_err(|e| format!("Failed to decrypt signing key: {}", e))?;
|
||||
.map_err(|e| CommitError::KeyDecryptionFailed(e.to_string()))?;
|
||||
let signing_key =
|
||||
SigningKey::from_slice(&key_bytes).map_err(|e| format!("Invalid signing key: {}", e))?;
|
||||
SigningKey::from_slice(&key_bytes).map_err(|e| CommitError::InvalidKey(e.to_string()))?;
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let rev_str = rev.to_string();
|
||||
let (new_commit_bytes, _sig) =
|
||||
@@ -189,7 +254,7 @@ pub async fn commit_and_log(
|
||||
.block_store
|
||||
.put(&new_commit_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save commit block: {:?}", e))?;
|
||||
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?;
|
||||
|
||||
let mut all_block_cids: Vec<Vec<u8>> = blocks_cids
|
||||
.iter()
|
||||
@@ -218,7 +283,7 @@ pub async fn commit_and_log(
|
||||
upserts.push(RecordUpsert {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
cid: unsafe { crate::types::CidLink::new_unchecked(cid.to_string()) },
|
||||
cid: crate::types::CidLink::from(cid),
|
||||
});
|
||||
}
|
||||
RecordOp::Delete {
|
||||
@@ -283,23 +348,20 @@ pub async fn commit_and_log(
|
||||
let commit_event = CommitEventData {
|
||||
did: did.clone(),
|
||||
event_type: RepoEventType::Commit,
|
||||
commit_cid: Some(unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }),
|
||||
prev_cid: current_root_cid
|
||||
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
|
||||
commit_cid: Some(crate::types::CidLink::from(new_root_cid)),
|
||||
prev_cid: current_root_cid.map(crate::types::CidLink::from),
|
||||
ops: Some(json!(ops_json)),
|
||||
blobs: Some(blobs.to_vec()),
|
||||
blocks_cids: Some(blocks_cids.to_vec()),
|
||||
prev_data_cid: prev_data_cid
|
||||
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
|
||||
prev_data_cid: prev_data_cid.map(crate::types::CidLink::from),
|
||||
rev: Some(rev_str.clone()),
|
||||
};
|
||||
|
||||
let input = ApplyCommitInput {
|
||||
user_id,
|
||||
did: did.clone(),
|
||||
expected_root_cid: current_root_cid
|
||||
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
|
||||
new_root_cid: unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) },
|
||||
expected_root_cid: current_root_cid.map(crate::types::CidLink::from),
|
||||
new_root_cid: crate::types::CidLink::from(new_root_cid),
|
||||
new_rev: rev_str.clone(),
|
||||
new_block_cids: all_block_cids,
|
||||
obsolete_block_cids: obsolete_bytes,
|
||||
@@ -308,22 +370,16 @@ pub async fn commit_and_log(
|
||||
commit_event,
|
||||
};
|
||||
|
||||
let result = state
|
||||
let _result = state
|
||||
.repo_repo
|
||||
.apply_commit(input)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ApplyCommitError::RepoNotFound => "Repo not found".to_string(),
|
||||
ApplyCommitError::ConcurrentModification => {
|
||||
"ConcurrentModification: Repo has been modified since last read".to_string()
|
||||
}
|
||||
ApplyCommitError::Database(msg) => format!("DB Error: {}", msg),
|
||||
ApplyCommitError::RepoNotFound => CommitError::RepoNotFound,
|
||||
ApplyCommitError::ConcurrentModification => CommitError::ConcurrentModification,
|
||||
ApplyCommitError::Database(msg) => CommitError::DatabaseError(msg),
|
||||
})?;
|
||||
|
||||
if result.is_account_active {
|
||||
let _ = sequence_sync_event(state, did, &new_root_cid.to_string(), Some(&rev_str)).await;
|
||||
}
|
||||
|
||||
Ok(CommitResult {
|
||||
commit_cid: new_root_cid,
|
||||
rev: rev_str,
|
||||
@@ -335,7 +391,7 @@ pub async fn create_record_internal(
|
||||
collection: &Nsid,
|
||||
rkey: &Rkey,
|
||||
record: &serde_json::Value,
|
||||
) -> Result<(String, Cid), String> {
|
||||
) -> Result<(String, Cid), CommitError> {
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use std::sync::Arc;
|
||||
@@ -343,8 +399,8 @@ pub async fn create_record_internal(
|
||||
.user_repo
|
||||
.get_id_by_did(did)
|
||||
.await
|
||||
.map_err(|e| format!("DB error: {}", e))?
|
||||
.ok_or_else(|| "User not found".to_string())?;
|
||||
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
|
||||
.ok_or(CommitError::UserNotFound)?;
|
||||
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
@@ -352,36 +408,38 @@ pub async fn create_record_internal(
|
||||
.repo_repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.map_err(|e| format!("DB error: {}", e))?
|
||||
.ok_or_else(|| "Repo not found".to_string())?;
|
||||
let current_root_cid =
|
||||
Cid::from_str(root_cid_link.as_str()).map_err(|_| "Invalid repo root CID".to_string())?;
|
||||
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
|
||||
.ok_or(CommitError::RepoNotFound)?;
|
||||
let current_root_cid = Cid::from_str(root_cid_link.as_str())
|
||||
.map_err(|e| CommitError::InvalidCid(e.to_string()))?;
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = tracking_store
|
||||
.get(¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch commit: {:?}", e))?
|
||||
.ok_or_else(|| "Commit block not found".to_string())?;
|
||||
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?
|
||||
.ok_or(CommitError::BlockStoreFailed(
|
||||
"Commit block not found".into(),
|
||||
))?;
|
||||
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
|
||||
.map_err(|e| format!("Failed to parse commit: {:?}", e))?;
|
||||
.map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?;
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let record_ipld = crate::util::json_to_ipld(record);
|
||||
let mut record_bytes = Vec::new();
|
||||
serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld)
|
||||
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
|
||||
.map_err(|e| CommitError::RecordSerializationFailed(format!("{:?}", e)))?;
|
||||
let record_cid = tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save record block: {:?}", e))?;
|
||||
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?;
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to add to MST: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
let new_mst_root = new_mst
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to persist MST: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
let op = RecordOp::Create {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
@@ -392,10 +450,10 @@ pub async fn create_record_internal(
|
||||
new_mst
|
||||
.blocks_for_path(&key, &mut new_mst_blocks)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get new MST blocks for path: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
mst.blocks_for_path(&key, &mut old_mst_blocks)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get old MST blocks for path: {:?}", e))?;
|
||||
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
|
||||
.chain(
|
||||
old_mst_blocks
|
||||
@@ -439,36 +497,38 @@ pub async fn sequence_identity_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<SequenceNumber, String> {
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
state
|
||||
.repo_repo
|
||||
.insert_identity_event(did, handle)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (identity event): {}", e))
|
||||
.map_err(|e| CommitError::DatabaseError(format!("identity event: {}", e)))
|
||||
}
|
||||
pub async fn sequence_account_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
status: tranquil_db_traits::AccountStatus,
|
||||
) -> Result<SequenceNumber, String> {
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
state
|
||||
.repo_repo
|
||||
.insert_account_event(did, status)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (account event): {}", e))
|
||||
.map_err(|e| CommitError::DatabaseError(format!("account event: {}", e)))
|
||||
}
|
||||
pub async fn sequence_sync_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
commit_cid: &str,
|
||||
rev: Option<&str>,
|
||||
) -> Result<SequenceNumber, String> {
|
||||
let cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid) };
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
let cid_link: crate::types::CidLink = commit_cid
|
||||
.parse()
|
||||
.map_err(|_| CommitError::InvalidCid(commit_cid.to_string()))?;
|
||||
state
|
||||
.repo_repo
|
||||
.insert_sync_event(did, &cid_link, rev)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (sync event): {}", e))
|
||||
.map_err(|e| CommitError::DatabaseError(format!("sync event: {}", e)))
|
||||
}
|
||||
|
||||
pub async fn sequence_genesis_commit(
|
||||
@@ -477,13 +537,12 @@ pub async fn sequence_genesis_commit(
|
||||
commit_cid: &Cid,
|
||||
mst_root_cid: &Cid,
|
||||
rev: &str,
|
||||
) -> Result<SequenceNumber, String> {
|
||||
let commit_cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid.to_string()) };
|
||||
let mst_root_cid_link =
|
||||
unsafe { crate::types::CidLink::new_unchecked(mst_root_cid.to_string()) };
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
let commit_cid_link = crate::types::CidLink::from(commit_cid);
|
||||
let mst_root_cid_link = crate::types::CidLink::from(mst_root_cid);
|
||||
state
|
||||
.repo_repo
|
||||
.insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (genesis commit event): {}", e))
|
||||
.map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e)))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::api::repo::record::utils::{
|
||||
get_current_root_cid,
|
||||
};
|
||||
use crate::auth::{
|
||||
Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||
require_verified_or_delegated,
|
||||
};
|
||||
use crate::cid_types::CommitCid;
|
||||
@@ -14,6 +14,7 @@ use crate::delegation::DelegationActionType;
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use crate::validation::ValidationStatus;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -32,7 +33,7 @@ use uuid::Uuid;
|
||||
pub struct RepoWriteAuth {
|
||||
pub did: Did,
|
||||
pub user_id: Uuid,
|
||||
pub is_oauth: bool,
|
||||
pub auth_source: AuthSource,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
@@ -66,7 +67,7 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
|
||||
Ok(RepoWriteAuth {
|
||||
did: principal_did.into_did(),
|
||||
user_id,
|
||||
is_oauth: user.is_oauth(),
|
||||
auth_source: user.auth_source.clone(),
|
||||
scope: user.scope.clone(),
|
||||
controller_did: scope_proof.controller_did().map(|c| c.into_did()),
|
||||
})
|
||||
@@ -97,7 +98,7 @@ pub struct CreateRecordOutput {
|
||||
pub cid: String,
|
||||
pub commit: CommitInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub validation_status: Option<String>,
|
||||
pub validation_status: Option<ValidationStatus>,
|
||||
}
|
||||
pub async fn create_record(
|
||||
State(state): State<AppState>,
|
||||
@@ -323,10 +324,7 @@ pub async fn create_record(
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) if e.contains("ConcurrentModification") => {
|
||||
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
|
||||
}
|
||||
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
|
||||
Err(e) => return Ok(ApiError::from(e).into_response()),
|
||||
};
|
||||
|
||||
for conflict_uri in conflict_uris_to_cleanup {
|
||||
@@ -375,7 +373,7 @@ pub async fn create_record(
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
},
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
validation_status,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
@@ -402,7 +400,7 @@ pub struct PutRecordOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<CommitInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub validation_status: Option<String>,
|
||||
pub validation_status: Option<ValidationStatus>,
|
||||
}
|
||||
pub async fn put_record(
|
||||
State(state): State<AppState>,
|
||||
@@ -494,7 +492,7 @@ pub async fn put_record(
|
||||
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: None,
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
validation_status,
|
||||
}),
|
||||
)
|
||||
.into_response());
|
||||
@@ -600,10 +598,7 @@ pub async fn put_record(
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) if e.contains("ConcurrentModification") => {
|
||||
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
|
||||
}
|
||||
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
|
||||
Err(e) => return Ok(ApiError::from(e).into_response()),
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -634,7 +629,7 @@ pub async fn put_record(
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
validation_status,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::cache::Cache;
|
||||
use crate::plc::PlcClient;
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -131,7 +130,7 @@ async fn assert_valid_did_document_for_service(
|
||||
did: &crate::types::Did,
|
||||
with_retry: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let expected_endpoint = format!("https://{}", hostname);
|
||||
|
||||
if did.as_str().starts_with("did:plc:") {
|
||||
@@ -201,7 +200,7 @@ async fn assert_valid_did_document_for_service(
|
||||
.await
|
||||
.map_err(ApiError::InvalidRequest)?;
|
||||
|
||||
let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok();
|
||||
let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone();
|
||||
if let Some(ref expected_rotation_key) = server_rotation_key {
|
||||
let rotation_keys = doc_data
|
||||
.get("rotationKeys")
|
||||
@@ -285,7 +284,7 @@ async fn assert_valid_did_document_for_service(
|
||||
arr.iter().find(|svc| {
|
||||
svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds")
|
||||
|| svc.get("type").and_then(|t| t.as_str())
|
||||
== Some("AtprotoPersonalDataServer")
|
||||
== Some(crate::plc::ServiceType::Pds.as_str())
|
||||
})
|
||||
})
|
||||
.and_then(|svc| svc.get("serviceEndpoint"))
|
||||
@@ -316,7 +315,7 @@ pub async fn activate_account(
|
||||
);
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Repo,
|
||||
crate::oauth::scopes::AccountAction::Manage,
|
||||
@@ -366,10 +365,16 @@ pub async fn activate_account(
|
||||
did
|
||||
);
|
||||
if let Some(ref h) = handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", h)).await;
|
||||
let _ = state.cache.delete(&crate::cache_keys::handle_key(h)).await;
|
||||
}
|
||||
let _ = state.cache.delete(&format!("plc:doc:{}", did)).await;
|
||||
let _ = state.cache.delete(&format!("plc:data:{}", did)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::plc_doc_key(&did))
|
||||
.await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::plc_data_key(&did))
|
||||
.await;
|
||||
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
|
||||
@@ -479,7 +484,7 @@ pub async fn deactivate_account(
|
||||
Json(input): Json<DeactivateAccountInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Repo,
|
||||
crate::oauth::scopes::AccountAction::Manage,
|
||||
@@ -502,7 +507,7 @@ pub async fn deactivate_account(
|
||||
match result {
|
||||
Ok(true) => {
|
||||
if let Some(ref h) = handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", h)).await;
|
||||
let _ = state.cache.delete(&crate::cache_keys::handle_key(h)).await;
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
@@ -546,7 +551,7 @@ pub async fn request_account_delete(
|
||||
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
|
||||
.await
|
||||
.log_db_err("creating deletion token")?;
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
@@ -659,7 +664,10 @@ pub async fn delete_account(
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&crate::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
info!("Account {} deleted successfully", did);
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
@@ -150,8 +150,9 @@ pub async fn create_app_password(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let privilege =
|
||||
tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false));
|
||||
let privilege = tranquil_db_traits::AppPasswordPrivilege::from_privileged_flag(
|
||||
input.privileged.unwrap_or(false),
|
||||
);
|
||||
let created_at = chrono::Utc::now();
|
||||
|
||||
let create_data = AppPasswordCreate {
|
||||
@@ -232,7 +233,7 @@ pub async fn revoke_app_password(
|
||||
.log_db_err("revoking sessions for app password")?;
|
||||
|
||||
futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
|
||||
let cache_key = format!("auth:session:{}:{}", &auth.did, jti);
|
||||
let cache_key = crate::cache_keys::session_key(&auth.did, jti);
|
||||
let cache = state.cache.clone();
|
||||
async move {
|
||||
let _ = cache.delete(&cache_key).await;
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
|
||||
use crate::auth::{Auth, NotTakendown};
|
||||
use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -21,7 +20,7 @@ use tranquil_db_traits::CommsChannel;
|
||||
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
fn email_update_cache_key(did: &str) -> String {
|
||||
format!("email_update:{}", did)
|
||||
crate::cache_keys::email_update_key(did)
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> String {
|
||||
@@ -51,7 +50,7 @@ pub async fn request_email_update(
|
||||
input: Option<Json<RequestEmailUpdateInput>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Email,
|
||||
crate::oauth::scopes::AccountAction::Manage,
|
||||
@@ -105,13 +104,12 @@ pub async fn request_email_update(
|
||||
}
|
||||
}
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_short_token_email(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user.id,
|
||||
&token,
|
||||
"email_update",
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
@@ -138,7 +136,7 @@ pub async fn confirm_email(
|
||||
Json(input): Json<ConfirmEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Email,
|
||||
crate::oauth::scopes::AccountAction::Manage,
|
||||
@@ -173,13 +171,13 @@ pub async fn confirm_email(
|
||||
|
||||
let verified = crate::auth::verification_token::verify_signup_token(
|
||||
&confirmation_code,
|
||||
"email",
|
||||
CommsChannel::Email,
|
||||
&provided_email,
|
||||
);
|
||||
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did.as_str() {
|
||||
if token_data.did != *did {
|
||||
return Err(ApiError::InvalidToken(None));
|
||||
}
|
||||
}
|
||||
@@ -216,7 +214,7 @@ pub async fn update_email(
|
||||
Json(input): Json<UpdateEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Email,
|
||||
crate::oauth::scopes::AccountAction::Manage,
|
||||
@@ -324,13 +322,13 @@ pub async fn update_email(
|
||||
|
||||
let verified = crate::auth::verification_token::verify_channel_update_token(
|
||||
&confirmation_token,
|
||||
"email_update",
|
||||
CommsChannel::Email,
|
||||
¤t_email_lower,
|
||||
);
|
||||
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did.as_str() {
|
||||
if token_data.did != *did {
|
||||
return Err(ApiError::InvalidToken(None));
|
||||
}
|
||||
}
|
||||
@@ -361,16 +359,19 @@ pub async fn update_email(
|
||||
.await
|
||||
.log_db_err("updating email")?;
|
||||
|
||||
let verification_token =
|
||||
crate::auth::verification_token::generate_signup_token(did, "email", &new_email);
|
||||
let verification_token = crate::auth::verification_token::generate_signup_token(
|
||||
did,
|
||||
CommsChannel::Email,
|
||||
&new_email,
|
||||
);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
"email",
|
||||
tranquil_db_traits::CommsChannel::Email,
|
||||
&new_email,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
@@ -422,8 +423,8 @@ pub async fn check_email_verified(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckChannelVerifiedInput {
|
||||
pub did: String,
|
||||
pub channel: String,
|
||||
pub did: crate::types::Did,
|
||||
pub channel: CommsChannel,
|
||||
}
|
||||
|
||||
pub async fn check_channel_verified(
|
||||
@@ -431,23 +432,9 @@ pub async fn check_channel_verified(
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckChannelVerifiedInput>,
|
||||
) -> Response {
|
||||
let channel = match input.channel.to_lowercase().as_str() {
|
||||
"email" => CommsChannel::Email,
|
||||
"discord" => CommsChannel::Discord,
|
||||
"telegram" => CommsChannel::Telegram,
|
||||
"signal" => CommsChannel::Signal,
|
||||
_ => {
|
||||
return ApiError::InvalidRequest("invalid channel".into()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let did = match crate::Did::new(input.did) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(),
|
||||
};
|
||||
match state
|
||||
.user_repo
|
||||
.check_channel_verified_by_did(&did, channel)
|
||||
.check_channel_verified_by_did(&input.did, input.channel)
|
||||
.await
|
||||
{
|
||||
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
|
||||
@@ -490,9 +477,9 @@ pub async fn authorize_email_update(
|
||||
);
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
if token_data.channel != "email_update" {
|
||||
if token_data.channel != CommsChannel::Email {
|
||||
warn!(
|
||||
"authorize_email_update: wrong channel: {}",
|
||||
"authorize_email_update: wrong channel: {:?}",
|
||||
token_data.channel
|
||||
);
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
@@ -543,7 +530,7 @@ pub async fn authorize_email_update(
|
||||
|
||||
info!(did = %did, "Email update authorized via link click");
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let redirect_url = format!(
|
||||
"https://{}/app/verify?type=email-authorize-success",
|
||||
hostname
|
||||
@@ -558,7 +545,7 @@ pub async fn check_email_update_status(
|
||||
auth: Auth<NotTakendown>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Email,
|
||||
crate::oauth::scopes::AccountAction::Read,
|
||||
@@ -620,7 +607,7 @@ pub async fn check_email_in_use(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckCommsChannelInUseInput {
|
||||
pub channel: String,
|
||||
pub channel: CommsChannel,
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
@@ -629,16 +616,6 @@ pub async fn check_comms_channel_in_use(
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckCommsChannelInUseInput>,
|
||||
) -> Response {
|
||||
let channel = match input.channel.to_lowercase().as_str() {
|
||||
"email" => CommsChannel::Email,
|
||||
"discord" => CommsChannel::Discord,
|
||||
"telegram" => CommsChannel::Telegram,
|
||||
"signal" => CommsChannel::Signal,
|
||||
_ => {
|
||||
return ApiError::InvalidRequest("invalid channel".into()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let identifier = input.identifier.trim();
|
||||
if identifier.is_empty() {
|
||||
return ApiError::InvalidRequest("identifier is required".into()).into_response();
|
||||
@@ -646,7 +623,7 @@ pub async fn check_comms_channel_in_use(
|
||||
|
||||
let count = match state
|
||||
.user_repo
|
||||
.count_accounts_by_comms_identifier(channel, identifier)
|
||||
.count_accounts_by_comms_identifier(input.channel, identifier)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::api::error::DbResultExt;
|
||||
use crate::auth::{Admin, Auth, NotTakendown};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -15,7 +14,7 @@ use tracing::error;
|
||||
|
||||
const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
fn gen_random_token() -> String {
|
||||
pub(crate) fn gen_random_token() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let gen_segment = |rng: &mut rand::rngs::ThreadRng, len: usize| -> String {
|
||||
(0..len)
|
||||
@@ -25,8 +24,8 @@ fn gen_random_token() -> String {
|
||||
format!("{}-{}", gen_segment(&mut rng, 5), gen_segment(&mut rng, 5))
|
||||
}
|
||||
|
||||
fn gen_invite_code() -> String {
|
||||
let hostname = pds_hostname();
|
||||
pub fn gen_invite_code() -> String {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let hostname_prefix = hostname.replace('.', "-");
|
||||
format!("{}-{}", hostname_prefix, gen_random_token())
|
||||
}
|
||||
@@ -226,7 +225,7 @@ pub async fn get_account_invite_codes(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let use_count = uses.len() as i32;
|
||||
let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX);
|
||||
if !include_used && use_count >= info.available_uses {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
let cid = unsafe { crate::types::CidLink::new_unchecked(&cid_str) };
|
||||
let cid = match crate::types::CidLink::new(&cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let metadata = match state.blob_repo.get_blob_metadata(&cid).await {
|
||||
Ok(Some(m)) => m,
|
||||
@@ -38,7 +41,7 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
|
||||
.header(header::CONTENT_TYPE, &metadata.mime_type)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
||||
.body(Body::from(data))
|
||||
.unwrap(),
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
Err(e) => {
|
||||
error!("Failed to fetch logo from storage: {:?}", e);
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
use crate::BUILD_VERSION;
|
||||
use crate::state::AppState;
|
||||
use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username};
|
||||
use crate::util::{discord_app_id, discord_bot_username, telegram_bot_username};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
|
||||
fn get_available_comms_channels() -> Vec<&'static str> {
|
||||
let mut channels = vec!["email"];
|
||||
if std::env::var("DISCORD_BOT_TOKEN").is_ok() {
|
||||
channels.push("discord");
|
||||
fn get_available_comms_channels() -> Vec<tranquil_db_traits::CommsChannel> {
|
||||
use tranquil_db_traits::CommsChannel;
|
||||
let cfg = tranquil_config::get();
|
||||
let mut channels = vec![CommsChannel::Email];
|
||||
if cfg.discord.bot_token.is_some() {
|
||||
channels.push(CommsChannel::Discord);
|
||||
}
|
||||
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
|
||||
channels.push("telegram");
|
||||
if cfg.telegram.bot_token.is_some() {
|
||||
channels.push(CommsChannel::Telegram);
|
||||
}
|
||||
if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() {
|
||||
channels.push("signal");
|
||||
if cfg.signal.sender_number.is_some() {
|
||||
channels.push(CommsChannel::Signal);
|
||||
}
|
||||
channels
|
||||
}
|
||||
@@ -25,22 +28,17 @@ pub async fn robots_txt() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
pub fn is_self_hosted_did_web_enabled() -> bool {
|
||||
std::env::var("ENABLE_SELF_HOSTED_DID_WEB")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true)
|
||||
tranquil_config::get().server.enable_pds_hosted_did_web
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> impl IntoResponse {
|
||||
let pds_hostname = pds_hostname();
|
||||
let domains_str =
|
||||
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string());
|
||||
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
|
||||
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok();
|
||||
let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok();
|
||||
let contact_email = std::env::var("CONTACT_EMAIL").ok();
|
||||
let cfg = tranquil_config::get();
|
||||
let pds_hostname = &cfg.server.hostname;
|
||||
let domains = cfg.server.user_handle_domain_list();
|
||||
let invite_code_required = cfg.server.invite_code_required;
|
||||
let privacy_policy = cfg.server.privacy_policy_url.clone();
|
||||
let terms_of_service = cfg.server.terms_of_service_url.clone();
|
||||
let contact_email = cfg.server.contact_email.clone();
|
||||
let mut links = serde_json::Map::new();
|
||||
if let Some(pp) = privacy_policy {
|
||||
links.insert("privacyPolicy".to_string(), json!(pp));
|
||||
@@ -58,7 +56,7 @@ pub async fn describe_server() -> impl IntoResponse {
|
||||
"did": format!("did:web:{}", pds_hostname),
|
||||
"links": links,
|
||||
"contact": contact,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"version": BUILD_VERSION,
|
||||
"availableCommsChannels": get_available_comms_channels(),
|
||||
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
|
||||
});
|
||||
@@ -75,7 +73,17 @@ pub async fn describe_server() -> impl IntoResponse {
|
||||
}
|
||||
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.infra_repo.health_check().await {
|
||||
Ok(true) => (StatusCode::OK, "OK"),
|
||||
_ => (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
|
||||
Ok(true) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"version": format!("tranquil {}", BUILD_VERSION)
|
||||
})),
|
||||
),
|
||||
_ => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "Service Unavailable"
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::api::ApiError;
|
||||
use crate::api::error::DbResultExt;
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -147,7 +146,7 @@ pub async fn get_did_document(
|
||||
}
|
||||
|
||||
async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value {
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
|
||||
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
|
||||
Ok(Some(row)) => row,
|
||||
@@ -196,7 +195,7 @@ async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_
|
||||
})).collect::<Vec<_>>(),
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"type": crate::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
});
|
||||
@@ -244,7 +243,7 @@ async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"type": crate::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
})
|
||||
|
||||
@@ -50,9 +50,9 @@ pub use reauth::{
|
||||
};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{
|
||||
confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session,
|
||||
list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session,
|
||||
update_legacy_login_preference, update_locale,
|
||||
auto_resend_verification, confirm_signup, create_session, delete_session,
|
||||
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
|
||||
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
pub use totp::{
|
||||
|
||||
@@ -16,14 +16,14 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_db_traits::WebauthnChallengeType;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
|
||||
use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use crate::types::{Did, Handle, PlainPassword};
|
||||
use crate::validation::validate_password;
|
||||
|
||||
fn generate_setup_token() -> String {
|
||||
@@ -49,7 +49,7 @@ pub struct CreatePasskeyAccountInput {
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub verification_channel: Option<String>,
|
||||
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
@@ -73,7 +73,7 @@ pub async fn create_passkey_account(
|
||||
Json(input): Json<CreatePasskeyAccountInput>,
|
||||
) -> Response {
|
||||
let byod_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header(
|
||||
crate::util::get_header_str(&headers, "Authorization"),
|
||||
crate::util::get_header_str(&headers, http::header::AUTHORIZATION),
|
||||
) {
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
@@ -112,21 +112,23 @@ pub async fn create_passkey_account(
|
||||
.map(|d| d.starts_with("did:web:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let pds_suffix = format!(".{}", hostname_for_handles);
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
|
||||
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
|
||||
input
|
||||
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
.strip_suffix(&pds_suffix)
|
||||
.unwrap_or(&input.handle)
|
||||
} else {
|
||||
&input.handle
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(&input.handle),
|
||||
None => &input.handle,
|
||||
};
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, hostname_for_handles),
|
||||
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(None).into_response();
|
||||
}
|
||||
@@ -146,28 +148,36 @@ pub async fn create_passkey_account(
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
}
|
||||
|
||||
let _validated_invite_code = if let Some(ref code) = input.invite_code {
|
||||
let is_bootstrap = state.bootstrap_invite_code.is_some()
|
||||
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
|
||||
|
||||
let _validated_invite_code = if is_bootstrap {
|
||||
match input.invite_code.as_deref() {
|
||||
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None,
|
||||
_ => return ApiError::InvalidInviteCode.into_response(),
|
||||
}
|
||||
} else if let Some(ref code) = input.invite_code {
|
||||
match state.infra_repo.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return ApiError::InvalidInviteCode.into_response(),
|
||||
}
|
||||
} else {
|
||||
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let invite_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_required {
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
|
||||
let verification_channel = input
|
||||
.verification_channel
|
||||
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
|
||||
let verification_recipient = match verification_channel {
|
||||
"email" => match &email {
|
||||
tranquil_db_traits::CommsChannel::Email => match &email {
|
||||
Some(e) if !e.is_empty() => e.clone(),
|
||||
_ => return ApiError::MissingEmail.into_response(),
|
||||
},
|
||||
"discord" => match &input.discord_username {
|
||||
tranquil_db_traits::CommsChannel::Discord => match &input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !crate::api::validation::is_valid_discord_username(&clean) {
|
||||
@@ -179,7 +189,7 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !crate::api::validation::is_valid_telegram_username(clean) {
|
||||
@@ -191,13 +201,12 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_username {
|
||||
tranquil_db_traits::CommsChannel::Signal => match &input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
username.trim().trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
_ => return ApiError::MissingSignalNumber.into_response(),
|
||||
},
|
||||
_ => return ApiError::InvalidVerificationChannel.into_response(),
|
||||
};
|
||||
|
||||
use k256::ecdsa::SigningKey;
|
||||
@@ -237,7 +246,8 @@ pub async fn create_passkey_account(
|
||||
|
||||
let did = match did_type {
|
||||
"web" => {
|
||||
let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles);
|
||||
let pds_hostname = tranquil_config::get().server.hostname_without_port();
|
||||
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
|
||||
info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
|
||||
@@ -277,7 +287,7 @@ pub async fn create_passkey_account(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
return ApiError::InvalidDid(e.to_string()).into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (reserved key)");
|
||||
}
|
||||
@@ -309,8 +319,11 @@ pub async fn create_passkey_account(
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&secret_key));
|
||||
|
||||
let genesis_result = match crate::plc::create_genesis_operation(
|
||||
&secret_key,
|
||||
@@ -380,7 +393,10 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let did_typed = unsafe { Did::new_unchecked(&did) };
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
|
||||
};
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) {
|
||||
Ok(result) => result,
|
||||
@@ -398,27 +414,24 @@ pub async fn create_passkey_account(
|
||||
};
|
||||
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
|
||||
|
||||
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
|
||||
json!({
|
||||
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
|
||||
Some(json!({
|
||||
"$type": "app.bsky.actor.defs#personalDetailsPref",
|
||||
"birthDate": "1998-05-06T00:00:00.000Z"
|
||||
})
|
||||
});
|
||||
|
||||
let preferred_comms_channel = match verification_channel {
|
||||
"email" => tranquil_db_traits::CommsChannel::Email,
|
||||
"discord" => tranquil_db_traits::CommsChannel::Discord,
|
||||
"telegram" => tranquil_db_traits::CommsChannel::Telegram,
|
||||
"signal" => tranquil_db_traits::CommsChannel::Signal,
|
||||
_ => tranquil_db_traits::CommsChannel::Email,
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
};
|
||||
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
|
||||
handle: handle_typed.clone(),
|
||||
email: email.clone().unwrap_or_default(),
|
||||
did: did_typed.clone(),
|
||||
preferred_comms_channel,
|
||||
preferred_comms_channel: verification_channel,
|
||||
discord_username: input
|
||||
.discord_username
|
||||
.as_deref()
|
||||
@@ -445,7 +458,11 @@ pub async fn create_passkey_account(
|
||||
commit_cid: commit_cid.to_string(),
|
||||
repo_rev: rev.as_ref().to_string(),
|
||||
genesis_block_cids,
|
||||
invite_code: input.invite_code.clone(),
|
||||
invite_code: if is_bootstrap {
|
||||
None
|
||||
} else {
|
||||
input.invite_code.clone()
|
||||
},
|
||||
birthdate_pref,
|
||||
};
|
||||
|
||||
@@ -487,13 +504,11 @@ pub async fn create_passkey_account(
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
|
||||
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did_typed,
|
||||
&profile_collection,
|
||||
&profile_rkey,
|
||||
&crate::types::PROFILE_COLLECTION,
|
||||
&crate::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
@@ -503,7 +518,7 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
|
||||
let verification_token = crate::auth::verification_token::generate_signup_token(
|
||||
&did,
|
||||
&did_typed,
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
);
|
||||
@@ -625,7 +640,7 @@ pub async fn complete_passkey_setup(
|
||||
|
||||
let reg_state = match state
|
||||
.user_repo
|
||||
.load_webauthn_challenge(&input.did, "registration")
|
||||
.load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
|
||||
.await
|
||||
{
|
||||
Ok(Some(json)) => match serde_json::from_str(&json) {
|
||||
@@ -706,7 +721,7 @@ pub async fn complete_passkey_setup(
|
||||
|
||||
let _ = state
|
||||
.user_repo
|
||||
.delete_webauthn_challenge(&input.did, "registration")
|
||||
.delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
|
||||
.await;
|
||||
|
||||
info!(did = %input.did, "Passkey-only account setup completed");
|
||||
@@ -793,7 +808,7 @@ pub async fn start_passkey_registration_for_setup(
|
||||
};
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.save_webauthn_challenge(&input.did, "registration", &state_json)
|
||||
.save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json)
|
||||
.await
|
||||
{
|
||||
error!("Failed to save registration state: {:?}", e);
|
||||
@@ -824,7 +839,7 @@ pub async fn request_passkey_recovery(
|
||||
_rate_limit: RateLimited<PasswordResetLimit>,
|
||||
Json(input): Json<RequestPasskeyRecoveryInput>,
|
||||
) -> Response {
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let identifier = input.email.trim().to_lowercase();
|
||||
let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
|
||||
let normalized_handle =
|
||||
@@ -859,7 +874,7 @@ pub async fn request_passkey_recovery(
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let recovery_url = format!(
|
||||
"https://{}/app/recover-passkey?did={}&token={}",
|
||||
hostname,
|
||||
@@ -946,6 +961,20 @@ pub async fn recover_passkey_account(
|
||||
if result.passkeys_deleted > 0 {
|
||||
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
|
||||
}
|
||||
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await {
|
||||
let actual_channel =
|
||||
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.set_channel_verified(&input.did, actual_channel)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to implicitly verify channel on passkey recovery: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
info!(did = %input.did, "Passkey-only account recovered with temporary password");
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_db_traits::WebauthnChallengeType;
|
||||
use webauthn_rs::prelude::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -64,7 +65,7 @@ pub async fn start_passkey_registration(
|
||||
|
||||
state
|
||||
.user_repo
|
||||
.save_webauthn_challenge(&auth.did, "registration", &state_json)
|
||||
.save_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration, &state_json)
|
||||
.await
|
||||
.log_db_err("saving registration state")?;
|
||||
|
||||
@@ -98,7 +99,7 @@ pub async fn finish_passkey_registration(
|
||||
|
||||
let reg_state_json = state
|
||||
.user_repo
|
||||
.load_webauthn_challenge(&auth.did, "registration")
|
||||
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
|
||||
.await
|
||||
.log_db_err("loading registration state")?
|
||||
.ok_or(ApiError::NoRegistrationInProgress)?;
|
||||
@@ -140,7 +141,7 @@ pub async fn finish_passkey_registration(
|
||||
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.delete_webauthn_challenge(&auth.did, "registration")
|
||||
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to delete registration state: {:?}", e);
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::auth::{
|
||||
use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -38,7 +37,7 @@ pub async fn request_password_reset(
|
||||
if identifier.is_empty() {
|
||||
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
|
||||
}
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized = identifier.to_lowercase();
|
||||
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
|
||||
let is_email_lookup = normalized.contains('@');
|
||||
@@ -78,7 +77,7 @@ pub async fn request_password_reset(
|
||||
error!("DB error setting reset code: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_password_reset(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
@@ -171,7 +170,7 @@ pub async fn reset_password(
|
||||
}
|
||||
};
|
||||
futures::future::join_all(result.session_jtis.iter().map(|jti| {
|
||||
let cache_key = format!("auth:session:{}:{}", result.did, jti);
|
||||
let cache_key = crate::cache_keys::session_key(&result.did, jti);
|
||||
let cache = state.cache.clone();
|
||||
async move {
|
||||
if let Err(e) = cache.delete(&cache_key).await {
|
||||
@@ -183,6 +182,20 @@ pub async fn reset_password(
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await {
|
||||
let actual_channel =
|
||||
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.set_channel_verified(&user.did, actual_channel)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to implicitly verify channel on password reset: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
info!("Password reset completed for user {}", user_id);
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_db_traits::{SessionRepository, UserRepository};
|
||||
use tranquil_db_traits::{SessionRepository, UserRepository, WebauthnChallengeType};
|
||||
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
|
||||
@@ -17,12 +17,20 @@ use crate::types::PlainPassword;
|
||||
|
||||
pub const REAUTH_WINDOW_SECONDS: i64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReauthMethod {
|
||||
Password,
|
||||
Totp,
|
||||
Passkey,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReauthStatusResponse {
|
||||
pub last_reauth_at: Option<DateTime<Utc>>,
|
||||
pub reauth_required: bool,
|
||||
pub available_methods: Vec<String>,
|
||||
pub available_methods: Vec<ReauthMethod>,
|
||||
}
|
||||
|
||||
pub async fn get_reauth_status(
|
||||
@@ -180,7 +188,11 @@ pub async fn reauth_passkey_start(
|
||||
|
||||
state
|
||||
.user_repo
|
||||
.save_webauthn_challenge(&auth.did, "authentication", &state_json)
|
||||
.save_webauthn_challenge(
|
||||
&auth.did,
|
||||
WebauthnChallengeType::Authentication,
|
||||
&state_json,
|
||||
)
|
||||
.await
|
||||
.log_db_err("saving authentication state")?;
|
||||
|
||||
@@ -201,7 +213,7 @@ pub async fn reauth_passkey_finish(
|
||||
) -> Result<Response, ApiError> {
|
||||
let auth_state_json = state
|
||||
.user_repo
|
||||
.load_webauthn_challenge(&auth.did, "authentication")
|
||||
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
|
||||
.await
|
||||
.log_db_err("loading authentication state")?
|
||||
.ok_or(ApiError::NoChallengeInProgress)?;
|
||||
@@ -229,14 +241,17 @@ pub async fn reauth_passkey_finish(
|
||||
let cred_id_bytes = auth_result.cred_id().as_ref();
|
||||
match state
|
||||
.user_repo
|
||||
.update_passkey_counter(cred_id_bytes, auth_result.counter() as i32)
|
||||
.update_passkey_counter(
|
||||
cred_id_bytes,
|
||||
i32::try_from(auth_result.counter()).unwrap_or(i32::MAX),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(false) => {
|
||||
warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key");
|
||||
let _ = state
|
||||
.user_repo
|
||||
.delete_webauthn_challenge(&auth.did, "authentication")
|
||||
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
|
||||
.await;
|
||||
return Err(ApiError::PasskeyCounterAnomaly);
|
||||
}
|
||||
@@ -248,7 +263,7 @@ pub async fn reauth_passkey_finish(
|
||||
|
||||
let _ = state
|
||||
.user_repo
|
||||
.delete_webauthn_challenge(&auth.did, "authentication")
|
||||
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
|
||||
.await;
|
||||
|
||||
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
|
||||
@@ -265,12 +280,12 @@ pub async fn update_last_reauth_cached(
|
||||
did: &crate::types::Did,
|
||||
) -> Result<DateTime<Utc>, tranquil_db_traits::DbError> {
|
||||
let now = session_repo.update_last_reauth(did).await?;
|
||||
let cache_key = format!("reauth:{}", did);
|
||||
let cache_key = crate::cache_keys::reauth_key(did);
|
||||
let _ = cache
|
||||
.set(
|
||||
&cache_key,
|
||||
&now.timestamp().to_string(),
|
||||
std::time::Duration::from_secs(REAUTH_WINDOW_SECONDS as u64),
|
||||
std::time::Duration::from_secs(u64::try_from(REAUTH_WINDOW_SECONDS).unwrap_or(300)),
|
||||
)
|
||||
.await;
|
||||
Ok(now)
|
||||
@@ -290,7 +305,7 @@ async fn get_available_reauth_methods(
|
||||
user_repo: &dyn UserRepository,
|
||||
_session_repo: &dyn SessionRepository,
|
||||
did: &crate::types::Did,
|
||||
) -> Vec<String> {
|
||||
) -> Vec<ReauthMethod> {
|
||||
let mut methods = Vec::new();
|
||||
|
||||
let has_password = user_repo
|
||||
@@ -301,17 +316,17 @@ async fn get_available_reauth_methods(
|
||||
.is_some();
|
||||
|
||||
if has_password {
|
||||
methods.push("password".to_string());
|
||||
methods.push(ReauthMethod::Password);
|
||||
}
|
||||
|
||||
let has_totp = user_repo.has_totp_enabled(did).await.unwrap_or(false);
|
||||
if has_totp {
|
||||
methods.push("totp".to_string());
|
||||
methods.push(ReauthMethod::Totp);
|
||||
}
|
||||
|
||||
let has_passkeys = user_repo.has_passkeys(did).await.unwrap_or(false);
|
||||
if has_passkeys {
|
||||
methods.push("passkey".to_string());
|
||||
methods.push(ReauthMethod::Passkey);
|
||||
}
|
||||
|
||||
methods
|
||||
@@ -332,7 +347,7 @@ pub async fn check_reauth_required_cached(
|
||||
cache: &std::sync::Arc<dyn crate::cache::Cache>,
|
||||
did: &crate::types::Did,
|
||||
) -> bool {
|
||||
let cache_key = format!("reauth:{}", did);
|
||||
let cache_key = crate::cache_keys::reauth_key(did);
|
||||
if let Some(timestamp_str) = cache.get(&cache_key).await
|
||||
&& let Ok(timestamp) = timestamp_str.parse::<i64>()
|
||||
{
|
||||
@@ -355,7 +370,7 @@ pub async fn check_reauth_required_cached(
|
||||
pub struct ReauthRequiredError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
pub reauth_methods: Vec<String>,
|
||||
pub reauth_methods: Vec<ReauthMethod>,
|
||||
}
|
||||
|
||||
pub async fn reauth_required_response(
|
||||
@@ -428,5 +443,5 @@ pub async fn legacy_mfa_required_response(
|
||||
pub struct MfaVerificationRequiredError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
pub reauth_methods: Vec<String>,
|
||||
pub reauth_methods: Vec<ReauthMethod>,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::AccountStatus;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use axum::http::Method;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -10,34 +11,44 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
|
||||
LazyLock::new(|| "com.atproto.server.createAccount".parse().unwrap());
|
||||
|
||||
const HOUR_SECS: i64 = 3600;
|
||||
const MINUTE_SECS: i64 = 60;
|
||||
|
||||
const PROTECTED_METHODS: &[&str] = &[
|
||||
"com.atproto.admin.sendEmail",
|
||||
"com.atproto.identity.requestPlcOperationSignature",
|
||||
"com.atproto.identity.signPlcOperation",
|
||||
"com.atproto.identity.updateHandle",
|
||||
"com.atproto.server.activateAccount",
|
||||
"com.atproto.server.confirmEmail",
|
||||
"com.atproto.server.createAppPassword",
|
||||
"com.atproto.server.deactivateAccount",
|
||||
"com.atproto.server.getAccountInviteCodes",
|
||||
"com.atproto.server.getSession",
|
||||
"com.atproto.server.listAppPasswords",
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
"com.atproto.server.requestEmailConfirmation",
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
"com.atproto.server.revokeAppPassword",
|
||||
"com.atproto.server.updateEmail",
|
||||
];
|
||||
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
[
|
||||
"com.atproto.admin.sendEmail",
|
||||
"com.atproto.identity.requestPlcOperationSignature",
|
||||
"com.atproto.identity.signPlcOperation",
|
||||
"com.atproto.identity.updateHandle",
|
||||
"com.atproto.server.activateAccount",
|
||||
"com.atproto.server.confirmEmail",
|
||||
"com.atproto.server.createAppPassword",
|
||||
"com.atproto.server.deactivateAccount",
|
||||
"com.atproto.server.getAccountInviteCodes",
|
||||
"com.atproto.server.getSession",
|
||||
"com.atproto.server.listAppPasswords",
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
"com.atproto.server.requestEmailConfirmation",
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
"com.atproto.server.revokeAppPassword",
|
||||
"com.atproto.server.updateEmail",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
});
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetServiceAuthParams {
|
||||
pub aud: String,
|
||||
pub lxm: Option<String>,
|
||||
pub aud: Did,
|
||||
pub lxm: Option<Nsid>,
|
||||
pub exp: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -51,8 +62,8 @@ pub async fn get_service_auth(
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<GetServiceAuthParams>,
|
||||
) -> Response {
|
||||
let auth_header = crate::util::get_header_str(&headers, "Authorization");
|
||||
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
|
||||
let auth_header = crate::util::get_header_str(&headers, axum::http::header::AUTHORIZATION);
|
||||
let dpop_proof = crate::util::get_header_str(&headers, crate::util::HEADER_DPOP);
|
||||
info!(
|
||||
has_auth_header = auth_header.is_some(),
|
||||
has_dpop_proof = dpop_proof.is_some(),
|
||||
@@ -68,40 +79,47 @@ pub async fn get_service_auth(
|
||||
}
|
||||
};
|
||||
|
||||
let (token, is_dpop) = if auth_header.len() >= 7
|
||||
&& auth_header[..7].eq_ignore_ascii_case("bearer ")
|
||||
{
|
||||
(auth_header[7..].trim().to_string(), false)
|
||||
} else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") {
|
||||
(auth_header[5..].trim().to_string(), true)
|
||||
} else {
|
||||
warn!(auth_scheme = ?auth_header.split_whitespace().next(), "getServiceAuth: invalid auth scheme");
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(Some(auth_header)) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
warn!(auth_scheme = ?auth_header.split_whitespace().next(), "getServiceAuth: invalid auth scheme");
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
}
|
||||
};
|
||||
let token = extracted.token;
|
||||
|
||||
let auth_user = if is_dpop {
|
||||
let auth_user = if extracted.scheme.is_dpop() {
|
||||
match crate::oauth::verify::verify_oauth_access_token(
|
||||
state.oauth_repo.as_ref(),
|
||||
&token,
|
||||
dpop_proof,
|
||||
"GET",
|
||||
Method::GET.as_str(),
|
||||
&crate::util::build_full_url(&format!(
|
||||
"/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
|
||||
params.aud,
|
||||
params.lxm.as_deref().unwrap_or("")
|
||||
params.lxm.as_ref().map_or("", |n| n.as_str())
|
||||
)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => crate::auth::AuthenticatedUser {
|
||||
did: unsafe { Did::new_unchecked(result.did) },
|
||||
is_admin: false,
|
||||
status: AccountStatus::Active,
|
||||
scope: result.scope,
|
||||
key_bytes: None,
|
||||
controller_did: None,
|
||||
auth_source: crate::auth::AuthSource::OAuth,
|
||||
},
|
||||
Ok(result) => {
|
||||
let did: Did = match result.did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(Some("Invalid DID in token".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
crate::auth::AuthenticatedUser {
|
||||
did,
|
||||
is_admin: false,
|
||||
status: AccountStatus::Active,
|
||||
scope: result.scope,
|
||||
key_bytes: None,
|
||||
controller_did: None,
|
||||
auth_source: crate::auth::AuthSource::OAuth,
|
||||
}
|
||||
}
|
||||
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -179,15 +197,15 @@ pub async fn get_service_auth(
|
||||
}
|
||||
};
|
||||
|
||||
let lxm = params.lxm.as_deref();
|
||||
let lxm_for_token = lxm.unwrap_or("*");
|
||||
let lxm = params.lxm.as_ref();
|
||||
let lxm_for_token = lxm.map_or("*", |n| n.as_str());
|
||||
|
||||
if let Some(method) = lxm {
|
||||
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
|
||||
auth_user.is_oauth(),
|
||||
&auth_user.auth_source,
|
||||
auth_user.scope.as_deref(),
|
||||
¶ms.aud,
|
||||
method,
|
||||
params.aud.as_str(),
|
||||
method.as_str(),
|
||||
) {
|
||||
return e;
|
||||
}
|
||||
@@ -209,12 +227,12 @@ pub async fn get_service_auth(
|
||||
.flatten()
|
||||
.is_some_and(|s| s.takedown_ref.is_some());
|
||||
|
||||
if is_takendown && lxm != Some("com.atproto.server.createAccount") {
|
||||
if is_takendown && lxm != Some(&*CREATE_ACCOUNT_NSID) {
|
||||
return ApiError::InvalidToken(Some("Bad token scope".into())).into_response();
|
||||
}
|
||||
|
||||
if let Some(method) = lxm
|
||||
&& PROTECTED_METHODS.contains(&method)
|
||||
&& PROTECTED_METHODS.contains(&method.as_str())
|
||||
{
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"cannot request a service auth token for the following protected method: {}",
|
||||
@@ -248,7 +266,7 @@ pub async fn get_service_auth(
|
||||
|
||||
let service_token = match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
¶ms.aud,
|
||||
params.aud.as_str(),
|
||||
lxm_for_token,
|
||||
&key_bytes,
|
||||
) {
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::auth::{
|
||||
use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AccountState, Did, Handle, PlainPassword};
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -66,10 +65,10 @@ pub async fn create_session(
|
||||
"create_session called with identifier: {}",
|
||||
input.identifier
|
||||
);
|
||||
let pds_host = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let pds_host = &tranquil_config::get().server.hostname;
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_identifier =
|
||||
NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles);
|
||||
NormalizedLoginIdentifier::normalize(&input.identifier, &hostname_for_handles);
|
||||
info!(
|
||||
"Normalized identifier: {} -> {}",
|
||||
input.identifier, normalized_identifier
|
||||
@@ -150,12 +149,23 @@ pub async fn create_session(
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
warn!("Login attempt for unverified account: {}", row.did);
|
||||
let resend_info = auto_resend_verification(&state, &row.did).await;
|
||||
let handle = resend_info
|
||||
.as_ref()
|
||||
.map(|r| r.handle.to_string())
|
||||
.unwrap_or_else(|| row.handle.to_string());
|
||||
let channel = resend_info
|
||||
.as_ref()
|
||||
.map(|r| r.channel.as_str())
|
||||
.unwrap_or(row.preferred_comms_channel.as_str());
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"error": "account_not_verified",
|
||||
"message": "Please verify your account before logging in",
|
||||
"did": row.did
|
||||
"did": row.did,
|
||||
"handle": handle,
|
||||
"channel": channel
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -182,7 +192,7 @@ pub async fn create_session(
|
||||
return ApiError::LegacyLoginBlocked.into_response();
|
||||
}
|
||||
Ok(crate::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_2fa_code(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
@@ -266,7 +276,7 @@ pub async fn create_session(
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::from(is_legacy_login),
|
||||
login_type: tranquil_db_traits::LoginType::from_legacy_flag(is_legacy_login),
|
||||
mfa_verified: false,
|
||||
scope: app_password_scopes.clone(),
|
||||
controller_did: app_password_controller.clone(),
|
||||
@@ -286,7 +296,7 @@ pub async fn create_session(
|
||||
ip = %client_ip,
|
||||
"Legacy login on TOTP-enabled account - sending notification"
|
||||
);
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
@@ -338,16 +348,10 @@ pub async fn get_session(
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(row)) => {
|
||||
let preferred_channel = match row.preferred_comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => "email",
|
||||
tranquil_db_traits::CommsChannel::Discord => "discord",
|
||||
tranquil_db_traits::CommsChannel::Telegram => "telegram",
|
||||
tranquil_db_traits::CommsChannel::Signal => "signal",
|
||||
};
|
||||
let preferred_channel_verified = row
|
||||
.channel_verification
|
||||
.is_verified(row.preferred_comms_channel);
|
||||
let pds_hostname = pds_hostname();
|
||||
let pds_hostname = &tranquil_config::get().server.hostname;
|
||||
let handle = full_handle(&row.handle, pds_hostname);
|
||||
let account_state = AccountState::from_db_fields(
|
||||
row.deactivated_at,
|
||||
@@ -365,7 +369,7 @@ pub async fn get_session(
|
||||
"handle": handle,
|
||||
"did": &auth.did,
|
||||
"active": account_state.is_active(),
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannel": row.preferred_comms_channel.as_str(),
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"preferredLocale": row.preferred_locale,
|
||||
"isAdmin": row.is_admin
|
||||
@@ -404,7 +408,7 @@ pub async fn delete_session(
|
||||
) -> Result<Response, ApiError> {
|
||||
let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
|
||||
&headers,
|
||||
"Authorization",
|
||||
http::header::AUTHORIZATION,
|
||||
))
|
||||
.ok_or(ApiError::AuthenticationRequired)?;
|
||||
let jti = crate::auth::get_jti_from_token(&extracted.token)
|
||||
@@ -413,7 +417,7 @@ pub async fn delete_session(
|
||||
match state.session_repo.delete_session_by_access_jti(&jti).await {
|
||||
Ok(rows) if rows > 0 => {
|
||||
if let Some(did) = did {
|
||||
let session_cache_key = format!("auth:session:{}:{}", did, jti);
|
||||
let session_cache_key = crate::cache_keys::session_key(&did, &jti);
|
||||
let _ = state.cache.delete(&session_cache_key).await;
|
||||
}
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
@@ -430,7 +434,7 @@ pub async fn refresh_session(
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
|
||||
&headers,
|
||||
"Authorization",
|
||||
http::header::AUTHORIZATION,
|
||||
)) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
@@ -548,16 +552,10 @@ pub async fn refresh_session(
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(u)) => {
|
||||
let preferred_channel = match u.preferred_comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => "email",
|
||||
tranquil_db_traits::CommsChannel::Discord => "discord",
|
||||
tranquil_db_traits::CommsChannel::Telegram => "telegram",
|
||||
tranquil_db_traits::CommsChannel::Signal => "signal",
|
||||
};
|
||||
let preferred_channel_verified = u
|
||||
.channel_verification
|
||||
.is_verified(u.preferred_comms_channel);
|
||||
let pds_hostname = pds_hostname();
|
||||
let pds_hostname = &tranquil_config::get().server.hostname;
|
||||
let handle = full_handle(&u.handle, pds_hostname);
|
||||
let account_state =
|
||||
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
|
||||
@@ -568,7 +566,7 @@ pub async fn refresh_session(
|
||||
"did": session_row.did,
|
||||
"email": u.email,
|
||||
"emailConfirmed": u.channel_verification.email,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannel": u.preferred_comms_channel.as_str(),
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"preferredLocale": u.preferred_locale,
|
||||
"isAdmin": u.is_admin,
|
||||
@@ -609,7 +607,7 @@ pub struct ConfirmSignupOutput {
|
||||
pub did: Did,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub preferred_channel: String,
|
||||
pub preferred_channel: tranquil_db_traits::CommsChannel,
|
||||
pub preferred_channel_verified: bool,
|
||||
}
|
||||
|
||||
@@ -631,29 +629,26 @@ pub async fn confirm_signup(
|
||||
}
|
||||
};
|
||||
|
||||
let (channel_str, identifier) = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
let identifier = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
|
||||
tranquil_db_traits::CommsChannel::Discord => {
|
||||
("discord", row.discord_username.clone().unwrap_or_default())
|
||||
row.discord_username.clone().unwrap_or_default()
|
||||
}
|
||||
tranquil_db_traits::CommsChannel::Telegram => (
|
||||
"telegram",
|
||||
row.telegram_username.clone().unwrap_or_default(),
|
||||
),
|
||||
tranquil_db_traits::CommsChannel::Signal => {
|
||||
("signal", row.signal_username.clone().unwrap_or_default())
|
||||
tranquil_db_traits::CommsChannel::Telegram => {
|
||||
row.telegram_username.clone().unwrap_or_default()
|
||||
}
|
||||
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
let normalized_token =
|
||||
crate::auth::verification_token::normalize_token_input(&input.verification_code);
|
||||
match crate::auth::verification_token::verify_signup_token(
|
||||
&normalized_token,
|
||||
channel_str,
|
||||
row.channel,
|
||||
&identifier,
|
||||
) {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != input.did.as_str() {
|
||||
if token_data.did != input.did {
|
||||
warn!(
|
||||
"Token DID mismatch for confirm_signup: expected {}, got {}",
|
||||
input.did, token_data.did
|
||||
@@ -722,7 +717,7 @@ pub async fn confirm_signup(
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_welcome(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
@@ -733,26 +728,92 @@ pub async fn confirm_signup(
|
||||
{
|
||||
warn!("Failed to enqueue welcome notification: {:?}", e);
|
||||
}
|
||||
let email_verified = matches!(row.channel, tranquil_db_traits::CommsChannel::Email);
|
||||
let preferred_channel = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => "email",
|
||||
tranquil_db_traits::CommsChannel::Discord => "discord",
|
||||
tranquil_db_traits::CommsChannel::Telegram => "telegram",
|
||||
tranquil_db_traits::CommsChannel::Signal => "signal",
|
||||
};
|
||||
Json(ConfirmSignupOutput {
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle: row.handle,
|
||||
did: row.did,
|
||||
email: row.email,
|
||||
email_verified,
|
||||
preferred_channel: preferred_channel.to_string(),
|
||||
email_verified: matches!(row.channel, tranquil_db_traits::CommsChannel::Email),
|
||||
preferred_channel: row.channel,
|
||||
preferred_channel_verified: true,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
pub struct AutoResendResult {
|
||||
pub handle: tranquil_types::Handle,
|
||||
pub channel: tranquil_db_traits::CommsChannel,
|
||||
}
|
||||
|
||||
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
|
||||
let debounce_key = crate::cache_keys::auto_verify_sent_key(did.as_str());
|
||||
let debounced = state.cache.get(&debounce_key).await.is_some();
|
||||
let row = match state.user_repo.get_resend_verification_by_did(did).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return None,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to fetch resend verification info for {}: {:?}",
|
||||
did, e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if row.channel_verification.has_any_verified() {
|
||||
return None;
|
||||
}
|
||||
let result = AutoResendResult {
|
||||
handle: row.handle.clone(),
|
||||
channel: row.channel,
|
||||
};
|
||||
let is_bot_channel = matches!(
|
||||
row.channel,
|
||||
tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord
|
||||
);
|
||||
if is_bot_channel || debounced {
|
||||
return Some(result);
|
||||
}
|
||||
let recipient = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
|
||||
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
|
||||
_ => return Some(result),
|
||||
};
|
||||
if recipient.is_empty() {
|
||||
warn!(
|
||||
"No recipient configured for auto-resend verification: {}",
|
||||
did
|
||||
);
|
||||
return Some(result);
|
||||
}
|
||||
let verification_token =
|
||||
crate::auth::verification_token::generate_signup_token(did, row.channel, &recipient);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
row.id,
|
||||
row.channel,
|
||||
&recipient,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to auto-resend verification for {}: {:?}", did, e);
|
||||
return Some(result);
|
||||
}
|
||||
let _ = state
|
||||
.cache
|
||||
.set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE)
|
||||
.await;
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResendVerificationInput {
|
||||
@@ -783,31 +844,28 @@ pub async fn resend_verification(
|
||||
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
|
||||
}
|
||||
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
let recipient = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
|
||||
tranquil_db_traits::CommsChannel::Discord => {
|
||||
("discord", row.discord_username.clone().unwrap_or_default())
|
||||
row.discord_username.clone().unwrap_or_default()
|
||||
}
|
||||
tranquil_db_traits::CommsChannel::Telegram => (
|
||||
"telegram",
|
||||
row.telegram_username.clone().unwrap_or_default(),
|
||||
),
|
||||
tranquil_db_traits::CommsChannel::Signal => {
|
||||
("signal", row.signal_username.clone().unwrap_or_default())
|
||||
tranquil_db_traits::CommsChannel::Telegram => {
|
||||
row.telegram_username.clone().unwrap_or_default()
|
||||
}
|
||||
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
let verification_token =
|
||||
crate::auth::verification_token::generate_signup_token(&input.did, channel_str, &recipient);
|
||||
crate::auth::verification_token::generate_signup_token(&input.did, row.channel, &recipient);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
row.id,
|
||||
channel_str,
|
||||
row.channel,
|
||||
&recipient,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
@@ -819,11 +877,18 @@ pub async fn resend_verification(
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SessionType {
|
||||
Legacy,
|
||||
OAuth,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub session_type: String,
|
||||
pub session_type: SessionType,
|
||||
pub client_name: Option<String>,
|
||||
pub created_at: String,
|
||||
pub expires_at: String,
|
||||
@@ -861,7 +926,7 @@ pub async fn list_sessions(
|
||||
|
||||
let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo {
|
||||
id: format!("jwt:{}", row.id),
|
||||
session_type: "legacy".to_string(),
|
||||
session_type: SessionType::Legacy,
|
||||
client_name: None,
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
expires_at: row.refresh_expires_at.to_rfc3339(),
|
||||
@@ -874,7 +939,7 @@ pub async fn list_sessions(
|
||||
let is_current_oauth = is_oauth && current_jti.as_deref() == Some(row.token_id.as_str());
|
||||
SessionInfo {
|
||||
id: format!("oauth:{}", row.id),
|
||||
session_type: "oauth".to_string(),
|
||||
session_type: SessionType::OAuth,
|
||||
client_name: Some(client_name),
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
expires_at: row.expires_at.to_rfc3339(),
|
||||
@@ -925,7 +990,7 @@ pub async fn revoke_session(
|
||||
.delete_session_by_id(session_id)
|
||||
.await
|
||||
.log_db_err("deleting session")?;
|
||||
let cache_key = format!("auth:session:{}:{}", &auth.did, access_jti);
|
||||
let cache_key = crate::cache_keys::session_key(&auth.did, &access_jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache: {:?}", e);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> String {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReserveSigningKeyInput {
|
||||
pub did: Option<String>,
|
||||
pub did: Option<crate::types::Did>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -38,13 +38,6 @@ pub async fn reserve_signing_key(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ReserveSigningKeyInput>,
|
||||
) -> Response {
|
||||
let did: Option<crate::types::Did> = match input.did {
|
||||
Some(ref d) => match d.parse() {
|
||||
Ok(parsed) => Some(parsed),
|
||||
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
||||
let private_key_bytes = signing_key.to_bytes();
|
||||
let public_key_did_key = public_key_to_did_key(&signing_key);
|
||||
@@ -52,7 +45,12 @@ pub async fn reserve_signing_key(
|
||||
let private_bytes: &[u8] = &private_key_bytes;
|
||||
match state
|
||||
.infra_repo
|
||||
.reserve_signing_key(did.as_ref(), &public_key_did_key, private_bytes, expires_at)
|
||||
.reserve_signing_key(
|
||||
input.did.as_ref(),
|
||||
&public_key_did_key,
|
||||
private_bytes,
|
||||
expires_at,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(key_id) => {
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::auth::{
|
||||
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -52,7 +51,7 @@ pub async fn create_totp_secret(
|
||||
.log_db_err("fetching handle")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let uri = generate_totp_uri(&secret, &handle, hostname);
|
||||
|
||||
let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| {
|
||||
|
||||
@@ -103,7 +103,7 @@ pub async fn list_trusted_devices(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RevokeTrustedDeviceInput {
|
||||
pub device_id: String,
|
||||
pub device_id: DeviceId,
|
||||
}
|
||||
|
||||
pub async fn revoke_trusted_device(
|
||||
@@ -111,10 +111,9 @@ pub async fn revoke_trusted_device(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<RevokeTrustedDeviceInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let device_id = DeviceId::from(input.device_id.clone());
|
||||
match state
|
||||
.oauth_repo
|
||||
.device_belongs_to_user(&device_id, &auth.did)
|
||||
.device_belongs_to_user(&input.device_id, &auth.did)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
@@ -129,7 +128,7 @@ pub async fn revoke_trusted_device(
|
||||
|
||||
state
|
||||
.oauth_repo
|
||||
.revoke_device_trust(&device_id)
|
||||
.revoke_device_trust(&input.device_id)
|
||||
.await
|
||||
.log_db_err("revoking device trust")?;
|
||||
|
||||
@@ -140,7 +139,7 @@ pub async fn revoke_trusted_device(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateTrustedDeviceInput {
|
||||
pub device_id: String,
|
||||
pub device_id: DeviceId,
|
||||
pub friendly_name: Option<String>,
|
||||
}
|
||||
|
||||
@@ -149,10 +148,9 @@ pub async fn update_trusted_device(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<UpdateTrustedDeviceInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let device_id = DeviceId::from(input.device_id.clone());
|
||||
match state
|
||||
.oauth_repo
|
||||
.device_belongs_to_user(&device_id, &auth.did)
|
||||
.device_belongs_to_user(&input.device_id, &auth.did)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
@@ -167,7 +165,7 @@ pub async fn update_trusted_device(
|
||||
|
||||
state
|
||||
.oauth_repo
|
||||
.update_device_friendly_name(&device_id, input.friendly_name.as_deref())
|
||||
.update_device_friendly_name(&input.device_id, input.friendly_name.as_deref())
|
||||
.await
|
||||
.log_db_err("updating device friendly name")?;
|
||||
|
||||
@@ -177,14 +175,10 @@ pub async fn update_trusted_device(
|
||||
|
||||
pub async fn get_device_trust_state(
|
||||
oauth_repo: &dyn OAuthRepository,
|
||||
device_id: &str,
|
||||
device_id: &DeviceId,
|
||||
did: &tranquil_types::Did,
|
||||
) -> DeviceTrustState {
|
||||
let device_id_typed = DeviceId::from(device_id.to_string());
|
||||
match oauth_repo
|
||||
.get_device_trust_info(&device_id_typed, did)
|
||||
.await
|
||||
{
|
||||
match oauth_repo.get_device_trust_info(device_id, did).await {
|
||||
Ok(Some(info)) => DeviceTrustState::from_timestamps(info.trusted_at, info.trusted_until),
|
||||
_ => DeviceTrustState::Untrusted,
|
||||
}
|
||||
@@ -192,7 +186,7 @@ pub async fn get_device_trust_state(
|
||||
|
||||
pub async fn is_device_trusted(
|
||||
oauth_repo: &dyn OAuthRepository,
|
||||
device_id: &str,
|
||||
device_id: &DeviceId,
|
||||
did: &tranquil_types::Did,
|
||||
) -> bool {
|
||||
get_device_trust_state(oauth_repo, device_id, did)
|
||||
@@ -202,23 +196,19 @@ pub async fn is_device_trusted(
|
||||
|
||||
pub async fn trust_device(
|
||||
oauth_repo: &dyn OAuthRepository,
|
||||
device_id: &str,
|
||||
device_id: &DeviceId,
|
||||
) -> Result<(), tranquil_db_traits::DbError> {
|
||||
let now = Utc::now();
|
||||
let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
|
||||
let device_id_typed = DeviceId::from(device_id.to_string());
|
||||
oauth_repo
|
||||
.trust_device(&device_id_typed, now, trusted_until)
|
||||
.await
|
||||
oauth_repo.trust_device(device_id, now, trusted_until).await
|
||||
}
|
||||
|
||||
pub async fn extend_device_trust(
|
||||
oauth_repo: &dyn OAuthRepository,
|
||||
device_id: &str,
|
||||
device_id: &DeviceId,
|
||||
) -> Result<(), tranquil_db_traits::DbError> {
|
||||
let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS);
|
||||
let device_id_typed = DeviceId::from(device_id.to_string());
|
||||
oauth_repo
|
||||
.extend_device_trust(&device_id_typed, trusted_until)
|
||||
.extend_device_trust(device_id, trusted_until)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -71,7 +70,7 @@ pub async fn resend_migration_verification(
|
||||
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
||||
}
|
||||
|
||||
let hostname = pds_hostname();
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::comms::comms_repo;
|
||||
use crate::types::Did;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
@@ -10,6 +9,7 @@ use crate::auth::verification_token::{
|
||||
VerificationPurpose, normalize_token_input, verify_token_signature,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use tranquil_db_traits::CommsChannel;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -23,8 +23,8 @@ pub struct VerifyTokenInput {
|
||||
pub struct VerifyTokenOutput {
|
||||
pub success: bool,
|
||||
pub did: Did,
|
||||
pub purpose: String,
|
||||
pub channel: String,
|
||||
pub purpose: VerificationPurpose,
|
||||
pub channel: CommsChannel,
|
||||
}
|
||||
|
||||
pub async fn verify_token(
|
||||
@@ -53,14 +53,14 @@ pub async fn verify_token_internal(
|
||||
|
||||
match token_data.purpose {
|
||||
VerificationPurpose::Migration => {
|
||||
handle_migration_verification(state, &token_data.did, &token_data.channel, &identifier)
|
||||
handle_migration_verification(state, &token_data.did, token_data.channel, &identifier)
|
||||
.await
|
||||
}
|
||||
VerificationPurpose::ChannelUpdate => {
|
||||
handle_channel_update(state, &token_data.did, &token_data.channel, &identifier).await
|
||||
handle_channel_update(state, &token_data.did, token_data.channel, &identifier).await
|
||||
}
|
||||
VerificationPurpose::Signup => {
|
||||
handle_signup_verification(state, &token_data.did, &token_data.channel, &identifier)
|
||||
handle_signup_verification(state, &token_data.did, token_data.channel, &identifier)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -68,20 +68,17 @@ pub async fn verify_token_internal(
|
||||
|
||||
async fn handle_migration_verification(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
if channel != "email" {
|
||||
if channel != CommsChannel::Email {
|
||||
return Err(ApiError::InvalidChannel);
|
||||
}
|
||||
|
||||
let did_typed: Did = did
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
let user = state
|
||||
.user_repo
|
||||
.get_verification_info(&did_typed)
|
||||
.get_verification_info(did)
|
||||
.await
|
||||
.log_db_err("during migration verification")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
@@ -102,30 +99,27 @@ async fn handle_migration_verification(
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string().into(),
|
||||
purpose: "migration".to_string(),
|
||||
channel: channel.to_string(),
|
||||
did: did.clone(),
|
||||
purpose: VerificationPurpose::Migration,
|
||||
channel,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_channel_update(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
let did_typed: Did = did
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
let user_id = state
|
||||
.user_repo
|
||||
.get_id_by_did(&did_typed)
|
||||
.get_id_by_did(did)
|
||||
.await
|
||||
.log_db_err("fetching user id")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
match channel {
|
||||
"email" => {
|
||||
CommsChannel::Email => {
|
||||
let success = state
|
||||
.user_repo
|
||||
.verify_email_channel(user_id, identifier)
|
||||
@@ -135,33 +129,30 @@ async fn handle_channel_update(
|
||||
return Err(ApiError::EmailTaken);
|
||||
}
|
||||
}
|
||||
"discord" => {
|
||||
CommsChannel::Discord => {
|
||||
state
|
||||
.user_repo
|
||||
.verify_discord_channel(user_id, identifier)
|
||||
.await
|
||||
.log_db_err("updating discord channel")?;
|
||||
}
|
||||
"telegram" => {
|
||||
CommsChannel::Telegram => {
|
||||
state
|
||||
.user_repo
|
||||
.verify_telegram_channel(user_id, identifier)
|
||||
.await
|
||||
.log_db_err("updating telegram channel")?;
|
||||
}
|
||||
"signal" => {
|
||||
CommsChannel::Signal => {
|
||||
state
|
||||
.user_repo
|
||||
.verify_signal_channel(user_id, identifier)
|
||||
.await
|
||||
.log_db_err("updating signal channel")?;
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError::InvalidChannel);
|
||||
}
|
||||
};
|
||||
|
||||
info!(did = %did, channel = %channel, "Channel verified successfully");
|
||||
info!(did = %did, channel = ?channel, "Channel verified successfully");
|
||||
|
||||
let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await;
|
||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
||||
@@ -170,7 +161,7 @@ async fn handle_channel_update(
|
||||
user_id,
|
||||
channel,
|
||||
&recipient,
|
||||
pds_hostname(),
|
||||
&tranquil_config::get().server.hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -179,20 +170,20 @@ async fn handle_channel_update(
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string().into(),
|
||||
purpose: "channel_update".to_string(),
|
||||
channel: channel.to_string(),
|
||||
did: did.clone(),
|
||||
purpose: VerificationPurpose::ChannelUpdate,
|
||||
channel,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_verified_recipient(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
channel: &str,
|
||||
channel: tranquil_db_traits::CommsChannel,
|
||||
identifier: &str,
|
||||
) -> String {
|
||||
match channel {
|
||||
"telegram" => state
|
||||
tranquil_db_traits::CommsChannel::Telegram => state
|
||||
.user_repo
|
||||
.get_telegram_chat_id(user_id)
|
||||
.await
|
||||
@@ -206,16 +197,13 @@ async fn resolve_verified_recipient(
|
||||
|
||||
async fn handle_signup_verification(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
let did_typed: Did = did
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
let user = state
|
||||
.user_repo
|
||||
.get_verification_info(&did_typed)
|
||||
.get_verification_info(did)
|
||||
.await
|
||||
.log_db_err("during signup verification")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
@@ -225,47 +213,44 @@ async fn handle_signup_verification(
|
||||
info!(did = %did, "Account already verified");
|
||||
return Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string().into(),
|
||||
purpose: "signup".to_string(),
|
||||
channel: channel.to_string(),
|
||||
did: did.clone(),
|
||||
purpose: VerificationPurpose::Signup,
|
||||
channel,
|
||||
}));
|
||||
}
|
||||
|
||||
match channel {
|
||||
"email" => {
|
||||
CommsChannel::Email => {
|
||||
state
|
||||
.user_repo
|
||||
.set_email_verified_flag(user.id)
|
||||
.await
|
||||
.log_db_err("updating email verified status")?;
|
||||
}
|
||||
"discord" => {
|
||||
CommsChannel::Discord => {
|
||||
state
|
||||
.user_repo
|
||||
.set_discord_verified_flag(user.id)
|
||||
.await
|
||||
.log_db_err("updating discord verified status")?;
|
||||
}
|
||||
"telegram" => {
|
||||
CommsChannel::Telegram => {
|
||||
state
|
||||
.user_repo
|
||||
.set_telegram_verified_flag(user.id)
|
||||
.await
|
||||
.log_db_err("updating telegram verified status")?;
|
||||
}
|
||||
"signal" => {
|
||||
CommsChannel::Signal => {
|
||||
state
|
||||
.user_repo
|
||||
.set_signal_verified_flag(user.id)
|
||||
.await
|
||||
.log_db_err("updating signal verified status")?;
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError::InvalidChannel);
|
||||
}
|
||||
};
|
||||
|
||||
info!(did = %did, channel = %channel, "Signup verified successfully");
|
||||
info!(did = %did, channel = ?channel, "Signup verified successfully");
|
||||
|
||||
let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await;
|
||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
||||
@@ -274,7 +259,7 @@ async fn handle_signup_verification(
|
||||
user.id,
|
||||
channel,
|
||||
&recipient,
|
||||
pds_hostname(),
|
||||
&tranquil_config::get().server.hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -283,8 +268,8 @@ async fn handle_signup_verification(
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string().into(),
|
||||
purpose: "signup".to_string(),
|
||||
channel: channel.to_string(),
|
||||
did: did.clone(),
|
||||
purpose: VerificationPurpose::Signup,
|
||||
channel,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::comms::comms_repo;
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramUpdate {
|
||||
@@ -32,9 +31,9 @@ pub async fn handle_telegram_webhook(
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let expected_secret = match &tranquil_config::get().telegram.webhook_secret {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured");
|
||||
return StatusCode::FORBIDDEN;
|
||||
}
|
||||
@@ -86,9 +85,9 @@ pub async fn handle_telegram_webhook(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
"telegram",
|
||||
tranquil_db_traits::CommsChannel::Telegram,
|
||||
&from.id.to_string(),
|
||||
pds_hostname(),
|
||||
&tranquil_config::get().server.hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ pub async fn dereference_scope(
|
||||
|
||||
for part in scope_parts {
|
||||
if let Some(cid_str) = part.strip_prefix("ref:") {
|
||||
let cache_key = format!("scope_ref:{}", cid_str);
|
||||
let cache_key = crate::cache_keys::scope_ref_key(cid_str);
|
||||
if let Some(cached) = state.cache.get(&cache_key).await {
|
||||
for s in cached.split_whitespace() {
|
||||
if !resolved_scopes.contains(&s.to_string()) {
|
||||
|
||||
@@ -23,7 +23,7 @@ impl ValidatedLocalHandle {
|
||||
}
|
||||
|
||||
pub fn new_allow_reserved(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
|
||||
let validated = validate_service_handle(handle.as_ref(), true)?;
|
||||
let validated = validate_service_handle(handle.as_ref(), ReservedHandlePolicy::Allow)?;
|
||||
Ok(Self(validated))
|
||||
}
|
||||
|
||||
@@ -252,13 +252,19 @@ impl std::fmt::Display for HandleValidationError {
|
||||
|
||||
impl std::error::Error for HandleValidationError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReservedHandlePolicy {
|
||||
Allow,
|
||||
Reject,
|
||||
}
|
||||
|
||||
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
|
||||
validate_service_handle(handle, false)
|
||||
validate_service_handle(handle, ReservedHandlePolicy::Reject)
|
||||
}
|
||||
|
||||
pub fn validate_service_handle(
|
||||
handle: &str,
|
||||
allow_reserved: bool,
|
||||
reserved_policy: ReservedHandlePolicy,
|
||||
) -> Result<String, HandleValidationError> {
|
||||
let handle = handle.trim();
|
||||
|
||||
@@ -301,7 +307,9 @@ pub fn validate_service_handle(
|
||||
return Err(HandleValidationError::BannedWord);
|
||||
}
|
||||
|
||||
if !allow_reserved && crate::handle::reserved::is_reserved_subdomain(handle) {
|
||||
if reserved_policy == ReservedHandlePolicy::Reject
|
||||
&& crate::handle::reserved::is_reserved_subdomain(handle)
|
||||
{
|
||||
return Err(HandleValidationError::Reserved);
|
||||
}
|
||||
|
||||
@@ -501,12 +509,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_allow_reserved() {
|
||||
assert_eq!(
|
||||
validate_service_handle("admin", true),
|
||||
validate_service_handle("admin", ReservedHandlePolicy::Allow),
|
||||
Ok("admin".to_string())
|
||||
);
|
||||
assert_eq!(validate_service_handle("api", true), Ok("api".to_string()));
|
||||
assert_eq!(
|
||||
validate_service_handle("admin", false),
|
||||
validate_service_handle("api", ReservedHandlePolicy::Allow),
|
||||
Ok("api".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_service_handle("admin", ReservedHandlePolicy::Reject),
|
||||
Err(HandleValidationError::Reserved)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde::Deserialize;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmChannelVerificationInput {
|
||||
pub channel: String,
|
||||
pub channel: tranquil_db_traits::CommsChannel,
|
||||
pub identifier: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,18 @@ use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DidResolutionError {
|
||||
#[error("Invalid did:web format")]
|
||||
InvalidDidWeb,
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpFailed(String),
|
||||
#[error("Invalid DID document: {0}")]
|
||||
InvalidDocument(String),
|
||||
#[error("DID not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DidDocument {
|
||||
pub id: String,
|
||||
@@ -52,13 +64,10 @@ pub struct DidResolver {
|
||||
|
||||
impl DidResolver {
|
||||
pub fn new() -> Self {
|
||||
let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(300);
|
||||
let cfg = tranquil_config::get();
|
||||
let cache_ttl_secs = cfg.plc.did_cache_ttl_secs;
|
||||
|
||||
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
|
||||
.unwrap_or_else(|_| "https://plc.directory".to_string());
|
||||
let plc_directory_url = cfg.plc.directory_url.clone();
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
@@ -78,10 +87,10 @@ impl DidResolver {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_did_web_url(did: &str) -> Result<String, String> {
|
||||
fn build_did_web_url(did: &str) -> Result<String, DidResolutionError> {
|
||||
let host = did
|
||||
.strip_prefix("did:web:")
|
||||
.ok_or("Invalid did:web format")?;
|
||||
.ok_or(DidResolutionError::InvalidDidWeb)?;
|
||||
|
||||
let (host, path) = if host.contains(':') {
|
||||
let decoded = host.replace("%3A", ":");
|
||||
@@ -184,7 +193,7 @@ impl DidResolver {
|
||||
self.extract_service_endpoint(&doc)
|
||||
}
|
||||
|
||||
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> {
|
||||
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
|
||||
let url = Self::build_did_web_url(did)?;
|
||||
|
||||
debug!("Resolving did:web {} via {}", did, url);
|
||||
@@ -194,18 +203,21 @@ impl DidResolver {
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID document: {}", e))
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> {
|
||||
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
|
||||
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
|
||||
|
||||
debug!("Resolving did:plc {} via {}", did, url);
|
||||
@@ -215,24 +227,27 @@ impl DidResolver {
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err("DID not found".to_string());
|
||||
return Err(DidResolutionError::NotFound);
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID document: {}", e))
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
fn extract_service_endpoint(&self, doc: &DidDocument) -> Option<ResolvedService> {
|
||||
if let Some(service) = doc.service.iter().find(|s| {
|
||||
s.service_type == "AtprotoAppView"
|
||||
s.service_type == crate::plc::ServiceType::AppView.as_str()
|
||||
|| s.id.contains("atproto_appview")
|
||||
|| s.id.ends_with("#bsky_appview")
|
||||
}) {
|
||||
@@ -329,7 +344,10 @@ impl DidResolver {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_did_document_web(&self, did: &str) -> Result<serde_json::Value, String> {
|
||||
async fn fetch_did_document_web(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<serde_json::Value, DidResolutionError> {
|
||||
let url = Self::build_did_web_url(did)?;
|
||||
|
||||
let resp = self
|
||||
@@ -337,18 +355,24 @@ impl DidResolver {
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID document: {}", e))
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_did_document_plc(&self, did: &str) -> Result<serde_json::Value, String> {
|
||||
async fn fetch_did_document_plc(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<serde_json::Value, DidResolutionError> {
|
||||
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
|
||||
|
||||
let resp = self
|
||||
@@ -356,19 +380,22 @@ impl DidResolver {
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err("DID not found".to_string());
|
||||
return Err(DidResolutionError::NotFound);
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID document: {}", e))
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn invalidate_cache(&self, did: &str) {
|
||||
|
||||
@@ -55,7 +55,7 @@ fn generate_short_token() -> String {
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
chrono::Utc::now().timestamp().max(0) as u64
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn create_email_token(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user