358 lines
9.4 KiB
Rust
358 lines
9.4 KiB
Rust
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<dyn DynAccess<RssConfig> + Send + Sync>,
|
|
pub style: Arc<dyn DynAccess<StyleConfig> + Send + Sync>,
|
|
pub posts: Arc<dyn PostManager + Send + Sync>,
|
|
pub templates: Arc<RwLock<Handlebars<'static>>>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct IndexTemplate<'a> {
|
|
bingus_info: &'a BingusInfo,
|
|
posts: Vec<PostMetadata>,
|
|
rss: bool,
|
|
js: bool,
|
|
tags: IndexMap<Arc<str>, u64>,
|
|
joined_tags: String,
|
|
style: &'a StyleConfig,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PostsTemplate<'a> {
|
|
bingus_info: &'a BingusInfo,
|
|
posts: Vec<PostMetadata>,
|
|
js: bool,
|
|
style: &'a StyleConfig,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PostTemplate<'a> {
|
|
bingus_info: &'a BingusInfo,
|
|
meta: &'a PostMetadata,
|
|
body: Arc<str>,
|
|
perf: RenderStats,
|
|
js: bool,
|
|
color: Option<&'a str>,
|
|
joined_tags: String,
|
|
style: &'a StyleConfig,
|
|
raw_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct QueryParams {
|
|
tag: Option<String>,
|
|
#[serde(rename = "n")]
|
|
num_posts: Option<usize>,
|
|
#[serde(flatten)]
|
|
other: IndexMap<String, Value>,
|
|
}
|
|
|
|
fn collect_tags(posts: &Vec<PostMetadata>) -> IndexMap<Arc<str>, 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<Arc<str>, 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<AppState>,
|
|
Query(query): Query<QueryParams>,
|
|
) -> AppResult<impl IntoResponse> {
|
|
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<AppState>,
|
|
Query(query): Query<QueryParams>,
|
|
) -> AppResult<Json<Vec<PostMetadata>>> {
|
|
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<AppState>,
|
|
Query(query): Query<QueryParams>,
|
|
) -> AppResult<Html<String>> {
|
|
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<AppState>,
|
|
Query(query): Query<QueryParams>,
|
|
) -> AppResult<Response> {
|
|
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::<Vec<Category>>(),
|
|
)
|
|
.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<AppState>,
|
|
Path(name): Path<Arc<str>>,
|
|
Query(query): Query<QueryParams>,
|
|
) -> AppResult<impl IntoResponse> {
|
|
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<AppState> {
|
|
Router::new()
|
|
.route("/", get(index))
|
|
.route(
|
|
"/post/:name",
|
|
get(
|
|
|Path(name): Path<String>| 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");
|
|
}),
|
|
)
|
|
}
|