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

Merge pull request #36 from zzstoatzz/feat/simple-customization

feat: simple user customization

authored by

nate nowack and committed by
GitHub
7f17cb54 d56f59c7

+2380 -1553
+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 + }
+31 -105
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 - use std::{fmt::Debug, sync::Arc}; 11 - 12 - /// Creates the tables in the db. 13 - pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { 14 - pool.conn(move |conn| { 15 - conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); 16 - 17 - // status 18 - conn.execute( 19 - "CREATE TABLE IF NOT EXISTS status ( 20 - uri TEXT PRIMARY KEY, 21 - authorDid TEXT NOT NULL, 22 - emoji TEXT NOT NULL, 23 - text TEXT, 24 - startedAt INTEGER NOT NULL, 25 - expiresAt INTEGER, 26 - indexedAt INTEGER NOT NULL 27 - )", 28 - [], 29 - ) 30 - .unwrap(); 31 - 32 - // auth_session 33 - conn.execute( 34 - "CREATE TABLE IF NOT EXISTS auth_session ( 35 - key TEXT PRIMARY KEY, 36 - session TEXT NOT NULL 37 - )", 38 - [], 39 - ) 40 - .unwrap(); 41 - 42 - // auth_state 43 - conn.execute( 44 - "CREATE TABLE IF NOT EXISTS auth_state ( 45 - key TEXT PRIMARY KEY, 46 - state TEXT NOT NULL 47 - )", 48 - [], 49 - ) 50 - .unwrap(); 51 - 52 - // Note: custom_emojis table removed - we serve emojis directly from static/emojis/ directory 53 - 54 - // Add indexes for performance optimization 55 - // Index on startedAt for feed queries (ORDER BY startedAt DESC) 56 - conn.execute( 57 - "CREATE INDEX IF NOT EXISTS idx_status_startedAt ON status(startedAt DESC)", 58 - [], 59 - ) 60 - .unwrap(); 61 - 62 - // Composite index for user status queries (WHERE authorDid = ? ORDER BY startedAt DESC) 63 - conn.execute( 64 - "CREATE INDEX IF NOT EXISTS idx_status_authorDid_startedAt ON status(authorDid, startedAt DESC)", 65 - [], 66 - ) 67 - .unwrap(); 68 - 69 - // Add hidden column for moderation (won't error if already exists) 70 - let _ = conn.execute( 71 - "ALTER TABLE status ADD COLUMN hidden BOOLEAN DEFAULT FALSE", 72 - [], 73 - ); 74 - 75 - Ok(()) 76 - }) 77 - .await?; 78 - Ok(()) 79 - } 9 + use std::{ 10 + sync::Arc, 11 + time::{SystemTime, UNIX_EPOCH}, 12 + }; 80 13 81 - ///Status table datatype 82 14 #[derive(Debug, Clone, Deserialize, Serialize)] 83 15 pub struct StatusFromDb { 84 16 pub uri: String, ··· 91 23 pub handle: Option<String>, 92 24 } 93 25 94 - //Status methods 95 26 impl StatusFromDb { 96 27 /// Creates a new [StatusFromDb] 97 28 pub fn new(uri: String, author_did: String, status: String) -> Self { ··· 109 40 } 110 41 111 42 /// Helper to map from [Row] to [StatusDb] 112 - fn map_from_row(row: &Row) -> Result<Self, rusqlite::Error> { 43 + fn map_from_row(row: &Row) -> Result<Self, async_sqlite::rusqlite::Error> { 113 44 Ok(Self { 114 45 uri: row.get(0)?, 115 46 author_did: row.get(1)?, ··· 152 83 pool.conn(move |conn| { 153 84 conn.execute( 154 85 "INSERT INTO status (uri, authorDid, emoji, text, startedAt, expiresAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", 155 - rusqlite::params![ 86 + async_sqlite::rusqlite::params![ 156 87 &cloned_self.uri, 157 88 &cloned_self.author_did, 158 89 &cloned_self.status, // emoji value ··· 178 109 true => { 179 110 let mut update_stmt = 180 111 conn.prepare("UPDATE status SET emoji = ?2, text = ?3, startedAt = ?4, expiresAt = ?5, indexedAt = ?6 WHERE uri = ?1")?; 181 - update_stmt.execute(rusqlite::params![ 112 + update_stmt.execute(async_sqlite::rusqlite::params![ 182 113 &cloned_self.uri, 183 114 &cloned_self.status, 184 115 &cloned_self.text, ··· 191 122 false => { 192 123 conn.execute( 193 124 "INSERT INTO status (uri, authorDid, emoji, text, startedAt, expiresAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", 194 - rusqlite::params![ 125 + async_sqlite::rusqlite::params![ 195 126 &cloned_self.uri, 196 127 &cloned_self.author_did, 197 128 &cloned_self.status, // emoji value ··· 208 139 .await?; 209 140 Ok(()) 210 141 } 142 + 211 143 pub async fn delete_by_uri(pool: &Pool, uri: String) -> Result<(), async_sqlite::Error> { 212 144 pool.conn(move |conn| { 213 145 let mut stmt = conn.prepare("DELETE FROM status WHERE uri = ?1")?; ··· 250 182 "SELECT * FROM status WHERE (hidden IS NULL OR hidden = FALSE) ORDER BY startedAt DESC LIMIT ?1 OFFSET ?2" 251 183 )?; 252 184 let status_iter = stmt 253 - .query_map(rusqlite::params![limit, offset], |row| { 185 + .query_map(async_sqlite::rusqlite::params![limit, offset], |row| { 254 186 Ok(Self::map_from_row(row).unwrap()) 255 187 }) 256 188 .unwrap(); ··· 277 209 stmt.query_row([did.as_str()], Self::map_from_row) 278 210 .map(Some) 279 211 .or_else(|err| { 280 - if err == rusqlite::Error::QueryReturnedNoRows { 212 + if err == async_sqlite::rusqlite::Error::QueryReturnedNoRows { 281 213 Ok(None) 282 214 } else { 283 215 Err(err) ··· 502 434 } 503 435 } 504 436 505 - // CustomEmoji struct removed - we serve emojis directly from static/emojis/ directory 506 - 507 - /// Get the most frequently used emojis from all statuses 508 - pub async fn get_frequent_emojis( 509 - pool: &Pool, 510 - limit: usize, 511 - ) -> Result<Vec<String>, async_sqlite::Error> { 512 - pool.conn(move |conn| { 513 - let mut stmt = conn.prepare( 514 - "SELECT emoji, COUNT(*) as count 515 - FROM status 516 - GROUP BY emoji 517 - ORDER BY count DESC 518 - LIMIT ?1", 519 - )?; 437 + #[derive(Debug, Clone, Serialize, Deserialize)] 438 + pub struct UserPreferences { 439 + pub did: String, 440 + pub font_family: String, 441 + pub accent_color: String, 442 + pub updated_at: i64, 443 + } 520 444 521 - let emoji_iter = stmt.query_map([limit], |row| row.get::<_, String>(0))?; 522 - 523 - let mut emojis = Vec::new(); 524 - for emoji in emoji_iter { 525 - emojis.push(emoji?); 445 + impl Default for UserPreferences { 446 + fn default() -> Self { 447 + Self { 448 + did: String::new(), 449 + font_family: "mono".to_string(), 450 + accent_color: "#1DA1F2".to_string(), 451 + updated_at: SystemTime::now() 452 + .duration_since(UNIX_EPOCH) 453 + .unwrap() 454 + .as_secs() as i64, 526 455 } 527 - 528 - Ok(emojis) 529 - }) 530 - .await 456 + } 531 457 }
+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 -1437
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 - /// Feed page - shows all users' statuses 882 - #[get("/feed")] 883 - async fn feed( 884 - request: HttpRequest, 885 - session: Session, 886 - oauth_client: web::Data<OAuthClientType>, 887 - db_pool: web::Data<Arc<Pool>>, 888 - handle_resolver: web::Data<HandleResolver>, 889 - config: web::Data<config::Config>, 890 - ) -> Result<impl Responder> { 891 - // This is essentially the old home function 892 - const TITLE: &str = "status feed"; 893 - 894 - // Check if dev mode is active 895 - let query = request.query_string(); 896 - let use_dev_mode = config.dev_mode && dev_utils::is_dev_mode_requested(query); 897 - 898 - let mut statuses = if use_dev_mode { 899 - // Mix dummy data with real data for testing 900 - let mut real_statuses = StatusFromDb::load_latest_statuses(&db_pool) 901 - .await 902 - .unwrap_or_else(|err| { 903 - log::error!("Error loading statuses: {err}"); 904 - vec![] 905 - }); 906 - let dummy_statuses = dev_utils::generate_dummy_statuses(15); 907 - real_statuses.extend(dummy_statuses); 908 - // Resort by started_at 909 - real_statuses.sort_by(|a, b| b.started_at.cmp(&a.started_at)); 910 - real_statuses 911 - } else { 912 - StatusFromDb::load_latest_statuses(&db_pool) 913 - .await 914 - .unwrap_or_else(|err| { 915 - log::error!("Error loading statuses: {err}"); 916 - vec![] 917 - }) 918 - }; 919 - 920 - let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 921 - for db_status in &mut statuses { 922 - let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 923 - match quick_resolve_map.get(&authors_did) { 924 - None => {} 925 - Some(found_handle) => { 926 - db_status.handle = Some(found_handle.clone()); 927 - continue; 928 - } 929 - } 930 - db_status.handle = match handle_resolver.resolve(&authors_did).await { 931 - Ok(did_doc) => match did_doc.also_known_as { 932 - None => None, 933 - Some(also_known_as) => match also_known_as.is_empty() { 934 - true => None, 935 - false => { 936 - let full_handle = also_known_as.first().unwrap(); 937 - let handle = full_handle.replace("at://", ""); 938 - quick_resolve_map.insert(authors_did, handle.clone()); 939 - Some(handle) 940 - } 941 - }, 942 - }, 943 - Err(err) => { 944 - log::error!("Error resolving did: {err}"); 945 - None 946 - } 947 - }; 948 - } 949 - 950 - match session.get::<String>("did").unwrap_or(None) { 951 - Some(did_string) => { 952 - log::debug!("Feed: User has session with DID: {}", did_string); 953 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 954 - let _my_status = StatusFromDb::my_status(&db_pool, &did) 955 - .await 956 - .unwrap_or_else(|err| { 957 - log::error!("Error loading my status: {err}"); 958 - None 959 - }); 960 - 961 - log::debug!( 962 - "Feed: Attempting to restore OAuth session for DID: {}", 963 - did_string 964 - ); 965 - match oauth_client.restore(&did).await { 966 - Ok(session) => { 967 - log::debug!("Feed: Successfully restored OAuth session"); 968 - let agent = Agent::new(session); 969 - let profile = agent 970 - .api 971 - .app 972 - .bsky 973 - .actor 974 - .get_profile( 975 - atrium_api::app::bsky::actor::get_profile::ParametersData { 976 - actor: atrium_api::types::string::AtIdentifier::Did(did.clone()), 977 - } 978 - .into(), 979 - ) 980 - .await; 981 - 982 - let is_admin = is_admin(did.as_str()); 983 - let html = FeedTemplate { 984 - title: TITLE, 985 - profile: match profile { 986 - Ok(profile) => { 987 - let profile_data = Profile { 988 - did: profile.did.to_string(), 989 - display_name: profile.display_name.clone(), 990 - }; 991 - Some(profile_data) 992 - } 993 - Err(err) => { 994 - log::error!("Error accessing profile: {err}"); 995 - None 996 - } 997 - }, 998 - statuses, 999 - is_admin, 1000 - dev_mode: use_dev_mode, 1001 - } 1002 - .render() 1003 - .expect("template should be valid"); 1004 - 1005 - Ok(web::Html::new(html)) 1006 - } 1007 - Err(err) => { 1008 - // Don't purge the session - OAuth tokens might be expired but user is still logged in 1009 - log::warn!("Could not restore OAuth session for feed: {:?}", err); 1010 - 1011 - // Show feed without profile info instead of error page 1012 - let html = FeedTemplate { 1013 - title: TITLE, 1014 - profile: None, 1015 - statuses, 1016 - is_admin: is_admin(did.as_str()), 1017 - dev_mode: use_dev_mode, 1018 - } 1019 - .render() 1020 - .expect("template should be valid"); 1021 - 1022 - Ok(web::Html::new(html)) 1023 - } 1024 - } 1025 - } 1026 - None => { 1027 - let html = FeedTemplate { 1028 - title: TITLE, 1029 - profile: None, 1030 - statuses, 1031 - is_admin: false, 1032 - dev_mode: use_dev_mode, 1033 - } 1034 - .render() 1035 - .expect("template should be valid"); 1036 - 1037 - Ok(web::Html::new(html)) 1038 - } 1039 - } 1040 - } 1041 - 1042 - /// The post body for changing your status 1043 - #[derive(Serialize, Deserialize, Clone)] 1044 - struct StatusForm { 1045 - status: String, 1046 - text: Option<String>, 1047 - expires_in: Option<String>, // e.g., "1h", "30m", "1d", etc. 1048 - } 1049 - 1050 - /// The post body for deleting a specific status 1051 - #[derive(Serialize, Deserialize)] 1052 - struct DeleteRequest { 1053 - uri: String, 1054 - } 1055 - 1056 - /// Parse duration string like "1h", "30m", "1d" into chrono::Duration 1057 - fn parse_duration(duration_str: &str) -> Option<chrono::Duration> { 1058 - if duration_str.is_empty() { 1059 - return None; 1060 - } 1061 - 1062 - let (num_str, unit) = duration_str.split_at(duration_str.len() - 1); 1063 - let num: i64 = num_str.parse().ok()?; 1064 - 1065 - match unit { 1066 - "m" => Some(chrono::Duration::minutes(num)), 1067 - "h" => Some(chrono::Duration::hours(num)), 1068 - "d" => Some(chrono::Duration::days(num)), 1069 - "w" => Some(chrono::Duration::weeks(num)), 1070 - _ => None, 1071 - } 1072 - } 1073 - 1074 - /// Clear the user's status by deleting the ATProto record 1075 - #[post("/status/clear")] 1076 - async fn clear_status( 1077 - request: HttpRequest, 1078 - session: Session, 1079 - oauth_client: web::Data<OAuthClientType>, 1080 - db_pool: web::Data<Arc<Pool>>, 1081 - ) -> HttpResponse { 1082 - // Check if the user is logged in 1083 - match session.get::<String>("did").unwrap_or(None) { 1084 - Some(did_string) => { 1085 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1086 - 1087 - // Get the user's current status to find the record key 1088 - match StatusFromDb::my_status(&db_pool, &did).await { 1089 - Ok(Some(current_status)) => { 1090 - // Extract the record key from the URI 1091 - // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 1092 - let parts: Vec<&str> = current_status.uri.split('/').collect(); 1093 - if let Some(rkey) = parts.last() { 1094 - // Get OAuth session 1095 - match oauth_client.restore(&did).await { 1096 - Ok(session) => { 1097 - let agent = Agent::new(session); 1098 - 1099 - // Delete the record from ATProto using com.atproto.repo.deleteRecord 1100 - let delete_request = 1101 - atrium_api::com::atproto::repo::delete_record::InputData { 1102 - collection: atrium_api::types::string::Nsid::new( 1103 - "io.zzstoatzz.status.record".to_string(), 1104 - ) 1105 - .expect("valid nsid"), 1106 - repo: did.clone().into(), 1107 - rkey: atrium_api::types::string::RecordKey::new( 1108 - rkey.to_string(), 1109 - ) 1110 - .expect("valid rkey"), 1111 - swap_commit: None, 1112 - swap_record: None, 1113 - }; 1114 - match agent 1115 - .api 1116 - .com 1117 - .atproto 1118 - .repo 1119 - .delete_record(delete_request.into()) 1120 - .await 1121 - { 1122 - Ok(_) => { 1123 - // Also remove from local database 1124 - let _ = StatusFromDb::delete_by_uri( 1125 - &db_pool, 1126 - current_status.uri, 1127 - ) 1128 - .await; 1129 - 1130 - Redirect::to("/") 1131 - .see_other() 1132 - .respond_to(&request) 1133 - .map_into_boxed_body() 1134 - } 1135 - Err(e) => { 1136 - log::error!("Failed to delete status from ATProto: {e}"); 1137 - HttpResponse::InternalServerError() 1138 - .body("Failed to clear status") 1139 - } 1140 - } 1141 - } 1142 - Err(e) => { 1143 - log::error!("Failed to restore OAuth session: {e}"); 1144 - HttpResponse::InternalServerError().body("Session error") 1145 - } 1146 - } 1147 - } else { 1148 - HttpResponse::BadRequest().body("Invalid status URI") 1149 - } 1150 - } 1151 - Ok(None) => { 1152 - // No status to clear 1153 - Redirect::to("/") 1154 - .see_other() 1155 - .respond_to(&request) 1156 - .map_into_boxed_body() 1157 - } 1158 - Err(e) => { 1159 - log::error!("Database error: {e}"); 1160 - HttpResponse::InternalServerError().body("Database error") 1161 - } 1162 - } 1163 - } 1164 - None => { 1165 - // Not logged in 1166 - Redirect::to("/login") 1167 - .see_other() 1168 - .respond_to(&request) 1169 - .map_into_boxed_body() 1170 - } 1171 - } 1172 - } 1173 - 1174 - /// Delete a specific status by URI (JSON endpoint) 1175 - #[post("/status/delete")] 1176 - async fn delete_status( 1177 - session: Session, 1178 - oauth_client: web::Data<OAuthClientType>, 1179 - db_pool: web::Data<Arc<Pool>>, 1180 - req: web::Json<DeleteRequest>, 1181 - ) -> HttpResponse { 1182 - // Check if the user is logged in 1183 - match session.get::<String>("did").unwrap_or(None) { 1184 - Some(did_string) => { 1185 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1186 - 1187 - // Parse the URI to verify it belongs to this user 1188 - // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 1189 - let uri_parts: Vec<&str> = req.uri.split('/').collect(); 1190 - if uri_parts.len() < 5 { 1191 - return HttpResponse::BadRequest().json(serde_json::json!({ 1192 - "error": "Invalid status URI format" 1193 - })); 1194 - } 1195 - 1196 - // Extract DID from URI (at://did:plc:xxx/...) 1197 - let uri_did_part = uri_parts[2]; 1198 - if uri_did_part != did_string { 1199 - return HttpResponse::Forbidden().json(serde_json::json!({ 1200 - "error": "You can only delete your own statuses" 1201 - })); 1202 - } 1203 - 1204 - // Extract record key 1205 - if let Some(rkey) = uri_parts.last() { 1206 - // Get OAuth session 1207 - match oauth_client.restore(&did).await { 1208 - Ok(session) => { 1209 - let agent = Agent::new(session); 1210 - 1211 - // Delete the record from ATProto 1212 - let delete_request = 1213 - atrium_api::com::atproto::repo::delete_record::InputData { 1214 - collection: atrium_api::types::string::Nsid::new( 1215 - "io.zzstoatzz.status.record".to_string(), 1216 - ) 1217 - .expect("valid nsid"), 1218 - repo: did.clone().into(), 1219 - rkey: atrium_api::types::string::RecordKey::new(rkey.to_string()) 1220 - .expect("valid rkey"), 1221 - swap_commit: None, 1222 - swap_record: None, 1223 - }; 1224 - 1225 - match agent 1226 - .api 1227 - .com 1228 - .atproto 1229 - .repo 1230 - .delete_record(delete_request.into()) 1231 - .await 1232 - { 1233 - Ok(_) => { 1234 - // Also remove from local database 1235 - let _ = 1236 - StatusFromDb::delete_by_uri(&db_pool, req.uri.clone()).await; 1237 - 1238 - HttpResponse::Ok().json(serde_json::json!({ 1239 - "success": true 1240 - })) 1241 - } 1242 - Err(e) => { 1243 - log::error!("Failed to delete status from ATProto: {e}"); 1244 - HttpResponse::InternalServerError().json(serde_json::json!({ 1245 - "error": "Failed to delete status" 1246 - })) 1247 - } 1248 - } 1249 - } 1250 - Err(e) => { 1251 - log::error!("Failed to restore OAuth session: {e}"); 1252 - HttpResponse::InternalServerError().json(serde_json::json!({ 1253 - "error": "Session error" 1254 - })) 1255 - } 1256 - } 1257 - } else { 1258 - HttpResponse::BadRequest().json(serde_json::json!({ 1259 - "error": "Invalid status URI" 1260 - })) 1261 - } 1262 - } 1263 - None => { 1264 - // Not logged in 1265 - HttpResponse::Unauthorized().json(serde_json::json!({ 1266 - "error": "Not authenticated" 1267 - })) 1268 - } 1269 - } 1270 - } 1271 - 1272 - /// Hide/unhide a status (admin only) 1273 - #[derive(Deserialize)] 1274 - struct HideStatusRequest { 1275 - uri: String, 1276 - hidden: bool, 1277 - } 1278 - 1279 - #[post("/admin/hide-status")] 1280 - async fn hide_status( 1281 - session: Session, 1282 - db_pool: web::Data<Arc<Pool>>, 1283 - req: web::Json<HideStatusRequest>, 1284 - ) -> HttpResponse { 1285 - // Check if the user is logged in and is admin 1286 - match session.get::<String>("did").unwrap_or(None) { 1287 - Some(did_string) => { 1288 - if !is_admin(&did_string) { 1289 - return HttpResponse::Forbidden().json(serde_json::json!({ 1290 - "error": "Admin access required" 1291 - })); 1292 - } 1293 - 1294 - // Update the hidden status in the database 1295 - let uri = req.uri.clone(); 1296 - let hidden = req.hidden; 1297 - 1298 - let result = db_pool 1299 - .conn(move |conn| { 1300 - conn.execute( 1301 - "UPDATE status SET hidden = ?1 WHERE uri = ?2", 1302 - rusqlite::params![hidden, uri], 1303 - ) 1304 - }) 1305 - .await; 1306 - 1307 - match result { 1308 - Ok(rows_affected) if rows_affected > 0 => { 1309 - HttpResponse::Ok().json(serde_json::json!({ 1310 - "success": true, 1311 - "message": if hidden { "Status hidden" } else { "Status unhidden" } 1312 - })) 1313 - } 1314 - Ok(_) => HttpResponse::NotFound().json(serde_json::json!({ 1315 - "error": "Status not found" 1316 - })), 1317 - Err(err) => { 1318 - log::error!("Error updating hidden status: {}", err); 1319 - HttpResponse::InternalServerError().json(serde_json::json!({ 1320 - "error": "Database error" 1321 - })) 1322 - } 1323 - } 1324 - } 1325 - None => HttpResponse::Unauthorized().json(serde_json::json!({ 1326 - "error": "Not authenticated" 1327 - })), 1328 - } 1329 - } 1330 - 1331 - /// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L208 1332 - /// Creates a new status 1333 - #[post("/status")] 1334 - async fn status( 1335 - request: HttpRequest, 1336 - session: Session, 1337 - oauth_client: web::Data<OAuthClientType>, 1338 - db_pool: web::Data<Arc<Pool>>, 1339 - form: web::Form<StatusForm>, 1340 - rate_limiter: web::Data<RateLimiter>, 1341 - ) -> Result<HttpResponse, AppError> { 1342 - // Apply rate limiting 1343 - let client_key = RateLimiter::get_client_key(&request); 1344 - if !rate_limiter.check_rate_limit(&client_key) { 1345 - return Err(AppError::RateLimitExceeded); 1346 - } 1347 - // Check if the user is logged in 1348 - match session.get::<String>("did").unwrap_or(None) { 1349 - Some(did_string) => { 1350 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1351 - // gets the user's session from the session store to resume 1352 - match oauth_client.restore(&did).await { 1353 - Ok(session) => { 1354 - let agent = Agent::new(session); 1355 - 1356 - // Calculate expiration time if provided 1357 - let expires = form 1358 - .expires_in 1359 - .as_ref() 1360 - .and_then(|exp| parse_duration(exp)) 1361 - .and_then(|duration| { 1362 - let expiry_time = chrono::Utc::now() + duration; 1363 - // Convert to ATProto Datetime format (RFC3339) 1364 - Some(Datetime::new(expiry_time.to_rfc3339().parse().ok()?)) 1365 - }); 1366 - 1367 - //Creates a strongly typed ATProto record 1368 - let status: KnownRecord = lexicons::io::zzstoatzz::status::record::RecordData { 1369 - created_at: Datetime::now(), 1370 - emoji: form.status.clone(), 1371 - text: form.text.clone(), 1372 - expires, 1373 - } 1374 - .into(); 1375 - 1376 - // TODO no data validation yet from esquema 1377 - // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3 1378 - 1379 - let create_result = agent 1380 - .api 1381 - .com 1382 - .atproto 1383 - .repo 1384 - .create_record( 1385 - atrium_api::com::atproto::repo::create_record::InputData { 1386 - collection: "io.zzstoatzz.status.record".parse().unwrap(), 1387 - repo: did.into(), 1388 - rkey: None, 1389 - record: status.into(), 1390 - swap_commit: None, 1391 - validate: None, 1392 - } 1393 - .into(), 1394 - ) 1395 - .await; 1396 - 1397 - match create_result { 1398 - Ok(record) => { 1399 - let mut status = StatusFromDb::new( 1400 - record.uri.clone(), 1401 - did_string, 1402 - form.status.clone(), 1403 - ); 1404 - 1405 - // Set the text field if provided 1406 - status.text = form.text.clone(); 1407 - 1408 - // Set the expiration time if provided 1409 - if let Some(exp_str) = &form.expires_in { 1410 - if let Some(duration) = parse_duration(exp_str) { 1411 - status.expires_at = Some(chrono::Utc::now() + duration); 1412 - } 1413 - } 1414 - 1415 - let _ = status.save(db_pool).await; 1416 - Ok(Redirect::to("/") 1417 - .see_other() 1418 - .respond_to(&request) 1419 - .map_into_boxed_body()) 1420 - } 1421 - Err(err) => { 1422 - log::error!("Error creating status: {err}"); 1423 - let error_html = ErrorTemplate { 1424 - title: "Error", 1425 - error: "Was an error creating the status, please check the logs.", 1426 - } 1427 - .render() 1428 - .expect("template should be valid"); 1429 - Ok(HttpResponse::Ok().body(error_html)) 1430 - } 1431 - } 1432 - } 1433 - Err(err) => { 1434 - // Destroys the system or you're in a loop 1435 - session.purge(); 1436 - log::error!( 1437 - "Error restoring session, we are removing the session from the cookie: {err}" 1438 - ); 1439 - Err(AppError::AuthenticationError("Session error".to_string())) 1440 - } 1441 - } 1442 - } 1443 - None => Err(AppError::AuthenticationError( 1444 - "You must be logged in to create a status.".to_string(), 1445 - )), 1446 - } 1447 - } 1448 - 1449 43 #[actix_web::main] 1450 44 async fn main() -> std::io::Result<()> { 1451 45 dotenv().ok(); ··· 1490 84 plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 1491 85 http_client: http_client.clone(), 1492 86 }); 1493 - let handle_resolver = Arc::new(handle_resolver); 87 + let handle_resolver: HandleResolver = Arc::new(handle_resolver); 1494 88 1495 89 // Create a new OAuth client 1496 90 let http_client = Arc::new(DefaultHttpClient::default()); ··· 1617 211 ) 1618 212 .service(Files::new("/static", "static").show_files_listing()) 1619 213 .service(Files::new("/emojis", "static/emojis").show_files_listing()) 1620 - .service(client_metadata) 1621 - .service(oauth_callback) 1622 - .service(login) 1623 - .service(login_post) 1624 - .service(logout) 1625 - .service(home) 1626 - .service(feed) 1627 - .service(status_json) 1628 - .service(owner_status_json) 1629 - .service(get_custom_emojis) 1630 - .service(get_frequent_emojis) 1631 - .service(get_following) 1632 - .service(api_feed) 1633 - .service(user_status_page) 1634 - .service(user_status_json) 1635 - .service(status) 1636 - .service(clear_status) 1637 - .service(delete_status) 1638 - .service(hide_status) 214 + .configure(api::configure_routes) 1639 215 }) 1640 216 .bind((host.as_str(), port))? 1641 217 .run() ··· 1645 221 #[cfg(test)] 1646 222 mod tests { 1647 223 use super::*; 224 + use crate::api::status::get_custom_emojis; 1648 225 use actix_web::{App, test}; 1649 226 1650 227 #[actix_web::test]
+39
static/settings.js
··· 1 + // Shared font map configuration 2 + const FONT_MAP = { 3 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 4 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 5 + 'serif': 'ui-serif, Georgia, Cambria, serif', 6 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 7 + }; 8 + 9 + // Check if user is authenticated by looking for auth-specific data 10 + function isAuthenticated() { 11 + // Check for data attribute that indicates authentication status 12 + return document.body.dataset.authenticated === 'true'; 13 + } 14 + 15 + // Helper to save preferences to API 16 + async function savePreferencesToAPI(updates) { 17 + if (!isAuthenticated()) return; 18 + 19 + try { 20 + await fetch('/api/preferences', { 21 + method: 'POST', 22 + headers: { 'Content-Type': 'application/json' }, 23 + body: JSON.stringify(updates) 24 + }); 25 + } catch (err) { 26 + console.log('Failed to save preferences to server'); 27 + } 28 + } 29 + 30 + // Apply font to the document 31 + function applyFont(fontKey) { 32 + const fontFamily = FONT_MAP[fontKey] || FONT_MAP.mono; 33 + document.documentElement.style.setProperty('--font-family', fontFamily); 34 + } 35 + 36 + // Apply accent color to the document 37 + function applyAccentColor(color) { 38 + document.documentElement.style.setProperty('--accent', color); 39 + }
+24
templates/base.html
··· 25 25 26 26 <!-- Shared Timestamp Formatter --> 27 27 <script src="/static/timestamps.js"></script> 28 + 29 + <!-- Shared Settings Module --> 30 + <script src="/static/settings.js"></script> 31 + 32 + <!-- Apply User Settings --> 33 + <script> 34 + // Apply saved settings immediately to prevent flash 35 + (function() { 36 + const savedFont = localStorage.getItem('fontFamily') || 'mono'; 37 + const savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 38 + 39 + // Use shared FONT_MAP from settings.js (will be available after load) 40 + // For immediate application, we still need local fontMap to prevent flash 41 + const fontMap = { 42 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 43 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 44 + 'serif': 'ui-serif, Georgia, Cambria, serif', 45 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 46 + }; 47 + 48 + document.documentElement.style.setProperty('--font-family', fontMap[savedFont] || fontMap.mono); 49 + document.documentElement.style.setProperty('--accent', savedAccent); 50 + })(); 51 + </script> 28 52 </head> 29 53 <body> 30 54 {% block content %}{% endblock %}
+1 -1
templates/error.html
··· 85 85 } 86 86 87 87 body { 88 - font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", "SF Mono", "Monaco", "Inconsolata", "Consolas", monospace; 88 + font-family: var(--font-family); 89 89 background: var(--bg-primary); 90 90 color: var(--text-primary); 91 91 line-height: 1.6;
+271 -4
templates/feed.html
··· 21 21 <polyline points="9 22 9 12 15 12 15 22"></polyline> 22 22 </svg> 23 23 </a> 24 + {% if let Some(Profile {did, display_name}) = profile %} 25 + <button class="settings-toggle" id="settings-toggle" aria-label="Settings"> 26 + <img src="https://api.iconify.design/lucide:settings.svg?color=%23888" width="20" height="20" alt="Settings"> 27 + </button> 28 + {% endif %} 24 29 <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"> 25 30 <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 26 31 <circle cx="12" cy="12" r="5"></circle> ··· 40 45 </button> 41 46 </div> 42 47 </header> 48 + 49 + <!-- Simple Settings (logged in users only) --> 50 + {% if let Some(Profile {did, display_name}) = profile %} 51 + <div class="simple-settings hidden" id="simple-settings"> 52 + <div class="settings-row"> 53 + <label>font</label> 54 + <div class="button-group"> 55 + <button class="font-btn active" data-font="system">system</button> 56 + <button class="font-btn" data-font="mono">mono</button> 57 + <button class="font-btn" data-font="serif">serif</button> 58 + <button class="font-btn" data-font="comic">comic</button> 59 + </div> 60 + </div> 61 + <div class="settings-row"> 62 + <label>accent</label> 63 + <input type="color" id="accent-color" value="#1DA1F2"> 64 + <div class="preset-colors"> 65 + <button class="color-preset" data-color="#1DA1F2" style="background: #1DA1F2"></button> 66 + <button class="color-preset" data-color="#FF6B6B" style="background: #FF6B6B"></button> 67 + <button class="color-preset" data-color="#4ECDC4" style="background: #4ECDC4"></button> 68 + <button class="color-preset" data-color="#FFEAA7" style="background: #FFEAA7"></button> 69 + <button class="color-preset" data-color="#A29BFE" style="background: #A29BFE"></button> 70 + <button class="color-preset" data-color="#FD79A8" style="background: #FD79A8"></button> 71 + </div> 72 + </div> 73 + </div> 74 + {% endif %} 43 75 44 76 <!-- Session Info --> 45 77 {% if let Some(Profile {did, display_name}) = profile %} ··· 182 214 } 183 215 184 216 body { 185 - font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", "SF Mono", "Monaco", "Inconsolata", "Consolas", monospace; 217 + font-family: var(--font-family); 186 218 background: var(--bg-primary); 187 219 color: var(--text-primary); 188 220 line-height: 1.6; ··· 242 274 } 243 275 244 276 .nav-button svg { 245 - stroke: currentColor; 277 + stroke: var(--accent); 278 + } 279 + 280 + /* Simple Settings */ 281 + .simple-settings { 282 + margin: 1rem 0; 283 + padding: 1rem; 284 + background: var(--bg-secondary); 285 + border-radius: var(--radius); 286 + display: flex; 287 + flex-direction: column; 288 + gap: 1rem; 289 + transition: all 0.3s ease; 290 + transform-origin: top; 291 + } 292 + 293 + .simple-settings.hidden { 294 + display: none; 295 + } 296 + 297 + .settings-row { 298 + display: flex; 299 + align-items: center; 300 + gap: 1rem; 301 + } 302 + 303 + .settings-row label { 304 + min-width: 60px; 305 + color: var(--text-secondary); 306 + font-size: 0.9rem; 307 + } 308 + 309 + .button-group { 310 + display: flex; 311 + gap: 0.25rem; 312 + } 313 + 314 + .font-btn { 315 + padding: 0.25rem 0.75rem; 316 + background: transparent; 317 + border: 1px solid var(--border-color); 318 + border-radius: var(--radius-sm); 319 + color: var(--text-secondary); 320 + cursor: pointer; 321 + transition: all 0.2s; 322 + font-size: 0.85rem; 323 + } 324 + 325 + .font-btn:hover { 326 + border-color: var(--accent); 327 + color: var(--text-primary); 328 + } 329 + 330 + .font-btn.active { 331 + background: var(--accent); 332 + border-color: var(--accent); 333 + color: white; 334 + } 335 + 336 + #accent-color { 337 + width: 50px; 338 + height: 32px; 339 + border: 1px solid var(--border-color); 340 + border-radius: var(--radius-sm); 341 + cursor: pointer; 342 + } 343 + 344 + .preset-colors { 345 + display: flex; 346 + gap: 0.25rem; 347 + } 348 + 349 + .color-preset { 350 + width: 24px; 351 + height: 24px; 352 + border: 2px solid transparent; 353 + border-radius: var(--radius-sm); 354 + cursor: pointer; 355 + transition: all 0.2s; 356 + } 357 + 358 + .color-preset:hover { 359 + transform: scale(1.2); 360 + border-color: var(--text-primary); 361 + } 362 + 363 + /* Settings toggle button */ 364 + .settings-toggle { 365 + background: var(--bg-secondary); 366 + border: 1px solid var(--border-color); 367 + border-radius: var(--radius-sm); 368 + padding: 0.5rem; 369 + cursor: pointer; 370 + display: flex; 371 + align-items: center; 372 + justify-content: center; 373 + transition: all 0.2s; 374 + } 375 + 376 + .settings-toggle:hover { 377 + background: var(--bg-tertiary); 378 + border-color: var(--accent); 379 + } 380 + 381 + .settings-toggle svg { 382 + stroke: var(--accent); 246 383 } 247 384 248 385 .theme-toggle { ··· 260 397 261 398 .theme-toggle:hover { 262 399 background: var(--bg-tertiary); 400 + border-color: var(--accent); 263 401 } 264 402 265 403 .theme-toggle svg { 266 - stroke: var(--text-secondary); 404 + stroke: var(--accent); 267 405 } 268 406 269 407 .sun-icon, .moon-icon { ··· 629 767 } 630 768 }; 631 769 770 + // Simple settings 771 + const initSettings = async () => { 772 + // Try to load from API first, fall back to localStorage 773 + let savedFont = localStorage.getItem('fontFamily') || 'mono'; 774 + let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 775 + 776 + // If user is logged in, fetch from API 777 + const isLoggedIn = document.querySelector('.settings-toggle'); 778 + if (isLoggedIn) { 779 + try { 780 + const response = await fetch('/api/preferences'); 781 + if (response.ok) { 782 + const data = await response.json(); 783 + if (!data.error) { 784 + savedFont = data.font_family || savedFont; 785 + savedAccent = data.accent_color || savedAccent; 786 + // Sync to localStorage 787 + localStorage.setItem('fontFamily', savedFont); 788 + localStorage.setItem('accentColor', savedAccent); 789 + } 790 + } 791 + } catch (err) { 792 + console.log('Using localStorage preferences'); 793 + } 794 + } 795 + 796 + // Apply font family 797 + const fontMap = { 798 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 799 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 800 + 'serif': 'ui-serif, Georgia, Cambria, serif', 801 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 802 + }; 803 + document.documentElement.style.setProperty('--font-family', fontMap[savedFont] || fontMap.system); 804 + 805 + // Update buttons 806 + document.querySelectorAll('.font-btn').forEach(btn => { 807 + btn.classList.toggle('active', btn.dataset.font === savedFont); 808 + }); 809 + 810 + // Apply accent color 811 + document.documentElement.style.setProperty('--accent', savedAccent); 812 + const accentInput = document.getElementById('accent-color'); 813 + if (accentInput) { 814 + accentInput.value = savedAccent; 815 + } 816 + }; 817 + 632 818 // Timestamp formatting is handled by /static/timestamps.js 633 819 634 820 // Fetch user's following list ··· 792 978 }; 793 979 794 980 // Initialize on page load 795 - document.addEventListener('DOMContentLoaded', () => { 981 + document.addEventListener('DOMContentLoaded', async () => { 796 982 initTheme(); 983 + await initSettings(); 797 984 // Timestamps are auto-initialized by timestamps.js 985 + 986 + // Settings toggle 987 + const settingsToggle = document.getElementById('settings-toggle'); 988 + const settingsPanel = document.getElementById('simple-settings'); 989 + if (settingsToggle && settingsPanel) { 990 + settingsToggle.addEventListener('click', () => { 991 + settingsPanel.classList.toggle('hidden'); 992 + }); 993 + } 994 + 995 + // Helper to save preferences to API 996 + const savePreferencesToAPI = async (updates) => { 997 + try { 998 + await fetch('/api/preferences', { 999 + method: 'POST', 1000 + headers: { 'Content-Type': 'application/json' }, 1001 + body: JSON.stringify(updates) 1002 + }); 1003 + } catch (err) { 1004 + console.log('Failed to save preferences to server'); 1005 + } 1006 + }; 1007 + 1008 + // Font family buttons 1009 + document.querySelectorAll('.font-btn').forEach(btn => { 1010 + btn.addEventListener('click', () => { 1011 + const font = btn.dataset.font; 1012 + localStorage.setItem('fontFamily', font); 1013 + 1014 + // Update UI 1015 + document.querySelectorAll('.font-btn').forEach(b => b.classList.remove('active')); 1016 + btn.classList.add('active'); 1017 + 1018 + // Apply 1019 + const fontMap = { 1020 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 1021 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 1022 + 'serif': 'ui-serif, Georgia, Cambria, serif', 1023 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1024 + }; 1025 + document.documentElement.style.setProperty('--font-family', fontMap[font] || fontMap.system); 1026 + 1027 + // Save to API if logged in 1028 + if (document.querySelector('.settings-toggle')) { 1029 + savePreferencesToAPI({ font_family: font }); 1030 + } 1031 + }); 1032 + }); 1033 + 1034 + // Accent color 1035 + const accentInput = document.getElementById('accent-color'); 1036 + if (accentInput) { 1037 + accentInput.addEventListener('input', () => { 1038 + const color = accentInput.value; 1039 + localStorage.setItem('accentColor', color); 1040 + document.documentElement.style.setProperty('--accent', color); 1041 + 1042 + // Save to API if logged in 1043 + if (document.querySelector('.settings-toggle')) { 1044 + savePreferencesToAPI({ accent_color: color }); 1045 + } 1046 + }); 1047 + } 1048 + 1049 + // Color presets 1050 + document.querySelectorAll('.color-preset').forEach(btn => { 1051 + btn.addEventListener('click', () => { 1052 + const color = btn.dataset.color; 1053 + localStorage.setItem('accentColor', color); 1054 + document.documentElement.style.setProperty('--accent', color); 1055 + if (accentInput) { 1056 + accentInput.value = color; 1057 + } 1058 + 1059 + // Save to API if logged in 1060 + if (document.querySelector('.settings-toggle')) { 1061 + savePreferencesToAPI({ accent_color: color }); 1062 + } 1063 + }); 1064 + }); 798 1065 799 1066 // Theme toggle 800 1067 const themeToggle = document.getElementById('theme-toggle');
+1 -1
templates/login.html
··· 118 118 } 119 119 120 120 body { 121 - font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", "SF Mono", "Monaco", "Inconsolata", "Consolas", monospace; 121 + font-family: var(--font-family); 122 122 background: var(--bg-primary); 123 123 color: var(--text-primary); 124 124 line-height: 1.6;
+274 -5
templates/status.html
··· 23 23 </svg> 24 24 </a> 25 25 {% if is_owner %} 26 + <button class="settings-toggle" id="settings-toggle" aria-label="Settings"> 27 + <img src="https://api.iconify.design/lucide:settings.svg?color=%23888" width="20" height="20" alt="Settings"> 28 + </button> 26 29 <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"> 27 30 <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 28 31 <circle cx="12" cy="12" r="5"></circle> ··· 43 46 {% endif %} 44 47 </div> 45 48 </header> 49 + 50 + <!-- Simple Settings (owner only) --> 51 + {% if is_owner %} 52 + <div class="simple-settings hidden" id="simple-settings"> 53 + <div class="settings-row"> 54 + <label>font</label> 55 + <div class="button-group"> 56 + <button class="font-btn active" data-font="system">system</button> 57 + <button class="font-btn" data-font="mono">mono</button> 58 + <button class="font-btn" data-font="serif">serif</button> 59 + <button class="font-btn" data-font="comic">comic</button> 60 + </div> 61 + </div> 62 + <div class="settings-row"> 63 + <label>accent</label> 64 + <input type="color" id="accent-color" value="#1DA1F2"> 65 + <div class="preset-colors"> 66 + <button class="color-preset" data-color="#1DA1F2" style="background: #1DA1F2"></button> 67 + <button class="color-preset" data-color="#FF6B6B" style="background: #FF6B6B"></button> 68 + <button class="color-preset" data-color="#4ECDC4" style="background: #4ECDC4"></button> 69 + <button class="color-preset" data-color="#FFEAA7" style="background: #FFEAA7"></button> 70 + <button class="color-preset" data-color="#A29BFE" style="background: #A29BFE"></button> 71 + <button class="color-preset" data-color="#FD79A8" style="background: #FD79A8"></button> 72 + </div> 73 + </div> 74 + </div> 75 + {% endif %} 46 76 47 77 <!-- Current Status Display --> 48 78 <div class="status-display"> ··· 72 102 {% else %} 73 103 <div class="no-status"> 74 104 <span class="status-emoji">💭</span> 75 - <p class="status-text">No status set</p> 105 + <p class="status-text">no status set</p> 76 106 </div> 77 107 {% endif %} 78 108 </div> ··· 104 134 type="text" 105 135 name="text" 106 136 id="status-text" 107 - placeholder="What's your status?" 137 + placeholder="what's your status?" 108 138 maxlength="100" 109 139 value="" 110 140 autocomplete="off" ··· 249 279 250 280 <script src="/static/emoji-data.js"></script> 251 281 <style> 282 + body { 283 + font-family: var(--font-family) !important; 284 + } 285 + 252 286 :root { 253 287 --bg-primary: #ffffff; 254 288 --bg-secondary: #f8f9fa; ··· 290 324 } 291 325 292 326 body { 293 - font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", "SF Mono", "Monaco", "Inconsolata", "Consolas", monospace; 294 327 background: var(--bg-primary); 295 328 color: var(--text-primary); 296 329 line-height: 1.6; ··· 368 401 } 369 402 370 403 .nav-button svg { 371 - stroke: currentColor; 404 + stroke: var(--accent); 405 + } 406 + 407 + /* Simple Settings */ 408 + .simple-settings { 409 + margin: 1rem 0; 410 + padding: 1rem; 411 + background: var(--bg-secondary); 412 + border-radius: var(--radius); 413 + display: flex; 414 + flex-direction: column; 415 + gap: 1rem; 416 + transition: all 0.3s ease; 417 + transform-origin: top; 418 + } 419 + 420 + .simple-settings.hidden { 421 + display: none; 422 + } 423 + 424 + .settings-row { 425 + display: flex; 426 + align-items: center; 427 + gap: 1rem; 428 + } 429 + 430 + .settings-row label { 431 + min-width: 60px; 432 + color: var(--text-secondary); 433 + font-size: 0.9rem; 434 + } 435 + 436 + .button-group { 437 + display: flex; 438 + gap: 0.25rem; 439 + } 440 + 441 + .font-btn { 442 + padding: 0.25rem 0.75rem; 443 + background: transparent; 444 + border: 1px solid var(--border-color); 445 + border-radius: var(--radius-sm); 446 + color: var(--text-secondary); 447 + cursor: pointer; 448 + transition: all 0.2s; 449 + font-size: 0.85rem; 450 + } 451 + 452 + .font-btn:hover { 453 + border-color: var(--accent); 454 + color: var(--text-primary); 455 + } 456 + 457 + .font-btn.active { 458 + background: var(--accent); 459 + border-color: var(--accent); 460 + color: white; 461 + } 462 + 463 + #accent-color { 464 + width: 50px; 465 + height: 32px; 466 + border: 1px solid var(--border-color); 467 + border-radius: var(--radius-sm); 468 + cursor: pointer; 469 + } 470 + 471 + .preset-colors { 472 + display: flex; 473 + gap: 0.25rem; 474 + } 475 + 476 + .color-preset { 477 + width: 24px; 478 + height: 24px; 479 + border: 2px solid transparent; 480 + border-radius: var(--radius-sm); 481 + cursor: pointer; 482 + transition: all 0.2s; 483 + } 484 + 485 + .color-preset:hover { 486 + transform: scale(1.2); 487 + border-color: var(--text-primary); 488 + } 489 + 490 + /* Settings toggle button */ 491 + .settings-toggle { 492 + background: var(--bg-secondary); 493 + border: 1px solid var(--border-color); 494 + border-radius: var(--radius-sm); 495 + padding: 0.5rem; 496 + cursor: pointer; 497 + display: flex; 498 + align-items: center; 499 + justify-content: center; 500 + transition: all 0.2s; 501 + } 502 + 503 + .settings-toggle:hover { 504 + background: var(--bg-tertiary); 505 + border-color: var(--accent); 506 + } 507 + 508 + .settings-toggle svg { 509 + stroke: var(--accent); 372 510 } 373 511 374 512 .theme-toggle { ··· 385 523 386 524 .theme-toggle:hover { 387 525 background: var(--bg-tertiary); 526 + border-color: var(--accent); 388 527 } 389 528 390 529 .theme-toggle { ··· 392 531 } 393 532 394 533 .theme-toggle svg { 395 - stroke: var(--text-secondary); 534 + stroke: var(--accent); 396 535 } 397 536 398 537 .theme-indicator { ··· 1080 1219 document.addEventListener('DOMContentLoaded', async () => { 1081 1220 initTheme(); 1082 1221 // Timestamps are auto-initialized by timestamps.js 1222 + 1223 + // Simple settings 1224 + const initSettings = async () => { 1225 + // Try to load from API first, fall back to localStorage 1226 + let savedFont = localStorage.getItem('fontFamily') || 'mono'; 1227 + let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 1228 + 1229 + // If user is logged in, fetch from API 1230 + const isOwner = document.querySelector('.settings-toggle'); 1231 + if (isOwner) { 1232 + try { 1233 + const response = await fetch('/api/preferences'); 1234 + if (response.ok) { 1235 + const data = await response.json(); 1236 + if (!data.error) { 1237 + savedFont = data.font_family || savedFont; 1238 + savedAccent = data.accent_color || savedAccent; 1239 + // Sync to localStorage 1240 + localStorage.setItem('fontFamily', savedFont); 1241 + localStorage.setItem('accentColor', savedAccent); 1242 + } 1243 + } 1244 + } catch (err) { 1245 + console.log('Using localStorage preferences'); 1246 + } 1247 + } 1248 + 1249 + // Apply font family 1250 + const fontMap = { 1251 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 1252 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 1253 + 'serif': 'ui-serif, Georgia, Cambria, serif', 1254 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1255 + }; 1256 + document.documentElement.style.setProperty('--font-family', fontMap[savedFont] || fontMap.system); 1257 + 1258 + // Update buttons 1259 + document.querySelectorAll('.font-btn').forEach(btn => { 1260 + btn.classList.toggle('active', btn.dataset.font === savedFont); 1261 + }); 1262 + 1263 + // Apply accent color 1264 + document.documentElement.style.setProperty('--accent', savedAccent); 1265 + const accentInput = document.getElementById('accent-color'); 1266 + if (accentInput) { 1267 + accentInput.value = savedAccent; 1268 + } 1269 + }; 1270 + 1271 + // Settings toggle 1272 + const settingsToggle = document.getElementById('settings-toggle'); 1273 + const settingsPanel = document.getElementById('simple-settings'); 1274 + if (settingsToggle && settingsPanel) { 1275 + settingsToggle.addEventListener('click', () => { 1276 + settingsPanel.classList.toggle('hidden'); 1277 + }); 1278 + } 1279 + 1280 + // Helper to save preferences to API 1281 + const savePreferencesToAPI = async (updates) => { 1282 + try { 1283 + await fetch('/api/preferences', { 1284 + method: 'POST', 1285 + headers: { 'Content-Type': 'application/json' }, 1286 + body: JSON.stringify(updates) 1287 + }); 1288 + } catch (err) { 1289 + console.log('Failed to save preferences to server'); 1290 + } 1291 + }; 1292 + 1293 + // Font family buttons 1294 + document.querySelectorAll('.font-btn').forEach(btn => { 1295 + btn.addEventListener('click', () => { 1296 + const font = btn.dataset.font; 1297 + localStorage.setItem('fontFamily', font); 1298 + 1299 + // Update UI 1300 + document.querySelectorAll('.font-btn').forEach(b => b.classList.remove('active')); 1301 + btn.classList.add('active'); 1302 + 1303 + // Apply 1304 + const fontMap = { 1305 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 1306 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 1307 + 'serif': 'ui-serif, Georgia, Cambria, serif', 1308 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1309 + }; 1310 + document.documentElement.style.setProperty('--font-family', fontMap[font] || fontMap.system); 1311 + 1312 + // Save to API if logged in 1313 + if (document.querySelector('.settings-toggle')) { 1314 + savePreferencesToAPI({ font_family: font }); 1315 + } 1316 + }); 1317 + }); 1318 + 1319 + // Accent color 1320 + const accentInput = document.getElementById('accent-color'); 1321 + if (accentInput) { 1322 + accentInput.addEventListener('input', () => { 1323 + const color = accentInput.value; 1324 + localStorage.setItem('accentColor', color); 1325 + document.documentElement.style.setProperty('--accent', color); 1326 + 1327 + // Save to API if logged in 1328 + if (document.querySelector('.settings-toggle')) { 1329 + savePreferencesToAPI({ accent_color: color }); 1330 + } 1331 + }); 1332 + } 1333 + 1334 + // Color presets 1335 + document.querySelectorAll('.color-preset').forEach(btn => { 1336 + btn.addEventListener('click', () => { 1337 + const color = btn.dataset.color; 1338 + localStorage.setItem('accentColor', color); 1339 + document.documentElement.style.setProperty('--accent', color); 1340 + if (accentInput) { 1341 + accentInput.value = color; 1342 + } 1343 + 1344 + // Save to API if logged in 1345 + if (document.querySelector('.settings-toggle')) { 1346 + savePreferencesToAPI({ accent_color: color }); 1347 + } 1348 + }); 1349 + }); 1350 + 1351 + await initSettings(); 1083 1352 1084 1353 // Load emoji data 1085 1354 if (window.emojiDataLoader) {