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// app.bsky.actor.getPreferences 98// com.bad-example.pocket.getPreferences 99 100#[OpenApi] 101impl Xrpc { 102 /// com.bad-example.pocket.getPreferences 103 /// 104 /// get stored bluesky prefs 105 #[oai( 106 path = "/app.bsky.actor.getPreferences", 107 method = "get", 108 tag = "ApiTags::Pocket" 109 )] 110 async fn app_bsky_get_prefs(&self, XrpcAuth(auth): XrpcAuth) -> GetBskyPrefsResponse { 111 let did = match self 112 .verifier 113 .verify("app.bsky.actor.getPreferences", &auth.token) 114 .await 115 { 116 Ok(d) => d, 117 Err(e) => return GetBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 118 }; 119 log::info!("verified did: {did}"); 120 // TODO: fetch from storage 121 GetBskyPrefsResponse::Ok(Json(GetBskyPrefsResponseObject::example())) 122 } 123 124 /// com.bad-example.pocket.putPreferences 125 /// 126 /// store bluesky prefs 127 #[oai( 128 path = "/com.bad-example.pocket.putPreferences", 129 method = "post", 130 tag = "ApiTags::Pocket" 131 )] 132 async fn app_bsky_put_prefs( 133 &self, 134 XrpcAuth(auth): XrpcAuth, 135 Json(prefs): Json<Value>, 136 ) -> PutBskyPrefsResponse { 137 let did = match self 138 .verifier 139 .verify("app.bsky.actor.getPreferences", &auth.token) 140 .await 141 { 142 Ok(d) => d, 143 Err(e) => return PutBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 144 }; 145 log::info!("verified did: {did}"); 146 log::warn!("received prefs: {prefs:?}"); 147 // TODO: put prefs into storage 148 PutBskyPrefsResponse::Ok(PlainText("hiiiiii".to_string())) 149 } 150} 151 152#[derive(Debug, Clone, Serialize)] 153#[serde(rename_all = "camelCase")] 154struct AppViewService { 155 id: String, 156 r#type: String, 157 service_endpoint: String, 158} 159#[derive(Debug, Clone, Serialize)] 160struct AppViewDoc { 161 id: String, 162 service: [AppViewService; 2], 163} 164/// Serve a did document for did:web for this to be an xrpc appview 165fn get_did_doc(domain: &str) -> impl Endpoint + use<> { 166 let doc = poem::web::Json(AppViewDoc { 167 id: format!("did:web:{domain}"), 168 service: [ 169 AppViewService { 170 id: "#pocket_prefs".to_string(), 171 // id: "#bsky_appview".to_string(), 172 r#type: "PocketPreferences".to_string(), 173 service_endpoint: format!("https://{domain}"), 174 }, 175 AppViewService { 176 id: "#bsky_appview".to_string(), 177 // id: "#bsky_appview".to_string(), 178 r#type: "BlueskyAppview".to_string(), 179 service_endpoint: format!("https://{domain}"), 180 }, 181 ], 182 }); 183 make_sync(move |_| doc.clone()) 184} 185 186pub async fn serve(domain: &str) -> () { 187 let verifier = TokenVerifier::new(domain); 188 let api_service = OpenApiService::new(Xrpc { verifier }, "Pocket", env!("CARGO_PKG_VERSION")) 189 .server(domain) 190 .url_prefix("/xrpc") 191 .contact( 192 ContactObject::new() 193 .name("@microcosm.blue") 194 .url("https://bsky.app/profile/microcosm.blue"), 195 ) 196 .description(include_str!("../api-description.md")) 197 .external_document(ExternalDocumentObject::new("https://microcosm.blue/pocket")); 198 199 let app = Route::new() 200 .nest("/openapi", api_service.spec_endpoint()) 201 .nest("/xrpc/", api_service) 202 .at("/.well-known/did.json", get_did_doc(domain)) 203 .at("/", StaticFileEndpoint::new("./static/index.html")) 204 .with( 205 Cors::new() 206 .allow_method(Method::GET) 207 .allow_method(Method::POST), 208 ) 209 .with(CatchPanic::new()) 210 .with(Tracing); 211 212 let listener = TcpListener::bind("127.0.0.1:3000"); 213 Server::new(listener).name("pocket").run(app).await.unwrap(); 214}