Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
1use crate::{Storage, 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}; 18use std::sync::{Arc, Mutex}; 19 20#[derive(Debug, SecurityScheme)] 21#[oai(ty = "bearer")] 22struct XrpcAuth(Bearer); 23 24#[derive(Tags)] 25enum ApiTags { 26 /// Custom pocket APIs 27 #[oai(rename = "Pocket APIs")] 28 Pocket, 29} 30 31#[derive(Object)] 32#[oai(example = true)] 33struct XrpcErrorResponseObject { 34 /// Should correspond an error `name` in the lexicon errors array 35 error: String, 36 /// Human-readable description and possibly additonal context 37 message: String, 38} 39impl Example for XrpcErrorResponseObject { 40 fn example() -> Self { 41 Self { 42 error: "PreferencesNotFound".to_string(), 43 message: "No preferences were found for this user".to_string(), 44 } 45 } 46} 47type XrpcError = Json<XrpcErrorResponseObject>; 48fn xrpc_error(error: impl AsRef<str>, message: impl AsRef<str>) -> XrpcError { 49 Json(XrpcErrorResponseObject { 50 error: error.as_ref().to_string(), 51 message: message.as_ref().to_string(), 52 }) 53} 54 55#[derive(Debug, Object)] 56#[oai(example = true)] 57struct BskyPrefsObject { 58 /// at-uri for this record 59 preferences: Value, 60} 61impl Example for BskyPrefsObject { 62 fn example() -> Self { 63 Self { 64 preferences: json!({ 65 "hello": "world", 66 }), 67 } 68 } 69} 70 71#[derive(ApiResponse)] 72enum GetBskyPrefsResponse { 73 /// Record found 74 #[oai(status = 200)] 75 Ok(Json<BskyPrefsObject>), 76 /// Bad request or no preferences to return 77 #[oai(status = 400)] 78 BadRequest(XrpcError), 79} 80 81#[derive(ApiResponse)] 82enum PutBskyPrefsResponse { 83 /// Record found 84 #[oai(status = 200)] 85 Ok(PlainText<String>), 86 /// Bad request or no preferences to return 87 #[oai(status = 400)] 88 BadRequest(XrpcError), 89 // /// Server errors 90 // #[oai(status = 500)] 91 // ServerError(XrpcError), 92} 93 94struct Xrpc { 95 verifier: TokenVerifier, 96 storage: Arc<Mutex<Storage>>, 97} 98 99#[OpenApi] 100impl Xrpc { 101 /// com.bad-example.pocket.getPreferences 102 /// 103 /// get stored preferencess 104 #[oai( 105 path = "/com.bad-example.pocket.getPreferences", 106 method = "get", 107 tag = "ApiTags::Pocket" 108 )] 109 async fn pocket_get_prefs(&self, XrpcAuth(auth): XrpcAuth) -> GetBskyPrefsResponse { 110 let (did, aud) = match self 111 .verifier 112 .verify("com.bad-example.pocket.getPreferences", &auth.token) 113 .await 114 { 115 Ok(d) => d, 116 Err(e) => return GetBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 117 }; 118 log::info!("verified did: {did}/{aud}"); 119 120 let storage = self.storage.clone(); 121 122 let Ok(Ok(res)) = tokio::task::spawn_blocking(move || { 123 storage 124 .lock() 125 .unwrap() 126 .get(&did, &aud) 127 .inspect_err(|e| log::error!("failed to get prefs: {e}")) 128 }) 129 .await 130 else { 131 return GetBskyPrefsResponse::BadRequest(xrpc_error("boooo", "failed to get from db")); 132 }; 133 134 let Some(serialized) = res else { 135 return GetBskyPrefsResponse::BadRequest(xrpc_error( 136 "NotFound", 137 "could not find prefs for u", 138 )); 139 }; 140 141 let preferences = match serde_json::from_str(&serialized) { 142 Ok(v) => v, 143 Err(e) => { 144 log::error!("failed to deserialize prefs: {e}"); 145 return GetBskyPrefsResponse::BadRequest(xrpc_error( 146 "boooo", 147 "failed to deserialize prefs", 148 )); 149 } 150 }; 151 152 GetBskyPrefsResponse::Ok(Json(BskyPrefsObject { preferences })) 153 } 154 155 /// com.bad-example.pocket.putPreferences 156 /// 157 /// store bluesky prefs 158 #[oai( 159 path = "/com.bad-example.pocket.putPreferences", 160 method = "post", 161 tag = "ApiTags::Pocket" 162 )] 163 async fn pocket_put_prefs( 164 &self, 165 XrpcAuth(auth): XrpcAuth, 166 Json(prefs): Json<BskyPrefsObject>, 167 ) -> PutBskyPrefsResponse { 168 let (did, aud) = match self 169 .verifier 170 .verify("com.bad-example.pocket.putPreferences", &auth.token) 171 .await 172 { 173 Ok(d) => d, 174 Err(e) => return PutBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 175 }; 176 log::info!("verified did: {did}/{aud}"); 177 log::warn!("received prefs: {prefs:?}"); 178 179 let storage = self.storage.clone(); 180 let serialized = prefs.preferences.to_string(); 181 182 let Ok(Ok(())) = tokio::task::spawn_blocking(move || { 183 storage 184 .lock() 185 .unwrap() 186 .put(&did, &aud, &serialized) 187 .inspect_err(|e| log::error!("failed to insert prefs: {e}")) 188 }) 189 .await 190 else { 191 return PutBskyPrefsResponse::BadRequest(xrpc_error("boooo", "failed to put to db")); 192 }; 193 194 PutBskyPrefsResponse::Ok(PlainText("saved.".to_string())) 195 } 196} 197 198#[derive(Debug, Clone, Serialize)] 199#[serde(rename_all = "camelCase")] 200struct AppViewService { 201 id: String, 202 r#type: String, 203 service_endpoint: String, 204} 205#[derive(Debug, Clone, Serialize)] 206struct AppViewDoc { 207 id: String, 208 service: [AppViewService; 2], 209} 210/// Serve a did document for did:web for this to be an xrpc appview 211fn get_did_doc(domain: &str) -> impl Endpoint + use<> { 212 let doc = poem::web::Json(AppViewDoc { 213 id: format!("did:web:{domain}"), 214 service: [ 215 AppViewService { 216 id: "#pocket_prefs".to_string(), 217 r#type: "PocketPreferences".to_string(), 218 service_endpoint: format!("https://{domain}"), 219 }, 220 AppViewService { 221 id: "#bsky_appview".to_string(), 222 r#type: "BlueskyAppview".to_string(), 223 service_endpoint: format!("https://{domain}"), 224 }, 225 ], 226 }); 227 make_sync(move |_| doc.clone()) 228} 229 230pub async fn serve(domain: &str, storage: Storage) -> () { 231 let verifier = TokenVerifier::default(); 232 let api_service = OpenApiService::new( 233 Xrpc { 234 verifier, 235 storage: Arc::new(Mutex::new(storage)), 236 }, 237 "Pocket", 238 env!("CARGO_PKG_VERSION"), 239 ) 240 .server(domain) 241 .url_prefix("/xrpc") 242 .contact( 243 ContactObject::new() 244 .name("@microcosm.blue") 245 .url("https://bsky.app/profile/microcosm.blue"), 246 ) 247 .description(include_str!("../api-description.md")) 248 .external_document(ExternalDocumentObject::new("https://microcosm.blue/pocket")); 249 250 let app = Route::new() 251 .nest("/openapi", api_service.spec_endpoint()) 252 .nest("/xrpc/", api_service) 253 .at("/.well-known/did.json", get_did_doc(domain)) 254 .at("/", StaticFileEndpoint::new("./static/index.html")) 255 .with( 256 Cors::new() 257 .allow_method(Method::GET) 258 .allow_method(Method::POST), 259 ) 260 .with(CatchPanic::new()) 261 .with(Tracing); 262 263 let listener = TcpListener::bind("127.0.0.1:3000"); 264 Server::new(listener).name("pocket").run(app).await.unwrap(); 265}