diff options
-rw-r--r-- | Cargo.lock | 15 | ||||
-rw-r--r-- | Cargo.toml | 1 | ||||
-rw-r--r-- | src/frontend/mod.rs | 53 | ||||
-rw-r--r-- | src/frontend/onboarding.rs | 2 | ||||
-rw-r--r-- | src/frontend/rss_conversion.rs | 65 | ||||
-rw-r--r-- | src/indieauth/mod.rs | 1 | ||||
-rw-r--r-- | src/login.rs | 6 | ||||
-rw-r--r-- | templates/src/templates.rs | 8 |
8 files changed, 131 insertions, 20 deletions
diff --git a/Cargo.lock b/Cargo.lock index bc54f91..302d8b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1898,6 +1898,7 @@ dependencies = [ "relative-path", "reqwest", "reqwest-middleware", + "rss", "serde", "serde_json", "serde_urlencoded", @@ -3073,6 +3074,20 @@ dependencies = [ ] [[package]] +name = "rss" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" +dependencies = [ + "atom_syndication", + "derive_builder", + "mime", + "never", + "quick-xml", + "serde", +] + +[[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index bf14ded..29bd75c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,6 +147,7 @@ redis = { version = "0.27.6", features = ["aio", "tokio-comp"], optional = true relative-path = "1.9.3" reqwest = { version = "0.12.12", default-features = false, features = ["gzip", "brotli", "json", "stream"] } reqwest-middleware = "0.4.0" +rss = { version = "2.0.11", features = ["mime", "serde"] } serde = { workspace = true } serde_json = { workspace = true } serde_urlencoded = { workspace = true } diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 9ba1a69..478a48e 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -7,11 +7,14 @@ use axum::{ use axum_extra::{extract::Host, headers::HeaderMapExt}; use futures_util::FutureExt; use serde::Deserialize; -use std::convert::TryInto; +use std::{convert::TryInto, str::FromStr}; use tracing::{debug, error}; //pub mod login; pub mod onboarding; +mod rss_conversion; +use rss_conversion::feed_to_rss; + use kittybox_frontend_renderer::{ Entry, Feed, VCard, ErrorPage, Template, MainPage, @@ -22,6 +25,8 @@ pub use kittybox_frontend_renderer::assets::statics; #[derive(Debug, Deserialize)] pub struct QueryParams { after: Option<String>, + #[serde(default)] + rss: bool, } #[derive(Debug)] @@ -297,7 +302,8 @@ pub async fn homepage<D: Storage>( cursor: cursor.as_deref(), webring: crate::database::settings::Setting::into_inner(webring) } - .to_string(), + .to_string(), + rss_link: Some(&(feed_path + "?rss=true")) } .to_string(), ).into_response() @@ -333,6 +339,7 @@ pub async fn homepage<D: Storage>( msg: Some(err.msg().to_string()), } .to_string(), + rss_link: None, } .to_string(), ).into_response() @@ -357,7 +364,7 @@ pub async fn catchall<D: Storage>( .unwrap(); match get_post_from_database(&db, path.as_str(), query.after, user).await { - Ok((post, cursor)) => { + Ok((mut post, cursor)) => { let (blogname, channels) = tokio::join!( db.get_setting::<crate::database::settings::SiteName>(&host) .map(Result::unwrap_or_default), @@ -365,22 +372,18 @@ pub async fn catchall<D: Storage>( db.get_channels(&host).map(|i| i.unwrap_or_default()) ); let mut headers = axum::http::HeaderMap::new(); - headers.insert( - axum::http::header::CONTENT_TYPE, - axum::http::HeaderValue::from_static(r#"text/html; charset="utf-8""#), - ); + headers.typed_insert(axum_extra::headers::ContentType::html()); headers.insert( axum::http::header::X_CONTENT_TYPE_OPTIONS, axum::http::HeaderValue::from_static("nosniff") ); if user.is_some() { - headers.insert( - axum::http::header::CACHE_CONTROL, - axum::http::HeaderValue::from_static("private") - ); + headers.typed_insert(axum_extra::headers::CacheControl::new().with_private()); } - if post["type"][0].as_str() == Some("h-entry") { + let post_type = post.pointer("/type/0").and_then(|i| i.as_str()); + + if post_type == Some("h-entry") { let last_modified = post["properties"]["updated"] .as_array() .and_then(|v| v.last()) @@ -400,6 +403,26 @@ pub async fn catchall<D: Storage>( } } + if query.rss { + match post_type { + Some("h-feed") => { + headers.typed_insert(axum_extra::headers::ContentType::from_str("application/rss+xml").unwrap()); + return ( + StatusCode::OK, + headers, + feed_to_rss(post).to_string() + ).into_response() + }, + _ => { + headers.typed_insert(axum_extra::headers::ContentType::text_utf8()); + return (StatusCode::NOT_FOUND, headers, "RSS feeds cannot be generated for this document.").into_response() + } + } + } + let rss_link: Option<String> = match post_type { + Some("h-feed") => Some(post["properties"]["uid"][0].as_str().unwrap().to_owned() + "?rss=true"), + _ => None + }; // Render the homepage ( StatusCode::OK, @@ -409,7 +432,7 @@ pub async fn catchall<D: Storage>( blog_name: blogname.as_ref(), feeds: channels, user: session.as_deref(), - content: match post.pointer("/type/0").and_then(|i| i.as_str()) { + content: match post_type { Some("h-entry") => Entry { post: &post, from_feed: false, }.to_string(), Some("h-feed") => Feed { feed: &post, cursor: cursor.as_deref() }.to_string(), Some("h-card") => VCard { card: &post }.to_string(), @@ -417,6 +440,7 @@ pub async fn catchall<D: Storage>( unimplemented!("Template for MF2-JSON type {:?}", unknown) } }, + rss_link: rss_link.as_deref() } .to_string(), ).into_response() @@ -443,7 +467,8 @@ pub async fn catchall<D: Storage>( code: err.code(), msg: Some(err.msg().to_owned()), } - .to_string(), + .to_string(), + rss_link: None, } .to_string(), ).into_response() diff --git a/src/frontend/onboarding.rs b/src/frontend/onboarding.rs index 4588157..3537933 100644 --- a/src/frontend/onboarding.rs +++ b/src/frontend/onboarding.rs @@ -23,6 +23,7 @@ pub async fn get() -> Html<String> { feeds: vec![], user: None, content: OnboardingPage {}.to_string(), + rss_link: None, } .to_string(), ) @@ -153,6 +154,7 @@ pub async fn post<D: Storage + 'static>( msg: Some(err.msg().to_string()), } .to_string(), + rss_link: None, } .to_string(), ), diff --git a/src/frontend/rss_conversion.rs b/src/frontend/rss_conversion.rs new file mode 100644 index 0000000..3448bf0 --- /dev/null +++ b/src/frontend/rss_conversion.rs @@ -0,0 +1,65 @@ +//! Conversion of MF2-JSON posts into RSS items. + +/// TTL of the RSS feeds generated by Kittybox. Compliant RSS +/// consumers must avoid refetching the feed before its TTL expires. +const RSS_TTL: std::time::Duration = std::time::Duration::from_secs(600); + +/// Convert a single MF2 post to an RSS item. +/// +/// Posts must have a UID. +pub fn post_to_rss(mut post: serde_json::Value) -> Option<rss::Item> { + let mut builder = rss::ItemBuilder::default(); + + let date = match post["properties"]["published"][0].take() { + serde_json::Value::String(d) => chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc3339(&d).ok(), + _ => None + }; + if let Some(date) = date { + builder.pub_date(date.to_rfc2822()); + } + if let serde_json::Value::String(title) = post["properties"]["title"][0].take() { + builder.title(title); + } + if let serde_json::Value::String(summary) = post["properties"]["summary"][0].take() { + builder.description(summary); + } + // Do not emit posts that do not have GUIDs. + match post["properties"]["uid"][0].take() { + serde_json::Value::String(uid) => { + builder.guid(rss::GuidBuilder::default().value(uid).permalink(true).build()); + }, + _ => { return None; }, + } + // TODO: enclosures for u-photo, u-video, u-audio + // requires knowing MIME type for the included media file + // can enrich from media endpoint + + let item = builder.build(); + if item.title.is_some() || item.description.is_some() { + Some(item) + } else { + None + } +} + +/// Convert an entire MF2 h-feed into an RSS channel. +/// Fairly opinionated. Provided MF2-JSON object must be a feed. +pub fn feed_to_rss(mut post: serde_json::Value) -> rss::Channel { + let children: Vec<serde_json::Value> = match post["children"].take() { + serde_json::Value::Array(children) => children, + _ => Vec::default(), + }; + rss::ChannelBuilder::default() + .title(post["properties"]["name"][0].as_str().unwrap()) + .link(post["properties"]["uid"][0].as_str().unwrap()) + .generator(Some(concat!( + env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION") + ).to_string())) + .ttl((RSS_TTL.as_secs() / 60).to_string()) + .items(children + .into_iter() + .filter_map(post_to_rss) + .collect::<Vec<rss::Item>>() + ) + .build() +} diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs index 00ae393..2fec907 100644 --- a/src/indieauth/mod.rs +++ b/src/indieauth/mod.rs @@ -269,6 +269,7 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( user: db.get_post(me.as_str()).await.unwrap().unwrap(), app: h_app }.to_string(), + rss_link: None, }.to_string()) .into_response() } diff --git a/src/login.rs b/src/login.rs index eaa787c..edb59f9 100644 --- a/src/login.rs +++ b/src/login.rs @@ -34,7 +34,8 @@ async fn get<S: Storage + Send + Sync + 'static>( blog_name: blogname.as_ref(), feeds: channels, user: None, - content: LoginPage {}.to_string() + content: LoginPage {}.to_string(), + rss_link: None, }.to_string() ) } @@ -293,7 +294,8 @@ async fn logout_page() -> impl axum::response::IntoResponse { blog_name: "Kittybox", feeds: vec![], user: None, - content: LogoutPage {}.to_string() + content: LogoutPage {}.to_string(), + rss_link: None, }.to_string()) } diff --git a/templates/src/templates.rs b/templates/src/templates.rs index 9b29fce..986d183 100644 --- a/templates/src/templates.rs +++ b/templates/src/templates.rs @@ -3,7 +3,7 @@ use kittybox_util::micropub::Channel; use crate::{Feed, VCard}; markup::define! { - Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<Channel>, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) { + Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<Channel>, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String, rss_link: Option<&'a str>) { @markup::doctype() html { head { @@ -22,13 +22,13 @@ markup::define! { // LibreJS-compliant JS licensing info (because TypeScript is a bitch) link[rel="jslicense", href="/.kittybox/static/jslicense.html"]; /*@if let Some(endpoints) = endpoints { - @if let Some(webmention) = &endpoints.webmention { - link[rel="webmention", href=&webmention]; - } @if let Some(microsub) = &endpoints.microsub { link[rel="microsub", href=µsub]; } }*/ + @if let Some(rss) = rss_link { + link[rel="alternate", r#type="application/rss+xml", href=rss]; + } } body { a[href="#main_content", id="skip-to-content"] { "Skip to content" } |