fix(health-check): actually authenticate against the hoster instead of just checking field presence

Previous behavior: login-based accounts (Doodstream/VOE/Vidmoly) reported
'Login hinterlegt — Bereit' as long as username/password were non-empty.
Entering nonsense (asdas@web.de / anything) passed. Now:

  - Vidmoly: POST /api/auth/login with JSON and verify /api/upload/config
    is reachable afterwards — 401/403 or non-OK message → BadCredentials.
  - Doodstream: login_ajax POST, success when either Dashboard HTML comes
    back or json.status == 'success'; OTP-required is surfaced as
    'Login gültig (OTP erforderlich)'.
  - VOE: Laravel CSRF scrape + POST /login, then verify /file-upload
    renders a fresh CSRF (only present when logged in).
  - Clouddrop: 401/403 now mapped to BadCredentials instead of generic.
  - Byse: parse JSON status field (server returns HTTP 200 + status:403
    on bad keys) and map accordingly.

Bogus credentials now correctly show a red 'Fehler' state.
This commit is contained in:
Claude 2026-04-20 19:11:06 +02:00
parent c2d706f6c9
commit 58be08b4e7

View File

@ -354,29 +354,135 @@ pub async fn run_health_check(
}
async fn check_account_live(hoster: &str, a: &Account) -> AppResult<String> {
// Byse's account-info endpoint returns HTTP 200 with {"status":403} on bad
// keys, so we parse the JSON properly instead of just checking the status.
let timeout = std::time::Duration::from_secs(20);
match hoster {
"clouddrop.cc" => {
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?;
let r = c.get("https://clouddrop.cc/api/cloud/files/?limit=1").bearer_auth(&a.api_key).send().await?;
if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Clouddrop".into(), format!("HTTP {}", r.status().as_u16()))) }
let c = reqwest::Client::builder().timeout(timeout).build()?;
let r = c.get("https://clouddrop.cc/api/cloud/files/?limit=1")
.bearer_auth(&a.api_key).send().await?;
match r.status().as_u16() {
200..=299 => Ok("API Key gültig".into()),
401 | 403 => Err(AppError::BadCredentials),
code => Err(AppError::HosterError("Clouddrop".into(), format!("HTTP {code}"))),
}
}
"byse.sx" => {
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?;
let c = reqwest::Client::builder().timeout(timeout).build()?;
let r = c.get(format!("https://api.byse.sx/api/account/info?key={}", urlencoding::encode(&a.api_key))).send().await?;
if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Byse".into(), format!("HTTP {}", r.status().as_u16()))) }
let text = r.text().await.unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
let status = v.get("status").and_then(|x| x.as_u64()).unwrap_or(200);
if status == 200 { Ok("API Key gültig".into()) }
else if status == 401 || status == 403 { Err(AppError::BadCredentials) }
else { Err(AppError::HosterError("Byse".into(),
v.get("msg").and_then(|x| x.as_str()).unwrap_or("Fehler").to_string())) }
}
"vidmoly.me" => {
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
let c = reqwest::Client::builder()
.timeout(timeout).cookie_store(true).build()?;
let _ = c.get("https://vidmoly.me").send().await;
let res = c.post("https://vidmoly.me/api/auth/login")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Origin", "https://vidmoly.me")
.header("Referer", "https://vidmoly.me/login")
.json(&serde_json::json!({ "login": a.username, "password": a.password }))
.send().await?;
let code = res.status().as_u16();
let body = res.text().await.unwrap_or_default();
if code == 401 || code == 403 || body.to_lowercase().contains("incorrect")
|| body.to_lowercase().contains("invalid") {
return Err(AppError::BadCredentials);
}
if !(200..300).contains(&code) {
return Err(AppError::HosterError("Vidmoly".into(), format!("HTTP {code}")));
}
// Verify session works against the upload-config endpoint.
let probe = c.get("https://vidmoly.me/api/upload/config")
.header("Accept", "application/json").send().await?;
if probe.status().is_success() { Ok("Login gültig".into()) }
else { Err(AppError::BadCredentials) }
}
"doodstream.com" => {
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
let c = reqwest::Client::builder()
.timeout(timeout).cookie_store(true).build()?;
let _ = c.get("https://doodstream.com").send().await;
let body = serde_urlencoded::to_string([
("op", "login_ajax"),
("login", a.username.as_str()),
("password", a.password.as_str()),
("loginotp", ""),
]).unwrap_or_default();
let res = c.post("https://doodstream.com/")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("X-Requested-With", "XMLHttpRequest")
.header("Referer", "https://doodstream.com/")
.body(body).send().await?;
let text = res.text().await.unwrap_or_default();
if text.contains("Dashboard") { return Ok("Login gültig".into()); }
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(s) = v.get("status").and_then(|x| x.as_str()) {
if s == "success" { return Ok("Login gültig".into()); }
let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen");
if msg.to_lowercase().contains("otp") { return Ok("Login gültig (OTP erforderlich)".into()); }
return Err(AppError::BadCredentials);
}
}
// If we scrape a logged-in page successfully that's also good.
let probe = c.get("https://doodstream.com/?op=my_files").send().await?;
if probe.status().is_success() {
let probe_text = probe.text().await.unwrap_or_default();
if probe_text.contains("logout") || probe_text.contains("Logout")
|| probe_text.contains("Dashboard") { return Ok("Login gültig".into()); }
}
Err(AppError::BadCredentials)
}
"voe.sx" => {
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
let c = reqwest::Client::builder()
.timeout(timeout).cookie_store(true).build()?;
let login_html = c.get("https://voe.sx/login").send().await?.text().await.unwrap_or_default();
let csrf = regex::Regex::new(r#"<meta\s+name=["']csrf-token["']\s+content=["']([^"']+)["']"#).unwrap()
.captures(&login_html).and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
.or_else(|| regex::Regex::new(r#"<input[^>]*name=["']_token["'][^>]*value=["']([^"']+)["']"#).unwrap()
.captures(&login_html).and_then(|c| c.get(1).map(|m| m.as_str().to_string())))
.ok_or_else(|| AppError::HosterError("VOE".into(), "CSRF-Token nicht gefunden".into()))?;
let body = serde_urlencoded::to_string([
("_token", csrf.as_str()),
("email", a.username.as_str()),
("password", a.password.as_str()),
]).unwrap_or_default();
let res = c.post("https://voe.sx/login")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Referer", "https://voe.sx/login")
.body(body).send().await?;
let text = res.text().await.unwrap_or_default();
if text.contains("credentials do not match") || text.contains("Incorrect") || text.contains("invalid") {
return Err(AppError::BadCredentials);
}
// Confirm session by pulling the upload page and looking for a CSRF
// (only present when logged in).
let upload_html = c.get("https://voe.sx/file-upload").send().await?.text().await.unwrap_or_default();
if regex::Regex::new(r#"name=["']csrf-token["']"#).unwrap().is_match(&upload_html) {
Ok("Login gültig".into())
} else {
Err(AppError::BadCredentials)
}
}
_ => {
if a.auth_type == "login" {
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
Ok("Login hinterlegt".into())
} else if a.auth_type == "api" {
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
Ok("API Key hinterlegt".into())
} else {
Ok("Nicht geprüft".into())
if a.auth_type == "login" && (a.username.is_empty() || a.password.is_empty()) {
return Err(AppError::BadCredentials);
}
if a.auth_type == "api" && a.api_key.is_empty() {
return Err(AppError::BadCredentials);
}
Ok("Nicht implementiert".into())
}
}
}