- 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.
225 lines
8.9 KiB
Rust
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),
|
|
}
|
|
}
|
|
})
|
|
}
|