diff options
author | Vika <vika@fireburn.ru> | 2025-03-13 11:06:23 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2025-03-13 11:06:23 +0300 |
commit | 4ca3c83d447fbf86a4f21d841a107cb28a64658e (patch) | |
tree | e196c4a83d602bdfeb84f5f5bb1973009e17cf22 /src | |
parent | ab6614c720064a5630100c1ba600942dcab2632f (diff) | |
download | kittybox-4ca3c83d447fbf86a4f21d841a107cb28a64658e.tar.zst |
WIP: RSS feed generator feature/rss
Some people are old-fashioned, and RSS feeds can be consumed even by laypeople unfamiliar with microformats2. This does violate DRY at first glance, but since the feeds are dynamically generated it's not repetition but rather format conversion, and as such does not violate DRY per se.
Diffstat (limited to 'src')
-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 |
5 files changed, 111 insertions, 16 deletions
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()) } |