diff --git a/src/subreddit.rs b/src/subreddit.rs index 34877250..01de9c2f 100644 --- a/src/subreddit.rs +++ b/src/subreddit.rs @@ -1,7 +1,8 @@ #![allow(clippy::cmp_owned)] 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 + catch_random, error, filter_posts, format_num, format_url, get_filters, info, nsfw_landing, param, redirect, rewrite_urls, setting, template, to_absolute_url, val, Post, + Preferences, Subreddit, }; use crate::{client::json, server::RequestExt, server::ResponseExt}; use crate::{config, utils}; @@ -12,9 +13,10 @@ use hyper::{Body, Request, Response}; use chrono::DateTime; use regex::Regex; -use rss::{ChannelBuilder, Item, Enclosure}; +use rss::{ChannelBuilder, Enclosure, Item}; use std::sync::LazyLock; use time::{Duration, OffsetDateTime}; +use url::form_urlencoded; // STRUCTS #[derive(Template)] @@ -59,6 +61,10 @@ struct WallTemplate { static GEO_FILTER_MATCH: LazyLock = LazyLock::new(|| Regex::new(r"geo_filter=(?\w+)").unwrap()); +const RSS_SORTS: &[&str] = &["best", "hot", "new", "controversial", "top", "rising"]; + +const RSS_TIME_FILTERS: &[&str] = &["hour", "day", "week", "month", "year", "all"]; + // SERVICES pub async fn community(req: Request) -> Result, String> { // Build Reddit API path @@ -598,10 +604,17 @@ pub async fn rss(req: Request) -> Result, String> { // Get subreddit let sub = req.param("sub").unwrap_or_default(); let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string()); - let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort)); + let (sort, listing_query) = match rss_listing_params(&req, &post_sort) { + Ok(params) => params, + Err(message) => return error(req, &message).await, + }; // Get path - let path = format!("/r/{sub}/{sort}.json?{}", req.uri().query().unwrap_or_default()); + let path = if listing_query.is_empty() { + format!("/r/{sub}/{sort}.json?raw_json=1") + } else { + format!("/r/{sub}/{sort}.json?{listing_query}&raw_json=1") + }; // Get subreddit link let subreddit_link: String = format!("{}/r/{sub}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default()); @@ -649,29 +662,19 @@ pub async fn rss(req: Request) -> Result, String> { } // Set enclosure image for RSS feed item -fn apply_enclosure(item: &mut Item, post: &Post) { +pub 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() - ) - ); + 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, - ); + let new_content = format!("{}
{}", item.description().unwrap_or(""), content,); item.set_content(new_content); } } - } fn get_rss_image(post: &Post) -> Option { @@ -694,25 +697,68 @@ fn get_rss_image(post: &Post) -> Option { /// 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", - } + // 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", + } +} + +fn rss_listing_params(req: &Request, default_sort: &str) -> Result<(String, String), String> { + let mut sort = default_sort.to_owned(); + let mut time_filter = None; + let mut limit = None; + + for (key, value) in form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes()) { + match key.as_ref() { + "sort" => { + if !RSS_SORTS.contains(&value.as_ref()) { + return Err(format!("Invalid RSS sort: {}", value)); + } + + sort = value.into_owned(); + } + "t" => { + if !RSS_TIME_FILTERS.contains(&value.as_ref()) { + return Err(format!("Invalid RSS time filter: {}", value)); + } + + time_filter = Some(value.into_owned()); + } + "limit" => { + let parsed = value.parse::().map_err(|_| "RSS limit must be a number between 1 and 100".to_string())?; + + if !(1..=100).contains(&parsed) { + return Err("RSS limit must be a number between 1 and 100".to_string()); + } + + limit = Some(parsed); + } + _ => {} + } + } + + let mut query = form_urlencoded::Serializer::new(String::new()); + + if let Some(time_filter) = time_filter { + query.append_pair("t", &time_filter); + } + + if let Some(limit) = limit { + query.append_pair("limit", &limit.to_string()); + } + + Ok((sort, query.finish())) } #[cfg(test)] diff --git a/src/user.rs b/src/user.rs index 36a06c44..961fdd51 100644 --- a/src/user.rs +++ b/src/user.rs @@ -136,6 +136,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::subreddit::apply_enclosure; use crate::utils::rewrite_urls; use hyper::header::CONTENT_TYPE; use rss::{ChannelBuilder, Item}; @@ -148,6 +149,8 @@ pub async fn rss(req: Request) -> Result, String> { // Get path let path = format!("/user/{user_str}/{listing}.json?{}&raw_json=1", req.uri().query().unwrap_or_default(),); + let user_link: String = format!("{}/user/{user_str}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default()); + // Get user let user_obj = user(&user_str).await.unwrap_or_default(); @@ -158,16 +161,22 @@ pub async fn rss(req: Request) -> Result, String> { let channel = ChannelBuilder::default() .title(user_str) .description(user_obj.description) + .link(&user_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), - 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| { + 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()), + 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() + }; + + apply_enclosure(&mut item, &post); + item }) .collect::>(), )