forked from
microcosm.blue/microcosm-rs
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}