APIs for links and references in the ATmosphere
at main 769 lines 27 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 if let Ok(res) = self.identity.handle_to_did(alleged_handle.clone()).await { 443 if let Some(did) = res { 444 // we did it joe 445 unverified_handle = Some(alleged_handle); 446 did 447 } else { 448 return invalid("Could not resolve handle identifier to a DID"); 449 } 450 } else { 451 // TODO: ServerError not BadRequest 452 return invalid("errored while trying to resolve handle to DID"); 453 } 454 } 455 }; 456 let Ok(partial_doc) = self.identity.did_to_partial_mini_doc(&did).await else { 457 return invalid("failed to get DID doc"); 458 }; 459 let Some(partial_doc) = partial_doc else { 460 return invalid("failed to find DID doc"); 461 }; 462 463 // ok so here's where we're at: 464 // ✅ we have a DID 465 // ✅ we have a partial doc 466 // 🔶 if we have a handle, it's from the `identifier` (user-input) 467 // -> then we just need to compare to the partial doc to confirm 468 // -> else we need to resolve the DID doc's to a handle and check 469 let handle = if let Some(h) = unverified_handle { 470 if h == partial_doc.unverified_handle { 471 h.to_string() 472 } else { 473 "handle.invalid".to_string() 474 } 475 } else { 476 let Ok(handle_did) = self 477 .identity 478 .handle_to_did(partial_doc.unverified_handle.clone()) 479 .await 480 else { 481 return invalid("failed to get did doc's handle"); 482 }; 483 let Some(handle_did) = handle_did else { 484 return invalid("failed to resolve did doc's handle"); 485 }; 486 if handle_did == did { 487 partial_doc.unverified_handle.to_string() 488 } else { 489 "handle.invalid".to_string() 490 } 491 }; 492 493 ResolveMiniIDResponse::Ok(Json(MiniDocResponseObject { 494 did: did.to_string(), 495 handle, 496 pds: partial_doc.pds, 497 signing_key: partial_doc.signing_key, 498 })) 499 } 500 501 async fn get_record_impl( 502 &self, 503 repo: String, 504 collection: String, 505 rkey: String, 506 cid: Option<String>, 507 ) -> GetRecordResponse { 508 let did = match Did::new(repo.clone()) { 509 Ok(did) => did, 510 Err(_) => { 511 let Ok(handle) = Handle::new(repo) else { 512 return GetRecordResponse::BadRequest(xrpc_error( 513 "InvalidRequest", 514 "repo was not a valid DID or handle", 515 )); 516 }; 517 if let Ok(res) = self.identity.handle_to_did(handle).await { 518 if let Some(did) = res { 519 did 520 } else { 521 return GetRecordResponse::BadRequest(xrpc_error( 522 "InvalidRequest", 523 "Could not resolve handle repo to a DID", 524 )); 525 } 526 } else { 527 return GetRecordResponse::ServerError(xrpc_error( 528 "ResolutionFailed", 529 "errored while trying to resolve handle to DID", 530 )); 531 } 532 } 533 }; 534 535 let Ok(collection) = Nsid::new(collection) else { 536 return GetRecordResponse::BadRequest(xrpc_error( 537 "InvalidRequest", 538 "invalid NSID for collection", 539 )); 540 }; 541 542 let Ok(rkey) = RecordKey::new(rkey) else { 543 return GetRecordResponse::BadRequest(xrpc_error("InvalidRequest", "invalid rkey")); 544 }; 545 546 let cid: Option<Cid> = if let Some(cid) = cid { 547 let Ok(cid) = Cid::from_str(&cid) else { 548 return GetRecordResponse::BadRequest(xrpc_error("InvalidRequest", "invalid CID")); 549 }; 550 Some(cid) 551 } else { 552 None 553 }; 554 555 let at_uri = format!("at://{}/{}/{}", &*did, &*collection, &*rkey); 556 557 let fr = self 558 .cache 559 .fetch(at_uri.clone(), { 560 let cid = cid.clone(); 561 let repo_api = self.repo.clone(); 562 || async move { 563 repo_api 564 .get_record(&did, &collection, &rkey, &cid) 565 .await 566 .map_err(|e| foyer::Error::Other(Box::new(e))) 567 } 568 }) 569 .await; 570 571 let entry = match fr { 572 Ok(e) => e, 573 Err(foyer::Error::Other(e)) => { 574 let record_error = match e.downcast::<RecordError>() { 575 Ok(e) => e, 576 Err(e) => { 577 log::error!("error (foyer other) getting cache entry, {e:?}"); 578 return GetRecordResponse::ServerError(xrpc_error( 579 "ServerError", 580 "sorry, something went wrong", 581 )); 582 } 583 }; 584 let RecordError::UpstreamBadRequest(ErrorResponseObject { error, message }) = 585 *record_error 586 else { 587 log::error!("RecordError getting cache entry, {record_error:?}"); 588 return GetRecordResponse::ServerError(xrpc_error( 589 "ServerError", 590 "sorry, something went wrong", 591 )); 592 }; 593 594 // all of the noise around here is so that we can ultimately reach this: 595 // upstream BadRequest extracted from the foyer result which we can proxy back 596 return GetRecordResponse::BadRequest(xrpc_error( 597 error, 598 format!("Upstream bad request: {message}"), 599 )); 600 } 601 Err(e) => { 602 log::error!("error (foyer) getting cache entry, {e:?}"); 603 return GetRecordResponse::ServerError(xrpc_error( 604 "ServerError", 605 "sorry, something went wrong", 606 )); 607 } 608 }; 609 610 match *entry { 611 CachedRecord::Found(ref raw) => { 612 let (found_cid, raw_value) = raw.into(); 613 if cid.clone().map(|c| c != found_cid).unwrap_or(false) { 614 return GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject { 615 error: "RecordNotFound".to_string(), 616 message: "A record was found but its CID did not match that requested" 617 .to_string(), 618 })); 619 } 620 // TODO: thank u stellz: https://gist.github.com/stella3d/51e679e55b264adff89d00a1e58d0272 621 let value = 622 serde_json::from_str(raw_value.get()).expect("RawValue to be valid json"); 623 GetRecordResponse::Ok(Json(FoundRecordResponseObject { 624 uri: at_uri, 625 cid: Some(found_cid.as_ref().to_string()), 626 value, 627 })) 628 } 629 CachedRecord::Deleted => GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject { 630 error: "RecordNotFound".to_string(), 631 message: "This record was deleted".to_string(), 632 })), 633 } 634 } 635 636 // TODO 637 // #[oai(path = "/com.atproto.identity.resolveHandle", method = "get")] 638 // #[oai(path = "/com.atproto.identity.resolveDid", method = "get")] 639 // but these are both not specified to do bidirectional validation, which is what we want to offer 640 // com.atproto.identity.resolveIdentity seems right, but requires returning the full did-doc 641 // would be nice if there were two queries: 642 // did -> verified handle + pds url 643 // handle -> verified did + pds url 644 // 645 // we could do horrible things and implement resolveIdentity with only a stripped-down fake did doc 646 // but this will *definitely* cause problems because eg. we're not currently storing pubkeys and 647 // those are a little bit important 648} 649 650#[derive(Debug, Clone, Serialize)] 651#[serde(rename_all = "camelCase")] 652struct AppViewService { 653 id: String, 654 r#type: String, 655 service_endpoint: String, 656} 657#[derive(Debug, Clone, Serialize)] 658struct AppViewDoc { 659 id: String, 660 service: [AppViewService; 1], 661} 662/// Serve a did document for did:web for this to be an xrpc appview 663/// 664/// No slingshot endpoints currently require auth, so it's not necessary to do 665/// service proxying, however clients may wish to: 666/// 667/// - PDS proxying offers a level of client IP anonymity from slingshot 668/// - slingshot *may* implement more generous per-user rate-limits for proxied requests in the future 669fn get_did_doc(domain: &str) -> impl Endpoint + use<> { 670 let doc = poem::web::Json(AppViewDoc { 671 id: format!("did:web:{domain}"), 672 service: [AppViewService { 673 id: "#slingshot".to_string(), 674 r#type: "SlingshotRecordProxy".to_string(), 675 service_endpoint: format!("https://{domain}"), 676 }], 677 }); 678 make_sync(move |_| doc.clone()) 679} 680 681pub async fn serve( 682 cache: HybridCache<String, CachedRecord>, 683 identity: Identity, 684 repo: Repo, 685 domain: Option<String>, 686 acme_contact: Option<String>, 687 certs: Option<PathBuf>, 688 shutdown: CancellationToken, 689) -> Result<(), ServerError> { 690 let repo = Arc::new(repo); 691 let api_service = OpenApiService::new( 692 Xrpc { 693 cache, 694 identity, 695 repo, 696 }, 697 "Slingshot", 698 env!("CARGO_PKG_VERSION"), 699 ) 700 .server(if let Some(ref h) = domain { 701 format!("https://{h}") 702 } else { 703 "http://localhost:3000".to_string() 704 }) 705 .url_prefix("/xrpc") 706 .contact( 707 ContactObject::new() 708 .name("@microcosm.blue") 709 .url("https://bsky.app/profile/microcosm.blue"), 710 ) 711 .description(include_str!("../api-description.md")) 712 .external_document(ExternalDocumentObject::new( 713 "https://microcosm.blue/slingshot", 714 )); 715 716 let mut app = Route::new() 717 .at("/", StaticFileEndpoint::new("./static/index.html")) 718 .nest("/openapi", api_service.spec_endpoint()) 719 .nest("/xrpc/", api_service); 720 721 if let Some(domain) = domain { 722 rustls::crypto::aws_lc_rs::default_provider() 723 .install_default() 724 .expect("alskfjalksdjf"); 725 726 app = app.at("/.well-known/did.json", get_did_doc(&domain)); 727 728 let mut auto_cert = AutoCert::builder() 729 .directory_url(LETS_ENCRYPT_PRODUCTION) 730 .domain(&domain); 731 if let Some(contact) = acme_contact { 732 auto_cert = auto_cert.contact(contact); 733 } 734 if let Some(certs) = certs { 735 auto_cert = auto_cert.cache_path(certs); 736 } 737 let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?; 738 739 run( 740 TcpListener::bind("0.0.0.0:443").acme(auto_cert), 741 app, 742 shutdown, 743 ) 744 .await 745 } else { 746 run(TcpListener::bind("127.0.0.1:3000"), app, shutdown).await 747 } 748} 749 750async fn run<L>(listener: L, app: Route, shutdown: CancellationToken) -> Result<(), ServerError> 751where 752 L: Listener + 'static, 753{ 754 let app = app 755 .with( 756 Cors::new() 757 .allow_origin_regex("*") 758 .allow_methods([Method::GET]) 759 .allow_credentials(false), 760 ) 761 .with(CatchPanic::new()) 762 .with(Tracing); 763 Server::new(listener) 764 .name("slingshot") 765 .run_with_graceful_shutdown(app, shutdown.cancelled(), None) 766 .await 767 .map_err(ServerError::ServerExited) 768 .inspect(|()| log::info!("server ended. goodbye.")) 769}