//! HTTP server implementation with Axum web framework. //! //! Provides REST API endpoints and template-rendered pages for displaying //! badge awards, with identity resolution and static file serving. use std::sync::Arc; use atproto_identity::{ axum::state::DidDocumentStorageExtractor, resolve::{IdentityResolver, InputType, parse_input}, storage::DidDocumentStorage, }; use axum::{ Router, body::Body, extract::{FromRef, Path, State}, http::{StatusCode, header}, response::{IntoResponse, Response}, routing::get, }; use axum_template::RenderHtml; use axum_template::engine::Engine; use minijinja::context; use serde::{Deserialize, Serialize}; use tower_http::services::ServeDir; use crate::{ config::Config, errors::{Result, ShowcaseError}, storage::{FileStorage, Storage}, }; #[cfg(feature = "reload")] use minijinja_autoreload::AutoReloader; #[cfg(feature = "reload")] /// Template engine with auto-reloading support for development. pub type AppEngine = Engine; #[cfg(feature = "embed")] use minijinja::Environment; #[cfg(feature = "embed")] pub type AppEngine = Engine>; #[cfg(not(any(feature = "reload", feature = "embed")))] pub type AppEngine = Engine>; /// Application state shared across HTTP handlers. #[derive(Clone)] pub struct AppState { /// Database storage for badges and awards. pub storage: Arc, /// Storage for DID documents. pub document_storage: Arc, /// Identity resolver for DID resolution. pub identity_resolver: IdentityResolver, /// Application configuration. pub config: Arc, /// Template engine for rendering HTML responses. pub template_env: AppEngine, /// File storage for badge images. pub file_storage: Arc, } impl FromRef for DidDocumentStorageExtractor { fn from_ref(context: &AppState) -> Self { atproto_identity::axum::state::DidDocumentStorageExtractor(context.document_storage.clone()) } } impl FromRef for IdentityResolver { fn from_ref(context: &AppState) -> Self { context.identity_resolver.clone() } } #[derive(Debug, Serialize, Deserialize)] struct AwardDisplay { pub did: String, pub handle: String, pub badge_name: String, pub badge_image: Option, pub signers: Vec, pub created_at: String, } /// Create the main application router with all HTTP routes. pub fn create_router(state: AppState) -> Router { Router::new() .route("/", get(handle_index)) .route("/badges/{subject}", get(handle_identity)) .route("/badge/{cid}", get(handle_badge_image)) .nest_service("/static", ServeDir::new(&state.config.http.static_path)) .with_state(state) } async fn handle_index(State(state): State) -> Result { match get_recent_awards(&state).await { Ok(awards) => Ok(RenderHtml( "index.html", state.template_env.clone(), context! { title => "Recent Badge Awards", awards => awards, }, )), Err(_) => Err(ShowcaseError::HttpInternalServerError), } } async fn handle_identity( Path(subject): Path, State(state): State, ) -> Result { let did = match parse_input(&subject) { Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => did, Ok(InputType::Handle(_)) => match state.storage.get_identity_by_handle(&subject).await { Ok(Some(identity)) => identity.did, Ok(None) => return Ok((StatusCode::NOT_FOUND).into_response()), Err(_) => return Ok((StatusCode::NOT_FOUND).into_response()), }, Err(_) => return Ok((StatusCode::NOT_FOUND).into_response()), }; match get_awards_for_identity(&state, &did).await { Ok((awards, identity_handle)) => Ok(RenderHtml( "identity.html", state.template_env.clone(), context! { title => format!("Badge Awards for @{}", identity_handle), subject => identity_handle, awards => awards, }, ) .into_response()), Err(_) => Err(ShowcaseError::HttpInternalServerError), } } async fn handle_badge_image( Path(cid): Path, State(state): State, ) -> impl IntoResponse { // Validate that the CID ends with ".png" if !cid.ends_with(".png") { tracing::warn!(?cid, "file not png"); return (StatusCode::NOT_FOUND).into_response(); } // Read the file using FileStorage let file_data = match state.file_storage.read_file(&cid).await { Ok(data) => data, Err(err) => { tracing::warn!(?cid, ?err, "file_storage read_file error"); return (StatusCode::NOT_FOUND).into_response(); } }; // Check file size (must be less than 1MB) const MAX_FILE_SIZE: usize = 1024 * 1024; // 1MB if file_data.len() > MAX_FILE_SIZE { tracing::warn!(?cid, len = file_data.len(), "file too big"); return (StatusCode::NOT_FOUND).into_response(); } // Validate that it's actually a PNG image if !is_valid_png(&file_data) { tracing::warn!(?cid, "file not png"); return (StatusCode::NOT_FOUND).into_response(); } // Return the image with appropriate content type Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/png") .header(header::CACHE_CONTROL, "public, max-age=86400") // Cache for 1 day .body(Body::from(file_data)) .unwrap() .into_response() } /// Validate that the provided bytes represent a valid PNG image fn is_valid_png(data: &[u8]) -> bool { // PNG signature: 89 50 4E 47 0D 0A 1A 0A const PNG_SIGNATURE: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; data.len() >= PNG_SIGNATURE.len() && data.starts_with(PNG_SIGNATURE) } async fn get_recent_awards(state: &AppState) -> Result> { let awards_with_details = state.storage.get_recent_awards(50).await?; let mut result = Vec::new(); for item in awards_with_details { let handle = item .identity .as_ref() .map(|i| i.handle.clone()) .unwrap_or_else(|| "unknown.handle".to_string()); let badge_image = item .badge .as_ref() .and_then(|b| b.image.clone()) .map(|img| format!("{}.png", img)); let signers = format_signers(&item.signer_identities); result.push(AwardDisplay { did: item.award.did, handle, badge_name: item.award.badge_name.clone(), badge_image, signers, created_at: item .award .created_at .format("%Y-%m-%d %H:%M UTC") .to_string(), }); } Ok(result) } async fn get_awards_for_identity( state: &AppState, did: &str, ) -> Result<(Vec, String)> { let awards_with_details = state.storage.get_awards_for_did(did, 50).await?; let identity_handle = state .storage .get_identity_by_did(did) .await? .map(|i| i.handle) .unwrap_or_else(|| "unknown.handle".to_string()); let mut result = Vec::new(); for item in awards_with_details { let handle = item .identity .as_ref() .map(|i| i.handle.clone()) .unwrap_or_else(|| "unknown.handle".to_string()); let badge_image = item .badge .as_ref() .and_then(|b| b.image.clone()) .map(|img| format!("{}.png", img)); let signers = format_signers(&item.signer_identities); result.push(AwardDisplay { did: item.award.did, handle, badge_name: item.award.badge_name.clone(), badge_image, signers, created_at: item .award .created_at .format("%Y-%m-%d %H:%M UTC") .to_string(), }); } Ok((result, identity_handle)) } fn format_signers(signer_identities: &[crate::storage::Identity]) -> Vec { let handles: Vec = signer_identities .iter() .map(|i| i.handle.to_string()) .collect(); if handles.len() <= 3 { handles } else { let mut result = handles[..2].to_vec(); result.push(format!("and {} others", handles.len() - 2)); result } } #[cfg(test)] mod tests { use super::*; use crate::storage::Identity; #[test] fn test_format_signers() { let identities = vec![ Identity { did: "did:plc:1".to_string(), handle: "alice.bsky.social".to_string(), record: serde_json::Value::Null, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }, Identity { did: "did:plc:2".to_string(), handle: "bob.bsky.social".to_string(), record: serde_json::Value::Null, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }, ]; let result = format_signers(&identities); assert_eq!(result, vec!["alice.bsky.social", "bob.bsky.social"]); let many_identities = vec![ Identity { did: "did:plc:1".to_string(), handle: "alice.bsky.social".to_string(), record: serde_json::Value::Null, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }, Identity { did: "did:plc:2".to_string(), handle: "bob.bsky.social".to_string(), record: serde_json::Value::Null, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }, Identity { did: "did:plc:3".to_string(), handle: "charlie.bsky.social".to_string(), record: serde_json::Value::Null, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }, Identity { did: "did:plc:4".to_string(), handle: "dave.bsky.social".to_string(), record: serde_json::Value::Null, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }, ]; let result = format_signers(&many_identities); assert_eq!( result, vec!["alice.bsky.social", "bob.bsky.social", "and 2 others"] ); } #[test] fn test_is_valid_png() { // Valid PNG signature let valid_png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00]; assert!(is_valid_png(&valid_png)); // Invalid signature let invalid_png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0B]; assert!(!is_valid_png(&invalid_png)); // Too short let too_short = vec![0x89, 0x50]; assert!(!is_valid_png(&too_short)); // Empty let empty = vec![]; assert!(!is_valid_png(&empty)); // JPEG signature (should fail) let jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0]; assert!(!is_valid_png(&jpeg)); } }