forked from
microcosm.blue/microcosm-rs
Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
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 host: String,
698 port: u16,
699 shutdown: CancellationToken,
700) -> Result<(), ServerError> {
701 let repo = Arc::new(repo);
702 let api_service = OpenApiService::new(
703 Xrpc {
704 cache,
705 identity,
706 repo,
707 },
708 "Slingshot",
709 env!("CARGO_PKG_VERSION"),
710 )
711 .server(if let Some(ref h) = domain {
712 format!("https://{h}")
713 } else {
714 "http://localhost:3000".to_string()
715 })
716 .url_prefix("/xrpc")
717 .contact(
718 ContactObject::new()
719 .name("@microcosm.blue")
720 .url("https://bsky.app/profile/microcosm.blue"),
721 )
722 .description(include_str!("../api-description.md"))
723 .external_document(ExternalDocumentObject::new(
724 "https://microcosm.blue/slingshot",
725 ));
726
727 let mut app = Route::new()
728 .at("/", StaticFileEndpoint::new("./static/index.html"))
729 .nest("/openapi", api_service.spec_endpoint())
730 .nest("/xrpc/", api_service);
731
732 if let Some(domain) = domain {
733 rustls::crypto::aws_lc_rs::default_provider()
734 .install_default()
735 .expect("alskfjalksdjf");
736
737 app = app.at("/.well-known/did.json", get_did_doc(&domain));
738
739 let mut auto_cert = AutoCert::builder()
740 .directory_url(LETS_ENCRYPT_PRODUCTION)
741 .domain(&domain);
742 if let Some(contact) = acme_contact {
743 auto_cert = auto_cert.contact(contact);
744 }
745 if let Some(certs) = certs {
746 auto_cert = auto_cert.cache_path(certs);
747 }
748 let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?;
749
750 run(
751 TcpListener::bind("0.0.0.0:443").acme(auto_cert),
752 app,
753 shutdown,
754 )
755 .await
756 } else {
757 run(
758 TcpListener::bind(format!("{host}:{port}")),
759 app,
760 shutdown,
761 )
762 .await
763 }
764}
765
766async fn run<L>(listener: L, app: Route, shutdown: CancellationToken) -> Result<(), ServerError>
767where
768 L: Listener + 'static,
769{
770 let app = app
771 .with(
772 Cors::new()
773 .allow_origin_regex("*")
774 .allow_methods([Method::GET])
775 .allow_credentials(false),
776 )
777 .with(CatchPanic::new())
778 .with(Tracing);
779 Server::new(listener)
780 .name("slingshot")
781 .run_with_graceful_shutdown(app, shutdown.cancelled(), None)
782 .await
783 .map_err(ServerError::ServerExited)
784 .inspect(|()| log::info!("server ended. goodbye."))
785}