From 5ba16ab3389a2424e2479f2d07ad274f0471c2c9 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 11 May 2026 13:32:08 +0000 Subject: [PATCH] sxyprn fixes-ish --- src/providers/sxyprn.rs | 53 +++--- src/proxies/lulustream.rs | 18 +- src/proxies/sxyprn.rs | 1 - src/proxy.rs | 7 + src/util/dean_edwards.rs | 334 ++++++++++++++++++++++++++++++++++++++ src/util/mod.rs | 1 + 6 files changed, 380 insertions(+), 34 deletions(-) create mode 100644 src/util/dean_edwards.rs diff --git a/src/providers/sxyprn.rs b/src/providers/sxyprn.rs index 72e4fc9..369895b 100644 --- a/src/providers/sxyprn.rs +++ b/src/providers/sxyprn.rs @@ -476,6 +476,15 @@ impl SxyprnProvider { let mut formats = vec![]; // Add sxyprn format + let sxyprn_url = format!( + "{}/proxy/sxyprn/post/{}", + options.public_url_base.as_deref().unwrap_or(""), + id + ); + formats.push( + VideoFormat::new(sxyprn_url.clone(), "auto".to_string(), "mp4".to_string()) + .format_note(sxyprn_url.split("/").nth(4).unwrap_or("sxyprn").to_string()), + ); let doodstream_urls: Vec = title_links .iter() @@ -491,32 +500,24 @@ impl SxyprnProvider { ); } - let lulustream_urls: Vec = title_links - .iter() - .filter(|url| proxy_name_for_url(url).as_deref() == Some("lulustream")) - .map(|url| rewrite_hoster_url(options, url)) - .collect(); + // let lulustream_urls: Vec = title_links + // .iter() + // .filter(|url| proxy_name_for_url(url).as_deref() == Some("lulustream")) + // .map(|url| rewrite_hoster_url(options, url)) + // .collect(); - for lulustream_url in lulustream_urls { - formats.push( - VideoFormat::m3u8( - lulustream_url.clone(), - "auto".to_string(), - "m3u8".to_string(), - ) - .format_note("lulustream".to_string()) - .format_id("lulustream".to_string()), - ); - } - let sxyprn_url = format!( - "{}/proxy/sxyprn/post/{}", - options.public_url_base.as_deref().unwrap_or(""), - id - ); - formats.push( - VideoFormat::new(sxyprn_url.clone(), "auto".to_string(), "mp4".to_string()) - .format_note(sxyprn_url.split("/").nth(4).unwrap_or("sxyprn").to_string()), - ); + // for lulustream_url in lulustream_urls { + // formats.push( + // VideoFormat::m3u8( + // lulustream_url.clone(), + // "auto".to_string(), + // "m3u8".to_string(), + // ) + // .format_note("lulustream".to_string()) + // .format_id("lulustream".to_string()), + // ); + // } + // Also collect and transform vidara.so URLs to proxy format and add as formats let vidara_urls: Vec = title_links .iter() @@ -534,7 +535,7 @@ impl SxyprnProvider { let mut video_item = VideoItem::new( id.clone(), title, - format!("{}/post/{}", self.url, id.clone()), + url.clone(), "sxyprn".to_string(), thumb, duration, diff --git a/src/proxies/lulustream.rs b/src/proxies/lulustream.rs index 8420372..565a749 100644 --- a/src/proxies/lulustream.rs +++ b/src/proxies/lulustream.rs @@ -2,7 +2,7 @@ use ntex::web; use url::Url; use serde_json::json; -use crate::util::requester::Requester; +use crate::util::{dean_edwards, requester::Requester}; #[derive(Debug, Clone)] pub struct LulustreamProxy {} @@ -51,7 +51,7 @@ impl LulustreamProxy { return false; }; (host == "lulustream.com" || host == "www.lulustream.com" || host == "luluvdo.com") - && (parsed.path().starts_with("/v/")||parsed.path().starts_with("/e/")) + && !parsed.path().is_empty() && parsed.path() != "/" } pub async fn get_video_url( @@ -64,15 +64,19 @@ impl LulustreamProxy { println!("LulustreamProxy: Invalid detail URL: {url}"); return String::new(); }; - let text = requester.get(&detail_url, None).await.unwrap_or_default(); - let video_url = text.split("sources: [{file:\"") + let mut text = requester.get(&detail_url, None).await.unwrap_or_default(); + if !text.contains("[{file:\"") { + let packedtext = text.split("").next()).unwrap_or_default(); + println!("LulustreamProxy: Found packed text: {packedtext}"); + text = dean_edwards::unpack(&packedtext).unwrap_or_default(); + println!("LulustreamProxy: Unpacked text: {text}"); + } + let video_url = text.split("[{file:\"") .nth(1) .and_then(|s| s.split('"').next()) .unwrap_or_default() .to_string(); - if video_url.is_empty() { - println!("LulustreamProxy: Failed to extract video URL for video ID: {video_id}"); - } + println!("LulustreamProxy: Extracted video URL: {video_url}"); video_url } } diff --git a/src/proxies/sxyprn.rs b/src/proxies/sxyprn.rs index ce98671..4d1c613 100644 --- a/src/proxies/sxyprn.rs +++ b/src/proxies/sxyprn.rs @@ -75,7 +75,6 @@ impl SxyprnProxy { Ok(None) => println!("No redirect found for {}", sxyprn_video_url), Err(e) => eprintln!("Request failed: {}", e), } - return "".to_string(); } } diff --git a/src/proxy.rs b/src/proxy.rs index 24469f9..8ea3135 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -13,6 +13,7 @@ use crate::proxies::spankbang::SpankbangProxy; use crate::proxies::sxyprn::SxyprnProxy; use crate::proxies::vjav::VjavProxy; use crate::proxies::vidara::VidaraProxy; +use crate::proxies::lulustream::LulustreamProxy; use crate::proxies::*; use crate::util::requester::Requester; @@ -27,6 +28,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(proxy2redirect)) .route(web::get().to(proxy2redirect)), ) + .service( + web::resource("/lulustream/{endpoint}*") + .route(web::post().to(proxy2redirect)) + .route(web::get().to(proxy2redirect)), + ) .service( web::resource("/sxyprn/{endpoint}*") .route(web::post().to(proxy2redirect)) @@ -149,6 +155,7 @@ fn get_proxy(proxy: &str) -> Option { "pimpbunny" => Some(AnyProxy::Pimpbunny(PimpbunnyProxy::new())), "porndish" => Some(AnyProxy::Porndish(PorndishProxy::new())), "spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::new())), + "lulustream" => Some(AnyProxy::Lulustream(LulustreamProxy::new())), _ => None, } } diff --git a/src/util/dean_edwards.rs b/src/util/dean_edwards.rs new file mode 100644 index 0000000..138deb4 --- /dev/null +++ b/src/util/dean_edwards.rs @@ -0,0 +1,334 @@ +/// Dean Edwards p,a,c,k,e,d unpacker. +/// +/// Mirrors the original JS decoder: +/// while(c--) if(k[c]) p = p.replace(/\b{c.toString(a)}\b/g, k[c]); +/// +/// Usage: +/// let source = r#"eval(function(p,a,c,k,e,d){...}('...',36,N,'w0|w1|...'.split('|'),0,{}))"#; +/// let plain = unpack(source)?; + +use std::fmt; + +// ── Error type ──────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum UnpackError { + NotPacked, + MalformedArgs(String), + BadBase(u32), +} + +impl fmt::Display for UnpackError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotPacked => write!(f, "input does not look like a packed script"), + Self::MalformedArgs(s) => write!(f, "could not parse packed arguments: {s}"), + Self::BadBase(b) => write!(f, "unsupported base {b} (supported: 2–62)"), + } + } +} + +impl std::error::Error for UnpackError {} + +// ── Public entry point ──────────────────────────────────────────────────────── + +/// Detect, parse, and unpack a Dean Edwards packed script. +/// Returns the deobfuscated source on success. +pub fn unpack(input: &str) -> Result { + let args = extract_args(input.trim())?; + decode(args) +} + +// ── Argument extraction ─────────────────────────────────────────────────────── + +struct PackedArgs { + payload: String, // p + base: u32, // a + words: Vec, // k (already split) +} + +/// Find the argument list of the outer `function(p,a,c,k,e,d){...}(...)` call +/// and parse p, a, c, k from it. +fn extract_args(input: &str) -> Result { + // Locate the opening of the argument tuple: the '(' that directly follows + // the closing '}' of the function body. + let body_end = input + .find("}('") // most common: }('payload',… + .or_else(|| input.find("}(\"")) + .ok_or(UnpackError::NotPacked)?; + + // args_start points at '(' – skip it to reach the opening quote of the payload. + let args_start = body_end + 1; + let args_str = input[args_start..].trim_start_matches('('); + + // Pull the payload string (first argument). + let (payload, after_payload) = parse_js_string(args_str) + .ok_or_else(|| UnpackError::MalformedArgs("could not read payload string".into()))?; + + // Expect ',base,count,' + let rest = after_payload + .trim_start_matches(','); + + let (base_str, rest) = rest + .split_once(',') + .ok_or_else(|| UnpackError::MalformedArgs("missing base".into()))?; + + let base: u32 = base_str + .trim() + .parse() + .map_err(|_| UnpackError::MalformedArgs(format!("bad base: {base_str}")))?; + + let (count_str, rest) = rest + .split_once(',') + .ok_or_else(|| UnpackError::MalformedArgs("missing count".into()))?; + + let count: usize = count_str + .trim() + .parse() + .map_err(|_| UnpackError::MalformedArgs(format!("bad count: {count_str}")))?; + + // Parse the dictionary k. Two common forms: + // 'w0|w1|w2'.split('|') + // ["w0","w1","w2"] + let words = parse_dictionary(rest.trim(), count) + .ok_or_else(|| UnpackError::MalformedArgs("could not parse dictionary".into()))?; + + Ok(PackedArgs { payload, base, words }) +} + +// ── Dictionary parser ───────────────────────────────────────────────────────── + +fn parse_dictionary(s: &str, count: usize) -> Option> { + if s.starts_with('\'') || s.starts_with('"') { + // 'w0|w1|…'.split('|') – separator can be any single char + let (joined, rest) = parse_js_string(s)?; + // find .split('') + let sep_start = rest.find(".split(")?; + let after_split = &rest[sep_start + 7..]; // skip `.split(` + let (sep, _) = parse_js_string(after_split.trim())?; + let sep_char = if sep.is_empty() { '|' } else { sep.chars().next().unwrap() }; + let words: Vec = joined.split(sep_char).map(str::to_owned).collect(); + Some(pad_to(words, count)) + } else if s.starts_with('[') { + // ["w0","w1",…] + let end = s.find(']')?; + let inner = &s[1..end]; + let words = parse_array_literal(inner); + Some(pad_to(words, count)) + } else { + None + } +} + +/// Parse a JS array literal (no nesting needed for the k array). +fn parse_array_literal(s: &str) -> Vec { + let mut words = Vec::new(); + let mut rest = s.trim(); + loop { + rest = rest.trim_start_matches(',').trim(); + if rest.is_empty() { break; } + if rest.starts_with('\'') || rest.starts_with('"') { + if let Some((w, after)) = parse_js_string(rest) { + words.push(w); + rest = after.trim(); + } else { + break; + } + } else { + // empty slot → push empty string + if let Some(pos) = rest.find(',') { + words.push(String::new()); + rest = &rest[pos..]; + } else { + break; + } + } + } + words +} + +fn pad_to(mut v: Vec, n: usize) -> Vec { + v.resize(n, String::new()); + v +} + +// ── JS string parser ────────────────────────────────────────────────────────── + +/// Parse a single-quoted or double-quoted JS string literal at the start of `s`. +/// Returns (unescaped content, remainder after closing quote). +fn parse_js_string(s: &str) -> Option<(String, &str)> { + let mut chars = s.char_indices(); + let (_, quote) = chars.next()?; + if quote != '\'' && quote != '"' { return None; } + + let mut result = String::new(); + let mut escaped = false; + + for (i, ch) in chars { + if escaped { + match ch { + 'n' => result.push('\n'), + 'r' => result.push('\r'), + 't' => result.push('\t'), + '\\' => result.push('\\'), + '\'' => result.push('\''), + '"' => result.push('"'), + _ => { result.push('\\'); result.push(ch); } + } + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == quote { + return Some((result, &s[i + ch.len_utf8()..])); + } else { + result.push(ch); + } + } + None // unclosed string +} + +// ── Number → string for arbitrary base ─────────────────────────────────────── + +/// JavaScript's `Number.prototype.toString(radix)` for bases 2–62. +/// Digits: 0-9, then a-z (10-35), then A-Z (36-61). +fn num_to_base_str(mut n: usize, base: u32) -> Result { + if base < 2 || base > 62 { + return Err(UnpackError::BadBase(base)); + } + if n == 0 { return Ok("0".to_owned()); } + + const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let base = base as usize; + let mut buf = Vec::new(); + while n > 0 { + buf.push(DIGITS[n % base] as char); + n /= base; + } + buf.reverse(); + Ok(buf.into_iter().collect()) +} + +// ── Core decode ─────────────────────────────────────────────────────────────── + +/// JS regex `\b` word-boundary replacement. +/// We use a hand-rolled matcher so we don't need an external crate. +fn replace_word_boundary(haystack: &str, needle: &str, replacement: &str) -> String { + if needle.is_empty() { return haystack.to_owned(); } + + let h: Vec = haystack.chars().collect(); + let n: Vec = needle.chars().collect(); + let nlen = n.len(); + let hlen = h.len(); + + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_'; + + let mut out = String::with_capacity(haystack.len()); + let mut i = 0; + + while i <= hlen.saturating_sub(nlen) { + // Check if h[i..i+nlen] == needle + if h[i..i + nlen] == n[..] { + // Word-boundary checks + let left_ok = i == 0 || !is_word(h[i - 1]); + let right_ok = i + nlen == hlen || !is_word(h[i + nlen]); + + if left_ok && right_ok { + out.push_str(replacement); + i += nlen; + continue; + } + } + out.push(h[i]); + i += 1; + } + // Append whatever is left + for ch in &h[i..] { out.push(*ch); } + out +} + +fn decode(args: PackedArgs) -> Result { + let PackedArgs { mut payload, base, words } = args; + + // Validate base once up front. + if base < 2 || base > 62 { + return Err(UnpackError::BadBase(base)); + } + + // Mirror the JS: for c from (words.len()-1) down to 0 + let count = words.len(); + for c in (0..count).rev() { + if words[c].is_empty() { continue; } + let key = num_to_base_str(c, base)?; + payload = replace_word_boundary(&payload, &key, &words[c]); + } + + Ok(payload) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal self-contained packed snippet (base 36, 3 words). + /// Original source: `var hello = world + foo;` + /// Packed manually for the test: + /// payload = "var 1 = 2 + 0;" + /// base = 36 + /// count = 3 + /// words = ["foo", "hello", "world"] (indices 0,1,2) + #[test] + fn test_basic_unpack() { + // Build a fake packed string without the eval wrapper for direct testing. + let args = PackedArgs { + payload: "var 1 = 2 + 0;".to_owned(), + base: 36, + words: vec!["foo".to_owned(), "hello".to_owned(), "world".to_owned()], + }; + let result = decode(args).unwrap(); + assert_eq!(result, "var hello = world + foo;"); + } + + #[test] + fn test_word_boundary() { + // "foo10bar" should NOT replace "10", but " 10 " should. + let result = replace_word_boundary("foo10bar baz 10 qux10", "10", "X"); + assert_eq!(result, "foo10bar baz X qux10"); + } + + #[test] + fn test_num_to_base_str() { + assert_eq!(num_to_base_str(0, 36).unwrap(), "0"); + assert_eq!(num_to_base_str(10, 36).unwrap(), "a"); + assert_eq!(num_to_base_str(35, 36).unwrap(), "z"); + assert_eq!(num_to_base_str(36, 36).unwrap(), "10"); + assert_eq!(num_to_base_str(255, 16).unwrap(), "ff"); + assert_eq!(num_to_base_str(7, 2).unwrap(), "111"); + } + + #[test] + fn test_parse_split_dictionary() { + let input = r#"'foo|bar|baz'.split('|'),0,{})"#; + let words = parse_dictionary(input, 3).unwrap(); + assert_eq!(words, vec!["foo", "bar", "baz"]); + } + + #[test] + fn test_parse_array_dictionary() { + let input = r#"["alpha","","gamma"],0,{})"#; + let words = parse_dictionary(input, 3).unwrap(); + assert_eq!(words[0], "alpha"); + assert_eq!(words[1], ""); + assert_eq!(words[2], "gamma"); + } + + /// Full round-trip with the eval wrapper (base 10, tiny example). + #[test] + fn test_full_eval_wrapper() { + // payload: "0 1 2" words: ["hello","world","rust"] base:10 count:3 + let packed = r#"eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(new RegExp('\\b'+c.toString(a)+'\\b','g'),k[c]);return p}('0 1 2',10,3,'hello|world|rust'.split('|'),0,{}))"#; + let result = unpack(packed).unwrap(); + assert_eq!(result, "hello world rust"); + } +} \ No newline at end of file diff --git a/src/util/mod.rs b/src/util/mod.rs index eafc127..b7fcc82 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -9,6 +9,7 @@ pub mod hoster_proxy; pub mod proxy; pub mod requester; pub mod time; +pub mod dean_edwards; pub fn parse_abbreviated_number(s: &str) -> Option { let s = s.trim();