Tauri 2 / Rust rewrite — initial 2.0 scaffold

Working:
  - Core: config, secret encryption, events, throttle
  - Upload manager with full rotation/classifier parity to v1
  - Clouddrop uploader (simple + chunked upload.clouddrop.cc)
  - Byse uploader with file-list polling for empty-filecode case
  - Vidmoly uploader (new /api/auth/login + /api/upload/config + X-Progress-ID)
  - Minimal frontend (accounts, settings, upload table, rotation log)
  - Release build: exe 6.9 MB, NSIS installer 2.5 MB, MSI 3.4 MB

Stubs (return 'not yet ported' error):
  - Doodstream (web login + CSRF — v1 scraper needs careful port)
  - VOE (web login + CSRF + delivery-node negotiation)

Not yet migrated from v1:
  - Queue persistence on restart
  - Folder monitor
  - Remote-control server
  - Drop-target floating window
  - Auto-updater
This commit is contained in:
Claude
2026-04-20 17:08:00 +02:00
commit 8627a8e694
28 changed files with 10540 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
//! Byse.sx uploader. Port of the generic XFS flow in `lib/hosters.js`.
//!
//! Steps:
//! 1. GET https://api.byse.sx/upload/server?key=API_KEY → { result: "https://srv.../upload.cgi" }
//! 2. Snapshot file list so we can identify the new upload even if filecode
//! comes back empty (Byse sometimes replies with msg=OK + filecode="" but
//! the file lands on the server anyway and gets its code async).
//! 3. POST multipart to the returned server with form field `key=API_KEY`.
//! 4. Parse JSON → if files[0].filecode is set, done. Otherwise poll file list
//! up to 30s for a new filecode that matches the uploaded filename.
use super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult};
use crate::events::UploadResult;
use bytes::Bytes;
use reqwest::{multipart, Body, Client};
use serde::Deserialize;
use std::path::Path;
use std::time::Duration;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
const API_BASE: &str = "https://api.byse.sx";
const DOWNLOAD_BASE: &str = "https://byse.sx";
fn client() -> AppResult<Client> {
Client::builder()
.timeout(Duration::from_secs(30 * 60))
.connect_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(20)
.gzip(true)
.user_agent("multi-hoster-uploader/2.0")
.build()
.map_err(AppError::from)
}
pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult> {
let key = task.api_key.trim();
if key.is_empty() { return Err(AppError::BadCredentials); }
let path = task.file_path.as_path();
let meta = tokio::fs::metadata(path).await?;
let file_size = meta.len();
let c = client()?;
// Baseline: which file_codes does the account already have?
let baseline = fetch_file_list(&c, key).await.unwrap_or_default();
let baseline_set: std::collections::HashSet<String> =
baseline.iter().map(|f| f.file_code.clone()).collect();
// Get upload server URL.
let server_url = get_upload_server(&c, key).await?;
// POST multipart.
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let upload_url = append_query(&server_url, "key", key);
let file = File::open(path).await?;
let stream = progress_stream(file, file_size, ctx.clone());
let body = Body::wrap_stream(stream);
let part = multipart::Part::stream_with_length(body, 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("key", key.to_string())
.part("file", part);
let resp = c.post(&upload_url)
.header("Accept", "application/json, text/plain;q=0.9, */*;q=0.8")
.multipart(form)
.send()
.await?;
let status = resp.status();
let raw = resp.text().await.unwrap_or_default();
if !status.is_success() {
// Network/CDN level failure — surface raw.
let snippet = raw.chars().take(240).collect::<String>();
return Err(AppError::HosterError(
"Byse".into(),
format!("Upload fehlgeschlagen (HTTP {}): {}", status.as_u16(), snippet),
));
}
let payload: ByseResp = serde_json::from_str(&raw)
.map_err(|_| AppError::BadResponse(format!("Byse: Antwort war kein JSON: {}", &raw[..raw.len().min(240)])))?;
// Normal success: files[0].filecode present.
if let Some(f) = payload.files.as_ref().and_then(|v| v.first()) {
let code = f.filecode.clone().or(f.file_code.clone()).unwrap_or_default();
if !code.is_empty() {
return Ok(UploadResult {
download_url: Some(format!("{DOWNLOAD_BASE}/d/{code}")),
embed_url: Some(format!("{DOWNLOAD_BASE}/e/{code}")),
file_code: Some(code),
});
}
// Per-file rejection (e.g. "Not video file format") → but we've seen
// the file land anyway. Poll before giving up.
if let Some(s) = &f.status {
if !is_ok_ish(s) {
tracing::warn!("Byse per-file status `{s}` — polling file list to confirm");
}
}
}
// Poll /api/file/list for the uploaded filename.
if let Some(found) = poll_for_upload(&c, key, &file_name, &baseline_set, &ctx).await {
return Ok(UploadResult {
download_url: Some(format!("{DOWNLOAD_BASE}/d/{}", found)),
embed_url: Some(format!("{DOWNLOAD_BASE}/e/{}", found)),
file_code: Some(found),
});
}
// Nothing landed on the account. If server reported a per-file status,
// surface that as the error; else a generic one.
let err_msg = payload
.files
.as_ref()
.and_then(|v| v.first())
.and_then(|f| f.status.clone())
.filter(|s| !is_ok_ish(s))
.map(|s| format!("Byse lehnte Datei ab: {s}"))
.unwrap_or_else(|| format!("Byse: Keine file_code-Antwort (Payload: {})", &raw[..raw.len().min(400)]));
Err(if err_msg.contains("lehnte Datei ab") {
AppError::FileRejected(err_msg)
} else {
AppError::HosterError("Byse".into(), err_msg)
})
}
async fn get_upload_server(c: &Client, key: &str) -> AppResult<String> {
let url = format!("{API_BASE}/upload/server?key={}", urlencoding::encode(key));
let resp = c.get(&url).header("Accept", "application/json").send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(AppError::HosterError("Byse".into(),
format!("/upload/server HTTP {}: {}", status, &text[..text.len().min(200)])));
}
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_|
AppError::BadResponse(format!("Byse /upload/server kein JSON: {}", &text[..text.len().min(200)])))?;
// Common shapes: { result: "https://..." } or { upload_url: "..." }
for k in ["result", "upload_url", "url", "server"] {
if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
if s.starts_with("http") { return Ok(s.to_string()); }
}
}
Err(AppError::BadResponse("Byse: Kein Upload-Server erhalten".into()))
}
fn append_query(url: &str, key: &str, val: &str) -> String {
if url.contains('?') {
format!("{url}&{key}={}", urlencoding::encode(val))
} else {
format!("{url}?{key}={}", urlencoding::encode(val))
}
}
fn is_ok_ish(s: &str) -> bool {
let l = s.to_lowercase();
matches!(l.as_str(), "ok" | "success" | "done")
}
// --- File-list polling ---
#[derive(Debug, Default, Clone)]
struct ByseFile {
file_code: String,
name: String,
}
async fn fetch_file_list(c: &Client, key: &str) -> AppResult<Vec<ByseFile>> {
let url = format!("{API_BASE}/api/file/list?key={}&per_page=100&sort=date&order=desc",
urlencoding::encode(key));
let resp = c.get(&url)
.header("Accept", "application/json")
.timeout(Duration::from_secs(30))
.send()
.await?;
if !resp.status().is_success() { return Ok(vec![]); }
let text = resp.text().await.unwrap_or_default();
let v: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => return Ok(vec![]),
};
let mut list = Vec::new();
let arr = v.get("files").and_then(|x| x.as_array()).cloned()
.or_else(|| v.pointer("/result/files").and_then(|x| x.as_array()).cloned())
.or_else(|| v.get("result").and_then(|x| x.as_array()).cloned())
.unwrap_or_default();
for f in arr {
let file_code = f.get("file_code").and_then(|x| x.as_str())
.or_else(|| f.get("filecode").and_then(|x| x.as_str()))
.unwrap_or("").to_string();
if file_code.is_empty() { continue; }
let name = f.get("title").and_then(|x| x.as_str())
.or_else(|| f.get("name").and_then(|x| x.as_str()))
.or_else(|| f.get("file_name").and_then(|x| x.as_str()))
.unwrap_or("").to_string();
list.push(ByseFile { file_code, name });
}
Ok(list)
}
fn normalize_title(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()); }
}
// Strip trailing extension, e.g. ".mkv"
if let Some(idx) = out.rfind(|_| false) { let _ = idx; }
out
}
async fn poll_for_upload(
c: &Client,
key: &str,
file_name: &str,
baseline: &std::collections::HashSet<String>,
ctx: &UploadCtx,
) -> Option<String> {
let expected = {
// strip file extension before normalizing
let stripped = std::path::Path::new(file_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(file_name);
normalize_title(stripped)
};
for _ in 0..15 {
if ctx.is_aborted() { return None; }
if let Ok(list) = fetch_file_list(c, key).await {
let new_files: Vec<_> = list.into_iter()
.filter(|f| !baseline.contains(&f.file_code))
.collect();
if let Some(exact) = new_files.iter()
.find(|f| normalize_title(&f.name) == expected) {
return Some(exact.file_code.clone());
}
if new_files.len() == 1 {
return Some(new_files[0].file_code.clone());
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
None
}
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),
}
}
})
}
// --- Response shape ---
#[derive(Deserialize, Default)]
struct ByseResp {
#[allow(dead_code)]
msg: Option<String>,
#[allow(dead_code)]
status: Option<u32>,
files: Option<Vec<ByseFileEntry>>,
}
#[derive(Deserialize, Default)]
struct ByseFileEntry {
filecode: Option<String>,
#[serde(rename = "file_code")]
file_code: Option<String>,
#[allow(dead_code)]
filename: Option<String>,
status: Option<String>,
}
+254
View File
@@ -0,0 +1,254 @@
//! Clouddrop.cc uploader. Port of `lib/clouddrop-upload.js`.
//!
//! Flow:
//! - files <= 16 MB → single POST /api/cloud/upload?mode=rename (multipart)
//! - files > 16 MB → POST /api/cloud/upload/init → PUT chunks @ upload.clouddrop.cc → POST /complete
//!
//! No share-link is created (server has link generation disabled by design).
//! The download_url is constructed from the returned file_code.
use super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult};
use crate::events::UploadResult;
use bytes::Bytes;
use reqwest::{multipart, Body, Client};
use serde::Deserialize;
use std::path::Path;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
use tokio_util::io::ReaderStream;
const BASE_URL: &str = "https://clouddrop.cc";
const CHUNK_UPLOAD_BASE: &str = "https://upload.clouddrop.cc/api/cloud";
const SIMPLE_UPLOAD_LIMIT: u64 = 16 * 1024 * 1024;
const CHUNK_SIZE: u64 = 16 * 1024 * 1024;
fn client() -> AppResult<Client> {
Client::builder()
.timeout(Duration::from_secs(30 * 60))
.connect_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(50)
.user_agent("multi-hoster-uploader/2.0")
.build()
.map_err(AppError::from)
}
pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult> {
if task.api_key.trim().is_empty() {
return Err(AppError::BadCredentials);
}
let path = task.file_path.as_path();
let meta = tokio::fs::metadata(path).await
.map_err(|_| AppError::Other(format!("Clouddrop: Datei nicht lesbar: {}", path.display())))?;
let file_size = meta.len();
if file_size == 0 {
return Err(AppError::Other("Clouddrop: Datei ist leer".into()));
}
let c = client()?;
let file_id = if file_size <= SIMPLE_UPLOAD_LIMIT {
upload_simple(&c, path, file_size, &task, &ctx).await?
} else {
upload_chunked(&c, path, file_size, &task, &ctx).await?
};
Ok(UploadResult {
download_url: Some(format!("{BASE_URL}/share/{file_id}")),
embed_url: None,
file_code: Some(file_id),
})
}
// --- Simple single-POST upload ---
async fn upload_simple(
c: &Client,
path: &Path,
file_size: u64,
task: &UploadTask,
ctx: &UploadCtx,
) -> AppResult<String> {
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let file = File::open(path).await?;
let stream = progress_stream(file, file_size, ctx.clone());
let body = Body::wrap_stream(stream);
let part = multipart::Part::stream_with_length(body, file_size)
.file_name(file_name)
.mime_str("application/octet-stream")
.map_err(|e| AppError::Other(format!("MIME build failed: {e}")))?;
let form = multipart::Form::new().part("file", part);
let url = format!("{BASE_URL}/api/cloud/upload?mode=rename");
let resp = c.post(&url)
.bearer_auth(&task.api_key)
.header("Accept", "application/json")
.multipart(form)
.send()
.await?;
let payload: SimpleResp = parse_json(resp).await?;
payload.file_id
.ok_or_else(|| AppError::BadResponse("Clouddrop: Keine fileId in Upload-Antwort".into()))
}
// --- Chunked upload ---
async fn upload_chunked(
c: &Client,
path: &Path,
file_size: u64,
task: &UploadTask,
ctx: &UploadCtx,
) -> AppResult<String> {
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
// 1) init session
let init_url = format!("{BASE_URL}/api/cloud/upload/init");
let init_payload = serde_json::json!({
"filename": file_name,
"size": file_size,
"parentId": serde_json::Value::Null
});
let init_resp = c.post(&init_url)
.bearer_auth(&task.api_key)
.header("Accept", "application/json")
.json(&init_payload)
.send()
.await?;
let init: InitResp = parse_json(init_resp).await?;
let session_id = init.session_id
.ok_or_else(|| AppError::BadResponse("Clouddrop: Keine sessionId von /upload/init".into()))?;
let chunk_size = init.chunk_size.unwrap_or(CHUNK_SIZE);
let total_chunks = init.total_chunks.unwrap_or_else(|| file_size.div_ceil(chunk_size));
// 2) read + PUT chunks sequentially
let mut file = File::open(path).await?;
let mut buf = vec![0u8; chunk_size as usize];
let mut bytes_sent: u64 = 0;
for i in 0..total_chunks {
if ctx.is_aborted() {
return Err(AppError::Aborted);
}
let offset = i * chunk_size;
let remaining = file_size - offset;
let this_size = chunk_size.min(remaining) as usize;
file.seek(SeekFrom::Start(offset)).await?;
file.read_exact(&mut buf[..this_size]).await?;
ctx.throttle(this_size as u64).await;
let url = format!("{CHUNK_UPLOAD_BASE}/upload/{session_id}/chunk/{i}");
let chunk_body = Bytes::copy_from_slice(&buf[..this_size]);
let resp = c.put(&url)
.bearer_auth(&task.api_key)
.header("Content-Type", "application/octet-stream")
.header("Accept", "application/json")
.body(chunk_body)
.send()
.await?;
let _: serde_json::Value = parse_json(resp).await?;
bytes_sent += this_size as u64;
(ctx.on_progress)(bytes_sent, file_size);
}
// 3) complete (swallow all errors — bytes are already on the server)
let complete_url = format!("{BASE_URL}/api/cloud/upload/{session_id}/complete");
if let Ok(resp) = c.post(&complete_url)
.bearer_auth(&task.api_key)
.header("Accept", "application/json")
.json(&serde_json::json!({}))
.send()
.await
{
if let Ok(cmp) = parse_json::<CompleteResp>(resp).await {
if let Some(id) = cmp.file_id.or(cmp.id) { return Ok(id); }
}
}
// Fall back to sessionId — prevents the upload-manager from retrying a
// multi-GB upload just because /complete hiccuped after all bytes landed.
Ok(session_id)
}
async fn parse_json<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> AppResult<T> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
// Try to lift the server's `error` / `message` field out for a better
// error message. Otherwise fall back to the raw snippet.
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
v.get("error").and_then(|e| e.as_str().map(|s| s.to_string()))
.or_else(|| v.get("message").and_then(|e| e.as_str().map(|s| s.to_string())))
})
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
return Err(AppError::HosterError("Clouddrop".into(), msg));
}
if text.is_empty() {
// serde_json can't parse empty — return a default value if T allows it.
let v = serde_json::from_str::<T>("{}")?;
return Ok(v);
}
Ok(serde_json::from_str::<T>(&text)?)
}
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_inner = ctx1.clone();
let ctx_progress = ctx2.clone();
async move {
match chunk {
Ok(bytes) => {
if ctx_inner.is_aborted() {
return Err(std::io::Error::new(std::io::ErrorKind::Other, "Aborted"));
}
ctx_inner.throttle(bytes.len() as u64).await;
acc += bytes.len() as u64;
(ctx_progress.on_progress)(acc, total);
Ok(bytes)
}
Err(e) => Err(e),
}
}
})
}
// --- Response shapes ---
#[derive(Deserialize, Default)]
struct SimpleResp {
#[serde(rename = "fileId")]
file_id: Option<String>,
}
#[derive(Deserialize, Default)]
struct InitResp {
#[serde(rename = "sessionId")]
session_id: Option<String>,
#[serde(rename = "chunkSize")]
chunk_size: Option<u64>,
#[serde(rename = "totalChunks")]
total_chunks: Option<u64>,
}
#[derive(Deserialize, Default)]
struct CompleteResp {
#[serde(rename = "fileId")]
file_id: Option<String>,
id: Option<String>,
}
+17
View File
@@ -0,0 +1,17 @@
//! Doodstream.com uploader — port of `lib/doodstream-upload.js` (TODO).
//!
//! Complex scraper: login via web form, parse CSRF from HTML, multipart upload
//! to a transit server resolved from the HTML. Ships as a stub in 2.0 POC —
//! the v1 Electron implementation keeps shipping alongside until the port
//! is complete.
use super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult};
use crate::events::UploadResult;
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> {
Err(AppError::Other(
"Doodstream-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1."
.into(),
))
}
+63
View File
@@ -0,0 +1,63 @@
//! Per-hoster uploaders + shared plumbing.
//!
//! Each hoster module exposes a single async `upload` function with the same
//! signature (see `UploadFn`). The dispatcher (`upload_file`) routes to the
//! right one based on `task.hoster`.
pub mod clouddrop;
pub mod byse;
pub mod vidmoly;
pub mod doodstream;
pub mod voe;
use crate::error::{AppError, AppResult};
use crate::events::UploadResult;
use crate::throttle::Throttle;
use std::sync::Arc;
use tokio::sync::Notify;
/// What the upload manager hands a hoster module.
#[derive(Clone, Debug)]
pub struct UploadTask {
pub hoster: String,
pub file_path: std::path::PathBuf,
pub account_id: String,
pub username: String,
pub password: String,
pub api_key: String,
}
/// Shared context: abort signal + optional throttles + progress callback.
#[derive(Clone)]
pub struct UploadCtx {
pub abort: Arc<Notify>,
pub aborted_flag: Arc<std::sync::atomic::AtomicBool>,
pub throttle_hoster: Option<Throttle>,
pub throttle_global: Option<Throttle>,
/// Fires whenever another chunk of bytes has been accepted for transmission.
/// Signature: (bytes_uploaded, bytes_total)
pub on_progress: Arc<dyn Fn(u64, u64) + Send + Sync>,
}
impl UploadCtx {
pub fn is_aborted(&self) -> bool {
self.aborted_flag.load(std::sync::atomic::Ordering::Relaxed)
}
pub async fn throttle(&self, n: u64) {
if let Some(t) = &self.throttle_hoster { t.consume(n).await; }
if let Some(t) = &self.throttle_global { t.consume(n).await; }
}
}
/// Dispatch: route to the hoster-specific uploader.
pub async fn upload_file(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult> {
match task.hoster.as_str() {
"clouddrop.cc" => clouddrop::upload(task, ctx).await,
"byse.sx" => byse::upload(task, ctx).await,
"vidmoly.me" => vidmoly::upload(task, ctx).await,
"doodstream.com" => doodstream::upload(task, ctx).await,
"voe.sx" => voe::upload(task, ctx).await,
other => Err(AppError::Other(format!("Unbekannter Hoster: {other}"))),
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Vidmoly.me uploader. Port of `lib/vidmoly-upload.js`.
//!
//! Modern (post-SPA) flow:
//! 1. GET https://vidmoly.me/ (warm up)
//! 2. POST /api/auth/login JSON {login, password} → sets `vidmoly_session`
//! 3. GET /api/upload/config → { sess_id, upload_url }
//! 4. POST `{upload_url}?X-Progress-ID=<random>`
//! multipart sess_id=, to_json=1, fld_id=0, file=<binary>
//! 5. JSON response: { status: "OK", file_code: "...", msg: "Upload Completed" }
//!
//! IMPORTANT: vidmoly.me cookies must NOT be sent to the transit server
//! (different origin). reqwest's cookie store handles that automatically
//! because cookies are domain-scoped.
use super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult};
use crate::events::UploadResult;
use bytes::Bytes;
use reqwest::{multipart, Body, Client};
use serde::Deserialize;
use std::sync::Arc;
use std::time::Duration;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
const BASE_URL: &str = "https://vidmoly.me";
fn logged_in_client() -> AppResult<Client> {
Client::builder()
.timeout(Duration::from_secs(30 * 60))
.connect_timeout(Duration::from_secs(60))
.cookie_store(true)
.pool_max_idle_per_host(10)
.gzip(true)
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.build()
.map_err(AppError::from)
}
fn transit_client() -> AppResult<Client> {
Client::builder()
.timeout(Duration::from_secs(30 * 60))
.connect_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(5)
.gzip(true)
// No cookie store → cross-origin transit upload stays clean
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.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 = Arc::new(logged_in_client()?);
// --- Login ---
// Warm-up GET establishes baseline cookies (cf_clearance, i18n_lang).
let _ = c.get(BASE_URL).send().await;
let login_resp = c.post(format!("{BASE_URL}/api/auth/login"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Origin", BASE_URL)
.header("Referer", format!("{BASE_URL}/login"))
.json(&serde_json::json!({ "login": task.username, "password": task.password }))
.send()
.await?;
let status = login_resp.status();
let body = login_resp.text().await.unwrap_or_default();
if status == 401 || status == 403 || regex::Regex::new(r"(?i)incorrect|invalid|wrong").unwrap().is_match(&body) {
return Err(AppError::BadCredentials);
}
if !status.is_success() {
return Err(AppError::HosterError("Vidmoly".into(), format!("Login HTTP {}", status.as_u16())));
}
// --- Upload config (confirms session is valid) ---
let cfg_resp = c.get(format!("{BASE_URL}/api/upload/config"))
.header("Accept", "application/json")
.send().await?;
if !cfg_resp.status().is_success() {
return Err(AppError::HosterError("Vidmoly".into(),
format!("/api/upload/config HTTP {}", cfg_resp.status().as_u16())));
}
let cfg: UploadConfig = serde_json::from_str(&cfg_resp.text().await.unwrap_or_default())
.map_err(|e| AppError::BadResponse(format!("Vidmoly: /api/upload/config kein JSON: {e}")))?;
let sess_id = cfg.sess_id
.ok_or_else(|| AppError::BadResponse("Vidmoly: sess_id fehlt".into()))?;
let upload_url = cfg.upload_url
.ok_or_else(|| AppError::BadResponse("Vidmoly: upload_url fehlt".into()))?;
// --- Transit upload ---
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 progress_id = format!("{}{:06}",
chrono::Utc::now().timestamp_millis(),
rand::Rng::gen_range(&mut rand::thread_rng(), 0u32..1_000_000));
let target_url = if upload_url.contains('?') {
format!("{upload_url}&X-Progress-ID={progress_id}")
} else {
format!("{upload_url}?X-Progress-ID={progress_id}")
};
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("to_json", "1")
.text("fld_id", "0")
.part("file", part);
let tc = transit_client()?;
let resp = tc.post(&target_url)
.header("Accept", "*/*")
.header("Origin", BASE_URL)
.header("Referer", format!("{BASE_URL}/"))
.multipart(form)
.send()
.await?;
let status = resp.status();
let raw = resp.text().await.unwrap_or_default();
// Try JSON success shape.
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
let 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 let Some(code) = code {
if !code.is_empty() {
return Ok(UploadResult {
download_url: Some(format!("{BASE_URL}/w/{code}")),
embed_url: Some(format!("{BASE_URL}/embed-{code}.html")),
file_code: Some(code.to_string()),
});
}
}
if let Some(s) = v.get("status").and_then(|x| x.as_str()) {
if !s.eq_ignore_ascii_case("ok") {
let msg = v.get("msg").and_then(|x| x.as_str()).unwrap_or(s).to_string();
return Err(AppError::HosterError("Vidmoly".into(), msg));
}
}
}
Err(AppError::BadResponse(format!(
"Vidmoly: unerwartete Upload-Antwort (HTTP {}): {}",
status.as_u16(),
&raw[..raw.len().min(400)]
)))
}
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),
}
}
})
}
#[derive(Deserialize, Default)]
struct UploadConfig {
sess_id: Option<String>,
upload_url: Option<String>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct UploadResp {
status: Option<String>,
file_code: Option<String>,
msg: Option<String>,
}
+16
View File
@@ -0,0 +1,16 @@
//! VOE.sx uploader — port of `lib/voe-upload.js` (TODO).
//!
//! VOE uses web login + CSRF scrape + session, plus CDN-fronted upload server
//! negotiation. The SPA redesign is still in flux; porting this properly is
//! follow-up work to 2.0's initial shipping scope.
use super::{UploadCtx, UploadTask};
use crate::error::{AppError, AppResult};
use crate::events::UploadResult;
pub async fn upload(_task: UploadTask, _ctx: UploadCtx) -> AppResult<UploadResult> {
Err(AppError::Other(
"VOE-Uploader in 2.0 noch nicht portiert. Nutze bis dahin v1."
.into(),
))
}