From 13778593707ae1f5576f8bd906fc9ebe71170a90 Mon Sep 17 00:00:00 2001 From: slydetector Date: Tue, 25 Nov 2025 16:58:54 +0000 Subject: [PATCH 1/2] feat(rss): add image enclosure to rss feed items --- Cargo.toml | 2 +- src/subreddit.rs | 101 ++++++++++++++++++++++++++++++++++++++++------- src/utils.rs | 23 ++++++----- 3 files changed, 101 insertions(+), 25 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1e0cb0cc..66fc7c2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ fastrand = "2.0.1" log = "0.4.20" pretty_env_logger = "0.5.0" dotenvy = "0.15.7" -rss = "2.0.7" +rss = "2.0.12" arc-swap = "1.7.1" serde_json_path = "0.7.1" async-recursion = "1.1.1" diff --git a/src/subreddit.rs b/src/subreddit.rs index 4046be1c..1d40612d 100644 --- a/src/subreddit.rs +++ b/src/subreddit.rs @@ -3,8 +3,7 @@ use crate::{config, utils}; // CRATES use crate::utils::{ - catch_random, error, filter_posts, format_num, format_url, get_filters, info, nsfw_landing, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, - Subreddit, + Post, Preferences, Subreddit, catch_random, error, filter_posts, format_num, format_url, get_filters, info, nsfw_landing, param, redirect, rewrite_urls, setting, template, to_absolute_url, val }; use crate::{client::json, server::RequestExt, server::ResponseExt}; use askama::Template; @@ -14,6 +13,7 @@ use hyper::{Body, Request, Response}; use chrono::DateTime; use regex::Regex; +use rss::{ChannelBuilder, Item, Enclosure}; use std::sync::LazyLock; use time::{Duration, OffsetDateTime}; @@ -595,7 +595,6 @@ pub async fn rss(req: Request) -> Result, String> { } use hyper::header::CONTENT_TYPE; - use rss::{ChannelBuilder, Item}; // Get subreddit let sub = req.param("sub").unwrap_or_default(); @@ -605,6 +604,9 @@ pub async fn rss(req: Request) -> Result, String> { // Get path let path = format!("/r/{sub}/{sort}.json?{}", req.uri().query().unwrap_or_default()); + // Get subreddit link + let subreddit_link: String = format!("{}/r/{sub}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default()); + // Get subreddit data let subreddit = subreddit(&sub, false).await?; @@ -615,21 +617,23 @@ pub async fn rss(req: Request) -> Result, String> { let channel = ChannelBuilder::default() .title(&subreddit.title) .description(&subreddit.description) + .link(&subreddit_link) .items( posts .into_iter() - .map(|post| Item { - title: Some(post.title.to_string()), - link: Some(format_url(&utils::get_post_url(&post))), - author: Some(post.author.name), - content: Some(rewrite_urls(&decode_html(&post.body).unwrap())), - pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()), - description: Some(format!( - "Comments", - config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), - post.permalink - )), - ..Default::default() + .map(|post| { + let mut item = Item { + title: Some(post.title.to_string()), + link: Some(format_url(&utils::get_post_url(&post))), + author: Some(post.author.name.to_string()), + content: Some(rewrite_urls(&decode_html(&post.body).unwrap())), + pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()), + description: Some(format!("Comments", to_absolute_url(&post.permalink))), + ..Default::default() + }; + + apply_enclosure(&mut item, &post); + item }) .collect::>(), ) @@ -645,6 +649,73 @@ pub async fn rss(req: Request) -> Result, String> { Ok(res) } +// Set enclosure image for RSS feed item +fn apply_enclosure(item: &mut Item, post: &Post) { + item.set_enclosure(get_rss_image(&post)); + + // Embed the number of gallery images in description and content since + // only the first image in the gallery is used for the enclosure + if post.post_type == "gallery" && post.gallery.len() > 1 { + item.set_description( + format!("Gallery with {} images", + to_absolute_url(&post.permalink), + post.gallery.len() + ) + ); + + if let Some(content) = item.content() { + let new_content = format!( + "{}
{}", + item.description().unwrap_or(""), + content, + ); + item.set_content(new_content); + } + } + +} + +fn get_rss_image(post: &Post) -> Option { + let image_url = match post.post_type.as_str() { + "image" => Some(post.media.url.clone()), + "gallery" => decode_html(&post.gallery[0].url).ok(), + "gif" | "video" => decode_html(&post.media.poster).ok(), + _ => None, + }; + + image_url.map(|url| { + let mut enclosure = Enclosure::default(); + enclosure.set_mime_type(get_mime_type(&url)); + enclosure.set_url(to_absolute_url(&url)); + enclosure.set_length("0"); + enclosure + }) +} + +/// Determines the MIME type based on file extension in a URL. +/// Handles both absolute and relative URLs with query parameters. +fn get_mime_type(url: &str) -> &'static str { + // Extract the path component, removing query parameters + let path = url.split('?').next().unwrap_or(url); + + // Get the file extension (everything after the last dot) + let extension = path + .rsplit('.') + .next() + .unwrap_or("") + .to_lowercase(); + + // Match common image extensions + match extension.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } +} + #[tokio::test(flavor = "multi_thread")] async fn test_fetching_subreddit() { let subreddit = subreddit("rust", false).await; diff --git a/src/utils.rs b/src/utils.rs index efe98b7d..d323c711 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1433,18 +1433,23 @@ pub fn url_path_basename(path: &str) -> String { } } -/// Returns the URL of a post, as needed by RSS feeds +/// Returns the absolute URL of a post, as needed by RSS feeds pub fn get_post_url(post: &Post) -> String { + match post.post_type.as_str() { + "image" | "gallery" | "gif" | "video" => return to_absolute_url(&post.permalink), + _ => {} + } + if let Some(out_url) = &post.out_url { - // Handle cross post - if out_url.starts_with("/r/") { - format!("{}{}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), out_url) - } else { - out_url.to_string() - } - } else { - format!("{}{}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), post.permalink) + return if out_url.starts_with("/r/") { to_absolute_url(out_url) } else { out_url.clone() }; } + + to_absolute_url(&post.permalink) +} + +/// Returns an absolute URL given a relative URL, as needed by RSS feeds +pub fn to_absolute_url(relative_path: &str) -> String { + format!("{}{}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), relative_path) } #[cfg(test)] From 726c3433a67cd7df0672453f86a34a9cd70bb1e8 Mon Sep 17 00:00:00 2001 From: slydetector Date: Wed, 26 Nov 2025 14:22:41 +0000 Subject: [PATCH 2/2] handle index out of bounds --- src/subreddit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subreddit.rs b/src/subreddit.rs index 1d40612d..172269b9 100644 --- a/src/subreddit.rs +++ b/src/subreddit.rs @@ -678,7 +678,7 @@ fn apply_enclosure(item: &mut Item, post: &Post) { fn get_rss_image(post: &Post) -> Option { let image_url = match post.post_type.as_str() { "image" => Some(post.media.url.clone()), - "gallery" => decode_html(&post.gallery[0].url).ok(), + "gallery" => post.gallery.get(0).and_then(|media| decode_html(&media.url).ok()), "gif" | "video" => decode_html(&post.media.poster).ok(), _ => None, };