use std::sync::Arc; use std::time::Duration; use arc_swap::access::DynAccess; use axum::extract::{Path, Query, State}; use axum::http::header::CONTENT_TYPE; use axum::http::Request; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::get; use axum::{Json, Router}; use handlebars::Handlebars; use include_dir::{include_dir, Dir}; use indexmap::IndexMap; use rss::{Category, ChannelBuilder, ItemBuilder}; use serde::{Deserialize, Serialize}; use serde_value::Value; use tokio::sync::RwLock; use tower::service_fn; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use tracing::{info, info_span, Span}; use crate::config::{DirsConfig, RssConfig, StyleConfig}; use crate::error::{AppError, AppResult}; use crate::post::{Filter, PostManager, PostMetadata, RenderStats, ReturnedPost}; use crate::serve_dir_included::handle; const STATIC: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/static"); #[derive(Serialize)] pub struct BingusInfo { pub name: &'static str, pub version: &'static str, pub repository: &'static str, } const BINGUS_INFO: BingusInfo = BingusInfo { name: env!("CARGO_PKG_NAME"), version: env!("CARGO_PKG_VERSION"), repository: env!("CARGO_PKG_REPOSITORY"), }; #[derive(Clone)] #[non_exhaustive] pub struct AppState { pub rss: Arc + Send + Sync>, pub style: Arc + Send + Sync>, pub posts: Arc, pub templates: Arc>>, } #[derive(Serialize)] struct IndexTemplate<'a> { bingus_info: &'a BingusInfo, posts: Vec, rss: bool, js: bool, tags: IndexMap, u64>, joined_tags: String, style: &'a StyleConfig, } #[derive(Serialize)] struct PostsTemplate<'a> { bingus_info: &'a BingusInfo, posts: Vec, js: bool, style: &'a StyleConfig, } #[derive(Serialize)] struct PostTemplate<'a> { bingus_info: &'a BingusInfo, meta: &'a PostMetadata, body: Arc, perf: RenderStats, js: bool, color: Option<&'a str>, joined_tags: String, style: &'a StyleConfig, raw_name: Option, } #[derive(Deserialize)] struct QueryParams { tag: Option, #[serde(rename = "n")] num_posts: Option, #[serde(flatten)] other: IndexMap, } fn collect_tags(posts: &Vec) -> IndexMap, u64> { let mut tags = IndexMap::new(); for post in posts { for tag in &post.tags { if let Some((existing_tag, count)) = tags.swap_remove_entry(tag) { tags.insert(existing_tag, count + 1); } else { tags.insert(tag.clone(), 1); } } } tags.sort_unstable_by(|k1, _v1, k2, _v2| k1.cmp(k2)); tags.sort_by(|_k1, v1, _k2, v2| v2.cmp(v1)); tags } fn join_tags_for_meta(tags: &IndexMap, u64>, delim: &str) -> String { let mut s = String::new(); let tags = tags.keys().enumerate(); let len = tags.len(); for (i, tag) in tags { s += tag; if i != len - 1 { s += delim; } } s } async fn index( State(AppState { rss, style, posts, templates, .. }): State, Query(query): Query, ) -> AppResult { let posts = posts .get_max_n_post_metadata_with_optional_tag_sorted( query.num_posts, query.tag.as_deref(), &query.other, ) .await?; let tags = collect_tags(&posts); let joined_tags = join_tags_for_meta(&tags, ", "); let reg = templates.read().await; let style = style.load(); let rendered = reg.render( "index", &IndexTemplate { bingus_info: &BINGUS_INFO, posts, rss: rss.load().enable, js: style.js_enable, tags, joined_tags, style: &style, }, ); drop((style, reg)); Ok(Html(rendered?)) } async fn posts_json( State(AppState { posts, .. }): State, Query(query): Query, ) -> AppResult>> { let posts = posts .get_max_n_post_metadata_with_optional_tag_sorted( query.num_posts, query.tag.as_deref(), &query.other, ) .await?; Ok(Json(posts)) } async fn posts( State(AppState { posts, templates, style, .. }): State, Query(query): Query, ) -> AppResult> { let posts = posts .get_max_n_post_metadata_with_optional_tag_sorted( query.num_posts, query.tag.as_deref(), &query.other, ) .await?; let reg = templates.read().await; let style = style.load(); let rendered = reg.render( "index", &PostsTemplate { bingus_info: &BINGUS_INFO, posts, js: style.js_enable, style: &style, }, ); drop((style, reg)); Ok(Html(rendered?)) } async fn rss( State(AppState { rss, style, posts, .. }): State, Query(query): Query, ) -> AppResult { if !rss.load().enable { return Err(AppError::RssDisabled); } let posts = posts .get_all_posts( query .tag .as_ref() .and(Some(Filter::Tags(query.tag.as_deref().as_slice()))) .as_slice(), &query.other, ) .await?; let rss = rss.load(); let style = style.load(); let mut channel = ChannelBuilder::default(); channel .title(&*style.title) .link(rss.link.to_string()) .description(&*style.description); //TODO: .language() for (metadata, content, _) in posts { channel.item( ItemBuilder::default() .title(metadata.title.to_string()) .description(metadata.description.to_string()) .author(metadata.author.to_string()) .categories( metadata .tags .into_iter() .map(|tag| Category { name: tag.to_string(), domain: None, }) .collect::>(), ) .pub_date(metadata.written_at.map(|date| date.to_rfc2822())) .content(content.to_string()) .link( rss.link .join(&format!("/posts/{}", metadata.name))? .to_string(), ) .build(), ); } drop((style, rss)); let body = channel.build().to_string(); drop(channel); Ok(([(CONTENT_TYPE, "text/xml")], body).into_response()) } async fn post( State(AppState { style, posts, templates, .. }): State, Path(name): Path>, Query(query): Query, ) -> AppResult { match posts.get_post(name.clone(), &query.other).await? { ReturnedPost::Rendered { ref meta, body, perf, raw_name, } => { let joined_tags = meta.tags.join(", "); let reg = templates.read().await; let style = style.load(); let rendered = reg.render( "post", &PostTemplate { bingus_info: &BINGUS_INFO, meta, body, perf, js: style.js_enable, color: meta.color.as_deref().or(style.default_color.as_deref()), joined_tags, style: &style, raw_name, }, ); drop((style, reg)); Ok(Html(rendered?).into_response()) } ReturnedPost::Raw { buffer, content_type, } => Ok(([(CONTENT_TYPE, content_type)], buffer).into_response()), } } pub fn new(dirs: &DirsConfig) -> Router { Router::new() .route("/", get(index)) .route( "/post/:name", get( |Path(name): Path| async move { Redirect::to(&format!("/posts/{}", name)) }, ), ) .route("/posts/:name", get(post)) .route("/posts", get(posts)) .route("/posts.json", get(posts_json)) .route("/feed.xml", get(rss)) .nest_service( "/static", ServeDir::new(&dirs.static_) .precompressed_gzip() .fallback(service_fn(|req| handle(req, &STATIC))), ) .nest_service("/media", ServeDir::new(&dirs.media)) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { info_span!( "request", method = ?request.method(), path = ?request.uri().path(), ) }) .on_response(|response: &Response<_>, duration: Duration, span: &Span| { let _ = span.enter(); let status = response.status(); info!(?status, ?duration, "response"); }), ) }