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

Merge branch 'main' into fix/technical-debt-issues

+130 -169
-27
.env.example
··· 1 - # Status App Configuration 2 - # Copy this file to .env and customize as needed 3 - 4 - # Owner handle for the default status page 5 - OWNER_HANDLE=zzstoatzz.io 6 - 7 - # Database URL (defaults to local SQLite) 8 - # For production, consider using a persistent volume 9 - DATABASE_URL=sqlite://./statusphere.sqlite3 10 - 11 - # OAuth redirect base URL (must match your deployment URL) 12 - # For production: https://status.yourdomain.com 13 - OAUTH_REDIRECT_BASE=http://localhost:8080 14 - 15 - # Server configuration 16 - SERVER_HOST=127.0.0.1 17 - SERVER_PORT=8080 18 - 19 - # Enable firehose ingester (true/false) 20 - # Set to true in production to receive real-time updates 21 - ENABLE_FIREHOSE=false 22 - 23 - # Log level (trace, debug, info, warn, error) 24 - RUST_LOG=info 25 - 26 - # Note: Admin DID is intentionally hardcoded in the code for security 27 - # This prevents accidental exposure or modification
+20 -27
src/db.rs
··· 12 12 /// Creates the tables in the db. 13 13 pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { 14 14 pool.conn(move |conn| { 15 - conn.execute("PRAGMA foreign_keys = ON", [])?; 15 + conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); 16 16 17 17 // status 18 18 conn.execute( ··· 26 26 indexedAt INTEGER NOT NULL 27 27 )", 28 28 [], 29 - )?; 29 + ) 30 + .unwrap(); 30 31 31 32 // auth_session 32 33 conn.execute( ··· 35 36 session TEXT NOT NULL 36 37 )", 37 38 [], 38 - )?; 39 + ) 40 + .unwrap(); 39 41 40 42 // auth_state 41 43 conn.execute( ··· 44 46 state TEXT NOT NULL 45 47 )", 46 48 [], 47 - )?; 49 + ) 50 + .unwrap(); 48 51 49 52 // Note: custom_emojis table removed - we serve emojis directly from static/emojis/ directory 50 53 ··· 53 56 conn.execute( 54 57 "CREATE INDEX IF NOT EXISTS idx_status_startedAt ON status(startedAt DESC)", 55 58 [], 56 - )?; 59 + ) 60 + .unwrap(); 57 61 58 62 // Composite index for user status queries (WHERE authorDid = ? ORDER BY startedAt DESC) 59 63 conn.execute( 60 64 "CREATE INDEX IF NOT EXISTS idx_status_authorDid_startedAt ON status(authorDid, startedAt DESC)", 61 65 [], 62 - )?; 66 + ) 67 + .unwrap(); 63 68 64 69 // Add hidden column for moderation (won't error if already exists) 65 70 let _ = conn.execute( ··· 125 130 indexed_at: { 126 131 let timestamp: i64 = row.get(6)?; 127 132 DateTime::from_timestamp(timestamp, 0).ok_or_else(|| { 128 - Error::InvalidColumnType(6, "Invalid timestamp".to_string(), Type::Text) 133 + Error::InvalidColumnType(6, "Invalid timestamp".parse().unwrap(), Type::Text) 129 134 })? 130 135 }, 131 136 handle: None, ··· 221 226 let mut stmt = 222 227 conn.prepare("SELECT * FROM status WHERE (hidden IS NULL OR hidden = FALSE) ORDER BY startedAt DESC LIMIT 10")?; 223 228 let status_iter = stmt 224 - .query_map([], |row| Self::map_from_row(row))?; 229 + .query_map([], |row| Ok(Self::map_from_row(row).unwrap())) 230 + .unwrap(); 225 231 226 232 let mut statuses = Vec::new(); 227 233 for status in status_iter { ··· 245 251 )?; 246 252 let status_iter = stmt 247 253 .query_map(rusqlite::params![limit, offset], |row| { 248 - Self::map_from_row(row) 249 - })?; 254 + Ok(Self::map_from_row(row).unwrap()) 255 + }) 256 + .unwrap(); 250 257 251 258 let mut statuses = Vec::new(); 252 259 for status in status_iter { ··· 325 332 where 326 333 V: Serialize, 327 334 { 328 - let session = serde_json::to_string(&session) 329 - .unwrap_or_else(|e| { 330 - log::error!("Failed to serialize session: {}", e); 331 - "{}".to_string() 332 - }); 335 + let session = serde_json::to_string(&session).unwrap(); 333 336 Self { 334 337 key: key.to_string(), 335 338 session, ··· 345 348 346 349 /// Gets a session by the users did(key) 347 350 pub async fn get_by_did(pool: &Pool, did: String) -> Result<Option<Self>, async_sqlite::Error> { 348 - let did = match Did::new(did) { 349 - Ok(d) => d, 350 - Err(e) => { 351 - log::error!("Invalid DID: {}", e); 352 - return Ok(None); 353 - } 354 - }; 351 + let did = Did::new(did).unwrap(); 355 352 pool.conn(move |conn| { 356 353 let mut stmt = conn.prepare("SELECT * FROM auth_session WHERE key = ?1")?; 357 354 stmt.query_row([did.as_str()], Self::map_from_row) ··· 428 425 where 429 426 V: Serialize, 430 427 { 431 - let state = serde_json::to_string(&state) 432 - .unwrap_or_else(|e| { 433 - log::error!("Failed to serialize state: {}", e); 434 - "{}".to_string() 435 - }); 428 + let state = serde_json::to_string(&state).unwrap(); 436 429 Self { 437 430 key: key.to_string(), 438 431 state,
+110 -115
src/main.rs
··· 45 45 }; 46 46 use templates::{ErrorTemplate, Profile}; 47 47 48 - mod config; 49 48 mod db; 50 49 mod error_handler; 51 50 mod ingester; ··· 79 78 /// HandleResolver to make it easier to access the OAuthClient in web requests 80 79 type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 81 80 81 + /// Admin DID for moderation 82 + const ADMIN_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 83 + 82 84 /// Check if a DID is the admin 83 - fn is_admin(did: &str, config: &config::Config) -> bool { 84 - did == config.admin_did 85 + fn is_admin(did: &str) -> bool { 86 + did == ADMIN_DID 85 87 } 86 88 87 89 /// OAuth client metadata endpoint for production 88 90 #[get("/client-metadata.json")] 89 - async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> { 90 - let public_url = config.oauth_redirect_base.clone(); 91 + async fn client_metadata() -> Result<HttpResponse> { 92 + let public_url = std::env::var("PUBLIC_URL") 93 + .unwrap_or_else(|_| "http://localhost:8080".to_string()); 91 94 92 95 let metadata = serde_json::json!({ 93 96 "client_id": format!("{}/client-metadata.json", public_url), ··· 187 190 let agent = Agent::new(bsky_session); 188 191 match agent.did().await { 189 192 Some(did) => { 190 - if let Err(e) = session.insert("did", did) { 191 - log::error!("Failed to save session: {}", e); 192 - } 193 + session.insert("did", did).unwrap(); 193 194 Redirect::to("/") 194 195 .see_other() 195 196 .respond_to(&request) ··· 246 247 } 247 248 248 249 /// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L101 249 - /// Shared function to resolve handles for a list of statuses 250 - async fn resolve_handles_for_statuses( 251 - statuses: &mut Vec<StatusFromDb>, 252 - handle_resolver: &HandleResolver, 253 - ) -> Result<()> { 254 - let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 255 - 256 - for db_status in statuses.iter_mut() { 257 - let authors_did = Did::new(db_status.author_did.clone()) 258 - .map_err(|e| AppError::InternalError(format!("Failed to parse DID: {}", e)))?; 259 - 260 - // Check cache first 261 - if let Some(found_handle) = quick_resolve_map.get(&authors_did) { 262 - db_status.handle = Some(found_handle.clone()); 263 - continue; 264 - } 265 - 266 - // Resolve handle 267 - db_status.handle = match handle_resolver.resolve(&authors_did).await { 268 - Ok(did_doc) => { 269 - did_doc.also_known_as 270 - .and_then(|aka| { 271 - if aka.is_empty() { 272 - None 273 - } else { 274 - aka.first().map(|full_handle| { 275 - let handle = full_handle.replace("at://", ""); 276 - quick_resolve_map.insert(authors_did.clone(), handle.clone()); 277 - handle 278 - }) 279 - } 280 - }) 281 - } 282 - Err(err) => { 283 - log::debug!("Could not resolve handle for DID {}: {}", authors_did.as_str(), err); 284 - None 285 - } 286 - }; 287 - } 288 - 289 - Ok(()) 290 - } 291 - 292 250 /// Login endpoint 293 251 #[post("/login")] 294 252 async fn login_post( ··· 344 302 _oauth_client: web::Data<OAuthClientType>, 345 303 db_pool: web::Data<Arc<Pool>>, 346 304 handle_resolver: web::Data<HandleResolver>, 347 - config: web::Data<config::Config>, 348 305 ) -> Result<impl Responder> { 349 - // Owner handle from config 350 - let owner_handle = &config.owner_handle; 306 + // Default owner of the domain 307 + const OWNER_HANDLE: &str = "zzstoatzz.io"; 351 308 352 309 // Check if user is logged in 353 310 match session.get::<String>("did").unwrap_or(None) { ··· 406 363 http_client: Arc::new(DefaultHttpClient::default()), 407 364 }); 408 365 409 - let owner_handle_str = owner_handle.to_string(); 410 - let owner_handle_parsed = 411 - atrium_api::types::string::Handle::new(owner_handle_str.clone()).ok(); 412 - let owner_did = if let Some(handle) = owner_handle_parsed { 366 + let owner_handle = 367 + atrium_api::types::string::Handle::new(OWNER_HANDLE.to_string()).ok(); 368 + let owner_did = if let Some(handle) = owner_handle { 413 369 atproto_handle_resolver.resolve(&handle).await.ok() 414 370 } else { 415 371 None ··· 445 401 446 402 let html = StatusTemplate { 447 403 title: "nate's status", 448 - handle: owner_handle_str, 404 + handle: OWNER_HANDLE.to_string(), 449 405 status_options: &STATUS_OPTIONS, 450 406 current_status, 451 407 history, ··· 552 508 async fn owner_status_json( 553 509 db_pool: web::Data<Arc<Pool>>, 554 510 _handle_resolver: web::Data<HandleResolver>, 555 - config: web::Data<config::Config>, 556 511 ) -> Result<impl Responder> { 557 - // Owner handle from config 558 - let owner_handle = &config.owner_handle; 512 + // Default owner of the domain 513 + const OWNER_HANDLE: &str = "zzstoatzz.io"; 559 514 560 515 // Resolve handle to DID using ATProto handle resolution 561 516 let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { ··· 564 519 }); 565 520 566 521 let did = match atproto_handle_resolver 567 - .resolve(&owner_handle.parse().expect("failed to parse handle")) 522 + .resolve(&OWNER_HANDLE.parse().expect("failed to parse handle")) 568 523 .await 569 524 { 570 525 Ok(d) => Some(d.to_string()), 571 526 Err(e) => { 572 - log::error!("Failed to resolve handle {}: {}", owner_handle, e); 527 + log::error!("Failed to resolve handle {}: {}", OWNER_HANDLE, e); 573 528 None 574 529 } 575 530 }; ··· 594 549 595 550 let response = if let Some(status_data) = current_status { 596 551 serde_json::json!({ 597 - "handle": owner_handle, 552 + "handle": OWNER_HANDLE, 598 553 "status": "known", 599 554 "emoji": status_data.status, 600 555 "text": status_data.text, ··· 603 558 }) 604 559 } else { 605 560 serde_json::json!({ 606 - "handle": owner_handle, 561 + "handle": OWNER_HANDLE, 607 562 "status": "unknown", 608 563 "message": "No current status is known" 609 564 }) ··· 634 589 vec![] 635 590 }); 636 591 637 - // Resolve handles for all statuses 638 - if let Err(e) = resolve_handles_for_statuses(&mut statuses, &handle_resolver).await { 639 - log::error!("Error resolving handles: {}", e); 592 + // Resolve handles for each status 593 + let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 594 + for db_status in &mut statuses { 595 + let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 596 + match quick_resolve_map.get(&authors_did) { 597 + None => {} 598 + Some(found_handle) => { 599 + db_status.handle = Some(found_handle.clone()); 600 + continue; 601 + } 602 + } 603 + db_status.handle = match handle_resolver.resolve(&authors_did).await { 604 + Ok(did_doc) => match did_doc.also_known_as { 605 + None => None, 606 + Some(also_known_as) => match also_known_as.is_empty() { 607 + true => None, 608 + false => { 609 + let full_handle = also_known_as.first().unwrap(); 610 + let handle = full_handle.replace("at://", ""); 611 + quick_resolve_map.insert(authors_did, handle.clone()); 612 + Some(handle) 613 + } 614 + }, 615 + }, 616 + Err(_) => None, 617 + }; 640 618 } 641 619 642 620 Ok(HttpResponse::Ok().json(statuses)) ··· 747 725 748 726 /// JSON API endpoint for status - returns current status or "unknown" 749 727 #[get("/api/status")] 750 - async fn status_json( 751 - db_pool: web::Data<Arc<Pool>>, 752 - config: web::Data<config::Config>, 753 - ) -> Result<impl Responder> { 754 - // For backwards compatibility, this returns the owner's status 755 - // Use owner's DID from config (admin DID) 756 - let owner_did = Did::new(config.admin_did.clone()).ok(); 728 + async fn status_json(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 729 + const OWNER_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 730 + 731 + let owner_did = Did::new(OWNER_DID.to_string()).ok(); 757 732 let current_status = if let Some(ref did) = owner_did { 758 733 StatusFromDb::my_status(&db_pool, did) 759 734 .await ··· 796 771 oauth_client: web::Data<OAuthClientType>, 797 772 db_pool: web::Data<Arc<Pool>>, 798 773 handle_resolver: web::Data<HandleResolver>, 799 - config: web::Data<config::Config>, 800 774 ) -> Result<impl Responder> { 801 775 // This is essentially the old home function 802 776 const TITLE: &str = "status feed"; ··· 807 781 vec![] 808 782 }); 809 783 810 - // Resolve handles for all statuses 811 - if let Err(e) = resolve_handles_for_statuses(&mut statuses, &handle_resolver).await { 812 - log::error!("Error resolving handles: {}", e); 784 + let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 785 + for db_status in &mut statuses { 786 + let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 787 + match quick_resolve_map.get(&authors_did) { 788 + None => {} 789 + Some(found_handle) => { 790 + db_status.handle = Some(found_handle.clone()); 791 + continue; 792 + } 793 + } 794 + db_status.handle = match handle_resolver.resolve(&authors_did).await { 795 + Ok(did_doc) => match did_doc.also_known_as { 796 + None => None, 797 + Some(also_known_as) => match also_known_as.is_empty() { 798 + true => None, 799 + false => { 800 + let full_handle = also_known_as.first().unwrap(); 801 + let handle = full_handle.replace("at://", ""); 802 + quick_resolve_map.insert(authors_did, handle.clone()); 803 + Some(handle) 804 + } 805 + }, 806 + }, 807 + Err(err) => { 808 + log::error!("Error resolving did: {err}"); 809 + None 810 + } 811 + }; 813 812 } 814 813 815 814 match session.get::<String>("did").unwrap_or(None) { ··· 838 837 ) 839 838 .await; 840 839 841 - let is_admin = is_admin(&did.to_string(), &config); 840 + let is_admin = is_admin(&did.to_string()); 842 841 let html = FeedTemplate { 843 842 title: TITLE, 844 843 profile: match profile { ··· 1133 1132 session: Session, 1134 1133 db_pool: web::Data<Arc<Pool>>, 1135 1134 req: web::Json<HideStatusRequest>, 1136 - config: web::Data<config::Config>, 1137 1135 ) -> HttpResponse { 1138 1136 // Check if the user is logged in and is admin 1139 1137 match session.get::<String>("did").unwrap_or(None) { 1140 1138 Some(did_string) => { 1141 - if !is_admin(&did_string, &config) { 1139 + if !is_admin(&did_string) { 1142 1140 return HttpResponse::Forbidden().json(serde_json::json!({ 1143 1141 "error": "Admin access required" 1144 1142 })); ··· 1240 1238 .repo 1241 1239 .create_record( 1242 1240 atrium_api::com::atproto::repo::create_record::InputData { 1243 - collection: "io.zzstoatzz.status.record".parse() 1244 - .map_err(|e| AppError::InternalError(format!("Invalid collection: {}", e)))?, 1241 + collection: "io.zzstoatzz.status.record".parse().unwrap(), 1245 1242 repo: did.into(), 1246 1243 rkey: None, 1247 1244 record: status.into(), ··· 1307 1304 #[actix_web::main] 1308 1305 async fn main() -> std::io::Result<()> { 1309 1306 dotenv().ok(); 1310 - 1311 - // Load configuration 1312 - let config = config::Config::from_env().expect("Failed to load configuration"); 1313 - let app_config = config.clone(); 1314 - 1315 - env_logger::init_from_env(env_logger::Env::new().default_filter_or(&config.log_level)); 1316 - let host = config.server_host.clone(); 1317 - let port = config.server_port; 1307 + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 1308 + let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 1309 + let port = std::env::var("PORT") 1310 + .unwrap_or_else(|_| "8080".to_string()) 1311 + .parse::<u16>() 1312 + .unwrap_or(8080); 1318 1313 1319 - // Use database URL from config 1320 - let db_connection_string = if config.database_url.starts_with("sqlite://") { 1321 - config.database_url.strip_prefix("sqlite://").unwrap_or(&config.database_url).to_string() 1322 - } else { 1323 - config.database_url.clone() 1324 - }; 1314 + //Uses a default sqlite db path or use the one from env 1315 + let db_connection_string = 1316 + std::env::var("DB_PATH").unwrap_or_else(|_| String::from("./statusphere.sqlite3")); 1325 1317 1326 1318 //Crates a db pool to share resources to the db 1327 1319 let pool = match PoolBuilder::new().path(db_connection_string).open().await { ··· 1352 1344 // Create a new OAuth client 1353 1345 let http_client = Arc::new(DefaultHttpClient::default()); 1354 1346 1355 - // Check if we're running in production (non-localhost) or locally 1356 - let is_production = !config.oauth_redirect_base.starts_with("http://localhost") 1357 - && !config.oauth_redirect_base.starts_with("http://127.0.0.1"); 1347 + // Check if we're running in production (with PUBLIC_URL) or locally 1348 + let public_url = std::env::var("PUBLIC_URL").ok(); 1358 1349 1359 - let client: OAuthClientType = if is_production { 1350 + let client: OAuthClientType = if let Some(public_url) = public_url { 1360 1351 // Production configuration with AtprotoClientMetadata 1361 - log::info!("Configuring OAuth for production with URL: {}", config.oauth_redirect_base); 1352 + log::info!("Configuring OAuth for production with URL: {}", public_url); 1362 1353 1363 - let oauth_config = OAuthClientConfig { 1354 + let config = OAuthClientConfig { 1364 1355 client_metadata: AtprotoClientMetadata { 1365 - client_id: format!("{}/client-metadata.json", config.oauth_redirect_base), 1366 - client_uri: Some(config.oauth_redirect_base.clone()), 1367 - redirect_uris: vec![format!("{}/oauth/callback", config.oauth_redirect_base)], 1356 + client_id: format!("{}/client-metadata.json", public_url), 1357 + client_uri: Some(public_url.clone()), 1358 + redirect_uris: vec![format!("{}/oauth/callback", public_url)], 1368 1359 token_endpoint_auth_method: AuthMethod::None, 1369 1360 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 1370 1361 scopes: vec![ ··· 1390 1381 state_store: SqliteStateStore::new(pool.clone()), 1391 1382 session_store: SqliteSessionStore::new(pool.clone()), 1392 1383 }; 1393 - Arc::new(OAuthClient::new(oauth_config).expect("failed to create OAuth client")) 1384 + Arc::new(OAuthClient::new(config).expect("failed to create OAuth client")) 1394 1385 } else { 1395 1386 // Local development configuration with AtprotoLocalhostClientMetadata 1396 1387 log::info!("Configuring OAuth for local development at {}:{}", host, port); 1397 1388 1398 - let oauth_config = OAuthClientConfig { 1389 + let config = OAuthClientConfig { 1399 1390 client_metadata: AtprotoLocalhostClientMetadata { 1400 1391 redirect_uris: Some(vec![format!( 1401 1392 //This must match the endpoint you use the callback function 1402 - "http://{}:{}/oauth/callback", host, port 1393 + "http://{host}:{port}/oauth/callback" 1403 1394 )]), 1404 1395 scopes: Some(vec![ 1405 1396 Scope::Known(KnownScope::Atproto), ··· 1422 1413 state_store: SqliteStateStore::new(pool.clone()), 1423 1414 session_store: SqliteSessionStore::new(pool.clone()), 1424 1415 }; 1425 - Arc::new(OAuthClient::new(oauth_config).expect("failed to create OAuth client")) 1416 + Arc::new(OAuthClient::new(config).expect("failed to create OAuth client")) 1426 1417 }; 1427 - // Only start the firehose ingester if enabled (from config) 1428 - if app_config.enable_firehose { 1418 + // Only start the firehose ingester if enabled (default: disabled locally) 1419 + let enable_firehose = std::env::var("ENABLE_FIREHOSE") 1420 + .unwrap_or_else(|_| "false".to_string()) 1421 + .parse::<bool>() 1422 + .unwrap_or(false); 1423 + 1424 + if enable_firehose { 1429 1425 let arc_pool = Arc::new(pool.clone()); 1430 1426 log::info!("Starting Jetstream firehose ingester"); 1431 1427 //Spawns the ingester that listens for other's Statusphere updates ··· 1447 1443 .app_data(web::Data::new(client.clone())) 1448 1444 .app_data(web::Data::new(arc_pool.clone())) 1449 1445 .app_data(web::Data::new(handle_resolver.clone())) 1450 - .app_data(web::Data::new(app_config.clone())) 1451 1446 .app_data(rate_limiter.clone()) 1452 1447 .wrap( 1453 1448 SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&[0; 64]))