- filesystem blob storage is now default
- also fixing a bug in delegation passkey oauth
+22
-9
.env.example
+22
-9
.env.example
···
14
14
# DATABASE_MIN_CONNECTIONS=10
15
15
# DATABASE_ACQUIRE_TIMEOUT_SECS=30
16
16
# =============================================================================
17
-
# Blob Storage (S3-compatible)
17
+
# Blob Storage
18
18
# =============================================================================
19
-
S3_ENDPOINT=http://localhost:9000
20
-
AWS_REGION=us-east-1
21
-
S3_BUCKET=pds-blobs
22
-
AWS_ACCESS_KEY_ID=minioadmin
23
-
AWS_SECRET_ACCESS_KEY=minioadmin
19
+
# Backend: "filesystem" (default) or "s3"
20
+
# BLOB_STORAGE_BACKEND=filesystem
21
+
# For filesystem backend:
22
+
BLOB_STORAGE_PATH=/var/lib/tranquil/blobs
23
+
# For S3 backend:
24
+
# S3_ENDPOINT=http://localhost:9000
25
+
# AWS_REGION=us-east-1
26
+
# S3_BUCKET=pds-blobs
27
+
# AWS_ACCESS_KEY_ID=minioadmin
28
+
# AWS_SECRET_ACCESS_KEY=minioadmin
24
29
# =============================================================================
25
-
# Backups (S3-compatible)
30
+
# Backups
26
31
# =============================================================================
27
-
# Set to enable automatic repo backups to S3
32
+
# Enable/disable automatic repo backups
33
+
# BACKUP_ENABLED=true
34
+
# Backend: "filesystem" (default) or "s3"
35
+
# BACKUP_STORAGE_BACKEND=filesystem
36
+
# For filesystem backend:
37
+
BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
38
+
# For S3 backend:
28
39
# BACKUP_S3_BUCKET=pds-backups
29
-
# BACKUP_ENABLED=true
40
+
# Backup schedule and retention
41
+
# BACKUP_RETENTION_COUNT=7
42
+
# BACKUP_INTERVAL_SECS=86400
30
43
# =============================================================================
31
44
# Valkey (for caching and distributed rate limiting)
32
45
# =============================================================================
+77
.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json
+77
.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "token",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "request_uri",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "provider: SsoProviderType",
19
+
"type_info": {
20
+
"Custom": {
21
+
"name": "sso_provider_type",
22
+
"kind": {
23
+
"Enum": [
24
+
"github",
25
+
"discord",
26
+
"google",
27
+
"gitlab",
28
+
"oidc"
29
+
]
30
+
}
31
+
}
32
+
}
33
+
},
34
+
{
35
+
"ordinal": 3,
36
+
"name": "provider_user_id",
37
+
"type_info": "Text"
38
+
},
39
+
{
40
+
"ordinal": 4,
41
+
"name": "provider_username",
42
+
"type_info": "Text"
43
+
},
44
+
{
45
+
"ordinal": 5,
46
+
"name": "provider_email",
47
+
"type_info": "Text"
48
+
},
49
+
{
50
+
"ordinal": 6,
51
+
"name": "created_at",
52
+
"type_info": "Timestamptz"
53
+
},
54
+
{
55
+
"ordinal": 7,
56
+
"name": "expires_at",
57
+
"type_info": "Timestamptz"
58
+
}
59
+
],
60
+
"parameters": {
61
+
"Left": [
62
+
"Text"
63
+
]
64
+
},
65
+
"nullable": [
66
+
false,
67
+
false,
68
+
false,
69
+
false,
70
+
true,
71
+
true,
72
+
false,
73
+
false
74
+
]
75
+
},
76
+
"hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82"
77
+
}
+77
.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json
+77
.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "token",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "request_uri",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "provider: SsoProviderType",
19
+
"type_info": {
20
+
"Custom": {
21
+
"name": "sso_provider_type",
22
+
"kind": {
23
+
"Enum": [
24
+
"github",
25
+
"discord",
26
+
"google",
27
+
"gitlab",
28
+
"oidc"
29
+
]
30
+
}
31
+
}
32
+
}
33
+
},
34
+
{
35
+
"ordinal": 3,
36
+
"name": "provider_user_id",
37
+
"type_info": "Text"
38
+
},
39
+
{
40
+
"ordinal": 4,
41
+
"name": "provider_username",
42
+
"type_info": "Text"
43
+
},
44
+
{
45
+
"ordinal": 5,
46
+
"name": "provider_email",
47
+
"type_info": "Text"
48
+
},
49
+
{
50
+
"ordinal": 6,
51
+
"name": "created_at",
52
+
"type_info": "Timestamptz"
53
+
},
54
+
{
55
+
"ordinal": 7,
56
+
"name": "expires_at",
57
+
"type_info": "Timestamptz"
58
+
}
59
+
],
60
+
"parameters": {
61
+
"Left": [
62
+
"Text"
63
+
]
64
+
},
65
+
"nullable": [
66
+
false,
67
+
false,
68
+
false,
69
+
false,
70
+
true,
71
+
true,
72
+
false,
73
+
false
74
+
]
75
+
},
76
+
"hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a"
77
+
}
+22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
+22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "email_verified",
9
+
"type_info": "Bool"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4"
22
+
}
+31
.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json
+31
.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
{
10
+
"Custom": {
11
+
"name": "sso_provider_type",
12
+
"kind": {
13
+
"Enum": [
14
+
"github",
15
+
"discord",
16
+
"google",
17
+
"gitlab",
18
+
"oidc"
19
+
]
20
+
}
21
+
}
22
+
},
23
+
"Text",
24
+
"Text",
25
+
"Text"
26
+
]
27
+
},
28
+
"nullable": []
29
+
},
30
+
"hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6"
31
+
}
+32
.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json
+32
.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Text",
10
+
{
11
+
"Custom": {
12
+
"name": "sso_provider_type",
13
+
"kind": {
14
+
"Enum": [
15
+
"github",
16
+
"discord",
17
+
"google",
18
+
"gitlab",
19
+
"oidc"
20
+
]
21
+
}
22
+
}
23
+
},
24
+
"Text",
25
+
"Text",
26
+
"Text"
27
+
]
28
+
},
29
+
"nullable": []
30
+
},
31
+
"hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a"
32
+
}
+3
Cargo.lock
+3
Cargo.lock
+2
-2
README.md
+2
-2
README.md
···
12
12
13
13
## What's different about Tranquil PDS
14
14
15
-
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 to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
15
+
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.
16
16
17
-
The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor.
17
+
The PDS itself is a single small binary with no node/npm runtime. It requires postgres and stores blobs on the local filesystem. Valkey is optional (enables distributed rate limiting for multi-node setups). The tradeoff vs the reference PDS's sqlite is that postgres is a battle-tested piece of infra that we already know how to scale, back up, and monitor.
18
18
19
19
## Quick Start
20
20
+25
-2
crates/tranquil-infra/src/lib.rs
+25
-2
crates/tranquil-infra/src/lib.rs
···
8
8
pub enum StorageError {
9
9
#[error("IO error: {0}")]
10
10
Io(#[from] std::io::Error),
11
-
#[error("S3 error: {0}")]
12
-
S3(String),
11
+
#[error("Storage error: {0}")]
12
+
Backend(String),
13
+
#[error("Not found: {0}")]
14
+
NotFound(String),
13
15
#[error("Other: {0}")]
14
16
Other(String),
15
17
}
···
33
35
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
34
36
) -> Result<StreamUploadResult, StorageError>;
35
37
async fn copy(&self, src_key: &str, dst_key: &str) -> Result<(), StorageError>;
38
+
}
39
+
40
+
#[async_trait]
41
+
pub trait BackupStorage: Send + Sync {
42
+
async fn put_backup(&self, did: &str, rev: &str, data: &[u8]) -> Result<String, StorageError>;
43
+
async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError>;
44
+
async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError>;
45
+
}
46
+
47
+
pub fn backup_retention_count() -> u32 {
48
+
std::env::var("BACKUP_RETENTION_COUNT")
49
+
.ok()
50
+
.and_then(|v| v.parse().ok())
51
+
.unwrap_or(7)
52
+
}
53
+
54
+
pub fn backup_interval_secs() -> u64 {
55
+
std::env::var("BACKUP_INTERVAL_SECS")
56
+
.ok()
57
+
.and_then(|v| v.parse().ok())
58
+
.unwrap_or(86400)
36
59
}
37
60
38
61
#[derive(Debug, thiserror::Error)]
+1
crates/tranquil-pds/Cargo.toml
+1
crates/tranquil-pds/Cargo.toml
+4
-4
crates/tranquil-pds/src/api/backup.rs
+4
-4
crates/tranquil-pds/src/api/backup.rs
···
3
3
use crate::auth::BearerAuth;
4
4
use crate::scheduled::generate_full_backup;
5
5
use crate::state::AppState;
6
-
use crate::storage::BackupStorage;
6
+
use crate::storage::{BackupStorage, backup_retention_count};
7
7
use axum::{
8
8
Json,
9
9
extract::{Query, State},
···
249
249
"Created manual backup"
250
250
);
251
251
252
-
let retention = BackupStorage::retention_count();
252
+
let retention = backup_retention_count();
253
253
if let Err(e) = cleanup_old_backups(
254
254
state.backup_repo.as_ref(),
255
-
backup_storage,
255
+
backup_storage.as_ref(),
256
256
user.id,
257
257
retention,
258
258
)
···
275
275
276
276
async fn cleanup_old_backups(
277
277
backup_repo: &dyn BackupRepository,
278
-
backup_storage: &BackupStorage,
278
+
backup_storage: &dyn BackupStorage,
279
279
user_id: uuid::Uuid,
280
280
retention_count: u32,
281
281
) -> Result<(), String> {
+1
-1
crates/tranquil-pds/src/api/repo/blob.rs
+1
-1
crates/tranquil-pds/src/api/repo/blob.rs
···
198
198
let cid = Cid::new_v1(0x55, multihash);
199
199
let cid_str = cid.to_string();
200
200
let cid_link: CidLink = CidLink::new_unchecked(&cid_str);
201
-
let storage_key = format!("blobs/{}", cid_str);
201
+
let storage_key = cid_str.clone();
202
202
203
203
info!(
204
204
"Blob upload complete: size={}, cid={}, copying to final location",
+3
-5
crates/tranquil-pds/src/api/server/reauth.rs
+3
-5
crates/tranquil-pds/src/api/server/reauth.rs
···
82
82
.await
83
83
.unwrap_or_default();
84
84
85
-
let app_password_valid = app_password_hashes
86
-
.iter()
87
-
.fold(false, |acc, h| {
88
-
acc | bcrypt::verify(&input.password, h).unwrap_or(false)
89
-
});
85
+
let app_password_valid = app_password_hashes.iter().fold(false, |acc, h| {
86
+
acc | bcrypt::verify(&input.password, h).unwrap_or(false)
87
+
});
90
88
91
89
if !app_password_valid {
92
90
warn!(did = %&auth.0.did, "Re-auth failed: invalid password");
+5
-1
crates/tranquil-pds/src/auth/mod.rs
+5
-1
crates/tranquil-pds/src/auth/mod.rs
···
49
49
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
50
50
let mut rng = rand::thread_rng();
51
51
let segments: Vec<String> = (0..4)
52
-
.map(|_| (0..4).map(|_| chars[rng.gen_range(0..chars.len())] as char).collect())
52
+
.map(|_| {
53
+
(0..4)
54
+
.map(|_| chars[rng.gen_range(0..chars.len())] as char)
55
+
.collect()
56
+
})
53
57
.collect();
54
58
segments.join("-")
55
59
}
+7
-1
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
+7
-1
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
···
127
127
.await
128
128
.is_err()
129
129
{
130
-
tracing::warn!("Failed to set delegated DID on authorization request");
130
+
return Json(DelegationAuthResponse {
131
+
success: false,
132
+
needs_totp: None,
133
+
redirect_uri: None,
134
+
error: Some("Failed to update authorization request".to_string()),
135
+
})
136
+
.into_response();
131
137
}
132
138
133
139
let grant = match state
+14
-18
crates/tranquil-pds/src/scheduled.rs
+14
-18
crates/tranquil-pds/src/scheduled.rs
···
15
15
use tranquil_types::{AtUri, CidLink, Did};
16
16
17
17
use crate::repo::PostgresBlockStore;
18
-
use crate::storage::{BackupStorage, BlobStorage};
18
+
use crate::storage::{BackupStorage, BlobStorage, backup_interval_secs, backup_retention_count};
19
19
use crate::sync::car::encode_car_header;
20
20
21
21
async fn process_genesis_commit(
···
537
537
repo_repo: Arc<dyn RepoRepository>,
538
538
backup_repo: Arc<dyn BackupRepository>,
539
539
block_store: PostgresBlockStore,
540
-
backup_storage: Arc<BackupStorage>,
540
+
backup_storage: Arc<dyn BackupStorage>,
541
541
mut shutdown_rx: watch::Receiver<bool>,
542
542
) {
543
-
let backup_interval = Duration::from_secs(BackupStorage::interval_secs());
543
+
let backup_interval = Duration::from_secs(backup_interval_secs());
544
544
545
545
info!(
546
546
interval_secs = backup_interval.as_secs(),
547
-
retention_count = BackupStorage::retention_count(),
547
+
retention_count = backup_retention_count(),
548
548
"Starting backup service"
549
549
);
550
550
···
564
564
repo_repo.as_ref(),
565
565
backup_repo.as_ref(),
566
566
&block_store,
567
-
&backup_storage,
567
+
backup_storage.as_ref(),
568
568
).await {
569
569
error!("Error processing scheduled backups: {}", e);
570
570
}
···
592
592
repo_repo: &dyn RepoRepository,
593
593
backup_repo: &dyn BackupRepository,
594
594
block_store: &PostgresBlockStore,
595
-
backup_storage: &BackupStorage,
595
+
backup_storage: &dyn BackupStorage,
596
596
user_id: uuid::Uuid,
597
597
did: String,
598
598
repo_root_cid: String,
···
656
656
repo_repo: &dyn RepoRepository,
657
657
backup_repo: &dyn BackupRepository,
658
658
block_store: &PostgresBlockStore,
659
-
backup_storage: &BackupStorage,
659
+
backup_storage: &dyn BackupStorage,
660
660
) -> Result<(), String> {
661
-
let backup_interval_secs = BackupStorage::interval_secs() as i64;
662
-
let retention_count = BackupStorage::retention_count();
661
+
let interval_secs = backup_interval_secs() as i64;
662
+
let retention = backup_retention_count();
663
663
664
664
let users_needing_backup = backup_repo
665
-
.get_users_needing_backup(backup_interval_secs, 50)
665
+
.get_users_needing_backup(interval_secs, 50)
666
666
.await
667
667
.map_err(|e| format!("DB error fetching users for backup: {:?}", e))?;
668
668
···
700
700
block_count = result.block_count,
701
701
"Created backup"
702
702
);
703
-
if let Err(e) = cleanup_old_backups(
704
-
backup_repo,
705
-
backup_storage,
706
-
result.user_id,
707
-
retention_count,
708
-
)
709
-
.await
703
+
if let Err(e) =
704
+
cleanup_old_backups(backup_repo, backup_storage, result.user_id, retention)
705
+
.await
710
706
{
711
707
warn!(did = %result.did, error = %e, "Failed to cleanup old backups");
712
708
}
···
844
840
845
841
async fn cleanup_old_backups(
846
842
backup_repo: &dyn BackupRepository,
847
-
backup_storage: &BackupStorage,
843
+
backup_storage: &dyn BackupStorage,
848
844
user_id: uuid::Uuid,
849
845
retention_count: u32,
850
846
) -> Result<(), String> {
+5
-1
crates/tranquil-pds/src/sso/endpoints.rs
+5
-1
crates/tranquil-pds/src/sso/endpoints.rs
···
1221
1221
scopes: None,
1222
1222
created_by_controller_did: None,
1223
1223
};
1224
-
if let Err(e) = state.session_repo.create_app_password(&app_password_data).await {
1224
+
if let Err(e) = state
1225
+
.session_repo
1226
+
.create_app_password(&app_password_data)
1227
+
.await
1228
+
{
1225
1229
tracing::warn!("Failed to create initial app password: {:?}", e);
1226
1230
}
1227
1231
+5
-5
crates/tranquil-pds/src/state.rs
+5
-5
crates/tranquil-pds/src/state.rs
···
5
5
use crate::rate_limit::RateLimiters;
6
6
use crate::repo::PostgresBlockStore;
7
7
use crate::sso::{SsoConfig, SsoManager};
8
-
use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage};
8
+
use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage};
9
9
use crate::sync::firehose::SequencedEvent;
10
10
use sqlx::PgPool;
11
11
use std::error::Error;
···
32
32
pub event_notifier: Arc<dyn RepoEventNotifier>,
33
33
pub block_store: PostgresBlockStore,
34
34
pub blob_store: Arc<dyn BlobStorage>,
35
-
pub backup_storage: Option<Arc<BackupStorage>>,
35
+
pub backup_storage: Option<Arc<dyn BackupStorage>>,
36
36
pub firehose_tx: broadcast::Sender<SequencedEvent>,
37
37
pub rate_limiters: Arc<RateLimiters>,
38
38
pub circuit_breakers: Arc<CircuitBreakers>,
···
165
165
166
166
let repos = Arc::new(PostgresRepositories::new(db.clone()));
167
167
let block_store = PostgresBlockStore::new(db);
168
-
let blob_store = S3BlobStorage::new().await;
169
-
let backup_storage = BackupStorage::new().await.map(Arc::new);
168
+
let blob_store = create_blob_storage().await;
169
+
let backup_storage = create_backup_storage().await;
170
170
171
171
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
172
172
.ok()
···
195
195
sso_repo: repos.sso.clone(),
196
196
repos,
197
197
block_store,
198
-
blob_store: Arc::new(blob_store),
198
+
blob_store,
199
199
backup_storage,
200
200
firehose_tx,
201
201
rate_limiters,
+3
-1
crates/tranquil-pds/src/storage/mod.rs
+3
-1
crates/tranquil-pds/src/storage/mod.rs
···
1
1
pub use tranquil_storage::{
2
-
BackupStorage, BlobStorage, S3BlobStorage, StorageError, StreamUploadResult,
2
+
BackupStorage, BlobStorage, FilesystemBackupStorage, FilesystemBlobStorage, S3BackupStorage,
3
+
S3BlobStorage, StorageError, StreamUploadResult, backup_interval_secs, backup_retention_count,
4
+
create_backup_storage, create_blob_storage,
3
5
};
+95
-25
crates/tranquil-pds/tests/common/mod.rs
+95
-25
crates/tranquil-pds/tests/common/mod.rs
···
1
+
#[cfg(feature = "s3-storage")]
1
2
use aws_config::BehaviorVersion;
3
+
#[cfg(feature = "s3-storage")]
2
4
use aws_sdk_s3::Client as S3Client;
5
+
#[cfg(feature = "s3-storage")]
3
6
use aws_sdk_s3::config::Credentials;
4
7
use chrono::Utc;
5
8
use reqwest::{Client, StatusCode, header};
6
9
use serde_json::{Value, json};
7
10
use sqlx::postgres::PgPoolOptions;
8
11
use std::collections::HashMap;
12
+
use std::path::PathBuf;
9
13
use std::sync::{Arc, OnceLock, RwLock};
10
14
#[allow(unused_imports)]
11
15
use std::time::Duration;
···
19
23
static MOCK_APPVIEW: OnceLock<MockServer> = OnceLock::new();
20
24
static MOCK_PLC: OnceLock<MockServer> = OnceLock::new();
21
25
static TEST_DB_POOL: OnceLock<sqlx::PgPool> = OnceLock::new();
26
+
static TEST_TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
22
27
23
-
#[cfg(not(feature = "external-infra"))]
28
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
29
+
use testcontainers::GenericImage;
30
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
24
31
use testcontainers::core::ContainerPort;
25
32
#[cfg(not(feature = "external-infra"))]
26
-
use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner};
33
+
use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
27
34
#[cfg(not(feature = "external-infra"))]
28
35
use testcontainers_modules::postgres::Postgres;
29
36
#[cfg(not(feature = "external-infra"))]
30
37
static DB_CONTAINER: OnceLock<ContainerAsync<Postgres>> = OnceLock::new();
31
-
#[cfg(not(feature = "external-infra"))]
38
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
32
39
static S3_CONTAINER: OnceLock<ContainerAsync<GenericImage>> = OnceLock::new();
33
40
34
41
#[allow(dead_code)]
···
42
49
43
50
fn has_external_infra() -> bool {
44
51
std::env::var("TRANQUIL_PDS_TEST_INFRA_READY").is_ok()
45
-
|| (std::env::var("DATABASE_URL").is_ok() && std::env::var("S3_ENDPOINT").is_ok())
52
+
|| (std::env::var("DATABASE_URL").is_ok()
53
+
&& (std::env::var("S3_ENDPOINT").is_ok() || std::env::var("BLOB_STORAGE_PATH").is_ok()))
46
54
}
47
55
#[cfg(test)]
48
56
#[ctor::dtor]
49
57
fn cleanup() {
58
+
if let Some(temp_dir) = TEST_TEMP_DIR.get() {
59
+
let _ = std::fs::remove_dir_all(temp_dir);
60
+
}
50
61
if has_external_infra() {
51
62
return;
52
63
}
···
125
136
async fn setup_with_external_infra() -> String {
126
137
let database_url =
127
138
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set when using external infra");
128
-
let s3_endpoint =
129
-
std::env::var("S3_ENDPOINT").expect("S3_ENDPOINT must be set when using external infra");
130
139
let plc_url = setup_mock_plc_directory().await;
131
140
unsafe {
132
-
std::env::set_var(
133
-
"S3_BUCKET",
134
-
std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string()),
135
-
);
136
-
std::env::set_var(
137
-
"AWS_ACCESS_KEY_ID",
138
-
std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_else(|_| "minioadmin".to_string()),
139
-
);
140
-
std::env::set_var(
141
-
"AWS_SECRET_ACCESS_KEY",
142
-
std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string()),
143
-
);
144
-
std::env::set_var(
145
-
"AWS_REGION",
146
-
std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
147
-
);
148
-
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
141
+
if std::env::var("S3_ENDPOINT").is_ok() {
142
+
let s3_endpoint = std::env::var("S3_ENDPOINT").unwrap();
143
+
std::env::set_var("BLOB_STORAGE_BACKEND", "s3");
144
+
std::env::set_var("BACKUP_STORAGE_BACKEND", "s3");
145
+
std::env::set_var("BACKUP_S3_BUCKET", "test-backups");
146
+
std::env::set_var(
147
+
"S3_BUCKET",
148
+
std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string()),
149
+
);
150
+
std::env::set_var(
151
+
"AWS_ACCESS_KEY_ID",
152
+
std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_else(|_| "minioadmin".to_string()),
153
+
);
154
+
std::env::set_var(
155
+
"AWS_SECRET_ACCESS_KEY",
156
+
std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string()),
157
+
);
158
+
std::env::set_var(
159
+
"AWS_REGION",
160
+
std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
161
+
);
162
+
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
163
+
} else if std::env::var("BLOB_STORAGE_PATH").is_ok() {
164
+
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
165
+
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
166
+
} else {
167
+
panic!("Either S3_ENDPOINT or BLOB_STORAGE_PATH must be set for external-infra");
168
+
}
149
169
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
150
170
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
151
171
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
···
160
180
spawn_app(database_url).await
161
181
}
162
182
163
-
#[cfg(not(feature = "external-infra"))]
183
+
#[cfg(all(not(feature = "external-infra"), not(feature = "s3-storage")))]
184
+
async fn setup_with_testcontainers() -> String {
185
+
let temp_dir = std::env::temp_dir().join(format!("tranquil-pds-test-{}", uuid::Uuid::new_v4()));
186
+
let blob_path = temp_dir.join("blobs");
187
+
let backup_path = temp_dir.join("backups");
188
+
std::fs::create_dir_all(&blob_path).expect("Failed to create blob temp directory");
189
+
std::fs::create_dir_all(&backup_path).expect("Failed to create backup temp directory");
190
+
TEST_TEMP_DIR.set(temp_dir).ok();
191
+
let plc_url = setup_mock_plc_directory().await;
192
+
unsafe {
193
+
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
194
+
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
195
+
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
196
+
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
197
+
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
198
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
199
+
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
200
+
}
201
+
let mock_server = MockServer::start().await;
202
+
setup_mock_appview(&mock_server).await;
203
+
let mock_uri = mock_server.uri();
204
+
let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri);
205
+
let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A"));
206
+
setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await;
207
+
MOCK_APPVIEW.set(mock_server).ok();
208
+
let container = Postgres::default()
209
+
.with_tag("18-alpine")
210
+
.with_label("tranquil_pds_test", "true")
211
+
.start()
212
+
.await
213
+
.expect("Failed to start Postgres");
214
+
let connection_string = format!(
215
+
"postgres://postgres:postgres@127.0.0.1:{}",
216
+
container
217
+
.get_host_port_ipv4(5432)
218
+
.await
219
+
.expect("Failed to get port")
220
+
);
221
+
DB_CONTAINER.set(container).ok();
222
+
spawn_app(connection_string).await
223
+
}
224
+
225
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
164
226
async fn setup_with_testcontainers() -> String {
165
227
let s3_container = GenericImage::new("cgr.dev/chainguard/minio", "latest")
166
228
.with_exposed_port(ContainerPort::Tcp(9000))
···
178
240
let s3_endpoint = format!("http://127.0.0.1:{}", s3_port);
179
241
let plc_url = setup_mock_plc_directory().await;
180
242
unsafe {
243
+
std::env::set_var("BLOB_STORAGE_BACKEND", "s3");
244
+
std::env::set_var("BACKUP_STORAGE_BACKEND", "s3");
245
+
std::env::set_var("BACKUP_S3_BUCKET", "test-backups");
181
246
std::env::set_var("S3_BUCKET", "test-bucket");
182
247
std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin");
183
248
std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin");
···
204
269
.build();
205
270
let s3_client = S3Client::from_conf(s3_config);
206
271
let _ = s3_client.create_bucket().bucket("test-bucket").send().await;
272
+
let _ = s3_client
273
+
.create_bucket()
274
+
.bucket("test-backups")
275
+
.send()
276
+
.await;
207
277
let mock_server = MockServer::start().await;
208
278
setup_mock_appview(&mock_server).await;
209
279
let mock_uri = mock_server.uri();
···
232
302
#[cfg(feature = "external-infra")]
233
303
async fn setup_with_testcontainers() -> String {
234
304
panic!(
235
-
"Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT."
305
+
"Testcontainers disabled with external-infra feature. Set DATABASE_URL and BLOB_STORAGE_PATH (or S3_ENDPOINT)."
236
306
);
237
307
}
238
308
+1
-4
crates/tranquil-pds/tests/oauth.rs
+1
-4
crates/tranquil-pds/tests/oauth.rs
···
1536
1536
let access_jwt = account["accessJwt"].as_str().unwrap();
1537
1537
1538
1538
let app_password_res = http_client
1539
-
.post(format!(
1540
-
"{}/xrpc/com.atproto.server.createAppPassword",
1541
-
url
1542
-
))
1539
+
.post(format!("{}/xrpc/com.atproto.server.createAppPassword", url))
1543
1540
.header("Authorization", format!("Bearer {}", access_jwt))
1544
1541
.json(&json!({ "name": "oauth-test-app" }))
1545
1542
.send()
+138
crates/tranquil-pds/tests/oauth_security.rs
+138
crates/tranquil-pds/tests/oauth_security.rs
···
1250
1250
"Error should be InsufficientScope"
1251
1251
);
1252
1252
}
1253
+
1254
+
#[tokio::test]
1255
+
async fn test_delegation_oauth_token_sub_is_delegated_account() {
1256
+
let url = base_url().await;
1257
+
let http_client = client();
1258
+
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
1259
+
1260
+
let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
1261
+
1262
+
let delegated_handle = format!("dlgsub{}", suffix);
1263
+
let delegated_res = http_client
1264
+
.post(format!("{}/xrpc/_delegation.createDelegatedAccount", url))
1265
+
.bearer_auth(&controller_jwt)
1266
+
.json(&json!({
1267
+
"handle": delegated_handle,
1268
+
"controllerScopes": "atproto"
1269
+
}))
1270
+
.send()
1271
+
.await
1272
+
.unwrap();
1273
+
assert_eq!(
1274
+
delegated_res.status(),
1275
+
StatusCode::OK,
1276
+
"Should create delegated account"
1277
+
);
1278
+
let delegated_account: Value = delegated_res.json().await.unwrap();
1279
+
let delegated_did = delegated_account["did"].as_str().unwrap();
1280
+
1281
+
assert_ne!(
1282
+
delegated_did, controller_did,
1283
+
"Delegated DID should be different from controller DID"
1284
+
);
1285
+
1286
+
let redirect_uri = "https://example.com/deleg-sub-callback";
1287
+
let mock_client = setup_mock_client_metadata(redirect_uri).await;
1288
+
let client_id = mock_client.uri();
1289
+
let (code_verifier, code_challenge) = generate_pkce();
1290
+
1291
+
let par_body: Value = http_client
1292
+
.post(format!("{}/oauth/par", url))
1293
+
.form(&[
1294
+
("response_type", "code"),
1295
+
("client_id", &client_id),
1296
+
("redirect_uri", redirect_uri),
1297
+
("code_challenge", &code_challenge),
1298
+
("code_challenge_method", "S256"),
1299
+
("scope", "atproto"),
1300
+
("login_hint", delegated_did),
1301
+
])
1302
+
.send()
1303
+
.await
1304
+
.unwrap()
1305
+
.json()
1306
+
.await
1307
+
.unwrap();
1308
+
let request_uri = par_body["request_uri"].as_str().unwrap();
1309
+
1310
+
let auth_res = http_client
1311
+
.post(format!("{}/oauth/delegation/auth", url))
1312
+
.header("Content-Type", "application/json")
1313
+
.json(&json!({
1314
+
"request_uri": request_uri,
1315
+
"delegated_did": delegated_did,
1316
+
"controller_did": controller_did,
1317
+
"password": "Testpass123!",
1318
+
"remember_device": false
1319
+
}))
1320
+
.send()
1321
+
.await
1322
+
.unwrap();
1323
+
assert_eq!(
1324
+
auth_res.status(),
1325
+
StatusCode::OK,
1326
+
"Delegation auth should succeed"
1327
+
);
1328
+
let auth_body: Value = auth_res.json().await.unwrap();
1329
+
assert!(
1330
+
auth_body["success"].as_bool().unwrap_or(false),
1331
+
"Delegation auth should report success: {:?}",
1332
+
auth_body
1333
+
);
1334
+
1335
+
let consent_res = http_client
1336
+
.post(format!("{}/oauth/authorize/consent", url))
1337
+
.header("Content-Type", "application/json")
1338
+
.json(&json!({
1339
+
"request_uri": request_uri,
1340
+
"approved_scopes": ["atproto"],
1341
+
"remember": false
1342
+
}))
1343
+
.send()
1344
+
.await
1345
+
.unwrap();
1346
+
assert_eq!(
1347
+
consent_res.status(),
1348
+
StatusCode::OK,
1349
+
"Consent should succeed"
1350
+
);
1351
+
let consent_body: Value = consent_res.json().await.unwrap();
1352
+
let redirect_location = consent_body["redirect_uri"]
1353
+
.as_str()
1354
+
.expect("Expected redirect_uri");
1355
+
1356
+
let code = redirect_location
1357
+
.split("code=")
1358
+
.nth(1)
1359
+
.unwrap()
1360
+
.split('&')
1361
+
.next()
1362
+
.unwrap();
1363
+
1364
+
let token_res = http_client
1365
+
.post(format!("{}/oauth/token", url))
1366
+
.form(&[
1367
+
("grant_type", "authorization_code"),
1368
+
("code", code),
1369
+
("redirect_uri", redirect_uri),
1370
+
("code_verifier", &code_verifier),
1371
+
("client_id", &client_id),
1372
+
])
1373
+
.send()
1374
+
.await
1375
+
.unwrap();
1376
+
assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed");
1377
+
let tokens: Value = token_res.json().await.unwrap();
1378
+
1379
+
let sub = tokens["sub"].as_str().expect("Token response should have sub claim");
1380
+
1381
+
assert_eq!(
1382
+
sub, delegated_did,
1383
+
"Token sub claim should be the DELEGATED account's DID, not the controller's. Got {} but expected {}",
1384
+
sub, delegated_did
1385
+
);
1386
+
assert_ne!(
1387
+
sub, controller_did,
1388
+
"Token sub claim should NOT be the controller's DID"
1389
+
);
1390
+
}
+3
crates/tranquil-storage/Cargo.toml
+3
crates/tranquil-storage/Cargo.toml
+602
-129
crates/tranquil-storage/src/lib.rs
+602
-129
crates/tranquil-storage/src/lib.rs
···
1
-
pub use tranquil_infra::{BlobStorage, StorageError, StreamUploadResult};
1
+
pub use tranquil_infra::{
2
+
BackupStorage, BlobStorage, StorageError, StreamUploadResult, backup_interval_secs,
3
+
backup_retention_count,
4
+
};
2
5
3
6
use async_trait::async_trait;
4
7
use aws_config::BehaviorVersion;
···
10
13
use bytes::Bytes;
11
14
use futures::Stream;
12
15
use sha2::{Digest, Sha256};
16
+
use std::path::{Path, PathBuf};
13
17
use std::pin::Pin;
18
+
use std::sync::Arc;
14
19
15
20
const MIN_PART_SIZE: usize = 5 * 1024 * 1024;
21
+
const EXDEV: i32 = 18;
22
+
const CID_SHARD_PREFIX_LEN: usize = 9;
23
+
24
+
fn split_cid_path(key: &str) -> Option<(&str, &str)> {
25
+
let is_cid = key.get(..3).map_or(false, |p| p.eq_ignore_ascii_case("baf"));
26
+
(key.len() > CID_SHARD_PREFIX_LEN && is_cid)
27
+
.then(|| key.split_at(CID_SHARD_PREFIX_LEN))
28
+
}
29
+
30
+
fn validate_key(key: &str) -> Result<(), StorageError> {
31
+
let dominated_by_traversal = key
32
+
.split('/')
33
+
.filter(|seg| !seg.is_empty())
34
+
.try_fold(0i32, |depth, segment| match segment {
35
+
".." => {
36
+
let new_depth = depth - 1;
37
+
(new_depth >= 0).then_some(new_depth)
38
+
}
39
+
"." => Some(depth),
40
+
_ => Some(depth + 1),
41
+
})
42
+
.is_none();
43
+
44
+
let has_null = key.contains('\0');
45
+
let is_absolute = key.starts_with('/');
46
+
47
+
match (dominated_by_traversal, has_null, is_absolute) {
48
+
(true, _, _) => Err(StorageError::Other(format!(
49
+
"Path traversal detected in key: {}",
50
+
key
51
+
))),
52
+
(_, true, _) => Err(StorageError::Other(format!(
53
+
"Null byte in key: {}",
54
+
key.replace('\0', "\\0")
55
+
))),
56
+
(_, _, true) => Err(StorageError::Other(format!(
57
+
"Absolute path not allowed: {}",
58
+
key
59
+
))),
60
+
_ => Ok(()),
61
+
}
62
+
}
63
+
64
+
async fn cleanup_orphaned_tmp_files(tmp_path: &Path) {
65
+
let tmp_path = tmp_path.to_path_buf();
66
+
let cleaned = tokio::task::spawn_blocking(move || {
67
+
std::fs::read_dir(&tmp_path)
68
+
.into_iter()
69
+
.flatten()
70
+
.filter_map(Result::ok)
71
+
.filter(|e| e.path().is_file())
72
+
.filter_map(|entry| std::fs::remove_file(entry.path()).ok())
73
+
.count()
74
+
})
75
+
.await
76
+
.unwrap_or(0);
77
+
78
+
if cleaned > 0 {
79
+
tracing::info!(
80
+
count = cleaned,
81
+
"Cleaned orphaned tmp files from previous run"
82
+
);
83
+
}
84
+
}
85
+
86
+
async fn rename_with_fallback(src: &Path, dst: &Path) -> Result<(), StorageError> {
87
+
match tokio::fs::rename(src, dst).await {
88
+
Ok(()) => Ok(()),
89
+
Err(e) if e.raw_os_error() == Some(EXDEV) => {
90
+
tokio::fs::copy(src, dst).await?;
91
+
tokio::fs::File::open(dst).await?.sync_all().await?;
92
+
let _ = tokio::fs::remove_file(src).await;
93
+
Ok(())
94
+
}
95
+
Err(e) => Err(StorageError::Io(e)),
96
+
}
97
+
}
98
+
99
+
async fn ensure_parent_dir(path: &Path) -> Result<(), StorageError> {
100
+
if let Some(parent) = path.parent() {
101
+
tokio::fs::create_dir_all(parent).await?;
102
+
}
103
+
Ok(())
104
+
}
105
+
106
+
fn map_io_not_found(key: &str) -> impl FnOnce(std::io::Error) -> StorageError + '_ {
107
+
|e| match e.kind() {
108
+
std::io::ErrorKind::NotFound => StorageError::NotFound(key.to_string()),
109
+
_ => StorageError::Io(e),
110
+
}
111
+
}
16
112
17
113
pub struct S3BlobStorage {
18
114
client: Client,
···
40
136
.load()
41
137
.await;
42
138
43
-
if let Ok(endpoint) = std::env::var("S3_ENDPOINT") {
44
-
let s3_config = aws_sdk_s3::config::Builder::from(&config)
45
-
.endpoint_url(endpoint)
46
-
.force_path_style(true)
47
-
.build();
48
-
Client::from_conf(s3_config)
49
-
} else {
50
-
Client::new(&config)
51
-
}
139
+
std::env::var("S3_ENDPOINT").ok().map_or_else(
140
+
|| Client::new(&config),
141
+
|endpoint| {
142
+
let s3_config = aws_sdk_s3::config::Builder::from(&config)
143
+
.endpoint_url(endpoint)
144
+
.force_path_style(true)
145
+
.build();
146
+
Client::from_conf(s3_config)
147
+
},
148
+
)
52
149
}
53
150
54
-
pub struct BackupStorage {
151
+
pub struct S3BackupStorage {
55
152
client: Client,
56
153
bucket: String,
57
154
}
58
155
59
-
impl BackupStorage {
156
+
impl S3BackupStorage {
60
157
pub async fn new() -> Option<Self> {
61
-
let backup_enabled = std::env::var("BACKUP_ENABLED")
62
-
.map(|v| v != "false" && v != "0")
63
-
.unwrap_or(true);
64
-
65
-
if !backup_enabled {
66
-
return None;
67
-
}
68
-
69
158
let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?;
70
159
let client = create_s3_client().await;
71
160
Some(Self { client, bucket })
72
161
}
162
+
}
73
163
74
-
pub fn retention_count() -> u32 {
75
-
std::env::var("BACKUP_RETENTION_COUNT")
76
-
.ok()
77
-
.and_then(|v| v.parse().ok())
78
-
.unwrap_or(7)
79
-
}
80
-
81
-
pub fn interval_secs() -> u64 {
82
-
std::env::var("BACKUP_INTERVAL_SECS")
83
-
.ok()
84
-
.and_then(|v| v.parse().ok())
85
-
.unwrap_or(86400)
86
-
}
87
-
88
-
pub async fn put_backup(
89
-
&self,
90
-
did: &str,
91
-
rev: &str,
92
-
data: &[u8],
93
-
) -> Result<String, StorageError> {
164
+
#[async_trait]
165
+
impl BackupStorage for S3BackupStorage {
166
+
async fn put_backup(&self, did: &str, rev: &str, data: &[u8]) -> Result<String, StorageError> {
94
167
let key = format!("{}/{}.car", did, rev);
95
168
self.client
96
169
.put_object()
···
99
172
.body(ByteStream::from(Bytes::copy_from_slice(data)))
100
173
.send()
101
174
.await
102
-
.map_err(|e| StorageError::S3(e.to_string()))?;
175
+
.map_err(|e| StorageError::Backend(e.to_string()))?;
103
176
104
177
Ok(key)
105
178
}
106
179
107
-
pub async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> {
180
+
async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> {
108
181
let resp = self
109
182
.client
110
183
.get_object()
···
112
185
.key(storage_key)
113
186
.send()
114
187
.await
115
-
.map_err(|e| StorageError::S3(e.to_string()))?;
188
+
.map_err(|e| StorageError::Backend(e.to_string()))?;
116
189
117
-
let data = resp
118
-
.body
190
+
resp.body
119
191
.collect()
120
192
.await
121
-
.map_err(|e| StorageError::S3(e.to_string()))?
122
-
.into_bytes();
123
-
124
-
Ok(data)
193
+
.map(|agg| agg.into_bytes())
194
+
.map_err(|e| StorageError::Backend(e.to_string()))
125
195
}
126
196
127
-
pub async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> {
197
+
async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> {
128
198
self.client
129
199
.delete_object()
130
200
.bucket(&self.bucket)
131
201
.key(storage_key)
132
202
.send()
133
203
.await
134
-
.map_err(|e| StorageError::S3(e.to_string()))?;
204
+
.map_err(|e| StorageError::Backend(e.to_string()))?;
135
205
136
206
Ok(())
137
207
}
···
151
221
.body(ByteStream::from(data))
152
222
.send()
153
223
.await
154
-
.map_err(|e| StorageError::S3(e.to_string()))?;
224
+
.map_err(|e| StorageError::Backend(e.to_string()))?;
155
225
156
226
Ok(())
157
227
}
···
168
238
.key(key)
169
239
.send()
170
240
.await
171
-
.map_err(|e| StorageError::S3(e.to_string()))?;
241
+
.map_err(|e| StorageError::Backend(e.to_string()))?;
172
242
173
-
let data = resp
174
-
.body
243
+
resp.body
175
244
.collect()
176
245
.await
177
-
.map_err(|e| StorageError::S3(e.to_string()))?
178
-
.into_bytes();
179
-
180
-
Ok(data)
246
+
.map(|agg| agg.into_bytes())
247
+
.map_err(|e| StorageError::Backend(e.to_string()))
181
248
}
182
249
183
250
async fn get_head(&self, key: &str, size: usize) -> Result<Bytes, StorageError> {
···
190
257
.range(range)
191
258
.send()
192
259
.await
193
-
.map_err(|e| StorageError::S3(e.to_string()))?;
260
+
.map_err(|e| StorageError::Backend(e.to_string()))?;
194
261
195
-
let data = resp
196
-
.body
262
+
resp.body
197
263
.collect()
198
264
.await
199
-
.map_err(|e| StorageError::S3(e.to_string()))?
200
-
.into_bytes();
201
-
202
-
Ok(data)
265
+
.map(|agg| agg.into_bytes())
266
+
.map_err(|e| StorageError::Backend(e.to_string()))
203
267
}
204
268
205
269
async fn delete(&self, key: &str) -> Result<(), StorageError> {
···
209
273
.key(key)
210
274
.send()
211
275
.await
212
-
.map_err(|e| StorageError::S3(e.to_string()))?;
276
+
.map_err(|e| StorageError::Backend(e.to_string()))?;
213
277
214
278
Ok(())
215
279
}
···
217
281
async fn put_stream(
218
282
&self,
219
283
key: &str,
220
-
mut stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
284
+
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
221
285
) -> Result<StreamUploadResult, StorageError> {
222
286
use futures::StreamExt;
223
287
···
228
292
.key(key)
229
293
.send()
230
294
.await
231
-
.map_err(|e| StorageError::S3(format!("Failed to create multipart upload: {}", e)))?;
295
+
.map_err(|e| {
296
+
StorageError::Backend(format!("Failed to create multipart upload: {}", e))
297
+
})?;
232
298
233
299
let upload_id = create_resp
234
300
.upload_id()
235
-
.ok_or_else(|| StorageError::S3("No upload ID returned".to_string()))?
301
+
.ok_or_else(|| StorageError::Backend("No upload ID returned".to_string()))?
236
302
.to_string();
237
303
238
-
let mut hasher = Sha256::new();
239
-
let mut total_size: u64 = 0;
240
-
let mut part_number = 1;
241
-
let mut completed_parts: Vec<CompletedPart> = Vec::new();
242
-
let mut buffer = Vec::with_capacity(MIN_PART_SIZE);
243
-
244
304
let upload_part = |client: &Client,
245
305
bucket: &str,
246
306
key: &str,
···
264
324
.body(ByteStream::from(data))
265
325
.send()
266
326
.await
267
-
.map_err(|e| StorageError::S3(format!("Failed to upload part: {}", e)))?;
327
+
.map_err(|e| StorageError::Backend(format!("Failed to upload part: {}", e)))?;
268
328
269
329
let etag = resp
270
330
.e_tag()
271
-
.ok_or_else(|| StorageError::S3("No ETag returned for part".to_string()))?
331
+
.ok_or_else(|| StorageError::Backend("No ETag returned for part".to_string()))?
272
332
.to_string();
273
333
274
334
Ok(CompletedPart::builder()
···
278
338
})
279
339
};
280
340
281
-
loop {
282
-
match stream.next().await {
283
-
Some(Ok(chunk)) => {
284
-
hasher.update(&chunk);
285
-
total_size += chunk.len() as u64;
286
-
buffer.extend_from_slice(&chunk);
341
+
struct UploadState {
342
+
hasher: Sha256,
343
+
total_size: u64,
344
+
part_number: i32,
345
+
completed_parts: Vec<CompletedPart>,
346
+
buffer: Vec<u8>,
347
+
}
287
348
288
-
if buffer.len() >= MIN_PART_SIZE {
289
-
let part_data =
290
-
std::mem::replace(&mut buffer, Vec::with_capacity(MIN_PART_SIZE));
291
-
let part = upload_part(
292
-
&self.client,
293
-
&self.bucket,
294
-
key,
295
-
&upload_id,
296
-
part_number,
297
-
part_data,
298
-
)
299
-
.await?;
300
-
completed_parts.push(part);
301
-
part_number += 1;
349
+
let initial_state = UploadState {
350
+
hasher: Sha256::new(),
351
+
total_size: 0,
352
+
part_number: 1,
353
+
completed_parts: Vec::new(),
354
+
buffer: Vec::with_capacity(MIN_PART_SIZE),
355
+
};
356
+
357
+
let abort_upload = || async {
358
+
let _ = self
359
+
.client
360
+
.abort_multipart_upload()
361
+
.bucket(&self.bucket)
362
+
.key(key)
363
+
.upload_id(&upload_id)
364
+
.send()
365
+
.await;
366
+
};
367
+
368
+
let result: Result<UploadState, StorageError> = {
369
+
let mut state = initial_state;
370
+
371
+
let chunk_results: Vec<Result<Bytes, std::io::Error>> = stream.collect().await;
372
+
373
+
for chunk_result in chunk_results {
374
+
match chunk_result {
375
+
Ok(chunk) => {
376
+
state.hasher.update(&chunk);
377
+
state.total_size += chunk.len() as u64;
378
+
state.buffer.extend_from_slice(&chunk);
379
+
380
+
if state.buffer.len() >= MIN_PART_SIZE {
381
+
let part_data = std::mem::replace(
382
+
&mut state.buffer,
383
+
Vec::with_capacity(MIN_PART_SIZE),
384
+
);
385
+
let part = upload_part(
386
+
&self.client,
387
+
&self.bucket,
388
+
key,
389
+
&upload_id,
390
+
state.part_number,
391
+
part_data,
392
+
)
393
+
.await?;
394
+
state.completed_parts.push(part);
395
+
state.part_number += 1;
396
+
}
302
397
}
303
-
}
304
-
Some(Err(e)) => {
305
-
let _ = self
306
-
.client
307
-
.abort_multipart_upload()
308
-
.bucket(&self.bucket)
309
-
.key(key)
310
-
.upload_id(&upload_id)
311
-
.send()
312
-
.await;
313
-
return Err(StorageError::Io(e));
398
+
Err(e) => {
399
+
abort_upload().await;
400
+
return Err(StorageError::Io(e));
401
+
}
314
402
}
315
-
None => break,
316
403
}
317
-
}
404
+
405
+
Ok(state)
406
+
};
407
+
408
+
let mut state = result?;
318
409
319
-
if !buffer.is_empty() {
410
+
if !state.buffer.is_empty() {
320
411
let part = upload_part(
321
412
&self.client,
322
413
&self.bucket,
323
414
key,
324
415
&upload_id,
325
-
part_number,
326
-
buffer,
416
+
state.part_number,
417
+
std::mem::take(&mut state.buffer),
327
418
)
328
419
.await?;
329
-
completed_parts.push(part);
420
+
state.completed_parts.push(part);
330
421
}
331
422
332
-
if completed_parts.is_empty() {
333
-
let _ = self
334
-
.client
335
-
.abort_multipart_upload()
336
-
.bucket(&self.bucket)
337
-
.key(key)
338
-
.upload_id(&upload_id)
339
-
.send()
340
-
.await;
423
+
if state.completed_parts.is_empty() {
424
+
abort_upload().await;
341
425
return Err(StorageError::Other("Empty upload".to_string()));
342
426
}
343
427
344
428
let completed_upload = CompletedMultipartUpload::builder()
345
-
.set_parts(Some(completed_parts))
429
+
.set_parts(Some(state.completed_parts))
346
430
.build();
347
431
348
432
self.client
···
353
437
.multipart_upload(completed_upload)
354
438
.send()
355
439
.await
356
-
.map_err(|e| StorageError::S3(format!("Failed to complete multipart upload: {}", e)))?;
440
+
.map_err(|e| {
441
+
StorageError::Backend(format!("Failed to complete multipart upload: {}", e))
442
+
})?;
357
443
358
-
let hash: [u8; 32] = hasher.finalize().into();
444
+
let hash: [u8; 32] = state.hasher.finalize().into();
359
445
Ok(StreamUploadResult {
360
446
sha256_hash: hash,
361
-
size: total_size,
447
+
size: state.total_size,
362
448
})
363
449
}
364
450
···
372
458
.key(dst_key)
373
459
.send()
374
460
.await
375
-
.map_err(|e| StorageError::S3(format!("Failed to copy object: {}", e)))?;
461
+
.map_err(|e| StorageError::Backend(format!("Failed to copy object: {}", e)))?;
462
+
463
+
Ok(())
464
+
}
465
+
}
466
+
467
+
pub struct FilesystemBlobStorage {
468
+
base_path: PathBuf,
469
+
tmp_path: PathBuf,
470
+
}
471
+
472
+
impl FilesystemBlobStorage {
473
+
pub async fn new(base_path: impl Into<PathBuf>) -> Result<Self, StorageError> {
474
+
let base_path = base_path.into();
475
+
let tmp_path = base_path.join(".tmp");
476
+
tokio::fs::create_dir_all(&base_path).await?;
477
+
tokio::fs::create_dir_all(&tmp_path).await?;
478
+
cleanup_orphaned_tmp_files(&tmp_path).await;
479
+
Ok(Self {
480
+
base_path,
481
+
tmp_path,
482
+
})
483
+
}
484
+
485
+
pub async fn from_env() -> Result<Self, StorageError> {
486
+
let path = std::env::var("BLOB_STORAGE_PATH")
487
+
.map_err(|_| StorageError::Other("BLOB_STORAGE_PATH not set".into()))?;
488
+
Self::new(path).await
489
+
}
490
+
491
+
fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> {
492
+
validate_key(key)?;
493
+
Ok(split_cid_path(key).map_or_else(
494
+
|| self.base_path.join(key),
495
+
|(dir, file)| self.base_path.join(dir).join(file),
496
+
))
497
+
}
498
+
499
+
async fn atomic_write(&self, path: &Path, data: &[u8]) -> Result<(), StorageError> {
500
+
use tokio::io::AsyncWriteExt;
501
+
502
+
let tmp_file_name = uuid::Uuid::new_v4().to_string();
503
+
let tmp_path = self.tmp_path.join(&tmp_file_name);
504
+
505
+
let mut file = tokio::fs::File::create(&tmp_path).await?;
506
+
file.write_all(data).await?;
507
+
file.sync_all().await?;
508
+
drop(file);
509
+
510
+
rename_with_fallback(&tmp_path, path).await
511
+
}
512
+
}
513
+
514
+
#[async_trait]
515
+
impl BlobStorage for FilesystemBlobStorage {
516
+
async fn put(&self, key: &str, data: &[u8]) -> Result<(), StorageError> {
517
+
let path = self.resolve_path(key)?;
518
+
ensure_parent_dir(&path).await?;
519
+
self.atomic_write(&path, data).await
520
+
}
521
+
522
+
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
523
+
self.put(key, &data).await
524
+
}
525
+
526
+
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
527
+
let path = self.resolve_path(key)?;
528
+
tokio::fs::read(&path).await.map_err(map_io_not_found(key))
529
+
}
530
+
531
+
async fn get_bytes(&self, key: &str) -> Result<Bytes, StorageError> {
532
+
self.get(key).await.map(Bytes::from)
533
+
}
534
+
535
+
async fn get_head(&self, key: &str, size: usize) -> Result<Bytes, StorageError> {
536
+
use tokio::io::AsyncReadExt;
537
+
let path = self.resolve_path(key)?;
538
+
let mut file = tokio::fs::File::open(&path)
539
+
.await
540
+
.map_err(map_io_not_found(key))?;
541
+
let mut buffer = vec![0u8; size];
542
+
let n = file.read(&mut buffer).await?;
543
+
buffer.truncate(n);
544
+
Ok(Bytes::from(buffer))
545
+
}
546
+
547
+
async fn delete(&self, key: &str) -> Result<(), StorageError> {
548
+
let path = self.resolve_path(key)?;
549
+
tokio::fs::remove_file(&path).await.or_else(|e| {
550
+
(e.kind() == std::io::ErrorKind::NotFound)
551
+
.then_some(())
552
+
.ok_or(StorageError::Io(e))
553
+
})
554
+
}
555
+
556
+
async fn put_stream(
557
+
&self,
558
+
key: &str,
559
+
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
560
+
) -> Result<StreamUploadResult, StorageError> {
561
+
use futures::TryStreamExt;
562
+
use tokio::io::AsyncWriteExt;
563
+
564
+
let tmp_file_name = uuid::Uuid::new_v4().to_string();
565
+
let tmp_path = self.tmp_path.join(&tmp_file_name);
566
+
let final_path = self.resolve_path(key)?;
567
+
ensure_parent_dir(&final_path).await?;
568
+
569
+
let file = tokio::fs::File::create(&tmp_path).await?;
570
+
571
+
struct StreamState {
572
+
file: tokio::fs::File,
573
+
hasher: Sha256,
574
+
total_size: u64,
575
+
}
576
+
577
+
let initial = StreamState {
578
+
file,
579
+
hasher: Sha256::new(),
580
+
total_size: 0,
581
+
};
582
+
583
+
let final_state = stream
584
+
.map_err(StorageError::Io)
585
+
.try_fold(initial, |mut state, chunk| async move {
586
+
state.hasher.update(&chunk);
587
+
state.total_size += chunk.len() as u64;
588
+
state.file.write_all(&chunk).await?;
589
+
Ok(state)
590
+
})
591
+
.await?;
592
+
593
+
final_state.file.sync_all().await?;
594
+
drop(final_state.file);
595
+
596
+
rename_with_fallback(&tmp_path, &final_path).await?;
376
597
598
+
let hash: [u8; 32] = final_state.hasher.finalize().into();
599
+
Ok(StreamUploadResult {
600
+
sha256_hash: hash,
601
+
size: final_state.total_size,
602
+
})
603
+
}
604
+
605
+
async fn copy(&self, src_key: &str, dst_key: &str) -> Result<(), StorageError> {
606
+
let src_path = self.resolve_path(src_key)?;
607
+
let dst_path = self.resolve_path(dst_key)?;
608
+
ensure_parent_dir(&dst_path).await?;
609
+
tokio::fs::copy(&src_path, &dst_path)
610
+
.await
611
+
.map_err(map_io_not_found(src_key))?;
612
+
tokio::fs::File::open(&dst_path).await?.sync_all().await?;
377
613
Ok(())
378
614
}
379
615
}
616
+
617
+
pub struct FilesystemBackupStorage {
618
+
base_path: PathBuf,
619
+
tmp_path: PathBuf,
620
+
}
621
+
622
+
impl FilesystemBackupStorage {
623
+
pub async fn new(base_path: impl Into<PathBuf>) -> Result<Self, StorageError> {
624
+
let base_path = base_path.into();
625
+
let tmp_path = base_path.join(".tmp");
626
+
tokio::fs::create_dir_all(&base_path).await?;
627
+
tokio::fs::create_dir_all(&tmp_path).await?;
628
+
cleanup_orphaned_tmp_files(&tmp_path).await;
629
+
Ok(Self {
630
+
base_path,
631
+
tmp_path,
632
+
})
633
+
}
634
+
635
+
pub async fn from_env() -> Result<Self, StorageError> {
636
+
let path = std::env::var("BACKUP_STORAGE_PATH")
637
+
.map_err(|_| StorageError::Other("BACKUP_STORAGE_PATH not set".into()))?;
638
+
Self::new(path).await
639
+
}
640
+
641
+
fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> {
642
+
validate_key(key)?;
643
+
Ok(self.base_path.join(key))
644
+
}
645
+
}
646
+
647
+
#[async_trait]
648
+
impl BackupStorage for FilesystemBackupStorage {
649
+
async fn put_backup(&self, did: &str, rev: &str, data: &[u8]) -> Result<String, StorageError> {
650
+
use tokio::io::AsyncWriteExt;
651
+
652
+
let key = format!("{}/{}.car", did, rev);
653
+
let final_path = self.resolve_path(&key)?;
654
+
ensure_parent_dir(&final_path).await?;
655
+
656
+
let tmp_file_name = uuid::Uuid::new_v4().to_string();
657
+
let tmp_path = self.tmp_path.join(&tmp_file_name);
658
+
659
+
let mut file = tokio::fs::File::create(&tmp_path).await?;
660
+
file.write_all(data).await?;
661
+
file.sync_all().await?;
662
+
drop(file);
663
+
664
+
rename_with_fallback(&tmp_path, &final_path).await?;
665
+
Ok(key)
666
+
}
667
+
668
+
async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> {
669
+
let path = self.resolve_path(storage_key)?;
670
+
tokio::fs::read(&path)
671
+
.await
672
+
.map(Bytes::from)
673
+
.map_err(map_io_not_found(storage_key))
674
+
}
675
+
676
+
async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> {
677
+
let path = self.resolve_path(storage_key)?;
678
+
tokio::fs::remove_file(&path).await.or_else(|e| {
679
+
(e.kind() == std::io::ErrorKind::NotFound)
680
+
.then_some(())
681
+
.ok_or(StorageError::Io(e))
682
+
})
683
+
}
684
+
}
685
+
686
+
pub async fn create_blob_storage() -> Arc<dyn BlobStorage> {
687
+
let backend = std::env::var("BLOB_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into());
688
+
689
+
match backend.as_str() {
690
+
"s3" => {
691
+
tracing::info!("Initializing S3 blob storage");
692
+
Arc::new(S3BlobStorage::new().await)
693
+
}
694
+
_ => {
695
+
tracing::info!("Initializing filesystem blob storage");
696
+
FilesystemBlobStorage::from_env()
697
+
.await
698
+
.unwrap_or_else(|e| {
699
+
panic!(
700
+
"Failed to initialize filesystem blob storage: {}. \
701
+
Set BLOB_STORAGE_PATH to a valid directory path.",
702
+
e
703
+
);
704
+
})
705
+
.pipe(Arc::new)
706
+
}
707
+
}
708
+
}
709
+
710
+
pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
711
+
let enabled = std::env::var("BACKUP_ENABLED")
712
+
.map(|v| v != "false" && v != "0")
713
+
.unwrap_or(true);
714
+
715
+
if !enabled {
716
+
tracing::info!("Backup storage disabled via BACKUP_ENABLED=false");
717
+
return None;
718
+
}
719
+
720
+
let backend = std::env::var("BACKUP_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into());
721
+
722
+
match backend.as_str() {
723
+
"s3" => S3BackupStorage::new().await.map_or_else(
724
+
|| {
725
+
tracing::error!(
726
+
"BACKUP_STORAGE_BACKEND=s3 but BACKUP_S3_BUCKET is not set. \
727
+
Backups will be disabled."
728
+
);
729
+
None
730
+
},
731
+
|storage| {
732
+
tracing::info!("Initialized S3 backup storage");
733
+
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
734
+
},
735
+
),
736
+
_ => FilesystemBackupStorage::from_env().await.map_or_else(
737
+
|e| {
738
+
tracing::error!(
739
+
"Failed to initialize filesystem backup storage: {}. \
740
+
Set BACKUP_STORAGE_PATH to a valid directory path. \
741
+
Backups will be disabled.",
742
+
e
743
+
);
744
+
None
745
+
},
746
+
|storage| {
747
+
tracing::info!("Initialized filesystem backup storage");
748
+
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
749
+
},
750
+
),
751
+
}
752
+
}
753
+
754
+
trait Pipe: Sized {
755
+
fn pipe<F, R>(self, f: F) -> R
756
+
where
757
+
F: FnOnce(Self) -> R,
758
+
{
759
+
f(self)
760
+
}
761
+
}
762
+
763
+
impl<T> Pipe for T {}
764
+
765
+
#[cfg(test)]
766
+
mod tests {
767
+
use super::*;
768
+
769
+
#[test]
770
+
fn split_path_from_raw_blob_cid() {
771
+
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
772
+
assert_eq!(
773
+
split_cid_path(cid),
774
+
Some(("bafkreihd", "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"))
775
+
);
776
+
}
777
+
778
+
#[test]
779
+
fn split_path_from_dag_cbor_cid() {
780
+
let cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje";
781
+
assert_eq!(
782
+
split_cid_path(cid),
783
+
Some(("bafyreigd", "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"))
784
+
);
785
+
}
786
+
787
+
#[test]
788
+
fn no_split_for_temp_keys() {
789
+
assert_eq!(split_cid_path("temp/abc123"), None);
790
+
}
791
+
792
+
#[test]
793
+
fn no_split_for_short_keys() {
794
+
assert_eq!(split_cid_path("bafkreihd"), None);
795
+
assert_eq!(split_cid_path("bafkrei"), None);
796
+
assert_eq!(split_cid_path("baf"), None);
797
+
assert_eq!(split_cid_path("ba"), None);
798
+
assert_eq!(split_cid_path(""), None);
799
+
}
800
+
801
+
#[test]
802
+
fn no_split_for_non_cid_keys() {
803
+
assert_eq!(split_cid_path("something/else/entirely"), None);
804
+
assert_eq!(split_cid_path("Qmabcdefghijklmnop"), None);
805
+
}
806
+
807
+
#[test]
808
+
fn split_cid_case_insensitive() {
809
+
let upper = "BAFKREIHDWDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU";
810
+
let mixed = "BaFkReIhDwDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu";
811
+
assert_eq!(
812
+
split_cid_path(upper),
813
+
Some(("BAFKREIHD", "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU"))
814
+
);
815
+
assert_eq!(
816
+
split_cid_path(mixed),
817
+
Some(("BaFkReIhD", "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"))
818
+
);
819
+
}
820
+
821
+
#[test]
822
+
fn split_at_minimum_length() {
823
+
let cid = "bafkreihdx";
824
+
assert_eq!(split_cid_path(cid), Some(("bafkreihd", "x")));
825
+
}
826
+
827
+
#[test]
828
+
fn resolve_path_shards_cid_keys() {
829
+
let base = PathBuf::from("/blobs");
830
+
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
831
+
832
+
let expected = PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku");
833
+
let result = split_cid_path(cid).map_or_else(
834
+
|| base.join(cid),
835
+
|(dir, file)| base.join(dir).join(file),
836
+
);
837
+
assert_eq!(result, expected);
838
+
}
839
+
840
+
#[test]
841
+
fn resolve_path_no_shard_for_temp() {
842
+
let base = PathBuf::from("/blobs");
843
+
let key = "temp/abc123";
844
+
845
+
let expected = PathBuf::from("/blobs/temp/abc123");
846
+
let result = split_cid_path(key).map_or_else(
847
+
|| base.join(key),
848
+
|(dir, file)| base.join(dir).join(file),
849
+
);
850
+
assert_eq!(result, expected);
851
+
}
852
+
}
+9
-27
docs/install-containers.md
+9
-27
docs/install-containers.md
···
7
7
8
8
## Prerequisites
9
9
10
-
- A VPS with at least 2GB RAM and 20GB disk
10
+
- A VPS with at least 2GB RAM
11
+
- Disk space for blobs (depends on usage; plan for ~1GB per active user as a baseline)
11
12
- A domain name pointing to your server's IP
12
13
- A **wildcard TLS certificate** for `*.pds.example.com` (user handles are served as subdomains)
13
14
- Root or sudo access
···
42
43
43
44
## Standalone Containers (No Compose)
44
45
45
-
If you already have postgres, valkey, and minio running on the host (eg., from the [Debian install guide](install-debian.md)), you can run just the app containers.
46
+
If you already have postgres and valkey running on the host (eg., from the [Debian install guide](install-debian.md)), you can run just the app containers.
46
47
47
48
Build the images:
48
49
```sh
···
50
51
podman build -t tranquil-pds-frontend:latest ./frontend
51
52
```
52
53
53
-
Run the backend with host networking (so it can access postgres/valkey/minio on localhost):
54
+
Run the backend with host networking (so it can access postgres/valkey on localhost) and mount the blob storage:
54
55
```sh
55
56
podman run -d --name tranquil-pds \
56
57
--network=host \
57
58
--env-file /etc/tranquil-pds/tranquil-pds.env \
59
+
-v /var/lib/tranquil:/var/lib/tranquil:Z \
58
60
tranquil-pds:latest
59
61
```
60
62
···
104
106
105
107
```bash
106
108
mkdir -p /etc/containers/systemd
107
-
mkdir -p /srv/tranquil-pds/{postgres,minio,valkey,certs,acme,config}
109
+
mkdir -p /srv/tranquil-pds/{postgres,valkey,blobs,backups,certs,acme,config}
108
110
```
109
111
110
112
## Create Environment File
···
152
154
```bash
153
155
source /srv/tranquil-pds/config/tranquil-pds.env
154
156
echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -
155
-
echo "$MINIO_ROOT_PASSWORD" | podman secret create tranquil-pds-minio-password -
156
157
```
157
158
158
159
## Start Services and Initialize
159
160
160
161
```bash
161
162
systemctl daemon-reload
162
-
systemctl start tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey
163
+
systemctl start tranquil-pds-db tranquil-pds-valkey
163
164
sleep 10
164
165
```
165
166
166
-
Create the minio buckets:
167
-
```bash
168
-
podman run --rm --pod tranquil-pds \
169
-
-e MINIO_ROOT_USER=minioadmin \
170
-
-e MINIO_ROOT_PASSWORD=your-minio-password \
171
-
cgr.dev/chainguard/minio-client:latest-dev \
172
-
sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups"
173
-
```
174
-
175
167
Run migrations:
176
168
```bash
177
169
cargo install sqlx-cli --no-default-features --features postgres
···
215
207
## Enable All Services
216
208
217
209
```bash
218
-
systemctl enable tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
210
+
systemctl enable tranquil-pds-db tranquil-pds-valkey tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
219
211
```
220
212
221
213
## Configure Firewall
···
260
252
261
253
```sh
262
254
mkdir -p /srv/tranquil-pds/{data,config}
263
-
mkdir -p /srv/tranquil-pds/data/{postgres,minio,valkey,certs,acme}
255
+
mkdir -p /srv/tranquil-pds/data/{postgres,valkey,blobs,backups,certs,acme}
264
256
```
265
257
266
258
## Clone Repository and Build Images
···
340
332
```sh
341
333
rc-service tranquil-pds start
342
334
sleep 15
343
-
```
344
-
345
-
Create the minio buckets:
346
-
```sh
347
-
source /srv/tranquil-pds/config/tranquil-pds.env
348
-
podman run --rm --network tranquil-pds_default \
349
-
-e MINIO_ROOT_USER="$MINIO_ROOT_USER" \
350
-
-e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \
351
-
cgr.dev/chainguard/minio-client:latest-dev \
352
-
sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups'
353
335
```
354
336
355
337
Run migrations:
+11
-41
docs/install-debian.md
+11
-41
docs/install-debian.md
···
4
4
5
5
## Prerequisites
6
6
7
-
- A VPS with at least 2GB RAM and 20GB disk
7
+
- A VPS with at least 2GB RAM
8
+
- Disk space for blobs (depends on usage; plan for ~1GB per active user as a baseline)
8
9
- A domain name pointing to your server's IP
9
10
- A wildcard TLS certificate for `*.pds.example.com` (user handles are served as subdomains)
10
11
- Root or sudo access
···
37
38
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE pds TO tranquil_pds;"
38
39
```
39
40
40
-
## Install minio
41
+
## Create Blob Storage Directories
41
42
42
43
```bash
43
-
curl -O https://dl.min.io/server/minio/release/linux-amd64/minio
44
-
chmod +x minio
45
-
mv minio /usr/local/bin/
46
-
mkdir -p /var/lib/minio/data
47
-
useradd -r -s /sbin/nologin minio-user
48
-
chown -R minio-user:minio-user /var/lib/minio
49
-
cat > /etc/default/minio << 'EOF'
50
-
MINIO_ROOT_USER=minioadmin
51
-
MINIO_ROOT_PASSWORD=your-minio-password
52
-
MINIO_VOLUMES="/var/lib/minio/data"
53
-
MINIO_OPTS="--console-address :9001"
54
-
EOF
55
-
cat > /etc/systemd/system/minio.service << 'EOF'
56
-
[Unit]
57
-
Description=MinIO Object Storage
58
-
After=network.target
59
-
[Service]
60
-
User=minio-user
61
-
Group=minio-user
62
-
EnvironmentFile=/etc/default/minio
63
-
ExecStart=/usr/local/bin/minio server $MINIO_VOLUMES $MINIO_OPTS
64
-
Restart=always
65
-
LimitNOFILE=65536
66
-
[Install]
67
-
WantedBy=multi-user.target
68
-
EOF
69
-
systemctl daemon-reload
70
-
systemctl enable minio
71
-
systemctl start minio
44
+
mkdir -p /var/lib/tranquil/blobs /var/lib/tranquil/backups
72
45
```
73
46
74
-
Create the buckets (wait a few seconds for minio to start):
75
-
```bash
76
-
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
77
-
chmod +x mc
78
-
mv mc /usr/local/bin/
79
-
mc alias set local http://localhost:9000 minioadmin your-minio-password
80
-
mc mb local/pds-blobs
81
-
mc mb local/pds-backups
82
-
```
47
+
We'll set ownership after creating the service user.
83
48
84
49
## Install valkey
85
50
···
142
107
143
108
```bash
144
109
useradd -r -s /sbin/nologin tranquil-pds
110
+
chown -R tranquil-pds:tranquil-pds /var/lib/tranquil
145
111
cp /opt/tranquil-pds/target/release/tranquil-pds /usr/local/bin/
146
112
147
113
cat > /etc/systemd/system/tranquil-pds.service << 'EOF'
148
114
[Unit]
149
115
Description=Tranquil PDS - AT Protocol PDS
150
-
After=network.target postgresql.service minio.service
116
+
After=network.target postgresql.service
151
117
[Service]
152
118
Type=simple
153
119
User=tranquil-pds
···
156
122
ExecStart=/usr/local/bin/tranquil-pds
157
123
Restart=always
158
124
RestartSec=5
125
+
ProtectSystem=strict
126
+
ProtectHome=true
127
+
PrivateTmp=true
128
+
ReadWritePaths=/var/lib/tranquil
159
129
[Install]
160
130
WantedBy=multi-user.target
161
131
EOF
+3
-3
docs/install-kubernetes.md
+3
-3
docs/install-kubernetes.md
···
4
4
5
5
- cloudnativepg (or your preferred postgres operator)
6
6
- valkey
7
-
- s3-compatible object storage (minio operator, or just use a managed service)
7
+
- a PersistentVolume for blob storage
8
8
- the app itself (it's just a container with some env vars)
9
9
10
10
You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains.
11
11
12
12
The container image expects:
13
13
- `DATABASE_URL` - postgres connection string
14
-
- `S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET`
15
-
- `BACKUP_S3_BUCKET` - bucket for repo backups (optional but recommended)
14
+
- `BLOB_STORAGE_PATH` - path to blob storage (mount a PV here)
15
+
- `BACKUP_STORAGE_PATH` - path for repo backups (optional but recommended)
16
16
- `VALKEY_URL` - redis:// connection string
17
17
- `PDS_HOSTNAME` - your PDS hostname (without protocol)
18
18
- `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48`
+2
-1
frontend/src/routes/OAuthDelegation.svelte
+2
-1
frontend/src/routes/OAuthDelegation.svelte
+780
frontend/src/routes/OAuthRegister.svelte
+780
frontend/src/routes/OAuthRegister.svelte
···
1
+
<script lang="ts">
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
+
import { api } from '../lib/api'
4
+
import { _ } from '../lib/i18n'
5
+
import {
6
+
createRegistrationFlow,
7
+
restoreRegistrationFlow,
8
+
VerificationStep,
9
+
KeyChoiceStep,
10
+
DidDocStep,
11
+
AppPasswordStep,
12
+
} from '../lib/registration'
13
+
import {
14
+
prepareCreationOptions,
15
+
serializeAttestationResponse,
16
+
type WebAuthnCreationOptionsResponse,
17
+
} from '../lib/webauthn'
18
+
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
19
+
20
+
let serverInfo = $state<{
21
+
availableUserDomains: string[]
22
+
inviteCodeRequired: boolean
23
+
availableCommsChannels?: string[]
24
+
selfHostedDidWebEnabled?: boolean
25
+
} | null>(null)
26
+
let loadingServerInfo = $state(true)
27
+
let serverInfoLoaded = false
28
+
let ssoAvailable = $state(false)
29
+
30
+
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
31
+
let passkeyName = $state('')
32
+
let clientName = $state<string | null>(null)
33
+
34
+
function getRequestUri(): string | null {
35
+
const params = new URLSearchParams(window.location.search)
36
+
return params.get('request_uri')
37
+
}
38
+
39
+
$effect(() => {
40
+
if (!serverInfoLoaded) {
41
+
serverInfoLoaded = true
42
+
loadServerInfo()
43
+
fetchClientName()
44
+
checkSsoAvailable()
45
+
}
46
+
})
47
+
48
+
async function checkSsoAvailable() {
49
+
try {
50
+
const response = await fetch('/oauth/sso/providers')
51
+
if (response.ok) {
52
+
const data = await response.json()
53
+
ssoAvailable = (data.providers?.length ?? 0) > 0
54
+
}
55
+
} catch {
56
+
ssoAvailable = false
57
+
}
58
+
}
59
+
60
+
async function fetchClientName() {
61
+
const requestUri = getRequestUri()
62
+
if (!requestUri) return
63
+
64
+
try {
65
+
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
66
+
headers: { 'Accept': 'application/json' }
67
+
})
68
+
if (response.ok) {
69
+
const data = await response.json()
70
+
clientName = data.client_name || null
71
+
}
72
+
} catch {
73
+
clientName = null
74
+
}
75
+
}
76
+
77
+
$effect(() => {
78
+
if (flow?.state.step === 'redirect-to-dashboard') {
79
+
completeOAuthRegistration()
80
+
}
81
+
})
82
+
83
+
let creatingStarted = false
84
+
$effect(() => {
85
+
if (flow?.state.step === 'creating' && !creatingStarted) {
86
+
creatingStarted = true
87
+
flow.createPasskeyAccount()
88
+
}
89
+
})
90
+
91
+
async function loadServerInfo() {
92
+
try {
93
+
const restored = restoreRegistrationFlow()
94
+
if (restored && restored.state.mode === 'passkey') {
95
+
flow = restored
96
+
serverInfo = await api.describeServer()
97
+
} else {
98
+
serverInfo = await api.describeServer()
99
+
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
100
+
flow = createRegistrationFlow('passkey', hostname)
101
+
}
102
+
} catch (e) {
103
+
console.error('Failed to load server info:', e)
104
+
} finally {
105
+
loadingServerInfo = false
106
+
}
107
+
}
108
+
109
+
function validateInfoStep(): string | null {
110
+
if (!flow) return 'Flow not initialized'
111
+
const info = flow.info
112
+
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
113
+
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
114
+
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
115
+
return $_('registerPasskey.errors.inviteRequired')
116
+
}
117
+
if (info.didType === 'web-external') {
118
+
if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
119
+
if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
120
+
}
121
+
switch (info.verificationChannel) {
122
+
case 'email':
123
+
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
124
+
break
125
+
case 'discord':
126
+
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
127
+
break
128
+
case 'telegram':
129
+
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
130
+
break
131
+
case 'signal':
132
+
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
133
+
break
134
+
}
135
+
return null
136
+
}
137
+
138
+
async function handleInfoSubmit(e: Event) {
139
+
e.preventDefault()
140
+
if (!flow) return
141
+
142
+
const validationError = validateInfoStep()
143
+
if (validationError) {
144
+
flow.setError(validationError)
145
+
return
146
+
}
147
+
148
+
if (!window.PublicKeyCredential) {
149
+
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
150
+
return
151
+
}
152
+
153
+
flow.clearError()
154
+
flow.proceedFromInfo()
155
+
}
156
+
157
+
async function handlePasskeyRegistration() {
158
+
if (!flow || !flow.account) return
159
+
160
+
flow.setSubmitting(true)
161
+
flow.clearError()
162
+
163
+
try {
164
+
const { options } = await api.startPasskeyRegistrationForSetup(
165
+
flow.account.did,
166
+
flow.account.setupToken!,
167
+
passkeyName || undefined
168
+
)
169
+
170
+
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
171
+
const credential = await navigator.credentials.create({
172
+
publicKey: publicKeyOptions
173
+
})
174
+
175
+
if (!credential) {
176
+
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
177
+
flow.setSubmitting(false)
178
+
return
179
+
}
180
+
181
+
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
182
+
183
+
const result = await api.completePasskeySetup(
184
+
flow.account.did,
185
+
flow.account.setupToken!,
186
+
credentialResponse,
187
+
passkeyName || undefined
188
+
)
189
+
190
+
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
191
+
} catch (err) {
192
+
if (err instanceof DOMException && err.name === 'NotAllowedError') {
193
+
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
194
+
} else if (err instanceof Error) {
195
+
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
196
+
} else {
197
+
flow.setError($_('registerPasskey.errors.passkeyFailed'))
198
+
}
199
+
} finally {
200
+
flow.setSubmitting(false)
201
+
}
202
+
}
203
+
204
+
async function completeOAuthRegistration() {
205
+
const requestUri = getRequestUri()
206
+
if (!requestUri || !flow?.account) {
207
+
navigate(routes.dashboard)
208
+
return
209
+
}
210
+
211
+
try {
212
+
const response = await fetch('/oauth/register/complete', {
213
+
method: 'POST',
214
+
headers: {
215
+
'Content-Type': 'application/json',
216
+
'Accept': 'application/json',
217
+
},
218
+
body: JSON.stringify({
219
+
request_uri: requestUri,
220
+
did: flow.account.did,
221
+
app_password: flow.account.appPassword,
222
+
}),
223
+
})
224
+
225
+
const data = await response.json()
226
+
227
+
if (!response.ok) {
228
+
flow.setError(data.error_description || data.error || $_('common.error'))
229
+
return
230
+
}
231
+
232
+
if (data.redirect_uri) {
233
+
window.location.href = data.redirect_uri
234
+
return
235
+
}
236
+
237
+
navigate(routes.dashboard)
238
+
} catch {
239
+
flow.setError($_('common.error'))
240
+
}
241
+
}
242
+
243
+
function isChannelAvailable(ch: string): boolean {
244
+
const available = serverInfo?.availableCommsChannels ?? ['email']
245
+
return available.includes(ch)
246
+
}
247
+
248
+
function channelLabel(ch: string): string {
249
+
switch (ch) {
250
+
case 'email':
251
+
return $_('register.email')
252
+
case 'discord':
253
+
return $_('register.discord')
254
+
case 'telegram':
255
+
return $_('register.telegram')
256
+
case 'signal':
257
+
return $_('register.signal')
258
+
default:
259
+
return ch
260
+
}
261
+
}
262
+
263
+
let fullHandle = $derived(() => {
264
+
if (!flow?.info.handle.trim()) return ''
265
+
return `${flow.info.handle.trim()}.${flow.state.pdsHostname}`
266
+
})
267
+
268
+
async function handleCancel() {
269
+
const requestUri = getRequestUri()
270
+
if (!requestUri) {
271
+
window.history.back()
272
+
return
273
+
}
274
+
275
+
try {
276
+
const response = await fetch('/oauth/authorize/deny', {
277
+
method: 'POST',
278
+
headers: {
279
+
'Content-Type': 'application/json',
280
+
'Accept': 'application/json'
281
+
},
282
+
body: JSON.stringify({ request_uri: requestUri })
283
+
})
284
+
285
+
const data = await response.json()
286
+
if (data.redirect_uri) {
287
+
window.location.href = data.redirect_uri
288
+
}
289
+
} catch {
290
+
window.history.back()
291
+
}
292
+
}
293
+
294
+
function goToLogin() {
295
+
const requestUri = getRequestUri()
296
+
if (requestUri) {
297
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
298
+
} else {
299
+
navigate(routes.login)
300
+
}
301
+
}
302
+
</script>
303
+
304
+
<div class="oauth-register-container">
305
+
{#if loadingServerInfo}
306
+
<div class="loading">
307
+
<div class="spinner"></div>
308
+
<p>{$_('common.loading')}</p>
309
+
</div>
310
+
{:else if flow}
311
+
<header class="page-header">
312
+
<h1>{$_('oauth.register.title')}</h1>
313
+
<p class="subtitle">
314
+
{#if clientName}
315
+
{$_('oauth.register.subtitle')} <strong>{clientName}</strong>
316
+
{:else}
317
+
{$_('oauth.register.subtitleGeneric')}
318
+
{/if}
319
+
</p>
320
+
</header>
321
+
322
+
{#if flow.state.error}
323
+
<div class="error">{flow.state.error}</div>
324
+
{/if}
325
+
326
+
{#if flow.state.step === 'info'}
327
+
<div class="migrate-callout">
328
+
<div class="migrate-icon">โ</div>
329
+
<div class="migrate-content">
330
+
<strong>{$_('register.migrateTitle')}</strong>
331
+
<p>{$_('register.migrateDescription')}</p>
332
+
<a href={getFullUrl(routes.migrate)} class="migrate-link">
333
+
{$_('register.migrateLink')} โ
334
+
</a>
335
+
</div>
336
+
</div>
337
+
338
+
<AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUri()} />
339
+
340
+
<div class="split-layout">
341
+
<div class="form-section">
342
+
<form onsubmit={handleInfoSubmit}>
343
+
<div class="field">
344
+
<label for="handle">{$_('register.handle')}</label>
345
+
<input
346
+
id="handle"
347
+
type="text"
348
+
bind:value={flow.info.handle}
349
+
placeholder={$_('register.handlePlaceholder')}
350
+
disabled={flow.state.submitting}
351
+
required
352
+
autocomplete="off"
353
+
/>
354
+
{#if fullHandle()}
355
+
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
356
+
{/if}
357
+
</div>
358
+
359
+
<fieldset>
360
+
<legend>{$_('register.contactMethod')}</legend>
361
+
<div class="contact-fields">
362
+
<div class="field">
363
+
<label for="verification-channel">{$_('register.verificationMethod')}</label>
364
+
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
365
+
<option value="email">{channelLabel('email')}</option>
366
+
{#if isChannelAvailable('discord')}
367
+
<option value="discord">{channelLabel('discord')}</option>
368
+
{/if}
369
+
{#if isChannelAvailable('telegram')}
370
+
<option value="telegram">{channelLabel('telegram')}</option>
371
+
{/if}
372
+
{#if isChannelAvailable('signal')}
373
+
<option value="signal">{channelLabel('signal')}</option>
374
+
{/if}
375
+
</select>
376
+
</div>
377
+
378
+
{#if flow.info.verificationChannel === 'email'}
379
+
<div class="field">
380
+
<label for="email">{$_('register.emailAddress')}</label>
381
+
<input
382
+
id="email"
383
+
type="email"
384
+
bind:value={flow.info.email}
385
+
placeholder={$_('register.emailPlaceholder')}
386
+
disabled={flow.state.submitting}
387
+
required
388
+
/>
389
+
</div>
390
+
{:else if flow.info.verificationChannel === 'discord'}
391
+
<div class="field">
392
+
<label for="discord-id">{$_('register.discordId')}</label>
393
+
<input
394
+
id="discord-id"
395
+
type="text"
396
+
bind:value={flow.info.discordId}
397
+
placeholder={$_('register.discordIdPlaceholder')}
398
+
disabled={flow.state.submitting}
399
+
required
400
+
/>
401
+
<p class="hint">{$_('register.discordIdHint')}</p>
402
+
</div>
403
+
{:else if flow.info.verificationChannel === 'telegram'}
404
+
<div class="field">
405
+
<label for="telegram-username">{$_('register.telegramUsername')}</label>
406
+
<input
407
+
id="telegram-username"
408
+
type="text"
409
+
bind:value={flow.info.telegramUsername}
410
+
placeholder={$_('register.telegramUsernamePlaceholder')}
411
+
disabled={flow.state.submitting}
412
+
required
413
+
/>
414
+
</div>
415
+
{:else if flow.info.verificationChannel === 'signal'}
416
+
<div class="field">
417
+
<label for="signal-number">{$_('register.signalNumber')}</label>
418
+
<input
419
+
id="signal-number"
420
+
type="tel"
421
+
bind:value={flow.info.signalNumber}
422
+
placeholder={$_('register.signalNumberPlaceholder')}
423
+
disabled={flow.state.submitting}
424
+
required
425
+
/>
426
+
<p class="hint">{$_('register.signalNumberHint')}</p>
427
+
</div>
428
+
{/if}
429
+
</div>
430
+
</fieldset>
431
+
432
+
<fieldset>
433
+
<legend>{$_('registerPasskey.identityType')}</legend>
434
+
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
435
+
<div class="radio-group">
436
+
<label class="radio-label">
437
+
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
438
+
<span class="radio-content">
439
+
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
440
+
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
441
+
</span>
442
+
</label>
443
+
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
444
+
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
445
+
<span class="radio-content">
446
+
<strong>{$_('registerPasskey.didWeb')}</strong>
447
+
{#if serverInfo?.selfHostedDidWebEnabled === false}
448
+
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
449
+
{:else}
450
+
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
451
+
{/if}
452
+
</span>
453
+
</label>
454
+
<label class="radio-label">
455
+
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
456
+
<span class="radio-content">
457
+
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
458
+
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
459
+
</span>
460
+
</label>
461
+
</div>
462
+
{#if flow.info.didType === 'web'}
463
+
<div class="warning-box">
464
+
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
465
+
<ul>
466
+
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
467
+
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
468
+
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
469
+
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
470
+
</ul>
471
+
</div>
472
+
{/if}
473
+
{#if flow.info.didType === 'web-external'}
474
+
<div class="field">
475
+
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
476
+
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
477
+
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
478
+
</div>
479
+
{/if}
480
+
</fieldset>
481
+
482
+
{#if serverInfo?.inviteCodeRequired}
483
+
<div class="field">
484
+
<label for="invite-code">{$_('register.inviteCode')} <span class="required">*</span></label>
485
+
<input
486
+
id="invite-code"
487
+
type="text"
488
+
bind:value={flow.info.inviteCode}
489
+
placeholder={$_('register.inviteCodePlaceholder')}
490
+
disabled={flow.state.submitting}
491
+
required
492
+
/>
493
+
</div>
494
+
{/if}
495
+
496
+
<div class="actions">
497
+
<button type="submit" class="primary" disabled={flow.state.submitting}>
498
+
{flow.state.submitting ? $_('common.loading') : $_('common.continue')}
499
+
</button>
500
+
</div>
501
+
502
+
<div class="secondary-actions">
503
+
<button type="button" class="link-btn" onclick={goToLogin}>
504
+
{$_('oauth.register.haveAccount')}
505
+
</button>
506
+
<button type="button" class="link-btn" onclick={handleCancel}>
507
+
{$_('common.cancel')}
508
+
</button>
509
+
</div>
510
+
</form>
511
+
512
+
<div class="form-links">
513
+
<p class="link-text">
514
+
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
515
+
</p>
516
+
</div>
517
+
</div>
518
+
519
+
<aside class="info-panel">
520
+
<h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
521
+
<p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
522
+
523
+
<h3>{$_('registerPasskey.infoHowItWorks')}</h3>
524
+
<p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
525
+
526
+
<h3>{$_('registerPasskey.infoAppAccess')}</h3>
527
+
<p>{$_('registerPasskey.infoAppAccessDesc')}</p>
528
+
</aside>
529
+
</div>
530
+
531
+
{:else if flow.state.step === 'key-choice'}
532
+
<KeyChoiceStep {flow} />
533
+
534
+
{:else if flow.state.step === 'initial-did-doc'}
535
+
<DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} />
536
+
537
+
{:else if flow.state.step === 'creating'}
538
+
<div class="creating">
539
+
<div class="spinner"></div>
540
+
<p>{$_('registerPasskey.creatingAccount')}</p>
541
+
</div>
542
+
543
+
{:else if flow.state.step === 'passkey'}
544
+
<div class="passkey-step">
545
+
<h2>{$_('registerPasskey.setupPasskey')}</h2>
546
+
<p>{$_('registerPasskey.passkeyDescription')}</p>
547
+
548
+
<div class="field">
549
+
<label for="passkey-name">{$_('registerPasskey.passkeyName')}</label>
550
+
<input
551
+
id="passkey-name"
552
+
type="text"
553
+
bind:value={passkeyName}
554
+
placeholder={$_('registerPasskey.passkeyNamePlaceholder')}
555
+
disabled={flow.state.submitting}
556
+
/>
557
+
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
558
+
</div>
559
+
560
+
<button
561
+
type="button"
562
+
class="primary"
563
+
onclick={handlePasskeyRegistration}
564
+
disabled={flow.state.submitting}
565
+
>
566
+
{flow.state.submitting ? $_('common.loading') : $_('registerPasskey.registerPasskey')}
567
+
</button>
568
+
</div>
569
+
570
+
{:else if flow.state.step === 'app-password'}
571
+
<AppPasswordStep {flow} />
572
+
573
+
{:else if flow.state.step === 'verify'}
574
+
<VerificationStep {flow} />
575
+
576
+
{:else if flow.state.step === 'updated-did-doc'}
577
+
<DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} />
578
+
579
+
{:else if flow.state.step === 'activating'}
580
+
<div class="creating">
581
+
<div class="spinner"></div>
582
+
<p>{$_('registerPasskey.activatingAccount')}</p>
583
+
</div>
584
+
{/if}
585
+
{/if}
586
+
</div>
587
+
588
+
<style>
589
+
.oauth-register-container {
590
+
max-width: var(--width-lg);
591
+
margin: var(--space-9) auto;
592
+
padding: var(--space-7);
593
+
}
594
+
595
+
.loading, .creating {
596
+
display: flex;
597
+
flex-direction: column;
598
+
align-items: center;
599
+
gap: var(--space-4);
600
+
padding: var(--space-8);
601
+
}
602
+
603
+
.loading p, .creating p {
604
+
color: var(--text-secondary);
605
+
}
606
+
607
+
.page-header {
608
+
margin-bottom: var(--space-6);
609
+
}
610
+
611
+
.page-header h1 {
612
+
margin: 0 0 var(--space-2) 0;
613
+
}
614
+
615
+
.subtitle {
616
+
color: var(--text-secondary);
617
+
margin: 0;
618
+
}
619
+
620
+
.form-section {
621
+
min-width: 0;
622
+
}
623
+
624
+
.form-links {
625
+
margin-top: var(--space-6);
626
+
}
627
+
628
+
.link-text {
629
+
text-align: center;
630
+
color: var(--text-secondary);
631
+
}
632
+
633
+
.link-text a {
634
+
color: var(--accent);
635
+
}
636
+
637
+
form {
638
+
display: flex;
639
+
flex-direction: column;
640
+
gap: var(--space-5);
641
+
}
642
+
643
+
.field {
644
+
display: flex;
645
+
flex-direction: column;
646
+
gap: var(--space-1);
647
+
}
648
+
649
+
label {
650
+
font-size: var(--text-sm);
651
+
font-weight: var(--font-medium);
652
+
}
653
+
654
+
input, select {
655
+
padding: var(--space-3);
656
+
border: 1px solid var(--border-color);
657
+
border-radius: var(--radius-md);
658
+
font-size: var(--text-base);
659
+
background: var(--bg-input);
660
+
color: var(--text-primary);
661
+
}
662
+
663
+
input:focus, select:focus {
664
+
outline: none;
665
+
border-color: var(--accent);
666
+
}
667
+
668
+
.hint {
669
+
font-size: var(--text-xs);
670
+
color: var(--text-muted);
671
+
margin: var(--space-1) 0 0 0;
672
+
}
673
+
674
+
.error {
675
+
padding: var(--space-3);
676
+
background: var(--error-bg);
677
+
border: 1px solid var(--error-border);
678
+
border-radius: var(--radius-md);
679
+
color: var(--error-text);
680
+
margin-bottom: var(--space-4);
681
+
}
682
+
683
+
.actions {
684
+
display: flex;
685
+
gap: var(--space-4);
686
+
margin-top: var(--space-2);
687
+
}
688
+
689
+
button.primary {
690
+
flex: 1;
691
+
padding: var(--space-3);
692
+
background: var(--accent);
693
+
color: var(--text-inverse);
694
+
border: none;
695
+
border-radius: var(--radius-md);
696
+
font-size: var(--text-base);
697
+
cursor: pointer;
698
+
transition: background-color var(--transition-fast);
699
+
}
700
+
701
+
button.primary:hover:not(:disabled) {
702
+
background: var(--accent-hover);
703
+
}
704
+
705
+
button.primary:disabled {
706
+
opacity: 0.6;
707
+
cursor: not-allowed;
708
+
}
709
+
710
+
.secondary-actions {
711
+
display: flex;
712
+
justify-content: center;
713
+
gap: var(--space-4);
714
+
margin-top: var(--space-4);
715
+
}
716
+
717
+
.link-btn {
718
+
background: none;
719
+
border: none;
720
+
color: var(--accent);
721
+
cursor: pointer;
722
+
font-size: var(--text-sm);
723
+
padding: var(--space-2);
724
+
}
725
+
726
+
.link-btn:hover {
727
+
text-decoration: underline;
728
+
}
729
+
730
+
.contact-fields {
731
+
display: flex;
732
+
flex-direction: column;
733
+
gap: var(--space-4);
734
+
}
735
+
736
+
.required {
737
+
color: var(--error-text);
738
+
}
739
+
740
+
.passkey-step {
741
+
display: flex;
742
+
flex-direction: column;
743
+
gap: var(--space-4);
744
+
}
745
+
746
+
.passkey-step h2 {
747
+
margin: 0;
748
+
}
749
+
750
+
.passkey-step p {
751
+
color: var(--text-secondary);
752
+
margin: 0;
753
+
}
754
+
755
+
fieldset {
756
+
border: 1px solid var(--border-color);
757
+
border-radius: var(--radius-md);
758
+
padding: var(--space-4);
759
+
}
760
+
761
+
legend {
762
+
padding: 0 var(--space-2);
763
+
font-weight: var(--font-medium);
764
+
}
765
+
766
+
.spinner {
767
+
width: 32px;
768
+
height: 32px;
769
+
border: 3px solid var(--border-color);
770
+
border-top-color: var(--accent);
771
+
border-radius: 50%;
772
+
animation: spin 1s linear infinite;
773
+
}
774
+
775
+
@keyframes spin {
776
+
to {
777
+
transform: rotate(360deg);
778
+
}
779
+
}
780
+
</style>
+680
frontend/src/routes/OAuthSsoRegister.svelte
+680
frontend/src/routes/OAuthSsoRegister.svelte
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte'
3
+
import { _ } from '../lib/i18n'
4
+
import { toast } from '../lib/toast.svelte'
5
+
import SsoIcon from '../components/SsoIcon.svelte'
6
+
7
+
interface PendingRegistration {
8
+
request_uri: string
9
+
provider: string
10
+
provider_user_id: string
11
+
provider_username: string | null
12
+
provider_email: string | null
13
+
provider_email_verified: boolean
14
+
}
15
+
16
+
interface CommsChannelConfig {
17
+
email: boolean
18
+
discord: boolean
19
+
telegram: boolean
20
+
signal: boolean
21
+
}
22
+
23
+
let pending = $state<PendingRegistration | null>(null)
24
+
let loading = $state(true)
25
+
let submitting = $state(false)
26
+
let error = $state<string | null>(null)
27
+
28
+
let handle = $state('')
29
+
let email = $state('')
30
+
let providerEmailOriginal = $state<string | null>(null)
31
+
let inviteCode = $state('')
32
+
let verificationChannel = $state('email')
33
+
let discordId = $state('')
34
+
let telegramUsername = $state('')
35
+
let signalNumber = $state('')
36
+
37
+
let handleAvailable = $state<boolean | null>(null)
38
+
let checkingHandle = $state(false)
39
+
let handleError = $state<string | null>(null)
40
+
41
+
let didType = $state<'plc' | 'web' | 'web-external'>('plc')
42
+
let externalDid = $state('')
43
+
44
+
let serverInfo = $state<{
45
+
availableUserDomains: string[]
46
+
inviteCodeRequired: boolean
47
+
selfHostedDidWebEnabled: boolean
48
+
} | null>(null)
49
+
50
+
let commsChannels = $state<CommsChannelConfig>({
51
+
email: true,
52
+
discord: false,
53
+
telegram: false,
54
+
signal: false,
55
+
})
56
+
57
+
function getToken(): string | null {
58
+
const params = new URLSearchParams(window.location.search)
59
+
return params.get('token')
60
+
}
61
+
62
+
function getProviderDisplayName(provider: string): string {
63
+
const names: Record<string, string> = {
64
+
github: 'GitHub',
65
+
discord: 'Discord',
66
+
google: 'Google',
67
+
gitlab: 'GitLab',
68
+
oidc: 'SSO',
69
+
}
70
+
return names[provider] || provider
71
+
}
72
+
73
+
function isChannelAvailable(ch: string): boolean {
74
+
return commsChannels[ch as keyof CommsChannelConfig] ?? false
75
+
}
76
+
77
+
function extractDomain(did: string): string {
78
+
return did.replace('did:web:', '').replace(/%3A/g, ':')
79
+
}
80
+
81
+
let fullHandle = $derived(() => {
82
+
if (!handle.trim()) return ''
83
+
const domain = serverInfo?.availableUserDomains?.[0]
84
+
return domain ? `${handle.trim()}.${domain}` : handle.trim()
85
+
})
86
+
87
+
onMount(() => {
88
+
loadPendingRegistration()
89
+
loadServerInfo()
90
+
})
91
+
92
+
async function loadServerInfo() {
93
+
try {
94
+
const response = await fetch('/xrpc/com.atproto.server.describeServer')
95
+
if (response.ok) {
96
+
const data = await response.json()
97
+
serverInfo = {
98
+
availableUserDomains: data.availableUserDomains || [],
99
+
inviteCodeRequired: data.inviteCodeRequired ?? false,
100
+
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
101
+
}
102
+
if (data.commsChannels) {
103
+
commsChannels = {
104
+
email: data.commsChannels.email ?? true,
105
+
discord: data.commsChannels.discord ?? false,
106
+
telegram: data.commsChannels.telegram ?? false,
107
+
signal: data.commsChannels.signal ?? false,
108
+
}
109
+
}
110
+
}
111
+
} catch {
112
+
serverInfo = null
113
+
}
114
+
}
115
+
116
+
async function loadPendingRegistration() {
117
+
const token = getToken()
118
+
if (!token) {
119
+
error = $_('sso_register.error_expired')
120
+
loading = false
121
+
return
122
+
}
123
+
124
+
try {
125
+
const response = await fetch(`/oauth/sso/pending-registration?token=${encodeURIComponent(token)}`)
126
+
if (!response.ok) {
127
+
const data = await response.json()
128
+
error = data.message || $_('sso_register.error_expired')
129
+
loading = false
130
+
return
131
+
}
132
+
133
+
pending = await response.json()
134
+
if (pending?.provider_email) {
135
+
email = pending.provider_email
136
+
providerEmailOriginal = pending.provider_email
137
+
}
138
+
if (pending?.provider_username) {
139
+
handle = pending.provider_username.toLowerCase().replace(/[^a-z0-9-]/g, '')
140
+
}
141
+
} catch {
142
+
error = $_('sso_register.error_expired')
143
+
} finally {
144
+
loading = false
145
+
}
146
+
}
147
+
148
+
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
149
+
150
+
$effect(() => {
151
+
if (checkHandleTimeout) {
152
+
clearTimeout(checkHandleTimeout)
153
+
}
154
+
handleAvailable = null
155
+
handleError = null
156
+
if (handle.length >= 3) {
157
+
checkHandleTimeout = setTimeout(() => checkHandleAvailability(), 400)
158
+
}
159
+
})
160
+
161
+
async function checkHandleAvailability() {
162
+
if (!handle || handle.length < 3) return
163
+
164
+
checkingHandle = true
165
+
handleError = null
166
+
167
+
try {
168
+
const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`)
169
+
const data = await response.json()
170
+
handleAvailable = data.available
171
+
if (!data.available && data.reason) {
172
+
handleError = data.reason
173
+
}
174
+
} catch {
175
+
handleAvailable = null
176
+
handleError = $_('common.error')
177
+
} finally {
178
+
checkingHandle = false
179
+
}
180
+
}
181
+
182
+
let usingVerifiedProviderEmail = $derived(
183
+
pending?.provider_email_verified &&
184
+
verificationChannel === 'email' &&
185
+
email.trim().toLowerCase() === providerEmailOriginal?.toLowerCase()
186
+
)
187
+
188
+
function isChannelValid(): boolean {
189
+
switch (verificationChannel) {
190
+
case 'email':
191
+
return !!email.trim()
192
+
case 'discord':
193
+
return !!discordId.trim()
194
+
case 'telegram':
195
+
return !!telegramUsername.trim()
196
+
case 'signal':
197
+
return !!signalNumber.trim()
198
+
default:
199
+
return false
200
+
}
201
+
}
202
+
203
+
async function handleSubmit(e: Event) {
204
+
e.preventDefault()
205
+
const token = getToken()
206
+
if (!token || !pending) return
207
+
208
+
if (!handle || handle.length < 3) {
209
+
handleError = $_('sso_register.error_handle_required')
210
+
return
211
+
}
212
+
213
+
if (handleAvailable === false) {
214
+
handleError = $_('sso_register.handle_taken')
215
+
return
216
+
}
217
+
218
+
if (!isChannelValid()) {
219
+
toast.error($_(`register.validation.${verificationChannel === 'email' ? 'emailRequired' : verificationChannel + 'Required'}`))
220
+
return
221
+
}
222
+
223
+
submitting = true
224
+
225
+
try {
226
+
const response = await fetch('/oauth/sso/complete-registration', {
227
+
method: 'POST',
228
+
headers: {
229
+
'Content-Type': 'application/json',
230
+
'Accept': 'application/json',
231
+
},
232
+
body: JSON.stringify({
233
+
token,
234
+
handle,
235
+
email: email || null,
236
+
invite_code: inviteCode || null,
237
+
verification_channel: verificationChannel,
238
+
discord_id: discordId || null,
239
+
telegram_username: telegramUsername || null,
240
+
signal_number: signalNumber || null,
241
+
did_type: didType,
242
+
did: didType === 'web-external' ? externalDid.trim() : null,
243
+
}),
244
+
})
245
+
246
+
const data = await response.json()
247
+
248
+
if (!response.ok) {
249
+
toast.error(data.message || data.error_description || data.error || $_('common.error'))
250
+
submitting = false
251
+
return
252
+
}
253
+
254
+
if (data.accessJwt && data.refreshJwt) {
255
+
localStorage.setItem('accessJwt', data.accessJwt)
256
+
localStorage.setItem('refreshJwt', data.refreshJwt)
257
+
}
258
+
259
+
if (data.redirectUrl) {
260
+
if (data.redirectUrl.startsWith('/app/verify')) {
261
+
localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
262
+
did: data.did,
263
+
handle: data.handle,
264
+
channel: verificationChannel,
265
+
}))
266
+
const url = new URL(data.redirectUrl, window.location.origin)
267
+
url.searchParams.set('handle', data.handle)
268
+
url.searchParams.set('channel', verificationChannel)
269
+
window.location.href = url.pathname + url.search
270
+
return
271
+
}
272
+
window.location.href = data.redirectUrl
273
+
return
274
+
}
275
+
276
+
toast.error($_('common.error'))
277
+
submitting = false
278
+
} catch {
279
+
toast.error($_('common.error'))
280
+
submitting = false
281
+
}
282
+
}
283
+
</script>
284
+
285
+
<div class="sso-register-container">
286
+
{#if loading}
287
+
<div class="loading">
288
+
<div class="spinner"></div>
289
+
<p>{$_('common.loading')}</p>
290
+
</div>
291
+
{:else if error && !pending}
292
+
<div class="error-container">
293
+
<div class="error-icon">!</div>
294
+
<h2>{$_('common.error')}</h2>
295
+
<p>{error}</p>
296
+
<a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
297
+
</div>
298
+
{:else if pending}
299
+
<header class="page-header">
300
+
<h1>{$_('sso_register.title')}</h1>
301
+
<p class="subtitle">{$_('sso_register.subtitle', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
302
+
</header>
303
+
304
+
<div class="provider-info">
305
+
<div class="provider-badge">
306
+
<SsoIcon provider={pending.provider} size={32} />
307
+
<div class="provider-details">
308
+
<span class="provider-name">{getProviderDisplayName(pending.provider)}</span>
309
+
{#if pending.provider_username}
310
+
<span class="provider-username">@{pending.provider_username}</span>
311
+
{/if}
312
+
</div>
313
+
</div>
314
+
</div>
315
+
316
+
<div class="split-layout sidebar-right">
317
+
<div class="form-section">
318
+
<form onsubmit={handleSubmit}>
319
+
<div class="field">
320
+
<label for="handle">{$_('sso_register.handle_label')}</label>
321
+
<input
322
+
id="handle"
323
+
type="text"
324
+
bind:value={handle}
325
+
placeholder={$_('register.handlePlaceholder')}
326
+
disabled={submitting}
327
+
required
328
+
autocomplete="off"
329
+
/>
330
+
{#if checkingHandle}
331
+
<p class="hint">{$_('common.checking')}</p>
332
+
{:else if handleError}
333
+
<p class="hint error">{handleError}</p>
334
+
{:else if handleAvailable === false}
335
+
<p class="hint error">{$_('sso_register.handle_taken')}</p>
336
+
{:else if handleAvailable === true}
337
+
<p class="hint success">{$_('sso_register.handle_available')}</p>
338
+
{:else if fullHandle()}
339
+
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
340
+
{/if}
341
+
</div>
342
+
343
+
<fieldset>
344
+
<legend>{$_('register.contactMethod')}</legend>
345
+
<div class="contact-fields">
346
+
<div class="field">
347
+
<label for="verification-channel">{$_('register.verificationMethod')}</label>
348
+
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
349
+
<option value="email">{$_('register.email')}</option>
350
+
<option value="discord" disabled={!isChannelAvailable('discord')}>
351
+
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
352
+
</option>
353
+
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
354
+
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
355
+
</option>
356
+
<option value="signal" disabled={!isChannelAvailable('signal')}>
357
+
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
358
+
</option>
359
+
</select>
360
+
</div>
361
+
362
+
{#if verificationChannel === 'email'}
363
+
<div class="field">
364
+
<label for="email">{$_('register.emailAddress')}</label>
365
+
<input
366
+
id="email"
367
+
type="email"
368
+
bind:value={email}
369
+
placeholder={$_('register.emailPlaceholder')}
370
+
disabled={submitting}
371
+
required
372
+
/>
373
+
{#if pending?.provider_email && pending?.provider_email_verified}
374
+
{#if usingVerifiedProviderEmail}
375
+
<p class="hint success">{$_('sso_register.emailVerifiedByProvider', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
376
+
{:else}
377
+
<p class="hint">{$_('sso_register.emailChangedNeedsVerification')}</p>
378
+
{/if}
379
+
{/if}
380
+
</div>
381
+
{:else if verificationChannel === 'discord'}
382
+
<div class="field">
383
+
<label for="discord-id">{$_('register.discordId')}</label>
384
+
<input
385
+
id="discord-id"
386
+
type="text"
387
+
bind:value={discordId}
388
+
placeholder={$_('register.discordIdPlaceholder')}
389
+
disabled={submitting}
390
+
required
391
+
/>
392
+
<p class="hint">{$_('register.discordIdHint')}</p>
393
+
</div>
394
+
{:else if verificationChannel === 'telegram'}
395
+
<div class="field">
396
+
<label for="telegram-username">{$_('register.telegramUsername')}</label>
397
+
<input
398
+
id="telegram-username"
399
+
type="text"
400
+
bind:value={telegramUsername}
401
+
placeholder={$_('register.telegramUsernamePlaceholder')}
402
+
disabled={submitting}
403
+
required
404
+
/>
405
+
</div>
406
+
{:else if verificationChannel === 'signal'}
407
+
<div class="field">
408
+
<label for="signal-number">{$_('register.signalNumber')}</label>
409
+
<input
410
+
id="signal-number"
411
+
type="tel"
412
+
bind:value={signalNumber}
413
+
placeholder={$_('register.signalNumberPlaceholder')}
414
+
disabled={submitting}
415
+
required
416
+
/>
417
+
<p class="hint">{$_('register.signalNumberHint')}</p>
418
+
</div>
419
+
{/if}
420
+
</div>
421
+
</fieldset>
422
+
423
+
<fieldset>
424
+
<legend>{$_('registerPasskey.identityType')}</legend>
425
+
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
426
+
<div class="radio-group">
427
+
<label class="radio-label">
428
+
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
429
+
<span class="radio-content">
430
+
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
431
+
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
432
+
</span>
433
+
</label>
434
+
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
435
+
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
436
+
<span class="radio-content">
437
+
<strong>{$_('registerPasskey.didWeb')}</strong>
438
+
{#if serverInfo?.selfHostedDidWebEnabled === false}
439
+
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
440
+
{:else}
441
+
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
442
+
{/if}
443
+
</span>
444
+
</label>
445
+
<label class="radio-label">
446
+
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
447
+
<span class="radio-content">
448
+
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
449
+
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
450
+
</span>
451
+
</label>
452
+
</div>
453
+
{#if didType === 'web'}
454
+
<div class="warning-box">
455
+
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
456
+
<ul>
457
+
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
458
+
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
459
+
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
460
+
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
461
+
</ul>
462
+
</div>
463
+
{/if}
464
+
{#if didType === 'web-external'}
465
+
<div class="field">
466
+
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
467
+
<input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
468
+
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
469
+
</div>
470
+
{/if}
471
+
</fieldset>
472
+
473
+
{#if serverInfo?.inviteCodeRequired}
474
+
<div class="field">
475
+
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
476
+
<input
477
+
id="invite-code"
478
+
type="text"
479
+
bind:value={inviteCode}
480
+
placeholder={$_('register.inviteCodePlaceholder')}
481
+
disabled={submitting}
482
+
required
483
+
/>
484
+
</div>
485
+
{/if}
486
+
487
+
<button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}>
488
+
{submitting ? $_('common.creating') : $_('sso_register.submit')}
489
+
</button>
490
+
</form>
491
+
</div>
492
+
493
+
<aside class="info-panel">
494
+
<h3>{$_('sso_register.infoAfterTitle')}</h3>
495
+
<ul class="info-list">
496
+
<li>{$_('sso_register.infoAddPassword')}</li>
497
+
<li>{$_('sso_register.infoAddPasskey')}</li>
498
+
<li>{$_('sso_register.infoLinkProviders')}</li>
499
+
<li>{$_('sso_register.infoChangeHandle')}</li>
500
+
</ul>
501
+
</aside>
502
+
</div>
503
+
{/if}
504
+
</div>
505
+
506
+
<style>
507
+
.sso-register-container {
508
+
max-width: var(--width-lg);
509
+
margin: var(--space-9) auto;
510
+
padding: var(--space-7);
511
+
}
512
+
513
+
.loading {
514
+
display: flex;
515
+
flex-direction: column;
516
+
align-items: center;
517
+
gap: var(--space-4);
518
+
padding: var(--space-8);
519
+
}
520
+
521
+
.loading p {
522
+
color: var(--text-secondary);
523
+
}
524
+
525
+
.error-container {
526
+
text-align: center;
527
+
padding: var(--space-8);
528
+
}
529
+
530
+
.error-icon {
531
+
width: 48px;
532
+
height: 48px;
533
+
border-radius: 50%;
534
+
background: var(--error-text);
535
+
color: var(--text-inverse);
536
+
display: flex;
537
+
align-items: center;
538
+
justify-content: center;
539
+
font-size: 24px;
540
+
font-weight: bold;
541
+
margin: 0 auto var(--space-4);
542
+
}
543
+
544
+
.error-container h2 {
545
+
margin-bottom: var(--space-2);
546
+
}
547
+
548
+
.error-container p {
549
+
color: var(--text-secondary);
550
+
margin-bottom: var(--space-6);
551
+
}
552
+
553
+
.back-link {
554
+
color: var(--accent);
555
+
text-decoration: none;
556
+
}
557
+
558
+
.back-link:hover {
559
+
text-decoration: underline;
560
+
}
561
+
562
+
.page-header {
563
+
margin-bottom: var(--space-6);
564
+
}
565
+
566
+
.page-header h1 {
567
+
margin: 0 0 var(--space-3) 0;
568
+
}
569
+
570
+
.subtitle {
571
+
color: var(--text-secondary);
572
+
margin: 0;
573
+
}
574
+
575
+
.form-section {
576
+
min-width: 0;
577
+
}
578
+
579
+
form {
580
+
display: flex;
581
+
flex-direction: column;
582
+
gap: var(--space-5);
583
+
}
584
+
585
+
.contact-fields {
586
+
display: flex;
587
+
flex-direction: column;
588
+
gap: var(--space-4);
589
+
}
590
+
591
+
.contact-fields .field {
592
+
margin-bottom: 0;
593
+
}
594
+
595
+
.hint.success {
596
+
color: var(--success-text);
597
+
}
598
+
599
+
.hint.error {
600
+
color: var(--error-text);
601
+
}
602
+
603
+
.info-panel {
604
+
background: var(--bg-secondary);
605
+
border-radius: var(--radius-xl);
606
+
padding: var(--space-6);
607
+
}
608
+
609
+
.info-panel h3 {
610
+
margin: 0 0 var(--space-4) 0;
611
+
font-size: var(--text-base);
612
+
font-weight: var(--font-semibold);
613
+
}
614
+
615
+
.info-list {
616
+
margin: 0;
617
+
padding-left: var(--space-5);
618
+
}
619
+
620
+
.info-list li {
621
+
margin-bottom: var(--space-2);
622
+
font-size: var(--text-sm);
623
+
color: var(--text-secondary);
624
+
line-height: var(--leading-relaxed);
625
+
}
626
+
627
+
.info-list li:last-child {
628
+
margin-bottom: 0;
629
+
}
630
+
631
+
.provider-info {
632
+
margin-bottom: var(--space-6);
633
+
}
634
+
635
+
.provider-badge {
636
+
display: flex;
637
+
align-items: center;
638
+
gap: var(--space-3);
639
+
padding: var(--space-4);
640
+
background: var(--bg-secondary);
641
+
border-radius: var(--radius-md);
642
+
}
643
+
644
+
.provider-details {
645
+
display: flex;
646
+
flex-direction: column;
647
+
}
648
+
649
+
.provider-name {
650
+
font-weight: var(--font-semibold);
651
+
}
652
+
653
+
.provider-username {
654
+
font-size: var(--text-sm);
655
+
color: var(--text-secondary);
656
+
}
657
+
658
+
.required {
659
+
color: var(--error-text);
660
+
}
661
+
662
+
button[type="submit"] {
663
+
margin-top: var(--space-3);
664
+
}
665
+
666
+
.spinner {
667
+
width: 32px;
668
+
height: 32px;
669
+
border: 3px solid var(--border-color);
670
+
border-top-color: var(--accent);
671
+
border-radius: 50%;
672
+
animation: spin 1s linear infinite;
673
+
}
674
+
675
+
@keyframes spin {
676
+
to {
677
+
transform: rotate(360deg);
678
+
}
679
+
}
680
+
</style>
+51
frontend/src/routes/RegisterPasskey.svelte
+51
frontend/src/routes/RegisterPasskey.svelte
···
1
+
<script lang="ts">
2
+
import { startOAuthRegister } from '../lib/oauth'
3
+
import { _ } from '../lib/i18n'
4
+
5
+
let error = $state<string | null>(null)
6
+
let initiated = false
7
+
8
+
$effect(() => {
9
+
if (!initiated) {
10
+
initiated = true
11
+
startOAuthRegister().catch((err) => {
12
+
error = err instanceof Error ? err.message : 'Failed to start registration'
13
+
})
14
+
}
15
+
})
16
+
</script>
17
+
18
+
<div class="register-redirect">
19
+
{#if error}
20
+
<div class="message error">{error}</div>
21
+
<a href="/app/login">{$_('register.signIn')}</a>
22
+
{:else}
23
+
<div class="loading-content">
24
+
<div class="spinner"></div>
25
+
<p>{$_('common.loading')}</p>
26
+
</div>
27
+
{/if}
28
+
</div>
29
+
30
+
<style>
31
+
.register-redirect {
32
+
min-height: 100vh;
33
+
display: flex;
34
+
flex-direction: column;
35
+
align-items: center;
36
+
justify-content: center;
37
+
gap: var(--space-4);
38
+
}
39
+
40
+
.loading-content {
41
+
display: flex;
42
+
flex-direction: column;
43
+
align-items: center;
44
+
gap: var(--space-4);
45
+
}
46
+
47
+
.loading-content p {
48
+
margin: 0;
49
+
color: var(--text-secondary);
50
+
}
51
+
</style>
+19
-85
scripts/install-debian.sh
+19
-85
scripts/install-debian.sh
···
44
44
sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true
45
45
sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true
46
46
47
-
log_info "Removing minio buckets..."
48
-
if command -v mc &>/dev/null; then
49
-
mc rb local/pds-blobs --force 2>/dev/null || true
50
-
mc rb local/pds-backups --force 2>/dev/null || true
51
-
mc alias remove local 2>/dev/null || true
52
-
fi
53
-
systemctl stop minio 2>/dev/null || true
54
-
rm -rf /var/lib/minio/data/.minio.sys 2>/dev/null || true
55
-
rm -f /etc/default/minio 2>/dev/null || true
47
+
log_info "Removing blob storage..."
48
+
rm -rf /var/lib/tranquil 2>/dev/null || true
56
49
57
50
log_info "Removing nginx config..."
58
51
rm -f /etc/nginx/sites-enabled/tranquil-pds
···
79
72
echo " - PostgreSQL database 'pds' and all data"
80
73
echo " - All Tranquil PDS configuration and credentials"
81
74
echo " - All source code in /opt/tranquil-pds"
82
-
echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data"
75
+
echo " - All blobs and backups in /var/lib/tranquil/"
83
76
echo ""
84
77
read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE
85
78
if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then
···
153
146
DPOP_SECRET=$(openssl rand -base64 48)
154
147
MASTER_KEY=$(openssl rand -base64 48)
155
148
DB_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
156
-
MINIO_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
157
149
158
150
mkdir -p /etc/tranquil-pds
159
151
cat > "$CREDENTIALS_FILE" << EOF
···
161
153
DPOP_SECRET="$DPOP_SECRET"
162
154
MASTER_KEY="$MASTER_KEY"
163
155
DB_PASSWORD="$DB_PASSWORD"
164
-
MINIO_PASSWORD="$MINIO_PASSWORD"
165
156
EOF
166
157
chmod 600 "$CREDENTIALS_FILE"
167
158
log_success "Secrets generated"
···
213
204
systemctl enable valkey-server 2>/dev/null || true
214
205
systemctl start valkey-server 2>/dev/null || true
215
206
216
-
log_info "Installing minio..."
217
-
if [[ ! -f /usr/local/bin/minio ]]; then
218
-
ARCH=$(dpkg --print-architecture)
219
-
case "$ARCH" in
220
-
amd64) curl -fsSL -o /tmp/minio https://dl.min.io/server/minio/release/linux-amd64/minio ;;
221
-
arm64) curl -fsSL -o /tmp/minio https://dl.min.io/server/minio/release/linux-arm64/minio ;;
222
-
*) log_error "Unsupported architecture: $ARCH"; exit 1 ;;
223
-
esac
224
-
chmod +x /tmp/minio
225
-
mv /tmp/minio /usr/local/bin/
226
-
fi
227
-
228
-
mkdir -p /var/lib/minio/data
229
-
id -u minio-user &>/dev/null || useradd -r -s /sbin/nologin minio-user
230
-
chown -R minio-user:minio-user /var/lib/minio
231
-
232
-
cat > /etc/default/minio << EOF
233
-
MINIO_ROOT_USER=minioadmin
234
-
MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}
235
-
MINIO_VOLUMES="/var/lib/minio/data"
236
-
MINIO_OPTS="--console-address :9001"
237
-
EOF
238
-
chmod 600 /etc/default/minio
239
-
240
-
cat > /etc/systemd/system/minio.service << 'EOF'
241
-
[Unit]
242
-
Description=MinIO Object Storage
243
-
After=network.target
244
-
245
-
[Service]
246
-
User=minio-user
247
-
Group=minio-user
248
-
EnvironmentFile=/etc/default/minio
249
-
ExecStart=/usr/local/bin/minio server $MINIO_VOLUMES $MINIO_OPTS
250
-
Restart=always
251
-
LimitNOFILE=65536
252
-
253
-
[Install]
254
-
WantedBy=multi-user.target
255
-
EOF
256
-
257
-
systemctl daemon-reload
258
-
systemctl enable minio
259
-
systemctl start minio
260
-
log_success "minio installed"
261
-
262
-
log_info "Waiting for minio..."
263
-
sleep 5
264
-
265
-
if [[ ! -f /usr/local/bin/mc ]]; then
266
-
ARCH=$(dpkg --print-architecture)
267
-
case "$ARCH" in
268
-
amd64) curl -fsSL -o /tmp/mc https://dl.min.io/client/mc/release/linux-amd64/mc ;;
269
-
arm64) curl -fsSL -o /tmp/mc https://dl.min.io/client/mc/release/linux-arm64/mc ;;
270
-
esac
271
-
chmod +x /tmp/mc
272
-
mv /tmp/mc /usr/local/bin/
273
-
fi
274
-
275
-
mc alias remove local 2>/dev/null || true
276
-
mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4
277
-
mc mb local/pds-blobs --ignore-existing
278
-
mc mb local/pds-backups --ignore-existing
279
-
log_success "minio buckets created"
207
+
log_info "Creating blob storage directories..."
208
+
mkdir -p /var/lib/tranquil/blobs /var/lib/tranquil/backups
209
+
log_success "Blob storage directories created"
280
210
281
211
log_info "Installing rust..."
282
212
if [[ -f "$HOME/.cargo/env" ]]; then
···
381
311
DATABASE_URL=postgres://tranquil_pds:${DB_PASSWORD}@localhost:5432/pds
382
312
DATABASE_MAX_CONNECTIONS=100
383
313
DATABASE_MIN_CONNECTIONS=10
384
-
S3_ENDPOINT=http://localhost:9000
385
-
AWS_REGION=us-east-1
386
-
S3_BUCKET=pds-blobs
387
-
BACKUP_S3_BUCKET=pds-backups
388
-
AWS_ACCESS_KEY_ID=minioadmin
389
-
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
314
+
BLOB_STORAGE_PATH=/var/lib/tranquil/blobs
315
+
BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
390
316
VALKEY_URL=redis://localhost:6379
391
317
JWT_SECRET=${JWT_SECRET}
392
318
DPOP_SECRET=${DPOP_SECRET}
···
406
332
mkdir -p /var/lib/tranquil-pds
407
333
cp -r /opt/tranquil-pds/frontend/dist /var/lib/tranquil-pds/frontend
408
334
chown -R tranquil-pds:tranquil-pds /var/lib/tranquil-pds
335
+
chown -R tranquil-pds:tranquil-pds /var/lib/tranquil
409
336
410
337
cat > /etc/systemd/system/tranquil-pds.service << 'EOF'
411
338
[Unit]
412
339
Description=Tranquil PDS - AT Protocol PDS
413
-
After=network.target postgresql.service minio.service
340
+
After=network.target postgresql.service
414
341
415
342
[Service]
416
343
Type=simple
···
420
347
ExecStart=/usr/local/bin/tranquil-pds
421
348
Restart=always
422
349
RestartSec=5
350
+
ProtectSystem=strict
351
+
ProtectHome=true
352
+
PrivateTmp=true
353
+
ReadWritePaths=/var/lib/tranquil
423
354
424
355
[Install]
425
356
WantedBy=multi-user.target
···
577
508
echo "PDS: https://${PDS_DOMAIN}"
578
509
echo ""
579
510
echo "Credentials (also in /etc/tranquil-pds/.credentials):"
580
-
echo " DB password: ${DB_PASSWORD}"
581
-
echo " MinIO password: ${MINIO_PASSWORD}"
511
+
echo " DB password: ${DB_PASSWORD}"
512
+
echo ""
513
+
echo "Data locations:"
514
+
echo " Blobs: /var/lib/tranquil/blobs"
515
+
echo " Backups: /var/lib/tranquil/backups"
582
516
echo ""
583
517
echo "Commands:"
584
518
echo " journalctl -u tranquil-pds -f # logs"
History
4 rounds
2 comments
expand 0 comments
pull request successfully merged
expand 2 comments
excellent idea, I have added that now, kiitos
working on tranquil.farm, https://tranquil.farm/xrpc/com.atproto.sync.getBlob?did=did:plc:3fwecdnvtcscjnrx2p4n7alz&cid=bafkreibhkuwqpqdd6yzye4bl3pdviobgxnhsy3tbtq72pm44zh5savsyyi
almost perfect. im gonna be even more picky and pedantic heh :3 id like the sharding to be even more like what other CAS on file systems do. ie id want bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku to be stored at /bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku
including the selfdescribing part is perhaps a bit redundant right now but will become quite useful if we or spec ever need to or want to expand the allowed types of cids for blobs.