A Rust application to showcase badge awards in the AT Protocol ecosystem.
at main 375 lines 12 kB view raw
1//! HTTP server implementation with Axum web framework. 2//! 3//! Provides REST API endpoints and template-rendered pages for displaying 4//! badge awards, with identity resolution and static file serving. 5 6use std::sync::Arc; 7 8use atproto_identity::{ 9 axum::state::DidDocumentStorageExtractor, 10 resolve::{IdentityResolver, InputType, parse_input}, 11 storage::DidDocumentStorage, 12}; 13use axum::{ 14 Router, 15 body::Body, 16 extract::{FromRef, Path, State}, 17 http::{StatusCode, header}, 18 response::{IntoResponse, Response}, 19 routing::get, 20}; 21use axum_template::RenderHtml; 22use axum_template::engine::Engine; 23use minijinja::context; 24use serde::{Deserialize, Serialize}; 25use tower_http::services::ServeDir; 26 27use crate::{ 28 config::Config, 29 errors::{Result, ShowcaseError}, 30 storage::{FileStorage, Storage}, 31}; 32 33#[cfg(feature = "reload")] 34use minijinja_autoreload::AutoReloader; 35 36#[cfg(feature = "reload")] 37/// Template engine with auto-reloading support for development. 38pub type AppEngine = Engine<AutoReloader>; 39 40#[cfg(feature = "embed")] 41use minijinja::Environment; 42 43#[cfg(feature = "embed")] 44pub type AppEngine = Engine<Environment<'static>>; 45 46#[cfg(not(any(feature = "reload", feature = "embed")))] 47pub type AppEngine = Engine<minijinja::Environment<'static>>; 48 49/// Application state shared across HTTP handlers. 50#[derive(Clone)] 51pub struct AppState { 52 /// Database storage for badges and awards. 53 pub storage: Arc<dyn Storage>, 54 /// Storage for DID documents. 55 pub document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 56 /// Identity resolver for DID resolution. 57 pub identity_resolver: IdentityResolver, 58 /// Application configuration. 59 pub config: Arc<Config>, 60 /// Template engine for rendering HTML responses. 61 pub template_env: AppEngine, 62 /// File storage for badge images. 63 pub file_storage: Arc<dyn FileStorage>, 64} 65 66impl FromRef<AppState> for DidDocumentStorageExtractor { 67 fn from_ref(context: &AppState) -> Self { 68 atproto_identity::axum::state::DidDocumentStorageExtractor(context.document_storage.clone()) 69 } 70} 71 72impl FromRef<AppState> for IdentityResolver { 73 fn from_ref(context: &AppState) -> Self { 74 context.identity_resolver.clone() 75 } 76} 77 78#[derive(Debug, Serialize, Deserialize)] 79struct AwardDisplay { 80 pub did: String, 81 pub handle: String, 82 pub badge_name: String, 83 pub badge_image: Option<String>, 84 pub signers: Vec<String>, 85 pub created_at: String, 86} 87 88/// Create the main application router with all HTTP routes. 89pub fn create_router(state: AppState) -> Router { 90 Router::new() 91 .route("/", get(handle_index)) 92 .route("/badges/{subject}", get(handle_identity)) 93 .route("/badge/{cid}", get(handle_badge_image)) 94 .nest_service("/static", ServeDir::new(&state.config.http.static_path)) 95 .with_state(state) 96} 97 98async fn handle_index(State(state): State<AppState>) -> Result<impl IntoResponse> { 99 match get_recent_awards(&state).await { 100 Ok(awards) => Ok(RenderHtml( 101 "index.html", 102 state.template_env.clone(), 103 context! { 104 title => "Recent Badge Awards", 105 awards => awards, 106 }, 107 )), 108 Err(_) => Err(ShowcaseError::HttpInternalServerError), 109 } 110} 111 112async fn handle_identity( 113 Path(subject): Path<String>, 114 State(state): State<AppState>, 115) -> Result<impl IntoResponse> { 116 let did = match parse_input(&subject) { 117 Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => did, 118 Ok(InputType::Handle(_)) => match state.storage.get_identity_by_handle(&subject).await { 119 Ok(Some(identity)) => identity.did, 120 Ok(None) => return Ok((StatusCode::NOT_FOUND).into_response()), 121 Err(_) => return Ok((StatusCode::NOT_FOUND).into_response()), 122 }, 123 Err(_) => return Ok((StatusCode::NOT_FOUND).into_response()), 124 }; 125 126 match get_awards_for_identity(&state, &did).await { 127 Ok((awards, identity_handle)) => Ok(RenderHtml( 128 "identity.html", 129 state.template_env.clone(), 130 context! { 131 title => format!("Badge Awards for @{}", identity_handle), 132 subject => identity_handle, 133 awards => awards, 134 }, 135 ) 136 .into_response()), 137 Err(_) => Err(ShowcaseError::HttpInternalServerError), 138 } 139} 140 141async fn handle_badge_image( 142 Path(cid): Path<String>, 143 State(state): State<AppState>, 144) -> impl IntoResponse { 145 // Validate that the CID ends with ".png" 146 if !cid.ends_with(".png") { 147 tracing::warn!(?cid, "file not png"); 148 return (StatusCode::NOT_FOUND).into_response(); 149 } 150 151 // Read the file using FileStorage 152 let file_data = match state.file_storage.read_file(&cid).await { 153 Ok(data) => data, 154 Err(err) => { 155 tracing::warn!(?cid, ?err, "file_storage read_file error"); 156 return (StatusCode::NOT_FOUND).into_response(); 157 } 158 }; 159 160 // Check file size (must be less than 1MB) 161 const MAX_FILE_SIZE: usize = 1024 * 1024; // 1MB 162 if file_data.len() > MAX_FILE_SIZE { 163 tracing::warn!(?cid, len = file_data.len(), "file too big"); 164 return (StatusCode::NOT_FOUND).into_response(); 165 } 166 167 // Validate that it's actually a PNG image 168 if !is_valid_png(&file_data) { 169 tracing::warn!(?cid, "file not png"); 170 return (StatusCode::NOT_FOUND).into_response(); 171 } 172 173 // Return the image with appropriate content type 174 Response::builder() 175 .status(StatusCode::OK) 176 .header(header::CONTENT_TYPE, "image/png") 177 .header(header::CACHE_CONTROL, "public, max-age=86400") // Cache for 1 day 178 .body(Body::from(file_data)) 179 .unwrap() 180 .into_response() 181} 182 183/// Validate that the provided bytes represent a valid PNG image 184fn is_valid_png(data: &[u8]) -> bool { 185 // PNG signature: 89 50 4E 47 0D 0A 1A 0A 186 const PNG_SIGNATURE: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; 187 188 data.len() >= PNG_SIGNATURE.len() && data.starts_with(PNG_SIGNATURE) 189} 190 191async fn get_recent_awards(state: &AppState) -> Result<Vec<AwardDisplay>> { 192 let awards_with_details = state.storage.get_recent_awards(50).await?; 193 194 let mut result = Vec::new(); 195 for item in awards_with_details { 196 let handle = item 197 .identity 198 .as_ref() 199 .map(|i| i.handle.clone()) 200 .unwrap_or_else(|| "unknown.handle".to_string()); 201 202 let badge_image = item 203 .badge 204 .as_ref() 205 .and_then(|b| b.image.clone()) 206 .map(|img| format!("{}.png", img)); 207 208 let signers = format_signers(&item.signer_identities); 209 210 result.push(AwardDisplay { 211 did: item.award.did, 212 handle, 213 badge_name: item.award.badge_name.clone(), 214 badge_image, 215 signers, 216 created_at: item 217 .award 218 .created_at 219 .format("%Y-%m-%d %H:%M UTC") 220 .to_string(), 221 }); 222 } 223 224 Ok(result) 225} 226 227async fn get_awards_for_identity( 228 state: &AppState, 229 did: &str, 230) -> Result<(Vec<AwardDisplay>, String)> { 231 let awards_with_details = state.storage.get_awards_for_did(did, 50).await?; 232 233 let identity_handle = state 234 .storage 235 .get_identity_by_did(did) 236 .await? 237 .map(|i| i.handle) 238 .unwrap_or_else(|| "unknown.handle".to_string()); 239 240 let mut result = Vec::new(); 241 for item in awards_with_details { 242 let handle = item 243 .identity 244 .as_ref() 245 .map(|i| i.handle.clone()) 246 .unwrap_or_else(|| "unknown.handle".to_string()); 247 248 let badge_image = item 249 .badge 250 .as_ref() 251 .and_then(|b| b.image.clone()) 252 .map(|img| format!("{}.png", img)); 253 254 let signers = format_signers(&item.signer_identities); 255 256 result.push(AwardDisplay { 257 did: item.award.did, 258 handle, 259 badge_name: item.award.badge_name.clone(), 260 badge_image, 261 signers, 262 created_at: item 263 .award 264 .created_at 265 .format("%Y-%m-%d %H:%M UTC") 266 .to_string(), 267 }); 268 } 269 270 Ok((result, identity_handle)) 271} 272 273fn format_signers(signer_identities: &[crate::storage::Identity]) -> Vec<String> { 274 let handles: Vec<String> = signer_identities 275 .iter() 276 .map(|i| i.handle.to_string()) 277 .collect(); 278 279 if handles.len() <= 3 { 280 handles 281 } else { 282 let mut result = handles[..2].to_vec(); 283 result.push(format!("and {} others", handles.len() - 2)); 284 result 285 } 286} 287 288#[cfg(test)] 289mod tests { 290 use super::*; 291 use crate::storage::Identity; 292 293 #[test] 294 fn test_format_signers() { 295 let identities = vec![ 296 Identity { 297 did: "did:plc:1".to_string(), 298 handle: "alice.bsky.social".to_string(), 299 record: serde_json::Value::Null, 300 created_at: chrono::Utc::now(), 301 updated_at: chrono::Utc::now(), 302 }, 303 Identity { 304 did: "did:plc:2".to_string(), 305 handle: "bob.bsky.social".to_string(), 306 record: serde_json::Value::Null, 307 created_at: chrono::Utc::now(), 308 updated_at: chrono::Utc::now(), 309 }, 310 ]; 311 312 let result = format_signers(&identities); 313 assert_eq!(result, vec!["alice.bsky.social", "bob.bsky.social"]); 314 315 let many_identities = vec![ 316 Identity { 317 did: "did:plc:1".to_string(), 318 handle: "alice.bsky.social".to_string(), 319 record: serde_json::Value::Null, 320 created_at: chrono::Utc::now(), 321 updated_at: chrono::Utc::now(), 322 }, 323 Identity { 324 did: "did:plc:2".to_string(), 325 handle: "bob.bsky.social".to_string(), 326 record: serde_json::Value::Null, 327 created_at: chrono::Utc::now(), 328 updated_at: chrono::Utc::now(), 329 }, 330 Identity { 331 did: "did:plc:3".to_string(), 332 handle: "charlie.bsky.social".to_string(), 333 record: serde_json::Value::Null, 334 created_at: chrono::Utc::now(), 335 updated_at: chrono::Utc::now(), 336 }, 337 Identity { 338 did: "did:plc:4".to_string(), 339 handle: "dave.bsky.social".to_string(), 340 record: serde_json::Value::Null, 341 created_at: chrono::Utc::now(), 342 updated_at: chrono::Utc::now(), 343 }, 344 ]; 345 346 let result = format_signers(&many_identities); 347 assert_eq!( 348 result, 349 vec!["alice.bsky.social", "bob.bsky.social", "and 2 others"] 350 ); 351 } 352 353 #[test] 354 fn test_is_valid_png() { 355 // Valid PNG signature 356 let valid_png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00]; 357 assert!(is_valid_png(&valid_png)); 358 359 // Invalid signature 360 let invalid_png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0B]; 361 assert!(!is_valid_png(&invalid_png)); 362 363 // Too short 364 let too_short = vec![0x89, 0x50]; 365 assert!(!is_valid_png(&too_short)); 366 367 // Empty 368 let empty = vec![]; 369 assert!(!is_valid_png(&empty)); 370 371 // JPEG signature (should fail) 372 let jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0]; 373 assert!(!is_valid_png(&jpeg)); 374 } 375}