From 457692f766c47a367e8b2ba7cc41dbb17257b35f Mon Sep 17 00:00:00 2001 From: slonkazoid Date: Thu, 2 May 2024 19:23:20 +0300 Subject: [PATCH] add rss --- Cargo.lock | 115 +++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + README.md | 8 +++- src/config.rs | 18 ++++++++ src/error.rs | 7 ++- src/main.rs | 65 +++++++++++++++++++++++++-- src/post/mod.rs | 58 ++++++++++++++++++++++-- 7 files changed, 263 insertions(+), 10 deletions(-) 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 ea7acae..2ebcda1 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; @@ -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,68 @@ 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_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) + .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() @@ -250,6 +308,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( diff --git a/src/post/mod.rs b/src/post/mod.rs index 0ecee71..89a02d4 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -200,7 +200,36 @@ 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>, @@ -210,13 +239,34 @@ impl PostManager { !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); } + Ok(posts) + } + + pub async fn get_max_n_posts_with_optional_tag_sorted( + &self, + n: Option, + tag: Option<&String>, + ) -> Result, PostError> { + let mut posts = self + .get_all_posts_filtered(|metadata, _| { + !tag.is_some_and(|tag| !metadata.tags.contains(tag)) + }) + .await?; + 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) }