An ATProtocol powered blogging engine.
at main 337 lines 11 kB view raw
1//! HTTP server implementation with Axum web framework. 2//! 3//! Provides REST API endpoints and template-rendered pages for displaying 4//! blog posts, with content storage and markdown rendering. 5 6use std::sync::Arc; 7 8use axum::{ 9 Router, 10 body::Body, 11 extract::{Path, State}, 12 http::{HeaderMap, HeaderValue, StatusCode, header}, 13 response::{IntoResponse, Response}, 14 routing::get, 15}; 16use axum_template::RenderHtml; 17use axum_template::engine::Engine; 18use minijinja::context; 19use serde::{Deserialize, Serialize}; 20use tower_http::services::ServeDir; 21 22use crate::{ 23 config::Config, 24 errors::{BlahgError, Result}, 25 render::RenderManager, 26 storage::{ContentStorage, Post, Storage}, 27}; 28 29#[cfg(feature = "reload")] 30use minijinja_autoreload::AutoReloader; 31 32#[cfg(feature = "reload")] 33/// Template engine with auto-reloading support for development. 34pub type AppEngine = Engine<AutoReloader>; 35 36#[cfg(feature = "embed")] 37use minijinja::Environment; 38 39#[cfg(feature = "embed")] 40pub type AppEngine = Engine<Environment<'static>>; 41 42#[cfg(not(any(feature = "reload", feature = "embed")))] 43pub type AppEngine = Engine<minijinja::Environment<'static>>; 44 45/// Application state shared across HTTP handlers. 46#[derive(Clone)] 47pub struct AppState { 48 /// Database storage for posts and identities. 49 pub(crate) storage: Arc<dyn Storage>, 50 /// Content storage for markdown files. 51 pub(crate) content_storage: Arc<dyn ContentStorage>, 52 /// Markdown renderer. 53 pub(crate) render_manager: Arc<dyn RenderManager>, 54 /// Application configuration. 55 pub(crate) config: Arc<Config>, 56 /// Template engine for rendering HTML responses. 57 pub(crate) template_env: AppEngine, 58} 59 60impl AppState { 61 /// Create a new AppState with the given dependencies. 62 pub fn new( 63 storage: Arc<dyn Storage>, 64 content_storage: Arc<dyn ContentStorage>, 65 render_manager: Arc<dyn RenderManager>, 66 config: Arc<Config>, 67 template_env: AppEngine, 68 ) -> Self { 69 Self { 70 storage, 71 content_storage, 72 render_manager, 73 config, 74 template_env, 75 } 76 } 77} 78 79/// Display structure for post listing. 80#[derive(Debug, Serialize, Deserialize)] 81struct PostDisplay { 82 slug: String, 83 title: String, 84 created_at: String, 85 created_at_date: String, 86 updated_at_date: String, 87 aturi: String, 88} 89 90impl TryFrom<Post> for PostDisplay { 91 type Error = BlahgError; 92 93 fn try_from(post: Post) -> Result<Self> { 94 Ok(PostDisplay { 95 aturi: post.aturi, 96 slug: format!("{}-{}", post.record_key, post.slug), 97 title: post.title, 98 created_at: post.created_at.format("%Y-%m-%d %H:%M UTC").to_string(), 99 created_at_date: post.created_at.format("%Y-%m-%d").to_string(), 100 updated_at_date: post.updated_at.format("%Y-%m-%d").to_string(), 101 }) 102 } 103} 104 105/// Create the main application router with all HTTP routes. 106pub fn create_router(state: AppState) -> Router { 107 Router::new() 108 .route("/", get(handle_index)) 109 .route("/sitemap.xml", get(hande_sitemap)) 110 .route("/posts/{full_slug}", get(handle_post)) 111 .route( 112 "/posts/{full_slug}/{collection}", 113 get(handle_post_references), 114 ) 115 .route("/content/{cid}", get(handle_content)) 116 .nest_service("/static", ServeDir::new(&state.config.http.static_path)) 117 .with_state(state) 118} 119 120async fn hande_sitemap(State(state): State<AppState>) -> Result<impl IntoResponse> { 121 match get_all_posts(&state).await { 122 Ok(posts) => { 123 let mut headers = HeaderMap::new(); 124 headers.insert( 125 "Content-Type", 126 HeaderValue::from_static("application/xml; charset=utf-8"), 127 ); 128 129 Ok(( 130 headers, 131 RenderHtml( 132 "sitemap.xml", 133 state.template_env.clone(), 134 context! { 135 posts => posts, 136 }, 137 ), 138 )) 139 } 140 Err(_) => Err(BlahgError::HttpInternalServerError), 141 } 142} 143 144/// GET / - Handle the index page (home page). 145/// Gets all posts from storage and lists them ordered by most recent to oldest. 146async fn handle_index(State(state): State<AppState>) -> Result<impl IntoResponse> { 147 match get_all_posts(&state).await { 148 Ok(posts) => Ok(RenderHtml( 149 "index.html", 150 state.template_env.clone(), 151 context! { 152 title => "Recent Posts", 153 posts => posts, 154 }, 155 )), 156 Err(_) => Err(BlahgError::HttpInternalServerError), 157 } 158} 159 160/// GET /posts/{record_key}-{slug} - Handle post pages. 161/// Gets the post from storage using the record key to construct an AT-URI, gets the content from file storage, 162/// and renders the markdown content from file storage into html to be passed to the template. 163async fn handle_post( 164 Path(full_slug): Path<String>, 165 State(state): State<AppState>, 166) -> Result<impl IntoResponse> { 167 let record_key = match full_slug.split_once('-') { 168 Some((value, _)) => value, 169 None => return Ok((StatusCode::NOT_FOUND).into_response()), 170 }; 171 172 // Construct AT-URI using the author config and record key 173 let aturi = format!( 174 "at://{}/tools.smokesignal.blahg.content.post/{}", 175 state.config.author, record_key 176 ); 177 178 // Get the post from storage using the AT-URI 179 let post = match state.storage.get_post(&aturi).await? { 180 Some(post) => post, 181 None => return Ok((StatusCode::NOT_FOUND).into_response()), 182 }; 183 184 let post_display = match PostDisplay::try_from(post.clone()) { 185 Ok(value) => value, 186 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 187 }; 188 189 // Get the content from file storage using the content CID 190 let content_data = match state.content_storage.read_content(&post.content).await { 191 Ok(data) => data, 192 Err(_) => return Ok((StatusCode::NOT_FOUND).into_response()), 193 }; 194 195 // Convert content data to string 196 let markdown_content = match String::from_utf8(content_data) { 197 Ok(content) => content, 198 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 199 }; 200 201 // Render the markdown content into HTML 202 let html_content = match state 203 .render_manager 204 .render_markdown(&markdown_content) 205 .await 206 { 207 Ok(html) => html, 208 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 209 }; 210 211 let collection_counts = match state.storage.get_post_reference_count(&aturi).await { 212 Ok(value) => value, 213 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 214 }; 215 216 let total_activity: i64 = collection_counts.values().sum(); 217 218 // Render the template with the post data and rendered content 219 Ok(RenderHtml( 220 "post.html", 221 state.template_env.clone(), 222 context! { 223 post => post_display, 224 post_content => html_content, 225 total_activity, 226 collection_counts, 227 }, 228 ) 229 .into_response()) 230} 231 232/// GET /posts/{record_key}-{slug}/{collection} - Handle post references by collection. 233/// Gets the post from storage using the record key to construct an AT-URI, verifies the post content exists, 234/// and returns the list of post references for the specified collection. 235async fn handle_post_references( 236 Path((full_slug, collection)): Path<(String, String)>, 237 State(state): State<AppState>, 238) -> Result<impl IntoResponse> { 239 let record_key = match full_slug.split_once('-') { 240 Some((value, _)) => value, 241 None => return Ok((StatusCode::NOT_FOUND).into_response()), 242 }; 243 244 // Construct AT-URI using the author config and record key 245 let aturi = format!( 246 "at://{}/tools.smokesignal.blahg.content.post/{}", 247 state.config.author, record_key 248 ); 249 250 // Get the post from storage using the AT-URI 251 let post = match state.storage.get_post(&aturi).await? { 252 Some(post) => post, 253 None => return Ok((StatusCode::NOT_FOUND).into_response()), 254 }; 255 256 let post_display: PostDisplay = match PostDisplay::try_from(post.clone()) { 257 Ok(value) => value, 258 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 259 }; 260 261 // Verify the post content exists in content storage 262 let content_exists = match state.content_storage.content_exists(&post.content).await { 263 Ok(exists) => exists, 264 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 265 }; 266 267 if !content_exists { 268 return Ok((StatusCode::NOT_FOUND).into_response()); 269 } 270 271 // Get post references for this post and collection 272 let post_references = match state 273 .storage 274 .get_post_references_for_post_for_collection(&aturi, &collection) 275 .await 276 { 277 Ok(references) => references, 278 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 279 }; 280 281 // Render the template with the post data and references 282 Ok(RenderHtml( 283 "post_references.html", 284 state.template_env.clone(), 285 context! { 286 post => post_display, 287 collection => collection, 288 post_references => post_references, 289 }, 290 ) 291 .into_response()) 292} 293 294/// GET /content/{cid} - Handle content requests. 295/// Gets content from content storage and returns it as a response. 296async fn handle_content( 297 Path(cid): Path<String>, 298 State(state): State<AppState>, 299) -> Result<impl IntoResponse> { 300 // Check if content exists 301 let exists = match state.content_storage.content_exists(&cid).await { 302 Ok(exists) => exists, 303 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 304 }; 305 306 if !exists { 307 return Ok((StatusCode::NOT_FOUND).into_response()); 308 } 309 310 // Read the content data 311 let content_data = match state.content_storage.read_content(&cid).await { 312 Ok(data) => data, 313 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 314 }; 315 316 // Return the content with appropriate headers 317 Ok(Response::builder() 318 .status(StatusCode::OK) 319 .header(header::CONTENT_TYPE, "application/octet-stream") 320 .header(header::CACHE_CONTROL, "public, max-age=86400") // Cache for 1 day 321 .body(Body::from(content_data)) 322 .unwrap() 323 .into_response()) 324} 325 326/// Get all posts from storage and format them for display. 327async fn get_all_posts(state: &AppState) -> Result<Vec<PostDisplay>> { 328 let mut posts = state.storage.get_posts().await?; 329 330 // Sort posts by created_at in descending order (most recent first) 331 posts.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 332 333 posts 334 .into_iter() 335 .map(PostDisplay::try_from) 336 .collect::<Result<Vec<_>>>() 337}