Skip to content

Commit 33eacc0

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 33eacc0

File tree

5 files changed

+114
-3
lines changed

5 files changed

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

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)