From aa06d2c5efb2679003b30ff24719c771b14f9d93 Mon Sep 17 00:00:00 2001 From: Pantonshire Date: Fri, 20 May 2022 20:13:03 +0100 Subject: [PATCH] RSS support --- Cargo.lock | 139 ++++++++++++++++++++++ blog_server/Cargo.toml | 3 + blog_server/src/main.rs | 36 ++++-- blog_server/src/post.rs | 6 +- blog_server/src/render.rs | 11 +- blog_server/src/service/contact.rs | 6 +- blog_server/src/service/index.rs | 6 +- blog_server/src/service/mod.rs | 1 + blog_server/src/service/post.rs | 8 +- blog_server/src/service/posts_list.rs | 6 +- blog_server/src/service/response.rs | 57 ++++++--- blog_server/src/service/rss.rs | 44 +++++++ blog_server/src/service/site.rs | 23 ++-- blog_server/src/service/static_content.rs | 10 +- 14 files changed, 297 insertions(+), 59 deletions(-) create mode 100644 blog_server/src/service/rss.rs diff --git a/Cargo.lock b/Cargo.lock index 4b83574..80e823c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,19 @@ dependencies = [ "syn", ] +[[package]] +name = "atom_syndication" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fb6a0b39c6517edafe46f8137e53c51742425a4dae1c73ee12264a37ad7541" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + [[package]] name = "atty" version = "0.2.14" @@ -148,6 +161,7 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" name = "blog_server" version = "0.1.0" dependencies = [ + "atom_syndication", "axum", "chrono", "knuffel", @@ -156,6 +170,7 @@ dependencies = [ "miette", "notify", "pulldown-cmark", + "rss", "syntect", "tokio", "tower", @@ -223,6 +238,90 @@ dependencies = [ "syntect", ] +[[package]] +name = "darling" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "diligent-date-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d0fd95c7c02e2d6c588c6c5628466fff9bdde4b8c6196465e087b08e792720" +dependencies = [ + "chrono", +] + +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "filetime" version = "0.2.16" @@ -436,6 +535,12 @@ dependencies = [ "want", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "1.8.1" @@ -742,6 +847,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + [[package]] name = "notify" version = "4.0.17" @@ -967,6 +1078,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "quick-xml" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quote" version = "1.0.18" @@ -1002,6 +1123,18 @@ version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +[[package]] +name = "rss" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acaf1331b7fc4edc3c2920819fee1766c27e8d40da593155832db3d6dea64e92" +dependencies = [ + "atom_syndication", + "derive_builder", + "never", + "quick-xml", +] + [[package]] name = "rustc-demangle" version = "0.1.21" @@ -1121,6 +1254,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "supports-color" version = "1.3.0" diff --git a/blog_server/Cargo.toml b/blog_server/Cargo.toml index 8936e29..227a341 100644 --- a/blog_server/Cargo.toml +++ b/blog_server/Cargo.toml @@ -15,6 +15,9 @@ tower = { version = "0.4", features = ["limit"] } tower-http = { version = "0.3", features = ["fs", "trace"] } # Compile-time HTTP templating maud = "0.23" +# Serialisation for RSS and Atom +atom_syndication = "0.11" +rss = "2" # KDL parsing knuffel = "2" # CommonMark parsing diff --git a/blog_server/src/main.rs b/blog_server/src/main.rs index 9e2c7de..e9d59ec 100644 --- a/blog_server/src/main.rs +++ b/blog_server/src/main.rs @@ -16,16 +16,32 @@ use codeblock::CodeBlockRenderer; use posts_store::ConcurrentPostsStore; use render::Renderer; -#[derive(knuffel::Decode)] -struct Config { +#[derive(knuffel::Decode, Clone, Debug)] +pub struct Config { #[knuffel(child, unwrap(argument))] bind: String, #[knuffel(child, unwrap(argument))] + concurrency_limit: usize, + #[knuffel(child, unwrap(argument))] posts_dir: PathBuf, #[knuffel(child, unwrap(argument))] static_dir: PathBuf, + #[knuffel(child)] + rss: RssConfig, +} + +#[derive(knuffel::Decode, Clone, Debug)] +pub struct RssConfig { #[knuffel(child, unwrap(argument))] - concurrency_limit: usize, + num_posts: usize, + #[knuffel(child, unwrap(argument))] + title: String, + #[knuffel(child, unwrap(argument))] + ttl: u32, + #[knuffel(child, unwrap(argument))] + protocol: String, + #[knuffel(child, unwrap(argument))] + domain: String, } fn main() -> miette::Result<()> { @@ -79,18 +95,18 @@ fn main() -> miette::Result<()> { .block_on(run(config, posts_store)) } -async fn run(config: Config, posts_store: ConcurrentPostsStore) -> miette::Result<()> { - let service = service::site_service( - posts_store, - &config.static_dir, - config.concurrency_limit - ); - +async fn run( + config: Config, + posts_store: ConcurrentPostsStore, +) -> miette::Result<()> +{ let bind_address = &config.bind .parse() .into_diagnostic() .wrap_err_with(|| format!("Failed to parse socket address \"{}\"", config.bind))?; + let service = service::site_service(config, posts_store); + info!(address = %bind_address, "Starting server"); Server::try_bind(bind_address) diff --git a/blog_server/src/post.rs b/blog_server/src/post.rs index dc62a85..9b7b13e 100644 --- a/blog_server/src/post.rs +++ b/blog_server/src/post.rs @@ -114,7 +114,7 @@ impl Post { pub fn updated(&self) -> DateTime { self.updated } - + pub fn parse( code_renderer: &CodeBlockRenderer, post_id: PostId, @@ -125,12 +125,12 @@ impl Post { ) -> Result { let mdpost = MdPost::parse(file_name, source)?; - Ok(Self::from_mdpost(post_id, code_renderer, created, updated, mdpost)) + Ok(Self::from_mdpost(code_renderer, post_id, created, updated, mdpost)) } fn from_mdpost( - id: PostId, code_renderer: &CodeBlockRenderer, + id: PostId, created: DateTime, updated: DateTime, mdpost: MdPost, diff --git a/blog_server/src/render.rs b/blog_server/src/render.rs index bff7232..fad6e88 100644 --- a/blog_server/src/render.rs +++ b/blog_server/src/render.rs @@ -10,9 +10,11 @@ use chrono::{DateTime, Utc}; use notify::DebouncedEvent; use tracing::{info, warn, error}; -use crate::codeblock::CodeBlockRenderer; -use crate::post::{ParseError, Post, PostId}; -use crate::posts_store::ConcurrentPostsStore; +use crate::{ + codeblock::CodeBlockRenderer, + post::{ParseError, Post, PostId}, + posts_store::ConcurrentPostsStore, +}; pub struct Renderer { posts: ConcurrentPostsStore, @@ -125,7 +127,8 @@ impl Renderer { #[tracing::instrument(skip(self))] fn remove(&self, target: &EventTarget) { - self.posts.write_blocking().remove(&target.id); + let mut guard = self.posts.write_blocking(); + guard.remove(&target.id); } #[tracing::instrument(skip(self))] diff --git a/blog_server/src/service/contact.rs b/blog_server/src/service/contact.rs index 7a771a9..ee200e3 100644 --- a/blog_server/src/service/contact.rs +++ b/blog_server/src/service/contact.rs @@ -1,10 +1,10 @@ use maud::html; use crate::template; -use super::response::HtmlResponse; +use super::response::Html; -pub async fn handle() -> HtmlResponse { - HtmlResponse::new() +pub async fn handle() -> Html { + Html::new() .with_title_static("Contact") .with_crawler_permissive() .with_head(html! { diff --git a/blog_server/src/service/index.rs b/blog_server/src/service/index.rs index 6d4f2c0..b84796f 100644 --- a/blog_server/src/service/index.rs +++ b/blog_server/src/service/index.rs @@ -5,10 +5,10 @@ use crate::{ posts_store::ConcurrentPostsStore, template, }; -use super::response::HtmlResponse; +use super::response::Html; -pub async fn handle(Extension(posts): Extension) -> HtmlResponse { - HtmlResponse::new() +pub async fn handle(Extension(posts): Extension) -> Html { + Html::new() .with_title_static("Pantonshire") .with_crawler_permissive() .with_head(html! { diff --git a/blog_server/src/service/mod.rs b/blog_server/src/service/mod.rs index c396b10..54f1faf 100644 --- a/blog_server/src/service/mod.rs +++ b/blog_server/src/service/mod.rs @@ -3,6 +3,7 @@ mod index; mod post; mod posts_list; mod response; +mod rss; mod site; mod static_content; diff --git a/blog_server/src/service/post.rs b/blog_server/src/service/post.rs index 2bf339c..0e6f488 100644 --- a/blog_server/src/service/post.rs +++ b/blog_server/src/service/post.rs @@ -2,18 +2,18 @@ use axum::extract::{Extension, Path}; use maud::html; use crate::posts_store::ConcurrentPostsStore; -use super::response::{ErrorResponse, HtmlResponse}; +use super::response::{Error, Html}; pub async fn handle( Path(post_id): Path, Extension(posts): Extension -) -> Result +) -> Result { let post = posts.get(&post_id) .await - .ok_or(ErrorResponse::PostNotFound)?; + .ok_or(Error::PostNotFound)?; - Ok(HtmlResponse::new() + Ok(Html::new() .with_crawler_permissive() .with_title_owned(post.title().to_owned()) .with_head(html! { diff --git a/blog_server/src/service/posts_list.rs b/blog_server/src/service/posts_list.rs index 04c8426..19e3acc 100644 --- a/blog_server/src/service/posts_list.rs +++ b/blog_server/src/service/posts_list.rs @@ -5,10 +5,10 @@ use crate::{ posts_store::ConcurrentPostsStore, template, }; -use super::response::HtmlResponse; +use super::response::Html; -pub async fn handle(Extension(posts): Extension) -> HtmlResponse { - HtmlResponse::new() +pub async fn handle(Extension(posts): Extension) -> Html { + Html::new() .with_title_static("Articles") .with_crawler_permissive() .with_head(html! { diff --git a/blog_server/src/service/response.rs b/blog_server/src/service/response.rs index 585242e..a204d80 100644 --- a/blog_server/src/service/response.rs +++ b/blog_server/src/service/response.rs @@ -4,31 +4,32 @@ use std::{ }; use axum::{ - http::StatusCode, - response::{IntoResponse, Html, Response}, + body::{Bytes, Full}, + http::{header::{self, HeaderValue}, StatusCode}, + response::{IntoResponse, Response}, }; use maud::{html, Markup, Render, Escaper, DOCTYPE}; #[derive(Debug)] -pub enum ErrorResponse { +pub enum Error { Internal, PostNotFound, StaticResourceNotFound, RouteNotFound, } -impl ErrorResponse { +impl Error { fn status_code(&self) -> StatusCode { match self { - ErrorResponse::Internal => StatusCode::INTERNAL_SERVER_ERROR, - ErrorResponse::PostNotFound => StatusCode::NOT_FOUND, - ErrorResponse::StaticResourceNotFound => StatusCode::NOT_FOUND, - ErrorResponse::RouteNotFound => StatusCode::NOT_FOUND, + Error::Internal => StatusCode::INTERNAL_SERVER_ERROR, + Error::PostNotFound => StatusCode::NOT_FOUND, + Error::StaticResourceNotFound => StatusCode::NOT_FOUND, + Error::RouteNotFound => StatusCode::NOT_FOUND, } } } -impl IntoResponse for ErrorResponse { +impl IntoResponse for Error { fn into_response(self) -> Response { let status_code = self.status_code(); @@ -51,7 +52,7 @@ impl IntoResponse for ErrorResponse { buf }; - HtmlResponse::new() + Html::new() .with_status(status_code) .with_body(html! { p { (status_text) } @@ -61,7 +62,7 @@ impl IntoResponse for ErrorResponse { } } -pub struct HtmlResponse { +pub struct Html { status: StatusCode, title: Cow<'static, str>, head: Option, @@ -69,7 +70,7 @@ pub struct HtmlResponse { crawler_hints: CrawlerHints, } -impl HtmlResponse { +impl Html { pub fn new() -> Self { Self { status: StatusCode::OK, @@ -117,13 +118,13 @@ impl HtmlResponse { } } -impl Default for HtmlResponse { +impl Default for Html { fn default() -> Self { Self::new() } } -impl IntoResponse for HtmlResponse { +impl IntoResponse for Html { fn into_response(self) -> Response { let html_doc = html! { (DOCTYPE) @@ -145,7 +146,7 @@ impl IntoResponse for HtmlResponse { } }; - (self.status, Html(html_doc.into_string())) + (self.status, axum::response::Html(html_doc.into_string())) .into_response() } } @@ -261,3 +262,29 @@ impl Render for CrawlerHints { let _result = self.write_meta_list_to(escaper); } } + +pub struct Rss(pub T); + +impl>> IntoResponse for Rss { + fn into_response(self) -> Response { + let headers = [ + (header::CONTENT_TYPE, HeaderValue::from_static("application/rss+xml")), + ]; + + (headers, self.0.into()) + .into_response() + } +} + +pub struct Atom(pub T); + +impl>> IntoResponse for Atom { + fn into_response(self) -> Response { + let headers = [ + (header::CONTENT_TYPE, HeaderValue::from_static("application/atom+xml")), + ]; + + (headers, self.0.into()) + .into_response() + } +} diff --git a/blog_server/src/service/rss.rs b/blog_server/src/service/rss.rs new file mode 100644 index 0000000..09cd1d1 --- /dev/null +++ b/blog_server/src/service/rss.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::Extension, +}; + +use super::response::Rss; +use crate::{posts_store::ConcurrentPostsStore, Config}; + +pub async fn handle( + Extension(config): Extension>, + Extension(posts): Extension, +) -> Rss { + let rss_items = posts.read() + .await + .iter_by_created() + .take(config.rss.num_posts) + .map(|post| { + rss::ItemBuilder::default() + .title(Some(post.title().to_owned())) + .link(Some(format!( + "{}://{}/articles/{}", + config.rss.protocol, + config.rss.domain, + post.id() + ))) + .pub_date(Some(post.created().to_rfc2822())) + .build() + }) + .collect::>(); + + Rss(rss::ChannelBuilder::default() + .title(config.rss.title.clone()) + .link(format!( + "{}://{}", + config.rss.protocol, config.rss.domain + )) + .ttl(Some(config.rss.ttl.to_string())) + .items(rss_items) + .build() + .to_string() + .into()) +} diff --git a/blog_server/src/service/site.rs b/blog_server/src/service/site.rs index fde8fca..74fa9c9 100644 --- a/blog_server/src/service/site.rs +++ b/blog_server/src/service/site.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::sync::Arc; use axum::{ handler::Handler, @@ -11,35 +11,40 @@ use tower::limit::ConcurrencyLimitLayer; use tower_http::trace::TraceLayer; use tracing::info; -use crate::posts_store::ConcurrentPostsStore; +use crate::{ + Config, + posts_store::ConcurrentPostsStore +}; use super::{ contact, index, post, posts_list, - response::ErrorResponse, + response::Error, + rss, static_content, }; pub fn service( + config: Config, posts_store: ConcurrentPostsStore, - static_dir: &Path, - concurrency_limit: usize ) -> Router { Router::new() .route("/", get(index::handle)) + .route("/rss.xml", get(rss::handle)) .route("/contact", get(contact::handle)) .route("/articles", get(posts_list::handle)) .route("/articles/:post_id", get(post::handle)) - .nest("/static", static_content::service(static_dir)) + .nest("/static", static_content::service(&config.static_dir)) .fallback(handle_fallback.into_service()) - .layer(ConcurrencyLimitLayer::new(concurrency_limit)) + .layer(ConcurrencyLimitLayer::new(config.concurrency_limit)) .layer(TraceLayer::new_for_http()) + .layer(Extension(Arc::new(config))) .layer(Extension(posts_store)) } -pub async fn handle_fallback(uri: Uri) -> ErrorResponse { +pub async fn handle_fallback(uri: Uri) -> Error { info!(path = %uri.path(), "Requested resource not found"); - ErrorResponse::RouteNotFound + Error::RouteNotFound } diff --git a/blog_server/src/service/static_content.rs b/blog_server/src/service/static_content.rs index ce1cf29..b037049 100644 --- a/blog_server/src/service/static_content.rs +++ b/blog_server/src/service/static_content.rs @@ -15,7 +15,7 @@ use tower::ServiceExt; use tower_http::services::ServeDir; use tracing::{info, error}; -use super::response::ErrorResponse; +use super::response::Error; pub fn service(static_dir: &Path) -> MethodRouter { let fallback_service = handle_fallback @@ -29,12 +29,12 @@ pub fn service(static_dir: &Path) -> MethodRouter { .handle_error(handle_error) } -pub async fn handle_fallback(uri: Uri) -> ErrorResponse { +pub async fn handle_fallback(uri: Uri) -> Error { info!(path = %uri.path(), "Requested static file not found"); - ErrorResponse::StaticResourceNotFound + Error::StaticResourceNotFound } -pub async fn handle_error(uri: Uri, err: io::Error) -> ErrorResponse { +pub async fn handle_error(uri: Uri, err: io::Error) -> Error { error!(path = %uri.path(), err = %err, "IO error"); - ErrorResponse::Internal + Error::Internal }