//! 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 → 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::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 { 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::(&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::(&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 = Lazy::new(|| Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap()); static SESS_RE_VUE: Lazy = Lazy::new(|| Regex::new(r#"sess_id['"\s:]+['"]([a-zA-Z0-9]+)['"]"#).unwrap()); static SRV_RE: Lazy = Lazy::new(|| Regex::new(r#"srv_url['"\s:]+['"]?(https?://[^'">\s]+)"#).unwrap()); static FN_RE: Lazy = Lazy::new(|| Regex::new(r#"name=["']fn["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap()); static DL_RE: Lazy = Lazy::new(|| Regex::new(r#"https?://[a-z0-9.]+/d/([a-zA-Z0-9]+)"#).unwrap()); fn extract_sess_id(html: &str) -> Option { 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 { // JSON shape? if let Ok(v) = serde_json::from_str::(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> + 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), } } }) }