Learn how to use Rust to build ATProto powered applications
at main 21 kB view raw
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}