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// 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}