Multi-Hoster-Upload-2/src-tauri/src/hosters/doodstream.rs
Claude c97c6b9469 Port Doodstream and VOE uploaders to Rust
- Doodstream: login_ajax + sess_id scrape from /?op=upload page +
    upload_server + multipart upload + XFS-style fn field + filecode
    extraction. Skips OTP path (v1 still has the full flow).
  - VOE: login page CSRF scrape + POST /login + fresh CSRF from
    /file-upload + /engine/delivery-node for CDN server + baseline
    my-files snapshot + multipart upload + file-list polling fallback
    when response is empty.

Both wire into the existing dispatcher (hosters::upload_file) and
pick up the same rotation/classifier layer as the other uploaders.

Release build clean: exe 7.0 MB, NSIS 2.5 MB, MSI 3.4 MB.
2026-04-20 17:14:09 +02:00

225 lines
8.9 KiB
Rust

//! Doodstream.com uploader. Port of `lib/doodstream-upload.js`.
//!
//! Flow (simplified — no OTP path in this revision):
//! 1. POST / op=login_ajax&login=…&password=… → sets session cookies
//! 2. GET /?op=upload → scrape `sess_id` from HTML
//! 3. GET /?op=upload_server → { result: "https://srv/upload/01" }
//! 4. POST <server> → multipart sess_id+utype=reg+file
//! 5. Response may be JSON or a filecode-bearing HTML; extract filecode.
use super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult};
use crate::events::UploadResult;
use bytes::Bytes;
use once_cell::sync::Lazy;
use regex::Regex;
use reqwest::{multipart, Body, Client};
use std::time::Duration;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
const BASE_URL: &str = "https://doodstream.com";
const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
fn build_client() -> AppResult<Client> {
Client::builder()
.timeout(Duration::from_secs(30 * 60))
.connect_timeout(Duration::from_secs(60))
.cookie_store(true)
.redirect(reqwest::redirect::Policy::limited(10))
.gzip(true)
.user_agent(UA)
.build()
.map_err(AppError::from)
}
pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult> {
if task.username.is_empty() || task.password.is_empty() {
return Err(AppError::BadCredentials);
}
let c = build_client()?;
// --- Login ---
let _ = c.get(BASE_URL).send().await; // warm up
let login_body = serde_urlencoded::to_string([
("op", "login_ajax"),
("login", task.username.as_str()),
("password", task.password.as_str()),
("loginotp", ""),
]).unwrap_or_default();
let res = c.post(format!("{BASE_URL}/"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Referer", format!("{BASE_URL}/"))
.header("X-Requested-With", "XMLHttpRequest")
.body(login_body)
.send()
.await?;
let status = res.status();
let body = res.text().await.unwrap_or_default();
// Redirect response (3xx auto-followed by client) or dashboard hit → login OK.
// Otherwise try JSON shape.
if !body.contains("Dashboard") {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(s) = v.get("status").and_then(|x| x.as_str()) {
if s == "fail" {
let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen");
if msg.to_lowercase().contains("otp") {
return Err(AppError::Other(format!("Doodstream: OTP erforderlich ({msg}) — OTP-Login in 2.0 POC noch nicht implementiert")));
}
return Err(AppError::BadCredentials);
}
}
} else if !status.is_redirection() && !status.is_success() {
return Err(AppError::BadCredentials);
}
}
// --- Extract sess_id from /?op=upload ---
let upload_page = c.get(format!("{BASE_URL}/?op=upload"))
.header("Referer", format!("{BASE_URL}/")).send().await?
.text().await.unwrap_or_default();
let sess_id = extract_sess_id(&upload_page)
.ok_or_else(|| AppError::BadResponse("Doodstream: sess_id nicht gefunden".into()))?;
// --- Get upload server ---
let srv_json = c.get(format!("{BASE_URL}/?op=upload_server"))
.header("Referer", format!("{BASE_URL}/?op=upload")).send().await?
.text().await.unwrap_or_default();
let server_url = match serde_json::from_str::<serde_json::Value>(&srv_json) {
Ok(v) => v.get("result").and_then(|x| x.as_str()).map(|s| s.to_string()),
Err(_) => None,
}.or_else(|| SRV_RE.captures(&upload_page).and_then(|c| c.get(1).map(|m| m.as_str().to_string())))
.ok_or_else(|| AppError::BadResponse("Doodstream: Upload-Server nicht erhalten".into()))?;
// --- POST multipart ---
let path = task.file_path.as_path();
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let file_size = tokio::fs::metadata(path).await?.len();
let file = File::open(path).await?;
let stream = progress_stream(file, file_size, ctx.clone());
let part = multipart::Part::stream_with_length(Body::wrap_stream(stream), file_size)
.file_name(file_name.clone())
.mime_str("application/octet-stream")
.map_err(|e| AppError::Other(format!("MIME: {e}")))?;
let form = multipart::Form::new()
.text("sess_id", sess_id)
.text("utype", "reg")
.part("file", part);
let up = c.post(&server_url)
.header("Referer", format!("{BASE_URL}/?op=upload"))
.multipart(form)
.send().await?;
let up_status = up.status();
let raw = up.text().await.unwrap_or_default();
if up_status.is_client_error() || up_status.is_server_error() {
return Err(AppError::HosterError("Doodstream".into(),
format!("Upload HTTP {}: {}", up_status.as_u16(), &raw[..raw.len().min(200)])));
}
parse_upload_response(&raw, &file_name, &c).await
}
static SESS_RE_INPUT: Lazy<Regex> = Lazy::new(||
Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap());
static SESS_RE_VUE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"sess_id['"\s:]+['"]([a-zA-Z0-9]+)['"]"#).unwrap());
static SRV_RE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"srv_url['"\s:]+['"]?(https?://[^'">\s]+)"#).unwrap());
static FN_RE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"name=["']fn["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap());
static DL_RE: Lazy<Regex> = Lazy::new(||
Regex::new(r#"https?://[a-z0-9.]+/d/([a-zA-Z0-9]+)"#).unwrap());
fn extract_sess_id(html: &str) -> Option<String> {
SESS_RE_INPUT.captures(html).or_else(|| SESS_RE_VUE.captures(html))
.and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
}
async fn parse_upload_response(raw: &str, _file_name: &str, client: &Client) -> AppResult<UploadResult> {
// JSON shape?
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
if let Some(code) = v.get("file_code").and_then(|x| x.as_str())
.or_else(|| v.pointer("/files/0/filecode").and_then(|x| x.as_str()))
.or_else(|| v.pointer("/result/0/filecode").and_then(|x| x.as_str()))
{
if !code.is_empty() {
return Ok(build_result(code));
}
}
}
// XFS-style: hidden input name="fn" holds the filecode. Submit op=upload_result
// to complete, then parse the follow-up for download URL.
if let Some(cap) = FN_RE.captures(raw) {
let fn_code = cap.get(1).unwrap().as_str();
// Submit upload_result
let body = serde_urlencoded::to_string([
("op", "upload_result"),
("fn", fn_code),
]).unwrap_or_default();
if let Ok(follow) = client.post(format!("{BASE_URL}/"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Referer", format!("{BASE_URL}/"))
.body(body).send().await
{
let follow_text = follow.text().await.unwrap_or_default();
if let Some(c) = DL_RE.captures(&follow_text) {
if let Some(m) = c.get(1) { return Ok(build_result(m.as_str())); }
}
}
if fn_code.len() >= 8 { return Ok(build_result(fn_code)); }
}
if let Some(c) = DL_RE.captures(raw) {
if let Some(m) = c.get(1) { return Ok(build_result(m.as_str())); }
}
Err(AppError::BadResponse(format!(
"Doodstream: Upload-Antwort ohne filecode ({}))",
&raw[..raw.len().min(240)]
)))
}
fn build_result(code: &str) -> UploadResult {
UploadResult {
download_url: Some(format!("https://dsvplay.com/d/{code}")),
embed_url: Some(format!("https://dsvplay.com/e/{code}")),
file_code: Some(code.to_string()),
}
}
fn progress_stream(
file: File,
total: u64,
ctx: UploadCtx,
) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
use futures::StreamExt;
let ctx1 = ctx.clone();
let ctx2 = ctx;
let mut acc: u64 = 0;
ReaderStream::with_capacity(file, 256 * 1024).then(move |chunk| {
let ctx_in = ctx1.clone();
let ctx_pr = ctx2.clone();
async move {
match chunk {
Ok(b) => {
if ctx_in.is_aborted() {
return Err(std::io::Error::new(std::io::ErrorKind::Other, "Aborted"));
}
ctx_in.throttle(b.len() as u64).await;
acc += b.len() as u64;
(ctx_pr.on_progress)(acc, total);
Ok(b)
}
Err(e) => Err(e),
}
}
})
}