Skip to content

Commit 057d3e3

Browse files
committed
feat(video): add RedGifs video support with proxy
Add support for RedGifs videos embedded in Reddit posts. Videos are proxied through redlib for privacy, similar to v.redd.it handling. Features: - Detect RedGifs posts and proxy videos through /redgifs/ endpoint - Two-step flow: video ID lookup via API, then proxy video file - Token caching with 24h expiry for RedGifs API authentication - Prefer HD quality, fallback to SD automatically - Lazy loading with preload="none" to save bandwidth Security: - Strict domain validation (only legitimate redgifs.com domains) - File extension validation (only .mp4 files) - Query parameter stripping from video IDs - Pattern matching for all versioned CDN subdomains (v1, v2, etc.) Implementation: - New redgifs module with API integration - Reuses existing proxy infrastructure - Domain validation helper for consistent security checks
1 parent 2dc6b5f commit 057d3e3

File tree

5 files changed

+122
-3
lines changed

5 files changed

+122
-3
lines changed

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod instance_info;
55
pub mod oauth;
66
pub mod oauth_resources;
77
pub mod post;
8+
pub mod redgifs;
89
pub mod search;
910
pub mod server;
1011
pub mod settings;

src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ async fn main() {
278278
app.at("/preview/:loc/:id").get(|r| proxy(r, "https://{loc}view.redd.it/{id}").boxed());
279279
app.at("/style/*path").get(|r| proxy(r, "https://styles.redditmedia.com/{path}").boxed());
280280
app.at("/static/*path").get(|r| proxy(r, "https://www.redditstatic.com/{path}").boxed());
281+
282+
// RedGifs proxy with lazy loading
283+
app.at("/redgifs/*path").get(|req| redlib::redgifs::handler(req).boxed());
281284

282285
// Browse user profile
283286
app

src/redgifs.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
use hyper::{Body, Request, Response};
2+
use serde_json::Value;
3+
use std::sync::LazyLock;
4+
5+
use crate::client::{proxy, CLIENT};
6+
use crate::server::RequestExt;
7+
8+
// RedGifs token cache: (token, expiry_timestamp)
9+
static REDGIFS_TOKEN: LazyLock<std::sync::Mutex<(String, i64)>> = LazyLock::new(|| std::sync::Mutex::new((String::new(), 0)));
10+
11+
/// Check if a domain is a legitimate RedGifs domain
12+
pub fn is_redgifs_domain(domain: &str) -> bool {
13+
domain == "redgifs.com" || domain == "www.redgifs.com" || domain.ends_with(".redgifs.com")
14+
}
15+
16+
/// HTTP handler for /redgifs/* routes
17+
/// Handles both video IDs (redirects) and actual video files (proxies)
18+
pub async fn handler(req: Request<Body>) -> Result<Response<Body>, String> {
19+
let path = req.param("path").unwrap_or_default();
20+
21+
// If path ends with .mp4, it's the actual video file - proxy it directly
22+
if path.ends_with(".mp4") {
23+
return proxy(req, &format!("https://media.redgifs.com/{}", path)).await;
24+
}
25+
26+
// Otherwise it's a video ID - fetch from RedGifs API and redirect
27+
match fetch_video_url(&format!("https://www.redgifs.com/watch/{}", path)).await.ok() {
28+
Some(video_url) => {
29+
let filename = video_url.strip_prefix("https://media.redgifs.com/").unwrap_or(&video_url);
30+
Ok(Response::builder()
31+
.status(302)
32+
.header("Location", format!("/redgifs/{}", filename))
33+
.body(Body::empty())
34+
.unwrap_or_default())
35+
}
36+
None => Ok(Response::builder().status(404).body("RedGifs video not found".into()).unwrap_or_default()),
37+
}
38+
}
39+
40+
/// Fetches the HD video URL with audio from RedGifs API
41+
async fn fetch_video_url(redgifs_url: &str) -> Result<String, String> {
42+
// Extract video ID from URL (e.g., "firstawkwardrhinoceros" from watch/firstawkwardrhinoceros)
43+
let video_id = redgifs_url
44+
.split('/')
45+
.last()
46+
.and_then(|s| s.split('?').next())
47+
.ok_or("Invalid RedGifs URL")?;
48+
49+
let token = get_token().await?;
50+
let api_url = format!("https://api.redgifs.com/v2/gifs/{}?views=yes", video_id);
51+
52+
let req = create_request(&api_url, Some(&token))?;
53+
let res = CLIENT.request(req).await.map_err(|e| e.to_string())?;
54+
let body_bytes = hyper::body::to_bytes(res.into_body()).await.map_err(|e| e.to_string())?;
55+
let json: Value = serde_json::from_slice(&body_bytes).map_err(|e| e.to_string())?;
56+
57+
// Prefer HD, fallback to SD
58+
let hd_url = json["gif"]["urls"]["hd"].as_str();
59+
let sd_url = json["gif"]["urls"]["sd"].as_str();
60+
61+
hd_url
62+
.or(sd_url)
63+
.map(String::from)
64+
.ok_or_else(|| "No video URL in RedGifs response".to_string())
65+
}
66+
67+
async fn get_token() -> Result<String, String> {
68+
let now = std::time::SystemTime::now()
69+
.duration_since(std::time::UNIX_EPOCH)
70+
.map_err(|_| "Time error")?
71+
.as_secs() as i64;
72+
73+
// Return cached token if still valid (without holding lock across await)
74+
{
75+
let cache = REDGIFS_TOKEN.lock().map_err(|_| "Lock error")?;
76+
if !cache.0.is_empty() && now < cache.1 {
77+
return Ok(cache.0.clone());
78+
}
79+
}
80+
81+
// Fetch new token
82+
let req = create_request("https://api.redgifs.com/v2/auth/temporary", None)?;
83+
let res = CLIENT.request(req).await.map_err(|e| e.to_string())?;
84+
let body_bytes = hyper::body::to_bytes(res.into_body()).await.map_err(|e| e.to_string())?;
85+
let json: Value = serde_json::from_slice(&body_bytes).map_err(|e| e.to_string())?;
86+
let token = json["token"].as_str().map(String::from).ok_or_else(|| "No token in RedGifs response".to_string())?;
87+
88+
// Cache the token
89+
let mut cache = REDGIFS_TOKEN.lock().map_err(|_| "Lock error")?;
90+
cache.0 = token.clone();
91+
cache.1 = now + 86000; // 24h - 400s buffer
92+
Ok(token)
93+
}
94+
95+
fn create_request(url: &str, token: Option<&str>) -> Result<Request<Body>, String> {
96+
let mut builder = hyper::Request::get(url)
97+
.header("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
98+
.header("referer", "https://www.redgifs.com/")
99+
.header("origin", "https://www.redgifs.com")
100+
.header("content-type", "application/json");
101+
102+
if let Some(t) = token {
103+
builder = builder.header("Authorization", format!("Bearer {}", t));
104+
}
105+
106+
builder.body(Body::empty()).map_err(|e| e.to_string())
107+
}

src/utils.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use libflate::deflate::{Decoder, Encoder};
1313
use log::error;
1414
use regex::Regex;
1515
use revision::revisioned;
16+
use crate::redgifs;
1617
use rust_embed::RustEmbed;
1718
use serde::{Deserialize, Deserializer, Serialize, Serializer};
1819
use serde_json::Value;
@@ -194,8 +195,11 @@ impl Media {
194195
let secure_media = &data["secure_media"]["reddit_video"];
195196
let crosspost_parent_media = &data["crosspost_parent_list"][0]["secure_media"]["reddit_video"];
196197

197-
// If post is a video, return the video
198-
let (post_type, url_val, alt_url_val) = if data_preview["fallback_url"].is_string() {
198+
// Check RedGifs FIRST before Reddit's cached fallback videos, then other video sources
199+
let domain = data["domain"].as_str().unwrap_or_default();
200+
let (post_type, url_val, alt_url_val) = if redgifs::is_redgifs_domain(domain) {
201+
("video", &data["url"], None)
202+
} else if data_preview["fallback_url"].is_string() {
199203
(
200204
if data_preview["is_gif"].as_bool().unwrap_or(false) { "gif" } else { "video" },
201205
&data_preview["fallback_url"],
@@ -1017,6 +1021,7 @@ static REGEX_URL_PREVIEW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?
10171021
static REGEX_URL_EXTERNAL_PREVIEW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://external\-preview\.redd\.it/(.*)").unwrap());
10181022
static REGEX_URL_STYLES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://styles\.redditmedia\.com/(.*)").unwrap());
10191023
static REGEX_URL_STATIC_MEDIA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://www\.redditstatic\.com/(.*)").unwrap());
1024+
static REGEX_URL_REDGIFS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://(?:www\.|v\d+\.)?redgifs\.com/watch/([^?#]*)").unwrap());
10201025

10211026
/// Direct urls to proxy if proxy is enabled
10221027
pub fn format_url(url: &str) -> String {
@@ -1069,6 +1074,9 @@ pub fn format_url(url: &str) -> String {
10691074
"external-preview.redd.it" => capture(&REGEX_URL_EXTERNAL_PREVIEW, "/preview/external-pre/", 1),
10701075
"styles.redditmedia.com" => capture(&REGEX_URL_STYLES, "/style/", 1),
10711076
"www.redditstatic.com" => capture(&REGEX_URL_STATIC_MEDIA, "/static/", 1),
1077+
"www.redgifs.com" => capture(&REGEX_URL_REDGIFS, "/redgifs/", 1),
1078+
"redgifs.com" => capture(&REGEX_URL_REDGIFS, "/redgifs/", 1),
1079+
d if d.starts_with("v") && d.ends_with(".redgifs.com") => capture(&REGEX_URL_REDGIFS, "/redgifs/", 1),
10721080
_ => url.to_string(),
10731081
}
10741082
})

templates/utils.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ <h1 class="post_title">
132132
<script src="/playHLSVideo.js"></script>
133133
{% else %}
134134
<div class="post_media_content">
135-
<video class="post_media_video" src="{{ post.media.url }}" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
135+
<video class="post_media_video" src="{{ post.media.url }}" preload="metadata" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
136136
</div>
137137
{% call render_hls_notification(post.permalink[1..]) %}
138138
{% endif %}

0 commit comments

Comments
 (0)