From a19c5762756b3cd8be3814aacc6bb8bb3658714c Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Wed, 8 May 2024 23:03:10 +0300 Subject: [PATCH 1/6] refactor part 1: move code --- Cargo.lock | 35 ++++++ Cargo.toml | 1 + src/app.rs | 193 +++++++++++++++++++++++++++++ src/main.rs | 318 +++--------------------------------------------- src/post/mod.rs | 153 ++++++++++++++++++----- 5 files changed, 373 insertions(+), 327 deletions(-) create mode 100644 src/app.rs diff --git a/Cargo.lock b/Cargo.lock index fd89cfb..faf9699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,7 @@ dependencies = [ "color-eyre", "comrak", "console-subscriber", + "derive_more", "fronma", "rss", "scc", @@ -486,6 +487,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "crc32fast" version = "1.4.0" @@ -585,6 +592,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + [[package]] name = "deunicode" version = "1.4.4" @@ -1435,6 +1455,15 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.15" @@ -1472,6 +1501,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d" +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" version = "1.0.198" diff --git a/Cargo.toml b/Cargo.toml index 661cd3c..b708cc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ comrak = { version = "0.22.0", features = [ "syntect", ], default-features = false } console-subscriber = { version = "0.2.0", optional = true } +derive_more = "0.99.17" fronma = "0.2.0" rss = "2.0.7" scc = { version = "2.1.0", features = ["serde"] } diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e229a99 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; +use std::time::Duration; + +use askama_axum::Template; +use axum::extract::{Path, Query, State}; +use axum::http::{header, Request}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use rss::{Category, ChannelBuilder, ItemBuilder}; +use serde::Deserialize; +use tokio::io::AsyncReadExt; +use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; +use tracing::{info, info_span, Span}; + +use crate::config::Config; +use crate::error::{AppError, AppResult}; +use crate::filters; +use crate::post::{PostManager, PostMetadata, RenderStats}; + +#[derive(Clone)] +pub struct AppState { + pub config: Arc, + pub posts: Arc>>, +} + +#[derive(Template)] +#[template(path = "index.html")] +struct IndexTemplate { + title: String, + description: String, + posts: Vec, +} + +#[derive(Template)] +#[template(path = "post.html")] +struct PostTemplate { + meta: PostMetadata, + rendered: String, + rendered_in: RenderStats, + markdown_access: bool, +} + +#[derive(Deserialize)] +struct QueryParams { + tag: Option, + #[serde(rename = "n")] + num_posts: Option, +} + +async fn index( + State(AppState { config, posts }): State, + Query(query): Query, +) -> AppResult { + let posts = posts + .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) + .await?; + + Ok(IndexTemplate { + title: config.title.clone(), + description: config.description.clone(), + posts, + }) +} + +async fn all_posts( + 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_ref()) + .await?; + + Ok(Json(posts)) +} + +async fn rss( + State(AppState { config, posts }): State, + Query(query): Query, +) -> AppResult { + if !config.rss.enable { + return Err(AppError::RssDisabled); + } + + let posts = posts + .get_all_posts_filtered(|metadata, _| { + !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::>(), + ) + .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); + + Ok(([(header::CONTENT_TYPE, "text/xml")], body).into_response()) +} + +async fn post( + State(AppState { config, posts }): State, + Path(name): Path, +) -> AppResult { + if name.ends_with(".md") && config.raw_access { + let mut file = tokio::fs::OpenOptions::new() + .read(true) + .open(config.dirs.posts.join(&name)) + .await?; + + let mut buf = Vec::new(); + file.read_to_end(&mut buf).await?; + + Ok(([("content-type", "text/plain")], buf).into_response()) + } else { + let post = posts.get_post(&name).await?; + let page = PostTemplate { + meta: post.0, + rendered: post.1, + rendered_in: post.2, + markdown_access: config.raw_access, + }; + + Ok(page.into_response()) + } +} + +pub fn new() -> 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(all_posts)) + .route("/feed.xml", get(rss)) + .nest_service("/static", ServeDir::new("static").precompressed_gzip()) + .nest_service("/media", ServeDir::new("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"); + }), + ) +} diff --git a/src/main.rs b/src/main.rs index 4fcc1ac..12d957b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![feature(let_chains)] +mod app; mod config; mod error; mod filters; @@ -10,182 +11,23 @@ mod ranged_i128_visitor; mod systemtime_as_secs; use std::future::IntoFuture; -use std::io::Read; use std::net::SocketAddr; use std::process::exit; use std::sync::Arc; use std::time::Duration; -use askama_axum::Template; -use axum::extract::{Path, Query, State}; -use axum::http::{header, Request}; -use axum::response::{IntoResponse, Redirect, Response}; -use axum::routing::{get, Router}; -use axum::Json; use color_eyre::eyre::{self, Context}; -use error::AppError; -use rss::{Category, ChannelBuilder, ItemBuilder}; -use serde::Deserialize; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::task::JoinSet; use tokio::{select, signal}; use tokio_util::sync::CancellationToken; -use tower_http::services::ServeDir; -use tower_http::trace::TraceLayer; use tracing::level_filters::LevelFilter; -use tracing::{debug, error, info, info_span, warn, Span}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; +use tracing::{debug, info, warn}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::{util::SubscriberInitExt, EnvFilter}; -use crate::config::Config; -use crate::error::{AppResult, PostError}; -use crate::post::cache::{Cache, CACHE_VERSION}; -use crate::post::{PostManager, PostMetadata, RenderStats}; - -type ArcState = Arc; - -#[derive(Clone)] -struct AppState { - pub config: Config, - pub posts: PostManager, -} - -#[derive(Template)] -#[template(path = "index.html")] -struct IndexTemplate { - title: String, - description: String, - posts: Vec, -} - -#[derive(Template)] -#[template(path = "post.html")] -struct PostTemplate { - meta: PostMetadata, - rendered: String, - rendered_in: RenderStats, - markdown_access: bool, -} - -#[derive(Deserialize)] -struct QueryParams { - tag: Option, - #[serde(rename = "n")] - num_posts: Option, -} - -async fn index( - State(state): State, - Query(query): Query, -) -> AppResult { - let posts = state - .posts - .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) - .await?; - - Ok(IndexTemplate { - title: state.config.title.clone(), - description: state.config.description.clone(), - posts, - }) -} - -async fn all_posts( - State(state): State, - Query(query): Query, -) -> AppResult>> { - let posts = state - .posts - .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) - .await?; - - Ok(Json(posts)) -} - -async fn rss( - State(state): State, - Query(query): Query, -) -> AppResult { - if !state.config.rss.enable { - return Err(AppError::RssDisabled); - } - - let posts = state - .posts - .get_all_posts_filtered(|metadata, _| { - !query - .tag - .as_ref() - .is_some_and(|tag| !metadata.tags.contains(tag)) - }) - .await?; - - let mut channel = ChannelBuilder::default(); - channel - .title(&state.config.title) - .link(state.config.rss.link.to_string()) - .description(&state.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::>(), - ) - .pub_date(metadata.created_at.map(|date| date.to_rfc2822())) - .content(content) - .link( - state - .config - .rss - .link - .join(&format!("/posts/{}", metadata.name))? - .to_string(), - ) - .build(), - ); - } - - let body = channel.build().to_string(); - drop(channel); - - Ok(([(header::CONTENT_TYPE, "text/xml")], body).into_response()) -} - -async fn post(State(state): State, Path(name): Path) -> AppResult { - if name.ends_with(".md") && state.config.raw_access { - let mut file = tokio::fs::OpenOptions::new() - .read(true) - .open(state.config.dirs.posts.join(&name)) - .await?; - - let mut buf = Vec::new(); - file.read_to_end(&mut buf).await?; - - Ok(([("content-type", "text/plain")], buf).into_response()) - } else { - let post = state.posts.get_post(&name).await?; - let page = PostTemplate { - meta: post.0, - rendered: post.1, - rendered_in: post.2, - markdown_access: state.config.raw_access, - }; - - Ok(page.into_response()) - } -} +use crate::app::AppState; +use crate::post::PostManager; #[tokio::main] async fn main() -> eyre::Result<()> { @@ -202,89 +44,26 @@ async fn main() -> eyre::Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); - let config = config::load() - .await - .context("couldn't load configuration")?; + let config = Arc::new( + config::load() + .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(); - let posts = if config.cache.enable { - if config.cache.persistence - && tokio::fs::try_exists(&config.cache.file) - .await - .with_context(|| { - format!("failed to check if {} exists", config.cache.file.display()) - })? - { - info!("loading cache from file"); - let path = &config.cache.file; - let load_cache = async { - let mut cache_file = tokio::fs::File::open(&path) - .await - .context("failed to open cache file")?; - let serialized = if config.cache.compress { - let cache_file = cache_file.into_std().await; - tokio::task::spawn_blocking(move || { - let mut buf = Vec::with_capacity(4096); - zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?; - Ok::<_, std::io::Error>(buf) - }) - .await - .context("failed to join blocking thread")? - .context("failed to read cache file")? - } else { - let mut buf = Vec::with_capacity(4096); - cache_file - .read_to_end(&mut buf) - .await - .context("failed to read cache file")?; - buf - }; - let mut cache: Cache = - bitcode::deserialize(serialized.as_slice()).context("failed to parse cache")?; - if cache.version() < CACHE_VERSION { - warn!("cache version changed, clearing cache"); - cache = Cache::default(); - }; - - Ok::(PostManager::new_with_cache( - config.dirs.posts.clone(), - config.render.clone(), - cache, - )) - } - .await; - match load_cache { - Ok(posts) => posts, - Err(err) => { - error!("failed to load cache: {}", err); - info!("using empty cache"); - PostManager::new_with_cache( - config.dirs.posts.clone(), - config.render.clone(), - Default::default(), - ) - } - } - } else { - PostManager::new_with_cache( - config.dirs.posts.clone(), - config.render.clone(), - Default::default(), - ) - } - } else { - PostManager::new(config.dirs.posts.clone(), config.render.clone()) + let posts = Arc::new(PostManager::new(Arc::clone(&config)).await?); + let state = AppState { + config: Arc::clone(&config), + posts, }; - let state = Arc::new(AppState { config, posts }); - - if state.config.cache.enable && state.config.cache.cleanup { - if let Some(t) = state.config.cache.cleanup_interval { - let state = Arc::clone(&state); + if config.cache.enable && config.cache.cleanup { + if let Some(t) = config.cache.cleanup_interval { + let state = state.clone(); let token = cancellation_token.child_token(); debug!("setting up cleanup task"); tasks.spawn(async move { @@ -303,35 +82,7 @@ async fn main() -> eyre::Result<()> { } } - let app = 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(all_posts)) - .route("/feed.xml", get(rss)) - .nest_service("/static", ServeDir::new("static").precompressed_gzip()) - .nest_service("/media", ServeDir::new("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"); - }), - ) - .with_state(state.clone()); + let app = app::new().with_state(state.clone()); let listener = TcpListener::bind(socket_addr) .await @@ -379,36 +130,7 @@ async fn main() -> eyre::Result<()> { task.context("failed to join task")?; } - // write cache to file - let config = &state.config; - let posts = &state.posts; - if config.cache.enable - && config.cache.persistence - && let Some(cache) = posts.cache() - { - let path = &config.cache.file; - let serialized = bitcode::serialize(cache).context("failed to serialize cache")?; - let mut cache_file = tokio::fs::File::create(path) - .await - .with_context(|| format!("failed to open cache at {}", path.display()))?; - let compression_level = config.cache.compression_level; - if config.cache.compress { - let cache_file = cache_file.into_std().await; - tokio::task::spawn_blocking(move || { - std::io::Write::write_all( - &mut zstd::stream::write::Encoder::new(cache_file, compression_level)? - .auto_finish(), - &serialized, - ) - }) - .await - .context("failed to join blocking thread")? - } else { - cache_file.write_all(&serialized).await - } - .context("failed to write cache to file")?; - info!("wrote cache to {}", path.display()); - } + drop(state); Ok::<(), color_eyre::Report>(()) }; diff --git a/src/post/mod.rs b/src/post/mod.rs index 1e2619f..99bef21 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -1,20 +1,22 @@ pub mod cache; use std::collections::BTreeSet; -use std::io; -use std::path::{Path, PathBuf}; +use std::io::{self, Read, Write}; +use std::ops::Deref; +use std::path::Path; use std::time::{Duration, Instant, SystemTime}; use chrono::{DateTime, Utc}; +use color_eyre::eyre::{self, Context}; use fronma::parser::{parse, ParsedData}; use serde::{Deserialize, Serialize}; use tokio::fs; use tokio::io::AsyncReadExt; -use tracing::warn; +use tracing::{error, info, warn}; -use crate::config::RenderConfig; +use crate::config::Config; use crate::markdown_render::render; -use crate::post::cache::Cache; +use crate::post::cache::{Cache, CACHE_VERSION}; use crate::systemtime_as_secs::as_secs; use crate::PostError; @@ -69,27 +71,84 @@ pub enum RenderStats { ParsedAndRendered(Duration, Duration, Duration), } -#[derive(Clone)] -pub struct PostManager { - dir: PathBuf, +pub struct PostManager +where + C: Deref, +{ cache: Option, - config: RenderConfig, + config: C, } -impl PostManager { - pub fn new(dir: PathBuf, config: RenderConfig) -> PostManager { - PostManager { - dir, - cache: None, - config, - } - } +impl PostManager +where + C: Deref, +{ + pub async fn new(config: C) -> eyre::Result> { + if config.cache.enable { + if config.cache.persistence + && tokio::fs::try_exists(&config.cache.file) + .await + .with_context(|| { + format!("failed to check if {} exists", config.cache.file.display()) + })? + { + info!("loading cache from file"); + let path = &config.cache.file; + let load_cache = async { + let mut cache_file = tokio::fs::File::open(&path) + .await + .context("failed to open cache file")?; + let serialized = if config.cache.compress { + let cache_file = cache_file.into_std().await; + tokio::task::spawn_blocking(move || { + let mut buf = Vec::with_capacity(4096); + zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?; + Ok::<_, std::io::Error>(buf) + }) + .await + .context("failed to join blocking thread")? + .context("failed to read cache file")? + } else { + let mut buf = Vec::with_capacity(4096); + cache_file + .read_to_end(&mut buf) + .await + .context("failed to read cache file")?; + buf + }; + let mut cache: Cache = bitcode::deserialize(serialized.as_slice()) + .context("failed to parse cache")?; + if cache.version() < CACHE_VERSION { + warn!("cache version changed, clearing cache"); + cache = Cache::default(); + }; - pub fn new_with_cache(dir: PathBuf, config: RenderConfig, cache: Cache) -> PostManager { - PostManager { - dir, - cache: Some(cache), - config, + Ok::(cache) + } + .await; + + Ok(Self { + cache: Some(match load_cache { + Ok(cache) => cache, + Err(err) => { + error!("failed to load cache: {}", err); + info!("using empty cache"); + Default::default() + } + }), + config, + }) + } else { + Ok(Self { + cache: Some(Default::default()), + config, + }) + } + } else { + Ok(Self { + cache: None, + config, + }) } } @@ -118,7 +177,7 @@ impl PostManager { let parsing = parsing_start.elapsed(); let before_render = Instant::now(); - let post = render(body, &self.config); + let post = render(body, &self.config.render); let rendering = before_render.elapsed(); if let Some(cache) = self.cache.as_ref() { @@ -128,7 +187,7 @@ impl PostManager { metadata.clone(), as_secs(&modified), post.clone(), - &self.config, + &self.config.render, ) .await .unwrap_or_else(|err| warn!("failed to insert {:?} into cache", err.0)) @@ -143,7 +202,7 @@ impl PostManager { ) -> Result, PostError> { let mut posts = Vec::new(); - let mut read_dir = fs::read_dir(&self.dir).await?; + let mut read_dir = fs::read_dir(&self.config.dirs.posts).await?; while let Some(entry) = read_dir.next_entry().await? { let path = entry.path(); let stat = fs::metadata(&path).await?; @@ -192,7 +251,7 @@ impl PostManager { ) -> Result, PostError> { let mut posts = Vec::new(); - let mut read_dir = fs::read_dir(&self.dir).await?; + let mut read_dir = fs::read_dir(&self.config.dirs.posts).await?; while let Some(entry) = read_dir.next_entry().await? { let path = entry.path(); let stat = fs::metadata(&path).await?; @@ -241,7 +300,7 @@ impl PostManager { name: &str, ) -> Result<(PostMetadata, String, RenderStats), PostError> { let start = Instant::now(); - let path = self.dir.join(name.to_owned() + ".md"); + let path = self.config.dirs.posts.join(name.to_owned() + ".md"); let stat = match tokio::fs::metadata(&path).await { Ok(value) => value, @@ -258,7 +317,7 @@ impl PostManager { let mtime = as_secs(&stat.modified()?); if let Some(cache) = self.cache.as_ref() - && let Some(hit) = cache.lookup(name, mtime, &self.config).await + && let Some(hit) = cache.lookup(name, mtime, &self.config.render).await { Ok(( hit.metadata, @@ -283,7 +342,7 @@ impl PostManager { if let Some(cache) = self.cache.as_ref() { cache .cleanup(|name| { - std::fs::metadata(self.dir.join(name.to_owned() + ".md")) + std::fs::metadata(self.config.dirs.posts.join(name.to_owned() + ".md")) .ok() .and_then(|metadata| metadata.modified().ok()) .map(|mtime| as_secs(&mtime)) @@ -291,4 +350,40 @@ impl PostManager { .await } } + + fn try_drop(&mut self) -> Result<(), eyre::Report> { + // write cache to file + let config = &self.config.cache; + if config.enable + && config.persistence + && let Some(cache) = self.cache() + { + let path = &config.file; + let serialized = bitcode::serialize(cache).context("failed to serialize cache")?; + let mut cache_file = std::fs::File::create(path) + .with_context(|| format!("failed to open cache at {}", path.display()))?; + let compression_level = config.compression_level; + if config.compress { + std::io::Write::write_all( + &mut zstd::stream::write::Encoder::new(cache_file, compression_level)? + .auto_finish(), + &serialized, + ) + } else { + cache_file.write_all(&serialized) + } + .context("failed to write cache to file")?; + info!("wrote cache to {}", path.display()); + } + Ok(()) + } +} + +impl Drop for PostManager +where + C: Deref, +{ + fn drop(&mut self) { + self.try_drop().unwrap() + } } From a7b5472fc6339743f3a6dc1788987016e5bd37d3 Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Thu, 9 May 2024 11:30:18 +0300 Subject: [PATCH 2/6] clean up new function --- src/main.rs | 8 ++--- src/post/mod.rs | 86 ++++++++++++++++++++++--------------------------- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/src/main.rs b/src/main.rs index 12d957b..5501830 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,12 +58,12 @@ async fn main() -> eyre::Result<()> { let posts = Arc::new(PostManager::new(Arc::clone(&config)).await?); let state = AppState { config: Arc::clone(&config), - posts, + posts: Arc::clone(&posts), }; if config.cache.enable && config.cache.cleanup { if let Some(t) = config.cache.cleanup_interval { - let state = state.clone(); + let posts = Arc::clone(&posts); let token = cancellation_token.child_token(); debug!("setting up cleanup task"); tasks.spawn(async move { @@ -72,13 +72,13 @@ async fn main() -> eyre::Result<()> { select! { _ = token.cancelled() => break, _ = interval.tick() => { - state.posts.cleanup().await + posts.cleanup().await } } } }); } else { - state.posts.cleanup().await; + posts.cleanup().await; } } diff --git a/src/post/mod.rs b/src/post/mod.rs index 99bef21..388fdd4 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -15,10 +15,10 @@ use tokio::io::AsyncReadExt; use tracing::{error, info, warn}; use crate::config::Config; +use crate::error::PostError; use crate::markdown_render::render; use crate::post::cache::{Cache, CACHE_VERSION}; use crate::systemtime_as_secs::as_secs; -use crate::PostError; #[derive(Deserialize)] struct FrontMatter { @@ -71,6 +71,32 @@ pub enum RenderStats { ParsedAndRendered(Duration, Duration, Duration), } +async fn load_cache(config: &Config) -> Result { + let path = &config.cache.file; + let mut cache_file = tokio::fs::File::open(&path) + .await + .context("failed to open cache file")?; + let serialized = if config.cache.compress { + let cache_file = cache_file.into_std().await; + tokio::task::spawn_blocking(move || { + let mut buf = Vec::with_capacity(4096); + zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?; + Ok::<_, std::io::Error>(buf) + }) + .await? + .context("failed to read cache file")? + } else { + let mut buf = Vec::with_capacity(4096); + cache_file + .read_to_end(&mut buf) + .await + .context("failed to read cache file")?; + buf + }; + + bitcode::deserialize(serialized.as_slice()).context("failed to parse cache") +} + pub struct PostManager where C: Deref, @@ -85,57 +111,21 @@ where { pub async fn new(config: C) -> eyre::Result> { if config.cache.enable { - if config.cache.persistence - && tokio::fs::try_exists(&config.cache.file) - .await - .with_context(|| { - format!("failed to check if {} exists", config.cache.file.display()) - })? - { + if config.cache.persistence && tokio::fs::try_exists(&config.cache.file).await? { info!("loading cache from file"); - let path = &config.cache.file; - let load_cache = async { - let mut cache_file = tokio::fs::File::open(&path) - .await - .context("failed to open cache file")?; - let serialized = if config.cache.compress { - let cache_file = cache_file.into_std().await; - tokio::task::spawn_blocking(move || { - let mut buf = Vec::with_capacity(4096); - zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?; - Ok::<_, std::io::Error>(buf) - }) - .await - .context("failed to join blocking thread")? - .context("failed to read cache file")? - } else { - let mut buf = Vec::with_capacity(4096); - cache_file - .read_to_end(&mut buf) - .await - .context("failed to read cache file")?; - buf - }; - let mut cache: Cache = bitcode::deserialize(serialized.as_slice()) - .context("failed to parse cache")?; - if cache.version() < CACHE_VERSION { - warn!("cache version changed, clearing cache"); - cache = Cache::default(); - }; + let mut cache = load_cache(&config).await.unwrap_or_else(|err| { + error!("failed to load cache: {}", err); + info!("using empty cache"); + Default::default() + }); - Ok::(cache) - } - .await; + if cache.version() < CACHE_VERSION { + warn!("cache version changed, clearing cache"); + cache = Default::default(); + }; Ok(Self { - cache: Some(match load_cache { - Ok(cache) => cache, - Err(err) => { - error!("failed to load cache: {}", err); - info!("using empty cache"); - Default::default() - } - }), + cache: Some(cache), config, }) } else { From cc41ba9421f93d06e4bf72cd97f51bb74342ef6d Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Tue, 14 May 2024 10:11:41 +0300 Subject: [PATCH 3/6] refactor part 2: create PostManager trait --- src/app.rs | 15 +++-- src/config.rs | 6 +- src/main.rs | 6 +- src/post/mod.rs | 167 ++++++++++++++++++++++++++++-------------------- 4 files changed, 113 insertions(+), 81 deletions(-) diff --git a/src/app.rs b/src/app.rs index e229a99..3e0d4be 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,12 +17,12 @@ use tracing::{info, info_span, Span}; use crate::config::Config; use crate::error::{AppError, AppResult}; use crate::filters; -use crate::post::{PostManager, PostMetadata, RenderStats}; +use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats}; #[derive(Clone)] pub struct AppState { pub config: Arc, - pub posts: Arc>>, + pub posts: Arc>>, } #[derive(Template)] @@ -84,7 +84,7 @@ async fn rss( } let posts = posts - .get_all_posts_filtered(|metadata, _| { + .get_all_posts(|metadata, _| { !query .tag .as_ref() @@ -161,7 +161,7 @@ async fn post( } } -pub fn new() -> Router { +pub fn new(config: &Config) -> Router { Router::new() .route("/", get(index)) .route( @@ -173,8 +173,11 @@ pub fn new() -> Router { .route("/posts/:name", get(post)) .route("/posts", get(all_posts)) .route("/feed.xml", get(rss)) - .nest_service("/static", ServeDir::new("static").precompressed_gzip()) - .nest_service("/media", ServeDir::new("media")) + .nest_service( + "/static", + ServeDir::new(&config.dirs._static).precompressed_gzip(), + ) + .nest_service("/media", ServeDir::new(&config.dirs.media)) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { diff --git a/src/config.rs b/src/config.rs index 5afb005..c54e1db 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use color_eyre::eyre::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tracing::{error, info}; +use tracing::{error, info, instrument}; use url::Url; use crate::ranged_i128_visitor::RangedI128Visitor; @@ -49,6 +49,8 @@ pub struct HttpConfig { pub struct DirsConfig { pub posts: PathBuf, pub media: PathBuf, + #[serde(rename = "static")] + pub _static: PathBuf, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -101,6 +103,7 @@ impl Default for DirsConfig { Self { posts: "posts".into(), media: "media".into(), + _static: "static".into(), } } } @@ -138,6 +141,7 @@ impl Default for CacheConfig { } } +#[instrument(name = "config")] pub async fn load() -> Result { let config_file = env::var(format!( "{}_CONFIG", diff --git a/src/main.rs b/src/main.rs index 5501830..c352c95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::{util::SubscriberInitExt, EnvFilter}; use crate::app::AppState; -use crate::post::PostManager; +use crate::post::{MarkdownPosts, PostManager}; #[tokio::main] async fn main() -> eyre::Result<()> { @@ -55,7 +55,7 @@ async fn main() -> eyre::Result<()> { let mut tasks = JoinSet::new(); let cancellation_token = CancellationToken::new(); - let posts = Arc::new(PostManager::new(Arc::clone(&config)).await?); + let posts = Arc::new(MarkdownPosts::new(Arc::clone(&config)).await?); let state = AppState { config: Arc::clone(&config), posts: Arc::clone(&posts), @@ -82,7 +82,7 @@ async fn main() -> eyre::Result<()> { } } - let app = app::new().with_state(state.clone()); + let app = app::new(&config).with_state(state.clone()); let listener = TcpListener::bind(socket_addr) .await diff --git a/src/post/mod.rs b/src/post/mod.rs index 388fdd4..64e77f7 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -97,7 +97,51 @@ async fn load_cache(config: &Config) -> Result { bitcode::deserialize(serialized.as_slice()).context("failed to parse cache") } -pub struct PostManager +pub trait PostManager { + async fn get_all_post_metadata( + &self, + filter: impl Fn(&PostMetadata) -> bool, + ) -> Result, PostError> { + self.get_all_posts(|m, _| filter(m)) + .await + .map(|vec| vec.into_iter().map(|(meta, ..)| meta).collect()) + } + + async fn get_all_posts( + &self, + filter: impl Fn(&PostMetadata, &str) -> bool, + ) -> Result, PostError>; + + async fn get_max_n_post_metadata_with_optional_tag_sorted( + &self, + n: Option, + tag: Option<&String>, + ) -> Result, PostError> { + let mut posts = self + .get_all_post_metadata(|metadata| !tag.is_some_and(|tag| !metadata.tags.contains(tag))) + .await?; + // we still want some semblance of order if created_at is None so sort by mtime as well + posts.sort_unstable_by_key(|metadata| metadata.modified_at.unwrap_or_default()); + posts.sort_by_key(|metadata| metadata.created_at.unwrap_or_default()); + posts.reverse(); + if let Some(n) = n { + posts.truncate(n); + } + + Ok(posts) + } + + #[allow(unused)] + async fn get_post_metadata(&self, name: &str) -> Result { + self.get_post(name).await.map(|(meta, ..)| meta) + } + + async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError>; + + async fn cleanup(&self); +} + +pub struct MarkdownPosts where C: Deref, { @@ -105,11 +149,11 @@ where config: C, } -impl PostManager +impl MarkdownPosts where C: Deref, { - pub async fn new(config: C) -> eyre::Result> { + pub async fn new(config: C) -> eyre::Result> { if config.cache.enable { if config.cache.persistence && tokio::fs::try_exists(&config.cache.file).await? { info!("loading cache from file"); @@ -186,7 +230,52 @@ where Ok((metadata, post, (parsing, rendering))) } - pub async fn get_all_post_metadata_filtered( + fn cache(&self) -> Option<&Cache> { + self.cache.as_ref() + } + + fn try_drop(&mut self) -> Result<(), eyre::Report> { + // write cache to file + let config = &self.config.cache; + if config.enable + && config.persistence + && let Some(cache) = self.cache() + { + let path = &config.file; + let serialized = bitcode::serialize(cache).context("failed to serialize cache")?; + let mut cache_file = std::fs::File::create(path) + .with_context(|| format!("failed to open cache at {}", path.display()))?; + let compression_level = config.compression_level; + if config.compress { + std::io::Write::write_all( + &mut zstd::stream::write::Encoder::new(cache_file, compression_level)? + .auto_finish(), + &serialized, + ) + } else { + cache_file.write_all(&serialized) + } + .context("failed to write cache to file")?; + info!("wrote cache to {}", path.display()); + } + Ok(()) + } +} + +impl Drop for MarkdownPosts +where + C: Deref, +{ + fn drop(&mut self) { + self.try_drop().unwrap() + } +} + +impl PostManager for MarkdownPosts +where + C: Deref, +{ + async fn get_all_post_metadata( &self, filter: impl Fn(&PostMetadata) -> bool, ) -> Result, PostError> { @@ -235,7 +324,7 @@ where Ok(posts) } - pub async fn get_all_posts_filtered( + async fn get_all_posts( &self, filter: impl Fn(&PostMetadata, &str) -> bool, ) -> Result, PostError> { @@ -264,31 +353,7 @@ where Ok(posts) } - pub async fn get_max_n_post_metadata_with_optional_tag_sorted( - &self, - n: Option, - tag: Option<&String>, - ) -> Result, PostError> { - let mut posts = self - .get_all_post_metadata_filtered(|metadata| { - !tag.is_some_and(|tag| !metadata.tags.contains(tag)) - }) - .await?; - // we still want some semblance of order if created_at is None so sort by mtime as well - posts.sort_unstable_by_key(|metadata| metadata.modified_at.unwrap_or_default()); - posts.sort_by_key(|metadata| metadata.created_at.unwrap_or_default()); - posts.reverse(); - if let Some(n) = n { - posts.truncate(n); - } - - Ok(posts) - } - - pub async fn get_post( - &self, - name: &str, - ) -> Result<(PostMetadata, String, RenderStats), PostError> { + async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError> { let start = Instant::now(); let path = self.config.dirs.posts.join(name.to_owned() + ".md"); @@ -324,11 +389,7 @@ where } } - pub fn cache(&self) -> Option<&Cache> { - self.cache.as_ref() - } - - pub async fn cleanup(&self) { + async fn cleanup(&self) { if let Some(cache) = self.cache.as_ref() { cache .cleanup(|name| { @@ -340,40 +401,4 @@ where .await } } - - fn try_drop(&mut self) -> Result<(), eyre::Report> { - // write cache to file - let config = &self.config.cache; - if config.enable - && config.persistence - && let Some(cache) = self.cache() - { - let path = &config.file; - let serialized = bitcode::serialize(cache).context("failed to serialize cache")?; - let mut cache_file = std::fs::File::create(path) - .with_context(|| format!("failed to open cache at {}", path.display()))?; - let compression_level = config.compression_level; - if config.compress { - std::io::Write::write_all( - &mut zstd::stream::write::Encoder::new(cache_file, compression_level)? - .auto_finish(), - &serialized, - ) - } else { - cache_file.write_all(&serialized) - } - .context("failed to write cache to file")?; - info!("wrote cache to {}", path.display()); - } - Ok(()) - } -} - -impl Drop for PostManager -where - C: Deref, -{ - fn drop(&mut self) { - self.try_drop().unwrap() - } } From 897e1cbf880e2e41b4cea1bd41828190313a383d Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Tue, 14 May 2024 10:23:35 +0300 Subject: [PATCH 4/6] move MarkdownPosts into it's own file --- src/post/cache.rs | 31 +++- src/post/markdown_posts.rs | 279 ++++++++++++++++++++++++++++++++++ src/post/mod.rs | 304 +------------------------------------ 3 files changed, 312 insertions(+), 302 deletions(-) create mode 100644 src/post/markdown_posts.rs diff --git a/src/post/cache.rs b/src/post/cache.rs index f6b86cc..45a74f1 100644 --- a/src/post/cache.rs +++ b/src/post/cache.rs @@ -1,10 +1,13 @@ use std::hash::{DefaultHasher, Hash, Hasher}; +use std::io::Read; +use color_eyre::eyre::{self, Context}; use scc::HashMap; use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; use tracing::{debug, instrument}; -use crate::config::RenderConfig; +use crate::config::{Config, RenderConfig}; use crate::post::PostMetadata; /// do not persist cache if this version number changed @@ -133,3 +136,29 @@ impl Cache { self.1 } } + +pub(crate) async fn load_cache(config: &Config) -> Result { + let path = &config.cache.file; + let mut cache_file = tokio::fs::File::open(&path) + .await + .context("failed to open cache file")?; + let serialized = if config.cache.compress { + let cache_file = cache_file.into_std().await; + tokio::task::spawn_blocking(move || { + let mut buf = Vec::with_capacity(4096); + zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?; + Ok::<_, std::io::Error>(buf) + }) + .await? + .context("failed to read cache file")? + } else { + let mut buf = Vec::with_capacity(4096); + cache_file + .read_to_end(&mut buf) + .await + .context("failed to read cache file")?; + buf + }; + + bitcode::deserialize(serialized.as_slice()).context("failed to parse cache") +} diff --git a/src/post/markdown_posts.rs b/src/post/markdown_posts.rs new file mode 100644 index 0000000..1f73f33 --- /dev/null +++ b/src/post/markdown_posts.rs @@ -0,0 +1,279 @@ +use std::io::{self, Write}; +use std::ops::Deref; +use std::path::Path; +use std::time::Duration; +use std::time::Instant; + +use color_eyre::eyre::{self, Context}; +use fronma::parser::{parse, ParsedData}; +use tokio::fs; +use tokio::io::AsyncReadExt; +use tracing::{error, info, warn}; + +use crate::config::Config; +use crate::markdown_render::render; +use crate::post::cache::{load_cache, Cache, CACHE_VERSION}; +use crate::post::{FrontMatter, PostError, PostManager, PostMetadata, RenderStats}; +use crate::systemtime_as_secs::as_secs; + +pub struct MarkdownPosts +where + C: Deref, +{ + cache: Option, + config: C, +} + +impl MarkdownPosts +where + C: Deref, +{ + pub async fn new(config: C) -> eyre::Result> { + if config.cache.enable { + if config.cache.persistence && tokio::fs::try_exists(&config.cache.file).await? { + info!("loading cache from file"); + let mut cache = load_cache(&config).await.unwrap_or_else(|err| { + error!("failed to load cache: {}", err); + info!("using empty cache"); + Default::default() + }); + + if cache.version() < CACHE_VERSION { + warn!("cache version changed, clearing cache"); + cache = Default::default(); + }; + + Ok(Self { + cache: Some(cache), + config, + }) + } else { + Ok(Self { + cache: Some(Default::default()), + config, + }) + } + } else { + Ok(Self { + cache: None, + config, + }) + } + } + + async fn parse_and_render( + &self, + name: String, + path: impl AsRef, + ) -> Result<(PostMetadata, String, (Duration, Duration)), PostError> { + let parsing_start = Instant::now(); + let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await { + Ok(val) => val, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => return Err(PostError::NotFound(name)), + _ => return Err(PostError::IoError(err)), + }, + }; + let stat = file.metadata().await?; + let modified = stat.modified()?; + let created = stat.created().ok(); + + let mut content = String::with_capacity(stat.len() as usize); + file.read_to_string(&mut content).await?; + + let ParsedData { headers, body } = parse::(&content)?; + let metadata = headers.into_full(name.to_owned(), created, Some(modified)); + let parsing = parsing_start.elapsed(); + + let before_render = Instant::now(); + let post = render(body, &self.config.render); + let rendering = before_render.elapsed(); + + if let Some(cache) = self.cache.as_ref() { + cache + .insert( + name.to_string(), + metadata.clone(), + as_secs(&modified), + post.clone(), + &self.config.render, + ) + .await + .unwrap_or_else(|err| warn!("failed to insert {:?} into cache", err.0)) + }; + + Ok((metadata, post, (parsing, rendering))) + } + + fn cache(&self) -> Option<&Cache> { + self.cache.as_ref() + } + + fn try_drop(&mut self) -> Result<(), eyre::Report> { + // write cache to file + let config = &self.config.cache; + if config.enable + && config.persistence + && let Some(cache) = self.cache() + { + let path = &config.file; + let serialized = bitcode::serialize(cache).context("failed to serialize cache")?; + let mut cache_file = std::fs::File::create(path) + .with_context(|| format!("failed to open cache at {}", path.display()))?; + let compression_level = config.compression_level; + if config.compress { + std::io::Write::write_all( + &mut zstd::stream::write::Encoder::new(cache_file, compression_level)? + .auto_finish(), + &serialized, + ) + } else { + cache_file.write_all(&serialized) + } + .context("failed to write cache to file")?; + info!("wrote cache to {}", path.display()); + } + Ok(()) + } +} + +impl Drop for MarkdownPosts +where + C: Deref, +{ + fn drop(&mut self) { + self.try_drop().unwrap() + } +} + +impl PostManager for MarkdownPosts +where + C: Deref, +{ + async fn get_all_post_metadata( + &self, + filter: impl Fn(&PostMetadata) -> bool, + ) -> Result, PostError> { + let mut posts = Vec::new(); + + let mut read_dir = fs::read_dir(&self.config.dirs.posts).await?; + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + let stat = fs::metadata(&path).await?; + + if stat.is_file() && path.extension().is_some_and(|ext| ext == "md") { + let mtime = as_secs(&stat.modified()?); + // TODO. this? + let name = path + .clone() + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + + if let Some(cache) = self.cache.as_ref() + && let Some(hit) = cache.lookup_metadata(&name, mtime).await + && filter(&hit) + { + 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), + }, + } + } + } + } + + Ok(posts) + } + + async fn get_all_posts( + &self, + filter: impl Fn(&PostMetadata, &str) -> bool, + ) -> Result, PostError> { + let mut posts = Vec::new(); + + let mut read_dir = fs::read_dir(&self.config.dirs.posts).await?; + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + let stat = fs::metadata(&path).await?; + + if stat.is_file() && path.extension().is_some_and(|ext| ext == "md") { + let name = path + .clone() + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + + let post = self.get_post(&name).await?; + if filter(&post.0, &post.1) { + posts.push(post); + } + } + } + + Ok(posts) + } + + async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError> { + let start = Instant::now(); + let path = self.config.dirs.posts.join(name.to_owned() + ".md"); + + let stat = match tokio::fs::metadata(&path).await { + Ok(value) => value, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => { + if let Some(cache) = self.cache.as_ref() { + cache.remove(name).await; + } + return Err(PostError::NotFound(name.to_string())); + } + _ => return Err(PostError::IoError(err)), + }, + }; + let mtime = as_secs(&stat.modified()?); + + if let Some(cache) = self.cache.as_ref() + && let Some(hit) = cache.lookup(name, mtime, &self.config.render).await + { + Ok(( + hit.metadata, + hit.rendered, + RenderStats::Cached(start.elapsed()), + )) + } else { + let (metadata, rendered, stats) = self.parse_and_render(name.to_string(), path).await?; + Ok(( + metadata, + rendered, + RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1), + )) + } + } + + async fn cleanup(&self) { + if let Some(cache) = self.cache.as_ref() { + cache + .cleanup(|name| { + std::fs::metadata(self.config.dirs.posts.join(name.to_owned() + ".md")) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(|mtime| as_secs(&mtime)) + }) + .await + } + } +} diff --git a/src/post/mod.rs b/src/post/mod.rs index 64e77f7..2cf4e09 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -1,24 +1,14 @@ pub mod cache; +pub mod markdown_posts; use std::collections::BTreeSet; -use std::io::{self, Read, Write}; -use std::ops::Deref; -use std::path::Path; -use std::time::{Duration, Instant, SystemTime}; +use std::time::{Duration, SystemTime}; use chrono::{DateTime, Utc}; -use color_eyre::eyre::{self, Context}; -use fronma::parser::{parse, ParsedData}; use serde::{Deserialize, Serialize}; -use tokio::fs; -use tokio::io::AsyncReadExt; -use tracing::{error, info, warn}; -use crate::config::Config; use crate::error::PostError; -use crate::markdown_render::render; -use crate::post::cache::{Cache, CACHE_VERSION}; -use crate::systemtime_as_secs::as_secs; +pub use crate::post::markdown_posts::MarkdownPosts; #[derive(Deserialize)] struct FrontMatter { @@ -71,32 +61,6 @@ pub enum RenderStats { ParsedAndRendered(Duration, Duration, Duration), } -async fn load_cache(config: &Config) -> Result { - let path = &config.cache.file; - let mut cache_file = tokio::fs::File::open(&path) - .await - .context("failed to open cache file")?; - let serialized = if config.cache.compress { - let cache_file = cache_file.into_std().await; - tokio::task::spawn_blocking(move || { - let mut buf = Vec::with_capacity(4096); - zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?; - Ok::<_, std::io::Error>(buf) - }) - .await? - .context("failed to read cache file")? - } else { - let mut buf = Vec::with_capacity(4096); - cache_file - .read_to_end(&mut buf) - .await - .context("failed to read cache file")?; - buf - }; - - bitcode::deserialize(serialized.as_slice()).context("failed to parse cache") -} - pub trait PostManager { async fn get_all_post_metadata( &self, @@ -140,265 +104,3 @@ pub trait PostManager { async fn cleanup(&self); } - -pub struct MarkdownPosts -where - C: Deref, -{ - cache: Option, - config: C, -} - -impl MarkdownPosts -where - C: Deref, -{ - pub async fn new(config: C) -> eyre::Result> { - if config.cache.enable { - if config.cache.persistence && tokio::fs::try_exists(&config.cache.file).await? { - info!("loading cache from file"); - let mut cache = load_cache(&config).await.unwrap_or_else(|err| { - error!("failed to load cache: {}", err); - info!("using empty cache"); - Default::default() - }); - - if cache.version() < CACHE_VERSION { - warn!("cache version changed, clearing cache"); - cache = Default::default(); - }; - - Ok(Self { - cache: Some(cache), - config, - }) - } else { - Ok(Self { - cache: Some(Default::default()), - config, - }) - } - } else { - Ok(Self { - cache: None, - config, - }) - } - } - - async fn parse_and_render( - &self, - name: String, - path: impl AsRef, - ) -> Result<(PostMetadata, String, (Duration, Duration)), PostError> { - let parsing_start = Instant::now(); - let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await { - Ok(val) => val, - Err(err) => match err.kind() { - io::ErrorKind::NotFound => return Err(PostError::NotFound(name)), - _ => return Err(PostError::IoError(err)), - }, - }; - let stat = file.metadata().await?; - let modified = stat.modified()?; - let created = stat.created().ok(); - - let mut content = String::with_capacity(stat.len() as usize); - file.read_to_string(&mut content).await?; - - let ParsedData { headers, body } = parse::(&content)?; - let metadata = headers.into_full(name.to_owned(), created, Some(modified)); - let parsing = parsing_start.elapsed(); - - let before_render = Instant::now(); - let post = render(body, &self.config.render); - let rendering = before_render.elapsed(); - - if let Some(cache) = self.cache.as_ref() { - cache - .insert( - name.to_string(), - metadata.clone(), - as_secs(&modified), - post.clone(), - &self.config.render, - ) - .await - .unwrap_or_else(|err| warn!("failed to insert {:?} into cache", err.0)) - }; - - Ok((metadata, post, (parsing, rendering))) - } - - fn cache(&self) -> Option<&Cache> { - self.cache.as_ref() - } - - fn try_drop(&mut self) -> Result<(), eyre::Report> { - // write cache to file - let config = &self.config.cache; - if config.enable - && config.persistence - && let Some(cache) = self.cache() - { - let path = &config.file; - let serialized = bitcode::serialize(cache).context("failed to serialize cache")?; - let mut cache_file = std::fs::File::create(path) - .with_context(|| format!("failed to open cache at {}", path.display()))?; - let compression_level = config.compression_level; - if config.compress { - std::io::Write::write_all( - &mut zstd::stream::write::Encoder::new(cache_file, compression_level)? - .auto_finish(), - &serialized, - ) - } else { - cache_file.write_all(&serialized) - } - .context("failed to write cache to file")?; - info!("wrote cache to {}", path.display()); - } - Ok(()) - } -} - -impl Drop for MarkdownPosts -where - C: Deref, -{ - fn drop(&mut self) { - self.try_drop().unwrap() - } -} - -impl PostManager for MarkdownPosts -where - C: Deref, -{ - async fn get_all_post_metadata( - &self, - filter: impl Fn(&PostMetadata) -> bool, - ) -> Result, PostError> { - let mut posts = Vec::new(); - - let mut read_dir = fs::read_dir(&self.config.dirs.posts).await?; - while let Some(entry) = read_dir.next_entry().await? { - let path = entry.path(); - let stat = fs::metadata(&path).await?; - - if stat.is_file() && path.extension().is_some_and(|ext| ext == "md") { - let mtime = as_secs(&stat.modified()?); - // TODO. this? - let name = path - .clone() - .file_stem() - .unwrap() - .to_string_lossy() - .to_string(); - - if let Some(cache) = self.cache.as_ref() - && let Some(hit) = cache.lookup_metadata(&name, mtime).await - && filter(&hit) - { - 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), - }, - } - } - } - } - - Ok(posts) - } - - async fn get_all_posts( - &self, - filter: impl Fn(&PostMetadata, &str) -> bool, - ) -> Result, PostError> { - let mut posts = Vec::new(); - - let mut read_dir = fs::read_dir(&self.config.dirs.posts).await?; - while let Some(entry) = read_dir.next_entry().await? { - let path = entry.path(); - let stat = fs::metadata(&path).await?; - - if stat.is_file() && path.extension().is_some_and(|ext| ext == "md") { - let name = path - .clone() - .file_stem() - .unwrap() - .to_string_lossy() - .to_string(); - - let post = self.get_post(&name).await?; - if filter(&post.0, &post.1) { - posts.push(post); - } - } - } - - Ok(posts) - } - - async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError> { - let start = Instant::now(); - let path = self.config.dirs.posts.join(name.to_owned() + ".md"); - - let stat = match tokio::fs::metadata(&path).await { - Ok(value) => value, - Err(err) => match err.kind() { - io::ErrorKind::NotFound => { - if let Some(cache) = self.cache.as_ref() { - cache.remove(name).await; - } - return Err(PostError::NotFound(name.to_string())); - } - _ => return Err(PostError::IoError(err)), - }, - }; - let mtime = as_secs(&stat.modified()?); - - if let Some(cache) = self.cache.as_ref() - && let Some(hit) = cache.lookup(name, mtime, &self.config.render).await - { - Ok(( - hit.metadata, - hit.rendered, - RenderStats::Cached(start.elapsed()), - )) - } else { - let (metadata, rendered, stats) = self.parse_and_render(name.to_string(), path).await?; - Ok(( - metadata, - rendered, - RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1), - )) - } - } - - async fn cleanup(&self) { - if let Some(cache) = self.cache.as_ref() { - cache - .cleanup(|name| { - std::fs::metadata(self.config.dirs.posts.join(name.to_owned() + ".md")) - .ok() - .and_then(|metadata| metadata.modified().ok()) - .map(|mtime| as_secs(&mtime)) - }) - .await - } - } -} From cf102126b38572bff9cdb6f3b32866149a6a012b Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Tue, 14 May 2024 12:26:43 +0300 Subject: [PATCH 5/6] move the rest of markdown-related stuff into it's own file --- README.md | 3 +- src/app.rs | 36 +++++------ src/config.rs | 4 +- src/post/markdown_posts.rs | 127 ++++++++++++++++++++++++++++--------- src/post/mod.rs | 50 ++++----------- 5 files changed, 128 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index de24d7f..afa78ac 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ the default configuration with comments looks like this ```toml title = "bingus-blog" # title of the website description = "blazingly fast markdown blog software written in rust memory safe" # description of the website -raw_access = true # allow users to see the raw markdown of a post +markdown_access = true # allow users to see the raw markdown of a post + # endpoint: /posts/.md [rss] enable = false # serve an rss field under /feed.xml diff --git a/src/app.rs b/src/app.rs index 3e0d4be..72b8322 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,13 +3,13 @@ use std::time::Duration; use askama_axum::Template; use axum::extract::{Path, Query, State}; +use axum::http::header::CONTENT_TYPE; use axum::http::{header, Request}; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::get; use axum::{Json, Router}; use rss::{Category, ChannelBuilder, ItemBuilder}; use serde::Deserialize; -use tokio::io::AsyncReadExt; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use tracing::{info, info_span, Span}; @@ -17,7 +17,7 @@ use tracing::{info, info_span, Span}; use crate::config::Config; use crate::error::{AppError, AppResult}; use crate::filters; -use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats}; +use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost}; #[derive(Clone)] pub struct AppState { @@ -138,26 +138,20 @@ async fn post( State(AppState { config, posts }): State, Path(name): Path, ) -> AppResult { - if name.ends_with(".md") && config.raw_access { - let mut file = tokio::fs::OpenOptions::new() - .read(true) - .open(config.dirs.posts.join(&name)) - .await?; + match posts.get_post(&name).await? { + ReturnedPost::Rendered(meta, rendered, rendered_in) => { + let page = PostTemplate { + meta, + rendered, + rendered_in, + markdown_access: config.markdown_access, + }; - let mut buf = Vec::new(); - file.read_to_end(&mut buf).await?; - - Ok(([("content-type", "text/plain")], buf).into_response()) - } else { - let post = posts.get_post(&name).await?; - let page = PostTemplate { - meta: post.0, - rendered: post.1, - rendered_in: post.2, - markdown_access: config.raw_access, - }; - - Ok(page.into_response()) + Ok(page.into_response()) + } + ReturnedPost::Raw(body, content_type) => { + Ok(([(CONTENT_TYPE, content_type)], body).into_response()) + } } } diff --git a/src/config.rs b/src/config.rs index c54e1db..23df5bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,7 +64,7 @@ pub struct RssConfig { pub struct Config { pub title: String, pub description: String, - pub raw_access: bool, + pub markdown_access: bool, pub num_posts: usize, pub rss: RssConfig, pub dirs: DirsConfig, @@ -78,7 +78,7 @@ impl Default for Config { Self { title: "bingus-blog".into(), description: "blazingly fast markdown blog software written in rust memory safe".into(), - raw_access: true, + markdown_access: true, num_posts: 5, // i have a love-hate relationship with serde // it was engimatic at first, but then i started actually using it diff --git a/src/post/markdown_posts.rs b/src/post/markdown_posts.rs index 1f73f33..e1ee1df 100644 --- a/src/post/markdown_posts.rs +++ b/src/post/markdown_posts.rs @@ -1,11 +1,16 @@ +use std::collections::BTreeSet; use std::io::{self, Write}; use std::ops::Deref; use std::path::Path; use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use axum::http::HeaderValue; +use chrono::{DateTime, Utc}; use color_eyre::eyre::{self, Context}; use fronma::parser::{parse, ParsedData}; +use serde::Deserialize; use tokio::fs; use tokio::io::AsyncReadExt; use tracing::{error, info, warn}; @@ -13,9 +18,40 @@ use tracing::{error, info, warn}; use crate::config::Config; use crate::markdown_render::render; use crate::post::cache::{load_cache, Cache, CACHE_VERSION}; -use crate::post::{FrontMatter, PostError, PostManager, PostMetadata, RenderStats}; +use crate::post::{PostError, PostManager, PostMetadata, RenderStats, ReturnedPost}; use crate::systemtime_as_secs::as_secs; +#[derive(Deserialize)] +struct FrontMatter { + pub title: String, + pub description: String, + pub author: String, + pub icon: Option, + pub created_at: Option>, + pub modified_at: Option>, + #[serde(default)] + pub tags: BTreeSet, +} + +impl FrontMatter { + pub fn into_full( + self, + name: String, + created: Option, + modified: Option, + ) -> PostMetadata { + PostMetadata { + name, + title: self.title, + description: self.description, + author: self.author, + 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.into_iter().collect(), + } + } +} pub struct MarkdownPosts where C: Deref, @@ -219,8 +255,10 @@ where .to_string(); let post = self.get_post(&name).await?; - if filter(&post.0, &post.1) { - posts.push(post); + if let ReturnedPost::Rendered(meta, content, stats) = post + && filter(&meta, &content) + { + posts.push((meta, content, stats)); } } } @@ -228,39 +266,66 @@ where Ok(posts) } - async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError> { - let start = Instant::now(); - let path = self.config.dirs.posts.join(name.to_owned() + ".md"); + async fn get_post(&self, name: &str) -> Result { + if self.config.markdown_access && name.ends_with(".md") { + let path = self.config.dirs.posts.join(name); - let stat = match tokio::fs::metadata(&path).await { - Ok(value) => value, - Err(err) => match err.kind() { - io::ErrorKind::NotFound => { - if let Some(cache) = self.cache.as_ref() { - cache.remove(name).await; + let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await { + Ok(value) => value, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => { + if let Some(cache) = self.cache.as_ref() { + cache.remove(name).await; + } + return Err(PostError::NotFound(name.to_string())); } - return Err(PostError::NotFound(name.to_string())); - } - _ => return Err(PostError::IoError(err)), - }, - }; - let mtime = as_secs(&stat.modified()?); + _ => return Err(PostError::IoError(err)), + }, + }; - if let Some(cache) = self.cache.as_ref() - && let Some(hit) = cache.lookup(name, mtime, &self.config.render).await - { - Ok(( - hit.metadata, - hit.rendered, - RenderStats::Cached(start.elapsed()), + let mut buf = Vec::with_capacity(4096); + + file.read_to_end(&mut buf).await?; + + Ok(ReturnedPost::Raw( + buf, + HeaderValue::from_static("text/plain"), )) } else { - let (metadata, rendered, stats) = self.parse_and_render(name.to_string(), path).await?; - Ok(( - metadata, - rendered, - RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1), - )) + let start = Instant::now(); + let path = self.config.dirs.posts.join(name.to_owned() + ".md"); + + let stat = match tokio::fs::metadata(&path).await { + Ok(value) => value, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => { + if let Some(cache) = self.cache.as_ref() { + cache.remove(name).await; + } + return Err(PostError::NotFound(name.to_string())); + } + _ => return Err(PostError::IoError(err)), + }, + }; + let mtime = as_secs(&stat.modified()?); + + if let Some(cache) = self.cache.as_ref() + && let Some(hit) = cache.lookup(name, mtime, &self.config.render).await + { + Ok(ReturnedPost::Rendered( + hit.metadata, + hit.rendered, + RenderStats::Cached(start.elapsed()), + )) + } else { + let (metadata, rendered, stats) = + self.parse_and_render(name.to_string(), path).await?; + Ok(ReturnedPost::Rendered( + metadata, + rendered, + RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1), + )) + } } } diff --git a/src/post/mod.rs b/src/post/mod.rs index 2cf4e09..6dcb0b1 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -1,47 +1,15 @@ pub mod cache; pub mod markdown_posts; -use std::collections::BTreeSet; -use std::time::{Duration, SystemTime}; +use std::time::Duration; +use axum::http::HeaderValue; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::error::PostError; pub use crate::post::markdown_posts::MarkdownPosts; -#[derive(Deserialize)] -struct FrontMatter { - pub title: String, - pub description: String, - pub author: String, - pub icon: Option, - pub created_at: Option>, - pub modified_at: Option>, - #[serde(default)] - pub tags: BTreeSet, -} - -impl FrontMatter { - pub fn into_full( - self, - name: String, - created: Option, - modified: Option, - ) -> PostMetadata { - PostMetadata { - name, - title: self.title, - description: self.description, - author: self.author, - 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.into_iter().collect(), - } - } -} - #[derive(Serialize, Deserialize, Clone, Debug)] pub struct PostMetadata { pub name: String, @@ -54,13 +22,18 @@ pub struct PostMetadata { pub tags: Vec, } -#[allow(unused)] pub enum RenderStats { Cached(Duration), // format: Total, Parsed in, Rendered in ParsedAndRendered(Duration, Duration, Duration), } +#[allow(clippy::large_enum_variant)] // Raw will be returned very rarely +pub enum ReturnedPost { + Rendered(PostMetadata, String, RenderStats), + Raw(Vec, HeaderValue), +} + pub trait PostManager { async fn get_all_post_metadata( &self, @@ -97,10 +70,13 @@ pub trait PostManager { #[allow(unused)] async fn get_post_metadata(&self, name: &str) -> Result { - self.get_post(name).await.map(|(meta, ..)| meta) + match self.get_post(name).await? { + ReturnedPost::Rendered(metadata, ..) => Ok(metadata), + ReturnedPost::Raw(..) => Err(PostError::NotFound(name.to_string())), + } } - async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError>; + async fn get_post(&self, name: &str) -> Result; async fn cleanup(&self); } From 84932c0d1e88a974ae4e05ac245f881c5ab65520 Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Thu, 13 Jun 2024 21:52:18 +0300 Subject: [PATCH 6/6] add custom date formatting and client side date formatting --- README.md | 12 +++++++++--- src/app.rs | 10 +++++++++- src/config.rs | 18 ++++++++++++++---- src/filters.rs | 24 +++++++++++++++++++++--- src/post/cache.rs | 5 ++--- static/main.js | 4 ++++ static/style.css | 29 +++++++++++++++++++++++++++++ templates/index.html | 3 +++ templates/macros.askama | 22 +++++++++++++++++----- templates/post.html | 33 ++++++++++++++++----------------- 10 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 static/main.js diff --git a/README.md b/README.md index afa78ac..dd87090 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ blazingly fast markdown blog software written in rust memory safe - [ ] ^ replace HashMap with HashCache once i implement [this](https://github.com/wvwwvwwv/scalable-concurrent-containers/issues/139) - [x] (de)compress cache with zstd on startup/shutdown - [ ] make date parsing less strict -- [ ] make date formatting better +- [x] make date formatting better - [ ] date formatting respects user timezone - [x] clean up imports and require less features - [ ] improve home page @@ -36,10 +36,16 @@ blazingly fast markdown blog software written in rust memory safe the default configuration with comments looks like this ```toml -title = "bingus-blog" # title of the website -description = "blazingly fast markdown blog software written in rust memory safe" # description of the website +title = "bingus-blog" # title of the blog +# description of the blog +description = "blazingly fast markdown blog software written in rust memory safe" markdown_access = true # allow users to see the raw markdown of a post # endpoint: /posts/.md +date_format = "RFC3339" # format string used to format dates in the backend + # it's highly recommended to leave this as default, + # so the date can be formatted by the browser. + # format: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers +js_enable = true # enable javascript (required for above) [rss] enable = false # serve an rss field under /feed.xml diff --git a/src/app.rs b/src/app.rs index 72b8322..1a8b0ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use tracing::{info, info_span, Span}; -use crate::config::Config; +use crate::config::{Config, DateFormat}; use crate::error::{AppError, AppResult}; use crate::filters; use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost}; @@ -31,6 +31,8 @@ struct IndexTemplate { title: String, description: String, posts: Vec, + df: DateFormat, + js: bool, } #[derive(Template)] @@ -40,6 +42,8 @@ struct PostTemplate { rendered: String, rendered_in: RenderStats, markdown_access: bool, + df: DateFormat, + js: bool, } #[derive(Deserialize)] @@ -61,6 +65,8 @@ async fn index( title: config.title.clone(), description: config.description.clone(), posts, + df: config.date_format.clone(), + js: config.js_enable, }) } @@ -145,6 +151,8 @@ async fn post( rendered, rendered_in, markdown_access: config.markdown_access, + df: config.date_format.clone(), + js: config.js_enable, }; Ok(page.into_response()) diff --git a/src/config.rs b/src/config.rs index 23df5bf..f4dca81 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use std::env; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv6Addr}; use std::path::PathBuf; use color_eyre::eyre::{bail, Context, Result}; @@ -59,13 +59,22 @@ pub struct RssConfig { pub link: Url, } +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub enum DateFormat { + #[default] + RFC3339, + #[serde(untagged)] + Strftime(String), +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(default)] pub struct Config { pub title: String, pub description: String, pub markdown_access: bool, - pub num_posts: usize, + pub date_format: DateFormat, + pub js_enable: bool, pub rss: RssConfig, pub dirs: DirsConfig, pub http: HttpConfig, @@ -79,7 +88,8 @@ impl Default for Config { title: "bingus-blog".into(), description: "blazingly fast markdown blog software written in rust memory safe".into(), markdown_access: true, - num_posts: 5, + date_format: Default::default(), + js_enable: true, // i have a love-hate relationship with serde // it was engimatic at first, but then i started actually using it // writing my own serialize and deserialize implementations.. spending @@ -111,7 +121,7 @@ impl Default for DirsConfig { impl Default for HttpConfig { fn default() -> Self { Self { - host: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + host: IpAddr::V6(Ipv6Addr::UNSPECIFIED), port: 3000, } } diff --git a/src/filters.rs b/src/filters.rs index 35929dc..24d0a10 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -1,11 +1,29 @@ -use std::{collections::HashMap, time::Duration}; +use std::collections::HashMap; +use std::fmt::Display; +use std::time::Duration; use chrono::{DateTime, TimeZone}; +use crate::config::DateFormat; use crate::post::PostMetadata; -pub fn date(date: &DateTime) -> Result { - Ok(date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) +fn format_date(date: &DateTime, date_format: &DateFormat) -> String +where + T: TimeZone, + T::Offset: Display, +{ + match date_format { + DateFormat::RFC3339 => date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + DateFormat::Strftime(ref format_string) => date.format(format_string).to_string(), + } +} + +pub fn date(date: &DateTime, date_format: &DateFormat) -> Result +where + T: TimeZone, + T::Offset: Display, +{ + Ok(format_date(date, date_format)) } pub fn duration(duration: &&Duration) -> Result { diff --git a/src/post/cache.rs b/src/post/cache.rs index 45a74f1..cd3e837 100644 --- a/src/post/cache.rs +++ b/src/post/cache.rs @@ -1,15 +1,14 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use std::io::Read; +use crate::config::{Config, RenderConfig}; +use crate::post::PostMetadata; use color_eyre::eyre::{self, Context}; use scc::HashMap; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; use tracing::{debug, instrument}; -use crate::config::{Config, RenderConfig}; -use crate::post::PostMetadata; - /// do not persist cache if this version number changed pub const CACHE_VERSION: u16 = 2; diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..ad261ed --- /dev/null +++ b/static/main.js @@ -0,0 +1,4 @@ +for (let el of document.querySelectorAll(".date-rfc3339")) { + let date = new Date(Date.parse(el.textContent)); + el.textContent = date.toLocaleString(); +} diff --git a/static/style.css b/static/style.css index 21633a7..ac679b2 100644 --- a/static/style.css +++ b/static/style.css @@ -84,6 +84,35 @@ div.post { margin-bottom: 1em; } +.table { + display: grid; + /*grid-template-columns: auto auto auto; + grid-template-rows: auto auto;*/ + width: max-content; +} + +.table > :not(.value)::after { + content: ":"; +} + +.table > .value { + margin-left: 1em; + text-align: end; + grid-column: 2; +} + +.table > .created { + grid-row: 1; +} + +.table > .modified { + grid-row: 2; +} + +.table > .tags { + grid-row: 3; +} + /* BEGIN cool effect everyone liked */ body { diff --git a/templates/index.html b/templates/index.html index 591c008..bdf4cdc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,6 +9,9 @@ {{ title }} + {% if js %} + + {% endif %}
diff --git a/templates/macros.askama b/templates/macros.askama index 17be7a6..bd21a25 100644 --- a/templates/macros.askama +++ b/templates/macros.askama @@ -1,19 +1,31 @@ +{% macro span_date(value) %} +{{ value|date(df) }} +{% endmacro %} {% macro table(post) %} +
{% match post.created_at %} {% when Some(created_at) %} - written:       {{ created_at|date }}
+
written
+
{% call span_date(created_at) %}
{% when None %} {% endmatch %} {% match post.modified_at %} {% when Some(modified_at) %} - last modified: {{ modified_at|date }}
+
last modified
+
{% call span_date(modified_at) %}
{% when None %} {% endmatch %} - {% if !post.tags.is_empty() %} - tags:          +
tags
+
{% for tag in post.tags %} {{ tag }} - {% endfor %}
+ {% endfor %} +
{% endif %} +
{% endmacro %} diff --git a/templates/post.html b/templates/post.html index a4ddf62..ba78574 100644 --- a/templates/post.html +++ b/templates/post.html @@ -2,20 +2,21 @@ - - - - - - - {% match meta.icon %} {% when Some with (url) %} - - - {% when None %} {% endmatch %} - {{ meta.title }} - - - + + + + + + {% match meta.icon %} {% when Some with (url) %} + + + {% when None %} {% endmatch %} + {{ meta.title }} + + + {% if js %} + + {% endif %}
@@ -24,11 +25,9 @@

{{ meta.description }}

-
+
-
{% call macros::table(meta) %} -
link
back to home