diff --git a/Cargo.lock b/Cargo.lock index 4a306bd..fd89cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,19 @@ dependencies = [ "syn 2.0.60", ] +[[package]] +name = "atom_syndication" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571832dcff775e26562e8e6930cd483de5587301d40d3a3b85d532b6383e15a7" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml 0.30.0", +] + [[package]] name = "autocfg" version = "1.2.0" @@ -290,6 +303,7 @@ dependencies = [ "comrak", "console-subscriber", "fronma", + "rss", "scc", "serde", "syntect", @@ -300,6 +314,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "zstd", ] @@ -576,12 +591,30 @@ version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "322ef0094744e63628e6f0eb2295517f79276a5b342a4c2ff3042566ca181d4e" +[[package]] +name = "diligent-date-parser" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6cf7fe294274a222363f84bcb63cdea762979a0443b4cf1f4f8fd17c86b1182" +dependencies = [ + "chrono", +] + [[package]] name = "either" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + [[package]] name = "entities" version = "1.0.1" @@ -918,6 +951,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indenter" version = "0.3.3" @@ -1061,6 +1104,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + [[package]] name = "nom" version = "7.1.3" @@ -1208,7 +1257,7 @@ dependencies = [ "base64", "indexmap 2.2.6", "line-wrap", - "quick-xml", + "quick-xml 0.31.0", "serde", "time", ] @@ -1266,6 +1315,16 @@ dependencies = [ "prost", ] +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.31.0" @@ -1358,6 +1417,18 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +[[package]] +name = "rss" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7b2c77eb4450d7d5f98df52c381cd6c4e19b75dad9209a9530b85a44510219a" +dependencies = [ + "atom_syndication", + "derive_builder", + "never", + "quick-xml 0.30.0", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1652,6 +1723,21 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.37.0" @@ -1929,18 +2015,45 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode_categories" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6016a12..661cd3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ comrak = { version = "0.22.0", features = [ ], default-features = false } console-subscriber = { version = "0.2.0", optional = true } fronma = "0.2.0" +rss = "2.0.7" scc = { version = "2.1.0", features = ["serde"] } serde = { version = "1.0.197", features = ["derive"] } syntect = "5.2.0" @@ -56,4 +57,5 @@ tower-http = { version = "0.5.2", features = [ ], default-features = false } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +url = { version = "2.5.0", features = ["serde"] } zstd = { version = "0.13.1", default-features = false } diff --git a/README.md b/README.md index 30c3c67..de24d7f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ blazingly fast markdown blog software written in rust memory safe ## TODO -- [ ] RSS +- [x] RSS - [x] finish writing this document - [x] document config - [ ] extend syntect options @@ -27,6 +27,7 @@ blazingly fast markdown blog software written in rust memory safe - [x] clean up imports and require less features - [ ] improve home page - [x] tags +- [ ] multi-language support - [x] be blazingly fast - [x] 100+ MiB binary size @@ -39,6 +40,11 @@ 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 +[rss] +enable = false # serve an rss field under /feed.xml + # this may be a bit resource intensive +link = "https://..." # public url of the blog, required if rss is enabled + [dirs] posts = "posts" # where posts are stored media = "media" # directory served under /media/ diff --git a/src/config.rs b/src/config.rs index d0c72ee..6f0ae6a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,7 @@ use color_eyre::eyre::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tracing::{error, info}; +use url::Url; use crate::ranged_i128_visitor::RangedI128Visitor; @@ -50,6 +51,12 @@ pub struct DirsConfig { pub media: PathBuf, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RssConfig { + pub enable: bool, + pub link: Url, +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(default)] pub struct Config { @@ -57,6 +64,7 @@ pub struct Config { pub description: String, pub raw_access: bool, pub num_posts: usize, + pub rss: RssConfig, pub dirs: DirsConfig, pub http: HttpConfig, pub render: RenderConfig, @@ -70,6 +78,16 @@ impl Default for Config { description: "blazingly fast markdown blog software written in rust memory safe".into(), raw_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 + // writing my own serialize and deserialize implementations.. spending + // a lot of time in the docs trying to understand each and every option.. + // now with this knowledge i can do stuff like this! (see rss field) + // and i'm proud to say that it still makes 0 sense. + rss: RssConfig { + enable: false, + link: Url::parse("http://example.com").unwrap(), + }, dirs: Default::default(), http: Default::default(), render: Default::default(), diff --git a/src/error.rs b/src/error.rs index 57f6a42..25dda7e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -53,6 +53,10 @@ pub type AppResult = Result; pub enum AppError { #[error("failed to fetch post: {0}")] PostError(#[from] PostError), + #[error("rss is disabled")] + RssDisabled, + #[error(transparent)] + UrlError(#[from] url::ParseError), } impl From for AppError { @@ -75,7 +79,8 @@ impl IntoResponse for AppError { PostError::NotFound(_) => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }, - //_ => StatusCode::INTERNAL_SERVER_ERROR, + AppError::RssDisabled => StatusCode::FORBIDDEN, + AppError::UrlError(_) => StatusCode::INTERNAL_SERVER_ERROR, }; ( status_code, diff --git a/src/main.rs b/src/main.rs index d94fcab..4fcc1ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,11 +18,13 @@ use std::time::Duration; use askama_axum::Template; use axum::extract::{Path, Query, State}; -use axum::http::Request; +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; @@ -57,8 +59,8 @@ struct IndexTemplate { } #[derive(Template)] -#[template(path = "view_post.html")] -struct ViewPostTemplate { +#[template(path = "post.html")] +struct PostTemplate { meta: PostMetadata, rendered: String, rendered_in: RenderStats, @@ -78,7 +80,7 @@ async fn index( ) -> AppResult { let posts = state .posts - .get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) + .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) .await?; Ok(IndexTemplate { @@ -94,12 +96,73 @@ async fn all_posts( ) -> AppResult>> { let posts = state .posts - .get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) + .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() @@ -113,7 +176,7 @@ async fn post(State(state): State, Path(name): Path) -> AppRes Ok(([("content-type", "text/plain")], buf).into_response()) } else { let post = state.posts.get_post(&name).await?; - let page = ViewPostTemplate { + let page = PostTemplate { meta: post.0, rendered: post.1, rendered_in: post.2, @@ -250,6 +313,7 @@ async fn main() -> eyre::Result<()> { ) .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( @@ -316,30 +380,24 @@ async fn main() -> eyre::Result<()> { } // write cache to file - let AppState { config, posts } = Arc::::try_unwrap(state).unwrap_or_else(|state| { - warn!("couldn't unwrap Arc over AppState, more than one strong reference exists for Arc. cloning instead"); - // TODO: only do this when persistence is enabled - // first check config from inside the arc, then try unwrap - AppState::clone(state.as_ref()) - }); + let config = &state.config; + let posts = &state.posts; if config.cache.enable && config.cache.persistence - && let Some(cache) = posts.into_cache() + && let Some(cache) = posts.cache() { let path = &config.cache.file; - let serialized = bitcode::serialize(&cache).context("failed to serialize cache")?; + 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, - config.cache.compression_level, - )? - .auto_finish(), + &mut zstd::stream::write::Encoder::new(cache_file, compression_level)? + .auto_finish(), &serialized, ) }) diff --git a/src/post/cache.rs b/src/post/cache.rs index 6437aa3..f6b86cc 100644 --- a/src/post/cache.rs +++ b/src/post/cache.rs @@ -8,7 +8,7 @@ use crate::config::RenderConfig; use crate::post::PostMetadata; /// do not persist cache if this version number changed -pub const CACHE_VERSION: u16 = 1; +pub const CACHE_VERSION: u16 = 2; #[derive(Serialize, Deserialize, Clone)] pub struct CacheValue { @@ -111,6 +111,7 @@ impl Cache { let old_size = self.0.len(); let mut i = 0; + // TODO: multithread self.0 .retain_async(|k, v| { if get_mtime(k).is_some_and(|mtime| mtime == v.mtime) { diff --git a/src/post/mod.rs b/src/post/mod.rs index ceed862..1e2619f 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -5,7 +5,6 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime}; -use askama::Template; use chrono::{DateTime, Utc}; use fronma::parser::{parse, ParsedData}; use serde::{Deserialize, Serialize}; @@ -63,14 +62,6 @@ pub struct PostMetadata { pub tags: Vec, } -use crate::filters; -#[derive(Template)] -#[template(path = "post.html")] -struct Post<'a> { - pub meta: &'a PostMetadata, - pub rendered_markdown: String, -} - #[allow(unused)] pub enum RenderStats { Cached(Duration), @@ -127,12 +118,7 @@ impl PostManager { let parsing = parsing_start.elapsed(); let before_render = Instant::now(); - let rendered_markdown = render(body, &self.config); - let post = Post { - meta: &metadata, - rendered_markdown, - } - .render()?; + let post = render(body, &self.config); let rendering = before_render.elapsed(); if let Some(cache) = self.cache.as_ref() { @@ -151,7 +137,7 @@ impl PostManager { Ok((metadata, post, (parsing, rendering))) } - pub async fn list_posts( + pub async fn get_all_post_metadata_filtered( &self, filter: impl Fn(&PostMetadata) -> bool, ) -> Result, PostError> { @@ -200,21 +186,53 @@ impl PostManager { Ok(posts) } - pub async fn get_max_n_posts_with_optional_tag_sorted( + pub async fn get_all_posts_filtered( + &self, + filter: impl Fn(&PostMetadata, &str) -> bool, + ) -> Result, PostError> { + let mut posts = Vec::new(); + + let mut read_dir = fs::read_dir(&self.dir).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) + } + + pub async fn get_max_n_post_metadata_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))) + .get_all_post_metadata_filtered(|metadata| { + !tag.is_some_and(|tag| !metadata.tags.contains(tag)) + }) .await?; - posts.sort_unstable_by_key(|metadata| metadata.created_at.unwrap_or_default()); - + // 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 = Vec::from(&posts[posts.len().saturating_sub(n)..]); + posts.truncate(n); } - posts.reverse(); Ok(posts) } @@ -257,8 +275,8 @@ impl PostManager { } } - pub fn into_cache(self) -> Option { - self.cache + pub fn cache(&self) -> Option<&Cache> { + self.cache.as_ref() } pub async fn cleanup(&self) { diff --git a/templates/post.html b/templates/post.html index d148e5f..a4ddf62 100644 --- a/templates/post.html +++ b/templates/post.html @@ -1,16 +1,52 @@ {%- import "macros.askama" as macros -%} -

- {{ meta.title }} - -

-

{{ meta.description }}

-

- -

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

-
-{{ rendered_markdown|escape("none") }} + + + + + + + + + + {% match meta.icon %} {% when Some with (url) %} + + + {% when None %} {% endmatch %} + {{ meta.title }} + + + + + +
+

+ {{ meta.title }} + +

+

{{ meta.description }}

+
+ +
+ {% call macros::table(meta) %} +
+ link
+ back to home +
+
+ {{ rendered|escape("none") }} +
+ +
+ {% match rendered_in %} + {% when RenderStats::ParsedAndRendered(total, parsing, rendering) %} + parsed and + rendered in {{ total|duration }} + {% when RenderStats::Cached(total) %} + retrieved from cache in {{ total|duration }} + {% endmatch %} + {% if markdown_access %} + - view raw + {% endif %} +
+ + diff --git a/templates/view_post.html b/templates/view_post.html deleted file mode 100644 index e469da5..0000000 --- a/templates/view_post.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - {% match meta.icon %} {% when Some with (url) %} - - - {% when None %} {% endmatch %} - {{ meta.title }} - - - - - -
{{ rendered|escape("none") }}
- - - -