Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 6 additions & 87 deletions src/subreddit.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -127,7 +124,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, 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());
}

Expand Down Expand Up @@ -620,20 +617,7 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, 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!("<a href='{}'>Comments</a>", to_absolute_url(&post.permalink))),
..Default::default()
};

apply_enclosure(&mut item, &post);
item
})
.map(|post| build_rss_item(&post))
.collect::<Vec<_>>(),
)
.build();
Expand All @@ -648,72 +632,7 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, 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!("<a href='{}'>Gallery with {} images</a>",
to_absolute_url(&post.permalink),
post.gallery.len()
)
);

if let Some(content) = item.content() {
let new_content = format!(
"{}<br/>{}",
item.description().unwrap_or(""),
content,
);
item.set_content(new_content);
}
}

}

fn get_rss_image(post: &Post) -> Option<Enclosure> {
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 {
Expand Down
23 changes: 6 additions & 17 deletions src/user.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -56,7 +54,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, 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());
}

Expand Down Expand Up @@ -136,9 +134,7 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, 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();
Expand All @@ -161,14 +157,7 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, 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::<Vec<_>>(),
)
.build();
Expand Down
114 changes: 114 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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!(
"<a href='{}'>Gallery with {} images</a>",
to_absolute_url(&post.permalink),
post.gallery.len()
),
_ => format!("<a href='{}'>Comments</a>", 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!("<a href=\"{}\"><img src=\"{}\" width=\"100%\" /></a><br/>", url, url)
}
"gallery" => {
post.gallery.iter().map(|media| {
let url = to_absolute_url(&media.url);
format!("<a href=\"{}\"><img src=\"{}\" width=\"100%\" /></a><br/>", url, url)
}).collect::<Vec<_>>().join("\n")
}
"video" | "gif" => {
let poster = to_absolute_url(&post.media.poster);
let video_url = to_absolute_url(&post.media.url);
format!(
"<video controls preload=\"metadata\" poster=\"{}\" width=\"100%\"><source src=\"{}\" type=\"video/mp4\" /></video><br/>",
poster, video_url
)
}
_ => String::new(),
}
}

/// Decodes HTML entities like &lt;/&gt; 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<Enclosure> {
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};
Expand Down