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..8299a6e0 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -7,7 +7,9 @@ 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;
use revision::revisioned;
use rust_embed::RustEmbed;
@@ -22,6 +24,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 +1452,117 @@ 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 = unescape_html(&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(),
+ }
+}
+
+/// 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 {
+ 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};