Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 30 kB View raw
1use crate::{ 2 CachedRecord, ErrorResponseObject, Identity, Repo, 3 error::{RecordError, ServerError}, 4}; 5use atrium_api::types::string::{Cid, Did, Handle, Nsid, RecordKey}; 6use foyer::HybridCache; 7use microcosm_links::at_uri::parse_at_uri as normalize_at_uri; 8use serde::Serialize; 9use std::path::PathBuf; 10use std::str::FromStr; 11use std::sync::Arc; 12use std::time::Instant; 13use tokio_util::sync::CancellationToken; 14 15use poem::{ 16 Endpoint, EndpointExt, IntoResponse, Route, Server, 17 endpoint::{StaticFileEndpoint, make_sync}, 18 http::Method, 19 listener::{ 20 Listener, TcpListener, 21 acme::{AutoCert, LETS_ENCRYPT_PRODUCTION}, 22 }, 23 middleware::{CatchPanic, Cors, Tracing}, 24}; 25use poem_openapi::{ 26 ApiResponse, ContactObject, ExternalDocumentObject, Object, OpenApi, OpenApiService, Tags, 27 param::Query, payload::Json, types::Example, 28}; 29 30fn example_handle() -> String { 31 "bad-example.com".to_string() 32} 33fn example_did() -> String { 34 "did:plc:hdhoaan3xa3jiuq4fg4mefid".to_string() 35} 36fn example_collection() -> String { 37 "app.bsky.feed.like".to_string() 38} 39fn example_rkey() -> String { 40 "3lv4ouczo2b2a".to_string() 41} 42fn example_uri() -> String { 43 format!( 44 "at://{}/{}/{}", 45 example_did(), 46 example_collection(), 47 example_rkey() 48 ) 49} 50fn example_pds() -> String { 51 "https://porcini.us-east.host.bsky.network".to_string() 52} 53fn example_signing_key() -> String { 54 "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j".to_string() 55} 56 57#[derive(Object)] 58#[oai(example = true)] 59struct XrpcErrorResponseObject { 60 /// Should correspond an error `name` in the lexicon errors array 61 error: String, 62 /// Human-readable description and possibly additonal context 63 message: String, 64} 65impl Example for XrpcErrorResponseObject { 66 fn example() -> Self { 67 Self { 68 error: "RecordNotFound".to_string(), 69 message: "This record was deleted".to_string(), 70 } 71 } 72} 73type XrpcError = Json<XrpcErrorResponseObject>; 74fn xrpc_error(error: impl AsRef<str>, message: impl AsRef<str>) -> XrpcError { 75 Json(XrpcErrorResponseObject { 76 error: error.as_ref().to_string(), 77 message: message.as_ref().to_string(), 78 }) 79} 80 81fn bad_request_handler_get_record(err: poem::Error) -> GetRecordResponse { 82 GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject { 83 error: "InvalidRequest".to_string(), 84 message: format!("Bad request, here's some info that maybe should not be exposed: {err}"), 85 })) 86} 87 88fn bad_request_handler_resolve_mini(err: poem::Error) -> ResolveMiniIDResponse { 89 ResolveMiniIDResponse::BadRequest(Json(XrpcErrorResponseObject { 90 error: "InvalidRequest".to_string(), 91 message: format!("Bad request, here's some info that maybe should not be exposed: {err}"), 92 })) 93} 94 95fn bad_request_handler_resolve_handle(err: poem::Error) -> JustDidResponse { 96 JustDidResponse::BadRequest(Json(XrpcErrorResponseObject { 97 error: "InvalidRequest".to_string(), 98 message: format!("Bad request, here's some info that maybe should not be exposed: {err}"), 99 })) 100} 101 102#[derive(Object)] 103#[oai(example = true)] 104struct FoundRecordResponseObject { 105 /// at-uri for this record 106 uri: String, 107 /// CID for this exact version of the record 108 /// 109 /// Slingshot will always return the CID, despite it not being a required 110 /// response property in the official lexicon. 111 /// 112 /// TODO: probably actually let it be optional, idk are some pds's weirdly 113 /// not returning it? 114 cid: Option<String>, 115 /// the record itself as JSON 116 value: serde_json::Value, 117} 118impl Example for FoundRecordResponseObject { 119 fn example() -> Self { 120 Self { 121 uri: example_uri(), 122 cid: Some("bafyreialv3mzvvxaoyrfrwoer3xmabbmdchvrbyhayd7bga47qjbycy74e".to_string()), 123 value: serde_json::json!({ 124 "$type": "app.bsky.feed.like", 125 "createdAt": "2025-07-29T18:02:02.327Z", 126 "subject": { 127 "cid": "bafyreia2gy6eyk5qfetgahvshpq35vtbwy6negpy3gnuulcdi723mi7vxy", 128 "uri": "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv4lkb4vgs2k" 129 } 130 }), 131 } 132 } 133} 134 135#[derive(ApiResponse)] 136#[oai(bad_request_handler = "bad_request_handler_get_record")] 137enum GetRecordResponse { 138 /// Record found 139 #[oai(status = 200)] 140 Ok(Json<FoundRecordResponseObject>), 141 /// Bad request or no record to return 142 /// 143 /// The only error name in the repo.getRecord lexicon is `RecordNotFound`, 144 /// but the [canonical api docs](https://docs.bsky.app/docs/api/com-atproto-repo-get-record) 145 /// also list `InvalidRequest`, `ExpiredToken`, and `InvalidToken`. Of 146 /// these, slingshot will only generate `RecordNotFound` or `InvalidRequest`, 147 /// but may return any proxied error code from the upstream repo. 148 #[oai(status = 400)] 149 BadRequest(XrpcError), 150 /// Server errors 151 #[oai(status = 500)] 152 ServerError(XrpcError), 153} 154 155#[derive(Object)] 156#[oai(example = true)] 157struct MiniDocResponseObject { 158 /// DID, bi-directionally verified if a handle was provided in the query. 159 did: String, 160 /// The validated handle of the account or `handle.invalid` if the handle 161 /// did not bi-directionally match the DID document. 162 handle: String, 163 /// The identity's PDS URL 164 pds: String, 165 /// The atproto signing key publicKeyMultibase 166 /// 167 /// Legacy key encoding not supported. the key is returned directly; `id`, 168 /// `type`, and `controller` are omitted. 169 signing_key: String, 170} 171impl Example for MiniDocResponseObject { 172 fn example() -> Self { 173 Self { 174 did: example_did(), 175 handle: example_handle(), 176 pds: example_pds(), 177 signing_key: example_signing_key(), 178 } 179 } 180} 181 182#[derive(ApiResponse)] 183#[oai(bad_request_handler = "bad_request_handler_resolve_mini")] 184enum ResolveMiniIDResponse { 185 /// Identity resolved 186 #[oai(status = 200)] 187 Ok(Json<MiniDocResponseObject>), 188 /// Bad request or identity not resolved 189 #[oai(status = 400)] 190 BadRequest(XrpcError), 191} 192 193#[derive(Object)] 194#[oai(example = true)] 195struct FoundDidResponseObject { 196 /// the DID, bi-directionally verified if using Slingshot 197 did: String, 198} 199impl Example for FoundDidResponseObject { 200 fn example() -> Self { 201 Self { did: example_did() } 202 } 203} 204 205#[derive(ApiResponse)] 206#[oai(bad_request_handler = "bad_request_handler_resolve_handle")] 207enum JustDidResponse { 208 /// Resolution succeeded 209 #[oai(status = 200)] 210 Ok(Json<FoundDidResponseObject>), 211 /// Bad request, failed to resolve, or failed to verify 212 /// 213 /// `error` will be one of `InvalidRequest`, `HandleNotFound`. 214 #[oai(status = 400)] 215 BadRequest(XrpcError), 216 /// Something went wrong trying to complete the request 217 #[oai(status = 500)] 218 ServerError(XrpcError), 219} 220 221struct Xrpc { 222 cache: HybridCache<String, CachedRecord>, 223 identity: Identity, 224 repo: Arc<Repo>, 225} 226 227#[derive(Tags)] 228enum ApiTags { 229 /// Core ATProtocol-compatible APIs. 230 /// 231 /// > [!tip] 232 /// > Upstream documentation is available at 233 /// > https://docs.bsky.app/docs/category/http-reference 234 /// 235 /// These queries are usually executed directly against the PDS containing 236 /// the data being requested. Slingshot offers a caching view of the same 237 /// contents with better expected performance and reliability. 238 #[oai(rename = "com.atproto.* queries")] 239 ComAtproto, 240 /// Additional and improved APIs. 241 /// 242 /// These APIs offer small tweaks to the core ATProtocol APIs, with more 243 /// more convenient [request parameters](#tag/slingshot-specific-queries/GET/xrpc/com.bad-example.repo.getUriRecord) 244 /// or [response formats](#tag/slingshot-specific-queries/GET/xrpc/com.bad-example.identity.resolveMiniDoc). 245 /// 246 /// > [!important] 247 /// > At the moment, these are namespaced under the `com.bad-example.*` NSID 248 /// > prefix, but as they stabilize they may be migrated to an org namespace 249 /// > like `blue.microcosm.*`. Support for asliasing to `com.bad-example.*` 250 /// > will be maintained as long as it's in use. 251 #[oai(rename = "slingshot-specific queries")] 252 Custom, 253} 254 255#[OpenApi] 256impl Xrpc { 257 /// com.atproto.repo.getRecord 258 /// 259 /// Get a single record from a repository. Does not require auth. 260 /// 261 /// > [!tip] 262 /// > See also the [canonical `com.atproto` XRPC documentation](https://docs.bsky.app/docs/api/com-atproto-repo-get-record) 263 /// > that this endpoint aims to be compatible with. 264 #[oai( 265 path = "/com.atproto.repo.getRecord", 266 method = "get", 267 tag = "ApiTags::ComAtproto" 268 )] 269 async fn get_record( 270 &self, 271 /// The DID or handle of the repo 272 #[oai(example = "example_did")] 273 Query(repo): Query<String>, 274 /// The NSID of the record collection 275 #[oai(example = "example_collection")] 276 Query(collection): Query<String>, 277 /// The Record key 278 #[oai(example = "example_rkey")] 279 Query(rkey): Query<String>, 280 /// Optional: the CID of the version of the record. 281 /// 282 /// If not specified, then return the most recent version. 283 /// 284 /// If a stale `CID` is specified and a newer version of the record 285 /// exists, Slingshot returns a `NotFound` error. That is: Slingshot 286 /// only retains the most recent version of a record. 287 Query(cid): Query<Option<String>>, 288 ) -> GetRecordResponse { 289 self.get_record_impl(repo, collection, rkey, cid).await 290 } 291 292 /// blue.microcosm.repo.getRecordByUri 293 /// 294 /// alias of `com.bad-example.repo.getUriRecord` with intention to stabilize under this name 295 #[oai( 296 path = "/blue.microcosm.repo.getRecordByUri", 297 method = "get", 298 tag = "ApiTags::Custom" 299 )] 300 async fn get_record_by_uri( 301 &self, 302 /// The at-uri of the record 303 /// 304 /// The identifier can be a DID or an atproto handle, and the collection 305 /// and rkey segments must be present. 306 #[oai(example = "example_uri")] 307 Query(at_uri): Query<String>, 308 /// Optional: the CID of the version of the record. 309 /// 310 /// If not specified, then return the most recent version. 311 /// 312 /// > [!tip] 313 /// > If specified and a newer version of the record exists, returns 404 not 314 /// > found. That is: slingshot only retains the most recent version of a 315 /// > record. 316 Query(cid): Query<Option<String>>, 317 ) -> GetRecordResponse { 318 self.get_uri_record(Query(at_uri), Query(cid)).await 319 } 320 321 /// com.bad-example.repo.getUriRecord 322 /// 323 /// Ergonomic complement to [`com.atproto.repo.getRecord`](https://docs.bsky.app/docs/api/com-atproto-repo-get-record) 324 /// which accepts an `at-uri` instead of individual repo/collection/rkey params 325 #[oai( 326 path = "/com.bad-example.repo.getUriRecord", 327 method = "get", 328 tag = "ApiTags::Custom" 329 )] 330 async fn get_uri_record( 331 &self, 332 /// The at-uri of the record 333 /// 334 /// The identifier can be a DID or an atproto handle, and the collection 335 /// and rkey segments must be present. 336 #[oai(example = "example_uri")] 337 Query(at_uri): Query<String>, 338 /// Optional: the CID of the version of the record. 339 /// 340 /// If not specified, then return the most recent version. 341 /// 342 /// > [!tip] 343 /// > If specified and a newer version of the record exists, returns 404 not 344 /// > found. That is: slingshot only retains the most recent version of a 345 /// > record. 346 Query(cid): Query<Option<String>>, 347 ) -> GetRecordResponse { 348 let bad_at_uri = || { 349 GetRecordResponse::BadRequest(xrpc_error( 350 "InvalidRequest", 351 "at-uri does not appear to be valid", 352 )) 353 }; 354 355 let Some(normalized) = normalize_at_uri(&at_uri) else { 356 return bad_at_uri(); 357 }; 358 359 // TODO: move this to links 360 let Some(rest) = normalized.strip_prefix("at://") else { 361 return bad_at_uri(); 362 }; 363 let Some((repo, rest)) = rest.split_once('/') else { 364 return bad_at_uri(); 365 }; 366 let Some((collection, rest)) = rest.split_once('/') else { 367 return bad_at_uri(); 368 }; 369 let rkey = if let Some((rkey, _rest)) = rest.split_once('?') { 370 rkey 371 } else { 372 rest 373 }; 374 375 self.get_record_impl( 376 repo.to_string(), 377 collection.to_string(), 378 rkey.to_string(), 379 cid, 380 ) 381 .await 382 } 383 384 /// com.atproto.identity.resolveHandle 385 /// 386 /// Resolves an atproto [`handle`](https://atproto.com/guides/glossary#handle) 387 /// (hostname) to a [`DID`](https://atproto.com/guides/glossary#did-decentralized-id). 388 /// 389 /// > [!tip] 390 /// > Compatibility note: Slingshot will **always bi-directionally verify 391 /// > against the DID document**, which is optional according to the 392 /// > authoritative lexicon. 393 /// 394 /// > [!tip] 395 /// > See the [canonical `com.atproto` XRPC documentation](https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle) 396 /// > that this endpoint aims to be compatible with. 397 #[oai( 398 path = "/com.atproto.identity.resolveHandle", 399 method = "get", 400 tag = "ApiTags::ComAtproto" 401 )] 402 async fn resolve_handle( 403 &self, 404 /// The handle to resolve. 405 #[oai(example = "example_handle")] 406 Query(handle): Query<String>, 407 ) -> JustDidResponse { 408 let Ok(handle) = Handle::new(handle.to_lowercase()) else { 409 return JustDidResponse::BadRequest(xrpc_error("InvalidRequest", "not a valid handle")); 410 }; 411 412 let Ok(alleged_did) = self.identity.handle_to_did(handle.clone()).await else { 413 return JustDidResponse::ServerError(xrpc_error("Failed", "Could not resolve handle")); 414 }; 415 416 let Some(alleged_did) = alleged_did else { 417 return JustDidResponse::BadRequest(xrpc_error( 418 "HandleNotFound", 419 "Could not resolve handle to a DID", 420 )); 421 }; 422 423 let Ok(partial_doc) = self.identity.did_to_partial_mini_doc(&alleged_did).await else { 424 return JustDidResponse::ServerError(xrpc_error("Failed", "Could not fetch DID doc")); 425 }; 426 427 let Some(partial_doc) = partial_doc else { 428 return JustDidResponse::BadRequest(xrpc_error( 429 "HandleNotFound", 430 "Resolved handle but could not find DID doc for the DID", 431 )); 432 }; 433 434 if partial_doc.unverified_handle != handle { 435 return JustDidResponse::BadRequest(xrpc_error( 436 "HandleNotFound", 437 "Resolved handle failed bi-directional validation", 438 )); 439 } 440 441 JustDidResponse::Ok(Json(FoundDidResponseObject { 442 did: alleged_did.to_string(), 443 })) 444 } 445 446 /// blue.microcosm.identity.resolveMiniDoc 447 /// 448 /// alias of `com.bad-example.identity.resolveMiniDoc` with intention to stabilize under this name 449 #[oai( 450 path = "/blue.microcosm.identity.resolveMiniDoc", 451 method = "get", 452 tag = "ApiTags::Custom" 453 )] 454 async fn resolve_mini_doc( 455 &self, 456 /// Handle or DID to resolve 457 #[oai(example = "example_handle")] 458 Query(identifier): Query<String>, 459 ) -> ResolveMiniIDResponse { 460 self.resolve_mini_id(Query(identifier)).await 461 } 462 463 /// com.bad-example.identity.resolveMiniDoc 464 /// 465 /// Like [com.atproto.identity.resolveIdentity](https://docs.bsky.app/docs/api/com-atproto-identity-resolve-identity) 466 /// but instead of the full `didDoc` it returns an atproto-relevant subset. 467 #[oai( 468 path = "/com.bad-example.identity.resolveMiniDoc", 469 method = "get", 470 tag = "ApiTags::Custom" 471 )] 472 async fn resolve_mini_id( 473 &self, 474 /// Handle or DID to resolve 475 #[oai(example = "example_handle")] 476 Query(identifier): Query<String>, 477 ) -> ResolveMiniIDResponse { 478 let invalid = |reason: &'static str| { 479 ResolveMiniIDResponse::BadRequest(xrpc_error("InvalidRequest", reason)) 480 }; 481 482 let mut unverified_handle = None; 483 let did = match Did::new(identifier.clone()) { 484 Ok(did) => did, 485 Err(_) => { 486 let Ok(alleged_handle) = Handle::new(identifier.to_lowercase()) else { 487 return invalid("Identifier was not a valid DID or handle"); 488 }; 489 490 match self.identity.handle_to_did(alleged_handle.clone()).await { 491 Ok(res) => { 492 if let Some(did) = res { 493 // we did it joe 494 unverified_handle = Some(alleged_handle); 495 did 496 } else { 497 return invalid("Could not resolve handle identifier to a DID"); 498 } 499 } 500 Err(e) => { 501 log::debug!("failed to resolve handle: {e}"); 502 // TODO: ServerError not BadRequest 503 return invalid("Errored while trying to resolve handle to DID"); 504 } 505 } 506 } 507 }; 508 let Ok(partial_doc) = self.identity.did_to_partial_mini_doc(&did).await else { 509 return invalid("Failed to get DID doc"); 510 }; 511 let Some(partial_doc) = partial_doc else { 512 return invalid("Failed to find DID doc"); 513 }; 514 515 // ok so here's where we're at: 516 // ✅ we have a DID 517 // ✅ we have a partial doc 518 // 🔶 if we have a handle, it's from the `identifier` (user-input) 519 // -> then we just need to compare to the partial doc to confirm 520 // -> else we need to resolve the DID doc's to a handle and check 521 let handle = if let Some(h) = unverified_handle { 522 if h == partial_doc.unverified_handle { 523 h.to_string() 524 } else { 525 "handle.invalid".to_string() 526 } 527 } else { 528 let Ok(handle_did) = self 529 .identity 530 .handle_to_did(partial_doc.unverified_handle.clone()) 531 .await 532 else { 533 return invalid("Failed to get DID doc's handle"); 534 }; 535 let Some(handle_did) = handle_did else { 536 return invalid("Failed to resolve DID doc's handle"); 537 }; 538 if handle_did == did { 539 partial_doc.unverified_handle.to_string() 540 } else { 541 "handle.invalid".to_string() 542 } 543 }; 544 545 ResolveMiniIDResponse::Ok(Json(MiniDocResponseObject { 546 did: did.to_string(), 547 handle, 548 pds: partial_doc.pds, 549 signing_key: partial_doc.signing_key, 550 })) 551 } 552 553 async fn get_record_impl( 554 &self, 555 repo: String, 556 collection: String, 557 rkey: String, 558 cid: Option<String>, 559 ) -> GetRecordResponse { 560 let did = match Did::new(repo.clone()) { 561 Ok(did) => did, 562 Err(_) => { 563 let Ok(handle) = Handle::new(repo.to_lowercase()) else { 564 return GetRecordResponse::BadRequest(xrpc_error( 565 "InvalidRequest", 566 "Repo was not a valid DID or handle", 567 )); 568 }; 569 match self.identity.handle_to_did(handle).await { 570 Ok(res) => { 571 if let Some(did) = res { 572 did 573 } else { 574 return GetRecordResponse::BadRequest(xrpc_error( 575 "InvalidRequest", 576 "Could not resolve handle repo to a DID", 577 )); 578 } 579 } 580 Err(e) => { 581 log::debug!("handle resolution failed: {e}"); 582 return GetRecordResponse::ServerError(xrpc_error( 583 "ResolutionFailed", 584 "Errored while trying to resolve handle to DID", 585 )); 586 } 587 } 588 } 589 }; 590 591 let Ok(collection) = Nsid::new(collection) else { 592 return GetRecordResponse::BadRequest(xrpc_error( 593 "InvalidRequest", 594 "Invalid NSID for collection", 595 )); 596 }; 597 598 let Ok(rkey) = RecordKey::new(rkey) else { 599 return GetRecordResponse::BadRequest(xrpc_error("InvalidRequest", "Invalid rkey")); 600 }; 601 602 let cid: Option<Cid> = if let Some(cid) = cid { 603 let Ok(cid) = Cid::from_str(&cid) else { 604 return GetRecordResponse::BadRequest(xrpc_error("InvalidRequest", "Invalid CID")); 605 }; 606 Some(cid) 607 } else { 608 None 609 }; 610 611 let at_uri = format!("at://{}/{}/{}", &*did, &*collection, &*rkey); 612 613 metrics::counter!("slingshot_get_record").increment(1); 614 let fr = self 615 .cache 616 .get_or_fetch(&at_uri, { 617 let cid = cid.clone(); 618 let repo_api = self.repo.clone(); 619 || async move { 620 let t0 = Instant::now(); 621 let res = repo_api.get_record(&did, &collection, &rkey, &cid).await; 622 let success = if res.is_ok() { "true" } else { "false" }; 623 metrics::histogram!("slingshot_fetch_record", "success" => success) 624 .record(t0.elapsed()); 625 res 626 } 627 }) 628 .await; 629 630 let entry = match fr { 631 Ok(e) => e, 632 Err(e) if e.kind() == foyer::ErrorKind::External => { 633 let record_error = match e.source().map(|s| s.downcast_ref::<RecordError>()) { 634 Some(Some(e)) => e, 635 other => { 636 if other.is_none() { 637 log::error!("external error without a source. wat? {e}"); 638 } else { 639 log::error!("downcast to RecordError failed...? {e}"); 640 } 641 return GetRecordResponse::ServerError(xrpc_error( 642 "ServerError", 643 "sorry, something went wrong", 644 )); 645 } 646 }; 647 let RecordError::UpstreamBadRequest(ErrorResponseObject { 648 ref error, 649 ref message, 650 }) = *record_error 651 else { 652 log::error!("RecordError getting cache entry, {record_error:?}"); 653 return GetRecordResponse::ServerError(xrpc_error( 654 "ServerError", 655 "sorry, something went wrong", 656 )); 657 }; 658 659 // all of the noise around here is so that we can ultimately reach this: 660 // upstream BadRequest extracted from the foyer result which we can proxy back 661 return GetRecordResponse::BadRequest(xrpc_error( 662 error, 663 format!("Upstream bad request: {message}"), 664 )); 665 } 666 Err(e) => { 667 log::error!("error (foyer) getting cache entry, {e:?}"); 668 return GetRecordResponse::ServerError(xrpc_error( 669 "ServerError", 670 "sorry, something went wrong", 671 )); 672 } 673 }; 674 675 match *entry { 676 CachedRecord::Found(ref raw) => { 677 let (found_cid, raw_value) = raw.into(); 678 if cid.clone().map(|c| c != found_cid).unwrap_or(false) { 679 return GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject { 680 error: "RecordNotFound".to_string(), 681 message: "A record was found but its CID did not match that requested" 682 .to_string(), 683 })); 684 } 685 // TODO: thank u stellz: https://gist.github.com/stella3d/51e679e55b264adff89d00a1e58d0272 686 let value = 687 serde_json::from_str(raw_value.get()).expect("RawValue to be valid json"); 688 GetRecordResponse::Ok(Json(FoundRecordResponseObject { 689 uri: at_uri, 690 cid: Some(found_cid.as_ref().to_string()), 691 value, 692 })) 693 } 694 CachedRecord::Deleted => GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject { 695 error: "RecordNotFound".to_string(), 696 message: "This record was deleted".to_string(), 697 })), 698 } 699 } 700 701 // TODO 702 // #[oai(path = "/com.atproto.identity.resolveDid", method = "get")] 703 // but these are both not specified to do bidirectional validation, which is what we want to offer 704 // com.atproto.identity.resolveIdentity seems right, but requires returning the full did-doc 705 // would be nice if there were two queries: 706 // did -> verified handle + pds url 707 // handle -> verified did + pds url 708 // 709 // we could do horrible things and implement resolveIdentity with only a stripped-down fake did doc 710 // but this will *definitely* cause problems probably 711 // 712 // resolveMiniDoc gets most of this well enough. 713} 714 715#[derive(Debug, Clone, Serialize)] 716#[serde(rename_all = "camelCase")] 717struct AppViewService { 718 id: String, 719 r#type: String, 720 service_endpoint: String, 721} 722#[derive(Debug, Clone, Serialize)] 723struct AppViewDoc { 724 id: String, 725 service: [AppViewService; 1], 726} 727/// Serve a did document for did:web for this to be an xrpc appview 728/// 729/// No slingshot endpoints currently require auth, so it's not necessary to do 730/// service proxying, however clients may wish to: 731/// 732/// - PDS proxying offers a level of client IP anonymity from slingshot 733/// - slingshot *may* implement more generous per-user rate-limits for proxied requests in the future 734fn get_did_doc(domain: &str) -> impl Endpoint + use<> { 735 let doc = poem::web::Json(AppViewDoc { 736 id: format!("did:web:{domain}"), 737 service: [AppViewService { 738 id: "#slingshot".to_string(), 739 r#type: "SlingshotRecordProxy".to_string(), 740 service_endpoint: format!("https://{domain}"), 741 }], 742 }); 743 make_sync(move |_| doc.clone()) 744} 745 746#[allow(clippy::too_many_arguments)] 747pub async fn serve( 748 cache: HybridCache<String, CachedRecord>, 749 identity: Identity, 750 repo: Repo, 751 acme_domain: Option<String>, 752 acme_contact: Option<String>, 753 acme_cache_path: Option<PathBuf>, 754 acme_ipv6: bool, 755 shutdown: CancellationToken, 756 bind: std::net::SocketAddr, 757) -> Result<(), ServerError> { 758 let repo = Arc::new(repo); 759 let api_service = OpenApiService::new( 760 Xrpc { 761 cache, 762 identity, 763 repo, 764 }, 765 "Slingshot", 766 env!("CARGO_PKG_VERSION"), 767 ) 768 .server(if let Some(ref h) = acme_domain { 769 format!("https://{h}") 770 } else { 771 format!("http://{bind}") // yeah should probably fix this for reverse-proxy scenarios but it's ok for dev for now 772 }) 773 .url_prefix("/xrpc") 774 .contact( 775 ContactObject::new() 776 .name("@microcosm.blue") 777 .url("https://bsky.app/profile/microcosm.blue"), 778 ) 779 .description(include_str!("../api-description.md")) 780 .external_document(ExternalDocumentObject::new( 781 "https://microcosm.blue/slingshot", 782 )); 783 784 let mut app = Route::new() 785 .at("/", StaticFileEndpoint::new("./static/index.html")) 786 .nest("/openapi", api_service.spec_endpoint()) 787 .nest("/xrpc/", api_service); 788 789 if let Some(domain) = acme_domain { 790 rustls::crypto::aws_lc_rs::default_provider() 791 .install_default() 792 .expect("alskfjalksdjf"); 793 794 app = app.at("/.well-known/did.json", get_did_doc(&domain)); 795 796 let mut auto_cert = AutoCert::builder() 797 .directory_url(LETS_ENCRYPT_PRODUCTION) 798 .domain(&domain); 799 if let Some(contact) = acme_contact { 800 auto_cert = auto_cert.contact(contact); 801 } 802 if let Some(cache_path) = acme_cache_path { 803 auto_cert = auto_cert.cache_path(cache_path); 804 } 805 let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?; 806 807 run( 808 TcpListener::bind(if acme_ipv6 { "[::]:443" } else { "0.0.0.0:443" }).acme(auto_cert), 809 app, 810 shutdown, 811 ) 812 .await 813 } else { 814 run(TcpListener::bind(bind), app, shutdown).await 815 } 816} 817 818async fn run<L>(listener: L, app: Route, shutdown: CancellationToken) -> Result<(), ServerError> 819where 820 L: Listener + 'static, 821{ 822 let app = app 823 .with( 824 Cors::new() 825 .allow_origin_regex("*") 826 .allow_methods([Method::GET]) 827 .allow_credentials(false), 828 ) 829 .with(CatchPanic::new()) 830 .around(request_counter) 831 .with(Tracing); 832 833 Server::new(listener) 834 .name("slingshot") 835 .run_with_graceful_shutdown(app, shutdown.cancelled(), None) 836 .await 837 .map_err(ServerError::ServerExited) 838 .inspect(|()| log::info!("server ended. goodbye.")) 839} 840 841async fn request_counter<E: Endpoint>(next: E, req: poem::Request) -> poem::Result<poem::Response> { 842 let t0 = std::time::Instant::now(); 843 let method = req.method().to_string(); 844 let path = req.uri().path().to_string(); 845 let res = next.call(req).await?.into_response(); 846 metrics::histogram!( 847 "server_request", 848 "endpoint" => format!("{method} {path}"), 849 "status" => res.status().to_string(), 850 ) 851 .record(t0.elapsed()); 852 Ok(res) 853}