From 78bf0f43a3371bc1fed853408a09d0d90246b56d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 1 Sep 2026 14:31:32 -0700 Subject: [PATCH 1/2] Fix RSS feed media --- src/subreddit.rs | 93 +++------------------------------------ src/user.rs | 23 +++------- src/utils.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 105 deletions(-) diff --git a/src/subreddit.rs b/src/subreddit.rs index 34877250..2343128a 100644 --- a/src/subreddit.rs +++ b/src/subreddit.rs @@ -1,18 +1,15 @@ #![allow(clippy::cmp_owned)] +use crate::{config}; +use crate::{client::json, server::RequestExt, server::ResponseExt}; use crate::utils::{ - 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 + build_rss_item, should_be_nsfw_gated, Post, Preferences, Subreddit, catch_random, error, filter_posts, format_num, format_url, get_filters, info, nsfw_landing, param, redirect, rewrite_urls, setting, template, val }; -use crate::{client::json, server::RequestExt, server::ResponseExt}; -use crate::{config, utils}; use askama::Template; use cookie::Cookie; -use htmlescape::decode_html; use hyper::{Body, Request, Response}; - -use chrono::DateTime; use regex::Regex; -use rss::{ChannelBuilder, Item, Enclosure}; +use rss::ChannelBuilder; use std::sync::LazyLock; use time::{Duration, OffsetDateTime}; @@ -127,7 +124,7 @@ pub async fn community(req: Request) -> Result, String> { let req_url = req.uri().to_string(); // Return landing page if this post if this is NSFW community but the user // has disabled the display of NSFW content or if the instance is SFW-only. - if sub.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) { + if sub.nsfw && should_be_nsfw_gated(&req, &req_url) { return Ok(nsfw_landing(req, req_url).await.unwrap_or_default()); } @@ -620,20 +617,7 @@ pub async fn rss(req: Request) -> Result, String> { .items( posts .into_iter() - .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 - }) + .map(|post| build_rss_item(&post)) .collect::>(), ) .build(); @@ -648,72 +632,7 @@ 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" => post.gallery.get(0).and_then(|media| decode_html(&media.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", - } -} #[cfg(test)] mod tests { diff --git a/src/user.rs b/src/user.rs index 36a06c44..4c6660a5 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,12 +1,10 @@ #![allow(clippy::cmp_owned)] -use crate::client::json; -use crate::server::RequestExt; -use crate::utils::{error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User}; -use crate::{config, utils}; +use crate::{config}; +use crate::{client::json, server::RequestExt}; +use crate::utils::{build_rss_item, error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User, should_be_nsfw_gated}; use askama::Template; -use chrono::DateTime; -use htmlescape::decode_html; use hyper::{Body, Request, Response}; +use rss::ChannelBuilder; use time::{macros::format_description, OffsetDateTime}; // STRUCTS @@ -56,7 +54,7 @@ pub async fn profile(req: Request) -> Result, String> { // Return landing page if this post if this Reddit deems this user NSFW, // but we have also disabled the display of NSFW content or if the instance // is SFW-only. - if user.nsfw && utils::should_be_nsfw_gated(&req, &req_url) { + if user.nsfw && should_be_nsfw_gated(&req, &req_url) { return Ok(nsfw_landing(req, req_url).await.unwrap_or_default()); } @@ -136,9 +134,7 @@ pub async fn rss(req: Request) -> Result, String> { if config::get_setting("REDLIB_ENABLE_RSS").is_none() { return Ok(error(req, "RSS is disabled on this instance.").await.unwrap_or_default()); } - use crate::utils::rewrite_urls; use hyper::header::CONTENT_TYPE; - use rss::{ChannelBuilder, Item}; // Get user let user_str = req.param("name").unwrap_or_default(); @@ -161,14 +157,7 @@ pub async fn rss(req: Request) -> Result, String> { .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), - pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()), - content: Some(rewrite_urls(&decode_html(&post.body).unwrap_or_else(|_| post.body.clone()))), - ..Default::default() - }) + .map(|post| build_rss_item(&post)) .collect::>(), ) .build(); diff --git a/src/utils.rs b/src/utils.rs index 36b798f4..880e4448 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -8,6 +8,7 @@ use cookie::Cookie; use hyper::{Body, Request, Response}; use libflate::deflate::{Decoder, Encoder}; use log::error; +use chrono::DateTime; use regex::Regex; use revision::revisioned; use rust_embed::RustEmbed; @@ -22,6 +23,7 @@ use std::string::ToString; use std::sync::LazyLock; use time::{macros::format_description, Duration, OffsetDateTime}; use url::Url; +use rss::{Enclosure, Guid, Item}; /// Write a message to stderr on debug mode. This function is a no-op on /// release code. @@ -1449,6 +1451,112 @@ pub fn to_absolute_url(relative_path: &str) -> String { format!("{}{}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), relative_path) } +// ===================================================== +// RSS Feed Helpers +// ===================================================== + +/// Build an RSS item from a Post, with proper enclosure, GUID, and media embed +pub fn build_rss_item(post: &Post) -> Item { + let mut item = Item { + title: Some(post.title.to_string()), + link: Some(to_absolute_url(&post.permalink)), + author: Some(post.author.name.to_string()), + pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()), + guid: Some(Guid { + value: to_absolute_url(&post.permalink), + permalink: true, + }), + ..Default::default() + }; + + // Build description + let description_str = match post.post_type.as_str() { + "gallery" => format!( + "Gallery with {} images", + to_absolute_url(&post.permalink), + post.gallery.len() + ), + _ => format!("Comments", to_absolute_url(&post.permalink)), + }; + item.set_description(description_str.clone()); + + // Build content:encoded — embed media + body + let image_html = build_media_html(&post); + let body = &post.body; + let content = if !image_html.is_empty() || !body.is_empty() { + format!("{}{}", image_html, body) + } else { + description_str + }; + item.set_content(content); + + // Set enclosure for media posts + if let Some(enclosure) = get_rss_image(post) { + item.set_enclosure(enclosure); + } + + item +} + +/// Generate the HTML for embedding media (images/videos) in RSS content +/// Uses proxied Redlib URLs and inline styles to fit the reader window +fn build_media_html(post: &Post) -> String { + match post.post_type.as_str() { + "image" => { + let url = to_absolute_url(&post.media.url); + format!("
", url, url) + } + "gallery" => { + post.gallery.iter().map(|media| { + let url = to_absolute_url(&media.url); + format!("
", url, url) + }).collect::>().join("\n") + } + "video" | "gif" => { + let poster = to_absolute_url(&post.media.poster); + let video_url = to_absolute_url(&post.media.url); + format!( + "
", + poster, video_url + ) + } + _ => String::new(), + } +} + +/// Create an RSS enclosure for the first image of a post +/// Uses proxied Redlib URLs +fn get_rss_image(post: &Post) -> Option { + let image_url = match post.post_type.as_str() { + "image" => Some(to_absolute_url(&post.media.url)), + "gallery" => post.gallery.get(0).map(|media| to_absolute_url(&media.url)), + "gif" | "video" => Some(to_absolute_url(&post.media.poster)), + _ => None, + }; + + image_url.map(|url| { + let mut enclosure = Enclosure::default(); + enclosure.set_mime_type(get_mime_type(&url)); + enclosure.set_url(url); + enclosure.set_length("0"); + enclosure + }) +} + +/// Determines the MIME type based on file extension in a URL +fn get_mime_type(url: &str) -> &'static str { + let path = url.split('?').next().unwrap_or(url); + let extension = path.rsplit('.').next().unwrap_or("").to_lowercase(); + match extension.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } +} + #[cfg(test)] mod tests { use super::{deflate_compress, deflate_decompress, format_num, format_url, render_bullet_lists, rewrite_emotes, rewrite_urls, url_path_basename, Post, Preferences}; @@ -1687,4 +1795,4 @@ How`s your monitor by the way? Any IPS bleed whatsoever? I either got lucky or t let deserialized: Preferences = bincode::deserialize(&decompressed).unwrap(); assert_eq!(*input, deserialized); } -} +} \ No newline at end of file From 623af0e701c9ba107b770588a3c33f30e6ae2866 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 2 Sep 2026 10:50:00 -0700 Subject: [PATCH 2/2] HTML escape the content so it renders nicely in RSS readers --- src/utils.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 880e4448..8299a6e0 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,6 +7,7 @@ use askama::Template; use cookie::Cookie; use hyper::{Body, Request, Response}; use libflate::deflate::{Decoder, Encoder}; +use htmlescape; use log::error; use chrono::DateTime; use regex::Regex; @@ -1482,7 +1483,7 @@ pub fn build_rss_item(post: &Post) -> Item { // Build content:encoded — embed media + body let image_html = build_media_html(&post); - let body = &post.body; + let body = unescape_html(&post.body); let content = if !image_html.is_empty() || !body.is_empty() { format!("{}{}", image_html, body) } else { @@ -1524,6 +1525,11 @@ fn build_media_html(post: &Post) -> String { } } +/// Decodes HTML entities like </> back to their character equivalents +fn unescape_html(html: &str) -> String { + htmlescape::decode_html(html).expect("failed to decode HTML entities") +} + /// Create an RSS enclosure for the first image of a post /// Uses proxied Redlib URLs fn get_rss_image(post: &Post) -> Option { @@ -1795,4 +1801,4 @@ How`s your monitor by the way? Any IPS bleed whatsoever? I either got lucky or t let deserialized: Preferences = bincode::deserialize(&decompressed).unwrap(); assert_eq!(*input, deserialized); } -} \ No newline at end of file +}