From 573ea751679ae477d4551976b722c6ba58bb3714 Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Wed, 1 May 2024 18:25:01 +0300 Subject: [PATCH] revamp config and add tags support --- README.md | 39 +++++++++-------- src/config.rs | 54 ++++++++++++++++++------ src/error.rs | 45 +++++++++++++++++++- src/main.rs | 109 ++++++++++++++++++------------------------------ src/post/mod.rs | 53 +++++++++++++++++++---- 5 files changed, 194 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 6c3bcdb..b728dc6 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ blazingly fast markdown blog software written in rust memory safe - [ ] date formatting respects user timezone - [ ] clean up imports and require less features - [ ] improve home page -- [ ] tags +- [x] tags (backend) +- [ ] tags (frontend) - [x] be blazingly fast - [x] 100+ MiB binary size @@ -35,26 +36,30 @@ blazingly fast markdown blog software written in rust memory safe the default configuration with comments looks like this ```toml -# main settings -host = "0.0.0.0" # ip to listen on -port = 3000 # port to listen on title = "bingus-blog" # title of the website description = "blazingly fast markdown blog software written in rust memory safe" # description of the website -posts_dir = "posts" # where posts are stored -markdown_access = true # allow users to see the raw markdown of a post +raw_access = true # allow users to see the raw markdown of a post -[cache] # cache settings -enable = true # save metadata and rendered posts into RAM - # highly recommended, only turn off if absolutely necessary -cleanup = true # clean cache, highly recommended -#cleanup_interval = 86400000 # clean the cache regularly instead of just at startu - # uncomment to enable -persistence = true # save the cache to on shutdown and load on startup -file = "cache" # file to save the cache to -compress = true # compress the cache file -compression_level = 3 # zstd compression level, 3 is recommended +[dirs] +posts = "posts" # where posts are stored +media = "media" # directory served under /media/ -[render] # post rendering settings +[http] +host = "0.0.0.0" # ip to listen on +port = 3000 # port to listen on + +[cache] +enable = true # save metadata and rendered posts into RAM + # highly recommended, only turn off if absolutely necessary +cleanup = true # clean cache, highly recommended +#cleanup_interval = 86400000 # clean the cache regularly instead of just at startup + # uncomment to enable +persistence = true # save the cache to on shutdown and load on startup +file = "cache" # file to save the cache to +compress = true # compress the cache file +compression_level = 3 # zstd compression level, 3 is recommended + +[render] syntect.load_defaults = false # include default syntect themes syntect.themes_dir = "themes" # directory to include themes from syntect.theme = "Catppuccin Mocha" # theme file name (without `.tmTheme`) diff --git a/src/config.rs b/src/config.rs index 3fc3b34..d0c72ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,6 @@ -use std::{ - env, - net::{IpAddr, Ipv4Addr}, - path::PathBuf, -}; +use std::env; +use std::net::{IpAddr, Ipv4Addr}; +use std::path::PathBuf; use color_eyre::eyre::{bail, Context, Result}; use serde::{Deserialize, Serialize}; @@ -40,28 +38,60 @@ pub struct CacheConfig { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(default)] -pub struct Config { +pub struct HttpConfig { pub host: IpAddr, pub port: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(default)] +pub struct DirsConfig { + pub posts: PathBuf, + pub media: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(default)] +pub struct Config { pub title: String, pub description: String, - pub posts_dir: PathBuf, + pub raw_access: bool, + pub num_posts: usize, + pub dirs: DirsConfig, + pub http: HttpConfig, pub render: RenderConfig, pub cache: CacheConfig, - pub markdown_access: bool, } impl Default for Config { fn default() -> Self { Self { - host: IpAddr::V4(Ipv4Addr::UNSPECIFIED), - port: 3000, title: "bingus-blog".into(), description: "blazingly fast markdown blog software written in rust memory safe".into(), + raw_access: true, + num_posts: 5, + dirs: Default::default(), + http: Default::default(), render: Default::default(), - posts_dir: "posts".into(), cache: Default::default(), - markdown_access: true, + } + } +} + +impl Default for DirsConfig { + fn default() -> Self { + Self { + posts: "posts".into(), + media: "media".into(), + } + } +} + +impl Default for HttpConfig { + fn default() -> Self { + Self { + host: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + port: 3000, } } } diff --git a/src/error.rs b/src/error.rs index 1531664..57f6a42 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,9 +1,10 @@ use std::fmt::Display; -use axum::{http::StatusCode, response::IntoResponse}; +use askama_axum::Template; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; use thiserror::Error; -// fronma is too lazy to implement std::error::Error for their own types #[derive(Debug)] #[repr(transparent)] pub struct FronmaError(fronma::error::Error); @@ -45,3 +46,43 @@ impl IntoResponse for PostError { (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() } } + +pub type AppResult = Result; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("failed to fetch post: {0}")] + PostError(#[from] PostError), +} + +impl From for AppError { + #[inline(always)] + fn from(value: std::io::Error) -> Self { + Self::PostError(PostError::IoError(value)) + } +} + +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTemplate { + error: String, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status_code = match &self { + AppError::PostError(err) => match err { + PostError::NotFound(_) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + //_ => StatusCode::INTERNAL_SERVER_ERROR, + }; + ( + status_code, + ErrorTemplate { + error: self.to_string(), + }, + ) + .into_response() + } +} diff --git a/src/main.rs b/src/main.rs index 3436efa..4f0a0db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,13 +17,13 @@ use std::sync::Arc; use std::time::Duration; use askama_axum::Template; -use axum::extract::{MatchedPath, Path, State}; -use axum::http::{Request, StatusCode}; +use axum::extract::{Path, Query, State}; +use axum::http::Request; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, Router}; use axum::Json; use color_eyre::eyre::{self, Context}; -use thiserror::Error; +use serde::Deserialize; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::task::JoinSet; @@ -36,7 +36,7 @@ use tracing::{debug, error, info, info_span, warn, Span}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use crate::config::Config; -use crate::error::PostError; +use crate::error::{AppResult, PostError}; use crate::post::{PostManager, PostMetadata, RenderStats}; type ArcState = Arc; @@ -64,59 +64,46 @@ struct ViewPostTemplate { markdown_access: bool, } -type AppResult = Result; - -#[derive(Error, Debug)] -enum AppError { - #[error("failed to fetch post: {0}")] - PostError(#[from] PostError), +#[derive(Deserialize)] +struct QueryParams { + tag: Option, + #[serde(rename = "n")] + num_posts: Option, } -impl From for AppError { - #[inline(always)] - fn from(value: std::io::Error) -> Self { - Self::PostError(PostError::IoError(value)) - } -} +async fn index( + State(state): State, + Query(query): Query, +) -> AppResult { + let posts = state + .posts + .get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) + .await?; -#[derive(Template)] -#[template(path = "error.html")] -struct ErrorTemplate { - error: String, -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let status_code = match &self { - AppError::PostError(err) => match err { - PostError::NotFound(_) => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }, - //_ => StatusCode::INTERNAL_SERVER_ERROR, - }; - ( - status_code, - ErrorTemplate { - error: self.to_string(), - }, - ) - .into_response() - } -} - -async fn index(State(state): State) -> AppResult { Ok(IndexTemplate { title: state.config.title.clone(), description: state.config.description.clone(), - posts: state.posts.list_posts().await?, + posts, }) } +async fn all_posts( + State(state): State, + Query(query): Query, +) -> AppResult>> { + let posts = state + .posts + .get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) + .await?; + + Ok(Json(posts)) +} + async fn post(State(state): State, Path(name): Path) -> AppResult { - if name.ends_with(".md") && state.config.markdown_access { + if name.ends_with(".md") && state.config.raw_access { let mut file = tokio::fs::OpenOptions::new() .read(true) - .open(state.config.posts_dir.join(&name)) + .open(state.config.dirs.posts.join(&name)) .await?; let mut buf = Vec::new(); @@ -129,18 +116,13 @@ async fn post(State(state): State, Path(name): Path) -> AppRes meta: post.0, rendered: post.1, rendered_in: post.2, - markdown_access: state.config.markdown_access, + markdown_access: state.config.raw_access, }; Ok(page.into_response()) } } -async fn all_posts(State(state): State) -> AppResult>> { - let posts = state.posts.list_posts().await?; - Ok(Json(posts)) -} - #[tokio::main] async fn main() -> eyre::Result<()> { #[cfg(feature = "tokio-console")] @@ -160,6 +142,8 @@ async fn main() -> eyre::Result<()> { .await .context("couldn't load configuration")?; + let socket_addr = SocketAddr::new(config.http.host, config.http.port); + let mut tasks = JoinSet::new(); let cancellation_token = CancellationToken::new(); @@ -198,7 +182,7 @@ async fn main() -> eyre::Result<()> { let cache = bitcode::deserialize(serialized.as_slice()).context("failed to parse cache")?; Ok::(PostManager::new_with_cache( - config.posts_dir.clone(), + config.dirs.posts.clone(), config.render.clone(), cache, )) @@ -210,7 +194,7 @@ async fn main() -> eyre::Result<()> { error!("failed to load cache: {}", err); info!("using empty cache"); PostManager::new_with_cache( - config.posts_dir.clone(), + config.dirs.posts.clone(), config.render.clone(), Default::default(), ) @@ -218,13 +202,13 @@ async fn main() -> eyre::Result<()> { } } else { PostManager::new_with_cache( - config.posts_dir.clone(), + config.dirs.posts.clone(), config.render.clone(), Default::default(), ) } } else { - PostManager::new(config.posts_dir.clone(), config.render.clone()) + PostManager::new(config.dirs.posts.clone(), config.render.clone()) }; let state = Arc::new(AppState { config, posts }); @@ -265,16 +249,10 @@ async fn main() -> eyre::Result<()> { .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { - let matched_path = request - .extensions() - .get::() - .map(MatchedPath::as_str); - info_span!( "request", method = ?request.method(), path = ?request.uri().path(), - matched_path, ) }) .on_response(|response: &Response<_>, duration: Duration, span: &Span| { @@ -285,14 +263,9 @@ async fn main() -> eyre::Result<()> { ) .with_state(state.clone()); - let listener = TcpListener::bind((state.config.host, state.config.port)) + let listener = TcpListener::bind(socket_addr) .await - .with_context(|| { - format!( - "couldn't listen on {}", - SocketAddr::new(state.config.host, state.config.port) - ) - })?; + .with_context(|| format!("couldn't listen on {}", socket_addr))?; let local_addr = listener .local_addr() .context("couldn't get socket address")?; diff --git a/src/post/mod.rs b/src/post/mod.rs index c5f6dfc..bdf6e3c 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -26,6 +26,8 @@ struct FrontMatter { pub icon: Option, pub created_at: Option>, pub modified_at: Option>, + #[serde(default)] + pub tags: Vec, } impl FrontMatter { @@ -43,11 +45,12 @@ impl FrontMatter { icon: self.icon, created_at: self.created_at.or_else(|| created.map(|t| t.into())), modified_at: self.modified_at.or_else(|| modified.map(|t| t.into())), + tags: self.tags, } } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct PostMetadata { pub name: String, pub title: String, @@ -56,6 +59,7 @@ pub struct PostMetadata { pub icon: Option, pub created_at: Option>, pub modified_at: Option>, + pub tags: Vec, } use crate::filters; @@ -66,10 +70,10 @@ struct Post<'a> { pub rendered_markdown: String, } -// format: TOTAL OP1 OP2 #[allow(unused)] pub enum RenderStats { Cached(Duration), + // format: Total, Parsed in, Rendered in ParsedAndRendered(Duration, Duration, Duration), } @@ -146,7 +150,10 @@ impl PostManager { Ok((metadata, post, (parsing, rendering))) } - pub async fn list_posts(&self) -> Result, PostError> { + pub async fn list_posts( + &self, + filter: impl Fn(&PostMetadata) -> bool, + ) -> Result, PostError> { let mut posts = Vec::new(); let mut read_dir = fs::read_dir(&self.dir).await?; @@ -166,10 +173,25 @@ impl PostManager { if let Some(cache) = self.cache.as_ref() && let Some(hit) = cache.lookup_metadata(&name, mtime).await + && filter(&hit) { - posts.push(hit) - } else if let Ok((metadata, ..)) = self.parse_and_render(name, path).await { - posts.push(metadata); + posts.push(hit); + } else { + match self.parse_and_render(name, path).await { + Ok((metadata, ..)) => { + if filter(&metadata) { + posts.push(metadata); + } + } + Err(err) => match err { + PostError::IoError(ref io_err) + if matches!(io_err.kind(), io::ErrorKind::NotFound) => + { + warn!("TOCTOU: {}", err) + } + _ => return Err(err), + }, + } } } } @@ -177,7 +199,24 @@ impl PostManager { Ok(posts) } - // third entry in the tuple is whether it got rendered and if so, how long did it take + pub async fn get_max_n_posts_with_optional_tag_sorted( + &self, + n: Option, + tag: Option<&String>, + ) -> Result, PostError> { + let mut posts = self + .list_posts(|metadata| !tag.is_some_and(|tag| !metadata.tags.contains(tag))) + .await?; + posts.sort_unstable_by_key(|metadata| metadata.created_at.unwrap_or_default()); + + if let Some(n) = n { + posts = Vec::from(&posts[posts.len().saturating_sub(n)..]); + } + + posts.reverse(); + Ok(posts) + } + pub async fn get_post( &self, name: &str,