2024-08-13 15:53:18 +03:00
|
|
|
use std::collections::HashMap;
|
2024-05-08 23:03:10 +03:00
|
|
|
use std::sync::Arc;
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
use axum::extract::{Path, Query, State};
|
2024-05-14 12:26:43 +03:00
|
|
|
use axum::http::header::CONTENT_TYPE;
|
2024-08-13 15:53:18 +03:00
|
|
|
use axum::http::Request;
|
2024-05-08 23:03:10 +03:00
|
|
|
use axum::response::{IntoResponse, Redirect, Response};
|
|
|
|
use axum::routing::get;
|
|
|
|
use axum::{Json, Router};
|
2024-08-13 15:53:18 +03:00
|
|
|
use handlebars::Handlebars;
|
2024-05-08 23:03:10 +03:00
|
|
|
use rss::{Category, ChannelBuilder, ItemBuilder};
|
2024-08-13 15:53:18 +03:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use serde_json::Map;
|
|
|
|
use tokio::sync::RwLock;
|
2024-05-08 23:03:10 +03:00
|
|
|
use tower_http::services::ServeDir;
|
|
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
use tracing::{info, info_span, Span};
|
|
|
|
|
2024-08-01 00:25:42 +03:00
|
|
|
use crate::config::{Config, DateFormat, Sort};
|
2024-05-08 23:03:10 +03:00
|
|
|
use crate::error::{AppError, AppResult};
|
2024-05-14 12:26:43 +03:00
|
|
|
use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost};
|
2024-05-08 23:03:10 +03:00
|
|
|
|
|
|
|
#[derive(Clone)]
|
2024-08-13 15:53:18 +03:00
|
|
|
#[non_exhaustive]
|
2024-05-08 23:03:10 +03:00
|
|
|
pub struct AppState {
|
|
|
|
pub config: Arc<Config>,
|
2024-05-14 10:11:41 +03:00
|
|
|
pub posts: Arc<MarkdownPosts<Arc<Config>>>,
|
2024-08-13 15:53:18 +03:00
|
|
|
pub reg: Arc<RwLock<Handlebars<'static>>>,
|
2024-05-08 23:03:10 +03:00
|
|
|
}
|
|
|
|
|
2024-08-13 15:53:18 +03:00
|
|
|
#[derive(Serialize)]
|
2024-07-01 03:05:48 +03:00
|
|
|
struct IndexTemplate<'a> {
|
|
|
|
title: &'a str,
|
|
|
|
description: &'a str,
|
2024-05-08 23:03:10 +03:00
|
|
|
posts: Vec<PostMetadata>,
|
2024-06-13 23:19:47 +03:00
|
|
|
rss: bool,
|
2024-07-01 03:05:48 +03:00
|
|
|
df: &'a DateFormat,
|
2024-06-13 21:52:18 +03:00
|
|
|
js: bool,
|
2024-07-01 03:16:17 +03:00
|
|
|
color: Option<&'a str>,
|
2024-08-01 00:25:42 +03:00
|
|
|
sort: Sort,
|
2024-08-13 15:53:18 +03:00
|
|
|
tags: Map<String, serde_json::Value>,
|
|
|
|
joined_tags: String,
|
2024-05-08 23:03:10 +03:00
|
|
|
}
|
|
|
|
|
2024-08-13 15:53:18 +03:00
|
|
|
#[derive(Serialize)]
|
2024-07-01 03:05:48 +03:00
|
|
|
struct PostTemplate<'a> {
|
|
|
|
meta: &'a PostMetadata,
|
2024-05-08 23:03:10 +03:00
|
|
|
rendered: String,
|
|
|
|
rendered_in: RenderStats,
|
|
|
|
markdown_access: bool,
|
2024-07-01 03:05:48 +03:00
|
|
|
df: &'a DateFormat,
|
2024-06-13 21:52:18 +03:00
|
|
|
js: bool,
|
2024-07-01 03:05:48 +03:00
|
|
|
color: Option<&'a str>,
|
2024-08-13 15:53:18 +03:00
|
|
|
joined_tags: String,
|
2024-05-08 23:03:10 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
struct QueryParams {
|
|
|
|
tag: Option<String>,
|
|
|
|
#[serde(rename = "n")]
|
|
|
|
num_posts: Option<usize>,
|
|
|
|
}
|
|
|
|
|
2024-08-13 15:53:18 +03:00
|
|
|
fn collect_tags(posts: &Vec<PostMetadata>) -> Map<String, serde_json::Value> {
|
|
|
|
let mut tags = HashMap::new();
|
|
|
|
|
|
|
|
for post in posts {
|
|
|
|
for tag in &post.tags {
|
|
|
|
if let Some((existing_tag, count)) = tags.remove_entry(tag) {
|
|
|
|
tags.insert(existing_tag, count + 1);
|
|
|
|
} else {
|
|
|
|
tags.insert(tag.clone(), 1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let mut tags: Vec<(String, u64)> = tags.into_iter().collect();
|
|
|
|
|
|
|
|
tags.sort_unstable_by_key(|(v, _)| v.clone());
|
|
|
|
tags.sort_by_key(|(_, v)| -(*v as i64));
|
|
|
|
|
|
|
|
let mut map = Map::new();
|
|
|
|
|
|
|
|
for tag in tags.into_iter() {
|
|
|
|
map.insert(tag.0, tag.1.into());
|
|
|
|
}
|
|
|
|
|
|
|
|
map
|
|
|
|
}
|
|
|
|
|
|
|
|
fn join_tags_for_meta(tags: &Map<String, serde_json::Value>, 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
|
|
|
|
}
|
|
|
|
|
2024-07-01 03:05:48 +03:00
|
|
|
async fn index<'a>(
|
2024-08-13 15:53:18 +03:00
|
|
|
State(AppState {
|
|
|
|
config, posts, reg, ..
|
|
|
|
}): State<AppState>,
|
2024-05-08 23:03:10 +03:00
|
|
|
Query(query): Query<QueryParams>,
|
2024-08-13 15:53:18 +03:00
|
|
|
) -> AppResult<impl IntoResponse> {
|
2024-05-08 23:03:10 +03:00
|
|
|
let posts = posts
|
|
|
|
.get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
|
|
|
|
.await?;
|
|
|
|
|
2024-08-13 15:53:18 +03:00
|
|
|
let tags = collect_tags(&posts);
|
|
|
|
let joined_tags = join_tags_for_meta(&tags, ", ");
|
|
|
|
|
|
|
|
let reg = reg.read().await;
|
|
|
|
let rendered = reg.render(
|
|
|
|
"index",
|
|
|
|
&IndexTemplate {
|
|
|
|
title: &config.title,
|
|
|
|
description: &config.description,
|
|
|
|
posts,
|
|
|
|
rss: config.rss.enable,
|
|
|
|
df: &config.date_format,
|
|
|
|
js: config.js_enable,
|
|
|
|
color: config.default_color.as_deref(),
|
|
|
|
sort: config.default_sort,
|
|
|
|
tags,
|
|
|
|
joined_tags,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
drop(reg);
|
|
|
|
Ok(([(CONTENT_TYPE, "text/html")], rendered?))
|
2024-05-08 23:03:10 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
async fn all_posts(
|
|
|
|
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_ref())
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
Ok(Json(posts))
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn rss(
|
2024-08-13 15:53:18 +03:00
|
|
|
State(AppState { config, posts, .. }): State<AppState>,
|
2024-05-08 23:03:10 +03:00
|
|
|
Query(query): Query<QueryParams>,
|
|
|
|
) -> AppResult<Response> {
|
|
|
|
if !config.rss.enable {
|
|
|
|
return Err(AppError::RssDisabled);
|
|
|
|
}
|
|
|
|
|
|
|
|
let posts = posts
|
2024-05-14 10:11:41 +03:00
|
|
|
.get_all_posts(|metadata, _| {
|
2024-05-08 23:03:10 +03:00
|
|
|
!query
|
|
|
|
.tag
|
|
|
|
.as_ref()
|
|
|
|
.is_some_and(|tag| !metadata.tags.contains(tag))
|
|
|
|
})
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
let mut channel = ChannelBuilder::default();
|
|
|
|
channel
|
|
|
|
.title(&config.title)
|
|
|
|
.link(config.rss.link.to_string())
|
|
|
|
.description(&config.description);
|
|
|
|
//TODO: .language()
|
|
|
|
|
|
|
|
for (metadata, content, _) in posts {
|
|
|
|
channel.item(
|
|
|
|
ItemBuilder::default()
|
|
|
|
.title(metadata.title)
|
|
|
|
.description(metadata.description)
|
|
|
|
.author(metadata.author)
|
|
|
|
.categories(
|
|
|
|
metadata
|
|
|
|
.tags
|
|
|
|
.into_iter()
|
|
|
|
.map(|tag| Category {
|
|
|
|
name: tag,
|
|
|
|
domain: None,
|
|
|
|
})
|
|
|
|
.collect::<Vec<Category>>(),
|
|
|
|
)
|
|
|
|
.pub_date(metadata.created_at.map(|date| date.to_rfc2822()))
|
|
|
|
.content(content)
|
|
|
|
.link(
|
|
|
|
config
|
|
|
|
.rss
|
|
|
|
.link
|
|
|
|
.join(&format!("/posts/{}", metadata.name))?
|
|
|
|
.to_string(),
|
|
|
|
)
|
|
|
|
.build(),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
let body = channel.build().to_string();
|
|
|
|
drop(channel);
|
|
|
|
|
2024-08-13 15:53:18 +03:00
|
|
|
Ok(([(CONTENT_TYPE, "text/xml")], body).into_response())
|
2024-05-08 23:03:10 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
async fn post(
|
2024-08-13 15:53:18 +03:00
|
|
|
State(AppState {
|
|
|
|
config, posts, reg, ..
|
|
|
|
}): State<AppState>,
|
2024-05-08 23:03:10 +03:00
|
|
|
Path(name): Path<String>,
|
2024-08-13 15:53:18 +03:00
|
|
|
) -> AppResult<impl IntoResponse> {
|
2024-05-14 12:26:43 +03:00
|
|
|
match posts.get_post(&name).await? {
|
2024-08-13 15:53:18 +03:00
|
|
|
ReturnedPost::Rendered(ref meta, rendered, rendered_in) => {
|
|
|
|
let joined_tags = meta.tags.join(", ");
|
|
|
|
|
|
|
|
let reg = reg.read().await;
|
|
|
|
let rendered = reg.render(
|
|
|
|
"post",
|
|
|
|
&PostTemplate {
|
|
|
|
meta,
|
|
|
|
rendered,
|
|
|
|
rendered_in,
|
|
|
|
markdown_access: config.markdown_access,
|
|
|
|
df: &config.date_format,
|
|
|
|
js: config.js_enable,
|
|
|
|
color: meta.color.as_deref().or(config.default_color.as_deref()),
|
|
|
|
joined_tags,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
drop(reg);
|
|
|
|
Ok(([(CONTENT_TYPE, "text/html")], rendered?).into_response())
|
2024-05-14 12:26:43 +03:00
|
|
|
}
|
|
|
|
ReturnedPost::Raw(body, content_type) => {
|
|
|
|
Ok(([(CONTENT_TYPE, content_type)], body).into_response())
|
|
|
|
}
|
2024-05-08 23:03:10 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-14 10:11:41 +03:00
|
|
|
pub fn new(config: &Config) -> Router<AppState> {
|
2024-05-08 23:03:10 +03:00
|
|
|
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(all_posts))
|
|
|
|
.route("/feed.xml", get(rss))
|
2024-05-14 10:11:41 +03:00
|
|
|
.nest_service(
|
|
|
|
"/static",
|
|
|
|
ServeDir::new(&config.dirs._static).precompressed_gzip(),
|
|
|
|
)
|
|
|
|
.nest_service("/media", ServeDir::new(&config.dirs.media))
|
2024-05-08 23:03:10 +03:00
|
|
|
.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");
|
|
|
|
}),
|
|
|
|
)
|
|
|
|
}
|