···11+# Environment Configuration
22+PORT="8080" # The port your server will listen on
33+HOST="127.0.0.1" # Hostname for the server
44+PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
55+# DB_PATH="./statusphere.sqlite3" # The SQLite database path. Leave commented out to use a temporary in-memory database.
66+77+
···11+MIT License
22+33+Copyright (c) 2025 Bailey Townsend
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+1070-5
README.md
···11-# Dev commands
11+22+33+# !!!!!!!!!!!!!!!Squash before going public!!!!!!!!!!
44+55+66+77+88+99+1010+1111+1212+Originally taken
1313+from [bluesky-social/atproto-website](https://github.com/bluesky-social/atproto-website/blob/dbcd70ced53078579c7e5b015a26db295b7a7807/src/app/%5Blocale%5D/guides/applications/en.mdx)
1414+1515+> [!NOTE]
1616+> ***This tutorial is based off of the original quick start guide found [here](https://atproto.com/guides/applications).
1717+> The goal is to follow as closely to the original as possible, expect for one small change. It's in Rust 🦀.
1818+> All credit goes to the maintainers of the original project and tutorial. This was made to help you get started with
1919+> using Rust to write applications in the Atmosphere. Parts that stray from the tutorial, or need extra context will be in blocks like this one.***
2020+2121+# Quick start guide to building applications on AT Protocol
2222+2323+[Find the source code on GitHub](https://github.com/fatfingers23/rusty_statusphere_example_app)
2424+2525+In this guide, we're going to build a simple multi-user app that publishes your current "status" as an emoji. Our
2626+application will look like this:
2727+2828+
2929+3030+We will cover how to:
3131+3232+- Signin via OAuth
3333+- Fetch information about users (profiles)
3434+- Listen to the network firehose for new data via the [Jetstream](https://docs.bsky.app/blog/jetstream)
3535+- Publish data on the user's account using a custom schema
3636+3737+We're going to keep this light so you can quickly wrap your head around ATProto. There will be links with more
3838+information about each step.
3939+4040+## Introduction
4141+4242+Data in the Atmosphere is stored on users' personal repos. It's almost like each user has their own website. Our goal is
4343+to aggregate data from the users into our SQLite DB.
4444+4545+Think of our app like a Google. If Google's job was to say which emoji each website had under `/status.json`, then it
4646+would show something like:
4747+4848+- `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.json`
4949+- `bsky.app` is feeling 🦋 according to `https://bsky.app/status.json`
5050+- `reddit.com` is feeling 🤓 according to `https://reddit.com/status.json`
5151+5252+The Atmosphere works the same way, except we're going to check `at://` instead of `https://`. Each user has a data repo
5353+under an `at://` URL. We'll crawl all the user data repos in the Atmosphere for all the "status.json" records and
5454+aggregate them into our SQLite database.
5555+5656+> `at://` is the URL scheme of the AT Protocol. Under the hood it uses common tech like HTTP and DNS, but it adds all of
5757+> the features we'll be using in this tutorial.
5858+5959+## Step 1. Starting with our Actix Web app
6060+6161+Start by cloning the repo and installing packages.
6262+6363+```bash
6464+git clone git@github.com:fatfingers23/rusty_statusphere_example_app.git
6565+cd rusty_statusphere_example_app
6666+cp .env.template .env
6767+cargo run
6868+# Navigate to http://127.0.0.1:8080
6969+```
7070+7171+Our repo is a regular Web app. We're rendering our HTML server-side like it's 1999. We also have a SQLite database that
7272+we're managing with [async-sqlite](https://crates.io/crates/async-sqlite).
7373+7474+Our starting stack:
7575+7676+- [Rust](https://www.rust-lang.org/tools/install)
7777+- Rust web server ([Actix Web](https://actix.rs/))
7878+- SQLite database ([async-sqlite](https://crates.io/crates/async-sqlite))
7979+- HTML Templating ([askama](https://crates.io/crates/askama))
8080+8181+> [!NOTE]
8282+> Along with the above, we are also using a couple of community maintained projects for using rust with the ATProtocol.
8383+> Since these are community maintained I have also linked sponsor links for the maintainers and _highly_ recommend you to
8484+> think
8585+> about sponsoring them.
8686+> Thanks to their work and projects, we are able to create Rust applications in the Atmosphere.
8787+> - ATProtocol client and OAuth
8888+ with [atrium](https://github.com/atrium-rs/atrium) - [sponsor sugyan](https://github.com/sponsors/sugyan)
8989+> - Jetstream consumer
9090+ with [rocketman](https://crates.io/crates/rocketman)- [buy natalie a coffee](https://ko-fi.com/uxieq)
9191+9292+With each step we'll explain how our Web app taps into the Atmosphere. Refer to the codebase for more detailed code
9393+— again, this tutorial is going to keep it light and quick to digest.
9494+9595+## Step 2. Signing in with OAuth
9696+9797+When somebody logs into our app, they'll give us read & write access to their personal `at://` repo. We'll use that to
9898+write the status json record.
9999+100100+We're going to accomplish this using OAuth ([spec](https://github.com/bluesky-social/proposals/tree/main/0004-oauth)).
101101+Most of the OAuth flows are going to be handled for us using
102102+the [atrium-oauth](https://crates.io/crates/atrium-oauth)
103103+crate. This is the arrangement we're aiming toward:
104104+105105+
106106+107107+When the user logs in, the OAuth client will create a new session with their repo server and give us read/write access
108108+along with basic user info.
109109+110110+
111111+112112+Our login page just asks the user for their "handle," which is the domain name associated with their account.
113113+For [Bluesky](https://bsky.app) users, these tend to look like `alice.bsky.social`, but they can be any kind of domain (
114114+eg `alice.com`).
115115+116116+```html
117117+<!-- templates/login.html -->
118118+<form action="/login" method="post" class="login-form">
119119+ <input
120120+ type="text"
121121+ name="handle"
122122+ placeholder="Enter your handle (eg alice.bsky.social)"
123123+ required
124124+ />
125125+ <button type="submit">Log in</button>
126126+</form>
127127+```
128128+129129+When they submit the form, we tell our OAuth client to initiate the authorization flow and then redirect the user to
130130+their server to complete the process.
131131+132132+```rust
133133+/** ./src/main.rs **/
134134+/// Login endpoint
135135+#[post("/login")]
136136+async fn login_post(
137137+ request: HttpRequest,
138138+ params: web::Form<LoginForm>,
139139+ oauth_client: web::Data<OAuthClientType>,
140140+) -> HttpResponse {
141141+ // This will act the same as the js method isValidHandle
142142+ match atrium_api::types::string::Handle::new(params.handle.clone()) {
143143+ Ok(handle) => {
144144+ // Initiates the OAuth flow
145145+ let oauth_url = oauth_client
146146+ .authorize(
147147+ &handle,
148148+ AuthorizeOptions {
149149+ scopes: vec![
150150+ Scope::Known(KnownScope::Atproto),
151151+ Scope::Known(KnownScope::TransitionGeneric),
152152+ ],
153153+ ..Default::default()
154154+ },
155155+ )
156156+ .await;
157157+ match oauth_url {
158158+ Ok(url) => Redirect::to(url)
159159+ .see_other()
160160+ .respond_to(&request)
161161+ .map_into_boxed_body(),
162162+ Err(err) => {
163163+ log::error!("Error: {err}");
164164+ let html = LoginTemplate {
165165+ title: "Log in",
166166+ error: Some("OAuth error"),
167167+ };
168168+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
169169+ }
170170+ }
171171+ }
172172+ Err(err) => {
173173+ let html: LoginTemplate<'_> = LoginTemplate {
174174+ title: "Log in",
175175+ error: Some(err),
176176+ };
177177+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
178178+ }
179179+ }
180180+}
181181+```
182182+183183+This is the same kind of SSO flow that Google or GitHub uses. The user will be asked for their password, then asked to
184184+confirm the session with your application.
185185+186186+When that finishes, the user will be sent back to `/oauth/callback` on our Web app. The OAuth client will store the
187187+access tokens for the user's server, and then we attach their account's [DID](https://atproto.com/specs/did) to the
188188+cookie-session.
189189+190190+```rust
191191+/** ./src/main.rs **/
192192+/// OAuth callback endpoint to complete session creation
193193+#[get("/oauth/callback")]
194194+async fn oauth_callback(
195195+ request: HttpRequest,
196196+ params: web::Query<CallbackParams>,
197197+ oauth_client: web::Data<OAuthClientType>,
198198+ session: Session,
199199+) -> HttpResponse {
200200+ // Store the credentials
201201+ match oauth_client.callback(params.into_inner()).await {
202202+ Ok((bsky_session, _)) => {
203203+ let agent = Agent::new(bsky_session);
204204+ match agent.did().await {
205205+ Some(did) => {
206206+ //Attach the account DID to our user via a cookie
207207+ session.insert("did", did).unwrap();
208208+ Redirect::to("/")
209209+ .see_other()
210210+ .respond_to(&request)
211211+ .map_into_boxed_body()
212212+ }
213213+ None => {
214214+ let html = ErrorTemplate {
215215+ title: "Log in",
216216+ error: "The OAuth agent did not return a DID. My try relogging in.",
217217+ };
218218+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
219219+ }
220220+ }
221221+ }
222222+ Err(err) => {
223223+ log::error!("Error: {err}");
224224+ let html = ErrorTemplate {
225225+ title: "Log in",
226226+ error: "OAuth error, check the logs",
227227+ };
228228+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
229229+ }
230230+ }
231231+}
232232+```
233233+234234+With that, we're in business! We now have a session with the user's repo server and can use that to access their data.
235235+236236+## Step 3. Fetching the user's profile
237237+238238+Why don't we learn something about our user? In [Bluesky](https://bsky.app), users publish a "profile" record which
239239+looks like this:
240240+241241+```rust
242242+pub struct ProfileViewDetailedData {
243243+ pub display_name: Option<String>, // a human friendly name
244244+ pub description: Option<String>, // a short bio
245245+ pub avatar: Option<String>, // small profile picture
246246+ pub banner: Option<String>, // banner image to put on profiles
247247+ pub created_at: Option<String> // declared time this profile data was added
248248+ // ...
249249+}
250250+```
251251+252252+You can examine this record directly using [atproto-browser.vercel.app](https://atproto-browser.vercel.app). For
253253+instance, [this is the profile record for @bsky.app](https://atproto-browser.vercel.app/at?u=at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.actor.profile/self).
254254+255255+> [!NOTE]
256256+> In the original tutorial `agent.com.atproto.repo.getRecord` is used, which is
257257+> this [method](https://docs.rs/atrium-api/latest/atrium_api/com/atproto/repo/get_record/index.html) in atrium-api.
258258+> For simplicity we are
259259+> using [agent.api.app.bsky.actor.get_profile](https://docs.rs/atrium-api/latest/atrium_api/app/bsky/actor/get_profile/index.html).
260260+> The original text found here has been moved to [Step 4. Reading & writing records](#step-4-reading--writing-records)
261261+> since it makes more sense in that context.
262262+263263+We're going to use the [Agent](https://crates.io/crates/atrium-oauth) associated with the
264264+user's OAuth session to fetch this record.
265265+266266+Let's update our homepage to fetch this profile record:
267267+268268+```rust
269269+/** ./src/main.rs **/
270270+/// Homepage
271271+#[get("/")]
272272+async fn home(
273273+ _req: HttpRequest,
274274+ session: Session,
275275+ oauth_client: web::Data<OAuthClientType>,
276276+ db_pool: web::Data<Pool>,
277277+ handle_resolver: web::Data<HandleResolver>,
278278+) -> Result<impl Responder> {
279279+ const TITLE: &str = "Home";
280280+281281+ // If the user is signed in, get an agent which communicates with their server
282282+ match session.get::<String>("did").unwrap_or(None) {
283283+ Some(did) => {
284284+ let did = Did::new(did).expect("failed to parse did");
285285+ match oauth_client.restore(&did).await {
286286+ Ok(session) => {
287287+ let agent = Agent::new(session);
288288+289289+ // Fetch additional information about the logged-in user
290290+ let profile = agent
291291+ .api
292292+ .app
293293+ .bsky
294294+ .actor
295295+ .get_profile(
296296+ atrium_api::app::bsky::actor::get_profile::ParametersData {
297297+ actor: atrium_api::types::string::AtIdentifier::Did(did),
298298+ }.into(),
299299+ )
300300+ .await;
301301+302302+ // Serve the logged-in view
303303+ let html = HomeTemplate {
304304+ title: TITLE,
305305+ status_options: &STATUS_OPTIONS,
306306+ profile: match profile {
307307+ Ok(profile) => {
308308+ let profile_data = Profile {
309309+ did: profile.did.to_string(),
310310+ display_name: profile.display_name.clone(),
311311+ };
312312+ Some(profile_data)
313313+ }
314314+ Err(err) => {
315315+ log::error!("Error accessing profile: {err}");
316316+ None
317317+ }
318318+ },
319319+ }.render().expect("template should be valid");
320320+321321+ Ok(web::Html::new(html))
322322+ }
323323+ Err(err) => {
324324+ //Unset the session
325325+ session.remove("did");
326326+ log::error!("Error restoring session: {err}");
327327+ let error_html = ErrorTemplate {
328328+ title: TITLE,
329329+ error: "Was an error resuming the session, please check the logs.",
330330+ }.render().expect("template should be valid");
331331+332332+ Ok(web::Html::new(error_html))
333333+ }
334334+ }
335335+ }
336336+ None => {
337337+ // Serve the logged-out view
338338+ let html = HomeTemplate {
339339+ title: TITLE,
340340+ status_options: &STATUS_OPTIONS,
341341+ profile: None,
342342+ }.render().expect("template should be valid");
343343+344344+ Ok(web::Html::new(html))
345345+ }
346346+ }
347347+}
348348+```
349349+350350+With that data, we can give a nice personalized welcome banner for our user:
351351+352352+
353353+354354+```html
355355+<!-- templates/home.html -->
356356+<div class="card">
357357+ {% if let Some(Profile {did, display_name}) = profile %}
358358+ <form action="/logout" method="post" class="session-form">
359359+ <div>
360360+ Hi,
361361+ {% if let Some(display_name) = display_name %}
362362+ <strong>{{display_name}}</strong>
363363+ {% else %}
364364+ <strong>friend</strong>
365365+ {% endif %}.
366366+ What's your status today??
367367+ </div>
368368+ <div>
369369+ <button type="submit">Log out</button>
370370+ </div>
371371+ </form>
372372+ {% else %}
373373+ <div class="session-form">
374374+ <div><a href="/login">Log in</a> to set your status!</div>
375375+ <div>
376376+ <a href="/login" class="button">Log in</a>
377377+ </div>
378378+ </div>
379379+ {% endif %}
380380+</div>
381381+```
382382+383383+## Step 4. Reading & writing records
384384+385385+You can think of the user repositories as collections of JSON records:
386386+387387+
388388+389389+When asking for a record, we provide three pieces of information.
390390+391391+- **repo** The [DID](https://atproto.com/specs/did) which identifies the user,
392392+- **collection** The collection name, and
393393+- **rkey** The record key
394394+395395+We'll explain the collection name shortly. Record keys are strings
396396+with [some restrictions](https://atproto.com/specs/record-key#record-key-syntax) and a couple of common patterns. The
397397+`"self"` pattern is used when a collection is expected to only contain one record which describes the user.
398398+399399+Let's look again at how we read the "profile" record:
400400+401401+```rust
402402+fn example_get_record() {
403403+ let get_result = agent
404404+ .api
405405+ .com
406406+ .atproto
407407+ .repo
408408+ .get_record(
409409+ atrium_api::com::atproto::repo::get_record::ParametersData {
410410+ cid: None,
411411+ collection: "app.bsky.actor.profile" // The collection
412412+ .parse()
413413+ .unwrap(),
414414+ repo: did.into(), // The user
415415+ rkey: "self".parse().unwrap(), // The record key
416416+ }
417417+ .into(),
418418+ )
419419+ .await;
420420+}
421421+422422+```
423423+424424+We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen:
425425+426426+```rust
427427+fn example_create_record() {
428428+ let did = atrium_api::types::string::Did::new(did_string.clone()).unwrap();
429429+ let agent = Agent::new(session);
430430+431431+ let status: Unknown = serde_json::from_str(
432432+ format!(
433433+ r#"{{"$type":"xyz.statusphere.status","status":"{}","createdAt":"{}"}}"#,
434434+ form.status,
435435+ Datetime::now().as_str()
436436+ )
437437+ .as_str(),
438438+ ).unwrap();
439439+440440+ let create_result = agent
441441+ .api
442442+ .com
443443+ .atproto
444444+ .repo
445445+ .create_record(
446446+ atrium_api::com::atproto::repo::create_record::InputData {
447447+ collection: Status::NSID.parse().unwrap(), // The collection
448448+ repo: did.clone().into(), // The user
449449+ rkey: None, // The record key, auto creates with None
450450+ record: status, // The record from a strong type
451451+ swap_commit: None,
452452+ validate: None,
453453+ }
454454+ .into(),
455455+ )
456456+ .await;
457457+}
458458+```
459459+460460+Our `POST /status` route is going to use this API to publish the user's status to their repo.
461461+462462+```rust
463463+/// "Set status" Endpoint
464464+#[post("/status")]
465465+async fn status(
466466+ request: HttpRequest,
467467+ session: Session,
468468+ oauth_client: web::Data<OAuthClientType>,
469469+ db_pool: web::Data<Pool>,
470470+ form: web::Form<StatusForm>,
471471+) -> HttpResponse {
472472+ const TITLE: &str = "Home";
473473+474474+ // If the user is signed in, get an agent which communicates with their server
475475+ match session.get::<String>("did").unwrap_or(None) {
476476+ Some(did_string) => {
477477+ let did = atrium_api::types::string::Did::new(did_string.clone())
478478+ .expect("failed to parse did");
479479+ match oauth_client.restore(&did).await {
480480+ Ok(session) => {
481481+ let agent = Agent::new(session);
482482+483483+ // Construct their status record
484484+ let status: Unknown = serde_json::from_str(
485485+ format!(
486486+ r#"{{"$type":"xyz.statusphere.status","status":"{}","createdAt":"{}"}}"#,
487487+ form.status,
488488+ Datetime::now().as_str()
489489+ )
490490+ .as_str(),
491491+ ).unwrap();
492492+493493+ // Write the status record to the user's repository
494494+ let create_result = agent
495495+ .api
496496+ .com
497497+ .atproto
498498+ .repo
499499+ .create_record(
500500+ atrium_api::com::atproto::repo::create_record::InputData {
501501+ collection: "xyz.statusphere.status".parse().unwrap(),
502502+ repo: did.clone().into(),
503503+ rkey: None,
504504+ record: status,
505505+ swap_commit: None,
506506+ validate: None,
507507+ }
508508+ .into(),
509509+ )
510510+ .await;
511511+512512+ match create_result {
513513+ Ok(_) => Redirect::to("/")
514514+ .see_other()
515515+ .respond_to(&request)
516516+ .map_into_boxed_body(),
517517+ Err(err) => {
518518+ log::error!("Error creating status: {err}");
519519+ let error_html = ErrorTemplate {
520520+ title: TITLE,
521521+ error: "Was an error creating the status, please check the logs.",
522522+ }
523523+ .render()
524524+ .expect("template should be valid");
525525+ HttpResponse::Ok().body(error_html)
526526+ }
527527+ }
528528+ }
529529+ Err(err) => {
530530+ //Unset the session
531531+ session.remove("did");
532532+ log::error!(
533533+ "Error restoring session, we are removing the session from the cookie: {err}"
534534+ );
535535+ let error_html = ErrorTemplate {
536536+ title: TITLE,
537537+ error: "Was an error resuming the session, please check the logs.",
538538+ }
539539+ .render()
540540+ .expect("template should be valid");
541541+ HttpResponse::Ok().body(error_html)
542542+ }
543543+ }
544544+ }
545545+ None => {
546546+ let error_template = ErrorTemplate {
547547+ title: "Error",
548548+ error: "You must be logged in to create a status.",
549549+ }
550550+ .render()
551551+ .expect("template should be valid");
552552+ HttpResponse::Ok().body(error_template)
553553+ }
554554+ }
555555+}
556556+```
557557+558558+Now in our homepage we can list out the status buttons:
559559+560560+```html
561561+<!-- templates/home.html -->
562562+<form action="/status" method="post" class="status-options">
563563+ {% for status in status_options %}
564564+ <button
565565+ class="{% if let Some(my_status) = my_status %} {%if my_status == status %} status-option selected {% else %} status-option {% endif %} {% else %} status-option {%endif%} "
566566+ name="status" value="{{status}}">
567567+ {{status}}
568568+ </button>
569569+ {% endfor %}
570570+</form>
571571+```
572572+573573+And here we are!
574574+575575+
576576+577577+## Step 5. Creating a custom "status" schema
578578+579579+Repo collections are typed, meaning that they have a defined schema. The `app.bsky.actor.profile` type
580580+definition [can be found here](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/actor/profile.json).
581581+582582+Anybody can create a new schema using the [Lexicon](https://atproto.com/specs/lexicon) language, which is very similar
583583+to [JSON-Schema](http://json-schema.org/). The schemas use [reverse-DNS IDs](https://atproto.com/specs/nsid) which
584584+indicate ownership. In this demo app we're going to use `xyz.statusphere` which we registered specifically for this
585585+project (aka statusphere.xyz).
586586+587587+> ### Why create a schema?
588588+>
589589+> Schemas help other applications understand the data your app is creating. By publishing your schemas, you make it
590590+> easier for other application authors to publish data in a format your app will recognize and handle.
591591+592592+Let's create our schema in the `/lexicons` folder of our codebase. You
593593+can [read more about how to define schemas here](https://atproto.com/guides/lexicon).
594594+595595+```json
596596+/** lexicons/status.json **/
597597+{
598598+ "lexicon": 1,
599599+ "id": "xyz.statusphere.status",
600600+ "defs": {
601601+ "main": {
602602+ "type": "record",
603603+ "key": "tid",
604604+ "record": {
605605+ "type": "object",
606606+ "required": [
607607+ "status",
608608+ "createdAt"
609609+ ],
610610+ "properties": {
611611+ "status": {
612612+ "type": "string",
613613+ "minLength": 1,
614614+ "maxGraphemes": 1,
615615+ "maxLength": 32
616616+ },
617617+ "createdAt": {
618618+ "type": "string",
619619+ "format": "datetime"
620620+ }
621621+ }
622622+ }
623623+ }
624624+ }
625625+}
626626+```
627627+628628+Now let's run some code-generation using our schema:
629629+630630+> [!NOTE]
631631+> For generating schemas, we are going to
632632+> use [esquema-cli](https://github.com/fatfingers23/esquema?tab=readme-ov-file)
633633+> (Which is a tool I've created from a fork of atrium's codegen).
634634+> This can be installed by running this command
635635+`cargo install esquema-cli --git https://github.com/fatfingers23/esquema.git`
636636+> This is a WIP tool with bugs and missing features. But it's good enough for us to generate Rust types from the lexicon
637637+> schema.
638638+639639+```bash
640640+esquema-cli generate -l ./lexicons/ -o ./src/lexicons/
641641+```
642642+643643+644644+645645+This will produce Rust structs. Here's what that generated code looks like:
646646+647647+```rust
648648+/** ./src/lexicons/xyz/statusphere/status.rs **/
649649+// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
650650+//!Definitions for the `xyz.statusphere.status` namespace.
651651+use atrium_api::types::TryFromUnknown;
652652+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
653653+#[serde(rename_all = "camelCase")]
654654+pub struct RecordData {
655655+ pub created_at: atrium_api::types::string::Datetime,
656656+ pub status: String,
657657+}
658658+pub type Record = atrium_api::types::Object<RecordData>;
659659+impl From<atrium_api::types::Unknown> for RecordData {
660660+ fn from(value: atrium_api::types::Unknown) -> Self {
661661+ Self::try_from_unknown(value).unwrap()
662662+ }
663663+}
664664+665665+```
666666+667667+> [!NOTE]
668668+> You may have noticed we do not cover the validation part like in the TypeScript version.
669669+> Esquema can validate to a point such as the data structure and if a field is there or not.
670670+> But validation of the data itself is not possible, yet.
671671+> There are plans to add it.
672672+> Maybe you would like to add it?
673673+> https://github.com/fatfingers23/esquema/issues/3
674674+675675+Let's use that code to improve the `POST /status` route:
676676+677677+```rust
678678+/// "Set status" Endpoint
679679+#[post("/status")]
680680+async fn status(
681681+ request: HttpRequest,
682682+ session: Session,
683683+ oauth_client: web::Data<OAuthClientType>,
684684+ db_pool: web::Data<Pool>,
685685+ form: web::Form<StatusForm>,
686686+) -> HttpResponse {
687687+ // ...
688688+ let agent = Agent::new(session);
689689+ //We use the new status type we generated with esquema
690690+ let status: KnownRecord = lexicons::xyz::statusphere::status::RecordData {
691691+ created_at: Datetime::now(),
692692+ status: form.status.clone(),
693693+ }
694694+ .into();
695695+696696+ // TODO no validation yet from esquema
697697+ // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3
698698+699699+ let create_result = agent
700700+ .api
701701+ .com
702702+ .atproto
703703+ .repo
704704+ .create_record(
705705+ atrium_api::com::atproto::repo::create_record::InputData {
706706+ collection: Status::NSID.parse().unwrap(),
707707+ repo: did.into(),
708708+ rkey: None,
709709+ record: status.into(),
710710+ swap_commit: None,
711711+ validate: None,
712712+ }
713713+ .into(),
714714+ )
715715+ .await;
716716+ // ...
717717+}
718718+```
719719+> [!NOTE]
720720+> You will notice the first example used a string to serialize to Unknown, you could do something similar with
721721+> a struct you create, then serialize.But I created esquema to make that easier.
722722+> With esquema you can use other provided lexicons
723723+> or ones you create to build out the data structure for your ATProtocol application.
724724+> As well as in future updates it will honor the
725725+> validation you have in the Lexicon.
726726+> Things like string should be 10 long, etc.
727727+728728+## Step 6. Listening to the firehose
729729+730730+> [!IMPORTANT]
731731+> It is important to note that the original tutorial they connect directly to the firehose, but in this one we use
732732+> [rocketman](https://crates.io/crates/rocketman) to connect to the Jetstream instead.
733733+> For most use cases this is fine and usually easier when using other clients than the Bluesky provided ones.
734734+> But it is important to note there are some differences that can
735735+> be found in their introduction to Jetstream article.
736736+> https://docs.bsky.app/blog/jetstream#tradeoffs-and-use-cases
737737+738738+So far, we have:
739739+740740+- Logged in via OAuth
741741+- Created a custom schema
742742+- Read & written records for the logged in user
743743+744744+Now we want to fetch the status records from other users.
745745+746746+Remember how we referred to our app as being like Google, crawling around the repos to get their records? One advantage
747747+we have in the AT Protocol is that each repo publishes an event log of their updates.
748748+749749+
750750+751751+Using a [~~Relay~~ Jetstream service](https://docs.bsky.app/blog/jetstream) we can listen to an
752752+aggregated firehose of these events across all users in the network. In our case what we're looking for are valid
753753+`xyz.statusphere.status` records.
754754+755755+```rust
756756+/** ./src/ingester.rs **/
757757+#[async_trait]
758758+impl LexiconIngestor for StatusSphereIngester {
759759+ async fn ingest(&self, message: Event<Value>) -> anyhow::Result<()> {
760760+ if let Some(commit) = &message.commit {
761761+ //We manually construct the uri since jetstream does not provide it
762762+ //at://{users did}/{collection: xyz.statusphere.status}{records key}
763763+ let record_uri = format!("at://{}/{}/{}", message.did, commit.collection, commit.rkey);
764764+ match commit.operation {
765765+ Operation::Create | Operation::Update => {
766766+ if let Some(record) = &commit.record {
767767+ //We deserialize the record into our Rust struct
768768+ let status_at_proto_record = serde_json::from_value::<
769769+ lexicons::xyz::statusphere::status::RecordData,
770770+ >(record.clone())?;
771771+772772+ if let Some(ref _cid) = commit.cid {
773773+ // Although esquema does not have full validation yet,
774774+ // if you get to this point,
775775+ // You know the data structure is the same
776776+777777+ // Store the status
778778+ // TODO
779779+ }
780780+ }
781781+ }
782782+ Operation::Delete => {},
783783+ }
784784+ } else {
785785+ return Err(anyhow!("Message has no commit"));
786786+ }
787787+ Ok(())
788788+}
789789+}
790790+```
791791+792792+Let's create a SQLite table to store these statuses:
793793+794794+```rust
795795+/** ./src/db.rs **/
796796+// Create our statuses table
797797+pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> {
798798+ pool.conn(move |conn| {
799799+ conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
800800+801801+ // status
802802+ conn.execute(
803803+ "CREATE TABLE IF NOT EXISTS status (
804804+ uri TEXT PRIMARY KEY,
805805+ authorDid TEXT NOT NULL,
806806+ status TEXT NOT NULL,
807807+ createdAt INTEGER NOT NULL,
808808+ indexedAt INTEGER NOT NULL
809809+ )",
810810+ [],
811811+ )
812812+ .unwrap();
813813+814814+// ...
815815+```
816816+817817+Now we can write these statuses into our database as they arrive from the firehose:
818818+819819+```rust
820820+/** ./src/ingester.rs **/
821821+// If the write is a valid status update
822822+if let Some(record) = &commit.record {
823823+ let status_at_proto_record = serde_json::from_value::<
824824+ lexicons::xyz::statusphere::status::RecordData,
825825+ >(record.clone())?;
826826+827827+ if let Some(ref _cid) = commit.cid {
828828+ // Although esquema does not have full validation yet,
829829+ // if you get to this point,
830830+ // You know the data structure is the same
831831+ let created = status_at_proto_record.created_at.as_ref();
832832+ let right_now = chrono::Utc::now();
833833+ // We save or update the record in the db
834834+ StatusFromDb {
835835+ uri: record_uri,
836836+ author_did: message.did.clone(),
837837+ status: status_at_proto_record.status.clone(),
838838+ created_at: created.to_utc(),
839839+ indexed_at: right_now,
840840+ handle: None,
841841+ }
842842+ .save_or_update(&self.db_pool)
843843+ .await?;
844844+ }
845845+}
846846+```
847847+848848+You can almost think of information flowing in a loop:
849849+850850+
851851+852852+Applications write to the repo. The write events are then emitted on the firehose where they're caught by the apps and
853853+ingested into their databases.
854854+855855+Why sync from the event log like this? Because there are other apps in the network that will write the records we're
856856+interested in. By subscribing to the event log (via the Jetstream), we ensure that we catch all the data we're interested in —
857857+including data published by other apps!
285833-tailwinds
44-watchexec -w templates -r ~/Applications/tailwindcss --input public/css/base.css --output public/css/style.css -m
859859+## Step 7. Listing the latest statuses
586066-watch actix
77-watchexec -w templates -w src -r cargo run
861861+Now that we have statuses populating our SQLite, we can produce a timeline of status updates by users. We also use
862862+a [DID](https://atproto.com/specs/did)-to-handle resolver so we can show a nice username with the statuses:
863863+```rust
864864+/** ./src/main.rs **/
865865+// Homepage
866866+/// Home
867867+#[get("/")]
868868+async fn home(
869869+ session: Session,
870870+ oauth_client: web::Data<OAuthClientType>,
871871+ db_pool: web::Data<Arc<Pool>>,
872872+ handle_resolver: web::Data<HandleResolver>,
873873+) -> Result<impl Responder> {
874874+ const TITLE: &str = "Home";
875875+ // Fetch data stored in our SQLite
876876+ let mut statuses = StatusFromDb::load_latest_statuses(&db_pool)
877877+ .await
878878+ .unwrap_or_else(|err| {
879879+ log::error!("Error loading statuses: {err}");
880880+ vec![]
881881+ });
882882+883883+ // We resolve the handles to the DID. This is a bit messy atm,
884884+ // and there are hopes to find a cleaner way
885885+ // to handle resolving the DIDs and formating the handles,
886886+ // But it gets the job done for the purpose of this tutorial.
887887+ // PRs are welcomed!
888888+889889+ //Simple way to cut down on resolve calls if we already know the handle for the did
890890+ let mut quick_resolve_map: HashMap<Did, String> = HashMap::new();
891891+ for db_status in &mut statuses {
892892+ let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did");
893893+ //Check to see if we already resolved it to cut down on resolve requests
894894+ match quick_resolve_map.get(&authors_did) {
895895+ None => {}
896896+ Some(found_handle) => {
897897+ db_status.handle = Some(found_handle.clone());
898898+ continue;
899899+ }
900900+ }
901901+ //Attempts to resolve the DID to a handle
902902+ db_status.handle = match handle_resolver.resolve(&authors_did).await {
903903+ Ok(did_doc) => {
904904+ match did_doc.also_known_as {
905905+ None => None,
906906+ Some(also_known_as) => {
907907+ match also_known_as.is_empty() {
908908+ true => None,
909909+ false => {
910910+ //also_known as a list starts the array with the highest priority handle
911911+ let formatted_handle =
912912+ format!("@{}", also_known_as[0]).replace("at://", "");
913913+ quick_resolve_map.insert(authors_did, formatted_handle.clone());
914914+ Some(formatted_handle)
915915+ }
916916+ }
917917+ }
918918+ }
919919+ }
920920+ Err(err) => {
921921+ log::error!("Error resolving did: {err}");
922922+ None
923923+ }
924924+ };
925925+ }
926926+ // ...
927927+```
928928+>[!NOTE]
929929+> We use a newly released handle resolver from atrium.
930930+> Can see
931931+> how it is set up in [./src/main.rs](https://github.com/fatfingers23/rusty_statusphere_example_app/blob/a13ab7eb8fcba901a483468f7fd7c56b2948972d/src/main.rs#L508)
932932+933933+934934+Our HTML can now list these status records:
935935+936936+```html
937937+<!-- ./templates/home.html -->
938938+{% for status in statuses %}
939939+<div class="{% if loop.first %} status-line no-line {% else %} status-line {% endif %} ">
940940+ <div>
941941+ <div class="status">{{status.status}}</div>
942942+ </div>
943943+ <div class="desc">
944944+ <a class="author"
945945+ href="https://bsky.app/profile/{{status.author_did}}">{{status.author_display_name()}}</a>
946946+ {% if status.is_today() %}
947947+ is feeling {{status.status}} today
948948+ {% else %}
949949+ was feeling {{status.status}} on {{status.created_at}}
950950+ {% endif %}
951951+ </div>
952952+</div>
953953+{% endfor %}
954954+`
955955+})}
956956+```
957957+958958+
959959+960960+## Step 8. Optimistic updates
961961+962962+As a final optimization, let's introduce "optimistic updates."
963963+964964+Remember the information flow loop with the repo write and the event log?
965965+966966+
967967+968968+Since we're updating our users' repos locally, we can short-circuit that flow to our own database:
969969+970970+
971971+972972+This is an important optimization to make, because it ensures that the user sees their own changes while using your app.
973973+When the event eventually arrives from the firehose, we just discard it since we already have it saved locally.
974974+975975+To do this, we just update `POST /status` to include an additional write to our SQLite DB:
976976+977977+```rust
978978+/** ./src/main.rs **/
979979+/// Creates a new status
980980+#[post("/status")]
981981+async fn status(
982982+ request: HttpRequest,
983983+ session: Session,
984984+ oauth_client: web::Data<OAuthClientType>,
985985+ db_pool: web::Data<Arc<Pool>>,
986986+ form: web::Form<StatusForm>,
987987+) -> HttpResponse {
988988+ //...
989989+ let create_result = agent
990990+ .api
991991+ .com
992992+ .atproto
993993+ .repo
994994+ .create_record(
995995+ atrium_api::com::atproto::repo::create_record::InputData {
996996+ collection: Status::NSID.parse().unwrap(),
997997+ repo: did.into(),
998998+ rkey: None,
999999+ record: status.into(),
10001000+ swap_commit: None,
10011001+ validate: None,
10021002+ }
10031003+ .into(),
10041004+ )
10051005+ .await;
10061006+10071007+ match create_result {
10081008+ Ok(record) => {
10091009+ let status = StatusFromDb::new(
10101010+ record.uri.clone(),
10111011+ did_string,
10121012+ form.status.clone(),
10131013+ );
10141014+10151015+ let _ = status.save(db_pool).await;
10161016+ Redirect::to("/")
10171017+ .see_other()
10181018+ .respond_to(&request)
10191019+ .map_into_boxed_body()
10201020+ }
10211021+ Err(err) => {
10221022+ log::error!("Error creating status: {err}");
10231023+ let error_html = ErrorTemplate {
10241024+ title: "Error",
10251025+ error: "Was an error creating the status, please check the logs.",
10261026+ }
10271027+ .render()
10281028+ .expect("template should be valid");
10291029+ HttpResponse::Ok().body(error_html)
10301030+ }
10311031+ }
10321032+ //...
10331033+}
10341034+```
10351035+10361036+You'll notice this code looks almost exactly like what we're doing in `ingester.rs`.
10371037+10381038+## Thinking in AT Proto
10391039+10401040+In this tutorial we've covered the key steps to building an atproto app. Data is published in its canonical form on
10411041+users' `at://` repos and then aggregated into apps' databases to produce views of the network.
10421042+10431043+When building your app, think in these four key steps:
10441044+10451045+- Design the [Lexicon](#) schemas for the records you'll publish into the Atmosphere.
10461046+- Create a database for aggregating the records into useful views.
10471047+- Build your application to write the records on your users' repos.
10481048+- Listen to the firehose to aggregate data across the network.
10491049+10501050+Remember this flow of information throughout:
10511051+10521052+
10531053+10541054+This is how every app in the Atmosphere works, including the [Bluesky social app](https://bsky.app).
10551055+10561056+## Next steps
10571057+10581058+If you want to practice what you've learned, here are some additional challenges you could try:
10591059+10601060+- Sync the profile records of all users so that you can show their display names instead of their handles.
10611061+- Count the number of each status used and display the total counts.
10621062+- Fetch the authed user's `app.bsky.graph.follow` follows and show statuses from them.
10631063+- Create a different kind of schema, like a way to post links to websites and rate them 1 through 4 stars.
10641064+10651065+[Ready to learn more? Specs, guides, and SDKs can be found here.](https://atproto.com/)
10661066+10671067+>[!NOTE]
10681068+> Thank you for checking out my version of the Statusphere example project!
10691069+> There are parts of this I feel can be improved on and made more efficient,
10701070+> but I think it does a good job for providing you with a starting point to start building Rust applications in the Atmosphere.
10711071+> See something you think could be done better? Then please submit a PR!
10721072+> [@baileytownsend.dev](https://bsky.app/profile/baileytownsend.dev)
images/cover.png
This is a binary file and will not be displayed.
images/emojis.png
This is a binary file and will not be displayed.
+2
justfile
···11+watch:
22+ watchexec -w src -w templates -r cargo run
···11+use actix_web::web::Data;
22+use async_sqlite::{
33+ Pool, rusqlite,
44+ rusqlite::{Error, Row},
55+};
66+use atrium_api::types::string::Did;
77+use chrono::{DateTime, Datelike, Utc};
88+use rusqlite::types::Type;
99+use serde::{Deserialize, Serialize};
1010+use std::{fmt::Debug, sync::Arc};
1111+1212+/// Creates the tables in the db.
1313+pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> {
1414+ pool.conn(move |conn| {
1515+ conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
1616+1717+ // status
1818+ conn.execute(
1919+ "CREATE TABLE IF NOT EXISTS status (
2020+ uri TEXT PRIMARY KEY,
2121+ authorDid TEXT NOT NULL,
2222+ status TEXT NOT NULL,
2323+ createdAt INTEGER NOT NULL,
2424+ indexedAt INTEGER NOT NULL
2525+ )",
2626+ [],
2727+ )
2828+ .unwrap();
2929+3030+ // auth_session
3131+ conn.execute(
3232+ "CREATE TABLE IF NOT EXISTS auth_session (
3333+ key TEXT PRIMARY KEY,
3434+ session TEXT NOT NULL
3535+ )",
3636+ [],
3737+ )
3838+ .unwrap();
3939+4040+ // auth_state
4141+ conn.execute(
4242+ "CREATE TABLE IF NOT EXISTS auth_state (
4343+ key TEXT PRIMARY KEY,
4444+ state TEXT NOT NULL
4545+ )",
4646+ [],
4747+ )
4848+ .unwrap();
4949+ Ok(())
5050+ })
5151+ .await?;
5252+ Ok(())
5353+}
5454+5555+///Status table datatype
5656+#[derive(Debug, Clone, Deserialize, Serialize)]
5757+pub struct StatusFromDb {
5858+ pub uri: String,
5959+ pub author_did: String,
6060+ pub status: String,
6161+ pub created_at: DateTime<Utc>,
6262+ pub indexed_at: DateTime<Utc>,
6363+ pub handle: Option<String>,
6464+}
6565+6666+//Status methods
6767+impl StatusFromDb {
6868+ /// Creates a new [StatusFromDb]
6969+ pub fn new(uri: String, author_did: String, status: String) -> Self {
7070+ let now = chrono::Utc::now();
7171+ Self {
7272+ uri,
7373+ author_did,
7474+ status,
7575+ created_at: now,
7676+ indexed_at: now,
7777+ handle: None,
7878+ }
7979+ }
8080+8181+ /// Helper to map from [Row] to [StatusDb]
8282+ fn map_from_row(row: &Row) -> Result<Self, rusqlite::Error> {
8383+ Ok(Self {
8484+ uri: row.get(0)?,
8585+ author_did: row.get(1)?,
8686+ status: row.get(2)?,
8787+ //DateTimes are stored as INTEGERS then parsed into a DateTime<UTC>
8888+ created_at: {
8989+ let timestamp: i64 = row.get(3)?;
9090+ DateTime::from_timestamp(timestamp, 0).ok_or_else(|| {
9191+ Error::InvalidColumnType(3, "Invalid timestamp".parse().unwrap(), Type::Text)
9292+ })?
9393+ },
9494+ //DateTimes are stored as INTEGERS then parsed into a DateTime<UTC>
9595+ indexed_at: {
9696+ let timestamp: i64 = row.get(4)?;
9797+ DateTime::from_timestamp(timestamp, 0).ok_or_else(|| {
9898+ Error::InvalidColumnType(4, "Invalid timestamp".parse().unwrap(), Type::Text)
9999+ })?
100100+ },
101101+ handle: None,
102102+ })
103103+ }
104104+105105+ /// Helper for the UI to see if indexed_at date is today or not
106106+ pub fn is_today(&self) -> bool {
107107+ let now = Utc::now();
108108+109109+ self.indexed_at.day() == now.day()
110110+ && self.indexed_at.month() == now.month()
111111+ && self.indexed_at.year() == now.year()
112112+ }
113113+114114+ /// Saves the [StatusDb]
115115+ pub async fn save(&self, pool: Data<Arc<Pool>>) -> Result<(), async_sqlite::Error> {
116116+ let cloned_self = self.clone();
117117+ pool.conn(move |conn| {
118118+ Ok(conn.execute(
119119+ "INSERT INTO status (uri, authorDid, status, createdAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5)",
120120+ [
121121+ &cloned_self.uri,
122122+ &cloned_self.author_did,
123123+ &cloned_self.status,
124124+ &cloned_self.created_at.timestamp().to_string(),
125125+ &cloned_self.indexed_at.timestamp().to_string(),
126126+ ],
127127+ )?)
128128+ })
129129+ .await?;
130130+ Ok(())
131131+ }
132132+133133+ /// Saves or updates a status by its did(uri)
134134+ pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> {
135135+ let cloned_self = self.clone();
136136+ pool.conn(move |conn| {
137137+ //We check to see if the session already exists, if so we need to update not insert
138138+ let mut stmt = conn.prepare("SELECT COUNT(*) FROM status WHERE uri = ?1")?;
139139+ let count: i64 = stmt.query_row([&cloned_self.uri], |row| row.get(0))?;
140140+ match count > 0 {
141141+ true => {
142142+ let mut update_stmt =
143143+ conn.prepare("UPDATE status SET status = ?2, indexedAt = ? WHERE uri = ?1")?;
144144+ update_stmt.execute([&cloned_self.uri, &cloned_self.status, &cloned_self.indexed_at.timestamp().to_string()])?;
145145+ Ok(())
146146+ }
147147+ false => {
148148+ conn.execute(
149149+ "INSERT INTO status (uri, authorDid, status, createdAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5)",
150150+ [
151151+ &cloned_self.uri,
152152+ &cloned_self.author_did,
153153+ &cloned_self.status,
154154+ &cloned_self.created_at.timestamp().to_string(),
155155+ &cloned_self.indexed_at.timestamp().to_string(),
156156+ ],
157157+ )?;
158158+ Ok(())
159159+ }
160160+ }
161161+ })
162162+ .await?;
163163+ Ok(())
164164+ }
165165+ pub async fn delete_by_uri(pool: &Pool, uri: String) -> Result<(), async_sqlite::Error> {
166166+ pool.conn(move |conn| {
167167+ let mut stmt = conn.prepare("DELETE FROM status WHERE uri = ?1")?;
168168+ stmt.execute([&uri])
169169+ })
170170+ .await?;
171171+ Ok(())
172172+ }
173173+174174+ /// Loads the last 10 statuses we have saved
175175+ pub async fn load_latest_statuses(
176176+ pool: &Data<Arc<Pool>>,
177177+ ) -> Result<Vec<Self>, async_sqlite::Error> {
178178+ Ok(pool
179179+ .conn(move |conn| {
180180+ let mut stmt = conn.prepare("SELECT * FROM status ORDER BY indexedAt DESC")?;
181181+ let status_iter = stmt
182182+ .query_map([], |row| Ok(Self::map_from_row(row).unwrap()))
183183+ .unwrap();
184184+185185+ let mut statuses = Vec::new();
186186+ for status in status_iter {
187187+ statuses.push(status?);
188188+ }
189189+ Ok(statuses)
190190+ })
191191+ .await?)
192192+ }
193193+194194+ /// Loads the logged-in users current status
195195+ pub async fn my_status(
196196+ pool: &Data<Arc<Pool>>,
197197+ did: &str,
198198+ ) -> Result<Option<Self>, async_sqlite::Error> {
199199+ let did = did.to_string();
200200+ pool.conn(move |conn| {
201201+ let mut stmt = conn.prepare(
202202+ "SELECT * FROM status WHERE authorDid = ?1 ORDER BY createdAt DESC LIMIT 1",
203203+ )?;
204204+ stmt.query_row([did.as_str()], |row| Self::map_from_row(row))
205205+ .map(Some)
206206+ .or_else(|err| {
207207+ if err == rusqlite::Error::QueryReturnedNoRows {
208208+ Ok(None)
209209+ } else {
210210+ Err(err)
211211+ }
212212+ })
213213+ })
214214+ .await
215215+ }
216216+217217+ /// ui helper to show a handle or did if the handle cannot be found
218218+ pub fn author_display_name(&self) -> String {
219219+ match self.handle.as_ref() {
220220+ Some(handle) => handle.to_string(),
221221+ None => self.author_did.to_string(),
222222+ }
223223+ }
224224+}
225225+226226+/// AuthSession table data type
227227+#[derive(Debug, Clone, Deserialize, Serialize)]
228228+pub struct AuthSession {
229229+ pub key: String,
230230+ pub session: String,
231231+}
232232+233233+impl AuthSession {
234234+ /// Creates a new [AuthSession]
235235+ pub fn new<V>(key: String, session: V) -> Self
236236+ where
237237+ V: Serialize,
238238+ {
239239+ let session = serde_json::to_string(&session).unwrap();
240240+ Self {
241241+ key: key.to_string(),
242242+ session,
243243+ }
244244+ }
245245+246246+ /// Helper to map from [Row] to [AuthSession]
247247+ fn map_from_row(row: &Row) -> Result<Self, Error> {
248248+ let key: String = row.get(0)?;
249249+ let session: String = row.get(1)?;
250250+ Ok(Self { key, session })
251251+ }
252252+253253+ /// Gets a session by the users did(key)
254254+ pub async fn get_by_did(pool: &Pool, did: String) -> Result<Option<Self>, async_sqlite::Error> {
255255+ let did = Did::new(did).unwrap();
256256+ pool.conn(move |conn| {
257257+ let mut stmt = conn.prepare("SELECT * FROM auth_session WHERE key = ?1")?;
258258+ stmt.query_row([did.as_str()], |row| Self::map_from_row(row))
259259+ .map(Some)
260260+ .or_else(|err| {
261261+ if err == Error::QueryReturnedNoRows {
262262+ Ok(None)
263263+ } else {
264264+ Err(err)
265265+ }
266266+ })
267267+ })
268268+ .await
269269+ }
270270+271271+ /// Saves or updates the session by its did(key)
272272+ pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> {
273273+ let cloned_self = self.clone();
274274+ pool.conn(move |conn| {
275275+ //We check to see if the session already exists, if so we need to update not insert
276276+ let mut stmt = conn.prepare("SELECT COUNT(*) FROM auth_session WHERE key = ?1")?;
277277+ let count: i64 = stmt.query_row([&cloned_self.key], |row| row.get(0))?;
278278+ match count > 0 {
279279+ true => {
280280+ let mut update_stmt =
281281+ conn.prepare("UPDATE auth_session SET session = ?2 WHERE key = ?1")?;
282282+ update_stmt.execute([&cloned_self.key, &cloned_self.session])?;
283283+ Ok(())
284284+ }
285285+ false => {
286286+ conn.execute(
287287+ "INSERT INTO auth_session (key, session) VALUES (?1, ?2)",
288288+ [&cloned_self.key, &cloned_self.session],
289289+ )?;
290290+ Ok(())
291291+ }
292292+ }
293293+ })
294294+ .await?;
295295+ Ok(())
296296+ }
297297+298298+ /// Deletes the session by did
299299+ pub async fn delete_by_did(pool: &Pool, did: String) -> Result<(), async_sqlite::Error> {
300300+ pool.conn(move |conn| {
301301+ let mut stmt = conn.prepare("DELETE FROM auth_session WHERE key = ?1")?;
302302+ stmt.execute([&did])
303303+ })
304304+ .await?;
305305+ Ok(())
306306+ }
307307+308308+ /// Deletes all the sessions
309309+ pub async fn delete_all(pool: &Pool) -> Result<(), async_sqlite::Error> {
310310+ pool.conn(move |conn| {
311311+ let mut stmt = conn.prepare("DELETE FROM auth_session")?;
312312+ stmt.execute([])
313313+ })
314314+ .await?;
315315+ Ok(())
316316+ }
317317+}
318318+319319+/// AuthState table datatype
320320+#[derive(Debug, Clone, Deserialize, Serialize)]
321321+pub struct AuthState {
322322+ pub key: String,
323323+ pub state: String,
324324+}
325325+326326+impl AuthState {
327327+ /// Creates a new [AuthState]
328328+ pub fn new<V>(key: String, state: V) -> Self
329329+ where
330330+ V: Serialize,
331331+ {
332332+ let state = serde_json::to_string(&state).unwrap();
333333+ Self {
334334+ key: key.to_string(),
335335+ state,
336336+ }
337337+ }
338338+339339+ /// Helper to map from [Row] to [AuthState]
340340+ fn map_from_row(row: &Row) -> Result<Self, Error> {
341341+ let key: String = row.get(0)?;
342342+ let state: String = row.get(1)?;
343343+ Ok(Self { key, state })
344344+ }
345345+346346+ /// Gets a state by the users key
347347+ pub async fn get_by_key(pool: &Pool, key: String) -> Result<Option<Self>, async_sqlite::Error> {
348348+ pool.conn(move |conn| {
349349+ let mut stmt = conn.prepare("SELECT * FROM auth_state WHERE key = ?1")?;
350350+ stmt.query_row([key.as_str()], |row| Self::map_from_row(row))
351351+ .map(Some)
352352+ .or_else(|err| {
353353+ if err == Error::QueryReturnedNoRows {
354354+ Ok(None)
355355+ } else {
356356+ Err(err)
357357+ }
358358+ })
359359+ })
360360+ .await
361361+ }
362362+363363+ /// Saves or updates the state by its key
364364+ pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> {
365365+ let cloned_self = self.clone();
366366+ pool.conn(move |conn| {
367367+ //We check to see if the state already exists, if so we need to update
368368+ let mut stmt = conn.prepare("SELECT COUNT(*) FROM auth_state WHERE key = ?1")?;
369369+ let count: i64 = stmt.query_row([&cloned_self.key], |row| row.get(0))?;
370370+ match count > 0 {
371371+ true => {
372372+ let mut update_stmt =
373373+ conn.prepare("UPDATE auth_state SET state = ?2 WHERE key = ?1")?;
374374+ update_stmt.execute([&cloned_self.key, &cloned_self.state])?;
375375+ Ok(())
376376+ }
377377+ false => {
378378+ conn.execute(
379379+ "INSERT INTO auth_state (key, state) VALUES (?1, ?2)",
380380+ [&cloned_self.key, &cloned_self.state],
381381+ )?;
382382+ Ok(())
383383+ }
384384+ }
385385+ })
386386+ .await?;
387387+ Ok(())
388388+ }
389389+390390+ pub async fn delete_by_key(pool: &Pool, key: String) -> Result<(), async_sqlite::Error> {
391391+ pool.conn(move |conn| {
392392+ let mut stmt = conn.prepare("DELETE FROM auth_state WHERE key = ?1")?;
393393+ stmt.execute([&key])
394394+ })
395395+ .await?;
396396+ Ok(())
397397+ }
398398+399399+ pub async fn delete_all(pool: &Pool) -> Result<(), async_sqlite::Error> {
400400+ pool.conn(move |conn| {
401401+ let mut stmt = conn.prepare("DELETE FROM auth_state")?;
402402+ stmt.execute([])
403403+ })
404404+ .await?;
405405+ Ok(())
406406+ }
407407+}
+114
src/ingester.rs
···11+use crate::db::StatusFromDb;
22+use crate::lexicons;
33+use crate::lexicons::xyz::statusphere::Status;
44+use anyhow::anyhow;
55+use async_sqlite::Pool;
66+use async_trait::async_trait;
77+use atrium_api::types::Collection;
88+use log::error;
99+use rocketman::{
1010+ connection::JetstreamConnection,
1111+ handler,
1212+ ingestion::LexiconIngestor,
1313+ options::JetstreamOptions,
1414+ types::event::{Event, Operation},
1515+};
1616+use serde_json::Value;
1717+use std::{
1818+ collections::HashMap,
1919+ sync::{Arc, Mutex},
2020+};
2121+2222+#[async_trait]
2323+impl LexiconIngestor for StatusSphereIngester {
2424+ async fn ingest(&self, message: Event<Value>) -> anyhow::Result<()> {
2525+ if let Some(commit) = &message.commit {
2626+ //We manually construct the uri since Jetstream does not provide it
2727+ //at://{users did}/{collection: xyz.statusphere.status}{records key}
2828+ let record_uri = format!("at://{}/{}/{}", message.did, commit.collection, commit.rkey);
2929+ match commit.operation {
3030+ Operation::Create | Operation::Update => {
3131+ if let Some(record) = &commit.record {
3232+ let status_at_proto_record = serde_json::from_value::<
3333+ lexicons::xyz::statusphere::status::RecordData,
3434+ >(record.clone())?;
3535+3636+ if let Some(ref _cid) = commit.cid {
3737+ // Although esquema does not have full validation yet,
3838+ // if you get to this point,
3939+ // You know the data structure is the same
4040+ let created = status_at_proto_record.created_at.as_ref();
4141+ let right_now = chrono::Utc::now();
4242+ // We save or update the record in the db
4343+ StatusFromDb {
4444+ uri: record_uri,
4545+ author_did: message.did.clone(),
4646+ status: status_at_proto_record.status.clone(),
4747+ created_at: created.to_utc(),
4848+ indexed_at: right_now,
4949+ handle: None,
5050+ }
5151+ .save_or_update(&self.db_pool)
5252+ .await?;
5353+ }
5454+ }
5555+ }
5656+ Operation::Delete => StatusFromDb::delete_by_uri(&self.db_pool, record_uri).await?,
5757+ }
5858+ } else {
5959+ return Err(anyhow!("Message has no commit"));
6060+ }
6161+ Ok(())
6262+ }
6363+}
6464+pub struct StatusSphereIngester {
6565+ db_pool: Arc<Pool>,
6666+}
6767+6868+pub async fn start_ingester(db_pool: Arc<Pool>) {
6969+ // init the builder
7070+ let opts = JetstreamOptions::builder()
7171+ // your EXACT nsids
7272+ // Which in this case is xyz.statusphere.status
7373+ .wanted_collections(vec![Status::NSID.parse().unwrap()])
7474+ .build();
7575+ // create the jetstream connector
7676+ let jetstream = JetstreamConnection::new(opts);
7777+7878+ // create your ingesters
7979+ // Which in this case is xyz.statusphere.status
8080+ let mut ingesters: HashMap<String, Box<dyn LexiconIngestor + Send + Sync>> = HashMap::new();
8181+ ingesters.insert(
8282+ // your EXACT nsid
8383+ Status::NSID.parse().unwrap(),
8484+ Box::new(StatusSphereIngester { db_pool }),
8585+ );
8686+8787+ // tracks the last message we've processed
8888+ let cursor: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None));
8989+9090+ // get channels
9191+ let msg_rx = jetstream.get_msg_rx();
9292+ let reconnect_tx = jetstream.get_reconnect_tx();
9393+9494+ // spawn a task to process messages from the queue.
9595+ // this is a simple implementation, you can use a more complex one based on needs.
9696+ let c_cursor = cursor.clone();
9797+ tokio::spawn(async move {
9898+ while let Ok(message) = msg_rx.recv_async().await {
9999+ if let Err(e) =
100100+ handler::handle_message(message, &ingesters, reconnect_tx.clone(), c_cursor.clone())
101101+ .await
102102+ {
103103+ error!("Error processing message: {}", e);
104104+ };
105105+ }
106106+ });
107107+108108+ // connect to jetstream
109109+ // retries internally, but may fail if there is an extreme error.
110110+ if let Err(e) = jetstream.connect(cursor.clone()).await {
111111+ error!("Failed to connect to Jetstream: {}", e);
112112+ std::process::exit(1);
113113+ }
114114+}
+3
src/lexicons/mod.rs
···11+// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
22+pub mod record;
33+pub mod xyz;
+23
src/lexicons/record.rs
···11+// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
22+//!A collection of known record types.
33+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
44+#[serde(tag = "$type")]
55+pub enum KnownRecord {
66+ #[serde(rename = "xyz.statusphere.status")]
77+ LexiconsXyzStatusphereStatus(Box<crate::lexicons::xyz::statusphere::status::Record>),
88+}
99+impl From<crate::lexicons::xyz::statusphere::status::Record> for KnownRecord {
1010+ fn from(record: crate::lexicons::xyz::statusphere::status::Record) -> Self {
1111+ KnownRecord::LexiconsXyzStatusphereStatus(Box::new(record))
1212+ }
1313+}
1414+impl From<crate::lexicons::xyz::statusphere::status::RecordData> for KnownRecord {
1515+ fn from(record_data: crate::lexicons::xyz::statusphere::status::RecordData) -> Self {
1616+ KnownRecord::LexiconsXyzStatusphereStatus(Box::new(record_data.into()))
1717+ }
1818+}
1919+impl Into<atrium_api::types::Unknown> for KnownRecord {
2020+ fn into(self) -> atrium_api::types::Unknown {
2121+ atrium_api::types::TryIntoUnknown::try_into_unknown(&self).unwrap()
2222+ }
2323+}
+3
src/lexicons/xyz.rs
···11+// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
22+//!Definitions for the `xyz` namespace.
33+pub mod statusphere;
+9
src/lexicons/xyz/statusphere.rs
···11+// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
22+//!Definitions for the `xyz.statusphere` namespace.
33+pub mod status;
44+#[derive(Debug)]
55+pub struct Status;
66+impl atrium_api::types::Collection for Status {
77+ const NSID: &'static str = "xyz.statusphere.status";
88+ type Record = status::Record;
99+}
+15
src/lexicons/xyz/statusphere/status.rs
···11+// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
22+//!Definitions for the `xyz.statusphere.status` namespace.
33+use atrium_api::types::TryFromUnknown;
44+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
55+#[serde(rename_all = "camelCase")]
66+pub struct RecordData {
77+ pub created_at: atrium_api::types::string::Datetime,
88+ pub status: String,
99+}
1010+pub type Record = atrium_api::types::Object<RecordData>;
1111+impl From<atrium_api::types::Unknown> for RecordData {
1212+ fn from(value: atrium_api::types::Unknown) -> Self {
1313+ Self::try_from_unknown(value).unwrap()
1414+ }
1515+}
+562-7
src/main.rs
···11+use crate::{
22+ db::{StatusFromDb, create_tables_in_database},
33+ ingester::start_ingester,
44+ lexicons::record::KnownRecord,
55+ lexicons::xyz::statusphere::Status,
66+ storage::{SqliteSessionStore, SqliteStateStore},
77+ templates::{HomeTemplate, LoginTemplate},
88+};
19use actix_files::Files;
22-use actix_web::{App, HttpServer, Responder, Result, middleware, web};
33-use controllers::FeedController::feed_controller;
44-use std::collections::HashMap;
1010+use actix_session::{
1111+ Session, SessionMiddleware, config::PersistentSession, storage::CookieSessionStore,
1212+};
1313+use actix_web::{
1414+ App, HttpRequest, HttpResponse, HttpServer, Responder, Result,
1515+ cookie::{self, Key},
1616+ get, middleware, post,
1717+ web::{self, Redirect},
1818+};
1919+use askama::Template;
2020+use async_sqlite::{Pool, PoolBuilder};
2121+use atrium_api::{
2222+ agent::Agent,
2323+ types::Collection,
2424+ types::string::{Datetime, Did},
2525+};
2626+use atrium_common::resolver::Resolver;
2727+use atrium_identity::{
2828+ did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL},
2929+ handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
3030+};
3131+use atrium_oauth::{
3232+ AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient,
3333+ KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope,
3434+};
3535+use dotenv::dotenv;
3636+use resolver::HickoryDnsTxtResolver;
3737+use serde::{Deserialize, Serialize};
3838+use std::{
3939+ collections::HashMap,
4040+ io::{Error, ErrorKind},
4141+ sync::Arc,
4242+};
4343+use templates::{ErrorTemplate, Profile};
4444+4545+extern crate dotenv;
4646+4747+mod db;
4848+mod ingester;
4949+mod lexicons;
5050+mod resolver;
5151+mod storage;
5252+mod templates;
5353+5454+/// OAuthClientType to make it easier to access the OAuthClient in web requests
5555+type OAuthClientType = Arc<
5656+ OAuthClient<
5757+ SqliteStateStore,
5858+ SqliteSessionStore,
5959+ CommonDidResolver<DefaultHttpClient>,
6060+ AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>,
6161+ >,
6262+>;
6363+6464+/// HandleResolver to make it easier to access the OAuthClient in web requests
6565+type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>;
6666+6767+/// All the available emoji status options
6868+const STATUS_OPTIONS: [&str; 29] = [
6969+ "👍",
7070+ "👎",
7171+ "💙",
7272+ "🥹",
7373+ "😧",
7474+ "😤",
7575+ "🙃",
7676+ "😉",
7777+ "😎",
7878+ "🤓",
7979+ "🤨",
8080+ "🥳",
8181+ "😭",
8282+ "😤",
8383+ "🤯",
8484+ "🫡",
8585+ "💀",
8686+ "✊",
8787+ "🤘",
8888+ "👀",
8989+ "🧠",
9090+ "👩💻",
9191+ "🧑💻",
9292+ "🥷",
9393+ "🧌",
9494+ "🦋",
9595+ "🚀",
9696+ "🥔",
9797+ "🦀",
9898+];
9999+100100+/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L71
101101+/// OAuth callback endpoint to complete session creation
102102+#[get("/oauth/callback")]
103103+async fn oauth_callback(
104104+ request: HttpRequest,
105105+ params: web::Query<CallbackParams>,
106106+ oauth_client: web::Data<OAuthClientType>,
107107+ session: Session,
108108+) -> HttpResponse {
109109+ //Processes the call back and parses out a session if found and valid
110110+ match oauth_client.callback(params.into_inner()).await {
111111+ Ok((bsky_session, _)) => {
112112+ let agent = Agent::new(bsky_session);
113113+ match agent.did().await {
114114+ Some(did) => {
115115+ session.insert("did", did).unwrap();
116116+ Redirect::to("/")
117117+ .see_other()
118118+ .respond_to(&request)
119119+ .map_into_boxed_body()
120120+ }
121121+ None => {
122122+ let html = ErrorTemplate {
123123+ title: "Error",
124124+ error: "The OAuth agent did not return a DID. My try relogging in.",
125125+ };
126126+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
127127+ }
128128+ }
129129+ }
130130+ Err(err) => {
131131+ log::error!("Error: {err}");
132132+ let html = ErrorTemplate {
133133+ title: "Error",
134134+ error: "OAuth error, check the logs",
135135+ };
136136+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
137137+ }
138138+ }
139139+}
140140+141141+/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L93
142142+/// Takes you to the login page
143143+#[get("/login")]
144144+async fn login() -> Result<impl Responder> {
145145+ let html = LoginTemplate {
146146+ title: "Log in",
147147+ error: None,
148148+ };
149149+ Ok(web::Html::new(
150150+ html.render().expect("template should be valid"),
151151+ ))
152152+}
153153+154154+/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L93
155155+/// Logs you out by destroying your cookie on the server and web browser
156156+#[get("/logout")]
157157+async fn logout(request: HttpRequest, session: Session) -> HttpResponse {
158158+ session.purge();
159159+ Redirect::to("/")
160160+ .see_other()
161161+ .respond_to(&request)
162162+ .map_into_boxed_body()
163163+}
164164+165165+/// The post body for logging in
166166+#[derive(Serialize, Deserialize, Clone)]
167167+struct LoginForm {
168168+ handle: String,
169169+}
170170+171171+/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L101
172172+/// Login endpoint
173173+#[post("/login")]
174174+async fn login_post(
175175+ request: HttpRequest,
176176+ params: web::Form<LoginForm>,
177177+ oauth_client: web::Data<OAuthClientType>,
178178+) -> HttpResponse {
179179+ // This will act the same as the js method isValidHandle to make sure it is valid
180180+ match atrium_api::types::string::Handle::new(params.handle.clone()) {
181181+ Ok(handle) => {
182182+ //Creates the oauth url to redirect to for the user to log in with their credentials
183183+ let oauth_url = oauth_client
184184+ .authorize(
185185+ &handle,
186186+ AuthorizeOptions {
187187+ scopes: vec![
188188+ Scope::Known(KnownScope::Atproto),
189189+ Scope::Known(KnownScope::TransitionGeneric),
190190+ ],
191191+ ..Default::default()
192192+ },
193193+ )
194194+ .await;
195195+ match oauth_url {
196196+ Ok(url) => Redirect::to(url)
197197+ .see_other()
198198+ .respond_to(&request)
199199+ .map_into_boxed_body(),
200200+ Err(err) => {
201201+ log::error!("Error: {err}");
202202+ let html = LoginTemplate {
203203+ title: "Log in",
204204+ error: Some("OAuth error"),
205205+ };
206206+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
207207+ }
208208+ }
209209+ }
210210+ Err(err) => {
211211+ let html: LoginTemplate<'_> = LoginTemplate {
212212+ title: "Log in",
213213+ error: Some(err),
214214+ };
215215+ HttpResponse::Ok().body(html.render().expect("template should be valid"))
216216+ }
217217+ }
218218+}
219219+220220+/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L146
221221+/// Home
222222+#[get("/")]
223223+async fn home(
224224+ session: Session,
225225+ oauth_client: web::Data<OAuthClientType>,
226226+ db_pool: web::Data<Arc<Pool>>,
227227+ handle_resolver: web::Data<HandleResolver>,
228228+) -> Result<impl Responder> {
229229+ const TITLE: &str = "Home";
230230+ //Loads the last 10 statuses saved in the DB
231231+ let mut statuses = StatusFromDb::load_latest_statuses(&db_pool)
232232+ .await
233233+ .unwrap_or_else(|err| {
234234+ log::error!("Error loading statuses: {err}");
235235+ vec![]
236236+ });
237237+238238+ //Simple way to cut down on resolve calls if we already know the handle for the did
239239+ let mut quick_resolve_map: HashMap<Did, String> = HashMap::new();
240240+ // We resolve the handles to the DID. This is a bit messy atm,
241241+ // and there are hopes to find a cleaner way
242242+ // to handle resolving the DIDs and formating the handles,
243243+ // But it gets the job done for the purpose of this tutorial.
244244+ // PRs are welcomed!
245245+ for db_status in &mut statuses {
246246+ let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did");
247247+ //Check to see if we already resolved it to cut down on resolve requests
248248+ match quick_resolve_map.get(&authors_did) {
249249+ None => {}
250250+ Some(found_handle) => {
251251+ db_status.handle = Some(found_handle.clone());
252252+ continue;
253253+ }
254254+ }
255255+ //Attempts to resolve the DID to a handle
256256+ db_status.handle = match handle_resolver.resolve(&authors_did).await {
257257+ Ok(did_doc) => {
258258+ match did_doc.also_known_as {
259259+ None => None,
260260+ Some(also_known_as) => {
261261+ match also_known_as.is_empty() {
262262+ true => None,
263263+ false => {
264264+ //also_known as a list starts the array with the highest priority handle
265265+ let formatted_handle =
266266+ format!("@{}", also_known_as[0]).replace("at://", "");
267267+ quick_resolve_map.insert(authors_did, formatted_handle.clone());
268268+ Some(formatted_handle)
269269+ }
270270+ }
271271+ }
272272+ }
273273+ }
274274+ Err(err) => {
275275+ log::error!("Error resolving did: {err}");
276276+ None
277277+ }
278278+ };
279279+ }
280280+281281+ // If the user is signed in, get an agent which communicates with their server
282282+ match session.get::<String>("did").unwrap_or(None) {
283283+ Some(did) => {
284284+ let did = Did::new(did).expect("failed to parse did");
285285+ //Grabs the users last status to highlight it in the ui
286286+ let my_status = StatusFromDb::my_status(&db_pool, &did)
287287+ .await
288288+ .unwrap_or_else(|err| {
289289+ log::error!("Error loading my status: {err}");
290290+ None
291291+ });
292292+293293+ // gets the users session from the session store to resume
294294+ match oauth_client.restore(&did).await {
295295+ Ok(session) => {
296296+ //Creates an agent to make authenticated requests
297297+ let agent = Agent::new(session);
298298+299299+ // Fetch additional information about the logged-in user
300300+ let profile = agent
301301+ .api
302302+ .app
303303+ .bsky
304304+ .actor
305305+ .get_profile(
306306+ atrium_api::app::bsky::actor::get_profile::ParametersData {
307307+ actor: atrium_api::types::string::AtIdentifier::Did(did),
308308+ }
309309+ .into(),
310310+ )
311311+ .await;
312312+313313+ let html = HomeTemplate {
314314+ title: TITLE,
315315+ status_options: &STATUS_OPTIONS,
316316+ profile: match profile {
317317+ Ok(profile) => {
318318+ let profile_data = Profile {
319319+ did: profile.did.to_string(),
320320+ display_name: profile.display_name.clone(),
321321+ };
322322+ Some(profile_data)
323323+ }
324324+ Err(err) => {
325325+ log::error!("Error accessing profile: {err}");
326326+ None
327327+ }
328328+ },
329329+ statuses,
330330+ my_status: my_status.as_ref().map(|s| s.status.clone()),
331331+ }
332332+ .render()
333333+ .expect("template should be valid");
533466-pub mod controllers;
335335+ Ok(web::Html::new(html))
336336+ }
337337+ Err(err) => {
338338+ // Destroys the system or you're in a loop
339339+ session.purge();
340340+ log::error!("Error restoring session: {err}");
341341+ let error_html = ErrorTemplate {
342342+ title: "Error",
343343+ error: "Was an error resuming the session, please check the logs.",
344344+ }
345345+ .render()
346346+ .expect("template should be valid");
347347+ Ok(web::Html::new(error_html))
348348+ }
349349+ }
350350+ }
351351+352352+ None => {
353353+ let html = HomeTemplate {
354354+ title: TITLE,
355355+ status_options: &STATUS_OPTIONS,
356356+ profile: None,
357357+ statuses,
358358+ my_status: None,
359359+ }
360360+ .render()
361361+ .expect("template should be valid");
362362+363363+ Ok(web::Html::new(html))
364364+ }
365365+ }
366366+}
367367+368368+/// The post body for changing your status
369369+#[derive(Serialize, Deserialize, Clone)]
370370+struct StatusForm {
371371+ status: String,
372372+}
373373+374374+/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L208
375375+/// Creates a new status
376376+#[post("/status")]
377377+async fn status(
378378+ request: HttpRequest,
379379+ session: Session,
380380+ oauth_client: web::Data<OAuthClientType>,
381381+ db_pool: web::Data<Arc<Pool>>,
382382+ form: web::Form<StatusForm>,
383383+) -> HttpResponse {
384384+ // Check if the user is logged in
385385+ match session.get::<String>("did").unwrap_or(None) {
386386+ Some(did_string) => {
387387+ let did = Did::new(did_string.clone()).expect("failed to parse did");
388388+ // gets the users session from the session store to resume
389389+ match oauth_client.restore(&did).await {
390390+ Ok(session) => {
391391+ let agent = Agent::new(session);
392392+ //Creates a strongly typed ATProto record
393393+ let status: KnownRecord = lexicons::xyz::statusphere::status::RecordData {
394394+ created_at: Datetime::now(),
395395+ status: form.status.clone(),
396396+ }
397397+ .into();
398398+399399+ // TODO no data validation yet from esquema
400400+ // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3
401401+402402+ let create_result = agent
403403+ .api
404404+ .com
405405+ .atproto
406406+ .repo
407407+ .create_record(
408408+ atrium_api::com::atproto::repo::create_record::InputData {
409409+ collection: Status::NSID.parse().unwrap(),
410410+ repo: did.into(),
411411+ rkey: None,
412412+ record: status.into(),
413413+ swap_commit: None,
414414+ validate: None,
415415+ }
416416+ .into(),
417417+ )
418418+ .await;
419419+420420+ match create_result {
421421+ Ok(record) => {
422422+ let status = StatusFromDb::new(
423423+ record.uri.clone(),
424424+ did_string,
425425+ form.status.clone(),
426426+ );
427427+428428+ let _ = status.save(db_pool).await;
429429+ Redirect::to("/")
430430+ .see_other()
431431+ .respond_to(&request)
432432+ .map_into_boxed_body()
433433+ }
434434+ Err(err) => {
435435+ log::error!("Error creating status: {err}");
436436+ let error_html = ErrorTemplate {
437437+ title: "Error",
438438+ error: "Was an error creating the status, please check the logs.",
439439+ }
440440+ .render()
441441+ .expect("template should be valid");
442442+ HttpResponse::Ok().body(error_html)
443443+ }
444444+ }
445445+ }
446446+ Err(err) => {
447447+ // Destroys the system or you're in a loop
448448+ session.purge();
449449+ log::error!(
450450+ "Error restoring session, we are removing the session from the cookie: {err}"
451451+ );
452452+ let error_html = ErrorTemplate {
453453+ title: "Error",
454454+ error: "Was an error resuming the session, please check the logs.",
455455+ }
456456+ .render()
457457+ .expect("template should be valid");
458458+ HttpResponse::Ok().body(error_html)
459459+ }
460460+ }
461461+ }
462462+ None => {
463463+ let error_template = ErrorTemplate {
464464+ title: "Error",
465465+ error: "You must be logged in to create a status.",
466466+ }
467467+ .render()
468468+ .expect("template should be valid");
469469+ HttpResponse::Ok().body(error_template)
470470+ }
471471+ }
472472+}
74738474#[actix_web::main]
9475async fn main() -> std::io::Result<()> {
476476+ dotenv().ok();
10477 env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
478478+ let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
479479+ let port = std::env::var("PORT")
480480+ .unwrap_or_else(|_| "8080".to_string())
481481+ .parse::<u16>()
482482+ .unwrap_or(8080);
114831212- log::info!("starting HTTP server at http://localhost:8080");
484484+ //Uses a default sqlite db path or use the one from env
485485+ let db_connection_string =
486486+ std::env::var("DB_PATH").unwrap_or_else(|_| String::from("./statusphere.sqlite3"));
487487+488488+ //Crates a db pool to share resources to the db
489489+ let pool = match PoolBuilder::new().path(db_connection_string).open().await {
490490+ Ok(pool) => pool,
491491+ Err(err) => {
492492+ log::error!("Error creating the sqlite pool: {}", err);
493493+ return Err(Error::new(
494494+ ErrorKind::Other,
495495+ "sqlite pool could not be created.",
496496+ ));
497497+ }
498498+ };
13499500500+ //Creates the DB and tables
501501+ create_tables_in_database(&pool)
502502+ .await
503503+ .expect("Could not create the database");
504504+505505+ //Create a new handle resolver for home page
506506+ let http_client = Arc::new(DefaultHttpClient::default());
507507+508508+ let handle_resolver = CommonDidResolver::new(CommonDidResolverConfig {
509509+ plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
510510+ http_client: http_client.clone(),
511511+ });
512512+ let handle_resolver = Arc::new(handle_resolver);
513513+514514+ // Create a new OAuth client
515515+ let http_client = Arc::new(DefaultHttpClient::default());
516516+ let config = OAuthClientConfig {
517517+ client_metadata: AtprotoLocalhostClientMetadata {
518518+ redirect_uris: Some(vec![String::from(format!(
519519+ //This must match the endpoint you use the callback function
520520+ "http://{host}:{port}/oauth/callback"
521521+ ))]),
522522+ scopes: Some(vec![
523523+ Scope::Known(KnownScope::Atproto),
524524+ Scope::Known(KnownScope::TransitionGeneric),
525525+ ]),
526526+ },
527527+ keys: None,
528528+ resolver: OAuthResolverConfig {
529529+ did_resolver: CommonDidResolver::new(CommonDidResolverConfig {
530530+ plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
531531+ http_client: http_client.clone(),
532532+ }),
533533+ handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig {
534534+ dns_txt_resolver: HickoryDnsTxtResolver::default(),
535535+ http_client: http_client.clone(),
536536+ }),
537537+ authorization_server_metadata: Default::default(),
538538+ protected_resource_metadata: Default::default(),
539539+ },
540540+ state_store: SqliteStateStore::new(pool.clone()),
541541+ session_store: SqliteSessionStore::new(pool.clone()),
542542+ };
543543+ let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client"));
544544+ let arc_pool = Arc::new(pool.clone());
545545+ //Spawns the ingester that listens for other's Statusphere updates
546546+ tokio::spawn(async move {
547547+ start_ingester(arc_pool).await;
548548+ });
549549+ let arc_pool = Arc::new(pool.clone());
550550+ log::info!("starting HTTP server at http://{host}:{port}");
14551 HttpServer::new(move || {
15552 App::new()
16553 .wrap(middleware::Logger::default())
554554+ .app_data(web::Data::new(client.clone()))
555555+ .app_data(web::Data::new(arc_pool.clone()))
556556+ .app_data(web::Data::new(handle_resolver.clone()))
557557+ .wrap(
558558+ SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&[0; 64]))
559559+ //TODO will need to set to true in production
560560+ .cookie_secure(false)
561561+ // customize session and cookie expiration
562562+ .session_lifecycle(
563563+ PersistentSession::default().session_ttl(cookie::time::Duration::days(14)),
564564+ )
565565+ .build(),
566566+ )
17567 .service(Files::new("/css", "public/css").show_files_listing())
1818- .service(feed_controller())
568568+ .service(oauth_callback)
569569+ .service(login)
570570+ .service(login_post)
571571+ .service(logout)
572572+ .service(home)
573573+ .service(status)
19574 })
2020- .bind(("127.0.0.1", 8080))?
575575+ .bind(("127.0.0.1", port))?
21576 .run()
22577 .await
23578}