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

feat: add testing infrastructure, rate limiting, and error handling

- Add 'just test' command for running tests
- Implement rate limiting (30 req/min per IP) on status endpoint
- Add centralized error handling with AppError type
- Add comprehensive test coverage for new features
- 9 tests total, all passing

+329 -19
+4 -1
justfile
··· 12 12 13 13 clean: 14 14 cargo clean 15 - rm -f status.db 15 + rm -f status.db 16 + 17 + test: 18 + cargo test
+34
progress.md
··· 45 45 - Cleaned up dead code from original fork 46 46 - Posted thread about the launch 47 47 48 + ## Progress Update (Sept 2, 2025) 49 + 50 + ### Major Features Added 51 + - **Custom Emoji Support**: Integrated 1600+ animated emojis from bufo.zone 52 + - Scraped and stored in `/static/emojis/` 53 + - Searchable in emoji picker 54 + - Supports GIF animation 55 + - No database needed - served directly from filesystem 56 + - **Infinite Scrolling**: Global feed now loads forever 57 + - Added `/api/feed` endpoint with pagination 58 + - Smooth loading with "beginning of time" indicator 59 + - Handles large datasets efficiently 60 + - **Theme Consistency**: Added theme toggle indicator across all pages 61 + - **Performance Optimization**: Added database indexes on critical columns 62 + - `idx_status_startedAt` for feed queries 63 + - `idx_status_authorDid_startedAt` for user queries 64 + 65 + ### Bug Fixes 66 + - Fixed favicon not loading in production 67 + - Fixed custom emoji layout issues in picker 68 + - Fixed theme toggle icons being invisible 69 + - Removed unused CSS file and public directory 70 + - Suppressed dead_code warning for auto-generated lexicons 71 + 72 + ### Code Quality Improvements 73 + - Created 5 GitHub issues for technical debt: 74 + - ✅ #1: Database indexes (COMPLETED) 75 + - #2: Excessive unwrap() usage (57 instances) 76 + - #3: Duplicated handle resolution code 77 + - #4: Hardcoded configuration values 78 + - #5: No rate limiting on API endpoints 79 + - Cleaned up unused `public/css` directory 80 + - Removed hardcoded OWNER_DID references 81 + 48 82 ## Next Steps 📋 49 83 50 84 ### Immediate
+96
src/error_handler.rs
··· 1 + use actix_web::{ 2 + error::{ResponseError, ErrorInternalServerError}, 3 + http::StatusCode, 4 + HttpResponse, Result, 5 + }; 6 + use std::fmt; 7 + 8 + #[derive(Debug)] 9 + pub enum AppError { 10 + InternalError(String), 11 + DatabaseError(String), 12 + AuthenticationError(String), 13 + ValidationError(String), 14 + NotFound(String), 15 + RateLimitExceeded, 16 + } 17 + 18 + impl fmt::Display for AppError { 19 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 20 + match self { 21 + AppError::InternalError(msg) => write!(f, "Internal server error: {}", msg), 22 + AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg), 23 + AppError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg), 24 + AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), 25 + AppError::NotFound(msg) => write!(f, "Not found: {}", msg), 26 + AppError::RateLimitExceeded => write!(f, "Rate limit exceeded"), 27 + } 28 + } 29 + } 30 + 31 + impl ResponseError for AppError { 32 + fn error_response(&self) -> HttpResponse { 33 + let (status_code, error_message) = match self { 34 + AppError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), 35 + AppError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error occurred".to_string()), 36 + AppError::AuthenticationError(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), 37 + AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()), 38 + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), 39 + AppError::RateLimitExceeded => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Please try again later.".to_string()), 40 + }; 41 + 42 + HttpResponse::build(status_code) 43 + .body(format!("Error {}: {}", status_code.as_u16(), error_message)) 44 + } 45 + 46 + fn status_code(&self) -> StatusCode { 47 + match self { 48 + AppError::InternalError(_) | AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, 49 + AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED, 50 + AppError::ValidationError(_) => StatusCode::BAD_REQUEST, 51 + AppError::NotFound(_) => StatusCode::NOT_FOUND, 52 + AppError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS, 53 + } 54 + } 55 + } 56 + 57 + // Conversion helpers 58 + impl From<async_sqlite::Error> for AppError { 59 + fn from(err: async_sqlite::Error) -> Self { 60 + AppError::DatabaseError(err.to_string()) 61 + } 62 + } 63 + 64 + impl From<serde_json::Error> for AppError { 65 + fn from(err: serde_json::Error) -> Self { 66 + AppError::InternalError(err.to_string()) 67 + } 68 + } 69 + 70 + // Helper function to wrap results 71 + pub fn handle_result<T>(result: Result<T, AppError>) -> Result<T> { 72 + result.map_err(|e| ErrorInternalServerError(e)) 73 + } 74 + 75 + #[cfg(test)] 76 + mod tests { 77 + use super::*; 78 + 79 + #[test] 80 + fn test_error_display() { 81 + let err = AppError::ValidationError("Invalid input".to_string()); 82 + assert_eq!(err.to_string(), "Validation error: Invalid input"); 83 + 84 + let err = AppError::RateLimitExceeded; 85 + assert_eq!(err.to_string(), "Rate limit exceeded"); 86 + } 87 + 88 + #[test] 89 + fn test_error_status_codes() { 90 + assert_eq!(AppError::InternalError("test".to_string()).status_code(), StatusCode::INTERNAL_SERVER_ERROR); 91 + assert_eq!(AppError::ValidationError("test".to_string()).status_code(), StatusCode::BAD_REQUEST); 92 + assert_eq!(AppError::AuthenticationError("test".to_string()).status_code(), StatusCode::UNAUTHORIZED); 93 + assert_eq!(AppError::NotFound("test".to_string()).status_code(), StatusCode::NOT_FOUND); 94 + assert_eq!(AppError::RateLimitExceeded.status_code(), StatusCode::TOO_MANY_REQUESTS); 95 + } 96 + }
+86 -18
src/main.rs
··· 1 1 use crate::{ 2 2 db::{StatusFromDb, create_tables_in_database}, 3 + error_handler::AppError, 3 4 ingester::start_ingester, 4 5 lexicons::record::KnownRecord, 6 + rate_limiter::RateLimiter, 5 7 storage::{SqliteSessionStore, SqliteStateStore}, 6 8 templates::{FeedTemplate, LoginTemplate, StatusTemplate}, 7 9 }; ··· 38 40 collections::HashMap, 39 41 io::{Error, ErrorKind}, 40 42 sync::Arc, 43 + time::Duration, 41 44 }; 42 45 use templates::{ErrorTemplate, Profile}; 43 46 44 47 mod db; 48 + mod error_handler; 45 49 mod ingester; 46 50 #[allow(dead_code)] 47 51 mod lexicons; 52 + mod rate_limiter; 48 53 mod resolver; 49 54 mod storage; 50 55 mod templates; ··· 1112 1117 oauth_client: web::Data<OAuthClientType>, 1113 1118 db_pool: web::Data<Arc<Pool>>, 1114 1119 form: web::Form<StatusForm>, 1115 - ) -> HttpResponse { 1120 + rate_limiter: web::Data<RateLimiter>, 1121 + ) -> Result<HttpResponse, AppError> { 1122 + // Apply rate limiting 1123 + let client_key = RateLimiter::get_client_key(&request); 1124 + if !rate_limiter.check_rate_limit(&client_key) { 1125 + return Err(AppError::RateLimitExceeded); 1126 + } 1116 1127 // Check if the user is logged in 1117 1128 match session.get::<String>("did").unwrap_or(None) { 1118 1129 Some(did_string) => { ··· 1182 1193 } 1183 1194 1184 1195 let _ = status.save(db_pool).await; 1185 - Redirect::to("/") 1196 + Ok(Redirect::to("/") 1186 1197 .see_other() 1187 1198 .respond_to(&request) 1188 - .map_into_boxed_body() 1199 + .map_into_boxed_body()) 1189 1200 } 1190 1201 Err(err) => { 1191 1202 log::error!("Error creating status: {err}"); ··· 1195 1206 } 1196 1207 .render() 1197 1208 .expect("template should be valid"); 1198 - HttpResponse::Ok().body(error_html) 1209 + Ok(HttpResponse::Ok().body(error_html)) 1199 1210 } 1200 1211 } 1201 1212 } ··· 1205 1216 log::error!( 1206 1217 "Error restoring session, we are removing the session from the cookie: {err}" 1207 1218 ); 1208 - let error_html = ErrorTemplate { 1209 - title: "Error", 1210 - error: "Was an error resuming the session, please check the logs.", 1211 - } 1212 - .render() 1213 - .expect("template should be valid"); 1214 - HttpResponse::Ok().body(error_html) 1219 + Err(AppError::AuthenticationError("Session error".to_string())) 1215 1220 } 1216 1221 } 1217 1222 } 1218 1223 None => { 1219 - let error_template = ErrorTemplate { 1220 - title: "Error", 1221 - error: "You must be logged in to create a status.", 1222 - } 1223 - .render() 1224 - .expect("template should be valid"); 1225 - HttpResponse::Ok().body(error_template) 1224 + Err(AppError::AuthenticationError("You must be logged in to create a status.".to_string())) 1226 1225 } 1227 1226 } 1228 1227 } ··· 1358 1357 log::info!("Jetstream firehose disabled (set ENABLE_FIREHOSE=true to enable)"); 1359 1358 } 1360 1359 let arc_pool = Arc::new(pool.clone()); 1360 + 1361 + // Create rate limiter - 30 requests per minute per IP 1362 + let rate_limiter = web::Data::new(RateLimiter::new(30, Duration::from_secs(60))); 1363 + 1361 1364 log::info!("starting HTTP server at http://{host}:{port}"); 1362 1365 HttpServer::new(move || { 1363 1366 App::new() ··· 1365 1368 .app_data(web::Data::new(client.clone())) 1366 1369 .app_data(web::Data::new(arc_pool.clone())) 1367 1370 .app_data(web::Data::new(handle_resolver.clone())) 1371 + .app_data(rate_limiter.clone()) 1368 1372 .wrap( 1369 1373 SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&[0; 64])) 1370 1374 //TODO will need to set to true in production ··· 1398 1402 .run() 1399 1403 .await 1400 1404 } 1405 + 1406 + #[cfg(test)] 1407 + mod tests { 1408 + use super::*; 1409 + use actix_web::{test, App}; 1410 + 1411 + #[actix_web::test] 1412 + async fn test_health_check() { 1413 + // Simple test to verify our test infrastructure works 1414 + assert_eq!(2 + 2, 4); 1415 + } 1416 + 1417 + #[actix_web::test] 1418 + async fn test_custom_emojis_endpoint() { 1419 + // Test that the custom emojis endpoint returns JSON 1420 + let app = test::init_service( 1421 + App::new() 1422 + .service(get_custom_emojis) 1423 + ).await; 1424 + 1425 + let req = test::TestRequest::get() 1426 + .uri("/api/custom-emojis") 1427 + .to_request(); 1428 + 1429 + let resp = test::call_service(&app, req).await; 1430 + assert!(resp.status().is_success()); 1431 + } 1432 + 1433 + #[actix_web::test] 1434 + async fn test_rate_limiting() { 1435 + // Simple test of the rate limiter directly 1436 + let rate_limiter = RateLimiter::new(3, Duration::from_secs(60)); 1437 + 1438 + // Should allow first 3 requests from same IP 1439 + for i in 0..3 { 1440 + assert!(rate_limiter.check_rate_limit("test_ip"), 1441 + "Request {} should be allowed", i + 1); 1442 + } 1443 + 1444 + // 4th request should be blocked 1445 + assert!(!rate_limiter.check_rate_limit("test_ip"), 1446 + "4th request should be blocked"); 1447 + 1448 + // Different IP should have its own limit 1449 + assert!(rate_limiter.check_rate_limit("different_ip"), 1450 + "Different IP should have its own rate limit"); 1451 + } 1452 + 1453 + #[actix_web::test] 1454 + async fn test_error_handling() { 1455 + use crate::error_handler::AppError; 1456 + use actix_web::{http::StatusCode, ResponseError}; 1457 + 1458 + // Test that our error types return correct status codes 1459 + let err = AppError::ValidationError("test".to_string()); 1460 + assert_eq!(err.status_code(), StatusCode::BAD_REQUEST); 1461 + 1462 + let err = AppError::RateLimitExceeded; 1463 + assert_eq!(err.status_code(), StatusCode::TOO_MANY_REQUESTS); 1464 + 1465 + let err = AppError::AuthenticationError("test".to_string()); 1466 + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); 1467 + } 1468 + }
+109
src/rate_limiter.rs
··· 1 + use actix_web::HttpRequest; 2 + use std::collections::HashMap; 3 + use std::sync::{Arc, Mutex}; 4 + use std::time::{Duration, Instant}; 5 + 6 + #[derive(Clone)] 7 + pub struct RateLimiter { 8 + buckets: Arc<Mutex<HashMap<String, TokenBucket>>>, 9 + max_tokens: u32, 10 + refill_rate: Duration, 11 + } 12 + 13 + struct TokenBucket { 14 + tokens: u32, 15 + last_refill: Instant, 16 + } 17 + 18 + impl RateLimiter { 19 + pub fn new(max_tokens: u32, refill_rate: Duration) -> Self { 20 + Self { 21 + buckets: Arc::new(Mutex::new(HashMap::new())), 22 + max_tokens, 23 + refill_rate, 24 + } 25 + } 26 + 27 + pub fn check_rate_limit(&self, key: &str) -> bool { 28 + let mut buckets = self.buckets.lock().unwrap(); 29 + let now = Instant::now(); 30 + 31 + let bucket = buckets.entry(key.to_string()).or_insert(TokenBucket { 32 + tokens: self.max_tokens, 33 + last_refill: now, 34 + }); 35 + 36 + // Refill tokens based on elapsed time 37 + let elapsed = now.duration_since(bucket.last_refill); 38 + let tokens_to_add = (elapsed.as_secs_f64() / self.refill_rate.as_secs_f64() * self.max_tokens as f64) as u32; 39 + 40 + if tokens_to_add > 0 { 41 + bucket.tokens = (bucket.tokens + tokens_to_add).min(self.max_tokens); 42 + bucket.last_refill = now; 43 + } 44 + 45 + // Check if we have tokens available 46 + if bucket.tokens > 0 { 47 + bucket.tokens -= 1; 48 + true 49 + } else { 50 + false 51 + } 52 + } 53 + 54 + pub fn get_client_key(req: &HttpRequest) -> String { 55 + // Use IP address as the key for rate limiting 56 + req.connection_info() 57 + .realip_remote_addr() 58 + .unwrap_or("unknown") 59 + .to_string() 60 + } 61 + } 62 + 63 + #[cfg(test)] 64 + mod tests { 65 + use super::*; 66 + use std::thread; 67 + 68 + #[test] 69 + fn test_rate_limiter_basic() { 70 + let limiter = RateLimiter::new(5, Duration::from_secs(1)); 71 + 72 + // Should allow first 5 requests 73 + for _ in 0..5 { 74 + assert!(limiter.check_rate_limit("test_client")); 75 + } 76 + 77 + // 6th request should be blocked 78 + assert!(!limiter.check_rate_limit("test_client")); 79 + } 80 + 81 + #[test] 82 + fn test_rate_limiter_refill() { 83 + let limiter = RateLimiter::new(2, Duration::from_millis(100)); 84 + 85 + // Use up tokens 86 + assert!(limiter.check_rate_limit("test_client")); 87 + assert!(limiter.check_rate_limit("test_client")); 88 + assert!(!limiter.check_rate_limit("test_client")); 89 + 90 + // Wait for refill 91 + thread::sleep(Duration::from_millis(150)); 92 + 93 + // Should have tokens again 94 + assert!(limiter.check_rate_limit("test_client")); 95 + } 96 + 97 + #[test] 98 + fn test_rate_limiter_different_clients() { 99 + let limiter = RateLimiter::new(1, Duration::from_secs(1)); 100 + 101 + // Different clients should have separate buckets 102 + assert!(limiter.check_rate_limit("client1")); 103 + assert!(limiter.check_rate_limit("client2")); 104 + 105 + // But same client should be limited 106 + assert!(!limiter.check_rate_limit("client1")); 107 + assert!(!limiter.check_rate_limit("client2")); 108 + } 109 + }