├── .gitignore ├── justfile ├── rust-toolchain.toml ├── images ├── cover.png └── emojis.png ├── src ├── lexicons │ ├── mod.rs │ ├── xyz.rs │ ├── xyz │ │ ├── statusphere.rs │ │ └── statusphere │ │ │ └── status.rs │ └── record.rs ├── templates.rs ├── resolver.rs ├── ingester.rs ├── storage.rs ├── db.rs └── main.rs ├── templates ├── error.html ├── base.html ├── login.html └── home.html ├── .env.template ├── lexicons └── status.json ├── Cargo.toml ├── LICENSE ├── public └── css │ └── style.css └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | .env 4 | statusphere.sqlite3 -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | watch: 2 | watchexec -w src -w templates -r cargo run -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | version = "1.85.1" 4 | -------------------------------------------------------------------------------- /images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/rusty_statusphere_example_app/HEAD/images/cover.png -------------------------------------------------------------------------------- /images/emojis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/rusty_statusphere_example_app/HEAD/images/emojis.png -------------------------------------------------------------------------------- /src/lexicons/mod.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | pub mod record; 3 | pub mod xyz; 4 | -------------------------------------------------------------------------------- /src/lexicons/xyz.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `xyz` namespace. 3 | pub mod statusphere; 4 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Error: {{error}}

6 |
7 | Go Home 8 |
9 | 10 | {%endblock content%} -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ title }} 8 | 9 | 10 | 11 | 12 | 13 | {% block content %}{% endblock %} 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # Environment Configuration 2 | PORT="8080" # The port your server will listen on 3 | HOST="127.0.0.1" # Hostname for the server 4 | PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id. 5 | # DB_PATH="./statusphere.sqlite3" # The SQLite database path. Leave commented out to use a temporary in-memory database. 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lexicons/xyz/statusphere.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `xyz.statusphere` namespace. 3 | pub mod status; 4 | #[derive(Debug)] 5 | pub struct Status; 6 | impl atrium_api::types::Collection for Status { 7 | const NSID: &'static str = "xyz.statusphere.status"; 8 | type Record = status::Record; 9 | } 10 | -------------------------------------------------------------------------------- /lexicons/status.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "xyz.statusphere.status", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "key": "tid", 8 | "record": { 9 | "type": "object", 10 | "required": ["status", "createdAt"], 11 | "properties": { 12 | "status": { 13 | "type": "string", 14 | "minLength": 1, 15 | "maxGraphemes": 1, 16 | "maxLength": 32 17 | }, 18 | "createdAt": { "type": "string", "format": "datetime" } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lexicons/xyz/statusphere/status.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `xyz.statusphere.status` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | pub created_at: atrium_api::types::string::Datetime, 8 | pub status: String, 9 | } 10 | pub type Record = atrium_api::types::Object; 11 | impl From for RecordData { 12 | fn from(value: atrium_api::types::Unknown) -> Self { 13 | Self::try_from_unknown(value).unwrap() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 9 |
10 | 17 | 21 |
22 |
23 | 24 | {%endblock content%} -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rusty_statusphere_example_app" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | actix-files = "0.6.6" 8 | actix-session = { version = "0.10", features = ["cookie-session"] } 9 | actix-web = "4.10.2" 10 | anyhow = "1.0.97" 11 | askama = "0.13" 12 | atrium-common = "0.1.1" 13 | atrium-api = "0.25.0" 14 | atrium-identity = "0.1.3" 15 | atrium-oauth = "0.1.0" 16 | chrono = "0.4.40" 17 | env_logger = "0.11.7" 18 | hickory-resolver = "0.24.1" 19 | log = "0.4.27" 20 | serde = { version = "1.0.219", features = ["derive"] } 21 | serde_json = "1.0.140" 22 | rocketman = "0.2.0" 23 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 24 | dotenv = "0.15.0" 25 | thiserror = "1.0.69" 26 | async-sqlite = "0.5.0" 27 | async-trait = "0.1.88" 28 | 29 | [build-dependencies] 30 | askama = "0.13" 31 | 32 | 33 | [profile.dev.package.askama_derive] 34 | opt-level = 3 35 | -------------------------------------------------------------------------------- /src/templates.rs: -------------------------------------------------------------------------------- 1 | ///The askama template types for HTML 2 | /// 3 | use crate::db::StatusFromDb; 4 | use askama::Template; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Template)] 8 | #[template(path = "home.html")] 9 | pub struct HomeTemplate<'a> { 10 | pub title: &'a str, 11 | pub status_options: &'a [&'a str], 12 | pub profile: Option, 13 | pub statuses: Vec, 14 | pub my_status: Option, 15 | } 16 | 17 | #[derive(Serialize, Deserialize, Debug, Clone)] 18 | pub struct Profile { 19 | pub did: String, 20 | pub display_name: Option, 21 | } 22 | 23 | #[derive(Template)] 24 | #[template(path = "login.html")] 25 | pub struct LoginTemplate<'a> { 26 | pub title: &'a str, 27 | pub error: Option<&'a str>, 28 | } 29 | 30 | #[derive(Template)] 31 | #[template(path = "error.html")] 32 | pub struct ErrorTemplate<'a> { 33 | pub title: &'a str, 34 | pub error: &'a str, 35 | } 36 | -------------------------------------------------------------------------------- /src/resolver.rs: -------------------------------------------------------------------------------- 1 | use atrium_identity::handle::DnsTxtResolver; 2 | use hickory_resolver::TokioAsyncResolver; 3 | 4 | /// Setup for dns resolver for the handle resolver 5 | pub struct HickoryDnsTxtResolver { 6 | resolver: hickory_resolver::TokioAsyncResolver, 7 | } 8 | 9 | impl Default for HickoryDnsTxtResolver { 10 | fn default() -> Self { 11 | Self { 12 | resolver: TokioAsyncResolver::tokio_from_system_conf() 13 | .expect("failed to create resolver"), 14 | } 15 | } 16 | } 17 | 18 | impl DnsTxtResolver for HickoryDnsTxtResolver { 19 | async fn resolve( 20 | &self, 21 | query: &str, 22 | ) -> core::result::Result, Box> { 23 | println!("Resolving TXT for: {}", query); 24 | Ok(self 25 | .resolver 26 | .txt_lookup(query) 27 | .await? 28 | .iter() 29 | .map(|txt| txt.to_string()) 30 | .collect()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Bailey Townsend 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lexicons/record.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!A collection of known record types. 3 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 4 | #[serde(tag = "$type")] 5 | pub enum KnownRecord { 6 | #[serde(rename = "xyz.statusphere.status")] 7 | LexiconsXyzStatusphereStatus(Box), 8 | } 9 | impl From for KnownRecord { 10 | fn from(record: crate::lexicons::xyz::statusphere::status::Record) -> Self { 11 | KnownRecord::LexiconsXyzStatusphereStatus(Box::new(record)) 12 | } 13 | } 14 | impl From for KnownRecord { 15 | fn from(record_data: crate::lexicons::xyz::statusphere::status::RecordData) -> Self { 16 | KnownRecord::LexiconsXyzStatusphereStatus(Box::new(record_data.into())) 17 | } 18 | } 19 | impl Into for KnownRecord { 20 | fn into(self) -> atrium_api::types::Unknown { 21 | atrium_api::types::TryIntoUnknown::try_into_unknown(&self).unwrap() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 | 10 |
11 |
12 | {% if let Some(Profile {did, display_name}) = profile %} 13 |
14 |
15 | Hi, 16 | {% if let Some(display_name) = display_name %} 17 | {{display_name}} 18 | {% else %} 19 | friend 20 | {% endif %}. What's 21 | your status today?? 22 |
23 |
24 | 25 |
26 |
27 | {% else %} 28 |
29 |
Log in to set your status!
30 |
31 | Log in 32 |
33 |
34 | {% endif %} 35 | 36 | 37 |
38 |
39 | {% for status in status_options %} 40 | 45 | 46 | {% endfor %} 47 |
48 | {% for status in statuses %} 49 |
50 |
51 |
{{status.status}}
52 |
53 |
54 | {{status.author_display_name()}} 56 | {% if status.is_today() %} 57 | is feeling {{status.status}} today 58 | {% else %} 59 | was feeling {{status.status}} on {{status.created_at}} 60 | {% endif %} 61 |
62 |
63 | {% endfor %} 64 |
65 |
66 | 67 | {%endblock content%} -------------------------------------------------------------------------------- /src/ingester.rs: -------------------------------------------------------------------------------- 1 | use crate::db::StatusFromDb; 2 | use crate::lexicons; 3 | use crate::lexicons::xyz::statusphere::Status; 4 | use anyhow::anyhow; 5 | use async_sqlite::Pool; 6 | use async_trait::async_trait; 7 | use atrium_api::types::Collection; 8 | use log::error; 9 | use rocketman::{ 10 | connection::JetstreamConnection, 11 | handler, 12 | ingestion::LexiconIngestor, 13 | options::JetstreamOptions, 14 | types::event::{Event, Operation}, 15 | }; 16 | use serde_json::Value; 17 | use std::{ 18 | collections::HashMap, 19 | sync::{Arc, Mutex}, 20 | }; 21 | 22 | #[async_trait] 23 | impl LexiconIngestor for StatusSphereIngester { 24 | async fn ingest(&self, message: Event) -> anyhow::Result<()> { 25 | if let Some(commit) = &message.commit { 26 | //We manually construct the uri since Jetstream does not provide it 27 | //at://{users did}/{collection: xyz.statusphere.status}{records key} 28 | let record_uri = format!("at://{}/{}/{}", message.did, commit.collection, commit.rkey); 29 | match commit.operation { 30 | Operation::Create | Operation::Update => { 31 | if let Some(record) = &commit.record { 32 | let status_at_proto_record = serde_json::from_value::< 33 | lexicons::xyz::statusphere::status::RecordData, 34 | >(record.clone())?; 35 | 36 | if let Some(ref _cid) = commit.cid { 37 | // Although esquema does not have full validation yet, 38 | // if you get to this point, 39 | // You know the data structure is the same 40 | let created = status_at_proto_record.created_at.as_ref(); 41 | let right_now = chrono::Utc::now(); 42 | // We save or update the record in the db 43 | StatusFromDb { 44 | uri: record_uri, 45 | author_did: message.did.clone(), 46 | status: status_at_proto_record.status.clone(), 47 | created_at: created.to_utc(), 48 | indexed_at: right_now, 49 | handle: None, 50 | } 51 | .save_or_update(&self.db_pool) 52 | .await?; 53 | } 54 | } 55 | } 56 | Operation::Delete => StatusFromDb::delete_by_uri(&self.db_pool, record_uri).await?, 57 | } 58 | } else { 59 | return Err(anyhow!("Message has no commit")); 60 | } 61 | Ok(()) 62 | } 63 | } 64 | pub struct StatusSphereIngester { 65 | db_pool: Arc, 66 | } 67 | 68 | pub async fn start_ingester(db_pool: Arc) { 69 | // init the builder 70 | let opts = JetstreamOptions::builder() 71 | // your EXACT nsids 72 | // Which in this case is xyz.statusphere.status 73 | .wanted_collections(vec![Status::NSID.parse().unwrap()]) 74 | .build(); 75 | // create the jetstream connector 76 | let jetstream = JetstreamConnection::new(opts); 77 | 78 | // create your ingesters 79 | // Which in this case is xyz.statusphere.status 80 | let mut ingesters: HashMap> = HashMap::new(); 81 | ingesters.insert( 82 | // your EXACT nsid 83 | Status::NSID.parse().unwrap(), 84 | Box::new(StatusSphereIngester { db_pool }), 85 | ); 86 | 87 | // tracks the last message we've processed 88 | let cursor: Arc>> = Arc::new(Mutex::new(None)); 89 | 90 | // get channels 91 | let msg_rx = jetstream.get_msg_rx(); 92 | let reconnect_tx = jetstream.get_reconnect_tx(); 93 | 94 | // spawn a task to process messages from the queue. 95 | // this is a simple implementation, you can use a more complex one based on needs. 96 | let c_cursor = cursor.clone(); 97 | tokio::spawn(async move { 98 | while let Ok(message) = msg_rx.recv_async().await { 99 | if let Err(e) = 100 | handler::handle_message(message, &ingesters, reconnect_tx.clone(), c_cursor.clone()) 101 | .await 102 | { 103 | error!("Error processing message: {}", e); 104 | }; 105 | } 106 | }); 107 | 108 | // connect to jetstream 109 | // retries internally, but may fail if there is an extreme error. 110 | if let Err(e) = jetstream.connect(cursor.clone()).await { 111 | error!("Failed to connect to Jetstream: {}", e); 112 | std::process::exit(1); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | 4 | --border-color: #ddd; 5 | --gray-100: #fafafa; 6 | --gray-500: #666; 7 | --gray-700: #333; 8 | --primary-100: #d2e7ff; 9 | --primary-200: #b1d3fa; 10 | --primary-400: #2e8fff; 11 | --primary-500: #0078ff; 12 | --primary-600: #0066db; 13 | --error-500: #f00; 14 | --error-100: #fee; 15 | } 16 | 17 | /* 18 | Josh's Custom CSS Reset 19 | https://www.joshwcomeau.com/css/custom-css-reset/ 20 | */ 21 | *, 22 | *::before, 23 | *::after { 24 | box-sizing: border-box; 25 | } 26 | 27 | * { 28 | margin: 0; 29 | } 30 | 31 | body { 32 | line-height: 1.5; 33 | -webkit-font-smoothing: antialiased; 34 | } 35 | 36 | img, 37 | picture, 38 | video, 39 | canvas, 40 | svg { 41 | display: block; 42 | max-width: 100%; 43 | } 44 | 45 | input, 46 | button, 47 | textarea, 48 | select { 49 | font: inherit; 50 | } 51 | 52 | p, 53 | h1, 54 | h2, 55 | h3, 56 | h4, 57 | h5, 58 | h6 { 59 | overflow-wrap: break-word; 60 | } 61 | 62 | #root, 63 | #__next { 64 | isolation: isolate; 65 | } 66 | 67 | /* 68 | Common components 69 | */ 70 | button, 71 | .button { 72 | display: inline-block; 73 | border: 0; 74 | background-color: var(--primary-500); 75 | border-radius: 50px; 76 | color: #fff; 77 | padding: 2px 10px; 78 | cursor: pointer; 79 | text-decoration: none; 80 | } 81 | 82 | button:hover, 83 | .button:hover { 84 | background: var(--primary-400); 85 | } 86 | 87 | /* 88 | Custom components 89 | */ 90 | .error { 91 | background-color: var(--error-100); 92 | color: var(--error-500); 93 | text-align: center; 94 | padding: 1rem; 95 | display: none; 96 | } 97 | 98 | .error.visible { 99 | display: block; 100 | } 101 | 102 | #header { 103 | background-color: #fff; 104 | text-align: center; 105 | padding: 0.5rem 0 1.5rem; 106 | } 107 | 108 | #header h1 { 109 | font-size: 5rem; 110 | } 111 | 112 | .container { 113 | display: flex; 114 | flex-direction: column; 115 | gap: 4px; 116 | margin: 0 auto; 117 | max-width: 600px; 118 | padding: 20px; 119 | } 120 | 121 | .card { 122 | /* border: 1px solid var(--border-color); */ 123 | border-radius: 6px; 124 | padding: 10px 16px; 125 | background-color: #fff; 126 | } 127 | 128 | .card> :first-child { 129 | margin-top: 0; 130 | } 131 | 132 | .card> :last-child { 133 | margin-bottom: 0; 134 | } 135 | 136 | .session-form { 137 | display: flex; 138 | flex-direction: row; 139 | align-items: center; 140 | justify-content: space-between; 141 | } 142 | 143 | .login-form { 144 | display: flex; 145 | flex-direction: row; 146 | gap: 6px; 147 | border: 1px solid var(--border-color); 148 | border-radius: 6px; 149 | padding: 10px 16px; 150 | background-color: #fff; 151 | } 152 | 153 | .login-form input { 154 | flex: 1; 155 | border: 0; 156 | } 157 | 158 | .status-options { 159 | display: flex; 160 | flex-direction: row; 161 | flex-wrap: wrap; 162 | gap: 8px; 163 | margin: 10px 0; 164 | } 165 | 166 | .status-option { 167 | font-size: 2rem; 168 | width: 3rem; 169 | height: 3rem; 170 | padding: 0; 171 | background-color: #fff; 172 | border: 1px solid var(--border-color); 173 | border-radius: 3rem; 174 | text-align: center; 175 | box-shadow: 0 1px 4px #0001; 176 | cursor: pointer; 177 | } 178 | 179 | .status-option:hover { 180 | background-color: var(--primary-100); 181 | box-shadow: 0 0 0 1px var(--primary-400); 182 | } 183 | 184 | .status-option.selected { 185 | box-shadow: 0 0 0 1px var(--primary-500); 186 | background-color: var(--primary-100); 187 | } 188 | 189 | .status-option.selected:hover { 190 | background-color: var(--primary-200); 191 | } 192 | 193 | .status-line { 194 | display: flex; 195 | flex-direction: row; 196 | align-items: center; 197 | gap: 10px; 198 | position: relative; 199 | margin-top: 15px; 200 | } 201 | 202 | .status-line:not(.no-line)::before { 203 | content: ''; 204 | position: absolute; 205 | width: 2px; 206 | background-color: var(--border-color); 207 | left: 1.45rem; 208 | bottom: calc(100% + 2px); 209 | height: 15px; 210 | } 211 | 212 | .status-line .status { 213 | font-size: 2rem; 214 | background-color: #fff; 215 | width: 3rem; 216 | height: 3rem; 217 | border-radius: 1.5rem; 218 | text-align: center; 219 | border: 1px solid var(--border-color); 220 | } 221 | 222 | .status-line .desc { 223 | color: var(--gray-500); 224 | } 225 | 226 | .status-line .author { 227 | color: var(--gray-700); 228 | font-weight: 600; 229 | text-decoration: none; 230 | } 231 | 232 | .status-line .author:hover { 233 | text-decoration: underline; 234 | } 235 | 236 | .signup-cta { 237 | text-align: center; 238 | text-wrap: balance; 239 | margin-top: 1rem; 240 | } -------------------------------------------------------------------------------- /src/storage.rs: -------------------------------------------------------------------------------- 1 | /// Storage impls to persis OAuth sessions if you are not using the memory stores 2 | /// https://github.com/bluesky-social/statusphere-example-app/blob/main/src/auth/storage.ts 3 | use crate::db::{AuthSession, AuthState}; 4 | use async_sqlite::Pool; 5 | use atrium_api::types::string::Did; 6 | use atrium_common::store::Store; 7 | use atrium_oauth::store::session::SessionStore; 8 | use atrium_oauth::store::state::StateStore; 9 | use serde::Serialize; 10 | use serde::de::DeserializeOwned; 11 | use std::fmt::Debug; 12 | use std::hash::Hash; 13 | use thiserror::Error; 14 | 15 | #[derive(Error, Debug)] 16 | pub enum SqliteStoreError { 17 | #[error("Invalid session")] 18 | InvalidSession, 19 | #[error("No session found")] 20 | NoSessionFound, 21 | #[error("Database error: {0}")] 22 | DatabaseError(async_sqlite::Error), 23 | } 24 | 25 | ///Persistent session store in sqlite 26 | impl SessionStore for SqliteSessionStore {} 27 | 28 | pub struct SqliteSessionStore { 29 | db_pool: Pool, 30 | } 31 | 32 | impl SqliteSessionStore { 33 | pub fn new(db: Pool) -> Self { 34 | Self { db_pool: db } 35 | } 36 | } 37 | 38 | impl Store for SqliteSessionStore 39 | where 40 | K: Debug + Eq + Hash + Send + Sync + 'static + From + AsRef, 41 | V: Debug + Clone + Send + Sync + 'static + Serialize + DeserializeOwned, 42 | { 43 | type Error = SqliteStoreError; 44 | async fn get(&self, key: &K) -> Result, Self::Error> { 45 | let did = key.as_ref().to_string(); 46 | match AuthSession::get_by_did(&self.db_pool, did).await { 47 | Ok(Some(auth_session)) => { 48 | let deserialized_session: V = serde_json::from_str(&auth_session.session) 49 | .map_err(|_| SqliteStoreError::InvalidSession)?; 50 | Ok(Some(deserialized_session)) 51 | } 52 | Ok(None) => Err(SqliteStoreError::NoSessionFound), 53 | Err(db_error) => { 54 | log::error!("Database error: {db_error}"); 55 | Err(SqliteStoreError::DatabaseError(db_error)) 56 | } 57 | } 58 | } 59 | 60 | async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 61 | let did = key.as_ref().to_string(); 62 | let auth_session = AuthSession::new(did, value); 63 | auth_session 64 | .save_or_update(&self.db_pool) 65 | .await 66 | .map_err(SqliteStoreError::DatabaseError)?; 67 | Ok(()) 68 | } 69 | 70 | async fn del(&self, _key: &K) -> Result<(), Self::Error> { 71 | let did = _key.as_ref().to_string(); 72 | AuthSession::delete_by_did(&self.db_pool, did) 73 | .await 74 | .map_err(SqliteStoreError::DatabaseError)?; 75 | Ok(()) 76 | } 77 | 78 | async fn clear(&self) -> Result<(), Self::Error> { 79 | AuthSession::delete_all(&self.db_pool) 80 | .await 81 | .map_err(SqliteStoreError::DatabaseError)?; 82 | Ok(()) 83 | } 84 | } 85 | 86 | ///Persistent session state in sqlite 87 | impl StateStore for SqliteStateStore {} 88 | 89 | pub struct SqliteStateStore { 90 | db_pool: Pool, 91 | } 92 | 93 | impl SqliteStateStore { 94 | pub fn new(db: Pool) -> Self { 95 | Self { db_pool: db } 96 | } 97 | } 98 | 99 | impl Store for SqliteStateStore 100 | where 101 | K: Debug + Eq + Hash + Send + Sync + 'static + From + AsRef, 102 | V: Debug + Clone + Send + Sync + 'static + Serialize + DeserializeOwned, 103 | { 104 | type Error = SqliteStoreError; 105 | async fn get(&self, key: &K) -> Result, Self::Error> { 106 | let key = key.as_ref().to_string(); 107 | match AuthState::get_by_key(&self.db_pool, key).await { 108 | Ok(Some(auth_state)) => { 109 | let deserialized_state: V = serde_json::from_str(&auth_state.state) 110 | .map_err(|_| SqliteStoreError::InvalidSession)?; 111 | Ok(Some(deserialized_state)) 112 | } 113 | Ok(None) => Err(SqliteStoreError::NoSessionFound), 114 | Err(db_error) => { 115 | log::error!("Database error: {db_error}"); 116 | Err(SqliteStoreError::DatabaseError(db_error)) 117 | } 118 | } 119 | } 120 | 121 | async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 122 | let did = key.as_ref().to_string(); 123 | let auth_state = AuthState::new(did, value); 124 | auth_state 125 | .save_or_update(&self.db_pool) 126 | .await 127 | .map_err(SqliteStoreError::DatabaseError)?; 128 | Ok(()) 129 | } 130 | 131 | async fn del(&self, _key: &K) -> Result<(), Self::Error> { 132 | let key = _key.as_ref().to_string(); 133 | AuthState::delete_by_key(&self.db_pool, key) 134 | .await 135 | .map_err(SqliteStoreError::DatabaseError)?; 136 | Ok(()) 137 | } 138 | 139 | async fn clear(&self) -> Result<(), Self::Error> { 140 | AuthState::delete_all(&self.db_pool) 141 | .await 142 | .map_err(SqliteStoreError::DatabaseError)?; 143 | Ok(()) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | use actix_web::web::Data; 2 | use async_sqlite::{ 3 | Pool, rusqlite, 4 | rusqlite::{Error, Row}, 5 | }; 6 | use atrium_api::types::string::Did; 7 | use chrono::{DateTime, Datelike, Utc}; 8 | use rusqlite::types::Type; 9 | use serde::{Deserialize, Serialize}; 10 | use std::{fmt::Debug, sync::Arc}; 11 | 12 | /// Creates the tables in the db. 13 | pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { 14 | pool.conn(move |conn| { 15 | conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); 16 | 17 | // status 18 | conn.execute( 19 | "CREATE TABLE IF NOT EXISTS status ( 20 | uri TEXT PRIMARY KEY, 21 | authorDid TEXT NOT NULL, 22 | status TEXT NOT NULL, 23 | createdAt INTEGER NOT NULL, 24 | indexedAt INTEGER NOT NULL 25 | )", 26 | [], 27 | ) 28 | .unwrap(); 29 | 30 | // auth_session 31 | conn.execute( 32 | "CREATE TABLE IF NOT EXISTS auth_session ( 33 | key TEXT PRIMARY KEY, 34 | session TEXT NOT NULL 35 | )", 36 | [], 37 | ) 38 | .unwrap(); 39 | 40 | // auth_state 41 | conn.execute( 42 | "CREATE TABLE IF NOT EXISTS auth_state ( 43 | key TEXT PRIMARY KEY, 44 | state TEXT NOT NULL 45 | )", 46 | [], 47 | ) 48 | .unwrap(); 49 | Ok(()) 50 | }) 51 | .await?; 52 | Ok(()) 53 | } 54 | 55 | ///Status table datatype 56 | #[derive(Debug, Clone, Deserialize, Serialize)] 57 | pub struct StatusFromDb { 58 | pub uri: String, 59 | pub author_did: String, 60 | pub status: String, 61 | pub created_at: DateTime, 62 | pub indexed_at: DateTime, 63 | pub handle: Option, 64 | } 65 | 66 | //Status methods 67 | impl StatusFromDb { 68 | /// Creates a new [StatusFromDb] 69 | pub fn new(uri: String, author_did: String, status: String) -> Self { 70 | let now = chrono::Utc::now(); 71 | Self { 72 | uri, 73 | author_did, 74 | status, 75 | created_at: now, 76 | indexed_at: now, 77 | handle: None, 78 | } 79 | } 80 | 81 | /// Helper to map from [Row] to [StatusDb] 82 | fn map_from_row(row: &Row) -> Result { 83 | Ok(Self { 84 | uri: row.get(0)?, 85 | author_did: row.get(1)?, 86 | status: row.get(2)?, 87 | //DateTimes are stored as INTEGERS then parsed into a DateTime 88 | created_at: { 89 | let timestamp: i64 = row.get(3)?; 90 | DateTime::from_timestamp(timestamp, 0).ok_or_else(|| { 91 | Error::InvalidColumnType(3, "Invalid timestamp".parse().unwrap(), Type::Text) 92 | })? 93 | }, 94 | //DateTimes are stored as INTEGERS then parsed into a DateTime 95 | indexed_at: { 96 | let timestamp: i64 = row.get(4)?; 97 | DateTime::from_timestamp(timestamp, 0).ok_or_else(|| { 98 | Error::InvalidColumnType(4, "Invalid timestamp".parse().unwrap(), Type::Text) 99 | })? 100 | }, 101 | handle: None, 102 | }) 103 | } 104 | 105 | /// Helper for the UI to see if indexed_at date is today or not 106 | pub fn is_today(&self) -> bool { 107 | let now = Utc::now(); 108 | 109 | self.indexed_at.day() == now.day() 110 | && self.indexed_at.month() == now.month() 111 | && self.indexed_at.year() == now.year() 112 | } 113 | 114 | /// Saves the [StatusDb] 115 | pub async fn save(&self, pool: Data>) -> Result<(), async_sqlite::Error> { 116 | let cloned_self = self.clone(); 117 | pool.conn(move |conn| { 118 | Ok(conn.execute( 119 | "INSERT INTO status (uri, authorDid, status, createdAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5)", 120 | [ 121 | &cloned_self.uri, 122 | &cloned_self.author_did, 123 | &cloned_self.status, 124 | &cloned_self.created_at.timestamp().to_string(), 125 | &cloned_self.indexed_at.timestamp().to_string(), 126 | ], 127 | )?) 128 | }) 129 | .await?; 130 | Ok(()) 131 | } 132 | 133 | /// Saves or updates a status by its did(uri) 134 | pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> { 135 | let cloned_self = self.clone(); 136 | pool.conn(move |conn| { 137 | //We check to see if the session already exists, if so we need to update not insert 138 | let mut stmt = conn.prepare("SELECT COUNT(*) FROM status WHERE uri = ?1")?; 139 | let count: i64 = stmt.query_row([&cloned_self.uri], |row| row.get(0))?; 140 | match count > 0 { 141 | true => { 142 | let mut update_stmt = 143 | conn.prepare("UPDATE status SET status = ?2, indexedAt = ? WHERE uri = ?1")?; 144 | update_stmt.execute([&cloned_self.uri, &cloned_self.status, &cloned_self.indexed_at.timestamp().to_string()])?; 145 | Ok(()) 146 | } 147 | false => { 148 | conn.execute( 149 | "INSERT INTO status (uri, authorDid, status, createdAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5)", 150 | [ 151 | &cloned_self.uri, 152 | &cloned_self.author_did, 153 | &cloned_self.status, 154 | &cloned_self.created_at.timestamp().to_string(), 155 | &cloned_self.indexed_at.timestamp().to_string(), 156 | ], 157 | )?; 158 | Ok(()) 159 | } 160 | } 161 | }) 162 | .await?; 163 | Ok(()) 164 | } 165 | pub async fn delete_by_uri(pool: &Pool, uri: String) -> Result<(), async_sqlite::Error> { 166 | pool.conn(move |conn| { 167 | let mut stmt = conn.prepare("DELETE FROM status WHERE uri = ?1")?; 168 | stmt.execute([&uri]) 169 | }) 170 | .await?; 171 | Ok(()) 172 | } 173 | 174 | /// Loads the last 10 statuses we have saved 175 | pub async fn load_latest_statuses( 176 | pool: &Data>, 177 | ) -> Result, async_sqlite::Error> { 178 | Ok(pool 179 | .conn(move |conn| { 180 | let mut stmt = 181 | conn.prepare("SELECT * FROM status ORDER BY indexedAt DESC LIMIT 10")?; 182 | let status_iter = stmt 183 | .query_map([], |row| Ok(Self::map_from_row(row).unwrap())) 184 | .unwrap(); 185 | 186 | let mut statuses = Vec::new(); 187 | for status in status_iter { 188 | statuses.push(status?); 189 | } 190 | Ok(statuses) 191 | }) 192 | .await?) 193 | } 194 | 195 | /// Loads the logged-in users current status 196 | pub async fn my_status( 197 | pool: &Data>, 198 | did: &str, 199 | ) -> Result, async_sqlite::Error> { 200 | let did = did.to_string(); 201 | pool.conn(move |conn| { 202 | let mut stmt = conn.prepare( 203 | "SELECT * FROM status WHERE authorDid = ?1 ORDER BY createdAt DESC LIMIT 1", 204 | )?; 205 | stmt.query_row([did.as_str()], |row| Self::map_from_row(row)) 206 | .map(Some) 207 | .or_else(|err| { 208 | if err == rusqlite::Error::QueryReturnedNoRows { 209 | Ok(None) 210 | } else { 211 | Err(err) 212 | } 213 | }) 214 | }) 215 | .await 216 | } 217 | 218 | /// ui helper to show a handle or did if the handle cannot be found 219 | pub fn author_display_name(&self) -> String { 220 | match self.handle.as_ref() { 221 | Some(handle) => handle.to_string(), 222 | None => self.author_did.to_string(), 223 | } 224 | } 225 | } 226 | 227 | /// AuthSession table data type 228 | #[derive(Debug, Clone, Deserialize, Serialize)] 229 | pub struct AuthSession { 230 | pub key: String, 231 | pub session: String, 232 | } 233 | 234 | impl AuthSession { 235 | /// Creates a new [AuthSession] 236 | pub fn new(key: String, session: V) -> Self 237 | where 238 | V: Serialize, 239 | { 240 | let session = serde_json::to_string(&session).unwrap(); 241 | Self { 242 | key: key.to_string(), 243 | session, 244 | } 245 | } 246 | 247 | /// Helper to map from [Row] to [AuthSession] 248 | fn map_from_row(row: &Row) -> Result { 249 | let key: String = row.get(0)?; 250 | let session: String = row.get(1)?; 251 | Ok(Self { key, session }) 252 | } 253 | 254 | /// Gets a session by the users did(key) 255 | pub async fn get_by_did(pool: &Pool, did: String) -> Result, async_sqlite::Error> { 256 | let did = Did::new(did).unwrap(); 257 | pool.conn(move |conn| { 258 | let mut stmt = conn.prepare("SELECT * FROM auth_session WHERE key = ?1")?; 259 | stmt.query_row([did.as_str()], |row| Self::map_from_row(row)) 260 | .map(Some) 261 | .or_else(|err| { 262 | if err == Error::QueryReturnedNoRows { 263 | Ok(None) 264 | } else { 265 | Err(err) 266 | } 267 | }) 268 | }) 269 | .await 270 | } 271 | 272 | /// Saves or updates the session by its did(key) 273 | pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> { 274 | let cloned_self = self.clone(); 275 | pool.conn(move |conn| { 276 | //We check to see if the session already exists, if so we need to update not insert 277 | let mut stmt = conn.prepare("SELECT COUNT(*) FROM auth_session WHERE key = ?1")?; 278 | let count: i64 = stmt.query_row([&cloned_self.key], |row| row.get(0))?; 279 | match count > 0 { 280 | true => { 281 | let mut update_stmt = 282 | conn.prepare("UPDATE auth_session SET session = ?2 WHERE key = ?1")?; 283 | update_stmt.execute([&cloned_self.key, &cloned_self.session])?; 284 | Ok(()) 285 | } 286 | false => { 287 | conn.execute( 288 | "INSERT INTO auth_session (key, session) VALUES (?1, ?2)", 289 | [&cloned_self.key, &cloned_self.session], 290 | )?; 291 | Ok(()) 292 | } 293 | } 294 | }) 295 | .await?; 296 | Ok(()) 297 | } 298 | 299 | /// Deletes the session by did 300 | pub async fn delete_by_did(pool: &Pool, did: String) -> Result<(), async_sqlite::Error> { 301 | pool.conn(move |conn| { 302 | let mut stmt = conn.prepare("DELETE FROM auth_session WHERE key = ?1")?; 303 | stmt.execute([&did]) 304 | }) 305 | .await?; 306 | Ok(()) 307 | } 308 | 309 | /// Deletes all the sessions 310 | pub async fn delete_all(pool: &Pool) -> Result<(), async_sqlite::Error> { 311 | pool.conn(move |conn| { 312 | let mut stmt = conn.prepare("DELETE FROM auth_session")?; 313 | stmt.execute([]) 314 | }) 315 | .await?; 316 | Ok(()) 317 | } 318 | } 319 | 320 | /// AuthState table datatype 321 | #[derive(Debug, Clone, Deserialize, Serialize)] 322 | pub struct AuthState { 323 | pub key: String, 324 | pub state: String, 325 | } 326 | 327 | impl AuthState { 328 | /// Creates a new [AuthState] 329 | pub fn new(key: String, state: V) -> Self 330 | where 331 | V: Serialize, 332 | { 333 | let state = serde_json::to_string(&state).unwrap(); 334 | Self { 335 | key: key.to_string(), 336 | state, 337 | } 338 | } 339 | 340 | /// Helper to map from [Row] to [AuthState] 341 | fn map_from_row(row: &Row) -> Result { 342 | let key: String = row.get(0)?; 343 | let state: String = row.get(1)?; 344 | Ok(Self { key, state }) 345 | } 346 | 347 | /// Gets a state by the users key 348 | pub async fn get_by_key(pool: &Pool, key: String) -> Result, async_sqlite::Error> { 349 | pool.conn(move |conn| { 350 | let mut stmt = conn.prepare("SELECT * FROM auth_state WHERE key = ?1")?; 351 | stmt.query_row([key.as_str()], |row| Self::map_from_row(row)) 352 | .map(Some) 353 | .or_else(|err| { 354 | if err == Error::QueryReturnedNoRows { 355 | Ok(None) 356 | } else { 357 | Err(err) 358 | } 359 | }) 360 | }) 361 | .await 362 | } 363 | 364 | /// Saves or updates the state by its key 365 | pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> { 366 | let cloned_self = self.clone(); 367 | pool.conn(move |conn| { 368 | //We check to see if the state already exists, if so we need to update 369 | let mut stmt = conn.prepare("SELECT COUNT(*) FROM auth_state WHERE key = ?1")?; 370 | let count: i64 = stmt.query_row([&cloned_self.key], |row| row.get(0))?; 371 | match count > 0 { 372 | true => { 373 | let mut update_stmt = 374 | conn.prepare("UPDATE auth_state SET state = ?2 WHERE key = ?1")?; 375 | update_stmt.execute([&cloned_self.key, &cloned_self.state])?; 376 | Ok(()) 377 | } 378 | false => { 379 | conn.execute( 380 | "INSERT INTO auth_state (key, state) VALUES (?1, ?2)", 381 | [&cloned_self.key, &cloned_self.state], 382 | )?; 383 | Ok(()) 384 | } 385 | } 386 | }) 387 | .await?; 388 | Ok(()) 389 | } 390 | 391 | pub async fn delete_by_key(pool: &Pool, key: String) -> Result<(), async_sqlite::Error> { 392 | pool.conn(move |conn| { 393 | let mut stmt = conn.prepare("DELETE FROM auth_state WHERE key = ?1")?; 394 | stmt.execute([&key]) 395 | }) 396 | .await?; 397 | Ok(()) 398 | } 399 | 400 | pub async fn delete_all(pool: &Pool) -> Result<(), async_sqlite::Error> { 401 | pool.conn(move |conn| { 402 | let mut stmt = conn.prepare("DELETE FROM auth_state")?; 403 | stmt.execute([]) 404 | }) 405 | .await?; 406 | Ok(()) 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use 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 | }; 9 | use actix_files::Files; 10 | use actix_session::{ 11 | Session, SessionMiddleware, config::PersistentSession, storage::CookieSessionStore, 12 | }; 13 | use actix_web::{ 14 | App, HttpRequest, HttpResponse, HttpServer, Responder, Result, 15 | cookie::{self, Key}, 16 | get, middleware, post, 17 | web::{self, Redirect}, 18 | }; 19 | use askama::Template; 20 | use async_sqlite::{Pool, PoolBuilder}; 21 | use atrium_api::{ 22 | agent::Agent, 23 | types::Collection, 24 | types::string::{Datetime, Did}, 25 | }; 26 | use atrium_common::resolver::Resolver; 27 | use atrium_identity::{ 28 | did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}, 29 | handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 30 | }; 31 | use atrium_oauth::{ 32 | AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient, 33 | KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 34 | }; 35 | use dotenv::dotenv; 36 | use resolver::HickoryDnsTxtResolver; 37 | use serde::{Deserialize, Serialize}; 38 | use std::{ 39 | collections::HashMap, 40 | io::{Error, ErrorKind}, 41 | sync::Arc, 42 | }; 43 | use templates::{ErrorTemplate, Profile}; 44 | 45 | extern crate dotenv; 46 | 47 | mod db; 48 | mod ingester; 49 | mod lexicons; 50 | mod resolver; 51 | mod storage; 52 | mod templates; 53 | 54 | /// OAuthClientType to make it easier to access the OAuthClient in web requests 55 | type OAuthClientType = Arc< 56 | OAuthClient< 57 | SqliteStateStore, 58 | SqliteSessionStore, 59 | CommonDidResolver, 60 | AtprotoHandleResolver, 61 | >, 62 | >; 63 | 64 | /// HandleResolver to make it easier to access the OAuthClient in web requests 65 | type HandleResolver = Arc>; 66 | 67 | /// All the available emoji status options 68 | const 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")] 103 | async fn oauth_callback( 104 | request: HttpRequest, 105 | params: web::Query, 106 | oauth_client: web::Data, 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")] 144 | async fn login() -> Result { 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")] 157 | async 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)] 167 | struct 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")] 174 | async fn login_post( 175 | request: HttpRequest, 176 | params: web::Form, 177 | oauth_client: web::Data, 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("/")] 223 | async fn home( 224 | session: Session, 225 | oauth_client: web::Data, 226 | db_pool: web::Data>, 227 | handle_resolver: web::Data, 228 | ) -> Result { 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 = 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::("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)] 370 | struct 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")] 377 | async fn status( 378 | request: HttpRequest, 379 | session: Session, 380 | oauth_client: web::Data, 381 | db_pool: web::Data>, 382 | form: web::Form, 383 | ) -> HttpResponse { 384 | // Check if the user is logged in 385 | match session.get::("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] 475 | async 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::() 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 | } 579 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rusty Statusphere 2 | 3 | Originally taken 4 | from [bluesky-social/atproto-website](https://github.com/bluesky-social/atproto-website/blob/dbcd70ced53078579c7e5b015a26db295b7a7807/src/app/%5Blocale%5D/guides/applications/en.mdx) 5 | 6 | > [!NOTE] 7 | > ***This tutorial is based off of the original quick start guide found [here](https://atproto.com/guides/applications). 8 | > The goal is to follow as closely to the original as possible, expect for one small change. It's in Rust 🦀. 9 | > All credit goes to the maintainers of the original project and tutorial. This was made to help you get started with 10 | > 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.*** 11 | 12 | # Quick start guide to building applications on AT Protocol 13 | 14 | [Find the source code on GitHub](https://github.com/fatfingers23/rusty_statusphere_example_app) 15 | 16 | In this guide, we're going to build a simple multi-user app that publishes your current "status" as an emoji. Our 17 | application will look like this: 18 | 19 | ![Picture of the application](./images/cover.png) 20 | 21 | We will cover how to: 22 | 23 | - Signin via OAuth 24 | - Fetch information about users (profiles) 25 | - Listen to the network firehose for new data via the [Jetstream](https://docs.bsky.app/blog/jetstream) 26 | - Publish data on the user's account using a custom schema 27 | 28 | We're going to keep this light so you can quickly wrap your head around ATProto. There will be links with more 29 | information about each step. 30 | 31 | ## Introduction 32 | 33 | Data in the Atmosphere is stored on users' personal repos. It's almost like each user has their own website. Our goal is 34 | to aggregate data from the users into our SQLite DB. 35 | 36 | Think of our app like a Google. If Google's job was to say which emoji each website had under `/status.json`, then it 37 | would show something like: 38 | 39 | - `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.json` 40 | - `bsky.app` is feeling 🦋 according to `https://bsky.app/status.json` 41 | - `reddit.com` is feeling 🤓 according to `https://reddit.com/status.json` 42 | 43 | The Atmosphere works the same way, except we're going to check `at://` instead of `https://`. Each user has a data repo 44 | under an `at://` URL. We'll crawl all the user data repos in the Atmosphere for all the "status.json" records and 45 | aggregate them into our SQLite database. 46 | 47 | > `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 48 | > the features we'll be using in this tutorial. 49 | 50 | ## Step 1. Starting with our Actix Web app 51 | 52 | Start by cloning the repo and installing packages. 53 | 54 | ```bash 55 | git clone https://github.com/fatfingers23/rusty_statusphere_example_app.git 56 | cd rusty_statusphere_example_app 57 | cp .env.template .env 58 | cargo run 59 | # Navigate to http://127.0.0.1:8080 60 | ``` 61 | 62 | 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 63 | we're managing with [async-sqlite](https://crates.io/crates/async-sqlite). 64 | 65 | Our starting stack: 66 | 67 | - [Rust](https://www.rust-lang.org/tools/install) 68 | - Rust web server ([Actix Web](https://actix.rs/)) 69 | - SQLite database ([async-sqlite](https://crates.io/crates/async-sqlite)) 70 | - HTML Templating ([askama](https://crates.io/crates/askama)) 71 | 72 | > [!NOTE] 73 | > Along with the above, we are also using a couple of community maintained projects for using rust with the ATProtocol. 74 | > Since these are community maintained I have also linked sponsor links for the maintainers and _highly_ recommend you to 75 | > think 76 | > about sponsoring them. 77 | > Thanks to their work and projects, we are able to create Rust applications in the Atmosphere. 78 | > - ATProtocol client and OAuth 79 | with [atrium](https://github.com/atrium-rs/atrium) - [sponsor sugyan](https://github.com/sponsors/sugyan) 80 | > - Jetstream consumer 81 | with [rocketman](https://crates.io/crates/rocketman)- [buy natalie a coffee](https://ko-fi.com/uxieq) 82 | 83 | With each step we'll explain how our Web app taps into the Atmosphere. Refer to the codebase for more detailed code 84 | — again, this tutorial is going to keep it light and quick to digest. 85 | 86 | ## Step 2. Signing in with OAuth 87 | 88 | When somebody logs into our app, they'll give us read & write access to their personal `at://` repo. We'll use that to 89 | write the status json record. 90 | 91 | We're going to accomplish this using OAuth ([spec](https://github.com/bluesky-social/proposals/tree/main/0004-oauth)). 92 | Most of the OAuth flows are going to be handled for us using 93 | the [atrium-oauth](https://crates.io/crates/atrium-oauth) 94 | crate. This is the arrangement we're aiming toward: 95 | 96 | ![A diagram of the OAuth elements](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdiagram-oauth.5ebec062.png&w=750&q=75) 97 | 98 | When the user logs in, the OAuth client will create a new session with their repo server and give us read/write access 99 | along with basic user info. 100 | 101 | ![A screenshot of the login UI](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fapp-login.83cd693f.png&w=640&q=75) 102 | 103 | Our login page just asks the user for their "handle," which is the domain name associated with their account. 104 | For [Bluesky](https://bsky.app) users, these tend to look like `alice.bsky.social`, but they can be any kind of domain ( 105 | eg `alice.com`). 106 | 107 | ```html 108 | 109 | 118 | ``` 119 | 120 | When they submit the form, we tell our OAuth client to initiate the authorization flow and then redirect the user to 121 | their server to complete the process. 122 | 123 | ```rust 124 | /** ./src/main.rs **/ 125 | /// Login endpoint 126 | #[post("/login")] 127 | async fn login_post( 128 | request: HttpRequest, 129 | params: web::Form, 130 | oauth_client: web::Data, 131 | ) -> HttpResponse { 132 | // This will act the same as the js method isValidHandle 133 | match atrium_api::types::string::Handle::new(params.handle.clone()) { 134 | Ok(handle) => { 135 | // Initiates the OAuth flow 136 | let oauth_url = oauth_client 137 | .authorize( 138 | &handle, 139 | AuthorizeOptions { 140 | scopes: vec![ 141 | Scope::Known(KnownScope::Atproto), 142 | Scope::Known(KnownScope::TransitionGeneric), 143 | ], 144 | ..Default::default() 145 | }, 146 | ) 147 | .await; 148 | match oauth_url { 149 | Ok(url) => Redirect::to(url) 150 | .see_other() 151 | .respond_to(&request) 152 | .map_into_boxed_body(), 153 | Err(err) => { 154 | log::error!("Error: {err}"); 155 | let html = LoginTemplate { 156 | title: "Log in", 157 | error: Some("OAuth error"), 158 | }; 159 | HttpResponse::Ok().body(html.render().expect("template should be valid")) 160 | } 161 | } 162 | } 163 | Err(err) => { 164 | let html: LoginTemplate<'_> = LoginTemplate { 165 | title: "Log in", 166 | error: Some(err), 167 | }; 168 | HttpResponse::Ok().body(html.render().expect("template should be valid")) 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | This is the same kind of SSO flow that Google or GitHub uses. The user will be asked for their password, then asked to 175 | confirm the session with your application. 176 | 177 | When that finishes, the user will be sent back to `/oauth/callback` on our Web app. The OAuth client will store the 178 | access tokens for the user's server, and then we attach their account's [DID](https://atproto.com/specs/did) to the 179 | cookie-session. 180 | 181 | ```rust 182 | /** ./src/main.rs **/ 183 | /// OAuth callback endpoint to complete session creation 184 | #[get("/oauth/callback")] 185 | async fn oauth_callback( 186 | request: HttpRequest, 187 | params: web::Query, 188 | oauth_client: web::Data, 189 | session: Session, 190 | ) -> HttpResponse { 191 | // Store the credentials 192 | match oauth_client.callback(params.into_inner()).await { 193 | Ok((bsky_session, _)) => { 194 | let agent = Agent::new(bsky_session); 195 | match agent.did().await { 196 | Some(did) => { 197 | //Attach the account DID to our user via a cookie 198 | session.insert("did", did).unwrap(); 199 | Redirect::to("/") 200 | .see_other() 201 | .respond_to(&request) 202 | .map_into_boxed_body() 203 | } 204 | None => { 205 | let html = ErrorTemplate { 206 | title: "Log in", 207 | error: "The OAuth agent did not return a DID. My try relogging in.", 208 | }; 209 | HttpResponse::Ok().body(html.render().expect("template should be valid")) 210 | } 211 | } 212 | } 213 | Err(err) => { 214 | log::error!("Error: {err}"); 215 | let html = ErrorTemplate { 216 | title: "Log in", 217 | error: "OAuth error, check the logs", 218 | }; 219 | HttpResponse::Ok().body(html.render().expect("template should be valid")) 220 | } 221 | } 222 | } 223 | ``` 224 | 225 | 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. 226 | 227 | ## Step 3. Fetching the user's profile 228 | 229 | Why don't we learn something about our user? In [Bluesky](https://bsky.app), users publish a "profile" record which 230 | looks like this: 231 | 232 | ```rust 233 | pub struct ProfileViewDetailedData { 234 | pub display_name: Option, // a human friendly name 235 | pub description: Option, // a short bio 236 | pub avatar: Option, // small profile picture 237 | pub banner: Option, // banner image to put on profiles 238 | pub created_at: Option // declared time this profile data was added 239 | // ... 240 | } 241 | ``` 242 | 243 | You can examine this record directly using [atproto-browser.vercel.app](https://atproto-browser.vercel.app). For 244 | 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). 245 | 246 | > [!NOTE] 247 | > In the original tutorial `agent.com.atproto.repo.getRecord` is used, which is 248 | > this [method](https://docs.rs/atrium-api/latest/atrium_api/com/atproto/repo/get_record/index.html) in atrium-api. 249 | > For simplicity we are 250 | > using [agent.api.app.bsky.actor.get_profile](https://docs.rs/atrium-api/latest/atrium_api/app/bsky/actor/get_profile/index.html). 251 | > The original text found here has been moved to [Step 4. Reading & writing records](#step-4-reading--writing-records) 252 | > since it makes more sense in that context. 253 | 254 | We're going to use the [Agent](https://crates.io/crates/atrium-oauth) associated with the 255 | user's OAuth session to fetch this record. 256 | 257 | Let's update our homepage to fetch this profile record: 258 | 259 | ```rust 260 | /** ./src/main.rs **/ 261 | /// Homepage 262 | #[get("/")] 263 | async fn home( 264 | _req: HttpRequest, 265 | session: Session, 266 | oauth_client: web::Data, 267 | db_pool: web::Data, 268 | handle_resolver: web::Data, 269 | ) -> Result { 270 | const TITLE: &str = "Home"; 271 | 272 | // If the user is signed in, get an agent which communicates with their server 273 | match session.get::("did").unwrap_or(None) { 274 | Some(did) => { 275 | let did = Did::new(did).expect("failed to parse did"); 276 | match oauth_client.restore(&did).await { 277 | Ok(session) => { 278 | let agent = Agent::new(session); 279 | 280 | // Fetch additional information about the logged-in user 281 | let profile = agent 282 | .api 283 | .app 284 | .bsky 285 | .actor 286 | .get_profile( 287 | atrium_api::app::bsky::actor::get_profile::ParametersData { 288 | actor: atrium_api::types::string::AtIdentifier::Did(did), 289 | }.into(), 290 | ) 291 | .await; 292 | 293 | // Serve the logged-in view 294 | let html = HomeTemplate { 295 | title: TITLE, 296 | status_options: &STATUS_OPTIONS, 297 | profile: match profile { 298 | Ok(profile) => { 299 | let profile_data = Profile { 300 | did: profile.did.to_string(), 301 | display_name: profile.display_name.clone(), 302 | }; 303 | Some(profile_data) 304 | } 305 | Err(err) => { 306 | log::error!("Error accessing profile: {err}"); 307 | None 308 | } 309 | }, 310 | }.render().expect("template should be valid"); 311 | 312 | Ok(web::Html::new(html)) 313 | } 314 | Err(err) => { 315 | //Unset the session 316 | session.remove("did"); 317 | log::error!("Error restoring session: {err}"); 318 | let error_html = ErrorTemplate { 319 | title: TITLE, 320 | error: "Was an error resuming the session, please check the logs.", 321 | }.render().expect("template should be valid"); 322 | 323 | Ok(web::Html::new(error_html)) 324 | } 325 | } 326 | } 327 | None => { 328 | // Serve the logged-out view 329 | let html = HomeTemplate { 330 | title: TITLE, 331 | status_options: &STATUS_OPTIONS, 332 | profile: None, 333 | }.render().expect("template should be valid"); 334 | 335 | Ok(web::Html::new(html)) 336 | } 337 | } 338 | } 339 | ``` 340 | 341 | With that data, we can give a nice personalized welcome banner for our user: 342 | 343 | ![A screenshot of the banner image](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fapp-banner.1e92c654.png&w=640&q=75) 344 | 345 | ```html 346 | 347 |
348 | {% if let Some(Profile {did, display_name}) = profile %} 349 |
350 |
351 | Hi, 352 | {% if let Some(display_name) = display_name %} 353 | {{display_name}} 354 | {% else %} 355 | friend 356 | {% endif %}. 357 | What's your status today?? 358 |
359 |
360 | 361 |
362 |
363 | {% else %} 364 |
365 |
Log in to set your status!
366 |
367 | Log in 368 |
369 |
370 | {% endif %} 371 |
372 | ``` 373 | 374 | ## Step 4. Reading & writing records 375 | 376 | You can think of the user repositories as collections of JSON records: 377 | 378 | !["A diagram of a repository"](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdiagram-repo.4a34005b.png&w=750&q=75) 379 | 380 | When asking for a record, we provide three pieces of information. 381 | 382 | - **repo** The [DID](https://atproto.com/specs/did) which identifies the user, 383 | - **collection** The collection name, and 384 | - **rkey** The record key 385 | 386 | We'll explain the collection name shortly. Record keys are strings 387 | with [some restrictions](https://atproto.com/specs/record-key#record-key-syntax) and a couple of common patterns. The 388 | `"self"` pattern is used when a collection is expected to only contain one record which describes the user. 389 | 390 | Let's look again at how we read the "profile" record: 391 | 392 | ```rust 393 | fn example_get_record() { 394 | let get_result = agent 395 | .api 396 | .com 397 | .atproto 398 | .repo 399 | .get_record( 400 | atrium_api::com::atproto::repo::get_record::ParametersData { 401 | cid: None, 402 | collection: "app.bsky.actor.profile" // The collection 403 | .parse() 404 | .unwrap(), 405 | repo: did.into(), // The user 406 | rkey: "self".parse().unwrap(), // The record key 407 | } 408 | .into(), 409 | ) 410 | .await; 411 | } 412 | 413 | ``` 414 | 415 | We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen: 416 | 417 | ```rust 418 | fn example_create_record() { 419 | let did = atrium_api::types::string::Did::new(did_string.clone()).unwrap(); 420 | let agent = Agent::new(session); 421 | 422 | let status: Unknown = serde_json::from_str( 423 | format!( 424 | r#"{{"$type":"xyz.statusphere.status","status":"{}","createdAt":"{}"}}"#, 425 | form.status, 426 | Datetime::now().as_str() 427 | ) 428 | .as_str(), 429 | ).unwrap(); 430 | 431 | let create_result = agent 432 | .api 433 | .com 434 | .atproto 435 | .repo 436 | .create_record( 437 | atrium_api::com::atproto::repo::create_record::InputData { 438 | collection: Status::NSID.parse().unwrap(), // The collection 439 | repo: did.clone().into(), // The user 440 | rkey: None, // The record key, auto creates with None 441 | record: status, // The record from a strong type 442 | swap_commit: None, 443 | validate: None, 444 | } 445 | .into(), 446 | ) 447 | .await; 448 | } 449 | ``` 450 | 451 | Our `POST /status` route is going to use this API to publish the user's status to their repo. 452 | 453 | ```rust 454 | /// "Set status" Endpoint 455 | #[post("/status")] 456 | async fn status( 457 | request: HttpRequest, 458 | session: Session, 459 | oauth_client: web::Data, 460 | db_pool: web::Data, 461 | form: web::Form, 462 | ) -> HttpResponse { 463 | const TITLE: &str = "Home"; 464 | 465 | // If the user is signed in, get an agent which communicates with their server 466 | match session.get::("did").unwrap_or(None) { 467 | Some(did_string) => { 468 | let did = atrium_api::types::string::Did::new(did_string.clone()) 469 | .expect("failed to parse did"); 470 | match oauth_client.restore(&did).await { 471 | Ok(session) => { 472 | let agent = Agent::new(session); 473 | 474 | // Construct their status record 475 | let status: Unknown = serde_json::from_str( 476 | format!( 477 | r#"{{"$type":"xyz.statusphere.status","status":"{}","createdAt":"{}"}}"#, 478 | form.status, 479 | Datetime::now().as_str() 480 | ) 481 | .as_str(), 482 | ).unwrap(); 483 | 484 | // Write the status record to the user's repository 485 | let create_result = agent 486 | .api 487 | .com 488 | .atproto 489 | .repo 490 | .create_record( 491 | atrium_api::com::atproto::repo::create_record::InputData { 492 | collection: "xyz.statusphere.status".parse().unwrap(), 493 | repo: did.clone().into(), 494 | rkey: None, 495 | record: status, 496 | swap_commit: None, 497 | validate: None, 498 | } 499 | .into(), 500 | ) 501 | .await; 502 | 503 | match create_result { 504 | Ok(_) => Redirect::to("/") 505 | .see_other() 506 | .respond_to(&request) 507 | .map_into_boxed_body(), 508 | Err(err) => { 509 | log::error!("Error creating status: {err}"); 510 | let error_html = ErrorTemplate { 511 | title: TITLE, 512 | error: "Was an error creating the status, please check the logs.", 513 | } 514 | .render() 515 | .expect("template should be valid"); 516 | HttpResponse::Ok().body(error_html) 517 | } 518 | } 519 | } 520 | Err(err) => { 521 | //Unset the session 522 | session.remove("did"); 523 | log::error!( 524 | "Error restoring session, we are removing the session from the cookie: {err}" 525 | ); 526 | let error_html = ErrorTemplate { 527 | title: TITLE, 528 | error: "Was an error resuming the session, please check the logs.", 529 | } 530 | .render() 531 | .expect("template should be valid"); 532 | HttpResponse::Ok().body(error_html) 533 | } 534 | } 535 | } 536 | None => { 537 | let error_template = ErrorTemplate { 538 | title: "Error", 539 | error: "You must be logged in to create a status.", 540 | } 541 | .render() 542 | .expect("template should be valid"); 543 | HttpResponse::Ok().body(error_template) 544 | } 545 | } 546 | } 547 | ``` 548 | 549 | Now in our homepage we can list out the status buttons: 550 | 551 | ```html 552 | 553 |
554 | {% for status in status_options %} 555 | 560 | {% endfor %} 561 |
562 | ``` 563 | 564 | And here we are! 565 | 566 | ![A screenshot of the app's status options"](./images/emojis.png) 567 | 568 | ## Step 5. Creating a custom "status" schema 569 | 570 | Repo collections are typed, meaning that they have a defined schema. The `app.bsky.actor.profile` type 571 | definition [can be found here](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/actor/profile.json). 572 | 573 | Anybody can create a new schema using the [Lexicon](https://atproto.com/specs/lexicon) language, which is very similar 574 | to [JSON-Schema](http://json-schema.org/). The schemas use [reverse-DNS IDs](https://atproto.com/specs/nsid) which 575 | indicate ownership. In this demo app we're going to use `xyz.statusphere` which we registered specifically for this 576 | project (aka statusphere.xyz). 577 | 578 | > ### Why create a schema? 579 | > 580 | > Schemas help other applications understand the data your app is creating. By publishing your schemas, you make it 581 | > easier for other application authors to publish data in a format your app will recognize and handle. 582 | 583 | Let's create our schema in the `/lexicons` folder of our codebase. You 584 | can [read more about how to define schemas here](https://atproto.com/guides/lexicon). 585 | 586 | ```json 587 | /** lexicons/status.json **/ 588 | { 589 | "lexicon": 1, 590 | "id": "xyz.statusphere.status", 591 | "defs": { 592 | "main": { 593 | "type": "record", 594 | "key": "tid", 595 | "record": { 596 | "type": "object", 597 | "required": [ 598 | "status", 599 | "createdAt" 600 | ], 601 | "properties": { 602 | "status": { 603 | "type": "string", 604 | "minLength": 1, 605 | "maxGraphemes": 1, 606 | "maxLength": 32 607 | }, 608 | "createdAt": { 609 | "type": "string", 610 | "format": "datetime" 611 | } 612 | } 613 | } 614 | } 615 | } 616 | } 617 | ``` 618 | 619 | Now let's run some code-generation using our schema: 620 | 621 | > [!NOTE] 622 | > For generating schemas, we are going to 623 | > use [esquema-cli](https://github.com/fatfingers23/esquema?tab=readme-ov-file) 624 | > (Which is a tool I've created from a fork of atrium's codegen). 625 | > This can be installed by running this command 626 | `cargo install esquema-cli --git https://github.com/fatfingers23/esquema.git` 627 | > This is a WIP tool with bugs and missing features. But it's good enough for us to generate Rust types from the lexicon 628 | > schema. 629 | 630 | ```bash 631 | esquema-cli generate local -l ./lexicons/ -o ./src/ --module lexicons 632 | ``` 633 | 634 | 635 | 636 | This will produce Rust structs. Here's what that generated code looks like: 637 | 638 | ```rust 639 | /** ./src/lexicons/xyz/statusphere/status.rs **/ 640 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 641 | //!Definitions for the `xyz.statusphere.status` namespace. 642 | use atrium_api::types::TryFromUnknown; 643 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 644 | #[serde(rename_all = "camelCase")] 645 | pub struct RecordData { 646 | pub created_at: atrium_api::types::string::Datetime, 647 | pub status: String, 648 | } 649 | pub type Record = atrium_api::types::Object; 650 | impl From for RecordData { 651 | fn from(value: atrium_api::types::Unknown) -> Self { 652 | Self::try_from_unknown(value).unwrap() 653 | } 654 | } 655 | 656 | ``` 657 | 658 | > [!NOTE] 659 | > You may have noticed we do not cover the validation part like in the TypeScript version. 660 | > Esquema can validate to a point such as the data structure and if a field is there or not. 661 | > But validation of the data itself is not possible, yet. 662 | > There are plans to add it. 663 | > Maybe you would like to add it? 664 | > https://github.com/fatfingers23/esquema/issues/3 665 | 666 | Let's use that code to improve the `POST /status` route: 667 | 668 | ```rust 669 | /// "Set status" Endpoint 670 | #[post("/status")] 671 | async fn status( 672 | request: HttpRequest, 673 | session: Session, 674 | oauth_client: web::Data, 675 | db_pool: web::Data, 676 | form: web::Form, 677 | ) -> HttpResponse { 678 | // ... 679 | let agent = Agent::new(session); 680 | //We use the new status type we generated with esquema 681 | let status: KnownRecord = lexicons::xyz::statusphere::status::RecordData { 682 | created_at: Datetime::now(), 683 | status: form.status.clone(), 684 | } 685 | .into(); 686 | 687 | // TODO no validation yet from esquema 688 | // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3 689 | 690 | let create_result = agent 691 | .api 692 | .com 693 | .atproto 694 | .repo 695 | .create_record( 696 | atrium_api::com::atproto::repo::create_record::InputData { 697 | collection: Status::NSID.parse().unwrap(), 698 | repo: did.into(), 699 | rkey: None, 700 | record: status.into(), 701 | swap_commit: None, 702 | validate: None, 703 | } 704 | .into(), 705 | ) 706 | .await; 707 | // ... 708 | } 709 | ``` 710 | > [!NOTE] 711 | > You will notice the first example used a string to serialize to Unknown, you could do something similar with 712 | > a struct you create, then serialize.But I created esquema to make that easier. 713 | > With esquema you can use other provided lexicons 714 | > or ones you create to build out the data structure for your ATProtocol application. 715 | > As well as in future updates it will honor the 716 | > validation you have in the Lexicon. 717 | > Things like string should be 10 long, etc. 718 | 719 | ## Step 6. Listening to the firehose 720 | 721 | > [!IMPORTANT] 722 | > It is important to note that the original tutorial they connect directly to the firehose, but in this one we use 723 | > [rocketman](https://crates.io/crates/rocketman) to connect to the Jetstream instead. 724 | > For most use cases this is fine and usually easier when using other clients than the Bluesky provided ones. 725 | > But it is important to note there are some differences that can 726 | > be found in their introduction to Jetstream article. 727 | > https://docs.bsky.app/blog/jetstream#tradeoffs-and-use-cases 728 | 729 | So far, we have: 730 | 731 | - Logged in via OAuth 732 | - Created a custom schema 733 | - Read & written records for the logged in user 734 | 735 | Now we want to fetch the status records from other users. 736 | 737 | Remember how we referred to our app as being like Google, crawling around the repos to get their records? One advantage 738 | we have in the AT Protocol is that each repo publishes an event log of their updates. 739 | 740 | ![A diagram of the event stream"](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdiagram-event-stream.aa119d83.png&w=1080&q=75) 741 | 742 | Using a [~~Relay~~ Jetstream service](https://docs.bsky.app/blog/jetstream) we can listen to an 743 | aggregated firehose of these events across all users in the network. In our case what we're looking for are valid 744 | `xyz.statusphere.status` records. 745 | 746 | ```rust 747 | /** ./src/ingester.rs **/ 748 | #[async_trait] 749 | impl LexiconIngestor for StatusSphereIngester { 750 | async fn ingest(&self, message: Event) -> anyhow::Result<()> { 751 | if let Some(commit) = &message.commit { 752 | //We manually construct the uri since jetstream does not provide it 753 | //at://{users did}/{collection: xyz.statusphere.status}{records key} 754 | let record_uri = format!("at://{}/{}/{}", message.did, commit.collection, commit.rkey); 755 | match commit.operation { 756 | Operation::Create | Operation::Update => { 757 | if let Some(record) = &commit.record { 758 | //We deserialize the record into our Rust struct 759 | let status_at_proto_record = serde_json::from_value::< 760 | lexicons::xyz::statusphere::status::RecordData, 761 | >(record.clone())?; 762 | 763 | if let Some(ref _cid) = commit.cid { 764 | // Although esquema does not have full validation yet, 765 | // if you get to this point, 766 | // You know the data structure is the same 767 | 768 | // Store the status 769 | // TODO 770 | } 771 | } 772 | } 773 | Operation::Delete => {}, 774 | } 775 | } else { 776 | return Err(anyhow!("Message has no commit")); 777 | } 778 | Ok(()) 779 | } 780 | } 781 | ``` 782 | 783 | Let's create a SQLite table to store these statuses: 784 | 785 | ```rust 786 | /** ./src/db.rs **/ 787 | // Create our statuses table 788 | pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { 789 | pool.conn(move |conn| { 790 | conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); 791 | 792 | // status 793 | conn.execute( 794 | "CREATE TABLE IF NOT EXISTS status ( 795 | uri TEXT PRIMARY KEY, 796 | authorDid TEXT NOT NULL, 797 | status TEXT NOT NULL, 798 | createdAt INTEGER NOT NULL, 799 | indexedAt INTEGER NOT NULL 800 | )", 801 | [], 802 | ) 803 | .unwrap(); 804 | 805 | // ... 806 | ``` 807 | 808 | Now we can write these statuses into our database as they arrive from the firehose: 809 | 810 | ```rust 811 | /** ./src/ingester.rs **/ 812 | // If the write is a valid status update 813 | if let Some(record) = &commit.record { 814 | let status_at_proto_record = serde_json::from_value::< 815 | lexicons::xyz::statusphere::status::RecordData, 816 | >(record.clone())?; 817 | 818 | if let Some(ref _cid) = commit.cid { 819 | // Although esquema does not have full validation yet, 820 | // if you get to this point, 821 | // You know the data structure is the same 822 | let created = status_at_proto_record.created_at.as_ref(); 823 | let right_now = chrono::Utc::now(); 824 | // We save or update the record in the db 825 | StatusFromDb { 826 | uri: record_uri, 827 | author_did: message.did.clone(), 828 | status: status_at_proto_record.status.clone(), 829 | created_at: created.to_utc(), 830 | indexed_at: right_now, 831 | handle: None, 832 | } 833 | .save_or_update(&self.db_pool) 834 | .await?; 835 | } 836 | } 837 | ``` 838 | 839 | You can almost think of information flowing in a loop: 840 | 841 | ![A diagram of the flow of information](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdiagram-info-flow.ccf81d0b.png&w=1080&q=75) 842 | 843 | Applications write to the repo. The write events are then emitted on the firehose where they're caught by the apps and 844 | ingested into their databases. 845 | 846 | Why sync from the event log like this? Because there are other apps in the network that will write the records we're 847 | interested in. By subscribing to the event log (via the Jetstream), we ensure that we catch all the data we're interested in — 848 | including data published by other apps! 849 | 850 | ## Step 7. Listing the latest statuses 851 | 852 | Now that we have statuses populating our SQLite, we can produce a timeline of status updates by users. We also use 853 | a [DID](https://atproto.com/specs/did)-to-handle resolver so we can show a nice username with the statuses: 854 | ```rust 855 | /** ./src/main.rs **/ 856 | // Homepage 857 | /// Home 858 | #[get("/")] 859 | async fn home( 860 | session: Session, 861 | oauth_client: web::Data, 862 | db_pool: web::Data>, 863 | handle_resolver: web::Data, 864 | ) -> Result { 865 | const TITLE: &str = "Home"; 866 | // Fetch data stored in our SQLite 867 | let mut statuses = StatusFromDb::load_latest_statuses(&db_pool) 868 | .await 869 | .unwrap_or_else(|err| { 870 | log::error!("Error loading statuses: {err}"); 871 | vec![] 872 | }); 873 | 874 | // We resolve the handles to the DID. This is a bit messy atm, 875 | // and there are hopes to find a cleaner way 876 | // to handle resolving the DIDs and formating the handles, 877 | // But it gets the job done for the purpose of this tutorial. 878 | // PRs are welcomed! 879 | 880 | //Simple way to cut down on resolve calls if we already know the handle for the did 881 | let mut quick_resolve_map: HashMap = HashMap::new(); 882 | for db_status in &mut statuses { 883 | let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 884 | //Check to see if we already resolved it to cut down on resolve requests 885 | match quick_resolve_map.get(&authors_did) { 886 | None => {} 887 | Some(found_handle) => { 888 | db_status.handle = Some(found_handle.clone()); 889 | continue; 890 | } 891 | } 892 | //Attempts to resolve the DID to a handle 893 | db_status.handle = match handle_resolver.resolve(&authors_did).await { 894 | Ok(did_doc) => { 895 | match did_doc.also_known_as { 896 | None => None, 897 | Some(also_known_as) => { 898 | match also_known_as.is_empty() { 899 | true => None, 900 | false => { 901 | //also_known as a list starts the array with the highest priority handle 902 | let formatted_handle = 903 | format!("@{}", also_known_as[0]).replace("at://", ""); 904 | quick_resolve_map.insert(authors_did, formatted_handle.clone()); 905 | Some(formatted_handle) 906 | } 907 | } 908 | } 909 | } 910 | } 911 | Err(err) => { 912 | log::error!("Error resolving did: {err}"); 913 | None 914 | } 915 | }; 916 | } 917 | // ... 918 | ``` 919 | >[!NOTE] 920 | > We use a newly released handle resolver from atrium. 921 | > Can see 922 | > how it is set up in [./src/main.rs](https://github.com/fatfingers23/rusty_statusphere_example_app/blob/a13ab7eb8fcba901a483468f7fd7c56b2948972d/src/main.rs#L508) 923 | 924 | 925 | Our HTML can now list these status records: 926 | 927 | ```html 928 | 929 | {% for status in statuses %} 930 |
931 |
932 |
{{status.status}}
933 |
934 |
935 | {{status.author_display_name()}} 937 | {% if status.is_today() %} 938 | is feeling {{status.status}} today 939 | {% else %} 940 | was feeling {{status.status}} on {{status.created_at}} 941 | {% endif %} 942 |
943 |
944 | {% endfor %} 945 | ` 946 | })} 947 | ``` 948 | 949 | ![A screenshot of the app status timeline](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fapp-status-history.25e5d14a.png&w=640&q=75) 950 | 951 | ## Step 8. Optimistic updates 952 | 953 | As a final optimization, let's introduce "optimistic updates." 954 | 955 | Remember the information flow loop with the repo write and the event log? 956 | 957 | !["A diagram of the flow of information"](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdiagram-info-flow.ccf81d0b.png&w=1080&q=75) 958 | 959 | Since we're updating our users' repos locally, we can short-circuit that flow to our own database: 960 | 961 | ![A diagram illustrating optimistic updates](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdiagram-optimistic-update.ca3f4cf1.png&w=1080&q=75) 962 | 963 | This is an important optimization to make, because it ensures that the user sees their own changes while using your app. 964 | When the event eventually arrives from the firehose, we just discard it since we already have it saved locally. 965 | 966 | To do this, we just update `POST /status` to include an additional write to our SQLite DB: 967 | 968 | ```rust 969 | /** ./src/main.rs **/ 970 | /// Creates a new status 971 | #[post("/status")] 972 | async fn status( 973 | request: HttpRequest, 974 | session: Session, 975 | oauth_client: web::Data, 976 | db_pool: web::Data>, 977 | form: web::Form, 978 | ) -> HttpResponse { 979 | //... 980 | let create_result = agent 981 | .api 982 | .com 983 | .atproto 984 | .repo 985 | .create_record( 986 | atrium_api::com::atproto::repo::create_record::InputData { 987 | collection: Status::NSID.parse().unwrap(), 988 | repo: did.into(), 989 | rkey: None, 990 | record: status.into(), 991 | swap_commit: None, 992 | validate: None, 993 | } 994 | .into(), 995 | ) 996 | .await; 997 | 998 | match create_result { 999 | Ok(record) => { 1000 | let status = StatusFromDb::new( 1001 | record.uri.clone(), 1002 | did_string, 1003 | form.status.clone(), 1004 | ); 1005 | 1006 | let _ = status.save(db_pool).await; 1007 | Redirect::to("/") 1008 | .see_other() 1009 | .respond_to(&request) 1010 | .map_into_boxed_body() 1011 | } 1012 | Err(err) => { 1013 | log::error!("Error creating status: {err}"); 1014 | let error_html = ErrorTemplate { 1015 | title: "Error", 1016 | error: "Was an error creating the status, please check the logs.", 1017 | } 1018 | .render() 1019 | .expect("template should be valid"); 1020 | HttpResponse::Ok().body(error_html) 1021 | } 1022 | } 1023 | //... 1024 | } 1025 | ``` 1026 | 1027 | You'll notice this code looks almost exactly like what we're doing in `ingester.rs`. 1028 | 1029 | ## Thinking in AT Proto 1030 | 1031 | In this tutorial we've covered the key steps to building an atproto app. Data is published in its canonical form on 1032 | users' `at://` repos and then aggregated into apps' databases to produce views of the network. 1033 | 1034 | When building your app, think in these four key steps: 1035 | 1036 | - Design the [Lexicon](#) schemas for the records you'll publish into the Atmosphere. 1037 | - Create a database for aggregating the records into useful views. 1038 | - Build your application to write the records on your users' repos. 1039 | - Listen to the firehose to aggregate data across the network. 1040 | 1041 | Remember this flow of information throughout: 1042 | 1043 | ![A diagram of the flow of information](https://atproto.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdiagram-info-flow.ccf81d0b.png&w=1080&q=75) 1044 | 1045 | This is how every app in the Atmosphere works, including the [Bluesky social app](https://bsky.app). 1046 | 1047 | ## Next steps 1048 | 1049 | If you want to practice what you've learned, here are some additional challenges you could try: 1050 | 1051 | - Sync the profile records of all users so that you can show their display names instead of their handles. 1052 | - Count the number of each status used and display the total counts. 1053 | - Fetch the authed user's `app.bsky.graph.follow` follows and show statuses from them. 1054 | - Create a different kind of schema, like a way to post links to websites and rate them 1 through 4 stars. 1055 | 1056 | [Ready to learn more? Specs, guides, and SDKs can be found here.](https://atproto.com/) 1057 | 1058 | >[!NOTE] 1059 | > Thank you for checking out my version of the Statusphere example project! 1060 | > There are parts of this I feel can be improved on and made more efficient, 1061 | > but I think it does a good job for providing you with a starting point to start building Rust applications in the Atmosphere. 1062 | > See something you think could be done better? Then please submit a PR! 1063 | > [@baileytownsend.dev](https://bsky.app/profile/baileytownsend.dev) --------------------------------------------------------------------------------