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