Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Redlib

> [!NOTE]
> Docker images for this fork are published at [Docker Hub](https://hub.docker.com/r/luciferscircle/redlib): `docker.io/luciferscircle/redlib:latest`.

> An alternative private front-end to Reddit, with its origins in [Libreddit](https://github.com/libreddit/libreddit).

![screenshot](https://i.ibb.co/18vrdxk/redlib-rust.png)
Expand Down
2 changes: 1 addition & 1 deletion src/duplicates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
let new_path: String = format!(
"{}.json?before=t3_{}&sort={}&limit=1&raw_json=1",
req.uri().path(),
&duplicates[0].id,
duplicates[0].id,
if sort.is_empty() { "num_comments".to_string() } else { sort.clone() }
);
match json(new_path, true).await {
Expand Down
25 changes: 23 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,16 @@ async fn main() {
.at("/check_update.js")
.get(|_| resource(include_str!("../static/check_update.js"), "text/javascript", false).boxed());
app.at("/copy.js").get(|_| resource(include_str!("../static/copy.js"), "text/javascript", false).boxed());
app
.at("/downloadGallery.js")
.get(|_| resource(include_str!("../static/downloadGallery.js"), "text/javascript", false).boxed());

app.at("/commits.atom").get(|_| async move { proxy_commit_info().await }.boxed());
app.at("/instances.json").get(|_| async move { proxy_instances().await }.boxed());

// Proxy media through Redlib
app.at("/vid/:id/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
app.at("/vid/:id/dash/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
app.at("/vid/:id/cmaf/:size").get(|r| proxy(r, "https://v.redd.it/{id}/CMAF_{size}").boxed());
app.at("/hls/:id/*path").get(|r| proxy(r, "https://v.redd.it/{id}/{path}").boxed());
app.at("/img/*path").get(|r| proxy(r, "https://i.redd.it/{path}").boxed());
app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
Expand All @@ -276,6 +280,7 @@ async fn main() {
app.at("/preview/:loc/:id").get(|r| proxy(r, "https://{loc}view.redd.it/{id}").boxed());
app.at("/style/*path").get(|r| proxy(r, "https://styles.redditmedia.com/{path}").boxed());
app.at("/static/*path").get(|r| proxy(r, "https://www.redditstatic.com/{path}").boxed());
app.at("/giphy/:id/:ext").get(|r| proxy(r, "https://media.giphy.com/media/{id}/giphy.{ext}").boxed());

// Browse user profile
app
Expand Down Expand Up @@ -372,7 +377,7 @@ async fn main() {
Box::pin(async move {
let sub = req.param("sub").unwrap_or_default();
match req.param("id").as_deref() {
// Share link
// Subreddit post share link
Some(id) if (8..12).contains(&id.len()) => match canonical_path(format!("/r/{sub}/s/{id}"), 3).await {
Ok(Some(path)) => Ok(redirect(&path)),
Ok(None) => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,
Expand All @@ -384,6 +389,22 @@ async fn main() {
}
})
});
app.at("/u/:name/s/:id").get(|req: Request<Body>| {
Box::pin(async move {
let name = req.param("name").unwrap_or_default();
match req.param("id").as_deref() {
// User post share link
Some(id) if (8..12).contains(&id.len()) => match canonical_path(format!("/u/{name}/s/{id}"), 3).await {
Ok(Some(path)) => Ok(redirect(&path)),
Ok(None) => error(req, "Post ID is invalid. It may point to a post on a user that has been banned.").await,
Err(e) => error(req, &e).await,
},

// Error message for unknown pages
_ => error(req, "Nothing here").await,
}
})
});

app.at("/:id").get(|req: Request<Body>| {
Box::pin(async move {
Expand Down
63 changes: 25 additions & 38 deletions src/subreddit.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -12,7 +13,7 @@ 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};

Expand Down Expand Up @@ -67,7 +68,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
let subscribed = setting(&req, "subscriptions");
let front_page = setting(&req, "front_page");
let remove_default_feeds = setting(&req, "remove_default_feeds") == "on";
let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string());
let post_sort = setting(&req, "post_sort");
let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort));

let sub_name = req.param("sub").unwrap_or(if front_page == "default" || front_page.is_empty() {
Expand Down Expand Up @@ -167,8 +168,8 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
let no_posts = posts.is_empty();
let all_posts_hidden_nsfw = !no_posts && (posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on");
if sort == "new" {
posts.sort_by(|a, b| b.created_ts.cmp(&a.created_ts));
posts.sort_by(|a, b| b.flags.stickied.cmp(&a.flags.stickied));
posts.sort_by_key(|post| std::cmp::Reverse(post.created_ts));
posts.sort_by_key(|post| std::cmp::Reverse(post.flags.stickied));
}
Ok(template(&SubredditTemplate {
sub,
Expand Down Expand Up @@ -650,34 +651,24 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, String> {

// Set enclosure image for RSS feed item
fn apply_enclosure(item: &mut Item, post: &Post) {
item.set_enclosure(get_rss_image(&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()
)
);
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,
);
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()),
"gallery" => post.gallery.first().and_then(|media| decode_html(&media.url).ok()),
"gif" | "video" => decode_html(&post.media.poster).ok(),
_ => None,
};
Expand All @@ -694,25 +685,21 @@ fn get_rss_image(post: &Post) -> Option<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",
}
// 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)]
Expand Down
31 changes: 27 additions & 4 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ static REGEX_URL_WWW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://w
static REGEX_URL_OLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://old\.reddit\.com/(.*)").unwrap());
static REGEX_URL_NP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://np\.reddit\.com/(.*)").unwrap());
static REGEX_URL_PLAIN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://reddit\.com/(.*)").unwrap());
static REGEX_URL_VIDEOS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$|\?source=fallback))").unwrap());
static REGEX_URL_VIDEOS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://v\.redd\.it/(.*)/(DASH|CMAF)_([0-9]{2,4}(\.mp4|$|\?source=fallback))").unwrap());
static REGEX_URL_VIDEOS_HLS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$").unwrap());
static REGEX_URL_IMAGES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://i\.redd\.it/(.*)").unwrap());
static REGEX_URL_THUMBS_A: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://a\.thumbs\.redditmedia\.com/(.*)").unwrap());
Expand All @@ -1027,6 +1027,7 @@ pub fn format_url(url: &str) -> String {
regex.captures(url).map_or(String::new(), |caps| match segments {
1 => [format, &caps[1]].join(""),
2 => [format, &caps[1], "/", &caps[2]].join(""),
3 => [format, &caps[1], "/", caps[2].to_lowercase().as_str(), "/", &caps[3]].join(""),
_ => String::new(),
})
};
Expand Down Expand Up @@ -1057,7 +1058,7 @@ pub fn format_url(url: &str) -> String {
"old.reddit.com" => capture(&REGEX_URL_OLD, "/", 1),
"np.reddit.com" => capture(&REGEX_URL_NP, "/", 1),
"reddit.com" => capture(&REGEX_URL_PLAIN, "/", 1),
"v.redd.it" => chain!(capture(&REGEX_URL_VIDEOS, "/vid/", 2), capture(&REGEX_URL_VIDEOS_HLS, "/hls/", 2)),
"v.redd.it" => chain!(capture(&REGEX_URL_VIDEOS, "/vid/", 3), capture(&REGEX_URL_VIDEOS_HLS, "/hls/", 2)),
"i.redd.it" => capture(&REGEX_URL_IMAGES, "/img/", 1),
"a.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_A, "/thumb/a/", 1),
"b.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_B, "/thumb/b/", 1),
Expand Down Expand Up @@ -1158,6 +1159,24 @@ pub fn rewrite_urls(input_text: &str) -> String {
}
}

// Match Giphy URLs in comment anchor tags, capturing the GIF ID
// Handles: giphy.com/gifs/ID, media.giphy.com/media/ID, i.giphy.com/ID
static GIPHY_EMBED_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)<a\s+href="https?://(?:www\.)?(?:giphy\.com/(?:gifs|clips)|media\.giphy\.com/media|i\.giphy\.com)/([a-z0-9]+)[^"]*"[^>]*>[^<]*</a>"#).unwrap()
});

/// Rewrite Giphy URLs in comment body to embedded video elements
fn rewrite_giphy_links(comment: &str) -> String {
GIPHY_EMBED_REGEX
.replace_all(comment, |caps: &regex::Captures| {
let id = &caps[1];
format!(
r#"<div class="giphy-embed-container"><a href="/giphy/{id}/gif"><video class="giphy-embed" loop poster="/giphy/{id}/gif"><source src="/giphy/{id}/mp4" type="video/mp4"></video></a></div>"#
)
})
.to_string()
}

// These links all follow a pattern of "https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/SUBREDDIT_ID/RANDOM_FILENAME.png"
static REDDIT_EMOTE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/(.*)"#).unwrap());

Expand Down Expand Up @@ -1236,7 +1255,10 @@ pub fn rewrite_emotes(media_metadata: &Value, comment: String) -> String {
comment = render_bullet_lists(&comment);

// Call rewrite_urls() to transform any other Reddit links
rewrite_urls(&comment)
let comment = rewrite_urls(&comment);

// Rewrite Giphy links to embedded videos
rewrite_giphy_links(&comment)
}

/// Format vote count to a string that will be displayed.
Expand Down Expand Up @@ -1501,7 +1523,8 @@ mod tests {
format_url("https://preview.redd.it/qwerty.jpg?auto=webp&s=asdf"),
"/preview/pre/qwerty.jpg?auto=webp&s=asdf"
);
assert_eq!(format_url("https://v.redd.it/foo/DASH_360.mp4?source=fallback"), "/vid/foo/360.mp4");
assert_eq!(format_url("https://v.redd.it/foo/DASH_360.mp4?source=fallback"), "/vid/foo/dash/360.mp4");
assert_eq!(format_url("https://v.redd.it/foo/CMAF_720.mp4?source=fallback"), "/vid/foo/cmaf/720.mp4");
assert_eq!(
format_url("https://v.redd.it/foo/HLSPlaylist.m3u8?a=bar&v=1&f=sd"),
"/hls/foo/HLSPlaylist.m3u8?a=bar&v=1&f=sd"
Expand Down
78 changes: 78 additions & 0 deletions static/downloadGallery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Usage: downloadGallery(images, baseName)

(function() {
// Add js-enabled class for CSS detection
document.body.classList.add('js-enabled');

window.downloadGallery = async function(images, baseName) {
// Request permission to download multiple files
// This will show a browser prompt asking for permission
try {
// Create a single reusable download link
const a = document.createElement('a');
a.style.display = 'none';
document.body.appendChild(a);

// Use the File System Access API if available, otherwise fall back to individual downloads
for (let i = 0; i < images.length; i++) {
const imageUrl = images[i];
const filename = `${baseName}_${i + 1}.jpg`;

// Fetch the image as a blob
const response = await fetch(imageUrl);
const blob = await response.blob();

// Reuse the same link element
const url = URL.createObjectURL(blob);
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);

// Small delay between downloads to avoid overwhelming the browser
if (i < images.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}

document.body.removeChild(a);
} catch (error) {
console.error('Error downloading gallery:', error);
alert('Failed to download some images. Please try downloading them individually.');
}
};

// Attach click handlers to gallery download links
function attachDownloadHandlers() {
document.querySelectorAll('.download_gallery > a').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();

// Find all gallery images
const post = link.closest('.post');
if (!post) return;

const gallery = post.querySelector('.gallery');
if (!gallery) return;

const images = [];
gallery.querySelectorAll('figure img').forEach(function(img) {
images.push(img.src);
});

if (images.length === 0) return;

// Get base name from data attribute
const baseName = link.getAttribute('data-base-name') || 'redlib_gallery';

downloadGallery(images, baseName);
});
});
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attachDownloadHandlers);
} else {
attachDownloadHandlers();
}
})();
13 changes: 12 additions & 1 deletion static/playHLSVideo.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,21 @@
videoElement.parentNode.appendChild(qualitySelector);
}

// IntersectionObserver to autoplay/pause video based on visibility
var autoplayObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
newVideo.play();
} else {
newVideo.pause();
}
});
}, { threshold: 0.5 }); // Adjust the threshold as needed

newVideo.addEventListener('play', initializeHls);

if (autoplay) {
newVideo.play();
autoplayObserver.observe(newVideo);
}
});
} else {
Expand Down
Loading
Loading