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