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.
This commit is contained in:
parent
8627a8e694
commit
c97c6b9469
12
README.md
12
README.md
@ -49,8 +49,8 @@ Multi-Hoster-Upload-2.0/
|
|||||||
│ │ ├─ clouddrop.rs ✔ Full port (simple + chunked)
|
│ │ ├─ clouddrop.rs ✔ Full port (simple + chunked)
|
||||||
│ │ ├─ byse.rs ✔ Full port (XFS + file-list polling)
|
│ │ ├─ byse.rs ✔ Full port (XFS + file-list polling)
|
||||||
│ │ ├─ vidmoly.rs ✔ Full port (new SPA auth + transit server)
|
│ │ ├─ vidmoly.rs ✔ Full port (new SPA auth + transit server)
|
||||||
│ │ ├─ doodstream.rs ⚠ Stub — run v1 until ported
|
│ │ ├─ doodstream.rs ✔ Login + sess_id scrape + XFS upload (no OTP path yet)
|
||||||
│ │ └─ voe.rs ⚠ Stub — run v1 until ported
|
│ │ └─ voe.rs ✔ Login + CSRF + delivery-node + file-list polling
|
||||||
│ ├─ upload_manager.rs Batch orchestrator
|
│ ├─ upload_manager.rs Batch orchestrator
|
||||||
│ └─ commands.rs #[tauri::command] IPC handlers
|
│ └─ commands.rs #[tauri::command] IPC handlers
|
||||||
└─ README.md
|
└─ README.md
|
||||||
@ -76,8 +76,8 @@ Multi-Hoster-Upload-2.0/
|
|||||||
| Clouddrop uploader | ✅ simple + chunked (upload.clouddrop.cc) |
|
| Clouddrop uploader | ✅ simple + chunked (upload.clouddrop.cc) |
|
||||||
| Byse uploader | ✅ includes file-list polling for empty-filecode case |
|
| Byse uploader | ✅ includes file-list polling for empty-filecode case |
|
||||||
| Vidmoly uploader | ✅ new `/api/auth/login` + `/api/upload/config` + X-Progress-ID |
|
| Vidmoly uploader | ✅ new `/api/auth/login` + `/api/upload/config` + X-Progress-ID |
|
||||||
| Doodstream uploader | ⚠ stub (see port TODO) |
|
| Doodstream uploader | ✅ login_ajax + sess_id scrape + multipart upload (OTP flow TODO) |
|
||||||
| VOE uploader | ⚠ stub (see port TODO) |
|
| VOE uploader | ✅ Laravel login + CSRF + delivery-node + my-files polling |
|
||||||
| Queue persistence | ⚠ not yet — restart starts empty |
|
| Queue persistence | ⚠ not yet — restart starts empty |
|
||||||
| Folder monitor | ⚠ not yet |
|
| Folder monitor | ⚠ not yet |
|
||||||
| Remote-control server | ⚠ not yet |
|
| Remote-control server | ⚠ not yet |
|
||||||
@ -107,8 +107,8 @@ cargo tauri build
|
|||||||
It's separate from v1's `electron-config.json` so both versions can coexist.
|
It's separate from v1's `electron-config.json` so both versions can coexist.
|
||||||
- To migrate: in v1 use *Export Backup*, in v2 use *Import Backup*. Both speak the
|
- To migrate: in v1 use *Export Backup*, in v2 use *Import Backup*. Both speak the
|
||||||
same .mhu format.
|
same .mhu format.
|
||||||
- Doodstream & VOE still require v1 until their web-scraping is ported — the
|
- All 5 hosters are ported to Rust. Doodstream's OTP-required path still
|
||||||
Rust scaffolding for them is in place, just needs the login/CSRF logic.
|
throws "OTP erforderlich" — port as needed.
|
||||||
|
|
||||||
## Why Tauri over Electron
|
## Why Tauri over Electron
|
||||||
|
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -2489,6 +2489,7 @@ dependencies = [
|
|||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@ -50,6 +50,7 @@ bytes = "1"
|
|||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
|
serde_urlencoded = "0.7"
|
||||||
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|||||||
@ -1,17 +1,224 @@
|
|||||||
//! Doodstream.com uploader — port of `lib/doodstream-upload.js` (TODO).
|
//! Doodstream.com uploader. Port of `lib/doodstream-upload.js`.
|
||||||
//!
|
//!
|
||||||
//! Complex scraper: login via web form, parse CSRF from HTML, multipart upload
|
//! Flow (simplified — no OTP path in this revision):
|
||||||
//! to a transit server resolved from the HTML. Ships as a stub in 2.0 POC —
|
//! 1. POST / op=login_ajax&login=…&password=… → sets session cookies
|
||||||
//! the v1 Electron implementation keeps shipping alongside until the port
|
//! 2. GET /?op=upload → scrape `sess_id` from HTML
|
||||||
//! is complete.
|
//! 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 super::{UploadCtx, UploadTask};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::events::UploadResult;
|
use crate::events::UploadResult;
|
||||||
|
|
||||||
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> {
|
use bytes::Bytes;
|
||||||
Err(AppError::Other(
|
use once_cell::sync::Lazy;
|
||||||
"Doodstream-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1."
|
use regex::Regex;
|
||||||
.into(),
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,275 @@
|
|||||||
//! VOE.sx uploader — port of `lib/voe-upload.js` (TODO).
|
//! VOE.sx uploader. Port of `lib/voe-upload.js`.
|
||||||
//!
|
//!
|
||||||
//! VOE uses web login + CSRF scrape + session, plus CDN-fronted upload server
|
//! Flow:
|
||||||
//! negotiation. The SPA redesign is still in flux; porting this properly is
|
//! 1. GET /login → scrape CSRF token (meta or hidden input)
|
||||||
//! follow-up work to 2.0's initial shipping scope.
|
//! 2. POST /login _token + email + password → laravel session cookie
|
||||||
|
//! 3. GET /file-upload → fresh CSRF for upload
|
||||||
|
//! 4. GET /engine/delivery-node (X-CSRF) → { server, session_id }
|
||||||
|
//! 5. Snapshot /api2/my-files (to identify new uploads later)
|
||||||
|
//! 6. POST <server> multipart session_id+file → may return JSON w/ file_code
|
||||||
|
//! OR empty → poll my-files until a new file_code shows up.
|
||||||
|
|
||||||
use super::{UploadCtx, UploadTask};
|
use super::{UploadCtx, UploadTask};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::events::UploadResult;
|
use crate::events::UploadResult;
|
||||||
|
|
||||||
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> {
|
use bytes::Bytes;
|
||||||
Err(AppError::Other(
|
use once_cell::sync::Lazy;
|
||||||
"VOE-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1."
|
use regex::Regex;
|
||||||
.into(),
|
use reqwest::{multipart, Body, Client};
|
||||||
))
|
use std::collections::HashSet;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://voe.sx";
|
||||||
|
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 login_html = c.get(format!("{BASE_URL}/login"))
|
||||||
|
.send().await?.text().await.unwrap_or_default();
|
||||||
|
let csrf = extract_csrf(&login_html)
|
||||||
|
.ok_or_else(|| AppError::HosterError("VOE".into(), "CSRF-Token nicht gefunden".into()))?;
|
||||||
|
let body = serde_urlencoded::to_string([
|
||||||
|
("_token", csrf.as_str()),
|
||||||
|
("email", task.username.as_str()),
|
||||||
|
("password", task.password.as_str()),
|
||||||
|
]).unwrap_or_default();
|
||||||
|
let res = c.post(format!("{BASE_URL}/login"))
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.header("Referer", format!("{BASE_URL}/login"))
|
||||||
|
.body(body).send().await?;
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
if body.contains("credentials do not match") || body.contains("Incorrect") || body.contains("invalid") {
|
||||||
|
return Err(AppError::BadCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fresh CSRF from upload page ---
|
||||||
|
let upload_html = c.get(format!("{BASE_URL}/file-upload"))
|
||||||
|
.send().await?.text().await.unwrap_or_default();
|
||||||
|
let csrf_upload = extract_csrf(&upload_html).ok_or_else(||
|
||||||
|
AppError::HosterError("VOE".into(),
|
||||||
|
"CSRF-Token nicht gefunden. Bist du eingeloggt?".into()))?;
|
||||||
|
|
||||||
|
// --- Delivery node ---
|
||||||
|
let dn_body = c.get(format!("{BASE_URL}/engine/delivery-node"))
|
||||||
|
.header("X-CSRF-TOKEN", &csrf_upload)
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send().await?.text().await.unwrap_or_default();
|
||||||
|
let dn: serde_json::Value = serde_json::from_str(&dn_body).map_err(|_|
|
||||||
|
AppError::BadResponse(format!("VOE: delivery-node kein JSON: {}",
|
||||||
|
&dn_body[..dn_body.len().min(200)])))?;
|
||||||
|
let upload_server = dn.get("server").and_then(|x| x.as_str())
|
||||||
|
.filter(|_| dn.get("success").and_then(|x| x.as_bool()).unwrap_or(false))
|
||||||
|
.ok_or_else(|| AppError::HosterError("VOE".into(),
|
||||||
|
"Kein Upload-Server erhalten von delivery-node".into()))?
|
||||||
|
.to_string();
|
||||||
|
let session_id = dn.get("session_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||||
|
|
||||||
|
// --- Baseline file list ---
|
||||||
|
let baseline = fetch_file_codes(&c).await.unwrap_or_default();
|
||||||
|
|
||||||
|
// --- 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 mut form = multipart::Form::new().part("file", part);
|
||||||
|
if !session_id.is_empty() {
|
||||||
|
form = form.text("session_id", session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = c.post(&upload_server)
|
||||||
|
.header("X-CSRF-TOKEN", &csrf_upload)
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.header("Origin", BASE_URL)
|
||||||
|
.header("Referer", format!("{BASE_URL}/file-upload"))
|
||||||
|
.multipart(form).send().await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let raw = resp.text().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if status.is_client_error() || status.is_server_error() {
|
||||||
|
return Err(AppError::HosterError("VOE".into(),
|
||||||
|
format!("Upload HTTP {}: {}", status.as_u16(), &raw[..raw.len().min(200)])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JSON shape first.
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||||
|
let code = extract_code_from_value(&v);
|
||||||
|
if let Some(c) = code {
|
||||||
|
return Ok(build_urls(&c));
|
||||||
|
}
|
||||||
|
if let Some(msg) = v.get("error").and_then(|x| x.as_str()).or_else(|| v.get("message").and_then(|x| x.as_str())) {
|
||||||
|
return Err(AppError::HosterError("VOE".into(), msg.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: poll my-files.
|
||||||
|
if let Some(found) = poll_for_upload(&c, &file_name, &baseline, &ctx).await {
|
||||||
|
return Ok(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::BadResponse(format!(
|
||||||
|
"VOE Upload: kein file_code in der Antwort ({})",
|
||||||
|
&raw[..raw.len().min(200)]
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
static CSRF_META: Lazy<Regex> = Lazy::new(||
|
||||||
|
Regex::new(r#"<meta\s+name=["']csrf-token["']\s+content=["']([^"']+)["']"#).unwrap());
|
||||||
|
static CSRF_INPUT_1: Lazy<Regex> = Lazy::new(||
|
||||||
|
Regex::new(r#"<input[^>]*name=["']_token["'][^>]*value=["']([^"']+)["']"#).unwrap());
|
||||||
|
static CSRF_INPUT_2: Lazy<Regex> = Lazy::new(||
|
||||||
|
Regex::new(r#"<input[^>]*value=["']([^"']+)["'][^>]*name=["']_token["']"#).unwrap());
|
||||||
|
|
||||||
|
fn extract_csrf(html: &str) -> Option<String> {
|
||||||
|
CSRF_META.captures(html)
|
||||||
|
.or_else(|| CSRF_INPUT_1.captures(html))
|
||||||
|
.or_else(|| CSRF_INPUT_2.captures(html))
|
||||||
|
.and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_code_from_value(v: &serde_json::Value) -> Option<String> {
|
||||||
|
let keys = ["file_code", "filecode", "slug"];
|
||||||
|
for k in keys {
|
||||||
|
if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
|
||||||
|
if !s.is_empty() { return Some(s.to_string()); }
|
||||||
|
}
|
||||||
|
if let Some(s) = v.pointer(&format!("/file/{k}")).and_then(|x| x.as_str()) {
|
||||||
|
if !s.is_empty() { return Some(s.to_string()); }
|
||||||
|
}
|
||||||
|
if let Some(s) = v.pointer(&format!("/data/{k}")).and_then(|x| x.as_str()) {
|
||||||
|
if !s.is_empty() { return Some(s.to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_file_codes(c: &Client) -> AppResult<HashSet<String>> {
|
||||||
|
let body = c.get(format!("{BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50"))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send().await?.text().await.unwrap_or_default();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
|
||||||
|
let arr = v.get("data").and_then(|x| x.as_array()).cloned()
|
||||||
|
.or_else(|| v.get("files").and_then(|x| x.as_array()).cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut out = HashSet::new();
|
||||||
|
for f in arr {
|
||||||
|
if let Some(c) = f.get("file_code").and_then(|x| x.as_str())
|
||||||
|
.or_else(|| f.get("slug").and_then(|x| x.as_str())) {
|
||||||
|
if !c.is_empty() { out.insert(c.to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() { if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); } }
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_for_upload(c: &Client, file_name: &str, baseline: &HashSet<String>, ctx: &UploadCtx)
|
||||||
|
-> Option<UploadResult>
|
||||||
|
{
|
||||||
|
let expected = {
|
||||||
|
let stripped = std::path::Path::new(file_name).file_stem()
|
||||||
|
.and_then(|s| s.to_str()).unwrap_or(file_name);
|
||||||
|
normalize(stripped)
|
||||||
|
};
|
||||||
|
for _ in 0..10 {
|
||||||
|
if ctx.is_aborted() { return None; }
|
||||||
|
let body = match c.get(format!("{BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50"))
|
||||||
|
.header("Accept", "application/json").send().await {
|
||||||
|
Ok(r) => r.text().await.unwrap_or_default(),
|
||||||
|
Err(_) => { tokio::time::sleep(Duration::from_secs(2)).await; continue; }
|
||||||
|
};
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
|
||||||
|
let arr = v.get("data").and_then(|x| x.as_array()).cloned()
|
||||||
|
.or_else(|| v.get("files").and_then(|x| x.as_array()).cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut best: Option<(i32, String)> = None;
|
||||||
|
for f in &arr {
|
||||||
|
let code = match f.get("file_code").and_then(|x| x.as_str())
|
||||||
|
.or_else(|| f.get("slug").and_then(|x| x.as_str())) {
|
||||||
|
Some(c) if !c.is_empty() => c.to_string(), _ => continue,
|
||||||
|
};
|
||||||
|
if baseline.contains(&code) { continue; }
|
||||||
|
let title = normalize(f.get("title").and_then(|x| x.as_str())
|
||||||
|
.or_else(|| f.get("name").and_then(|x| x.as_str()))
|
||||||
|
.unwrap_or(""));
|
||||||
|
let score = if title == expected { 120 }
|
||||||
|
else if title.starts_with(&expected) || expected.starts_with(&title) { 90 }
|
||||||
|
else if title.contains(&expected) || expected.contains(&title) { 70 }
|
||||||
|
else { 10 };
|
||||||
|
if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) {
|
||||||
|
best = Some((score, code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((_, code)) = best { return Some(build_urls(&code)); }
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_urls(code: &str) -> UploadResult {
|
||||||
|
UploadResult {
|
||||||
|
download_url: Some(format!("{BASE_URL}/{code}")),
|
||||||
|
embed_url: Some(format!("{BASE_URL}/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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user