+6
-6
Cargo.toml
+6
-6
Cargo.toml
···
82
82
complexity = { level = "warn", priority = -1 }
83
83
perf = { level = "warn", priority = -1 }
84
84
style = { level = "warn", priority = -1 }
85
-
# pedantic = { level = "warn", priority = -1 }
85
+
pedantic = { level = "warn", priority = -1 }
86
86
restriction = { level = "warn", priority = -1 }
87
87
cargo = { level = "warn", priority = -1 }
88
88
# Temporary Allows
89
-
single_call_fn = "allow"
90
-
multiple_crate_versions = "allow"
89
+
multiple_crate_versions = "allow" # triggered by lib
91
90
expect_used = "allow"
91
+
missing_docs_in_private_items = "allow"
92
92
# # Temporary Allows - Restriction
93
-
min_ident_chars = "allow"
93
+
min_ident_chars = "allow" # 50 instances
94
94
# arbitrary_source_item_ordering = "allow"
95
-
renamed_function_params = "allow"
95
+
renamed_function_params = "allow" # possibly triggered by lib
96
96
# pattern_type_mismatch = "allow"
97
97
# Style Allows
98
98
implicit_return = "allow"
···
116
116
ref_patterns = "allow"
117
117
question_mark_used = "allow"
118
118
shadow_reuse = "allow"
119
+
single_call_fn = "allow"
119
120
# Warns
120
-
missing_docs_in_private_items = "warn"
121
121
use_self = "warn"
122
122
str_to_string = "warn"
123
123
print_stdout = "warn"
+7
-9
src/auth.rs
+7
-9
src/auth.rs
···
15
15
/// If specified in an API endpoint, this will guarantee that the API can only be called
16
16
/// by an authenticated user.
17
17
pub(crate) struct AuthenticatedUser {
18
+
/// The DID of the authenticated user.
18
19
did: String,
19
20
}
20
21
···
40
41
auth.strip_prefix("Bearer ")
41
42
});
42
43
43
-
let token = match token {
44
-
Some(tok) => tok,
45
-
None => {
46
-
return Err(Error::with_status(
47
-
StatusCode::UNAUTHORIZED,
48
-
anyhow!("no bearer token"),
49
-
));
50
-
}
44
+
let Some(token) = token else {
45
+
return Err(Error::with_status(
46
+
StatusCode::UNAUTHORIZED,
47
+
anyhow!("no bearer token"),
48
+
));
51
49
};
52
50
53
51
// N.B: We ignore all fields inside of the token up until this point because they can be
···
113
111
pub(crate) fn sign(
114
112
key: &Secp256k1Keypair,
115
113
typ: &str,
116
-
claims: serde_json::Value,
114
+
claims: &serde_json::Value,
117
115
) -> anyhow::Result<String> {
118
116
// RFC 9068
119
117
let hdr = serde_json::json!({
+1
-1
src/config.rs
+1
-1
src/config.rs
+1
-1
src/did.rs
+1
-1
src/did.rs
+44
-6
src/endpoints/identity.rs
+44
-6
src/endpoints/identity.rs
···
1
+
//! Identity endpoints (/xrpc/com.atproto.identity.*)
1
2
use std::collections::HashMap;
2
3
3
4
use anyhow::{Context as _, anyhow};
···
24
25
plc::{self, PlcOperation, PlcService},
25
26
};
26
27
28
+
/// (GET) Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document.
29
+
/// ### Query Parameters
30
+
/// - handle: The handle to resolve.
31
+
/// ### Responses
32
+
/// - 200 OK: {did: did}
33
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `HandleNotFound`]}
34
+
/// - 401 Unauthorized
27
35
async fn resolve_handle(
28
36
State(db): State<Db>,
29
37
State(client): State<Client>,
···
58
66
}
59
67
60
68
#[expect(unused_variables, clippy::todo, reason = "Not yet implemented")]
69
+
/// Request an email with a code to in order to request a signed PLC operation. Requires Auth.
70
+
/// - POST /xrpc/com.atproto.identity.requestPlcOperationSignature
71
+
/// ### Responses
72
+
/// - 200 OK
73
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
74
+
/// - 401 Unauthorized
61
75
async fn request_plc_operation_signature(user: AuthenticatedUser) -> Result<()> {
62
76
todo!()
63
77
}
64
78
65
79
#[expect(unused_variables, clippy::todo, reason = "Not yet implemented")]
80
+
/// Signs a PLC operation to update some value(s) in the requesting DID's document.
81
+
/// - POST /xrpc/com.atproto.identity.signPlcOperation
82
+
/// ### Request Body
83
+
/// - token: string // A token received through com.atproto.identity.requestPlcOperationSignature
84
+
/// - rotationKeys: string[]
85
+
/// - alsoKnownAs: string[]
86
+
/// - verificationMethods: services
87
+
/// ### Responses
88
+
/// - 200 OK: {operation: string}
89
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
90
+
/// - 401 Unauthorized
66
91
async fn sign_plc_operation(
67
92
user: AuthenticatedUser,
68
93
State(skey): State<SigningKey>,
···
77
102
clippy::too_many_arguments,
78
103
reason = "Many parameters are required for this endpoint"
79
104
)]
105
+
/// Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth.
106
+
/// - POST /xrpc/com.atproto.identity.updateHandle
107
+
/// ### Query Parameters
108
+
/// - handle: handle // The new handle.
109
+
/// ### Responses
110
+
/// - 200 OK
111
+
/// ## Errors
112
+
/// - If the handle is already in use.
113
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
114
+
/// - 401 Unauthorized
115
+
/// ## Panics
116
+
/// - If the handle is not valid.
80
117
async fn update_handle(
81
118
user: AuthenticatedUser,
82
119
State(skey): State<SigningKey>,
···
133
170
),
134
171
},
135
172
)
136
-
.await
137
173
.context("failed to sign plc op")?;
138
174
139
175
if !config.test {
···
149
185
let doc = tokio::fs::File::options()
150
186
.read(true)
151
187
.write(true)
152
-
.open(config.plc.path.join(format!("{}.car", did_hash)))
188
+
.open(config.plc.path.join(format!("{did_hash}.car")))
153
189
.await
154
190
.context("failed to open did doc")?;
155
191
···
188
224
}
189
225
190
226
#[rustfmt::skip]
227
+
/// Identity endpoints (/xrpc/com.atproto.identity.*)
228
+
/// ### Routes
229
+
/// - AP /xrpc/com.atproto.identity.updateHandle -> [`update_handle`]
230
+
/// - AP /xrpc/com.atproto.identity.requestPlcOperationSignature -> [`request_plc_operation_signature`]
231
+
/// - AP /xrpc/com.atproto.identity.signPlcOperation -> [`sign_plc_operation`]
232
+
/// - UG /xrpc/com.atproto.identity.resolveHandle -> [`resolve_handle`]
191
233
pub(super) fn routes() -> Router<AppState> {
192
-
// AP /xrpc/com.atproto.identity.updateHandle
193
-
// AP /xrpc/com.atproto.identity.requestPlcOperationSignature
194
-
// AP /xrpc/com.atproto.identity.signPlcOperation
195
-
// UG /xrpc/com.atproto.identity.resolveHandle
196
234
Router::new()
197
235
.route(concat!("/", identity::update_handle::NSID), post(update_handle))
198
236
.route(concat!("/", identity::request_plc_operation_signature::NSID), post(request_plc_operation_signature))
+168
-65
src/endpoints/repo.rs
+168
-65
src/endpoints/repo.rs
···
1
+
//! PDS repository endpoints /xrpc/com.atproto.repo.*)
1
2
use std::{collections::HashSet, str::FromStr as _};
2
3
3
4
use anyhow::{Context as _, anyhow};
···
38
39
/// SHA2-256 mulithash
39
40
const IPLD_MH_SHA2_256: u64 = 0x12;
40
41
42
+
/// Used in [`scan_blobs`] to identify a blob.
41
43
#[derive(Deserialize, Debug, Clone)]
42
44
struct BlobRef {
45
+
/// `BlobRef` link. Include `$` when serializing to JSON, since `$` isn't allowed in struct names.
43
46
#[serde(rename = "$link")]
44
47
link: String,
45
48
}
46
49
47
50
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
48
51
#[serde(rename_all = "camelCase")]
52
+
/// Parameters for [`list_records`].
49
53
pub(super) struct ListRecordsParameters {
50
54
///The NSID of the record type.
51
55
pub collection: Nsid,
56
+
/// The cursor to start from.
52
57
#[serde(skip_serializing_if = "core::option::Option::is_none")]
53
58
pub cursor: Option<String>,
54
59
///The number of records to return.
···
108
113
}
109
114
}
110
115
116
+
/// Resolves DID to DID document. Does not bi-directionally verify handle.
117
+
/// - GET /xrpc/com.atproto.repo.resolveDid
118
+
/// ### Query Parameters
119
+
/// - `did`: DID to resolve.
120
+
/// ### Responses
121
+
/// - 200 OK: {`did_doc`: `did_doc`}
122
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `DidNotFound`, `DidDeactivated`]}
111
123
async fn resolve_did(
112
124
db: &Db,
113
125
identifier: &AtIdentifier,
···
150
162
Ok((did.to_owned(), handle.to_owned()))
151
163
}
152
164
165
+
/// Used in [`apply_writes`] to scan for blobs in the JSON object and return their CIDs.
153
166
fn scan_blobs(unknown: &Unknown) -> anyhow::Result<Vec<Cid>> {
154
167
// { "$type": "blob", "ref": { "$link": "bafyrei..." } }
155
168
···
160
173
];
161
174
while let Some(value) = stack.pop() {
162
175
match value {
163
-
serde_json::Value::Null => (),
164
-
serde_json::Value::Bool(_) => (),
165
-
serde_json::Value::Number(_) => (),
166
-
serde_json::Value::String(_) => (),
176
+
serde_json::Value::Bool(_)
177
+
| serde_json::Value::Null
178
+
| serde_json::Value::Number(_)
179
+
| serde_json::Value::String(_) => (),
167
180
serde_json::Value::Array(values) => stack.extend(values.into_iter()),
168
181
serde_json::Value::Object(map) => {
169
182
if let (Some(blob_type), Some(blob_ref)) = (map.get("$type"), map.get("ref")) {
···
196
209
}
197
210
});
198
211
199
-
let blob = scan_blobs(&json.try_into_unknown().unwrap()).unwrap();
212
+
let blob = scan_blobs(&json.try_into_unknown().expect("should be valid JSON"))
213
+
.expect("should be able to scan blobs");
200
214
assert_eq!(
201
215
blob,
202
-
vec![Cid::from_str("bafkreifzxf2wa6dyakzbdaxkz2wkvfrv3hiuafhxewbn5wahcw6eh3hzji").unwrap()]
216
+
vec![
217
+
Cid::from_str("bafkreifzxf2wa6dyakzbdaxkz2wkvfrv3hiuafhxewbn5wahcw6eh3hzji")
218
+
.expect("should be valid CID")
219
+
]
203
220
);
204
221
}
205
222
206
-
#[expect(clippy::large_stack_frames)]
223
+
#[expect(clippy::too_many_lines)]
224
+
/// Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.
225
+
/// - POST /xrpc/com.atproto.repo.applyWrites
226
+
/// ### Request Body
227
+
/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
228
+
/// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.
229
+
/// - `writes`: `object[]` // One of:
230
+
/// - - com.atproto.repo.applyWrites.create
231
+
/// - - com.atproto.repo.applyWrites.update
232
+
/// - - com.atproto.repo.applyWrites.delete
233
+
/// - `swap_commit`: `cid` // If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.
207
234
async fn apply_writes(
208
235
user: AuthenticatedUser,
209
236
State(skey): State<SigningKey>,
···
257
284
blobs.extend(
258
285
new_blobs
259
286
.into_iter()
260
-
.map(|blob_cid| (key.to_owned(), blob_cid)),
287
+
.map(|blob_cid| (key.clone(), blob_cid)),
261
288
);
262
289
}
263
290
···
296
323
blobs.extend(
297
324
new_blobs
298
325
.into_iter()
299
-
.map(|blod_cid| (key.to_owned(), blod_cid)),
326
+
.map(|blod_cid| (key.clone(), blod_cid)),
300
327
);
301
328
}
302
329
ops.push(RepoOp::Create {
···
322
349
blobs.extend(
323
350
new_blobs
324
351
.into_iter()
325
-
.map(|blod_cid| (key.to_owned(), blod_cid)),
352
+
.map(|blod_cid| (key.clone(), blod_cid)),
326
353
);
327
354
}
328
355
ops.push(RepoOp::Update {
···
445
472
.await
446
473
.context("failed to remove blob_ref")?;
447
474
}
448
-
_ => {}
475
+
&RepoOp::Create { .. } => {}
449
476
}
450
477
}
451
478
452
-
// for (key, cid) in &blobs {
453
479
for &mut (ref key, cid) in &mut blobs {
454
480
let cid_str = cid.to_string();
455
481
···
520
546
))
521
547
}
522
548
549
+
/// Create a single new repository record. Requires auth, implemented by PDS.
550
+
/// - POST /xrpc/com.atproto.repo.createRecord
551
+
/// ### Request Body
552
+
/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
553
+
/// - `collection`: `nsid` // The NSID of the record collection.
554
+
/// - `rkey`: `string` // The record key. <= 512 characters.
555
+
/// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.
556
+
/// - `record`
557
+
/// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID.
558
+
/// ### Responses
559
+
/// - 200 OK: {`cid`: `cid`, `uri`: `at-uri`, `commit`: {`cid`: `cid`, `rev`: `tid`}, `validation_status`: [`valid`, `unknown`]}
560
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]}
561
+
/// - 401 Unauthorized
523
562
async fn create_record(
524
563
user: AuthenticatedUser,
525
564
State(skey): State<SigningKey>,
···
536
575
State(fhp),
537
576
Json(
538
577
repo::apply_writes::InputData {
539
-
repo: input.repo.to_owned(),
540
-
validate: input.validate.to_owned(),
541
-
swap_commit: input.swap_commit.to_owned(),
578
+
repo: input.repo.clone(),
579
+
validate: input.validate,
580
+
swap_commit: input.swap_commit.clone(),
542
581
writes: vec![repo::apply_writes::InputWritesItem::Create(Box::new(
543
582
repo::apply_writes::CreateData {
544
-
collection: input.collection.to_owned(),
545
-
rkey: input.rkey.to_owned(),
546
-
value: input.record.to_owned(),
583
+
collection: input.collection.clone(),
584
+
rkey: input.rkey.clone(),
585
+
value: input.record.clone(),
547
586
}
548
587
.into(),
549
588
))],
···
557
596
let create_result = if let repo::apply_writes::OutputResultsItem::CreateResult(create_result) =
558
597
write_result
559
598
.results
560
-
.to_owned()
599
+
.clone()
561
600
.and_then(|result| result.first().cloned())
562
601
.context("unexpected output from apply_writes")?
563
602
{
···
569
608
570
609
Ok(Json(
571
610
repo::create_record::OutputData {
572
-
cid: create_result.cid.to_owned(),
573
-
commit: write_result.commit.to_owned(),
574
-
uri: create_result.uri.to_owned(),
611
+
cid: create_result.cid.clone(),
612
+
commit: write_result.commit.clone(),
613
+
uri: create_result.uri.clone(),
575
614
validation_status: Some("unknown".to_owned()),
576
615
}
577
616
.into(),
578
617
))
579
618
}
580
619
620
+
/// Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.
621
+
/// - POST /xrpc/com.atproto.repo.putRecord
622
+
/// ### Request Body
623
+
/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
624
+
/// - `collection`: `nsid` // The NSID of the record collection.
625
+
/// - `rkey`: `string` // The record key. <= 512 characters.
626
+
/// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.
627
+
/// - `record`
628
+
/// - `swap_record`: `boolean` // Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation
629
+
/// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID.
630
+
/// ### Responses
631
+
/// - 200 OK: {"uri": "string","cid": "string","commit": {"cid": "string","rev": "string"},"validationStatus": "valid | unknown"}
632
+
/// - 400 Bad Request: {error:"`InvalidRequest` | `ExpiredToken` | `InvalidToken` | `InvalidSwap`"}
633
+
/// - 401 Unauthorized
581
634
async fn put_record(
582
635
user: AuthenticatedUser,
583
636
State(skey): State<SigningKey>,
···
596
649
State(fhp),
597
650
Json(
598
651
repo::apply_writes::InputData {
599
-
repo: input.repo.to_owned(),
652
+
repo: input.repo.clone(),
600
653
validate: input.validate,
601
-
swap_commit: input.swap_commit.to_owned(),
654
+
swap_commit: input.swap_commit.clone(),
602
655
writes: vec![repo::apply_writes::InputWritesItem::Update(Box::new(
603
656
repo::apply_writes::UpdateData {
604
-
collection: input.collection.to_owned(),
605
-
rkey: input.rkey.to_owned(),
606
-
value: input.record.to_owned(),
657
+
collection: input.collection.clone(),
658
+
rkey: input.rkey.clone(),
659
+
value: input.record.clone(),
607
660
}
608
661
.into(),
609
662
))],
···
616
669
617
670
let update_result = write_result
618
671
.results
619
-
.to_owned()
672
+
.clone()
620
673
.and_then(|result| result.first().cloned())
621
674
.context("unexpected output from apply_writes")?;
622
675
let (cid, uri) = match update_result {
623
676
repo::apply_writes::OutputResultsItem::CreateResult(create_result) => (
624
-
Some(create_result.cid.to_owned()),
625
-
Some(create_result.uri.to_owned()),
677
+
Some(create_result.cid.clone()),
678
+
Some(create_result.uri.clone()),
626
679
),
627
680
repo::apply_writes::OutputResultsItem::UpdateResult(update_result) => (
628
-
Some(update_result.cid.to_owned()),
629
-
Some(update_result.uri.to_owned()),
681
+
Some(update_result.cid.clone()),
682
+
Some(update_result.uri.clone()),
630
683
),
631
-
_ => (None, None),
684
+
repo::apply_writes::OutputResultsItem::DeleteResult(_) => (None, None),
632
685
};
633
686
Ok(Json(
634
687
repo::put_record::OutputData {
635
688
cid: cid.context("missing cid")?,
636
-
commit: write_result.commit.to_owned(),
689
+
commit: write_result.commit.clone(),
637
690
uri: uri.context("missing uri")?,
638
691
validation_status: Some("unknown".to_owned()),
639
692
}
···
641
694
))
642
695
}
643
696
697
+
/// Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.
698
+
/// - POST /xrpc/com.atproto.repo.deleteRecord
699
+
/// ### Request Body
700
+
/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
701
+
/// - `collection`: `nsid` // The NSID of the record collection.
702
+
/// - `rkey`: `string` // The record key. <= 512 characters.
703
+
/// - `swap_record`: `boolean` // Compare and swap with the previous record by CID.
704
+
/// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID.
705
+
/// ### Responses
706
+
/// - 200 OK: {"commit": {"cid": "string","rev": "string"}}
707
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]}
708
+
/// - 401 Unauthorized
644
709
async fn delete_record(
645
710
user: AuthenticatedUser,
646
711
State(skey): State<SigningKey>,
···
661
726
State(fhp),
662
727
Json(
663
728
repo::apply_writes::InputData {
664
-
repo: input.repo.to_owned(),
665
-
swap_commit: input.swap_commit.to_owned(),
729
+
repo: input.repo.clone(),
730
+
swap_commit: input.swap_commit.clone(),
666
731
validate: None,
667
732
writes: vec![repo::apply_writes::InputWritesItem::Delete(Box::new(
668
733
repo::apply_writes::DeleteData {
669
-
collection: input.collection.to_owned(),
670
-
rkey: input.rkey.to_owned(),
734
+
collection: input.collection.clone(),
735
+
rkey: input.rkey.clone(),
671
736
}
672
737
.into(),
673
738
))],
···
678
743
.await
679
744
.context("failed to apply writes")?
680
745
.commit
681
-
.to_owned(),
746
+
.clone(),
682
747
}
683
748
.into(),
684
749
))
685
750
}
686
751
752
+
/// Get information about an account and repository, including the list of collections. Does not require auth.
753
+
/// - GET /xrpc/com.atproto.repo.describeRepo
754
+
/// ### Query Parameters
755
+
/// - `repo`: `at-identifier` // The handle or DID of the repo.
756
+
/// ### Responses
757
+
/// - 200 OK: {"handle": "string","did": "string","didDoc": {},"collections": [string],"handleIsCorrect": true} \
758
+
/// handeIsCorrect - boolean - Indicates if handle is currently valid (resolves bi-directionally)
759
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
760
+
/// - 401 Unauthorized
687
761
async fn describe_repo(
688
762
State(config): State<AppConfig>,
689
763
State(db): State<Db>,
···
723
797
))
724
798
}
725
799
800
+
/// Get a single record from a repository. Does not require auth.
801
+
/// - GET /xrpc/com.atproto.repo.getRecord
802
+
/// ### Query Parameters
803
+
/// - `repo`: `at-identifier` // The handle or DID of the repo.
804
+
/// - `collection`: `nsid` // The NSID of the record collection.
805
+
/// - `rkey`: `string` // The record key. <= 512 characters.
806
+
/// - `cid`: `cid` // The CID of the version of the record. If not specified, then return the most recent version.
807
+
/// ### Responses
808
+
/// - 200 OK: {"uri": "string","cid": "string","value": {}}
809
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`]}
810
+
/// - 401 Unauthorized
726
811
async fn get_record(
727
812
State(config): State<AppConfig>,
728
813
State(db): State<Db>,
···
760
845
Err(Error::with_message(
761
846
StatusCode::BAD_REQUEST,
762
847
anyhow!("could not find the requested record at {}", uri),
763
-
ErrorMessage::new(
764
-
"RecordNotFound",
765
-
format!("Could not locate record: {}", uri),
766
-
),
848
+
ErrorMessage::new("RecordNotFound", format!("Could not locate record: {uri}")),
767
849
))
768
850
},
769
851
|record_value| {
770
852
Ok(Json(
771
853
repo::get_record::OutputData {
772
854
cid: cid.map(atrium_api::types::string::Cid::new),
773
-
uri: uri.to_owned(),
855
+
uri: uri.clone(),
774
856
value: record_value
775
857
.try_into_unknown()
776
858
.context("should be valid JSON")?,
···
781
863
)
782
864
}
783
865
866
+
/// List a range of records in a repository, matching a specific collection. Does not require auth.
867
+
/// - GET /xrpc/com.atproto.repo.listRecords
868
+
/// ### Query Parameters
869
+
/// - `repo`: `at-identifier` // The handle or DID of the repo.
870
+
/// - `collection`: `nsid` // The NSID of the record type.
871
+
/// - `limit`: `integer` // The maximum number of records to return. Default 50, >=1 and <=100.
872
+
/// - `cursor`: `string`
873
+
/// - `reverse`: `boolean` // Flag to reverse the order of the returned records.
874
+
/// ### Responses
875
+
/// - 200 OK: {"cursor": "string","records": [{"uri": "string","cid": "string","value": {}}]}
876
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
877
+
/// - 401 Unauthorized
784
878
async fn list_records(
785
879
State(config): State<AppConfig>,
786
880
State(db): State<Db>,
···
830
924
value: value.try_into_unknown().context("should be valid JSON")?,
831
925
}
832
926
.into(),
833
-
)
927
+
);
834
928
}
835
929
836
930
#[expect(clippy::pattern_type_mismatch)]
···
843
937
))
844
938
}
845
939
940
+
/// Upload a new blob, to be referenced from a repository record. \
941
+
/// The blob will be deleted if it is not referenced within a time window (eg, minutes). \
942
+
/// Blob restrictions (mimetype, size, etc) are enforced when the reference is created. \
943
+
/// Requires auth, implemented by PDS.
944
+
/// - POST /xrpc/com.atproto.repo.uploadBlob
945
+
/// ### Request Body
946
+
/// ### Responses
947
+
/// - 200 OK: {"blob": "binary"}
948
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
949
+
/// - 401 Unauthorized
846
950
async fn upload_blob(
847
951
user: AuthenticatedUser,
848
952
State(config): State<AppConfig>,
···
919
1023
920
1024
let cid_str = cid.to_string();
921
1025
922
-
tokio::fs::rename(
923
-
&filename,
924
-
config.blob.path.join(format!("{}.blob", cid_str)),
925
-
)
926
-
.await
927
-
.context("failed to finalize blob")?;
1026
+
tokio::fs::rename(&filename, config.blob.path.join(format!("{cid_str}.blob")))
1027
+
.await
1028
+
.context("failed to finalize blob")?;
928
1029
929
1030
let did_str = user.did();
930
1031
···
951
1052
))
952
1053
}
953
1054
954
-
#[rustfmt::skip]
1055
+
/// These endpoints are part of the atproto PDS repository management APIs. \
1056
+
/// Requests usually require authentication (unlike the com.atproto.sync.* endpoints), and are made directly to the user's own PDS instance.
1057
+
/// ### Routes
1058
+
/// - AP /xrpc/com.atproto.repo.applyWrites -> [`apply_writes`]
1059
+
/// - AP /xrpc/com.atproto.repo.createRecord -> [`create_record`]
1060
+
/// - AP /xrpc/com.atproto.repo.putRecord -> [`put_record`]
1061
+
/// - AP /xrpc/com.atproto.repo.deleteRecord -> [`delete_record`]
1062
+
/// - AP /xrpc/com.atproto.repo.uploadBlob -> [`upload_blob`]
1063
+
/// - UG /xrpc/com.atproto.repo.describeRepo -> [`describe_repo`]
1064
+
/// - UG /xrpc/com.atproto.repo.getRecord -> [`get_record`]
1065
+
/// - UG /xrpc/com.atproto.repo.listRecords -> [`list_records`]
955
1066
pub(super) fn routes() -> Router<AppState> {
956
-
// AP /xrpc/com.atproto.repo.applyWrites
957
-
// AP /xrpc/com.atproto.repo.createRecord
958
-
// AP /xrpc/com.atproto.repo.putRecord
959
-
// AP /xrpc/com.atproto.repo.deleteRecord
960
-
// AP /xrpc/com.atproto.repo.uploadBlob
961
-
// UG /xrpc/com.atproto.repo.describeRepo
962
-
// UG /xrpc/com.atproto.repo.getRecord
963
-
// UG /xrpc/com.atproto.repo.listRecords
964
1067
Router::new()
965
-
.route(concat!("/", repo::apply_writes::NSID), post(apply_writes))
1068
+
.route(concat!("/", repo::apply_writes::NSID), post(apply_writes))
966
1069
.route(concat!("/", repo::create_record::NSID), post(create_record))
967
-
.route(concat!("/", repo::put_record::NSID), post(put_record))
1070
+
.route(concat!("/", repo::put_record::NSID), post(put_record))
968
1071
.route(concat!("/", repo::delete_record::NSID), post(delete_record))
969
-
.route(concat!("/", repo::upload_blob::NSID), post(upload_blob))
1072
+
.route(concat!("/", repo::upload_blob::NSID), post(upload_blob))
970
1073
.route(concat!("/", repo::describe_repo::NSID), get(describe_repo))
971
-
.route(concat!("/", repo::get_record::NSID), get(get_record))
972
-
.route(concat!("/", repo::list_records::NSID), get(list_records))
1074
+
.route(concat!("/", repo::get_record::NSID), get(get_record))
1075
+
.route(concat!("/", repo::list_records::NSID), get(list_records))
973
1076
}
+105
-38
src/endpoints/server.rs
+105
-38
src/endpoints/server.rs
···
1
+
//! Server endpoints. (/xrpc/com.atproto.server.*)
1
2
use std::{collections::HashMap, str::FromStr as _};
2
3
3
4
use anyhow::{Context as _, anyhow};
···
37
38
/// This is a dummy password that can be used in absence of a real password.
38
39
const DUMMY_PASSWORD: &str = "$argon2id$v=19$m=19456,t=2,p=1$En2LAfHjeO0SZD5IUU1Abg$RpS8nHhhqY4qco2uyd41p9Y/1C+Lvi214MAWukzKQMI";
39
40
41
+
/// Create an invite code.
42
+
/// - POST /xrpc/com.atproto.server.createInviteCode
43
+
/// ### Request Body
44
+
/// - `useCount`: integer
45
+
/// - `forAccount`: string (optional)
46
+
/// ### Responses
47
+
/// - 200 OK: {code: string}
48
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
49
+
/// - 401 Unauthorized
40
50
async fn create_invite_code(
41
51
_user: AuthenticatedUser,
42
52
State(db): State<Db>,
···
70
80
))
71
81
}
72
82
83
+
#[expect(clippy::too_many_lines, reason = "TODO: refactor")]
84
+
/// Create an account. Implemented by PDS.
85
+
/// - POST /xrpc/com.atproto.server.createAccount
86
+
/// ### Request Body
87
+
/// - `email`: string
88
+
/// - `handle`: string (required)
89
+
/// - `did`: string - Pre-existing atproto DID, being imported to a new account.
90
+
/// - `inviteCode`: string
91
+
/// - `verificationCode`: string
92
+
/// - `verificationPhone`: string
93
+
/// - `password`: string - Initial account password. May need to meet instance-specific password strength requirements.
94
+
/// - `recoveryKey`: string - DID PLC rotation key (aka, recovery key) to be included in PLC creation operation.
95
+
/// - `plcOp`: object
96
+
/// ## Responses
97
+
/// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {}}
98
+
/// - 400 Bad Request: {error: [`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidHandle`, `InvalidPassword`, \
99
+
/// `InvalidInviteCode`, `HandleNotAvailable`, `UnsupportedDomain`, `UnresolvableDid`, `IncompatibleDidDoc`)}
100
+
/// - 401 Unauthorized
73
101
async fn create_account(
74
102
State(db): State<Db>,
75
103
State(skey): State<SigningKey>,
···
164
192
prev: None,
165
193
},
166
194
)
167
-
.await
168
195
.context("failed to sign genesis op")?;
169
196
let op_bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode genesis op")?;
170
197
···
179
206
#[expect(clippy::string_slice, reason = "digest length confirmed")]
180
207
digest[..24].to_owned()
181
208
};
182
-
let did = format!("did:plc:{}", did_hash);
209
+
let did = format!("did:plc:{did_hash}");
183
210
184
-
let doc = tokio::fs::File::create(config.plc.path.join(format!("{}.car", did_hash)))
211
+
let doc = tokio::fs::File::create(config.plc.path.join(format!("{did_hash}.car")))
185
212
.await
186
213
.context("failed to create did doc")?;
187
214
···
205
232
// Write out an initial commit for the user.
206
233
// https://atproto.com/guides/account-lifecycle
207
234
let (cid, rev, store) = async {
208
-
let file = tokio::fs::File::create_new(config.repo.path.join(format!("{}.car", did_hash)))
235
+
let file = tokio::fs::File::create_new(config.repo.path.join(format!("{did_hash}.car")))
209
236
.await
210
237
.context("failed to create repo file")?;
211
238
let mut store = CarStore::create(file)
···
316
343
let token = auth::sign(
317
344
&skey,
318
345
"at+jwt",
319
-
serde_json::json!({
346
+
&serde_json::json!({
320
347
"scope": "com.atproto.access",
321
348
"sub": did,
322
349
"iat": chrono::Utc::now().timestamp(),
···
329
356
let refresh_token = auth::sign(
330
357
&skey,
331
358
"refresh+jwt",
332
-
serde_json::json!({
359
+
&serde_json::json!({
333
360
"scope": "com.atproto.refresh",
334
361
"sub": did,
335
362
"iat": chrono::Utc::now().timestamp(),
···
351
378
))
352
379
}
353
380
381
+
/// Create an authentication session.
382
+
/// - POST /xrpc/com.atproto.server.createSession
383
+
/// ### Request Body
384
+
/// - `identifier`: string - Handle or other identifier supported by the server for the authenticating user.
385
+
/// - `password`: string - Password for the authenticating user.
386
+
/// - `authFactorToken` - string (optional)
387
+
/// - `allowTakedown` - boolean (optional) - When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned
388
+
/// ### Responses
389
+
/// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"email": "string","emailConfirmed": true,"emailAuthFactor": true,"active": true,"status": "takendown"}
390
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`, `AuthFactorTokenRequired`]}
391
+
/// - 401 Unauthorized
354
392
async fn create_session(
355
393
State(db): State<Db>,
356
394
State(skey): State<SigningKey>,
···
363
401
// TODO: `input.allow_takedown`
364
402
// TODO: `input.auth_factor_token`
365
403
366
-
let account = if let Some(account) = sqlx::query!(
404
+
let Some(account) = sqlx::query!(
367
405
r#"
368
-
WITH LatestHandles AS (
369
-
SELECT did, handle
370
-
FROM handles
371
-
WHERE (did, created_at) IN (
372
-
SELECT did, MAX(created_at) AS max_created_at
373
-
FROM handles
374
-
GROUP BY did
375
-
)
376
-
)
377
-
SELECT a.did, a.password, h.handle
378
-
FROM accounts a
379
-
LEFT JOIN LatestHandles h ON a.did = h.did
380
-
WHERE h.handle = ?
381
-
"#,
406
+
WITH LatestHandles AS (
407
+
SELECT did, handle
408
+
FROM handles
409
+
WHERE (did, created_at) IN (
410
+
SELECT did, MAX(created_at) AS max_created_at
411
+
FROM handles
412
+
GROUP BY did
413
+
)
414
+
)
415
+
SELECT a.did, a.password, h.handle
416
+
FROM accounts a
417
+
LEFT JOIN LatestHandles h ON a.did = h.did
418
+
WHERE h.handle = ?
419
+
"#,
382
420
handle
383
421
)
384
422
.fetch_optional(&db)
385
423
.await
386
424
.context("failed to authenticate")?
387
-
{
388
-
account
389
-
} else {
425
+
else {
390
426
counter!(AUTH_FAILED).increment(1);
391
427
392
428
// SEC: Call argon2's `verify_password` to simulate password verification and discard the result.
···
407
443
password.as_bytes(),
408
444
&PasswordHash::new(account.password.as_str()).context("invalid password hash in db")?,
409
445
) {
410
-
Ok(_) => {}
446
+
Ok(()) => {}
411
447
Err(_e) => {
412
448
counter!(AUTH_FAILED).increment(1);
413
449
···
423
459
let token = auth::sign(
424
460
&skey,
425
461
"at+jwt",
426
-
serde_json::json!({
462
+
&serde_json::json!({
427
463
"scope": "com.atproto.access",
428
464
"sub": did,
429
465
"iat": chrono::Utc::now().timestamp(),
···
436
472
let refresh_token = auth::sign(
437
473
&skey,
438
474
"refresh+jwt",
439
-
serde_json::json!({
475
+
&serde_json::json!({
440
476
"scope": "com.atproto.refresh",
441
477
"sub": did,
442
478
"iat": chrono::Utc::now().timestamp(),
···
464
500
))
465
501
}
466
502
503
+
/// Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').
504
+
/// - POST /xrpc/com.atproto.server.refreshSession
505
+
/// ### Responses
506
+
/// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"active": true,"status": "takendown"}
507
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`]}
508
+
/// - 401 Unauthorized
467
509
async fn refresh_session(
468
510
State(db): State<Db>,
469
511
State(skey): State<SigningKey>,
···
490
532
}
491
533
if claims
492
534
.get("exp")
493
-
.and_then(|exp| exp.as_i64())
535
+
.and_then(serde_json::Value::as_i64)
494
536
.context("failed to get `exp`")?
495
537
< chrono::Utc::now().timestamp()
496
538
{
···
534
576
let token = auth::sign(
535
577
&skey,
536
578
"at+jwt",
537
-
serde_json::json!({
579
+
&serde_json::json!({
538
580
"scope": "com.atproto.access",
539
581
"sub": did,
540
582
"iat": chrono::Utc::now().timestamp(),
···
547
589
let refresh_token = auth::sign(
548
590
&skey,
549
591
"refresh+jwt",
550
-
serde_json::json!({
592
+
&serde_json::json!({
551
593
"scope": "com.atproto.refresh",
552
594
"sub": did,
553
595
"iat": chrono::Utc::now().timestamp(),
···
575
617
))
576
618
}
577
619
620
+
/// Get a signed token on behalf of the requesting DID for the requested service.
621
+
/// - GET /xrpc/com.atproto.server.getServiceAuth
622
+
/// ### Request Query Parameters
623
+
/// - `aud`: string - The DID of the service that the token will be used to authenticate with
624
+
/// - `exp`: integer (optional) - The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.
625
+
/// - `lxm`: string (optional) - Lexicon (XRPC) method to bind the requested token to
626
+
/// ### Responses
627
+
/// - 200 OK: {token: string}
628
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `BadExpiration`]}
629
+
/// - 401 Unauthorized
578
630
async fn get_service_auth(
579
631
user: AuthenticatedUser,
580
632
State(skey): State<SigningKey>,
···
608
660
}
609
661
610
662
// Mint a bearer token by signing a JSON web token.
611
-
let token = auth::sign(&skey, "JWT", claims).context("failed to sign jwt")?;
663
+
let token = auth::sign(&skey, "JWT", &claims).context("failed to sign jwt")?;
612
664
613
665
Ok(Json(server::get_service_auth::OutputData { token }.into()))
614
666
}
615
667
668
+
/// Get information about the current auth session. Requires auth.
669
+
/// - GET /xrpc/com.atproto.server.getSession
670
+
/// ### Responses
671
+
/// - 200 OK: {"handle": "string","did": "string","email": "string","emailConfirmed": true,"emailAuthFactor": true,"didDoc": {},"active": true,"status": "takendown"}
672
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
673
+
/// - 401 Unauthorized
616
674
async fn get_session(
617
675
user: AuthenticatedUser,
618
676
State(db): State<Db>,
···
661
719
}
662
720
}
663
721
722
+
/// Describes the server's account creation requirements and capabilities. Implemented by PDS.
723
+
/// - GET /xrpc/com.atproto.server.describeServer
724
+
/// ### Responses
725
+
/// - 200 OK: {"inviteCodeRequired": true,"phoneVerificationRequired": true,"availableUserDomains": [`string`],"links": {"privacyPolicy": "string","termsOfService": "string"},"contact": {"email": "string"},"did": "string"}
726
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
727
+
/// - 401 Unauthorized
664
728
async fn describe_server(
665
729
State(config): State<AppConfig>,
666
730
) -> Result<Json<server::describe_server::Output>> {
···
679
743
}
680
744
681
745
#[rustfmt::skip]
746
+
/// These endpoints are part of the atproto PDS server and account management APIs. \
747
+
/// Requests often require authentication and are made directly to the user's own PDS instance.
748
+
/// ### Routes
749
+
/// - `GET /xrpc/com.atproto.server.describeServer` -> [`describe_server`]
750
+
/// - `POST /xrpc/com.atproto.server.createAccount` -> [`create_account`]
751
+
/// - `POST /xrpc/com.atproto.server.createSession` -> [`create_session`]
752
+
/// - `POST /xrpc/com.atproto.server.refreshSession` -> [`refresh_session`]
753
+
/// - `GET /xrpc/com.atproto.server.getServiceAuth` -> [`get_service_auth`]
754
+
/// - `GET /xrpc/com.atproto.server.getSession` -> [`get_session`]
755
+
/// - `POST /xrpc/com.atproto.server.createInviteCode` -> [`create_invite_code`]
682
756
pub(super) fn routes() -> Router<AppState> {
683
-
// UG /xrpc/com.atproto.server.describeServer
684
-
// UP /xrpc/com.atproto.server.createAccount
685
-
// UP /xrpc/com.atproto.server.createSession
686
-
// AP /xrpc/com.atproto.server.refreshSession
687
-
// AG /xrpc/com.atproto.server.getServiceAuth
688
-
// AG /xrpc/com.atproto.server.getSession
689
-
// AP /xrpc/com.atproto.server.createInviteCode
690
757
Router::new()
691
758
.route(concat!("/", server::describe_server::NSID), get(describe_server))
692
759
.route(concat!("/", server::create_account::NSID), post(create_account))
+92
-16
src/endpoints/sync.rs
+92
-16
src/endpoints/sync.rs
···
1
+
//! Endpoints for the `ATProto` sync API. (/xrpc/com.atproto.sync.*)
1
2
use std::str::FromStr as _;
2
3
3
4
use anyhow::{Context as _, anyhow};
···
27
28
storage::{open_repo_db, open_store},
28
29
};
29
30
30
-
// HACK: `limit` may be passed as a string, so we must treat it as one.
31
31
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
32
32
#[serde(rename_all = "camelCase")]
33
+
/// Parameters for `/xrpc/com.atproto.sync.listBlobs` \
34
+
/// HACK: `limit` may be passed as a string, so we must treat it as one.
33
35
pub(super) struct ListBlobsParameters {
34
36
#[serde(skip_serializing_if = "core::option::Option::is_none")]
37
+
/// Optional cursor to paginate through blobs.
35
38
pub cursor: Option<String>,
36
39
///The DID of the repo.
37
40
pub did: Did,
38
41
#[serde(skip_serializing_if = "core::option::Option::is_none")]
42
+
/// Optional limit of blobs to return.
39
43
pub limit: Option<String>,
40
44
///Optional revision of the repo to list blobs since.
41
45
#[serde(skip_serializing_if = "core::option::Option::is_none")]
42
46
pub since: Option<String>,
43
47
}
44
-
// HACK: `limit` may be passed as a string, so we must treat it as one.
45
48
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
46
49
#[serde(rename_all = "camelCase")]
50
+
/// Parameters for `/xrpc/com.atproto.sync.listRepos` \
51
+
/// HACK: `limit` may be passed as a string, so we must treat it as one.
47
52
pub(super) struct ListReposParameters {
48
53
#[serde(skip_serializing_if = "core::option::Option::is_none")]
54
+
/// Optional cursor to paginate through repos.
49
55
pub cursor: Option<String>,
50
56
#[serde(skip_serializing_if = "core::option::Option::is_none")]
57
+
/// Optional limit of repos to return.
51
58
pub limit: Option<String>,
52
59
}
53
-
// HACK: `cursor` may be passed as a string, so we must treat it as one.
54
60
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
55
61
#[serde(rename_all = "camelCase")]
62
+
/// Parameters for `/xrpc/com.atproto.sync.subscribeRepos` \
63
+
/// HACK: `cursor` may be passed as a string, so we must treat it as one.
56
64
pub(super) struct SubscribeReposParametersData {
57
65
///The last known event seq number to backfill from.
58
66
#[serde(skip_serializing_if = "core::option::Option::is_none")]
···
80
88
let s = ReaderStream::new(f);
81
89
82
90
Ok(Response::builder()
83
-
.header(http::header::CONTENT_LENGTH, format!("{}", len))
91
+
.header(http::header::CONTENT_LENGTH, format!("{len}"))
84
92
.body(Body::from_stream(s))
85
93
.context("failed to construct response")?)
86
94
}
87
95
96
+
/// Enumerates which accounts the requesting account is currently blocking. Requires auth.
97
+
/// - GET /xrpc/com.atproto.sync.getBlocks
98
+
/// ### Query Parameters
99
+
/// - `limit`: integer, optional, default: 50, >=1 and <=100
100
+
/// - `cursor`: string, optional
101
+
/// ### Responses
102
+
/// - 200 OK: ...
103
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
104
+
/// - 401 Unauthorized
88
105
async fn get_blocks(
89
106
State(config): State<AppConfig>,
90
107
Query(input): Query<sync::get_blocks::Parameters>,
···
120
137
.context("failed to construct response")?)
121
138
}
122
139
140
+
/// Get the current commit CID & revision of the specified repo. Does not require auth.
141
+
/// ### Query Parameters
142
+
/// - `did`: The DID of the repo.
143
+
/// ### Responses
144
+
/// - 200 OK: {"cid": "string","rev": "string"}
145
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]}
123
146
async fn get_latest_commit(
124
147
State(config): State<AppConfig>,
125
148
State(db): State<Db>,
···
141
164
))
142
165
}
143
166
167
+
/// Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.
168
+
/// ### Query Parameters
169
+
/// - `did`: The DID of the repo.
170
+
/// - `collection`: nsid
171
+
/// - `rkey`: record-key
172
+
/// ### Responses
173
+
/// - 200 OK: ...
174
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`, `RepoNotFound`, `RepoTakendown`,
175
+
/// `RepoSuspended`, `RepoDeactivated`]}
144
176
async fn get_record(
145
177
State(config): State<AppConfig>,
146
178
State(db): State<Db>,
···
168
200
.context("failed to construct response")?)
169
201
}
170
202
203
+
/// Get the hosting status for a repository, on this server. Expected to be implemented by PDS and Relay.
204
+
/// ### Query Parameters
205
+
/// - `did`: The DID of the repo.
206
+
/// ### Responses
207
+
/// - 200 OK: {"did": "string","active": true,"status": "takendown","rev": "string"}
208
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`]}
171
209
async fn get_repo_status(
172
210
State(db): State<Db>,
173
211
Query(input): Query<sync::get_repo::Parameters>,
···
178
216
.await
179
217
.context("failed to execute query")?;
180
218
181
-
let r = if let Some(r) = r {
182
-
r
183
-
} else {
219
+
let Some(r) = r else {
184
220
return Err(Error::with_status(
185
221
StatusCode::NOT_FOUND,
186
222
anyhow!("account not found"),
···
201
237
))
202
238
}
203
239
240
+
/// Download a repository export as CAR file. Optionally only a 'diff' since a previous revision.
241
+
/// Does not require auth; implemented by PDS.
242
+
/// ### Query Parameters
243
+
/// - `did`: The DID of the repo.
244
+
/// - `since`: The revision ('rev') of the repo to create a diff from.
245
+
/// ### Responses
246
+
/// - 200 OK: ...
247
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`,
248
+
/// `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]}
204
249
async fn get_repo(
205
250
State(config): State<AppConfig>,
206
251
State(db): State<Db>,
···
225
270
.context("failed to construct response")?)
226
271
}
227
272
273
+
/// List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS.
274
+
/// ### Query Parameters
275
+
/// - `did`: The DID of the repo. Required.
276
+
/// - `since`: Optional revision of the repo to list blobs since.
277
+
/// - `limit`: >= 1 and <= 1000, default 500
278
+
/// - `cursor`: string
279
+
/// ### Responses
280
+
/// - 200 OK: {"cursor": "string","cids": [string]}
281
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`, `RepoTakendown`,
282
+
/// `RepoSuspended`, `RepoDeactivated`]}
228
283
async fn list_blobs(
229
284
State(db): State<Db>,
230
285
Query(input): Query<ListBlobsParameters>,
···
255
310
))
256
311
}
257
312
313
+
/// Enumerates all the DID, rev, and commit CID for all repos hosted by this service.
314
+
/// Does not require auth; implemented by PDS and Relay.
315
+
/// ### Query Parameters
316
+
/// - `limit`: >= 1 and <= 1000, default 500
317
+
/// - `cursor`: string
318
+
/// ### Responses
319
+
/// - 200 OK: {"cursor": "string","repos": [{"did": "string","head": "string","rev": "string","active": true,"status": "takendown"}]}
320
+
/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
258
321
async fn list_repos(
259
322
State(db): State<Db>,
260
323
Query(input): Query<ListReposParameters>,
261
324
) -> Result<Json<sync::list_repos::Output>> {
262
325
struct Record {
326
+
/// The DID of the repo.
263
327
did: String,
328
+
/// The commit CID of the repo.
264
329
rev: String,
330
+
/// The root CID of the repo.
265
331
root: String,
266
332
}
267
333
···
317
383
Ok(Json(sync::list_repos::OutputData { cursor, repos }.into()))
318
384
}
319
385
386
+
/// Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events,
387
+
/// for all repositories on the current server. See the atproto specifications for details around stream sequencing,
388
+
/// repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.
389
+
/// ### Query Parameters
390
+
/// - `cursor`: The last known event seq number to backfill from.
391
+
/// ### Responses
392
+
/// - 200 OK: ...
320
393
async fn subscribe_repos(
321
394
ws_up: WebSocketUpgrade,
322
395
State(fh): State<FirehoseProducer>,
···
337
410
}
338
411
339
412
#[rustfmt::skip]
413
+
/// These endpoints are part of the atproto repository synchronization APIs. Requests usually do not require authentication,
414
+
/// and can be made to PDS intances or Relay instances.
415
+
/// ### Routes
416
+
/// - `GET /xrpc/com.atproto.sync.getBlob` -> [`get_blob`]
417
+
/// - `GET /xrpc/com.atproto.sync.getBlocks` -> [`get_blocks`]
418
+
/// - `GET /xrpc/com.atproto.sync.getLatestCommit` -> [`get_latest_commit`]
419
+
/// - `GET /xrpc/com.atproto.sync.getRecord` -> [`get_record`]
420
+
/// - `GET /xrpc/com.atproto.sync.getRepoStatus` -> [`get_repo_status`]
421
+
/// - `GET /xrpc/com.atproto.sync.getRepo` -> [`get_repo`]
422
+
/// - `GET /xrpc/com.atproto.sync.listBlobs` -> [`list_blobs`]
423
+
/// - `GET /xrpc/com.atproto.sync.listRepos` -> [`list_repos`]
424
+
/// - `GET /xrpc/com.atproto.sync.subscribeRepos` -> [`subscribe_repos`]
340
425
pub(super) fn routes() -> Router<AppState> {
341
-
// UG /xrpc/com.atproto.sync.getBlob
342
-
// UG /xrpc/com.atproto.sync.getBlocks
343
-
// UG /xrpc/com.atproto.sync.getLatestCommit
344
-
// UG /xrpc/com.atproto.sync.getRecord
345
-
// UG /xrpc/com.atproto.sync.getRepoStatus
346
-
// UG /xrpc/com.atproto.sync.getRepo
347
-
// UG /xrpc/com.atproto.sync.listBlobs
348
-
// UG /xrpc/com.atproto.sync.listRepos
349
-
// UG /xrpc/com.atproto.sync.subscribeRepos
350
426
Router::new()
351
427
.route(concat!("/", sync::get_blob::NSID), get(get_blob))
352
428
.route(concat!("/", sync::get_blocks::NSID), get(get_blocks))
+7
-1
src/error.rs
+7
-1
src/error.rs
···
11
11
#[derive(Error)]
12
12
#[expect(clippy::error_impl_error, reason = "just one")]
13
13
pub struct Error {
14
+
/// The actual error that occurred.
14
15
err: anyhow::Error,
16
+
/// The error message to be returned as JSON body.
15
17
message: Option<ErrorMessage>,
18
+
/// The HTTP status code to be returned.
16
19
status: StatusCode,
17
20
}
18
21
19
22
#[derive(Default, serde::Serialize)]
20
23
/// A JSON error message.
21
24
pub(crate) struct ErrorMessage {
25
+
/// The error type.
26
+
/// This is used to identify the error in the client.
27
+
/// E.g. `InvalidRequest`, `ExpiredToken`, `InvalidToken`, `HandleNotFound`.
22
28
error: String,
29
+
/// The error message.
23
30
message: String,
24
31
}
25
32
impl std::fmt::Display for ErrorMessage {
···
91
98
}
92
99
93
100
impl IntoResponse for Error {
94
-
#[expect(clippy::cognitive_complexity)]
95
101
fn into_response(self) -> Response {
96
102
error!("{:?}", self.err);
97
103
+42
-54
src/firehose.rs
+42
-54
src/firehose.rs
···
145
145
/// A firehose producer. This is used to transmit messages to the firehose for broadcast.
146
146
#[derive(Clone, Debug)]
147
147
pub(crate) struct FirehoseProducer {
148
+
/// The channel to send messages to the firehose.
148
149
tx: tokio::sync::mpsc::Sender<FirehoseMessage>,
149
150
}
150
151
···
189
190
}
190
191
}
191
192
192
-
#[expect(clippy::as_conversions)]
193
+
#[expect(
194
+
clippy::as_conversions,
195
+
clippy::cast_possible_truncation,
196
+
clippy::cast_sign_loss,
197
+
clippy::cast_precision_loss,
198
+
clippy::arithmetic_side_effects
199
+
)]
200
+
/// Convert a `usize` to a `f64`.
193
201
const fn convert_usize_f64(x: usize) -> Result<f64, &'static str> {
194
202
let result = x as f64;
195
-
if result as usize != x {
203
+
if result as usize - x > 0 {
196
204
return Err("cannot convert");
197
205
}
198
206
Ok(result)
199
207
}
200
208
201
209
/// Serialize a message.
202
-
async fn serialize_message(
203
-
seq: u64,
204
-
mut msg: sync::subscribe_repos::Message,
205
-
) -> (&'static str, Vec<u8>) {
210
+
fn serialize_message(seq: u64, mut msg: sync::subscribe_repos::Message) -> (&'static str, Vec<u8>) {
206
211
let mut dummy_seq = 0_i64;
207
212
#[expect(clippy::pattern_type_mismatch)]
208
213
let (ty, nseq) = match &mut msg {
···
214
219
sync::subscribe_repos::Message::Migrate(m) => ("#migrate", &mut m.seq),
215
220
sync::subscribe_repos::Message::Tombstone(m) => ("#tombstone", &mut m.seq),
216
221
};
217
-
218
-
#[expect(clippy::as_conversions)]
219
-
const fn convert_u64_i64(x: u64) -> Result<i64, &'static str> {
220
-
let result = x as i64;
221
-
if result as u64 != x {
222
-
return Err("cannot convert");
223
-
}
224
-
Ok(result)
225
-
}
226
222
// Set the sequence number.
227
-
*nseq = convert_u64_i64(seq).expect("should find seq");
223
+
*nseq = i64::try_from(seq).expect("should find seq");
228
224
229
225
let hdr = FrameHeader::Message(ty.to_owned());
230
226
···
261
257
) -> Result<WebSocket> {
262
258
if let Some(cursor) = cursor {
263
259
let mut frame = Vec::new();
264
-
#[expect(clippy::as_conversions)]
265
-
const fn convert_i64_u64(x: i64) -> Result<u64, &'static str> {
266
-
let result = x as u64;
267
-
if result as i64 != x {
268
-
return Err("cannot convert");
269
-
}
270
-
Ok(result)
260
+
let cursor = u64::try_from(cursor);
261
+
if cursor.is_err() {
262
+
tracing::warn!("cursor is not a valid u64");
263
+
return Ok(ws);
271
264
}
272
-
let cursor = convert_i64_u64(cursor).expect("should find cursor");
273
-
265
+
let cursor = cursor.expect("should be valid u64");
274
266
// Cursor specified; attempt to backfill the consumer.
275
267
if cursor > seq {
276
268
let hdr = FrameHeader::Error;
···
286
278
);
287
279
}
288
280
289
-
for &(historical_seq, ty, ref msg) in history.iter() {
281
+
for &(historical_seq, ty, ref msg) in history {
290
282
if cursor > historical_seq {
291
283
continue;
292
284
}
···
314
306
315
307
info!("attempting to reconnect to upstream relays");
316
308
for relay in &config.firehose.relays {
317
-
let host = match relay.host_str() {
318
-
Some(host) => host,
319
-
None => {
320
-
warn!("relay {} has no host specified", relay);
321
-
continue;
322
-
}
309
+
let Some(host) = relay.host_str() else {
310
+
warn!("relay {} has no host specified", relay);
311
+
continue;
323
312
};
324
313
325
314
let r = client
···
356
345
///
357
346
/// This will broadcast all updates in this PDS out to anyone who is listening.
358
347
///
359
-
/// Reference: https://atproto.com/specs/sync
360
-
pub(crate) async fn spawn(
348
+
/// Reference: <https://atproto.com/specs/sync>
349
+
pub(crate) fn spawn(
361
350
client: Client,
362
351
config: AppConfig,
363
352
) -> (tokio::task::JoinHandle<()>, FirehoseProducer) {
364
353
let (tx, mut rx) = tokio::sync::mpsc::channel(1000);
365
354
let handle = tokio::spawn(async move {
366
-
let mut clients: Vec<WebSocket> = Vec::new();
367
-
let mut history = VecDeque::with_capacity(1000);
368
355
fn time_since_inception() -> u64 {
369
356
chrono::Utc::now()
370
357
.timestamp_micros()
···
372
359
.expect("should not wrap")
373
360
.unsigned_abs()
374
361
}
362
+
let mut clients: Vec<WebSocket> = Vec::new();
363
+
let mut history = VecDeque::with_capacity(1000);
375
364
let mut seq = time_since_inception();
376
365
377
366
// TODO: We should use `com.atproto.sync.notifyOfUpdate` to reach out to relays
378
367
// that may have disconnected from us due to timeout.
379
368
380
369
loop {
381
-
match tokio::time::timeout(Duration::from_secs(30), rx.recv()).await {
382
-
Ok(msg) => match msg {
370
+
if let Ok(msg) = tokio::time::timeout(Duration::from_secs(30), rx.recv()).await {
371
+
match msg {
383
372
Some(FirehoseMessage::Broadcast(msg)) => {
384
-
let (ty, by) = serialize_message(seq, msg.clone()).await;
373
+
let (ty, by) = serialize_message(seq, msg.clone());
385
374
386
375
history.push_back((seq, ty, msg));
387
376
gauge!(FIREHOSE_HISTORY).set(
···
419
408
}
420
409
// All producers have been destroyed.
421
410
None => break,
422
-
},
423
-
Err(_) => {
424
-
if clients.is_empty() {
425
-
reconnect_relays(&client, &config).await;
426
-
}
411
+
}
412
+
} else {
413
+
if clients.is_empty() {
414
+
reconnect_relays(&client, &config).await;
415
+
}
427
416
428
-
let contents = rand::thread_rng()
429
-
.sample_iter(rand::distributions::Alphanumeric)
430
-
.take(15)
431
-
.map(char::from)
432
-
.collect::<String>();
417
+
let contents = rand::thread_rng()
418
+
.sample_iter(rand::distributions::Alphanumeric)
419
+
.take(15)
420
+
.map(char::from)
421
+
.collect::<String>();
433
422
434
-
// Send a websocket ping message.
435
-
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets
436
-
let message = Message::Ping(axum::body::Bytes::from_owner(contents));
437
-
drop(broadcast_message(&mut clients, message).await);
438
-
}
423
+
// Send a websocket ping message.
424
+
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets
425
+
let message = Message::Ping(axum::body::Bytes::from_owner(contents));
426
+
drop(broadcast_message(&mut clients, message).await);
439
427
}
440
428
}
441
429
});
+42
-46
src/main.rs
+42
-46
src/main.rs
···
68
68
}
69
69
}
70
70
71
-
#[rustfmt::skip]
72
71
/// Register all actor endpoints.
73
72
pub(crate) fn routes() -> Router<AppState> {
74
73
// AP /xrpc/app.bsky.actor.putPreferences
75
74
// AG /xrpc/app.bsky.actor.getPreferences
76
75
Router::new()
77
-
.route(concat!("/", actor::put_preferences::NSID), post(put_preferences))
78
-
.route(concat!("/", actor::get_preferences::NSID), get(get_preferences))
76
+
.route(
77
+
concat!("/", actor::put_preferences::NSID),
78
+
post(put_preferences),
79
+
)
80
+
.route(
81
+
concat!("/", actor::get_preferences::NSID),
82
+
get(get_preferences),
83
+
)
79
84
}
80
85
}
81
86
···
202
207
203
208
/// The index (/) route.
204
209
async fn index() -> impl IntoResponse {
205
-
r#"
210
+
r"
206
211
__ __
207
212
/\ \__ /\ \__
208
213
__ \ \ ,_\ _____ _ __ ___\ \ ,_\ ___
···
220
225
221
226
Code: https://github.com/DrChat/bluepds
222
227
Protocol: https://atproto.com
223
-
"#
228
+
"
224
229
}
225
230
226
231
/// Service proxy.
227
232
///
228
-
/// Reference: https://atproto.com/specs/xrpc#service-proxying
233
+
/// Reference: <https://atproto.com/specs/xrpc#service-proxying>
229
234
async fn service_proxy(
230
235
uri: Uri,
231
236
user: AuthenticatedUser,
···
265
270
.await
266
271
.with_context(|| format!("failed to resolve did document {}", did.as_str()))?;
267
272
268
-
let service = match did_doc.service.iter().find(|s| s.id == id) {
269
-
Some(service) => service,
270
-
None => {
271
-
return Err(Error::with_status(
272
-
StatusCode::BAD_REQUEST,
273
-
anyhow!("could not find resolve service #{id}"),
274
-
));
275
-
}
273
+
let Some(service) = did_doc.service.iter().find(|s| s.id == id) else {
274
+
return Err(Error::with_status(
275
+
StatusCode::BAD_REQUEST,
276
+
anyhow!("could not find resolve service #{id}"),
277
+
));
276
278
};
277
279
278
-
let url = service
280
+
let target_url: url::Url = service
279
281
.service_endpoint
280
-
.join(&format!("/xrpc{}", url_path))
282
+
.join(&format!("/xrpc{url_path}"))
281
283
.context("failed to construct target url")?;
282
284
283
285
let exp = (chrono::Utc::now().checked_add_signed(chrono::Duration::minutes(1)))
···
294
296
let token = auth::sign(
295
297
&skey,
296
298
"JWT",
297
-
serde_json::json!({
299
+
&serde_json::json!({
298
300
"iss": user_did.as_str(),
299
301
"aud": did.as_str(),
300
302
"lxm": lxm,
···
313
315
}
314
316
315
317
let r = client
316
-
.request(request.method().clone(), url)
318
+
.request(request.method().clone(), target_url)
317
319
.headers(h)
318
320
.header(http::header::AUTHORIZATION, format!("Bearer {token}"))
319
321
.body(reqwest::Body::wrap_stream(
···
338
340
/// The main application entry point.
339
341
#[expect(
340
342
clippy::cognitive_complexity,
343
+
clippy::too_many_lines,
341
344
reason = "main function has high complexity"
342
345
)]
343
346
async fn run() -> anyhow::Result<()> {
···
346
349
// Set up trace logging to console and account for the user-provided verbosity flag.
347
350
if args.verbosity.log_level_filter() != LevelFilter::Off {
348
351
let lvl = match args.verbosity.log_level_filter() {
349
-
LevelFilter::Off => tracing::Level::INFO,
350
352
LevelFilter::Error => tracing::Level::ERROR,
351
353
LevelFilter::Warn => tracing::Level::WARN,
352
-
LevelFilter::Info => tracing::Level::INFO,
354
+
LevelFilter::Info | LevelFilter::Off => tracing::Level::INFO,
353
355
LevelFilter::Debug => tracing::Level::DEBUG,
354
356
LevelFilter::Trace => tracing::Level::TRACE,
355
357
};
···
384
386
}
385
387
386
388
// Initialize metrics reporting.
387
-
metrics::setup(&config.metrics).context("failed to set up metrics exporter")?;
389
+
metrics::setup(config.metrics.as_ref()).context("failed to set up metrics exporter")?;
388
390
389
391
// Create a reqwest client that will be used for all outbound requests.
390
392
let simple_client = reqwest::Client::builder()
···
404
406
.context("failed to create key directory")?;
405
407
406
408
// Check if crypto keys exist. If not, create new ones.
407
-
let (skey, rkey) = match std::fs::File::open(&config.key) {
408
-
Ok(f) => {
409
-
let keys: KeyData = serde_ipld_dagcbor::from_reader(std::io::BufReader::new(f))
410
-
.context("failed to deserialize crypto keys")?;
409
+
let (skey, rkey) = if let Ok(f) = std::fs::File::open(&config.key) {
410
+
let keys: KeyData = serde_ipld_dagcbor::from_reader(std::io::BufReader::new(f))
411
+
.context("failed to deserialize crypto keys")?;
411
412
412
-
let skey =
413
-
Secp256k1Keypair::import(&keys.skey).context("failed to import signing key")?;
414
-
let rkey =
415
-
Secp256k1Keypair::import(&keys.rkey).context("failed to import rotation key")?;
413
+
let skey = Secp256k1Keypair::import(&keys.skey).context("failed to import signing key")?;
414
+
let rkey = Secp256k1Keypair::import(&keys.rkey).context("failed to import rotation key")?;
416
415
417
-
(SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
418
-
}
419
-
_ => {
420
-
info!("signing keys not found, generating new ones");
416
+
(SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
417
+
} else {
418
+
info!("signing keys not found, generating new ones");
421
419
422
-
let skey = Secp256k1Keypair::create(&mut rand::thread_rng());
423
-
let rkey = Secp256k1Keypair::create(&mut rand::thread_rng());
420
+
let skey = Secp256k1Keypair::create(&mut rand::thread_rng());
421
+
let rkey = Secp256k1Keypair::create(&mut rand::thread_rng());
424
422
425
-
let keys = KeyData {
426
-
skey: skey.export(),
427
-
rkey: rkey.export(),
428
-
};
423
+
let keys = KeyData {
424
+
skey: skey.export(),
425
+
rkey: rkey.export(),
426
+
};
429
427
430
-
let mut f = std::fs::File::create(&config.key).context("failed to create key file")?;
431
-
serde_ipld_dagcbor::to_writer(&mut f, &keys)
432
-
.context("failed to serialize crypto keys")?;
428
+
let mut f = std::fs::File::create(&config.key).context("failed to create key file")?;
429
+
serde_ipld_dagcbor::to_writer(&mut f, &keys).context("failed to serialize crypto keys")?;
433
430
434
-
(SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
435
-
}
431
+
(SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
436
432
};
437
433
438
434
tokio::fs::create_dir_all(&config.repo.path).await?;
···
451
447
.await
452
448
.context("failed to apply migrations")?;
453
449
454
-
let (_fh, fhp) = firehose::spawn(client.clone(), config.clone()).await;
450
+
let (_fh, fhp) = firehose::spawn(client.clone(), config.clone());
455
451
456
452
let addr = config
457
453
.listen_address
···
536
532
537
533
serve
538
534
.await
539
-
.map_err(|e| e.into())
535
+
.map_err(Into::into)
540
536
.and_then(|r| r)
541
537
.context("failed to serve app")
542
538
}
+2
-2
src/metrics.rs
+2
-2
src/metrics.rs
···
28
28
pub(crate) const REPO_OP_DELETE: &str = "bluepds.repo.op.delete";
29
29
30
30
/// Must be ran exactly once on startup. This will declare all of the instruments for `metrics`.
31
-
pub(crate) fn setup(config: &Option<config::MetricConfig>) -> anyhow::Result<()> {
31
+
pub(crate) fn setup(config: Option<&config::MetricConfig>) -> anyhow::Result<()> {
32
32
describe_counter!(AUTH_FAILED, "The number of failed authentication attempts.");
33
33
34
34
describe_gauge!(FIREHOSE_HISTORY, "The size of the firehose history buffer.");
···
53
53
describe_counter!(REPO_OP_UPDATE, "The count of updated records.");
54
54
describe_counter!(REPO_OP_DELETE, "The count of deleted records.");
55
55
56
-
if let Some(ref config) = *config {
56
+
if let Some(config) = config {
57
57
match *config {
58
58
config::MetricConfig::PrometheusPush(ref prometheus_config) => {
59
59
PrometheusBuilder::new()
+1
-4
src/plc.rs
+1
-4
src/plc.rs
···
72
72
pub sig: String,
73
73
}
74
74
75
-
pub(crate) async fn sign_op(
76
-
rkey: &RotationKey,
77
-
op: PlcOperation,
78
-
) -> anyhow::Result<SignedPlcOperation> {
75
+
pub(crate) fn sign_op(rkey: &RotationKey, op: PlcOperation) -> anyhow::Result<SignedPlcOperation> {
79
76
let bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode op")?;
80
77
let bytes = rkey.sign(&bytes).context("failed to sign op")?;
81
78