A Rust application to showcase badge awards in the AT Protocol ecosystem.
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}