slack status without the slack status.zzstoatzz.io/
quickslice

push the fucking code

+1752 -1691
+217
src/api/auth.rs
··· 1 + use crate::resolver::HickoryDnsTxtResolver; 2 + use crate::{ 3 + config, 4 + storage::{SqliteSessionStore, SqliteStateStore}, 5 + templates::{ErrorTemplate, LoginTemplate}, 6 + }; 7 + use actix_session::Session; 8 + use actix_web::{ 9 + HttpRequest, HttpResponse, Responder, Result, get, post, 10 + web::{self, Redirect}, 11 + }; 12 + use askama::Template; 13 + use atrium_api::agent::Agent; 14 + use atrium_identity::{did::CommonDidResolver, handle::AtprotoHandleResolver}; 15 + use atrium_oauth::{ 16 + AuthorizeOptions, CallbackParams, DefaultHttpClient, KnownScope, OAuthClient, Scope, 17 + }; 18 + use serde::{Deserialize, Serialize}; 19 + use std::sync::Arc; 20 + 21 + #[derive(Deserialize)] 22 + pub struct OAuthCallbackParams { 23 + pub state: Option<String>, 24 + pub iss: Option<String>, 25 + pub code: Option<String>, 26 + pub error: Option<String>, 27 + pub error_description: Option<String>, 28 + } 29 + 30 + pub type OAuthClientType = Arc< 31 + OAuthClient< 32 + SqliteStateStore, 33 + SqliteSessionStore, 34 + CommonDidResolver<DefaultHttpClient>, 35 + AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>, 36 + >, 37 + >; 38 + 39 + /// OAuth client metadata endpoint for production 40 + #[get("/oauth-client-metadata.json")] 41 + pub async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> { 42 + let public_url = config.oauth_redirect_base.clone(); 43 + 44 + let metadata = serde_json::json!({ 45 + "client_id": format!("{}/oauth-client-metadata.json", public_url), 46 + "client_name": "Status Sphere", 47 + "client_uri": public_url.clone(), 48 + "redirect_uris": [format!("{}/oauth/callback", public_url)], 49 + "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app", 50 + "grant_types": ["authorization_code", "refresh_token"], 51 + "response_types": ["code"], 52 + "token_endpoint_auth_method": "none", 53 + "dpop_bound_access_tokens": true 54 + }); 55 + 56 + Ok(HttpResponse::Ok() 57 + .content_type("application/json") 58 + .body(metadata.to_string())) 59 + } 60 + 61 + /// OAuth callback endpoint to complete session creation 62 + #[get("/oauth/callback")] 63 + pub async fn oauth_callback( 64 + request: HttpRequest, 65 + params: web::Query<OAuthCallbackParams>, 66 + oauth_client: web::Data<OAuthClientType>, 67 + session: Session, 68 + ) -> HttpResponse { 69 + // Check if there's an OAuth error from BlueSky 70 + if let Some(error) = &params.error { 71 + let error_msg = params 72 + .error_description 73 + .as_deref() 74 + .unwrap_or("An error occurred during authentication"); 75 + log::error!("OAuth error from BlueSky: {} - {}", error, error_msg); 76 + 77 + let html = ErrorTemplate { 78 + title: "Authentication Error", 79 + error: error_msg, 80 + }; 81 + return HttpResponse::BadRequest().body(html.render().expect("template should be valid")); 82 + } 83 + 84 + // Check if we have the required code field for a successful callback 85 + let code = match &params.code { 86 + Some(code) => code.clone(), 87 + None => { 88 + log::error!("OAuth callback missing required code parameter"); 89 + let html = ErrorTemplate { 90 + title: "Error", 91 + error: "Missing required OAuth code. Please try logging in again.", 92 + }; 93 + return HttpResponse::BadRequest() 94 + .body(html.render().expect("template should be valid")); 95 + } 96 + }; 97 + 98 + // Create CallbackParams for the OAuth client 99 + let callback_params = CallbackParams { 100 + code, 101 + state: params.state.clone(), 102 + iss: params.iss.clone(), 103 + }; 104 + 105 + //Processes the call back and parses out a session if found and valid 106 + match oauth_client.callback(callback_params).await { 107 + Ok((bsky_session, _)) => { 108 + let agent = Agent::new(bsky_session); 109 + match agent.did().await { 110 + Some(did) => { 111 + session.insert("did", did).unwrap(); 112 + Redirect::to("/") 113 + .see_other() 114 + .respond_to(&request) 115 + .map_into_boxed_body() 116 + } 117 + None => { 118 + let html = ErrorTemplate { 119 + title: "Error", 120 + error: "The OAuth agent did not return a DID. May try re-logging in.", 121 + }; 122 + HttpResponse::Ok().body(html.render().expect("template should be valid")) 123 + } 124 + } 125 + } 126 + Err(err) => { 127 + log::error!("Error: {err}"); 128 + let html = ErrorTemplate { 129 + title: "Error", 130 + error: "OAuth error, check the logs", 131 + }; 132 + HttpResponse::Ok().body(html.render().expect("template should be valid")) 133 + } 134 + } 135 + } 136 + 137 + /// Takes you to the login page 138 + #[get("/login")] 139 + pub async fn login() -> Result<impl Responder> { 140 + let html = LoginTemplate { 141 + title: "Log in", 142 + error: None, 143 + }; 144 + Ok(web::Html::new( 145 + html.render().expect("template should be valid"), 146 + )) 147 + } 148 + 149 + /// Logs you out by destroying your cookie on the server and web browser 150 + #[get("/logout")] 151 + pub async fn logout(request: HttpRequest, session: Session) -> HttpResponse { 152 + session.purge(); 153 + Redirect::to("/") 154 + .see_other() 155 + .respond_to(&request) 156 + .map_into_boxed_body() 157 + } 158 + 159 + /// The post body for logging in 160 + #[derive(Serialize, Deserialize, Clone)] 161 + pub struct LoginForm { 162 + pub handle: String, 163 + } 164 + 165 + /// Login endpoint 166 + #[post("/login")] 167 + pub async fn login_post( 168 + request: HttpRequest, 169 + params: web::Form<LoginForm>, 170 + oauth_client: web::Data<OAuthClientType>, 171 + ) -> HttpResponse { 172 + // This will act the same as the js method isValidHandle to make sure it is valid 173 + match atrium_api::types::string::Handle::new(params.handle.clone()) { 174 + Ok(handle) => { 175 + //Creates the oauth url to redirect to for the user to log in with their credentials 176 + let oauth_url = oauth_client 177 + .authorize( 178 + &handle, 179 + AuthorizeOptions { 180 + scopes: vec![ 181 + Scope::Known(KnownScope::Atproto), 182 + // Using granular scope for status records only 183 + // This replaces TransitionGeneric with specific permissions 184 + Scope::Unknown("repo:io.zzstoatzz.status.record".to_string()), 185 + // Need to read profiles for the feed page 186 + Scope::Unknown("rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview".to_string()), 187 + // Need to read following list for following feed 188 + Scope::Unknown("rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app".to_string()), 189 + ], 190 + ..Default::default() 191 + }, 192 + ) 193 + .await; 194 + match oauth_url { 195 + Ok(url) => Redirect::to(url) 196 + .see_other() 197 + .respond_to(&request) 198 + .map_into_boxed_body(), 199 + Err(err) => { 200 + log::error!("Error: {err}"); 201 + let html = LoginTemplate { 202 + title: "Log in", 203 + error: Some("OAuth error"), 204 + }; 205 + HttpResponse::Ok().body(html.render().expect("template should be valid")) 206 + } 207 + } 208 + } 209 + Err(err) => { 210 + let html: LoginTemplate<'_> = LoginTemplate { 211 + title: "Log in", 212 + error: Some(err), 213 + }; 214 + HttpResponse::Ok().body(html.render().expect("template should be valid")) 215 + } 216 + } 217 + }
+40
src/api/mod.rs
··· 1 + pub mod auth; 2 + pub mod preferences; 3 + pub mod status; 4 + 5 + pub use auth::OAuthClientType; 6 + pub use status::HandleResolver; 7 + 8 + use actix_web::web; 9 + 10 + /// Configure all API routes 11 + pub fn configure_routes(cfg: &mut web::ServiceConfig) { 12 + cfg 13 + // Auth routes 14 + .service(auth::client_metadata) 15 + .service(auth::oauth_callback) 16 + .service(auth::login) 17 + .service(auth::logout) 18 + .service(auth::login_post) 19 + // Status page routes 20 + .service(status::home) 21 + .service(status::user_status_page) 22 + .service(status::feed) 23 + // Status JSON API routes 24 + .service(status::owner_status_json) 25 + .service(status::user_status_json) 26 + .service(status::status_json) 27 + .service(status::api_feed) 28 + // Emoji API routes 29 + .service(status::get_frequent_emojis) 30 + .service(status::get_custom_emojis) 31 + .service(status::get_following) 32 + // Status management routes 33 + .service(status::status) 34 + .service(status::clear_status) 35 + .service(status::delete_status) 36 + .service(status::hide_status) 37 + // Preferences routes 38 + .service(preferences::get_preferences) 39 + .service(preferences::save_preferences); 40 + }
+75
src/api/preferences.rs
··· 1 + use crate::{db, error_handler::AppError}; 2 + use actix_session::Session; 3 + use actix_web::{Responder, Result, get, post, web}; 4 + use async_sqlite::Pool; 5 + use atrium_api::types::string::Did; 6 + use serde::Deserialize; 7 + use std::sync::Arc; 8 + 9 + #[derive(Deserialize)] 10 + pub struct PreferencesUpdate { 11 + pub font_family: Option<String>, 12 + pub accent_color: Option<String>, 13 + } 14 + 15 + /// Get user preferences 16 + #[get("/api/preferences")] 17 + pub async fn get_preferences( 18 + session: Session, 19 + db_pool: web::Data<Arc<Pool>>, 20 + ) -> Result<impl Responder> { 21 + let did = session.get::<Did>("did")?; 22 + 23 + if let Some(did) = did { 24 + let prefs = db::get_user_preferences(&db_pool, did.as_str()) 25 + .await 26 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 27 + Ok(web::Json(serde_json::json!({ 28 + "font_family": prefs.font_family, 29 + "accent_color": prefs.accent_color 30 + }))) 31 + } else { 32 + Ok(web::Json(serde_json::json!({ 33 + "error": "Not authenticated" 34 + }))) 35 + } 36 + } 37 + 38 + /// Save user preferences 39 + #[post("/api/preferences")] 40 + pub async fn save_preferences( 41 + session: Session, 42 + db_pool: web::Data<Arc<Pool>>, 43 + payload: web::Json<PreferencesUpdate>, 44 + ) -> Result<impl Responder> { 45 + let did = session.get::<Did>("did")?; 46 + 47 + if let Some(did) = did { 48 + let mut prefs = db::get_user_preferences(&db_pool, did.as_str()) 49 + .await 50 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 51 + 52 + if let Some(font) = &payload.font_family { 53 + prefs.font_family = font.clone(); 54 + } 55 + if let Some(color) = &payload.accent_color { 56 + prefs.accent_color = color.clone(); 57 + } 58 + prefs.updated_at = std::time::SystemTime::now() 59 + .duration_since(std::time::UNIX_EPOCH) 60 + .unwrap() 61 + .as_secs() as i64; 62 + 63 + db::save_user_preferences(&db_pool, &prefs) 64 + .await 65 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 66 + 67 + Ok(web::Json(serde_json::json!({ 68 + "success": true 69 + }))) 70 + } else { 71 + Ok(web::Json(serde_json::json!({ 72 + "error": "Not authenticated" 73 + }))) 74 + } 75 + }
+1217
src/api/status.rs
··· 1 + use crate::resolver::HickoryDnsTxtResolver; 2 + use crate::{ 3 + api::auth::OAuthClientType, 4 + config, 5 + db::{self, StatusFromDb}, 6 + dev_utils, 7 + error_handler::AppError, 8 + lexicons::record::KnownRecord, 9 + rate_limiter::RateLimiter, 10 + templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate}, 11 + }; 12 + use actix_session::Session; 13 + use actix_web::{ 14 + HttpRequest, HttpResponse, Responder, Result, get, post, 15 + web::{self, Redirect}, 16 + }; 17 + use askama::Template; 18 + use async_sqlite::{Pool, rusqlite}; 19 + use atrium_api::{ 20 + agent::Agent, 21 + types::string::{Datetime, Did}, 22 + }; 23 + use atrium_common::resolver::Resolver; 24 + use atrium_identity::{ 25 + did::CommonDidResolver, 26 + handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 27 + }; 28 + use atrium_oauth::DefaultHttpClient; 29 + use serde::{Deserialize, Serialize}; 30 + use std::{collections::HashMap, sync::Arc}; 31 + 32 + /// HandleResolver to make it easier to access the OAuthClient in web requests 33 + pub type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 34 + 35 + /// Admin DID for moderation 36 + const ADMIN_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 37 + 38 + /// Check if a DID is the admin 39 + fn is_admin(did: &str) -> bool { 40 + did == ADMIN_DID 41 + } 42 + 43 + /// The post body for changing your status 44 + #[derive(Serialize, Deserialize, Clone)] 45 + pub struct StatusForm { 46 + pub status: String, 47 + pub text: Option<String>, 48 + pub expires_in: Option<String>, // e.g., "1h", "30m", "1d", etc. 49 + } 50 + 51 + /// The post body for deleting a specific status 52 + #[derive(Serialize, Deserialize)] 53 + pub struct DeleteRequest { 54 + pub uri: String, 55 + } 56 + 57 + /// Hide/unhide a status (admin only) 58 + #[derive(Deserialize)] 59 + pub struct HideStatusRequest { 60 + pub uri: String, 61 + pub hidden: bool, 62 + } 63 + 64 + /// Parse duration string like "1h", "30m", "1d" into chrono::Duration 65 + fn parse_duration(duration_str: &str) -> Option<chrono::Duration> { 66 + if duration_str.is_empty() { 67 + return None; 68 + } 69 + 70 + let (num_str, unit) = duration_str.split_at(duration_str.len() - 1); 71 + let num: i64 = num_str.parse().ok()?; 72 + 73 + match unit { 74 + "m" => Some(chrono::Duration::minutes(num)), 75 + "h" => Some(chrono::Duration::hours(num)), 76 + "d" => Some(chrono::Duration::days(num)), 77 + "w" => Some(chrono::Duration::weeks(num)), 78 + _ => None, 79 + } 80 + } 81 + 82 + /// Homepage - shows logged-in user's status, or owner's status if not logged in 83 + #[get("/")] 84 + pub async fn home( 85 + session: Session, 86 + _oauth_client: web::Data<OAuthClientType>, 87 + db_pool: web::Data<Arc<Pool>>, 88 + handle_resolver: web::Data<HandleResolver>, 89 + ) -> Result<impl Responder> { 90 + // Default owner of the domain 91 + const OWNER_HANDLE: &str = "zzstoatzz.io"; 92 + 93 + // Check if user is logged in 94 + match session.get::<String>("did").unwrap_or(None) { 95 + Some(did_string) => { 96 + // User is logged in - show their status page 97 + log::debug!("Home: User is logged in with DID: {}", did_string); 98 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 99 + 100 + // Get their handle 101 + let handle = match handle_resolver.resolve(&did).await { 102 + Ok(did_doc) => did_doc 103 + .also_known_as 104 + .and_then(|aka| aka.first().map(|h| h.replace("at://", ""))) 105 + .unwrap_or_else(|| did_string.clone()), 106 + Err(_) => did_string.clone(), 107 + }; 108 + 109 + // Get user's status 110 + let current_status = StatusFromDb::my_status(&db_pool, &did) 111 + .await 112 + .unwrap_or(None) 113 + .and_then(|s| { 114 + // Check if status is expired 115 + if let Some(expires_at) = s.expires_at { 116 + if chrono::Utc::now() > expires_at { 117 + return None; // Status expired 118 + } 119 + } 120 + Some(s) 121 + }); 122 + 123 + let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 124 + .await 125 + .unwrap_or_else(|err| { 126 + log::error!("Error loading status history: {err}"); 127 + vec![] 128 + }); 129 + 130 + let html = StatusTemplate { 131 + title: "your status", 132 + handle, 133 + current_status, 134 + history, 135 + is_owner: true, // They're viewing their own status 136 + } 137 + .render() 138 + .expect("template should be valid"); 139 + 140 + Ok(web::Html::new(html)) 141 + } 142 + None => { 143 + // Not logged in - show owner's status 144 + // Resolve owner handle to DID 145 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 146 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 147 + http_client: Arc::new(DefaultHttpClient::default()), 148 + }); 149 + 150 + let owner_handle = 151 + atrium_api::types::string::Handle::new(OWNER_HANDLE.to_string()).ok(); 152 + let owner_did = if let Some(handle) = owner_handle { 153 + atproto_handle_resolver.resolve(&handle).await.ok() 154 + } else { 155 + None 156 + }; 157 + 158 + let current_status = if let Some(ref did) = owner_did { 159 + StatusFromDb::my_status(&db_pool, did) 160 + .await 161 + .unwrap_or(None) 162 + .and_then(|s| { 163 + // Check if status is expired 164 + if let Some(expires_at) = s.expires_at { 165 + if chrono::Utc::now() > expires_at { 166 + return None; // Status expired 167 + } 168 + } 169 + Some(s) 170 + }) 171 + } else { 172 + None 173 + }; 174 + 175 + let history = if let Some(ref did) = owner_did { 176 + StatusFromDb::load_user_statuses(&db_pool, did, 10) 177 + .await 178 + .unwrap_or_else(|err| { 179 + log::error!("Error loading status history: {err}"); 180 + vec![] 181 + }) 182 + } else { 183 + vec![] 184 + }; 185 + 186 + let html = StatusTemplate { 187 + title: "nate's status", 188 + handle: OWNER_HANDLE.to_string(), 189 + current_status, 190 + history, 191 + is_owner: false, // Visitor viewing owner's status 192 + } 193 + .render() 194 + .expect("template should be valid"); 195 + 196 + Ok(web::Html::new(html)) 197 + } 198 + } 199 + } 200 + 201 + /// View a specific user's status page by handle 202 + #[get("/@{handle}")] 203 + pub async fn user_status_page( 204 + handle: web::Path<String>, 205 + session: Session, 206 + db_pool: web::Data<Arc<Pool>>, 207 + _handle_resolver: web::Data<HandleResolver>, 208 + ) -> Result<impl Responder> { 209 + let handle = handle.into_inner(); 210 + 211 + // Resolve handle to DID using ATProto handle resolution 212 + // First we need to create a handle resolver 213 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 214 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 215 + http_client: Arc::new(DefaultHttpClient::default()), 216 + }); 217 + 218 + let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 219 + let did = match handle_obj { 220 + Some(h) => match atproto_handle_resolver.resolve(&h).await { 221 + Ok(did) => did, 222 + Err(_) => { 223 + // Could not resolve handle 224 + let html = ErrorTemplate { 225 + title: "User not found", 226 + error: &format!("Could not find user @{}. This handle may not exist or may not be using the ATProto network.", handle), 227 + } 228 + .render() 229 + .expect("template should be valid"); 230 + return Ok(web::Html::new(html)); 231 + } 232 + }, 233 + None => { 234 + // Invalid handle format 235 + let html = ErrorTemplate { 236 + title: "Invalid handle", 237 + error: &format!( 238 + "'{}' is not a valid handle format. Handles should be like 'alice.bsky.social'", 239 + handle 240 + ), 241 + } 242 + .render() 243 + .expect("template should be valid"); 244 + return Ok(web::Html::new(html)); 245 + } 246 + }; 247 + 248 + // Check if logged in user is viewing their own page 249 + let is_owner = match session.get::<String>("did").unwrap_or(None) { 250 + Some(session_did) => session_did == did.to_string(), 251 + None => false, 252 + }; 253 + 254 + // Get user's status 255 + let current_status = StatusFromDb::my_status(&db_pool, &did) 256 + .await 257 + .unwrap_or(None) 258 + .and_then(|s| { 259 + // Check if status is expired 260 + if let Some(expires_at) = s.expires_at { 261 + if chrono::Utc::now() > expires_at { 262 + return None; // Status expired 263 + } 264 + } 265 + Some(s) 266 + }); 267 + 268 + let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 269 + .await 270 + .unwrap_or_else(|err| { 271 + log::error!("Error loading status history: {err}"); 272 + vec![] 273 + }); 274 + 275 + let html = StatusTemplate { 276 + title: &format!("@{} status", handle), 277 + handle: handle.clone(), 278 + current_status, 279 + history, 280 + is_owner, 281 + } 282 + .render() 283 + .expect("template should be valid"); 284 + 285 + Ok(web::Html::new(html)) 286 + } 287 + 288 + /// JSON API for the owner's status (top-level endpoint) 289 + #[get("/json")] 290 + pub async fn owner_status_json( 291 + db_pool: web::Data<Arc<Pool>>, 292 + _handle_resolver: web::Data<HandleResolver>, 293 + ) -> Result<impl Responder> { 294 + // Default owner of the domain 295 + const OWNER_HANDLE: &str = "zzstoatzz.io"; 296 + 297 + // Resolve handle to DID using ATProto handle resolution 298 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 299 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 300 + http_client: Arc::new(DefaultHttpClient::default()), 301 + }); 302 + 303 + let did = match atproto_handle_resolver 304 + .resolve(&OWNER_HANDLE.parse().expect("failed to parse handle")) 305 + .await 306 + { 307 + Ok(d) => Some(d.to_string()), 308 + Err(e) => { 309 + log::error!("Failed to resolve handle {}: {}", OWNER_HANDLE, e); 310 + None 311 + } 312 + }; 313 + 314 + let current_status = if let Some(did) = did { 315 + let did = Did::new(did).expect("failed to parse did"); 316 + StatusFromDb::my_status(&db_pool, &did) 317 + .await 318 + .unwrap_or(None) 319 + .and_then(|s| { 320 + // Check if status is expired 321 + if let Some(expires_at) = s.expires_at { 322 + if chrono::Utc::now() > expires_at { 323 + return None; // Status expired 324 + } 325 + } 326 + Some(s) 327 + }) 328 + } else { 329 + None 330 + }; 331 + 332 + let response = if let Some(status_data) = current_status { 333 + serde_json::json!({ 334 + "handle": OWNER_HANDLE, 335 + "status": "known", 336 + "emoji": status_data.status, 337 + "text": status_data.text, 338 + "since": status_data.started_at.to_rfc3339(), 339 + "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 340 + }) 341 + } else { 342 + serde_json::json!({ 343 + "handle": OWNER_HANDLE, 344 + "status": "unknown", 345 + "message": "No current status is known" 346 + }) 347 + }; 348 + 349 + Ok(web::Json(response)) 350 + } 351 + 352 + /// JSON API for a specific user's status 353 + #[get("/@{handle}/json")] 354 + pub async fn user_status_json( 355 + handle: web::Path<String>, 356 + db_pool: web::Data<Arc<Pool>>, 357 + _handle_resolver: web::Data<HandleResolver>, 358 + ) -> Result<impl Responder> { 359 + let handle = handle.into_inner(); 360 + 361 + // Resolve handle to DID using ATProto handle resolution 362 + // First we need to create a handle resolver 363 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 364 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 365 + http_client: Arc::new(DefaultHttpClient::default()), 366 + }); 367 + 368 + let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 369 + let did = match handle_obj { 370 + Some(h) => match atproto_handle_resolver.resolve(&h).await { 371 + Ok(did) => did, 372 + Err(_) => { 373 + return Ok(web::Json(serde_json::json!({ 374 + "status": "unknown", 375 + "message": format!("Could not resolve handle @{}", handle) 376 + }))); 377 + } 378 + }, 379 + None => { 380 + return Ok(web::Json(serde_json::json!({ 381 + "status": "unknown", 382 + "message": format!("Invalid handle format: @{}", handle) 383 + }))); 384 + } 385 + }; 386 + 387 + let current_status = StatusFromDb::my_status(&db_pool, &did) 388 + .await 389 + .unwrap_or(None) 390 + .and_then(|s| { 391 + // Check if status is expired 392 + if let Some(expires_at) = s.expires_at { 393 + if chrono::Utc::now() > expires_at { 394 + return None; // Status expired 395 + } 396 + } 397 + Some(s) 398 + }); 399 + 400 + let response = if let Some(status_data) = current_status { 401 + serde_json::json!({ 402 + "status": "known", 403 + "emoji": status_data.status, 404 + "text": status_data.text, 405 + "since": status_data.started_at.to_rfc3339(), 406 + "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 407 + }) 408 + } else { 409 + serde_json::json!({ 410 + "status": "unknown", 411 + "message": format!("No current status is known for @{}", handle) 412 + }) 413 + }; 414 + 415 + Ok(web::Json(response)) 416 + } 417 + 418 + /// JSON API endpoint for status - returns current status or "unknown" 419 + #[get("/api/status")] 420 + pub async fn status_json(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 421 + const OWNER_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 422 + 423 + let owner_did = Did::new(OWNER_DID.to_string()).ok(); 424 + let current_status = if let Some(ref did) = owner_did { 425 + StatusFromDb::my_status(&db_pool, did) 426 + .await 427 + .unwrap_or(None) 428 + .and_then(|s| { 429 + // Check if status is expired 430 + if let Some(expires_at) = s.expires_at { 431 + if chrono::Utc::now() > expires_at { 432 + return None; // Status expired 433 + } 434 + } 435 + Some(s) 436 + }) 437 + } else { 438 + None 439 + }; 440 + 441 + let response = if let Some(status_data) = current_status { 442 + serde_json::json!({ 443 + "status": "known", 444 + "emoji": status_data.status, 445 + "text": status_data.text, 446 + "since": status_data.started_at.to_rfc3339(), 447 + "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 448 + }) 449 + } else { 450 + serde_json::json!({ 451 + "status": "unknown", 452 + "message": "No current status is known" 453 + }) 454 + }; 455 + 456 + Ok(web::Json(response)) 457 + } 458 + 459 + /// Feed page - shows all users' statuses 460 + #[get("/feed")] 461 + pub async fn feed( 462 + request: HttpRequest, 463 + session: Session, 464 + oauth_client: web::Data<OAuthClientType>, 465 + db_pool: web::Data<Arc<Pool>>, 466 + handle_resolver: web::Data<HandleResolver>, 467 + config: web::Data<config::Config>, 468 + ) -> Result<impl Responder> { 469 + // This is essentially the old home function 470 + const TITLE: &str = "status feed"; 471 + 472 + // Check if dev mode is active 473 + let query = request.query_string(); 474 + let use_dev_mode = config.dev_mode && dev_utils::is_dev_mode_requested(query); 475 + 476 + let mut statuses = if use_dev_mode { 477 + // Mix dummy data with real data for testing 478 + let mut real_statuses = StatusFromDb::load_latest_statuses(&db_pool) 479 + .await 480 + .unwrap_or_else(|err| { 481 + log::error!("Error loading statuses: {err}"); 482 + vec![] 483 + }); 484 + let dummy_statuses = dev_utils::generate_dummy_statuses(15); 485 + real_statuses.extend(dummy_statuses); 486 + // Resort by started_at 487 + real_statuses.sort_by(|a, b| b.started_at.cmp(&a.started_at)); 488 + real_statuses 489 + } else { 490 + StatusFromDb::load_latest_statuses(&db_pool) 491 + .await 492 + .unwrap_or_else(|err| { 493 + log::error!("Error loading statuses: {err}"); 494 + vec![] 495 + }) 496 + }; 497 + 498 + let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 499 + for db_status in &mut statuses { 500 + let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 501 + match quick_resolve_map.get(&authors_did) { 502 + None => {} 503 + Some(found_handle) => { 504 + db_status.handle = Some(found_handle.clone()); 505 + continue; 506 + } 507 + } 508 + db_status.handle = match handle_resolver.resolve(&authors_did).await { 509 + Ok(did_doc) => match did_doc.also_known_as { 510 + None => None, 511 + Some(also_known_as) => match also_known_as.is_empty() { 512 + true => None, 513 + false => { 514 + let full_handle = also_known_as.first().unwrap(); 515 + let handle = full_handle.replace("at://", ""); 516 + quick_resolve_map.insert(authors_did, handle.clone()); 517 + Some(handle) 518 + } 519 + }, 520 + }, 521 + Err(err) => { 522 + log::error!("Error resolving did: {err}"); 523 + None 524 + } 525 + }; 526 + } 527 + 528 + match session.get::<String>("did").unwrap_or(None) { 529 + Some(did_string) => { 530 + log::debug!("Feed: User has session with DID: {}", did_string); 531 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 532 + let _my_status = StatusFromDb::my_status(&db_pool, &did) 533 + .await 534 + .unwrap_or_else(|err| { 535 + log::error!("Error loading my status: {err}"); 536 + None 537 + }); 538 + 539 + log::debug!( 540 + "Feed: Attempting to restore OAuth session for DID: {}", 541 + did_string 542 + ); 543 + match oauth_client.restore(&did).await { 544 + Ok(session) => { 545 + log::debug!("Feed: Successfully restored OAuth session"); 546 + let agent = Agent::new(session); 547 + let profile = agent 548 + .api 549 + .app 550 + .bsky 551 + .actor 552 + .get_profile( 553 + atrium_api::app::bsky::actor::get_profile::ParametersData { 554 + actor: atrium_api::types::string::AtIdentifier::Did(did.clone()), 555 + } 556 + .into(), 557 + ) 558 + .await; 559 + 560 + let is_admin = is_admin(did.as_str()); 561 + let html = FeedTemplate { 562 + title: TITLE, 563 + profile: match profile { 564 + Ok(profile) => { 565 + let profile_data = Profile { 566 + did: profile.did.to_string(), 567 + display_name: profile.display_name.clone(), 568 + }; 569 + Some(profile_data) 570 + } 571 + Err(err) => { 572 + log::error!("Error accessing profile: {err}"); 573 + None 574 + } 575 + }, 576 + statuses, 577 + is_admin, 578 + dev_mode: use_dev_mode, 579 + } 580 + .render() 581 + .expect("template should be valid"); 582 + 583 + Ok(web::Html::new(html)) 584 + } 585 + Err(err) => { 586 + // Don't purge the session - OAuth tokens might be expired but user is still logged in 587 + log::warn!("Could not restore OAuth session for feed: {:?}", err); 588 + 589 + // Show feed without profile info instead of error page 590 + let html = FeedTemplate { 591 + title: TITLE, 592 + profile: None, 593 + statuses, 594 + is_admin: is_admin(did.as_str()), 595 + dev_mode: use_dev_mode, 596 + } 597 + .render() 598 + .expect("template should be valid"); 599 + 600 + Ok(web::Html::new(html)) 601 + } 602 + } 603 + } 604 + None => { 605 + let html = FeedTemplate { 606 + title: TITLE, 607 + profile: None, 608 + statuses, 609 + is_admin: false, 610 + dev_mode: use_dev_mode, 611 + } 612 + .render() 613 + .expect("template should be valid"); 614 + 615 + Ok(web::Html::new(html)) 616 + } 617 + } 618 + } 619 + 620 + /// Get paginated statuses for infinite scrolling 621 + #[get("/api/feed")] 622 + pub async fn api_feed( 623 + query: web::Query<HashMap<String, String>>, 624 + db_pool: web::Data<Arc<Pool>>, 625 + handle_resolver: web::Data<HandleResolver>, 626 + config: web::Data<config::Config>, 627 + ) -> Result<impl Responder> { 628 + let offset = query 629 + .get("offset") 630 + .and_then(|s| s.parse::<i32>().ok()) 631 + .unwrap_or(0); 632 + let limit = query 633 + .get("limit") 634 + .and_then(|s| s.parse::<i32>().ok()) 635 + .unwrap_or(20) 636 + .min(50); // Cap at 50 items per request 637 + 638 + // Check if dev mode is requested 639 + let use_dev_mode = config.dev_mode && query.get("dev").is_some_and(|v| v == "true" || v == "1"); 640 + 641 + let mut statuses = if use_dev_mode && offset == 0 { 642 + // For first page in dev mode, mix dummy data with real data 643 + let mut real_statuses = StatusFromDb::load_statuses_paginated(&db_pool, 0, limit / 2) 644 + .await 645 + .unwrap_or_else(|err| { 646 + log::error!("Error loading paginated statuses: {err}"); 647 + vec![] 648 + }); 649 + let dummy_statuses = dev_utils::generate_dummy_statuses((limit / 2) as usize); 650 + real_statuses.extend(dummy_statuses); 651 + real_statuses.sort_by(|a, b| b.started_at.cmp(&a.started_at)); 652 + real_statuses 653 + } else { 654 + StatusFromDb::load_statuses_paginated(&db_pool, offset, limit) 655 + .await 656 + .unwrap_or_else(|err| { 657 + log::error!("Error loading statuses: {err}"); 658 + vec![] 659 + }) 660 + }; 661 + 662 + // Resolve handles for each status 663 + let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 664 + for db_status in &mut statuses { 665 + let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 666 + match quick_resolve_map.get(&authors_did) { 667 + None => {} 668 + Some(found_handle) => { 669 + db_status.handle = Some(found_handle.clone()); 670 + continue; 671 + } 672 + } 673 + db_status.handle = match handle_resolver.resolve(&authors_did).await { 674 + Ok(did_doc) => match did_doc.also_known_as { 675 + None => None, 676 + Some(also_known_as) => match also_known_as.is_empty() { 677 + true => None, 678 + false => { 679 + let full_handle = also_known_as.first().unwrap(); 680 + let handle = full_handle.replace("at://", ""); 681 + quick_resolve_map.insert(authors_did, handle.clone()); 682 + Some(handle) 683 + } 684 + }, 685 + }, 686 + Err(_) => None, 687 + }; 688 + } 689 + 690 + Ok(HttpResponse::Ok().json(statuses)) 691 + } 692 + 693 + /// Get the most frequently used emojis from all statuses 694 + #[get("/api/frequent-emojis")] 695 + pub async fn get_frequent_emojis(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 696 + // Get top 20 most frequently used emojis 697 + let emojis = db::get_frequent_emojis(&db_pool, 20) 698 + .await 699 + .unwrap_or_else(|err| { 700 + log::error!("Failed to get frequent emojis: {}", err); 701 + Vec::new() 702 + }); 703 + 704 + // If we have less than 12 emojis, add some defaults to fill it out 705 + let mut result = emojis; 706 + if result.is_empty() { 707 + log::info!("No emoji usage data found, using defaults"); 708 + let defaults = vec![ 709 + "😊", "👍", "❤️", "😂", "🎉", "🔥", "✨", "💯", "🚀", "💪", "🙏", "👏", 710 + ]; 711 + result = defaults.into_iter().map(String::from).collect(); 712 + } else if result.len() < 12 { 713 + log::info!("Found {} emojis, padding with defaults", result.len()); 714 + let defaults = vec![ 715 + "😊", "👍", "❤️", "😂", "🎉", "🔥", "✨", "💯", "🚀", "💪", "🙏", "👏", 716 + ]; 717 + for emoji in defaults { 718 + if !result.contains(&emoji.to_string()) && result.len() < 20 { 719 + result.push(emoji.to_string()); 720 + } 721 + } 722 + } else { 723 + log::info!("Found {} frequently used emojis", result.len()); 724 + } 725 + 726 + Ok(web::Json(result)) 727 + } 728 + 729 + /// Get all custom emojis available on the site 730 + #[get("/api/custom-emojis")] 731 + pub async fn get_custom_emojis() -> Result<impl Responder> { 732 + use std::fs; 733 + 734 + #[derive(Serialize)] 735 + struct SimpleEmoji { 736 + name: String, 737 + filename: String, 738 + } 739 + 740 + let emojis_dir = "static/emojis"; 741 + let mut emojis = Vec::new(); 742 + 743 + if let Ok(entries) = fs::read_dir(emojis_dir) { 744 + for entry in entries.flatten() { 745 + if let Some(filename) = entry.file_name().to_str() { 746 + // Only include image files 747 + if filename.ends_with(".png") 748 + || filename.ends_with(".gif") 749 + || filename.ends_with(".jpg") 750 + || filename.ends_with(".webp") 751 + { 752 + // Remove file extension to get name 753 + let name = filename 754 + .rsplit_once('.') 755 + .map(|(name, _)| name) 756 + .unwrap_or(filename) 757 + .to_string(); 758 + emojis.push(SimpleEmoji { 759 + name: name.clone(), 760 + filename: filename.to_string(), 761 + }); 762 + } 763 + } 764 + } 765 + } 766 + 767 + // Sort by name 768 + emojis.sort_by(|a, b| a.name.cmp(&b.name)); 769 + 770 + Ok(HttpResponse::Ok().json(emojis)) 771 + } 772 + 773 + /// Get the DIDs of accounts the logged-in user follows 774 + #[get("/api/following")] 775 + pub async fn get_following( 776 + session: Session, 777 + _oauth_client: web::Data<OAuthClientType>, 778 + ) -> Result<impl Responder> { 779 + // Check if user is logged in 780 + let did = match session.get::<Did>("did").ok().flatten() { 781 + Some(did) => did, 782 + None => { 783 + return Ok(HttpResponse::Unauthorized().json(serde_json::json!({ 784 + "error": "Not logged in" 785 + }))); 786 + } 787 + }; 788 + 789 + // WORKAROUND: Call public API directly for getFollows since OAuth scope isn't working 790 + // Both getProfile and getFollows are public endpoints that don't require auth 791 + // but when called through OAuth, getFollows requires a scope that doesn't exist yet 792 + 793 + let mut all_follows = Vec::new(); 794 + let mut cursor: Option<String> = None; 795 + 796 + // Use reqwest to call the public API directly 797 + let client = reqwest::Client::new(); 798 + 799 + loop { 800 + let mut url = format!( 801 + "https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor={}", 802 + did.as_str() 803 + ); 804 + 805 + if let Some(c) = &cursor { 806 + url.push_str(&format!("&cursor={}", c)); 807 + } 808 + 809 + match client.get(&url).send().await { 810 + Ok(response) => { 811 + match response.json::<serde_json::Value>().await { 812 + Ok(json) => { 813 + // Extract follows 814 + if let Some(follows) = json["follows"].as_array() { 815 + for follow in follows { 816 + if let Some(did_str) = follow["did"].as_str() { 817 + all_follows.push(did_str.to_string()); 818 + } 819 + } 820 + } 821 + 822 + // Check for cursor 823 + cursor = json["cursor"].as_str().map(|s| s.to_string()); 824 + if cursor.is_none() { 825 + break; 826 + } 827 + } 828 + Err(err) => { 829 + log::error!("Failed to parse follows response: {}", err); 830 + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 831 + "error": "Failed to parse follows" 832 + }))); 833 + } 834 + } 835 + } 836 + Err(err) => { 837 + log::error!("Failed to fetch follows from public API: {}", err); 838 + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 839 + "error": "Failed to fetch follows" 840 + }))); 841 + } 842 + } 843 + } 844 + 845 + Ok(HttpResponse::Ok().json(serde_json::json!({ 846 + "follows": all_follows 847 + }))) 848 + } 849 + 850 + /// Clear the user's status by deleting the ATProto record 851 + #[post("/status/clear")] 852 + pub async fn clear_status( 853 + request: HttpRequest, 854 + session: Session, 855 + oauth_client: web::Data<OAuthClientType>, 856 + db_pool: web::Data<Arc<Pool>>, 857 + ) -> HttpResponse { 858 + // Check if the user is logged in 859 + match session.get::<String>("did").unwrap_or(None) { 860 + Some(did_string) => { 861 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 862 + 863 + // Get the user's current status to find the record key 864 + match StatusFromDb::my_status(&db_pool, &did).await { 865 + Ok(Some(current_status)) => { 866 + // Extract the record key from the URI 867 + // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 868 + let parts: Vec<&str> = current_status.uri.split('/').collect(); 869 + if let Some(rkey) = parts.last() { 870 + // Get OAuth session 871 + match oauth_client.restore(&did).await { 872 + Ok(session) => { 873 + let agent = Agent::new(session); 874 + 875 + // Delete the record from ATProto using com.atproto.repo.deleteRecord 876 + let delete_request = 877 + atrium_api::com::atproto::repo::delete_record::InputData { 878 + collection: atrium_api::types::string::Nsid::new( 879 + "io.zzstoatzz.status.record".to_string(), 880 + ) 881 + .expect("valid nsid"), 882 + repo: did.clone().into(), 883 + rkey: atrium_api::types::string::RecordKey::new( 884 + rkey.to_string(), 885 + ) 886 + .expect("valid rkey"), 887 + swap_commit: None, 888 + swap_record: None, 889 + }; 890 + match agent 891 + .api 892 + .com 893 + .atproto 894 + .repo 895 + .delete_record(delete_request.into()) 896 + .await 897 + { 898 + Ok(_) => { 899 + // Also remove from local database 900 + let _ = StatusFromDb::delete_by_uri( 901 + &db_pool, 902 + current_status.uri, 903 + ) 904 + .await; 905 + 906 + Redirect::to("/") 907 + .see_other() 908 + .respond_to(&request) 909 + .map_into_boxed_body() 910 + } 911 + Err(e) => { 912 + log::error!("Failed to delete status from ATProto: {e}"); 913 + HttpResponse::InternalServerError() 914 + .body("Failed to clear status") 915 + } 916 + } 917 + } 918 + Err(e) => { 919 + log::error!("Failed to restore OAuth session: {e}"); 920 + HttpResponse::InternalServerError().body("Session error") 921 + } 922 + } 923 + } else { 924 + HttpResponse::BadRequest().body("Invalid status URI") 925 + } 926 + } 927 + Ok(None) => { 928 + // No status to clear 929 + Redirect::to("/") 930 + .see_other() 931 + .respond_to(&request) 932 + .map_into_boxed_body() 933 + } 934 + Err(e) => { 935 + log::error!("Database error: {e}"); 936 + HttpResponse::InternalServerError().body("Database error") 937 + } 938 + } 939 + } 940 + None => { 941 + // Not logged in 942 + Redirect::to("/login") 943 + .see_other() 944 + .respond_to(&request) 945 + .map_into_boxed_body() 946 + } 947 + } 948 + } 949 + 950 + /// Delete a specific status by URI (JSON endpoint) 951 + #[post("/status/delete")] 952 + pub async fn delete_status( 953 + session: Session, 954 + oauth_client: web::Data<OAuthClientType>, 955 + db_pool: web::Data<Arc<Pool>>, 956 + req: web::Json<DeleteRequest>, 957 + ) -> HttpResponse { 958 + // Check if the user is logged in 959 + match session.get::<String>("did").unwrap_or(None) { 960 + Some(did_string) => { 961 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 962 + 963 + // Parse the URI to verify it belongs to this user 964 + // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 965 + let uri_parts: Vec<&str> = req.uri.split('/').collect(); 966 + if uri_parts.len() < 5 { 967 + return HttpResponse::BadRequest().json(serde_json::json!({ 968 + "error": "Invalid status URI format" 969 + })); 970 + } 971 + 972 + // Extract DID from URI (at://did:plc:xxx/...) 973 + let uri_did_part = uri_parts[2]; 974 + if uri_did_part != did_string { 975 + return HttpResponse::Forbidden().json(serde_json::json!({ 976 + "error": "You can only delete your own statuses" 977 + })); 978 + } 979 + 980 + // Extract record key 981 + if let Some(rkey) = uri_parts.last() { 982 + // Get OAuth session 983 + match oauth_client.restore(&did).await { 984 + Ok(session) => { 985 + let agent = Agent::new(session); 986 + 987 + // Delete the record from ATProto 988 + let delete_request = 989 + atrium_api::com::atproto::repo::delete_record::InputData { 990 + collection: atrium_api::types::string::Nsid::new( 991 + "io.zzstoatzz.status.record".to_string(), 992 + ) 993 + .expect("valid nsid"), 994 + repo: did.clone().into(), 995 + rkey: atrium_api::types::string::RecordKey::new(rkey.to_string()) 996 + .expect("valid rkey"), 997 + swap_commit: None, 998 + swap_record: None, 999 + }; 1000 + 1001 + match agent 1002 + .api 1003 + .com 1004 + .atproto 1005 + .repo 1006 + .delete_record(delete_request.into()) 1007 + .await 1008 + { 1009 + Ok(_) => { 1010 + // Also remove from local database 1011 + let _ = 1012 + StatusFromDb::delete_by_uri(&db_pool, req.uri.clone()).await; 1013 + 1014 + HttpResponse::Ok().json(serde_json::json!({ 1015 + "success": true 1016 + })) 1017 + } 1018 + Err(e) => { 1019 + log::error!("Failed to delete status from ATProto: {e}"); 1020 + HttpResponse::InternalServerError().json(serde_json::json!({ 1021 + "error": "Failed to delete status" 1022 + })) 1023 + } 1024 + } 1025 + } 1026 + Err(e) => { 1027 + log::error!("Failed to restore OAuth session: {e}"); 1028 + HttpResponse::InternalServerError().json(serde_json::json!({ 1029 + "error": "Session error" 1030 + })) 1031 + } 1032 + } 1033 + } else { 1034 + HttpResponse::BadRequest().json(serde_json::json!({ 1035 + "error": "Invalid status URI" 1036 + })) 1037 + } 1038 + } 1039 + None => { 1040 + // Not logged in 1041 + HttpResponse::Unauthorized().json(serde_json::json!({ 1042 + "error": "Not authenticated" 1043 + })) 1044 + } 1045 + } 1046 + } 1047 + 1048 + /// Hide/unhide a status (admin only) 1049 + #[post("/admin/hide-status")] 1050 + pub async fn hide_status( 1051 + session: Session, 1052 + db_pool: web::Data<Arc<Pool>>, 1053 + req: web::Json<HideStatusRequest>, 1054 + ) -> HttpResponse { 1055 + // Check if the user is logged in and is admin 1056 + match session.get::<String>("did").unwrap_or(None) { 1057 + Some(did_string) => { 1058 + if !is_admin(&did_string) { 1059 + return HttpResponse::Forbidden().json(serde_json::json!({ 1060 + "error": "Admin access required" 1061 + })); 1062 + } 1063 + 1064 + // Update the hidden status in the database 1065 + let uri = req.uri.clone(); 1066 + let hidden = req.hidden; 1067 + 1068 + let result = db_pool 1069 + .conn(move |conn| { 1070 + conn.execute( 1071 + "UPDATE status SET hidden = ?1 WHERE uri = ?2", 1072 + rusqlite::params![hidden, uri], 1073 + ) 1074 + }) 1075 + .await; 1076 + 1077 + match result { 1078 + Ok(rows_affected) if rows_affected > 0 => { 1079 + HttpResponse::Ok().json(serde_json::json!({ 1080 + "success": true, 1081 + "message": if hidden { "Status hidden" } else { "Status unhidden" } 1082 + })) 1083 + } 1084 + Ok(_) => HttpResponse::NotFound().json(serde_json::json!({ 1085 + "error": "Status not found" 1086 + })), 1087 + Err(err) => { 1088 + log::error!("Error updating hidden status: {}", err); 1089 + HttpResponse::InternalServerError().json(serde_json::json!({ 1090 + "error": "Database error" 1091 + })) 1092 + } 1093 + } 1094 + } 1095 + None => HttpResponse::Unauthorized().json(serde_json::json!({ 1096 + "error": "Not authenticated" 1097 + })), 1098 + } 1099 + } 1100 + 1101 + /// Creates a new status 1102 + #[post("/status")] 1103 + pub async fn status( 1104 + request: HttpRequest, 1105 + session: Session, 1106 + oauth_client: web::Data<OAuthClientType>, 1107 + db_pool: web::Data<Arc<Pool>>, 1108 + form: web::Form<StatusForm>, 1109 + rate_limiter: web::Data<RateLimiter>, 1110 + ) -> Result<HttpResponse, AppError> { 1111 + // Apply rate limiting 1112 + let client_key = RateLimiter::get_client_key(&request); 1113 + if !rate_limiter.check_rate_limit(&client_key) { 1114 + return Err(AppError::RateLimitExceeded); 1115 + } 1116 + // Check if the user is logged in 1117 + match session.get::<String>("did").unwrap_or(None) { 1118 + Some(did_string) => { 1119 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 1120 + // gets the user's session from the session store to resume 1121 + match oauth_client.restore(&did).await { 1122 + Ok(session) => { 1123 + let agent = Agent::new(session); 1124 + 1125 + // Calculate expiration time if provided 1126 + let expires = form 1127 + .expires_in 1128 + .as_ref() 1129 + .and_then(|exp| parse_duration(exp)) 1130 + .and_then(|duration| { 1131 + let expiry_time = chrono::Utc::now() + duration; 1132 + // Convert to ATProto Datetime format (RFC3339) 1133 + Some(Datetime::new(expiry_time.to_rfc3339().parse().ok()?)) 1134 + }); 1135 + 1136 + //Creates a strongly typed ATProto record 1137 + let status: KnownRecord = 1138 + crate::lexicons::io::zzstoatzz::status::record::RecordData { 1139 + created_at: Datetime::now(), 1140 + emoji: form.status.clone(), 1141 + text: form.text.clone(), 1142 + expires, 1143 + } 1144 + .into(); 1145 + 1146 + // TODO no data validation yet from esquema 1147 + // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3 1148 + 1149 + let create_result = agent 1150 + .api 1151 + .com 1152 + .atproto 1153 + .repo 1154 + .create_record( 1155 + atrium_api::com::atproto::repo::create_record::InputData { 1156 + collection: "io.zzstoatzz.status.record".parse().unwrap(), 1157 + repo: did.into(), 1158 + rkey: None, 1159 + record: status.into(), 1160 + swap_commit: None, 1161 + validate: None, 1162 + } 1163 + .into(), 1164 + ) 1165 + .await; 1166 + 1167 + match create_result { 1168 + Ok(record) => { 1169 + let mut status = StatusFromDb::new( 1170 + record.uri.clone(), 1171 + did_string, 1172 + form.status.clone(), 1173 + ); 1174 + 1175 + // Set the text field if provided 1176 + status.text = form.text.clone(); 1177 + 1178 + // Set the expiration time if provided 1179 + if let Some(exp_str) = &form.expires_in { 1180 + if let Some(duration) = parse_duration(exp_str) { 1181 + status.expires_at = Some(chrono::Utc::now() + duration); 1182 + } 1183 + } 1184 + 1185 + let _ = status.save(db_pool).await; 1186 + Ok(Redirect::to("/") 1187 + .see_other() 1188 + .respond_to(&request) 1189 + .map_into_boxed_body()) 1190 + } 1191 + Err(err) => { 1192 + log::error!("Error creating status: {err}"); 1193 + let error_html = ErrorTemplate { 1194 + title: "Error", 1195 + error: "Was an error creating the status, please check the logs.", 1196 + } 1197 + .render() 1198 + .expect("template should be valid"); 1199 + Ok(HttpResponse::Ok().body(error_html)) 1200 + } 1201 + } 1202 + } 1203 + Err(err) => { 1204 + // Destroys the system or you're in a loop 1205 + session.purge(); 1206 + log::error!( 1207 + "Error restoring session, we are removing the session from the cookie: {err}" 1208 + ); 1209 + Err(AppError::AuthenticationError("Session error".to_string())) 1210 + } 1211 + } 1212 + } 1213 + None => Err(AppError::AuthenticationError( 1214 + "You must be logged in to create a status.".to_string(), 1215 + )), 1216 + } 1217 + }
+10 -181
src/db.rs src/db/models.rs
··· 1 1 use actix_web::web::Data; 2 2 use async_sqlite::{ 3 - Pool, rusqlite, 4 - rusqlite::{Error, Row}, 3 + Pool, 4 + rusqlite::{Error, Row, types::Type}, 5 5 }; 6 6 use atrium_api::types::string::Did; 7 7 use chrono::{DateTime, Utc}; 8 - use rusqlite::types::Type; 9 8 use serde::{Deserialize, Serialize}; 10 9 use std::{ 11 - fmt::Debug, 12 10 sync::Arc, 13 11 time::{SystemTime, UNIX_EPOCH}, 14 12 }; 15 13 16 - /// Creates the tables in the db. 17 - pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { 18 - pool.conn(move |conn| { 19 - conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); 20 - 21 - // status 22 - conn.execute( 23 - "CREATE TABLE IF NOT EXISTS status ( 24 - uri TEXT PRIMARY KEY, 25 - authorDid TEXT NOT NULL, 26 - emoji TEXT NOT NULL, 27 - text TEXT, 28 - startedAt INTEGER NOT NULL, 29 - expiresAt INTEGER, 30 - indexedAt INTEGER NOT NULL 31 - )", 32 - [], 33 - ) 34 - .unwrap(); 35 - 36 - // auth_session 37 - conn.execute( 38 - "CREATE TABLE IF NOT EXISTS auth_session ( 39 - key TEXT PRIMARY KEY, 40 - session TEXT NOT NULL 41 - )", 42 - [], 43 - ) 44 - .unwrap(); 45 - 46 - // auth_state 47 - conn.execute( 48 - "CREATE TABLE IF NOT EXISTS auth_state ( 49 - key TEXT PRIMARY KEY, 50 - state TEXT NOT NULL 51 - )", 52 - [], 53 - ) 54 - .unwrap(); 55 - 56 - // user_preferences 57 - conn.execute( 58 - "CREATE TABLE IF NOT EXISTS user_preferences ( 59 - did TEXT PRIMARY KEY, 60 - font_family TEXT DEFAULT 'system', 61 - accent_color TEXT DEFAULT '#1DA1F2', 62 - updated_at INTEGER NOT NULL 63 - )", 64 - [], 65 - ) 66 - .unwrap(); 67 - 68 - // Note: custom_emojis table removed - we serve emojis directly from static/emojis/ directory 69 - 70 - // Add indexes for performance optimization 71 - // Index on startedAt for feed queries (ORDER BY startedAt DESC) 72 - conn.execute( 73 - "CREATE INDEX IF NOT EXISTS idx_status_startedAt ON status(startedAt DESC)", 74 - [], 75 - ) 76 - .unwrap(); 77 - 78 - // Composite index for user status queries (WHERE authorDid = ? ORDER BY startedAt DESC) 79 - conn.execute( 80 - "CREATE INDEX IF NOT EXISTS idx_status_authorDid_startedAt ON status(authorDid, startedAt DESC)", 81 - [], 82 - ) 83 - .unwrap(); 84 - 85 - // Add hidden column for moderation (won't error if already exists) 86 - let _ = conn.execute( 87 - "ALTER TABLE status ADD COLUMN hidden BOOLEAN DEFAULT FALSE", 88 - [], 89 - ); 90 - 91 - Ok(()) 92 - }) 93 - .await?; 94 - Ok(()) 95 - } 96 - 97 - ///Status table datatype 98 14 #[derive(Debug, Clone, Deserialize, Serialize)] 99 15 pub struct StatusFromDb { 100 16 pub uri: String, ··· 107 23 pub handle: Option<String>, 108 24 } 109 25 110 - //Status methods 111 26 impl StatusFromDb { 112 27 /// Creates a new [StatusFromDb] 113 28 pub fn new(uri: String, author_did: String, status: String) -> Self { ··· 125 40 } 126 41 127 42 /// Helper to map from [Row] to [StatusDb] 128 - fn map_from_row(row: &Row) -> Result<Self, rusqlite::Error> { 43 + pub fn map_from_row(row: &Row) -> Result<Self, async_sqlite::rusqlite::Error> { 129 44 Ok(Self { 130 45 uri: row.get(0)?, 131 46 author_did: row.get(1)?, ··· 168 83 pool.conn(move |conn| { 169 84 conn.execute( 170 85 "INSERT INTO status (uri, authorDid, emoji, text, startedAt, expiresAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", 171 - rusqlite::params![ 86 + async_sqlite::rusqlite::params![ 172 87 &cloned_self.uri, 173 88 &cloned_self.author_did, 174 89 &cloned_self.status, // emoji value ··· 194 109 true => { 195 110 let mut update_stmt = 196 111 conn.prepare("UPDATE status SET emoji = ?2, text = ?3, startedAt = ?4, expiresAt = ?5, indexedAt = ?6 WHERE uri = ?1")?; 197 - update_stmt.execute(rusqlite::params![ 112 + update_stmt.execute(async_sqlite::rusqlite::params![ 198 113 &cloned_self.uri, 199 114 &cloned_self.status, 200 115 &cloned_self.text, ··· 207 122 false => { 208 123 conn.execute( 209 124 "INSERT INTO status (uri, authorDid, emoji, text, startedAt, expiresAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", 210 - rusqlite::params![ 125 + async_sqlite::rusqlite::params![ 211 126 &cloned_self.uri, 212 127 &cloned_self.author_did, 213 128 &cloned_self.status, // emoji value ··· 224 139 .await?; 225 140 Ok(()) 226 141 } 142 + 227 143 pub async fn delete_by_uri(pool: &Pool, uri: String) -> Result<(), async_sqlite::Error> { 228 144 pool.conn(move |conn| { 229 145 let mut stmt = conn.prepare("DELETE FROM status WHERE uri = ?1")?; ··· 266 182 "SELECT * FROM status WHERE (hidden IS NULL OR hidden = FALSE) ORDER BY startedAt DESC LIMIT ?1 OFFSET ?2" 267 183 )?; 268 184 let status_iter = stmt 269 - .query_map(rusqlite::params![limit, offset], |row| { 185 + .query_map(async_sqlite::rusqlite::params![limit, offset], |row| { 270 186 Ok(Self::map_from_row(row).unwrap()) 271 187 }) 272 188 .unwrap(); ··· 293 209 stmt.query_row([did.as_str()], Self::map_from_row) 294 210 .map(Some) 295 211 .or_else(|err| { 296 - if err == rusqlite::Error::QueryReturnedNoRows { 212 + if err == async_sqlite::rusqlite::Error::QueryReturnedNoRows { 297 213 Ok(None) 298 214 } else { 299 215 Err(err) ··· 518 434 } 519 435 } 520 436 521 - // CustomEmoji struct removed - we serve emojis directly from static/emojis/ directory 522 - 523 - /// Get the most frequently used emojis from all statuses 524 - pub async fn get_frequent_emojis( 525 - pool: &Pool, 526 - limit: usize, 527 - ) -> Result<Vec<String>, async_sqlite::Error> { 528 - pool.conn(move |conn| { 529 - let mut stmt = conn.prepare( 530 - "SELECT emoji, COUNT(*) as count 531 - FROM status 532 - GROUP BY emoji 533 - ORDER BY count DESC 534 - LIMIT ?1", 535 - )?; 536 - 537 - let emoji_iter = stmt.query_map([limit], |row| row.get::<_, String>(0))?; 538 - 539 - let mut emojis = Vec::new(); 540 - for emoji in emoji_iter { 541 - emojis.push(emoji?); 542 - } 543 - 544 - Ok(emojis) 545 - }) 546 - .await 547 - } 548 - 549 437 #[derive(Debug, Clone, Serialize, Deserialize)] 550 438 pub struct UserPreferences { 551 439 pub did: String, ··· 558 446 fn default() -> Self { 559 447 Self { 560 448 did: String::new(), 561 - font_family: "system".to_string(), 449 + font_family: "mono".to_string(), 562 450 accent_color: "#1DA1F2".to_string(), 563 451 updated_at: SystemTime::now() 564 452 .duration_since(UNIX_EPOCH) ··· 567 455 } 568 456 } 569 457 } 570 - 571 - /// Get user preferences for a given DID 572 - pub async fn get_user_preferences( 573 - pool: &Pool, 574 - did: &str, 575 - ) -> Result<UserPreferences, async_sqlite::Error> { 576 - let did = did.to_string(); 577 - pool.conn(move |conn| { 578 - let mut stmt = conn.prepare( 579 - "SELECT did, font_family, accent_color, updated_at 580 - FROM user_preferences 581 - WHERE did = ?1", 582 - )?; 583 - 584 - let result = stmt.query_row([&did], |row| { 585 - Ok(UserPreferences { 586 - did: row.get(0)?, 587 - font_family: row.get(1)?, 588 - accent_color: row.get(2)?, 589 - updated_at: row.get(3)?, 590 - }) 591 - }); 592 - 593 - match result { 594 - Ok(prefs) => Ok(prefs), 595 - Err(rusqlite::Error::QueryReturnedNoRows) => { 596 - // Return default preferences for new users 597 - Ok(UserPreferences { 598 - did: did.clone(), 599 - ..Default::default() 600 - }) 601 - } 602 - Err(e) => Err(e), 603 - } 604 - }) 605 - .await 606 - } 607 - 608 - /// Save user preferences 609 - pub async fn save_user_preferences( 610 - pool: &Pool, 611 - prefs: &UserPreferences, 612 - ) -> Result<(), async_sqlite::Error> { 613 - let prefs = prefs.clone(); 614 - pool.conn(move |conn| { 615 - conn.execute( 616 - "INSERT OR REPLACE INTO user_preferences (did, font_family, accent_color, updated_at) 617 - VALUES (?1, ?2, ?3, ?4)", 618 - ( 619 - &prefs.did, 620 - &prefs.font_family, 621 - &prefs.accent_color, 622 - &prefs.updated_at, 623 - ), 624 - )?; 625 - Ok(()) 626 - }) 627 - .await 628 - }
+88
src/db/mod.rs
··· 1 + pub mod models; 2 + pub mod queries; 3 + 4 + pub use models::{AuthSession, AuthState, StatusFromDb}; 5 + pub use queries::{get_frequent_emojis, get_user_preferences, save_user_preferences}; 6 + 7 + use async_sqlite::Pool; 8 + 9 + /// Creates the tables in the db. 10 + pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { 11 + pool.conn(move |conn| { 12 + conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); 13 + 14 + // status 15 + conn.execute( 16 + "CREATE TABLE IF NOT EXISTS status ( 17 + uri TEXT PRIMARY KEY, 18 + authorDid TEXT NOT NULL, 19 + emoji TEXT NOT NULL, 20 + text TEXT, 21 + startedAt INTEGER NOT NULL, 22 + expiresAt INTEGER, 23 + indexedAt INTEGER NOT NULL 24 + )", 25 + [], 26 + ) 27 + .unwrap(); 28 + 29 + // auth_session 30 + conn.execute( 31 + "CREATE TABLE IF NOT EXISTS auth_session ( 32 + key TEXT PRIMARY KEY, 33 + session TEXT NOT NULL 34 + )", 35 + [], 36 + ) 37 + .unwrap(); 38 + 39 + // auth_state 40 + conn.execute( 41 + "CREATE TABLE IF NOT EXISTS auth_state ( 42 + key TEXT PRIMARY KEY, 43 + state TEXT NOT NULL 44 + )", 45 + [], 46 + ) 47 + .unwrap(); 48 + 49 + // user_preferences 50 + conn.execute( 51 + "CREATE TABLE IF NOT EXISTS user_preferences ( 52 + did TEXT PRIMARY KEY, 53 + font_family TEXT DEFAULT 'mono', 54 + accent_color TEXT DEFAULT '#1DA1F2', 55 + updated_at INTEGER NOT NULL 56 + )", 57 + [], 58 + ) 59 + .unwrap(); 60 + 61 + // Note: custom_emojis table removed - we serve emojis directly from static/emojis/ directory 62 + 63 + // Add indexes for performance optimization 64 + // Index on startedAt for feed queries (ORDER BY startedAt DESC) 65 + conn.execute( 66 + "CREATE INDEX IF NOT EXISTS idx_status_startedAt ON status(startedAt DESC)", 67 + [], 68 + ) 69 + .unwrap(); 70 + 71 + // Composite index for user status queries (WHERE authorDid = ? ORDER BY startedAt DESC) 72 + conn.execute( 73 + "CREATE INDEX IF NOT EXISTS idx_status_authorDid_startedAt ON status(authorDid, startedAt DESC)", 74 + [], 75 + ) 76 + .unwrap(); 77 + 78 + // Add hidden column for moderation (won't error if already exists) 79 + let _ = conn.execute( 80 + "ALTER TABLE status ADD COLUMN hidden BOOLEAN DEFAULT FALSE", 81 + [], 82 + ); 83 + 84 + Ok(()) 85 + }) 86 + .await?; 87 + Ok(()) 88 + }
+88
src/db/queries.rs
··· 1 + use async_sqlite::Pool; 2 + 3 + use super::models::UserPreferences; 4 + 5 + /// Get the most frequently used emojis from all statuses 6 + pub async fn get_frequent_emojis( 7 + pool: &Pool, 8 + limit: usize, 9 + ) -> Result<Vec<String>, async_sqlite::Error> { 10 + pool.conn(move |conn| { 11 + let mut stmt = conn.prepare( 12 + "SELECT emoji, COUNT(*) as count 13 + FROM status 14 + GROUP BY emoji 15 + ORDER BY count DESC 16 + LIMIT ?1", 17 + )?; 18 + 19 + let emoji_iter = stmt.query_map([limit], |row| row.get::<_, String>(0))?; 20 + 21 + let mut emojis = Vec::new(); 22 + for emoji in emoji_iter { 23 + emojis.push(emoji?); 24 + } 25 + 26 + Ok(emojis) 27 + }) 28 + .await 29 + } 30 + 31 + /// Get user preferences for a given DID 32 + pub async fn get_user_preferences( 33 + pool: &Pool, 34 + did: &str, 35 + ) -> Result<UserPreferences, async_sqlite::Error> { 36 + let did = did.to_string(); 37 + pool.conn(move |conn| { 38 + let mut stmt = conn.prepare( 39 + "SELECT did, font_family, accent_color, updated_at 40 + FROM user_preferences 41 + WHERE did = ?1", 42 + )?; 43 + 44 + let result = stmt.query_row([&did], |row| { 45 + Ok(UserPreferences { 46 + did: row.get(0)?, 47 + font_family: row.get(1)?, 48 + accent_color: row.get(2)?, 49 + updated_at: row.get(3)?, 50 + }) 51 + }); 52 + 53 + match result { 54 + Ok(prefs) => Ok(prefs), 55 + Err(async_sqlite::rusqlite::Error::QueryReturnedNoRows) => { 56 + // Return default preferences for new users 57 + Ok(UserPreferences { 58 + did: did.clone(), 59 + ..Default::default() 60 + }) 61 + } 62 + Err(e) => Err(e), 63 + } 64 + }) 65 + .await 66 + } 67 + 68 + /// Save user preferences 69 + pub async fn save_user_preferences( 70 + pool: &Pool, 71 + prefs: &UserPreferences, 72 + ) -> Result<(), async_sqlite::Error> { 73 + let prefs = prefs.clone(); 74 + pool.conn(move |conn| { 75 + conn.execute( 76 + "INSERT OR REPLACE INTO user_preferences (did, font_family, accent_color, updated_at) 77 + VALUES (?1, ?2, ?3, ?4)", 78 + ( 79 + &prefs.did, 80 + &prefs.font_family, 81 + &prefs.accent_color, 82 + &prefs.updated_at, 83 + ), 84 + )?; 85 + Ok(()) 86 + }) 87 + .await 88 + }
+14 -1507
src/main.rs
··· 1 1 #![allow(clippy::collapsible_if)] 2 2 3 + use crate::resolver::HickoryDnsTxtResolver; 3 4 use crate::{ 4 - db::{StatusFromDb, create_tables_in_database}, 5 - error_handler::AppError, 5 + api::{HandleResolver, OAuthClientType}, 6 + db::create_tables_in_database, 6 7 ingester::start_ingester, 7 - lexicons::record::KnownRecord, 8 8 rate_limiter::RateLimiter, 9 9 storage::{SqliteSessionStore, SqliteStateStore}, 10 - templates::{FeedTemplate, LoginTemplate, StatusTemplate}, 11 10 }; 12 11 use actix_files::Files; 13 - use actix_session::{ 14 - Session, SessionMiddleware, config::PersistentSession, storage::CookieSessionStore, 15 - }; 12 + use actix_session::{SessionMiddleware, config::PersistentSession, storage::CookieSessionStore}; 16 13 use actix_web::{ 17 - App, HttpRequest, HttpResponse, HttpServer, Responder, Result, 14 + App, HttpServer, 18 15 cookie::{self, Key}, 19 - get, middleware, post, 20 - web::{self, Redirect}, 21 - }; 22 - use askama::Template; 23 - use async_sqlite::rusqlite; 24 - use async_sqlite::{Pool, PoolBuilder}; 25 - use atrium_api::{ 26 - agent::Agent, 27 - types::string::{Datetime, Did}, 16 + middleware, web, 28 17 }; 29 - use atrium_common::resolver::Resolver; 18 + use async_sqlite::PoolBuilder; 30 19 use atrium_identity::{ 31 20 did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}, 32 21 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 33 22 }; 34 23 use atrium_oauth::{ 35 - AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, AuthorizeOptions, 36 - CallbackParams, DefaultHttpClient, GrantType, KnownScope, OAuthClient, OAuthClientConfig, 37 - OAuthResolverConfig, Scope, 24 + AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, 25 + GrantType, KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 38 26 }; 39 27 use dotenv::dotenv; 40 - use resolver::HickoryDnsTxtResolver; 41 - use serde::{Deserialize, Serialize}; 42 - use std::{collections::HashMap, io::Error, sync::Arc, time::Duration}; 43 - use templates::{ErrorTemplate, Profile}; 28 + use std::{io::Error, sync::Arc, time::Duration}; 44 29 30 + mod api; 45 31 mod config; 46 32 mod db; 47 33 mod dev_utils; ··· 54 40 mod storage; 55 41 mod templates; 56 42 57 - /// OAuthClientType to make it easier to access the OAuthClient in web requests 58 - /// Custom OAuth callback parameters that can handle both success and error cases 59 - #[derive(Debug, Deserialize)] 60 - struct OAuthCallbackParams { 61 - state: Option<String>, 62 - iss: Option<String>, 63 - code: Option<String>, 64 - error: Option<String>, 65 - error_description: Option<String>, 66 - } 67 - 68 - type OAuthClientType = Arc< 69 - OAuthClient< 70 - SqliteStateStore, 71 - SqliteSessionStore, 72 - CommonDidResolver<DefaultHttpClient>, 73 - AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>, 74 - >, 75 - >; 76 - 77 - /// HandleResolver to make it easier to access the OAuthClient in web requests 78 - type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 79 - 80 - /// Admin DID for moderation 81 - const ADMIN_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 82 - 83 - /// Check if a DID is the admin 84 - fn is_admin(did: &str) -> bool { 85 - did == ADMIN_DID 86 - } 87 - 88 - /// OAuth client metadata endpoint for production 89 - #[get("/oauth-client-metadata.json")] 90 - async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> { 91 - let public_url = config.oauth_redirect_base.clone(); 92 - 93 - let metadata = serde_json::json!({ 94 - "client_id": format!("{}/oauth-client-metadata.json", public_url), 95 - "client_name": "Status Sphere", 96 - "client_uri": public_url.clone(), 97 - "redirect_uris": [format!("{}/oauth/callback", public_url)], 98 - "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app", 99 - "grant_types": ["authorization_code", "refresh_token"], 100 - "response_types": ["code"], 101 - "token_endpoint_auth_method": "none", 102 - "dpop_bound_access_tokens": true 103 - }); 104 - 105 - Ok(HttpResponse::Ok() 106 - .content_type("application/json") 107 - .body(metadata.to_string())) 108 - } 109 - 110 - // Removed STATUS_OPTIONS: emojis are now loaded client-side from CDN 111 - 112 - /// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L71 113 - /// OAuth callback endpoint to complete session creation 114 - #[get("/oauth/callback")] 115 - async fn oauth_callback( 116 - request: HttpRequest, 117 - params: web::Query<OAuthCallbackParams>, 118 - oauth_client: web::Data<OAuthClientType>, 119 - session: Session, 120 - ) -> HttpResponse { 121 - // Check if there's an OAuth error from BlueSky 122 - if let Some(error) = &params.error { 123 - let error_msg = params 124 - .error_description 125 - .as_deref() 126 - .unwrap_or("An error occurred during authentication"); 127 - log::error!("OAuth error from BlueSky: {} - {}", error, error_msg); 128 - 129 - let html = ErrorTemplate { 130 - title: "Authentication Error", 131 - error: error_msg, 132 - }; 133 - return HttpResponse::BadRequest().body(html.render().expect("template should be valid")); 134 - } 135 - 136 - // Check if we have the required code field for a successful callback 137 - let code = match &params.code { 138 - Some(code) => code.clone(), 139 - None => { 140 - log::error!("OAuth callback missing required code parameter"); 141 - let html = ErrorTemplate { 142 - title: "Error", 143 - error: "Missing required OAuth code. Please try logging in again.", 144 - }; 145 - return HttpResponse::BadRequest() 146 - .body(html.render().expect("template should be valid")); 147 - } 148 - }; 149 - 150 - // Create CallbackParams for the OAuth client 151 - let callback_params = CallbackParams { 152 - code, 153 - state: params.state.clone(), 154 - iss: params.iss.clone(), 155 - }; 156 - 157 - //Processes the call back and parses out a session if found and valid 158 - match oauth_client.callback(callback_params).await { 159 - Ok((bsky_session, _)) => { 160 - let agent = Agent::new(bsky_session); 161 - match agent.did().await { 162 - Some(did) => { 163 - session.insert("did", did).unwrap(); 164 - Redirect::to("/") 165 - .see_other() 166 - .respond_to(&request) 167 - .map_into_boxed_body() 168 - } 169 - None => { 170 - let html = ErrorTemplate { 171 - title: "Error", 172 - error: "The OAuth agent did not return a DID. May try re-logging in.", 173 - }; 174 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 175 - } 176 - } 177 - } 178 - Err(err) => { 179 - log::error!("Error: {err}"); 180 - let html = ErrorTemplate { 181 - title: "Error", 182 - error: "OAuth error, check the logs", 183 - }; 184 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 185 - } 186 - } 187 - } 188 - 189 - /// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L93 190 - /// Takes you to the login page 191 - #[get("/login")] 192 - async fn login() -> Result<impl Responder> { 193 - let html = LoginTemplate { 194 - title: "Log in", 195 - error: None, 196 - }; 197 - Ok(web::Html::new( 198 - html.render().expect("template should be valid"), 199 - )) 200 - } 201 - 202 - /// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L93 203 - /// Logs you out by destroying your cookie on the server and web browser 204 - #[get("/logout")] 205 - async fn logout(request: HttpRequest, session: Session) -> HttpResponse { 206 - session.purge(); 207 - Redirect::to("/") 208 - .see_other() 209 - .respond_to(&request) 210 - .map_into_boxed_body() 211 - } 212 - 213 - /// The post body for logging in 214 - #[derive(Serialize, Deserialize, Clone)] 215 - struct LoginForm { 216 - handle: String, 217 - } 218 - 219 - /// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L101 220 - /// Login endpoint 221 - #[post("/login")] 222 - async fn login_post( 223 - request: HttpRequest, 224 - params: web::Form<LoginForm>, 225 - oauth_client: web::Data<OAuthClientType>, 226 - ) -> HttpResponse { 227 - // This will act the same as the js method isValidHandle to make sure it is valid 228 - match atrium_api::types::string::Handle::new(params.handle.clone()) { 229 - Ok(handle) => { 230 - //Creates the oauth url to redirect to for the user to log in with their credentials 231 - let oauth_url = oauth_client 232 - .authorize( 233 - &handle, 234 - AuthorizeOptions { 235 - scopes: vec![ 236 - Scope::Known(KnownScope::Atproto), 237 - // Using granular scope for status records only 238 - // This replaces TransitionGeneric with specific permissions 239 - Scope::Unknown("repo:io.zzstoatzz.status.record".to_string()), 240 - // Need to read profiles for the feed page 241 - Scope::Unknown("rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview".to_string()), 242 - // Need to read following list for following feed 243 - Scope::Unknown("rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app".to_string()), 244 - ], 245 - ..Default::default() 246 - }, 247 - ) 248 - .await; 249 - match oauth_url { 250 - Ok(url) => Redirect::to(url) 251 - .see_other() 252 - .respond_to(&request) 253 - .map_into_boxed_body(), 254 - Err(err) => { 255 - log::error!("Error: {err}"); 256 - let html = LoginTemplate { 257 - title: "Log in", 258 - error: Some("OAuth error"), 259 - }; 260 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 261 - } 262 - } 263 - } 264 - Err(err) => { 265 - let html: LoginTemplate<'_> = LoginTemplate { 266 - title: "Log in", 267 - error: Some(err), 268 - }; 269 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 270 - } 271 - } 272 - } 273 - 274 - /// Homepage - shows logged-in user's status, or owner's status if not logged in 275 - #[get("/")] 276 - async fn home( 277 - session: Session, 278 - _oauth_client: web::Data<OAuthClientType>, 279 - db_pool: web::Data<Arc<Pool>>, 280 - handle_resolver: web::Data<HandleResolver>, 281 - ) -> Result<impl Responder> { 282 - // Default owner of the domain 283 - const OWNER_HANDLE: &str = "zzstoatzz.io"; 284 - 285 - // Check if user is logged in 286 - match session.get::<String>("did").unwrap_or(None) { 287 - Some(did_string) => { 288 - // User is logged in - show their status page 289 - log::debug!("Home: User is logged in with DID: {}", did_string); 290 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 291 - 292 - // Get their handle 293 - let handle = match handle_resolver.resolve(&did).await { 294 - Ok(did_doc) => did_doc 295 - .also_known_as 296 - .and_then(|aka| aka.first().map(|h| h.replace("at://", ""))) 297 - .unwrap_or_else(|| did_string.clone()), 298 - Err(_) => did_string.clone(), 299 - }; 300 - 301 - // Get user's status 302 - let current_status = StatusFromDb::my_status(&db_pool, &did) 303 - .await 304 - .unwrap_or(None) 305 - .and_then(|s| { 306 - // Check if status is expired 307 - if let Some(expires_at) = s.expires_at { 308 - if chrono::Utc::now() > expires_at { 309 - return None; // Status expired 310 - } 311 - } 312 - Some(s) 313 - }); 314 - 315 - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 316 - .await 317 - .unwrap_or_else(|err| { 318 - log::error!("Error loading status history: {err}"); 319 - vec![] 320 - }); 321 - 322 - let html = StatusTemplate { 323 - title: "your status", 324 - handle, 325 - current_status, 326 - history, 327 - is_owner: true, // They're viewing their own status 328 - } 329 - .render() 330 - .expect("template should be valid"); 331 - 332 - Ok(web::Html::new(html)) 333 - } 334 - None => { 335 - // Not logged in - show owner's status 336 - // Resolve owner handle to DID 337 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 338 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 339 - http_client: Arc::new(DefaultHttpClient::default()), 340 - }); 341 - 342 - let owner_handle = 343 - atrium_api::types::string::Handle::new(OWNER_HANDLE.to_string()).ok(); 344 - let owner_did = if let Some(handle) = owner_handle { 345 - atproto_handle_resolver.resolve(&handle).await.ok() 346 - } else { 347 - None 348 - }; 349 - 350 - let current_status = if let Some(ref did) = owner_did { 351 - StatusFromDb::my_status(&db_pool, did) 352 - .await 353 - .unwrap_or(None) 354 - .and_then(|s| { 355 - // Check if status is expired 356 - if let Some(expires_at) = s.expires_at { 357 - if chrono::Utc::now() > expires_at { 358 - return None; // Status expired 359 - } 360 - } 361 - Some(s) 362 - }) 363 - } else { 364 - None 365 - }; 366 - 367 - let history = if let Some(ref did) = owner_did { 368 - StatusFromDb::load_user_statuses(&db_pool, did, 10) 369 - .await 370 - .unwrap_or_else(|err| { 371 - log::error!("Error loading status history: {err}"); 372 - vec![] 373 - }) 374 - } else { 375 - vec![] 376 - }; 377 - 378 - let html = StatusTemplate { 379 - title: "nate's status", 380 - handle: OWNER_HANDLE.to_string(), 381 - current_status, 382 - history, 383 - is_owner: false, // Visitor viewing owner's status 384 - } 385 - .render() 386 - .expect("template should be valid"); 387 - 388 - Ok(web::Html::new(html)) 389 - } 390 - } 391 - } 392 - 393 - /// View a specific user's status page by handle 394 - #[get("/@{handle}")] 395 - async fn user_status_page( 396 - handle: web::Path<String>, 397 - session: Session, 398 - db_pool: web::Data<Arc<Pool>>, 399 - _handle_resolver: web::Data<HandleResolver>, 400 - ) -> Result<impl Responder> { 401 - let handle = handle.into_inner(); 402 - 403 - // Resolve handle to DID using ATProto handle resolution 404 - // First we need to create a handle resolver 405 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 406 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 407 - http_client: Arc::new(DefaultHttpClient::default()), 408 - }); 409 - 410 - let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 411 - let did = match handle_obj { 412 - Some(h) => match atproto_handle_resolver.resolve(&h).await { 413 - Ok(did) => did, 414 - Err(_) => { 415 - // Could not resolve handle 416 - let html = ErrorTemplate { 417 - title: "User not found", 418 - error: &format!("Could not find user @{}. This handle may not exist or may not be using the ATProto network.", handle), 419 - } 420 - .render() 421 - .expect("template should be valid"); 422 - return Ok(web::Html::new(html)); 423 - } 424 - }, 425 - None => { 426 - // Invalid handle format 427 - let html = ErrorTemplate { 428 - title: "Invalid handle", 429 - error: &format!( 430 - "'{}' is not a valid handle format. Handles should be like 'alice.bsky.social'", 431 - handle 432 - ), 433 - } 434 - .render() 435 - .expect("template should be valid"); 436 - return Ok(web::Html::new(html)); 437 - } 438 - }; 439 - 440 - // Check if logged in user is viewing their own page 441 - let is_owner = match session.get::<String>("did").unwrap_or(None) { 442 - Some(session_did) => session_did == did.to_string(), 443 - None => false, 444 - }; 445 - 446 - // Get user's status 447 - let current_status = StatusFromDb::my_status(&db_pool, &did) 448 - .await 449 - .unwrap_or(None) 450 - .and_then(|s| { 451 - // Check if status is expired 452 - if let Some(expires_at) = s.expires_at { 453 - if chrono::Utc::now() > expires_at { 454 - return None; // Status expired 455 - } 456 - } 457 - Some(s) 458 - }); 459 - 460 - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 461 - .await 462 - .unwrap_or_else(|err| { 463 - log::error!("Error loading status history: {err}"); 464 - vec![] 465 - }); 466 - 467 - let html = StatusTemplate { 468 - title: &format!("@{} status", handle), 469 - handle: handle.clone(), 470 - current_status, 471 - history, 472 - is_owner, 473 - } 474 - .render() 475 - .expect("template should be valid"); 476 - 477 - Ok(web::Html::new(html)) 478 - } 479 - 480 - /// JSON API for the owner's status (top-level endpoint) 481 - #[get("/json")] 482 - async fn owner_status_json( 483 - db_pool: web::Data<Arc<Pool>>, 484 - _handle_resolver: web::Data<HandleResolver>, 485 - ) -> Result<impl Responder> { 486 - // Default owner of the domain 487 - const OWNER_HANDLE: &str = "zzstoatzz.io"; 488 - 489 - // Resolve handle to DID using ATProto handle resolution 490 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 491 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 492 - http_client: Arc::new(DefaultHttpClient::default()), 493 - }); 494 - 495 - let did = match atproto_handle_resolver 496 - .resolve(&OWNER_HANDLE.parse().expect("failed to parse handle")) 497 - .await 498 - { 499 - Ok(d) => Some(d.to_string()), 500 - Err(e) => { 501 - log::error!("Failed to resolve handle {}: {}", OWNER_HANDLE, e); 502 - None 503 - } 504 - }; 505 - 506 - let current_status = if let Some(did) = did { 507 - let did = Did::new(did).expect("failed to parse did"); 508 - StatusFromDb::my_status(&db_pool, &did) 509 - .await 510 - .unwrap_or(None) 511 - .and_then(|s| { 512 - // Check if status is expired 513 - if let Some(expires_at) = s.expires_at { 514 - if chrono::Utc::now() > expires_at { 515 - return None; // Status expired 516 - } 517 - } 518 - Some(s) 519 - }) 520 - } else { 521 - None 522 - }; 523 - 524 - let response = if let Some(status_data) = current_status { 525 - serde_json::json!({ 526 - "handle": OWNER_HANDLE, 527 - "status": "known", 528 - "emoji": status_data.status, 529 - "text": status_data.text, 530 - "since": status_data.started_at.to_rfc3339(), 531 - "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 532 - }) 533 - } else { 534 - serde_json::json!({ 535 - "handle": OWNER_HANDLE, 536 - "status": "unknown", 537 - "message": "No current status is known" 538 - }) 539 - }; 540 - 541 - Ok(web::Json(response)) 542 - } 543 - 544 - /// Get paginated statuses for infinite scrolling 545 - #[get("/api/feed")] 546 - async fn api_feed( 547 - query: web::Query<HashMap<String, String>>, 548 - db_pool: web::Data<Arc<Pool>>, 549 - handle_resolver: web::Data<HandleResolver>, 550 - config: web::Data<config::Config>, 551 - ) -> Result<impl Responder> { 552 - let offset = query 553 - .get("offset") 554 - .and_then(|s| s.parse::<i32>().ok()) 555 - .unwrap_or(0); 556 - let limit = query 557 - .get("limit") 558 - .and_then(|s| s.parse::<i32>().ok()) 559 - .unwrap_or(20) 560 - .min(50); // Cap at 50 items per request 561 - 562 - // Check if dev mode is requested 563 - let use_dev_mode = config.dev_mode && query.get("dev").is_some_and(|v| v == "true" || v == "1"); 564 - 565 - let mut statuses = if use_dev_mode && offset == 0 { 566 - // For first page in dev mode, mix dummy data with real data 567 - let mut real_statuses = StatusFromDb::load_statuses_paginated(&db_pool, 0, limit / 2) 568 - .await 569 - .unwrap_or_else(|err| { 570 - log::error!("Error loading paginated statuses: {err}"); 571 - vec![] 572 - }); 573 - let dummy_statuses = dev_utils::generate_dummy_statuses((limit / 2) as usize); 574 - real_statuses.extend(dummy_statuses); 575 - real_statuses.sort_by(|a, b| b.started_at.cmp(&a.started_at)); 576 - real_statuses 577 - } else { 578 - StatusFromDb::load_statuses_paginated(&db_pool, offset, limit) 579 - .await 580 - .unwrap_or_else(|err| { 581 - log::error!("Error loading statuses: {err}"); 582 - vec![] 583 - }) 584 - }; 585 - 586 - // Resolve handles for each status 587 - let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 588 - for db_status in &mut statuses { 589 - let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 590 - match quick_resolve_map.get(&authors_did) { 591 - None => {} 592 - Some(found_handle) => { 593 - db_status.handle = Some(found_handle.clone()); 594 - continue; 595 - } 596 - } 597 - db_status.handle = match handle_resolver.resolve(&authors_did).await { 598 - Ok(did_doc) => match did_doc.also_known_as { 599 - None => None, 600 - Some(also_known_as) => match also_known_as.is_empty() { 601 - true => None, 602 - false => { 603 - let full_handle = also_known_as.first().unwrap(); 604 - let handle = full_handle.replace("at://", ""); 605 - quick_resolve_map.insert(authors_did, handle.clone()); 606 - Some(handle) 607 - } 608 - }, 609 - }, 610 - Err(_) => None, 611 - }; 612 - } 613 - 614 - Ok(HttpResponse::Ok().json(statuses)) 615 - } 616 - 617 - /// Get the most frequently used emojis from all statuses 618 - #[get("/api/frequent-emojis")] 619 - async fn get_frequent_emojis(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 620 - // Get top 20 most frequently used emojis 621 - let emojis = db::get_frequent_emojis(&db_pool, 20) 622 - .await 623 - .unwrap_or_else(|err| { 624 - log::error!("Failed to get frequent emojis: {}", err); 625 - Vec::new() 626 - }); 627 - 628 - // If we have less than 12 emojis, add some defaults to fill it out 629 - let mut result = emojis; 630 - if result.is_empty() { 631 - log::info!("No emoji usage data found, using defaults"); 632 - let defaults = vec![ 633 - "😊", "👍", "❤️", "😂", "🎉", "🔥", "✨", "💯", "🚀", "💪", "🙏", "👏", 634 - ]; 635 - result = defaults.into_iter().map(String::from).collect(); 636 - } else if result.len() < 12 { 637 - log::info!("Found {} emojis, padding with defaults", result.len()); 638 - let defaults = vec![ 639 - "😊", "👍", "❤️", "😂", "🎉", "🔥", "✨", "💯", "🚀", "💪", "🙏", "👏", 640 - ]; 641 - for emoji in defaults { 642 - if !result.contains(&emoji.to_string()) && result.len() < 20 { 643 - result.push(emoji.to_string()); 644 - } 645 - } 646 - } else { 647 - log::info!("Found {} frequently used emojis", result.len()); 648 - } 649 - 650 - Ok(web::Json(result)) 651 - } 652 - 653 - /// Get all custom emojis available on the site 654 - #[get("/api/custom-emojis")] 655 - async fn get_custom_emojis() -> Result<impl Responder> { 656 - use std::fs; 657 - 658 - #[derive(Serialize)] 659 - struct SimpleEmoji { 660 - name: String, 661 - filename: String, 662 - } 663 - 664 - let emojis_dir = "static/emojis"; 665 - let mut emojis = Vec::new(); 666 - 667 - if let Ok(entries) = fs::read_dir(emojis_dir) { 668 - for entry in entries.flatten() { 669 - if let Some(filename) = entry.file_name().to_str() { 670 - // Only include image files 671 - if filename.ends_with(".png") 672 - || filename.ends_with(".gif") 673 - || filename.ends_with(".jpg") 674 - || filename.ends_with(".webp") 675 - { 676 - // Remove file extension to get name 677 - let name = filename 678 - .rsplit_once('.') 679 - .map(|(name, _)| name) 680 - .unwrap_or(filename) 681 - .to_string(); 682 - emojis.push(SimpleEmoji { 683 - name: name.clone(), 684 - filename: filename.to_string(), 685 - }); 686 - } 687 - } 688 - } 689 - } 690 - 691 - // Sort by name 692 - emojis.sort_by(|a, b| a.name.cmp(&b.name)); 693 - 694 - Ok(HttpResponse::Ok().json(emojis)) 695 - } 696 - 697 - /// Get the DIDs of accounts the logged-in user follows 698 - #[get("/api/following")] 699 - async fn get_following( 700 - session: Session, 701 - _oauth_client: web::Data<OAuthClientType>, 702 - ) -> Result<impl Responder> { 703 - // Check if user is logged in 704 - let did = match session.get::<Did>("did").ok().flatten() { 705 - Some(did) => did, 706 - None => { 707 - return Ok(HttpResponse::Unauthorized().json(serde_json::json!({ 708 - "error": "Not logged in" 709 - }))); 710 - } 711 - }; 712 - 713 - // WORKAROUND: Call public API directly for getFollows since OAuth scope isn't working 714 - // Both getProfile and getFollows are public endpoints that don't require auth 715 - // but when called through OAuth, getFollows requires a scope that doesn't exist yet 716 - 717 - let mut all_follows = Vec::new(); 718 - let mut cursor: Option<String> = None; 719 - 720 - // Use reqwest to call the public API directly 721 - let client = reqwest::Client::new(); 722 - 723 - loop { 724 - let mut url = format!( 725 - "https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor={}", 726 - did.as_str() 727 - ); 728 - 729 - if let Some(c) = &cursor { 730 - url.push_str(&format!("&cursor={}", c)); 731 - } 732 - 733 - match client.get(&url).send().await { 734 - Ok(response) => { 735 - match response.json::<serde_json::Value>().await { 736 - Ok(json) => { 737 - // Extract follows 738 - if let Some(follows) = json["follows"].as_array() { 739 - for follow in follows { 740 - if let Some(did_str) = follow["did"].as_str() { 741 - all_follows.push(did_str.to_string()); 742 - } 743 - } 744 - } 745 - 746 - // Check for cursor 747 - cursor = json["cursor"].as_str().map(|s| s.to_string()); 748 - if cursor.is_none() { 749 - break; 750 - } 751 - } 752 - Err(err) => { 753 - log::error!("Failed to parse follows response: {}", err); 754 - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 755 - "error": "Failed to parse follows" 756 - }))); 757 - } 758 - } 759 - } 760 - Err(err) => { 761 - log::error!("Failed to fetch follows from public API: {}", err); 762 - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 763 - "error": "Failed to fetch follows" 764 - }))); 765 - } 766 - } 767 - } 768 - 769 - Ok(HttpResponse::Ok().json(serde_json::json!({ 770 - "follows": all_follows 771 - }))) 772 - } 773 - 774 - /// JSON API for a specific user's status 775 - #[get("/@{handle}/json")] 776 - async fn user_status_json( 777 - handle: web::Path<String>, 778 - db_pool: web::Data<Arc<Pool>>, 779 - _handle_resolver: web::Data<HandleResolver>, 780 - ) -> Result<impl Responder> { 781 - let handle = handle.into_inner(); 782 - 783 - // Resolve handle to DID using ATProto handle resolution 784 - // First we need to create a handle resolver 785 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 786 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 787 - http_client: Arc::new(DefaultHttpClient::default()), 788 - }); 789 - 790 - let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 791 - let did = match handle_obj { 792 - Some(h) => match atproto_handle_resolver.resolve(&h).await { 793 - Ok(did) => did, 794 - Err(_) => { 795 - return Ok(web::Json(serde_json::json!({ 796 - "status": "unknown", 797 - "message": format!("Could not resolve handle @{}", handle) 798 - }))); 799 - } 800 - }, 801 - None => { 802 - return Ok(web::Json(serde_json::json!({ 803 - "status": "unknown", 804 - "message": format!("Invalid handle format: @{}", handle) 805 - }))); 806 - } 807 - }; 808 - 809 - let current_status = StatusFromDb::my_status(&db_pool, &did) 810 - .await 811 - .unwrap_or(None) 812 - .and_then(|s| { 813 - // Check if status is expired 814 - if let Some(expires_at) = s.expires_at { 815 - if chrono::Utc::now() > expires_at { 816 - return None; // Status expired 817 - } 818 - } 819 - Some(s) 820 - }); 821 - 822 - let response = if let Some(status_data) = current_status { 823 - serde_json::json!({ 824 - "status": "known", 825 - "emoji": status_data.status, 826 - "text": status_data.text, 827 - "since": status_data.started_at.to_rfc3339(), 828 - "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 829 - }) 830 - } else { 831 - serde_json::json!({ 832 - "status": "unknown", 833 - "message": format!("No current status is known for @{}", handle) 834 - }) 835 - }; 836 - 837 - Ok(web::Json(response)) 838 - } 839 - 840 - /// JSON API endpoint for status - returns current status or "unknown" 841 - #[get("/api/status")] 842 - async fn status_json(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 843 - const OWNER_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 844 - 845 - let owner_did = Did::new(OWNER_DID.to_string()).ok(); 846 - let current_status = if let Some(ref did) = owner_did { 847 - StatusFromDb::my_status(&db_pool, did) 848 - .await 849 - .unwrap_or(None) 850 - .and_then(|s| { 851 - // Check if status is expired 852 - if let Some(expires_at) = s.expires_at { 853 - if chrono::Utc::now() > expires_at { 854 - return None; // Status expired 855 - } 856 - } 857 - Some(s) 858 - }) 859 - } else { 860 - None 861 - }; 862 - 863 - let response = if let Some(status_data) = current_status { 864 - serde_json::json!({ 865 - "status": "known", 866 - "emoji": status_data.status, 867 - "text": status_data.text, 868 - "since": status_data.started_at.to_rfc3339(), 869 - "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 870 - }) 871 - } else { 872 - serde_json::json!({ 873 - "status": "unknown", 874 - "message": "No current status is known" 875 - }) 876 - }; 877 - 878 - Ok(web::Json(response)) 879 - } 880 - 881 - /// Get user preferences 882 - #[get("/api/preferences")] 883 - async fn get_preferences( 884 - session: Session, 885 - db_pool: web::Data<Arc<Pool>>, 886 - ) -> Result<impl Responder> { 887 - let did = session.get::<Did>("did")?; 888 - 889 - if let Some(did) = did { 890 - let prefs = db::get_user_preferences(&db_pool, did.as_str()) 891 - .await 892 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 893 - Ok(web::Json(serde_json::json!({ 894 - "font_family": prefs.font_family, 895 - "accent_color": prefs.accent_color 896 - }))) 897 - } else { 898 - Ok(web::Json(serde_json::json!({ 899 - "error": "Not authenticated" 900 - }))) 901 - } 902 - } 903 - 904 - #[derive(Deserialize)] 905 - struct PreferencesUpdate { 906 - font_family: Option<String>, 907 - accent_color: Option<String>, 908 - } 909 - 910 - /// Save user preferences 911 - #[post("/api/preferences")] 912 - async fn save_preferences( 913 - session: Session, 914 - db_pool: web::Data<Arc<Pool>>, 915 - payload: web::Json<PreferencesUpdate>, 916 - ) -> Result<impl Responder> { 917 - let did = session.get::<Did>("did")?; 918 - 919 - if let Some(did) = did { 920 - let mut prefs = db::get_user_preferences(&db_pool, did.as_str()) 921 - .await 922 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 923 - 924 - if let Some(font) = &payload.font_family { 925 - prefs.font_family = font.clone(); 926 - } 927 - if let Some(color) = &payload.accent_color { 928 - prefs.accent_color = color.clone(); 929 - } 930 - prefs.updated_at = std::time::SystemTime::now() 931 - .duration_since(std::time::UNIX_EPOCH) 932 - .unwrap() 933 - .as_secs() as i64; 934 - 935 - db::save_user_preferences(&db_pool, &prefs) 936 - .await 937 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 938 - 939 - Ok(web::Json(serde_json::json!({ 940 - "success": true 941 - }))) 942 - } else { 943 - Ok(web::Json(serde_json::json!({ 944 - "error": "Not authenticated" 945 - }))) 946 - } 947 - } 948 - 949 - /// Feed page - shows all users' statuses 950 - #[get("/feed")] 951 - async fn feed( 952 - request: HttpRequest, 953 - session: Session, 954 - oauth_client: web::Data<OAuthClientType>, 955 - db_pool: web::Data<Arc<Pool>>, 956 - handle_resolver: web::Data<HandleResolver>, 957 - config: web::Data<config::Config>, 958 - ) -> Result<impl Responder> { 959 - // This is essentially the old home function 960 - const TITLE: &str = "status feed"; 961 - 962 - // Check if dev mode is active 963 - let query = request.query_string(); 964 - let use_dev_mode = config.dev_mode && dev_utils::is_dev_mode_requested(query); 965 - 966 - let mut statuses = if use_dev_mode { 967 - // Mix dummy data with real data for testing 968 - let mut real_statuses = StatusFromDb::load_latest_statuses(&db_pool) 969 - .await 970 - .unwrap_or_else(|err| { 971 - log::error!("Error loading statuses: {err}"); 972 - vec![] 973 - }); 974 - let dummy_statuses = dev_utils::generate_dummy_statuses(15); 975 - real_statuses.extend(dummy_statuses); 976 - // Resort by started_at 977 - real_statuses.sort_by(|a, b| b.started_at.cmp(&a.started_at)); 978 - real_statuses 979 - } else { 980 - StatusFromDb::load_latest_statuses(&db_pool) 981 - .await 982 - .unwrap_or_else(|err| { 983 - log::error!("Error loading statuses: {err}"); 984 - vec![] 985 - }) 986 - }; 987 - 988 - let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 989 - for db_status in &mut statuses { 990 - let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 991 - match quick_resolve_map.get(&authors_did) { 992 - None => {} 993 - Some(found_handle) => { 994 - db_status.handle = Some(found_handle.clone()); 995 - continue; 996 - } 997 - } 998 - db_status.handle = match handle_resolver.resolve(&authors_did).await { 999 - Ok(did_doc) => match did_doc.also_known_as { 1000 - None => None, 1001 - Some(also_known_as) => match also_known_as.is_empty() { 1002 - true => None, 1003 - false => { 1004 - let full_handle = also_known_as.first().unwrap(); 1005 - let handle = full_handle.replace("at://", ""); 1006 - quick_resolve_map.insert(authors_did, handle.clone()); 1007 - Some(handle) 1008 - } 1009 - }, 1010 - }, 1011 - Err(err) => { 1012 - log::error!("Error resolving did: {err}"); 1013 - None 1014 - } 1015 - }; 1016 - } 1017 - 1018 - match session.get::<String>("did").unwrap_or(None) { 1019 - Some(did_string) => { 1020 - log::debug!("Feed: User has session with DID: {}", did_string); 1021 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1022 - let _my_status = StatusFromDb::my_status(&db_pool, &did) 1023 - .await 1024 - .unwrap_or_else(|err| { 1025 - log::error!("Error loading my status: {err}"); 1026 - None 1027 - }); 1028 - 1029 - log::debug!( 1030 - "Feed: Attempting to restore OAuth session for DID: {}", 1031 - did_string 1032 - ); 1033 - match oauth_client.restore(&did).await { 1034 - Ok(session) => { 1035 - log::debug!("Feed: Successfully restored OAuth session"); 1036 - let agent = Agent::new(session); 1037 - let profile = agent 1038 - .api 1039 - .app 1040 - .bsky 1041 - .actor 1042 - .get_profile( 1043 - atrium_api::app::bsky::actor::get_profile::ParametersData { 1044 - actor: atrium_api::types::string::AtIdentifier::Did(did.clone()), 1045 - } 1046 - .into(), 1047 - ) 1048 - .await; 1049 - 1050 - let is_admin = is_admin(did.as_str()); 1051 - let html = FeedTemplate { 1052 - title: TITLE, 1053 - profile: match profile { 1054 - Ok(profile) => { 1055 - let profile_data = Profile { 1056 - did: profile.did.to_string(), 1057 - display_name: profile.display_name.clone(), 1058 - }; 1059 - Some(profile_data) 1060 - } 1061 - Err(err) => { 1062 - log::error!("Error accessing profile: {err}"); 1063 - None 1064 - } 1065 - }, 1066 - statuses, 1067 - is_admin, 1068 - dev_mode: use_dev_mode, 1069 - } 1070 - .render() 1071 - .expect("template should be valid"); 1072 - 1073 - Ok(web::Html::new(html)) 1074 - } 1075 - Err(err) => { 1076 - // Don't purge the session - OAuth tokens might be expired but user is still logged in 1077 - log::warn!("Could not restore OAuth session for feed: {:?}", err); 1078 - 1079 - // Show feed without profile info instead of error page 1080 - let html = FeedTemplate { 1081 - title: TITLE, 1082 - profile: None, 1083 - statuses, 1084 - is_admin: is_admin(did.as_str()), 1085 - dev_mode: use_dev_mode, 1086 - } 1087 - .render() 1088 - .expect("template should be valid"); 1089 - 1090 - Ok(web::Html::new(html)) 1091 - } 1092 - } 1093 - } 1094 - None => { 1095 - let html = FeedTemplate { 1096 - title: TITLE, 1097 - profile: None, 1098 - statuses, 1099 - is_admin: false, 1100 - dev_mode: use_dev_mode, 1101 - } 1102 - .render() 1103 - .expect("template should be valid"); 1104 - 1105 - Ok(web::Html::new(html)) 1106 - } 1107 - } 1108 - } 1109 - 1110 - /// The post body for changing your status 1111 - #[derive(Serialize, Deserialize, Clone)] 1112 - struct StatusForm { 1113 - status: String, 1114 - text: Option<String>, 1115 - expires_in: Option<String>, // e.g., "1h", "30m", "1d", etc. 1116 - } 1117 - 1118 - /// The post body for deleting a specific status 1119 - #[derive(Serialize, Deserialize)] 1120 - struct DeleteRequest { 1121 - uri: String, 1122 - } 1123 - 1124 - /// Parse duration string like "1h", "30m", "1d" into chrono::Duration 1125 - fn parse_duration(duration_str: &str) -> Option<chrono::Duration> { 1126 - if duration_str.is_empty() { 1127 - return None; 1128 - } 1129 - 1130 - let (num_str, unit) = duration_str.split_at(duration_str.len() - 1); 1131 - let num: i64 = num_str.parse().ok()?; 1132 - 1133 - match unit { 1134 - "m" => Some(chrono::Duration::minutes(num)), 1135 - "h" => Some(chrono::Duration::hours(num)), 1136 - "d" => Some(chrono::Duration::days(num)), 1137 - "w" => Some(chrono::Duration::weeks(num)), 1138 - _ => None, 1139 - } 1140 - } 1141 - 1142 - /// Clear the user's status by deleting the ATProto record 1143 - #[post("/status/clear")] 1144 - async fn clear_status( 1145 - request: HttpRequest, 1146 - session: Session, 1147 - oauth_client: web::Data<OAuthClientType>, 1148 - db_pool: web::Data<Arc<Pool>>, 1149 - ) -> HttpResponse { 1150 - // Check if the user is logged in 1151 - match session.get::<String>("did").unwrap_or(None) { 1152 - Some(did_string) => { 1153 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1154 - 1155 - // Get the user's current status to find the record key 1156 - match StatusFromDb::my_status(&db_pool, &did).await { 1157 - Ok(Some(current_status)) => { 1158 - // Extract the record key from the URI 1159 - // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 1160 - let parts: Vec<&str> = current_status.uri.split('/').collect(); 1161 - if let Some(rkey) = parts.last() { 1162 - // Get OAuth session 1163 - match oauth_client.restore(&did).await { 1164 - Ok(session) => { 1165 - let agent = Agent::new(session); 1166 - 1167 - // Delete the record from ATProto using com.atproto.repo.deleteRecord 1168 - let delete_request = 1169 - atrium_api::com::atproto::repo::delete_record::InputData { 1170 - collection: atrium_api::types::string::Nsid::new( 1171 - "io.zzstoatzz.status.record".to_string(), 1172 - ) 1173 - .expect("valid nsid"), 1174 - repo: did.clone().into(), 1175 - rkey: atrium_api::types::string::RecordKey::new( 1176 - rkey.to_string(), 1177 - ) 1178 - .expect("valid rkey"), 1179 - swap_commit: None, 1180 - swap_record: None, 1181 - }; 1182 - match agent 1183 - .api 1184 - .com 1185 - .atproto 1186 - .repo 1187 - .delete_record(delete_request.into()) 1188 - .await 1189 - { 1190 - Ok(_) => { 1191 - // Also remove from local database 1192 - let _ = StatusFromDb::delete_by_uri( 1193 - &db_pool, 1194 - current_status.uri, 1195 - ) 1196 - .await; 1197 - 1198 - Redirect::to("/") 1199 - .see_other() 1200 - .respond_to(&request) 1201 - .map_into_boxed_body() 1202 - } 1203 - Err(e) => { 1204 - log::error!("Failed to delete status from ATProto: {e}"); 1205 - HttpResponse::InternalServerError() 1206 - .body("Failed to clear status") 1207 - } 1208 - } 1209 - } 1210 - Err(e) => { 1211 - log::error!("Failed to restore OAuth session: {e}"); 1212 - HttpResponse::InternalServerError().body("Session error") 1213 - } 1214 - } 1215 - } else { 1216 - HttpResponse::BadRequest().body("Invalid status URI") 1217 - } 1218 - } 1219 - Ok(None) => { 1220 - // No status to clear 1221 - Redirect::to("/") 1222 - .see_other() 1223 - .respond_to(&request) 1224 - .map_into_boxed_body() 1225 - } 1226 - Err(e) => { 1227 - log::error!("Database error: {e}"); 1228 - HttpResponse::InternalServerError().body("Database error") 1229 - } 1230 - } 1231 - } 1232 - None => { 1233 - // Not logged in 1234 - Redirect::to("/login") 1235 - .see_other() 1236 - .respond_to(&request) 1237 - .map_into_boxed_body() 1238 - } 1239 - } 1240 - } 1241 - 1242 - /// Delete a specific status by URI (JSON endpoint) 1243 - #[post("/status/delete")] 1244 - async fn delete_status( 1245 - session: Session, 1246 - oauth_client: web::Data<OAuthClientType>, 1247 - db_pool: web::Data<Arc<Pool>>, 1248 - req: web::Json<DeleteRequest>, 1249 - ) -> HttpResponse { 1250 - // Check if the user is logged in 1251 - match session.get::<String>("did").unwrap_or(None) { 1252 - Some(did_string) => { 1253 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1254 - 1255 - // Parse the URI to verify it belongs to this user 1256 - // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 1257 - let uri_parts: Vec<&str> = req.uri.split('/').collect(); 1258 - if uri_parts.len() < 5 { 1259 - return HttpResponse::BadRequest().json(serde_json::json!({ 1260 - "error": "Invalid status URI format" 1261 - })); 1262 - } 1263 - 1264 - // Extract DID from URI (at://did:plc:xxx/...) 1265 - let uri_did_part = uri_parts[2]; 1266 - if uri_did_part != did_string { 1267 - return HttpResponse::Forbidden().json(serde_json::json!({ 1268 - "error": "You can only delete your own statuses" 1269 - })); 1270 - } 1271 - 1272 - // Extract record key 1273 - if let Some(rkey) = uri_parts.last() { 1274 - // Get OAuth session 1275 - match oauth_client.restore(&did).await { 1276 - Ok(session) => { 1277 - let agent = Agent::new(session); 1278 - 1279 - // Delete the record from ATProto 1280 - let delete_request = 1281 - atrium_api::com::atproto::repo::delete_record::InputData { 1282 - collection: atrium_api::types::string::Nsid::new( 1283 - "io.zzstoatzz.status.record".to_string(), 1284 - ) 1285 - .expect("valid nsid"), 1286 - repo: did.clone().into(), 1287 - rkey: atrium_api::types::string::RecordKey::new(rkey.to_string()) 1288 - .expect("valid rkey"), 1289 - swap_commit: None, 1290 - swap_record: None, 1291 - }; 1292 - 1293 - match agent 1294 - .api 1295 - .com 1296 - .atproto 1297 - .repo 1298 - .delete_record(delete_request.into()) 1299 - .await 1300 - { 1301 - Ok(_) => { 1302 - // Also remove from local database 1303 - let _ = 1304 - StatusFromDb::delete_by_uri(&db_pool, req.uri.clone()).await; 1305 - 1306 - HttpResponse::Ok().json(serde_json::json!({ 1307 - "success": true 1308 - })) 1309 - } 1310 - Err(e) => { 1311 - log::error!("Failed to delete status from ATProto: {e}"); 1312 - HttpResponse::InternalServerError().json(serde_json::json!({ 1313 - "error": "Failed to delete status" 1314 - })) 1315 - } 1316 - } 1317 - } 1318 - Err(e) => { 1319 - log::error!("Failed to restore OAuth session: {e}"); 1320 - HttpResponse::InternalServerError().json(serde_json::json!({ 1321 - "error": "Session error" 1322 - })) 1323 - } 1324 - } 1325 - } else { 1326 - HttpResponse::BadRequest().json(serde_json::json!({ 1327 - "error": "Invalid status URI" 1328 - })) 1329 - } 1330 - } 1331 - None => { 1332 - // Not logged in 1333 - HttpResponse::Unauthorized().json(serde_json::json!({ 1334 - "error": "Not authenticated" 1335 - })) 1336 - } 1337 - } 1338 - } 1339 - 1340 - /// Hide/unhide a status (admin only) 1341 - #[derive(Deserialize)] 1342 - struct HideStatusRequest { 1343 - uri: String, 1344 - hidden: bool, 1345 - } 1346 - 1347 - #[post("/admin/hide-status")] 1348 - async fn hide_status( 1349 - session: Session, 1350 - db_pool: web::Data<Arc<Pool>>, 1351 - req: web::Json<HideStatusRequest>, 1352 - ) -> HttpResponse { 1353 - // Check if the user is logged in and is admin 1354 - match session.get::<String>("did").unwrap_or(None) { 1355 - Some(did_string) => { 1356 - if !is_admin(&did_string) { 1357 - return HttpResponse::Forbidden().json(serde_json::json!({ 1358 - "error": "Admin access required" 1359 - })); 1360 - } 1361 - 1362 - // Update the hidden status in the database 1363 - let uri = req.uri.clone(); 1364 - let hidden = req.hidden; 1365 - 1366 - let result = db_pool 1367 - .conn(move |conn| { 1368 - conn.execute( 1369 - "UPDATE status SET hidden = ?1 WHERE uri = ?2", 1370 - rusqlite::params![hidden, uri], 1371 - ) 1372 - }) 1373 - .await; 1374 - 1375 - match result { 1376 - Ok(rows_affected) if rows_affected > 0 => { 1377 - HttpResponse::Ok().json(serde_json::json!({ 1378 - "success": true, 1379 - "message": if hidden { "Status hidden" } else { "Status unhidden" } 1380 - })) 1381 - } 1382 - Ok(_) => HttpResponse::NotFound().json(serde_json::json!({ 1383 - "error": "Status not found" 1384 - })), 1385 - Err(err) => { 1386 - log::error!("Error updating hidden status: {}", err); 1387 - HttpResponse::InternalServerError().json(serde_json::json!({ 1388 - "error": "Database error" 1389 - })) 1390 - } 1391 - } 1392 - } 1393 - None => HttpResponse::Unauthorized().json(serde_json::json!({ 1394 - "error": "Not authenticated" 1395 - })), 1396 - } 1397 - } 1398 - 1399 - /// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L208 1400 - /// Creates a new status 1401 - #[post("/status")] 1402 - async fn status( 1403 - request: HttpRequest, 1404 - session: Session, 1405 - oauth_client: web::Data<OAuthClientType>, 1406 - db_pool: web::Data<Arc<Pool>>, 1407 - form: web::Form<StatusForm>, 1408 - rate_limiter: web::Data<RateLimiter>, 1409 - ) -> Result<HttpResponse, AppError> { 1410 - // Apply rate limiting 1411 - let client_key = RateLimiter::get_client_key(&request); 1412 - if !rate_limiter.check_rate_limit(&client_key) { 1413 - return Err(AppError::RateLimitExceeded); 1414 - } 1415 - // Check if the user is logged in 1416 - match session.get::<String>("did").unwrap_or(None) { 1417 - Some(did_string) => { 1418 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1419 - // gets the user's session from the session store to resume 1420 - match oauth_client.restore(&did).await { 1421 - Ok(session) => { 1422 - let agent = Agent::new(session); 1423 - 1424 - // Calculate expiration time if provided 1425 - let expires = form 1426 - .expires_in 1427 - .as_ref() 1428 - .and_then(|exp| parse_duration(exp)) 1429 - .and_then(|duration| { 1430 - let expiry_time = chrono::Utc::now() + duration; 1431 - // Convert to ATProto Datetime format (RFC3339) 1432 - Some(Datetime::new(expiry_time.to_rfc3339().parse().ok()?)) 1433 - }); 1434 - 1435 - //Creates a strongly typed ATProto record 1436 - let status: KnownRecord = lexicons::io::zzstoatzz::status::record::RecordData { 1437 - created_at: Datetime::now(), 1438 - emoji: form.status.clone(), 1439 - text: form.text.clone(), 1440 - expires, 1441 - } 1442 - .into(); 1443 - 1444 - // TODO no data validation yet from esquema 1445 - // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3 1446 - 1447 - let create_result = agent 1448 - .api 1449 - .com 1450 - .atproto 1451 - .repo 1452 - .create_record( 1453 - atrium_api::com::atproto::repo::create_record::InputData { 1454 - collection: "io.zzstoatzz.status.record".parse().unwrap(), 1455 - repo: did.into(), 1456 - rkey: None, 1457 - record: status.into(), 1458 - swap_commit: None, 1459 - validate: None, 1460 - } 1461 - .into(), 1462 - ) 1463 - .await; 1464 - 1465 - match create_result { 1466 - Ok(record) => { 1467 - let mut status = StatusFromDb::new( 1468 - record.uri.clone(), 1469 - did_string, 1470 - form.status.clone(), 1471 - ); 1472 - 1473 - // Set the text field if provided 1474 - status.text = form.text.clone(); 1475 - 1476 - // Set the expiration time if provided 1477 - if let Some(exp_str) = &form.expires_in { 1478 - if let Some(duration) = parse_duration(exp_str) { 1479 - status.expires_at = Some(chrono::Utc::now() + duration); 1480 - } 1481 - } 1482 - 1483 - let _ = status.save(db_pool).await; 1484 - Ok(Redirect::to("/") 1485 - .see_other() 1486 - .respond_to(&request) 1487 - .map_into_boxed_body()) 1488 - } 1489 - Err(err) => { 1490 - log::error!("Error creating status: {err}"); 1491 - let error_html = ErrorTemplate { 1492 - title: "Error", 1493 - error: "Was an error creating the status, please check the logs.", 1494 - } 1495 - .render() 1496 - .expect("template should be valid"); 1497 - Ok(HttpResponse::Ok().body(error_html)) 1498 - } 1499 - } 1500 - } 1501 - Err(err) => { 1502 - // Destroys the system or you're in a loop 1503 - session.purge(); 1504 - log::error!( 1505 - "Error restoring session, we are removing the session from the cookie: {err}" 1506 - ); 1507 - Err(AppError::AuthenticationError("Session error".to_string())) 1508 - } 1509 - } 1510 - } 1511 - None => Err(AppError::AuthenticationError( 1512 - "You must be logged in to create a status.".to_string(), 1513 - )), 1514 - } 1515 - } 1516 - 1517 43 #[actix_web::main] 1518 44 async fn main() -> std::io::Result<()> { 1519 45 dotenv().ok(); ··· 1558 84 plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 1559 85 http_client: http_client.clone(), 1560 86 }); 1561 - let handle_resolver = Arc::new(handle_resolver); 87 + let handle_resolver: HandleResolver = Arc::new(handle_resolver); 1562 88 1563 89 // Create a new OAuth client 1564 90 let http_client = Arc::new(DefaultHttpClient::default()); ··· 1685 211 ) 1686 212 .service(Files::new("/static", "static").show_files_listing()) 1687 213 .service(Files::new("/emojis", "static/emojis").show_files_listing()) 1688 - .service(client_metadata) 1689 - .service(oauth_callback) 1690 - .service(login) 1691 - .service(login_post) 1692 - .service(logout) 1693 - .service(home) 1694 - .service(feed) 1695 - .service(status_json) 1696 - .service(owner_status_json) 1697 - .service(get_custom_emojis) 1698 - .service(get_frequent_emojis) 1699 - .service(get_following) 1700 - .service(api_feed) 1701 - .service(get_preferences) 1702 - .service(save_preferences) 1703 - .service(user_status_page) 1704 - .service(user_status_json) 1705 - .service(status) 1706 - .service(clear_status) 1707 - .service(delete_status) 1708 - .service(hide_status) 214 + .configure(api::configure_routes) 1709 215 }) 1710 216 .bind((host.as_str(), port))? 1711 217 .run() ··· 1715 221 #[cfg(test)] 1716 222 mod tests { 1717 223 use super::*; 224 + use crate::api::status::get_custom_emojis; 1718 225 use actix_web::{App, test}; 1719 226 1720 227 #[actix_web::test]
+1 -1
templates/base.html
··· 30 30 <script> 31 31 // Apply saved settings immediately to prevent flash 32 32 (function() { 33 - const savedFont = localStorage.getItem('fontFamily') || 'system'; 33 + const savedFont = localStorage.getItem('fontFamily') || 'mono'; 34 34 const savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 35 35 36 36 const fontMap = {
+1 -1
templates/feed.html
··· 770 770 // Simple settings 771 771 const initSettings = async () => { 772 772 // Try to load from API first, fall back to localStorage 773 - let savedFont = localStorage.getItem('fontFamily') || 'system'; 773 + let savedFont = localStorage.getItem('fontFamily') || 'mono'; 774 774 let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 775 775 776 776 // If user is logged in, fetch from API
+1 -1
templates/status.html
··· 1223 1223 // Simple settings 1224 1224 const initSettings = async () => { 1225 1225 // Try to load from API first, fall back to localStorage 1226 - let savedFont = localStorage.getItem('fontFamily') || 'system'; 1226 + let savedFont = localStorage.getItem('fontFamily') || 'mono'; 1227 1227 let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 1228 1228 1229 1229 // If user is logged in, fetch from API