Learn how to use Rust to build ATProto powered applications
1use crate::{
2 db::{StatusFromDb, create_tables_in_database},
3 ingester::start_ingester,
4 lexicons::record::KnownRecord,
5 lexicons::xyz::statusphere::Status,
6 storage::{SqliteSessionStore, SqliteStateStore},
7 templates::{HomeTemplate, LoginTemplate},
8};
9use actix_files::Files;
10use actix_session::{
11 Session, SessionMiddleware, config::PersistentSession, storage::CookieSessionStore,
12};
13use actix_web::{
14 App, HttpRequest, HttpResponse, HttpServer, Responder, Result,
15 cookie::{self, Key},
16 get, middleware, post,
17 web::{self, Redirect},
18};
19use askama::Template;
20use async_sqlite::{Pool, PoolBuilder};
21use atrium_api::{
22 agent::Agent,
23 types::Collection,
24 types::string::{Datetime, Did},
25};
26use atrium_common::resolver::Resolver;
27use atrium_identity::{
28 did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL},
29 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
30};
31use atrium_oauth::{
32 AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient,
33 KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope,
34};
35use dotenv::dotenv;
36use resolver::HickoryDnsTxtResolver;
37use serde::{Deserialize, Serialize};
38use std::{
39 collections::HashMap,
40 io::{Error, ErrorKind},
41 sync::Arc,
42};
43use templates::{ErrorTemplate, Profile};
44
45extern crate dotenv;
46
47mod db;
48mod ingester;
49mod lexicons;
50mod resolver;
51mod storage;
52mod templates;
53
54/// OAuthClientType to make it easier to access the OAuthClient in web requests
55type OAuthClientType = Arc<
56 OAuthClient<
57 SqliteStateStore,
58 SqliteSessionStore,
59 CommonDidResolver<DefaultHttpClient>,
60 AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>,
61 >,
62>;
63
64/// HandleResolver to make it easier to access the OAuthClient in web requests
65type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>;
66
67/// All the available emoji status options
68const STATUS_OPTIONS: [&str; 29] = [
69 "👍",
70 "👎",
71 "💙",
72 "🥹",
73 "😧",
74 "😤",
75 "🙃",
76 "😉",
77 "😎",
78 "🤓",
79 "🤨",
80 "🥳",
81 "😭",
82 "😤",
83 "🤯",
84 "🫡",
85 "💀",
86 "✊",
87 "🤘",
88 "👀",
89 "🧠",
90 "👩💻",
91 "🧑💻",
92 "🥷",
93 "🧌",
94 "🦋",
95 "🚀",
96 "🥔",
97 "🦀",
98];
99
100/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L71
101/// OAuth callback endpoint to complete session creation
102#[get("/oauth/callback")]
103async fn oauth_callback(
104 request: HttpRequest,
105 params: web::Query<CallbackParams>,
106 oauth_client: web::Data<OAuthClientType>,
107 session: Session,
108) -> HttpResponse {
109 //Processes the call back and parses out a session if found and valid
110 match oauth_client.callback(params.into_inner()).await {
111 Ok((bsky_session, _)) => {
112 let agent = Agent::new(bsky_session);
113 match agent.did().await {
114 Some(did) => {
115 session.insert("did", did).unwrap();
116 Redirect::to("/")
117 .see_other()
118 .respond_to(&request)
119 .map_into_boxed_body()
120 }
121 None => {
122 let html = ErrorTemplate {
123 title: "Error",
124 error: "The OAuth agent did not return a DID. May try re-logging in.",
125 };
126 HttpResponse::Ok().body(html.render().expect("template should be valid"))
127 }
128 }
129 }
130 Err(err) => {
131 log::error!("Error: {err}");
132 let html = ErrorTemplate {
133 title: "Error",
134 error: "OAuth error, check the logs",
135 };
136 HttpResponse::Ok().body(html.render().expect("template should be valid"))
137 }
138 }
139}
140
141/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L93
142/// Takes you to the login page
143#[get("/login")]
144async fn login() -> Result<impl Responder> {
145 let html = LoginTemplate {
146 title: "Log in",
147 error: None,
148 };
149 Ok(web::Html::new(
150 html.render().expect("template should be valid"),
151 ))
152}
153
154/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L93
155/// Logs you out by destroying your cookie on the server and web browser
156#[get("/logout")]
157async fn logout(request: HttpRequest, session: Session) -> HttpResponse {
158 session.purge();
159 Redirect::to("/")
160 .see_other()
161 .respond_to(&request)
162 .map_into_boxed_body()
163}
164
165/// The post body for logging in
166#[derive(Serialize, Deserialize, Clone)]
167struct LoginForm {
168 handle: String,
169}
170
171/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L101
172/// Login endpoint
173#[post("/login")]
174async fn login_post(
175 request: HttpRequest,
176 params: web::Form<LoginForm>,
177 oauth_client: web::Data<OAuthClientType>,
178) -> HttpResponse {
179 // This will act the same as the js method isValidHandle to make sure it is valid
180 match atrium_api::types::string::Handle::new(params.handle.clone()) {
181 Ok(handle) => {
182 //Creates the oauth url to redirect to for the user to log in with their credentials
183 let oauth_url = oauth_client
184 .authorize(
185 &handle,
186 AuthorizeOptions {
187 scopes: vec![
188 Scope::Known(KnownScope::Atproto),
189 Scope::Known(KnownScope::TransitionGeneric),
190 ],
191 ..Default::default()
192 },
193 )
194 .await;
195 match oauth_url {
196 Ok(url) => Redirect::to(url)
197 .see_other()
198 .respond_to(&request)
199 .map_into_boxed_body(),
200 Err(err) => {
201 log::error!("Error: {err}");
202 let html = LoginTemplate {
203 title: "Log in",
204 error: Some("OAuth error"),
205 };
206 HttpResponse::Ok().body(html.render().expect("template should be valid"))
207 }
208 }
209 }
210 Err(err) => {
211 let html: LoginTemplate<'_> = LoginTemplate {
212 title: "Log in",
213 error: Some(err),
214 };
215 HttpResponse::Ok().body(html.render().expect("template should be valid"))
216 }
217 }
218}
219
220/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L146
221/// Home
222#[get("/")]
223async fn home(
224 session: Session,
225 oauth_client: web::Data<OAuthClientType>,
226 db_pool: web::Data<Arc<Pool>>,
227 handle_resolver: web::Data<HandleResolver>,
228) -> Result<impl Responder> {
229 const TITLE: &str = "Home";
230 //Loads the last 10 statuses saved in the DB
231 let mut statuses = StatusFromDb::load_latest_statuses(&db_pool)
232 .await
233 .unwrap_or_else(|err| {
234 log::error!("Error loading statuses: {err}");
235 vec![]
236 });
237
238 //Simple way to cut down on resolve calls if we already know the handle for the did
239 let mut quick_resolve_map: HashMap<Did, String> = HashMap::new();
240 // We resolve the handles to the DID. This is a bit messy atm,
241 // and there are hopes to find a cleaner way
242 // to handle resolving the DIDs and formating the handles,
243 // But it gets the job done for the purpose of this tutorial.
244 // PRs are welcomed!
245 for db_status in &mut statuses {
246 let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did");
247 //Check to see if we already resolved it to cut down on resolve requests
248 match quick_resolve_map.get(&authors_did) {
249 None => {}
250 Some(found_handle) => {
251 db_status.handle = Some(found_handle.clone());
252 continue;
253 }
254 }
255 //Attempts to resolve the DID to a handle
256 db_status.handle = match handle_resolver.resolve(&authors_did).await {
257 Ok(did_doc) => {
258 match did_doc.also_known_as {
259 None => None,
260 Some(also_known_as) => {
261 match also_known_as.is_empty() {
262 true => None,
263 false => {
264 //also_known as a list starts the array with the highest priority handle
265 let formatted_handle =
266 format!("@{}", also_known_as[0]).replace("at://", "");
267 quick_resolve_map.insert(authors_did, formatted_handle.clone());
268 Some(formatted_handle)
269 }
270 }
271 }
272 }
273 }
274 Err(err) => {
275 log::error!("Error resolving did: {err}");
276 None
277 }
278 };
279 }
280
281 // If the user is signed in, get an agent which communicates with their server
282 match session.get::<String>("did").unwrap_or(None) {
283 Some(did) => {
284 let did = Did::new(did).expect("failed to parse did");
285 //Grabs the users last status to highlight it in the ui
286 let my_status = StatusFromDb::my_status(&db_pool, &did)
287 .await
288 .unwrap_or_else(|err| {
289 log::error!("Error loading my status: {err}");
290 None
291 });
292
293 // gets the user's session from the session store to resume
294 match oauth_client.restore(&did).await {
295 Ok(session) => {
296 //Creates an agent to make authenticated requests
297 let agent = Agent::new(session);
298
299 // Fetch additional information about the logged-in user
300 let profile = agent
301 .api
302 .app
303 .bsky
304 .actor
305 .get_profile(
306 atrium_api::app::bsky::actor::get_profile::ParametersData {
307 actor: atrium_api::types::string::AtIdentifier::Did(did),
308 }
309 .into(),
310 )
311 .await;
312
313 let html = HomeTemplate {
314 title: TITLE,
315 status_options: &STATUS_OPTIONS,
316 profile: match profile {
317 Ok(profile) => {
318 let profile_data = Profile {
319 did: profile.did.to_string(),
320 display_name: profile.display_name.clone(),
321 };
322 Some(profile_data)
323 }
324 Err(err) => {
325 log::error!("Error accessing profile: {err}");
326 None
327 }
328 },
329 statuses,
330 my_status: my_status.as_ref().map(|s| s.status.clone()),
331 }
332 .render()
333 .expect("template should be valid");
334
335 Ok(web::Html::new(html))
336 }
337 Err(err) => {
338 // Destroys the system or you're in a loop
339 session.purge();
340 log::error!("Error restoring session: {err}");
341 let error_html = ErrorTemplate {
342 title: "Error",
343 error: "Was an error resuming the session, please check the logs.",
344 }
345 .render()
346 .expect("template should be valid");
347 Ok(web::Html::new(error_html))
348 }
349 }
350 }
351
352 None => {
353 let html = HomeTemplate {
354 title: TITLE,
355 status_options: &STATUS_OPTIONS,
356 profile: None,
357 statuses,
358 my_status: None,
359 }
360 .render()
361 .expect("template should be valid");
362
363 Ok(web::Html::new(html))
364 }
365 }
366}
367
368/// The post body for changing your status
369#[derive(Serialize, Deserialize, Clone)]
370struct StatusForm {
371 status: String,
372}
373
374/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L208
375/// Creates a new status
376#[post("/status")]
377async fn status(
378 request: HttpRequest,
379 session: Session,
380 oauth_client: web::Data<OAuthClientType>,
381 db_pool: web::Data<Arc<Pool>>,
382 form: web::Form<StatusForm>,
383) -> HttpResponse {
384 // Check if the user is logged in
385 match session.get::<String>("did").unwrap_or(None) {
386 Some(did_string) => {
387 let did = Did::new(did_string.clone()).expect("failed to parse did");
388 // gets the user's session from the session store to resume
389 match oauth_client.restore(&did).await {
390 Ok(session) => {
391 let agent = Agent::new(session);
392 //Creates a strongly typed ATProto record
393 let status: KnownRecord = lexicons::xyz::statusphere::status::RecordData {
394 created_at: Datetime::now(),
395 status: form.status.clone(),
396 }
397 .into();
398
399 // TODO no data validation yet from esquema
400 // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3
401
402 let create_result = agent
403 .api
404 .com
405 .atproto
406 .repo
407 .create_record(
408 atrium_api::com::atproto::repo::create_record::InputData {
409 collection: Status::NSID.parse().unwrap(),
410 repo: did.into(),
411 rkey: None,
412 record: status.into(),
413 swap_commit: None,
414 validate: None,
415 }
416 .into(),
417 )
418 .await;
419
420 match create_result {
421 Ok(record) => {
422 let status = StatusFromDb::new(
423 record.uri.clone(),
424 did_string,
425 form.status.clone(),
426 );
427
428 let _ = status.save(db_pool).await;
429 Redirect::to("/")
430 .see_other()
431 .respond_to(&request)
432 .map_into_boxed_body()
433 }
434 Err(err) => {
435 log::error!("Error creating status: {err}");
436 let error_html = ErrorTemplate {
437 title: "Error",
438 error: "Was an error creating the status, please check the logs.",
439 }
440 .render()
441 .expect("template should be valid");
442 HttpResponse::Ok().body(error_html)
443 }
444 }
445 }
446 Err(err) => {
447 // Destroys the system or you're in a loop
448 session.purge();
449 log::error!(
450 "Error restoring session, we are removing the session from the cookie: {err}"
451 );
452 let error_html = ErrorTemplate {
453 title: "Error",
454 error: "Was an error resuming the session, please check the logs.",
455 }
456 .render()
457 .expect("template should be valid");
458 HttpResponse::Ok().body(error_html)
459 }
460 }
461 }
462 None => {
463 let error_template = ErrorTemplate {
464 title: "Error",
465 error: "You must be logged in to create a status.",
466 }
467 .render()
468 .expect("template should be valid");
469 HttpResponse::Ok().body(error_template)
470 }
471 }
472}
473
474#[actix_web::main]
475async fn main() -> std::io::Result<()> {
476 dotenv().ok();
477 env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
478 let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
479 let port = std::env::var("PORT")
480 .unwrap_or_else(|_| "8080".to_string())
481 .parse::<u16>()
482 .unwrap_or(8080);
483
484 //Uses a default sqlite db path or use the one from env
485 let db_connection_string =
486 std::env::var("DB_PATH").unwrap_or_else(|_| String::from("./statusphere.sqlite3"));
487
488 //Crates a db pool to share resources to the db
489 let pool = match PoolBuilder::new().path(db_connection_string).open().await {
490 Ok(pool) => pool,
491 Err(err) => {
492 log::error!("Error creating the sqlite pool: {}", err);
493 return Err(Error::new(
494 ErrorKind::Other,
495 "sqlite pool could not be created.",
496 ));
497 }
498 };
499
500 //Creates the DB and tables
501 create_tables_in_database(&pool)
502 .await
503 .expect("Could not create the database");
504
505 //Create a new handle resolver for the home page
506 let http_client = Arc::new(DefaultHttpClient::default());
507
508 let handle_resolver = CommonDidResolver::new(CommonDidResolverConfig {
509 plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
510 http_client: http_client.clone(),
511 });
512 let handle_resolver = Arc::new(handle_resolver);
513
514 // Create a new OAuth client
515 let http_client = Arc::new(DefaultHttpClient::default());
516 let config = OAuthClientConfig {
517 client_metadata: AtprotoLocalhostClientMetadata {
518 redirect_uris: Some(vec![String::from(format!(
519 //This must match the endpoint you use the callback function
520 "http://{host}:{port}/oauth/callback"
521 ))]),
522 scopes: Some(vec![
523 Scope::Known(KnownScope::Atproto),
524 Scope::Known(KnownScope::TransitionGeneric),
525 ]),
526 },
527 keys: None,
528 resolver: OAuthResolverConfig {
529 did_resolver: CommonDidResolver::new(CommonDidResolverConfig {
530 plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
531 http_client: http_client.clone(),
532 }),
533 handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig {
534 dns_txt_resolver: HickoryDnsTxtResolver::default(),
535 http_client: http_client.clone(),
536 }),
537 authorization_server_metadata: Default::default(),
538 protected_resource_metadata: Default::default(),
539 },
540 state_store: SqliteStateStore::new(pool.clone()),
541 session_store: SqliteSessionStore::new(pool.clone()),
542 };
543 let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client"));
544 let arc_pool = Arc::new(pool.clone());
545 //Spawns the ingester that listens for other's Statusphere updates
546 tokio::spawn(async move {
547 start_ingester(arc_pool).await;
548 });
549 let arc_pool = Arc::new(pool.clone());
550 log::info!("starting HTTP server at http://{host}:{port}");
551 HttpServer::new(move || {
552 App::new()
553 .wrap(middleware::Logger::default())
554 .app_data(web::Data::new(client.clone()))
555 .app_data(web::Data::new(arc_pool.clone()))
556 .app_data(web::Data::new(handle_resolver.clone()))
557 .wrap(
558 SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&[0; 64]))
559 //TODO will need to set to true in production
560 .cookie_secure(false)
561 // customize session and cookie expiration
562 .session_lifecycle(
563 PersistentSession::default().session_ttl(cookie::time::Duration::days(14)),
564 )
565 .build(),
566 )
567 .service(Files::new("/css", "public/css").show_files_listing())
568 .service(oauth_callback)
569 .service(login)
570 .service(login_post)
571 .service(logout)
572 .service(home)
573 .service(status)
574 })
575 .bind(("127.0.0.1", port))?
576 .run()
577 .await
578}