Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- CAPTCHA pipeline: persistent manual challenge queue, solve/skip/retry
actions, timeout handling, automatic download resumption, and redacted
challenge history (MAT-140).

### Security

- `cargo audit` config now ignores RUSTSEC-2026-0194/0195 (quick-xml 0.39.4
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/adapters/driven/config/toml_config_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ struct ConfigDto {
dynamic_split_enabled: bool,
dynamic_split_min_remaining_mb: u64,

// CAPTCHA
captcha_timeout_seconds: u32,

// History
history_retention_days: i64,

Expand Down Expand Up @@ -225,6 +228,7 @@ impl From<AppConfig> for ConfigDto {
pre_allocate_space: c.pre_allocate_space,
dynamic_split_enabled: c.dynamic_split_enabled,
dynamic_split_min_remaining_mb: c.dynamic_split_min_remaining_mb,
captcha_timeout_seconds: c.captcha_timeout_seconds,
history_retention_days: c.history_retention_days,
account_selection_strategy: c.account_selection_strategy.to_string(),
proxy_type: c.proxy_type,
Expand Down Expand Up @@ -283,6 +287,7 @@ impl TryFrom<ConfigDto> for AppConfig {
pre_allocate_space: d.pre_allocate_space,
dynamic_split_enabled: d.dynamic_split_enabled,
dynamic_split_min_remaining_mb: d.dynamic_split_min_remaining_mb,
captcha_timeout_seconds: d.captcha_timeout_seconds,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
history_retention_days: normalize_history_retention_days(d.history_retention_days),
account_selection_strategy,
proxy_type: d.proxy_type,
Expand Down
49 changes: 48 additions & 1 deletion src-tauri/src/adapters/driven/event/tauri_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ pub fn spawn_tauri_event_bridge(app_handle: AppHandle, event_bus: &dyn EventBus)
/// notifications per download and the first refetch runs against the
/// pre-persist state (the race this flow exists to fix).
fn should_forward_to_frontend(event: &DomainEvent) -> bool {
!matches!(event, DomainEvent::DownloadCompleted { .. })
!matches!(
event,
DomainEvent::DownloadCompleted { .. } | DomainEvent::CaptchaRequired { .. }
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
)
}

fn event_name(event: &DomainEvent) -> &'static str {
match event {
DomainEvent::DownloadCreated { .. } => "download-created",
DomainEvent::DownloadQueued { .. } => "download-queued",
DomainEvent::DownloadStarted { .. } => "download-started",
DomainEvent::DownloadPaused { .. } => "download-paused",
DomainEvent::DownloadResumed { .. } => "download-resumed",
Expand All @@ -44,6 +48,11 @@ fn event_name(event: &DomainEvent) -> &'static str {
DomainEvent::DownloadWaiting { .. } => "download-waiting",
DomainEvent::DownloadWaitingStarted { .. } => "download-waiting-started",
DomainEvent::DownloadWaitingEnded { .. } => "download-waiting-ended",
DomainEvent::CaptchaRequired { .. } => "captcha-required",
DomainEvent::CaptchaPending { .. } => "captcha-pending",
DomainEvent::CaptchaSolved { .. } => "captcha-solved",
DomainEvent::CaptchaSkipped { .. } => "captcha-skipped",
DomainEvent::CaptchaTimedOut { .. } => "captcha-timed-out",
DomainEvent::DownloadChecking { .. } => "download-checking",
DomainEvent::DownloadCancelled { .. } => "download-cancelled",
DomainEvent::DownloadRemoved { .. } => "download-removed",
Expand Down Expand Up @@ -85,6 +94,7 @@ fn event_name(event: &DomainEvent) -> &'static str {
fn event_payload(event: &DomainEvent) -> serde_json::Value {
match event {
DomainEvent::DownloadCreated { id }
| DomainEvent::DownloadQueued { id }
| DomainEvent::DownloadStarted { id }
| DomainEvent::DownloadPaused { id }
| DomainEvent::DownloadResumed { id }
Expand All @@ -97,6 +107,43 @@ fn event_payload(event: &DomainEvent) -> serde_json::Value {
| DomainEvent::DownloadExtracting { id } => json!({ "id": id.0 }),
DomainEvent::DownloadCompletedPersisted { id, .. } => json!({ "id": id.0 }),

DomainEvent::CaptchaRequired { download_id, .. } => {
json!({ "downloadId": download_id.0 })
}
DomainEvent::CaptchaPending {
challenge_id,
download_id,
} => json!({ "challengeId": challenge_id.as_str(), "downloadId": download_id.0 }),
DomainEvent::CaptchaSolved {
challenge_id,
download_id,
solver,
duration_ms,
} => json!({
"challengeId": challenge_id.as_str(),
"downloadId": download_id.0,
"solver": solver,
"durationMs": duration_ms,
}),
DomainEvent::CaptchaSkipped {
challenge_id,
download_id,
reason,
} => json!({
"challengeId": challenge_id.as_str(),
"downloadId": download_id.0,
"reason": reason,
}),
DomainEvent::CaptchaTimedOut {
challenge_id,
download_id,
duration_ms,
} => json!({
"challengeId": challenge_id.as_str(),
"downloadId": download_id.0,
"durationMs": duration_ms,
}),

DomainEvent::DownloadFailed { id, error } => json!({ "id": id.0, "error": error }),
DomainEvent::DownloadRetrying { id, attempt } => {
json!({ "id": id.0, "attempt": attempt })
Expand Down
35 changes: 35 additions & 0 deletions src-tauri/src/adapters/driven/logging/download_log_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
DomainEvent::DownloadCreated { id } => {
store.push(id.0, "[INFO] Download created".to_string());
}
DomainEvent::DownloadQueued { id } => {
store.push(id.0, "[INFO] Download queued".to_string());
}
DomainEvent::DownloadStarted { id } => {
store.push(id.0, "[INFO] Download started".to_string());
}
Expand Down Expand Up @@ -62,6 +65,37 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
};
store.push(id.0, format!("[INFO] Wait {suffix}"));
}
DomainEvent::CaptchaPending { download_id, .. } => {
store.push(download_id.0, "[INFO] CAPTCHA waiting for user".to_string());
}
DomainEvent::CaptchaSolved {
download_id,
solver,
duration_ms,
..
} => {
store.push(
download_id.0,
format!("[INFO] CAPTCHA solved by {solver} in {duration_ms}ms"),
);
}
DomainEvent::CaptchaSkipped {
download_id,
reason,
..
} => {
store.push(download_id.0, format!("[WARN] {reason}"));
}
DomainEvent::CaptchaTimedOut {
download_id,
duration_ms,
..
} => {
store.push(
download_id.0,
format!("[WARN] CAPTCHA timed out after {duration_ms}ms"),
);
}
DomainEvent::DownloadChecking { id } => {
store.push(id.0, "[INFO] Checking download".to_string());
}
Expand Down Expand Up @@ -142,6 +176,7 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
);
}
DomainEvent::DownloadProgress { .. }
| DomainEvent::CaptchaRequired { .. }
| DomainEvent::DownloadCompletedPersisted { .. }
| DomainEvent::DownloadPrioritySet { .. }
| DomainEvent::QueueReordered { .. }
Expand Down
50 changes: 34 additions & 16 deletions src-tauri/src/adapters/driven/network/download_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ struct RemoteMetadata {
/// signal.
const MIN_SPLIT_SAMPLE_DURATION: std::time::Duration = std::time::Duration::from_millis(500);

fn source_resolution_event(
download_id: DownloadId,
error: &DomainError,
cancelled: bool,
) -> DomainEvent {
if cancelled {
return DomainEvent::DownloadCancelled { id: download_id };
}
if let DomainError::CaptchaRequired {
challenge_type,
challenge_url,
image_data,
} = error
{
return DomainEvent::CaptchaRequired {
download_id,
challenge_type: *challenge_type,
challenge_url: challenge_url.clone(),
image_data: image_data.clone(),
};
}
DomainEvent::DownloadFailed {
id: download_id,
error: safe_source_failure(error),
}
}

fn segment_count_for_attempt(
requested_segments: u32,
total_size: u64,
Expand Down Expand Up @@ -416,14 +443,8 @@ impl DownloadEngine for SegmentedDownloadEngine {
{
Ok(prepared) => prepared,
Err(error) => {
let event = if cancel_token.is_cancelled() {
DomainEvent::DownloadCancelled { id: download_id }
} else {
DomainEvent::DownloadFailed {
id: download_id,
error: safe_source_failure(&error),
}
};
let event =
source_resolution_event(download_id, &error, cancel_token.is_cancelled());
event_bus.publish(event);
active_downloads
.lock()
Expand Down Expand Up @@ -524,14 +545,11 @@ impl DownloadEngine for SegmentedDownloadEngine {
{
Ok(refreshed) => refreshed,
Err(error) => {
let event = if cancel_token.is_cancelled() {
DomainEvent::DownloadCancelled { id: download_id }
} else {
DomainEvent::DownloadFailed {
id: download_id,
error: safe_source_failure(&error),
}
};
let event = source_resolution_event(
download_id,
&error,
cancel_token.is_cancelled(),
);
event_bus.publish(event);
break;
}
Expand Down
25 changes: 25 additions & 0 deletions src-tauri/src/adapters/driven/network/download_engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,38 @@ use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use crate::adapters::driven::filesystem::FsFileStorage;
use crate::domain::event::DomainEvent;
use crate::domain::model::captcha::CaptchaType;
use crate::domain::model::download::{Download, DownloadId, Url};
use crate::domain::model::meta::DownloadMeta;
use crate::domain::ports::driven::FileStorage;

use super::test_support::*;
use super::*;

#[test]
fn captcha_resolution_failure_emits_the_typed_challenge() {
let event = source_resolution_event(
DownloadId(42),
&DomainError::CaptchaRequired {
challenge_type: CaptchaType::Image,
challenge_url: "https://hoster.example/file".into(),
image_data: Some(vec![1, 2, 3]),
},
false,
);

assert_eq!(
event,
DomainEvent::CaptchaRequired {
download_id: DownloadId(42),
challenge_type: CaptchaType::Image,
challenge_url: "https://hoster.example/file".into(),
image_data: Some(vec![1, 2, 3]),
}
);
}

#[test]
fn mock_file_storage_does_not_probe_the_host_filesystem() {
let temp = tempfile::tempdir().unwrap();
Expand Down
39 changes: 35 additions & 4 deletions src-tauri/src/adapters/driven/plugin/hoster_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ use std::collections::BTreeMap;
use serde::Deserialize;

use crate::domain::error::DomainError;
use crate::domain::ports::driven::ExtractedHosterLink;
use crate::domain::model::captcha::{
CaptchaType, MAX_CAPTCHA_IMAGE_BYTES, captcha_image_mime_type,
};
use crate::domain::ports::driven::{ExtractedCaptchaChallenge, ExtractedHosterLink};

const MAX_HOSTER_PAYLOAD_BYTES: usize = 8 * 1024 * 1024;
const MAX_HOSTER_FILES: usize = 500;
Expand All @@ -31,6 +34,10 @@ struct HosterFile {
headers: BTreeMap<String, String>,
traffic_used_bytes: Option<u64>,
traffic_total_bytes: Option<u64>,
#[serde(default)]
requires_captcha: bool,
captcha_type: Option<String>,
captcha_image_data: Option<Vec<u8>>,
Comment thread
mpiton marked this conversation as resolved.
}

#[cfg(test)]
Expand Down Expand Up @@ -58,8 +65,31 @@ pub(super) fn parse_hoster_links(payload: &str) -> Result<Vec<ExtractedHosterLin
.into_iter()
.map(|file| {
let source_url = bounded_required_url(file.url)?;
let direct_url =
bounded_required_url(file.direct_url.ok_or(DomainError::HosterNoFile)?)?;
let captcha = if file.requires_captcha {
let challenge_type = file
.captcha_type
.as_deref()
.unwrap_or("recaptcha_v2")
.parse::<CaptchaType>()?;
if file.captcha_image_data.as_ref().is_some_and(|data| {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
data.is_empty()
|| data.len() > MAX_CAPTCHA_IMAGE_BYTES
|| captcha_image_mime_type(data).is_none()
}) {
return Err(limit_error());
}
Some(ExtractedCaptchaChallenge {
challenge_type,
image_data: file.captcha_image_data,
})
} else {
None
};
let direct_url = match file.direct_url {
Some(url) => Some(bounded_required_url(url)?),
None if captcha.is_some() => None,
None => return Err(DomainError::HosterNoFile),
};
if file
.filename
.as_ref()
Expand All @@ -75,11 +105,12 @@ pub(super) fn parse_hoster_links(payload: &str) -> Result<Vec<ExtractedHosterLin
source_url,
filename: file.filename.filter(|name| !name.trim().is_empty()),
size_bytes: file.size_bytes,
direct_url: Some(direct_url),
direct_url,
resumable: file.resumable,
request_headers: file.headers.into_iter().collect(),
traffic_used_bytes: file.traffic_used_bytes,
traffic_total_bytes: file.traffic_total_bytes,
captcha,
})
})
.collect()
Expand Down
Loading
Loading