Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
1use crate::TokenVerifier; 2use poem::{ 3 Endpoint, EndpointExt, Route, Server, 4 endpoint::{StaticFileEndpoint, make_sync}, 5 http::Method, 6 listener::TcpListener, 7 middleware::{CatchPanic, Cors, Tracing}, 8}; 9use poem_openapi::{ 10 ApiResponse, ContactObject, ExternalDocumentObject, Object, OpenApi, OpenApiService, 11 SecurityScheme, Tags, 12 auth::Bearer, 13 payload::{Json, PlainText}, 14 types::Example, 15}; 16use serde::Serialize; 17use serde_json::{Value, json}; 18 19#[derive(Debug, SecurityScheme)] 20#[oai(ty = "bearer")] 21struct XrpcAuth(Bearer); 22 23#[derive(Tags)] 24enum ApiTags { 25 /// Custom pocket APIs 26 #[oai(rename = "Pocket APIs")] 27 Pocket, 28} 29 30#[derive(Object)] 31#[oai(example = true)] 32struct XrpcErrorResponseObject { 33 /// Should correspond an error `name` in the lexicon errors array 34 error: String, 35 /// Human-readable description and possibly additonal context 36 message: String, 37} 38impl Example for XrpcErrorResponseObject { 39 fn example() -> Self { 40 Self { 41 error: "PreferencesNotFound".to_string(), 42 message: "No preferences were found for this user".to_string(), 43 } 44 } 45} 46type XrpcError = Json<XrpcErrorResponseObject>; 47fn xrpc_error(error: impl AsRef<str>, message: impl AsRef<str>) -> XrpcError { 48 Json(XrpcErrorResponseObject { 49 error: error.as_ref().to_string(), 50 message: message.as_ref().to_string(), 51 }) 52} 53 54#[derive(Object)] 55#[oai(example = true)] 56struct GetBskyPrefsResponseObject { 57 /// at-uri for this record 58 preferences: Value, 59} 60impl Example for GetBskyPrefsResponseObject { 61 fn example() -> Self { 62 Self { 63 preferences: json!({ 64 "hello": "world", 65 }), 66 } 67 } 68} 69 70#[derive(ApiResponse)] 71enum GetBskyPrefsResponse { 72 /// Record found 73 #[oai(status = 200)] 74 Ok(Json<GetBskyPrefsResponseObject>), 75 /// Bad request or no preferences to return 76 #[oai(status = 400)] 77 BadRequest(XrpcError), 78} 79 80#[derive(ApiResponse)] 81enum PutBskyPrefsResponse { 82 /// Record found 83 #[oai(status = 200)] 84 Ok(PlainText<String>), 85 /// Bad request or no preferences to return 86 #[oai(status = 400)] 87 BadRequest(XrpcError), 88 // /// Server errors 89 // #[oai(status = 500)] 90 // ServerError(XrpcError), 91} 92 93struct Xrpc { 94 verifier: TokenVerifier, 95} 96 97#[OpenApi] 98impl Xrpc { 99 /// com.bad-example.pocket.getPreferences 100 /// 101 /// get stored bluesky prefs 102 #[oai( 103 path = "/com.bad-example.pocket.getPreferences", 104 method = "get", 105 tag = "ApiTags::Pocket" 106 )] 107 async fn app_bsky_get_prefs(&self, XrpcAuth(auth): XrpcAuth) -> GetBskyPrefsResponse { 108 let did = match self 109 .verifier 110 .verify("app.bsky.actor.getPreferences", &auth.token) 111 .await 112 { 113 Ok(d) => d, 114 Err(e) => return GetBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 115 }; 116 log::info!("verified did: {did}"); 117 // TODO: fetch from storage 118 GetBskyPrefsResponse::Ok(Json(GetBskyPrefsResponseObject::example())) 119 } 120 121 /// com.bad-example.pocket.putPreferences 122 /// 123 /// store bluesky prefs 124 #[oai( 125 path = "/com.bad-example.pocket.putPreferences", 126 method = "post", 127 tag = "ApiTags::Pocket" 128 )] 129 async fn app_bsky_put_prefs( 130 &self, 131 XrpcAuth(auth): XrpcAuth, 132 Json(prefs): Json<Value>, 133 ) -> PutBskyPrefsResponse { 134 let did = match self 135 .verifier 136 .verify("app.bsky.actor.getPreferences", &auth.token) 137 .await 138 { 139 Ok(d) => d, 140 Err(e) => return PutBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 141 }; 142 log::info!("verified did: {did}"); 143 log::warn!("received prefs: {prefs:?}"); 144 // TODO: put prefs into storage 145 PutBskyPrefsResponse::Ok(PlainText("hiiiiii".to_string())) 146 } 147} 148 149#[derive(Debug, Clone, Serialize)] 150#[serde(rename_all = "camelCase")] 151struct AppViewService { 152 id: String, 153 r#type: String, 154 service_endpoint: String, 155} 156#[derive(Debug, Clone, Serialize)] 157struct AppViewDoc { 158 id: String, 159 service: [AppViewService; 1], 160} 161/// Serve a did document for did:web for this to be an xrpc appview 162fn get_did_doc(domain: &str) -> impl Endpoint + use<> { 163 let doc = poem::web::Json(AppViewDoc { 164 id: format!("did:web:{domain}"), 165 service: [AppViewService { 166 id: "#pocket_prefs".to_string(), 167 r#type: "PocketPreferences".to_string(), 168 service_endpoint: format!("https://{domain}"), 169 }], 170 }); 171 make_sync(move |_| doc.clone()) 172} 173 174pub async fn serve(domain: &str) -> () { 175 let verifier = TokenVerifier::new(domain); 176 let api_service = OpenApiService::new(Xrpc { verifier }, "Pocket", env!("CARGO_PKG_VERSION")) 177 .server(domain) 178 .url_prefix("/xrpc") 179 .contact( 180 ContactObject::new() 181 .name("@microcosm.blue") 182 .url("https://bsky.app/profile/microcosm.blue"), 183 ) 184 .description(include_str!("../api-description.md")) 185 .external_document(ExternalDocumentObject::new("https://microcosm.blue/pocket")); 186 187 let app = Route::new() 188 .nest("/openapi", api_service.spec_endpoint()) 189 .nest("/xrpc/", api_service) 190 .at("/.well-known/did.json", get_did_doc(domain)) 191 .at("/", StaticFileEndpoint::new("./static/index.html")) 192 .with( 193 Cors::new() 194 .allow_method(Method::GET) 195 .allow_method(Method::POST), 196 ) 197 .with(CatchPanic::new()) 198 .with(Tracing); 199 200 let listener = TcpListener::bind("127.0.0.1:3000"); 201 Server::new(listener).name("pocket").run(app).await.unwrap(); 202}