about summary refs log tree commit diff
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2025-03-13 11:06:23 +0300
committerVika <vika@fireburn.ru>2025-03-13 11:06:23 +0300
commit4ca3c83d447fbf86a4f21d841a107cb28a64658e (patch)
treee196c4a83d602bdfeb84f5f5bb1973009e17cf22
parentab6614c720064a5630100c1ba600942dcab2632f (diff)
downloadkittybox-feature/rss.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.
-rw-r--r--Cargo.lock15
-rw-r--r--Cargo.toml1
-rw-r--r--src/frontend/mod.rs53
-rw-r--r--src/frontend/onboarding.rs2
-rw-r--r--src/frontend/rss_conversion.rs65
-rw-r--r--src/indieauth/mod.rs1
-rw-r--r--src/login.rs6
-rw-r--r--templates/src/templates.rs8
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=&microsub];
                     }
                 }*/
+                @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" }