An ATProtocol powered blogging engine.
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}