From 63726bb917a8d81ab4d4e9f46ad455e830a8d551 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:32:36 +0200 Subject: [PATCH 1/4] test(captcha): define pipeline acceptance coverage --- CHANGELOG.md | 5 + .../driven/plugin/hoster_contract_tests.rs | 17 ++ .../adapters/driven/sqlite/captcha_repo.rs | 11 + .../driven/sqlite/captcha_repo_tests.rs | 63 +++++ src-tauri/src/adapters/driven/sqlite/mod.rs | 5 + .../src/application/commands/captcha_tests.rs | 243 ++++++++++++++++++ src-tauri/src/application/commands/mod.rs | 2 + .../src/application/services/queue_manager.rs | 17 ++ src-tauri/src/domain/model/captcha.rs | 85 ++++-- src-tauri/src/domain/model/config.rs | 18 ++ src/views/__tests__/CaptchaView.test.tsx | 99 +++++++ 11 files changed, 544 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/adapters/driven/sqlite/captcha_repo.rs create mode 100644 src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs create mode 100644 src-tauri/src/application/commands/captcha_tests.rs create mode 100644 src/views/__tests__/CaptchaView.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7892a71f..aeef47c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ 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, and automatic download resumption (MAT-140). + ### Security - `cargo audit` config now ignores RUSTSEC-2026-0194/0195 (quick-xml 0.39.4 diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs index 350cdc05..6adc51e3 100644 --- a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs @@ -4,6 +4,7 @@ use super::ExtismPluginLoader; use super::capabilities::SharedHostResources; use super::hoster_contract::{parse_hoster_link, parse_hoster_links}; use crate::domain::error::DomainError; +use crate::domain::model::captcha::CaptchaType; use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::ports::driven::PluginLoader; @@ -34,6 +35,22 @@ fn test_parse_hoster_link_rejects_empty_file_list() { ); } +#[test] +fn test_parse_hoster_link_accepts_bounded_captcha_without_direct_url() { + let parsed = parse_hoster_link( + r#"{"files":[{"url":"https://hoster.example/file","direct_url":null,"requires_captcha":true,"captcha_type":"image","captcha_image_data":[137,80,78,71]}]}"#, + ) + .expect("captcha response is a valid unresolved hoster link"); + + assert!(parsed.direct_url.is_none()); + let captcha = parsed.captcha.expect("captcha metadata"); + assert_eq!(captcha.challenge_type, CaptchaType::Image); + assert_eq!( + captcha.image_data.as_deref(), + Some([137, 80, 78, 71].as_slice()) + ); +} + #[test] fn test_parse_hoster_links_preserves_every_gofile_entry() { let parsed = parse_hoster_links( diff --git a/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs b/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs new file mode 100644 index 00000000..96ae6bd4 --- /dev/null +++ b/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs @@ -0,0 +1,11 @@ +use sea_orm::DatabaseConnection; + +pub struct SqliteCaptchaRepo { + db: DatabaseConnection, +} + +impl SqliteCaptchaRepo { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } +} diff --git a/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs new file mode 100644 index 00000000..e455b127 --- /dev/null +++ b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs @@ -0,0 +1,63 @@ +use sea_orm::{ConnectionTrait, Statement}; + +use super::captcha_repo::SqliteCaptchaRepo; +use super::connection::setup_test_db; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType}; +use crate::domain::model::download::DownloadId; +use crate::domain::ports::driven::CaptchaRepository; + +fn challenge(id: &str, download_id: u64) -> CaptchaChallenge { + CaptchaChallenge::new( + CaptchaId::new(id), + DownloadId(download_id), + CaptchaType::Image, + format!("https://hoster.example/{download_id}"), + 1_000, + 61_000, + ) + .expect("valid challenge") + .with_image_data(vec![137, 80, 78, 71]) + .expect("bounded image") +} + +#[tokio::test(flavor = "multi_thread")] +async fn captcha_log_round_trip_and_pending_query() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteCaptchaRepo::new(db); + let mut item = challenge("captcha-1", 42); + + repo.save(&item).expect("save pending"); + let pending = repo.list_pending().expect("list pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].image_data(), Some([137, 80, 78, 71].as_slice())); + + item.solve(4_000, "manual").expect("solve"); + repo.save(&item).expect("save solved"); + assert!(repo.list_pending().expect("list pending").is_empty()); + + let stored = repo + .find_by_id(item.id()) + .expect("find") + .expect("stored challenge"); + assert_eq!(stored.status(), CaptchaStatus::Solved); + assert_eq!(stored.solver(), Some("manual")); + assert_eq!(stored.duration_ms(), Some(3_000)); +} + +#[tokio::test] +async fn captcha_log_never_has_a_solution_column() { + let db = setup_test_db().await.expect("test db"); + let columns = db + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "PRAGMA table_info(captcha_log)".to_string(), + )) + .await + .expect("table info"); + let names: Vec = columns + .iter() + .map(|row| row.try_get_by_index::(1).expect("column name")) + .collect(); + + assert!(!names.iter().any(|name| name.contains("solution"))); +} diff --git a/src-tauri/src/adapters/driven/sqlite/mod.rs b/src-tauri/src/adapters/driven/sqlite/mod.rs index d2326d0d..29a49d94 100644 --- a/src-tauri/src/adapters/driven/sqlite/mod.rs +++ b/src-tauri/src/adapters/driven/sqlite/mod.rs @@ -1,4 +1,6 @@ pub mod account_repo; +#[cfg(test)] +pub mod captcha_repo; pub mod connection; pub mod download_read_repo; pub mod download_repo; @@ -11,3 +13,6 @@ pub mod plugin_config_repo; pub mod progress_bridge; pub mod stats_repo; mod util; + +#[cfg(test)] +mod captcha_repo_tests; diff --git a/src-tauri/src/application/commands/captcha_tests.rs b/src-tauri/src/application/commands/captcha_tests.rs new file mode 100644 index 00000000..9b403ee0 --- /dev/null +++ b/src-tauri/src/application/commands/captcha_tests.rs @@ -0,0 +1,243 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use super::{ + EnqueueCaptchaCommand, RetryCaptchaCommand, SkipCaptchaCommand, SolveCaptchaCommand, + TimeoutCaptchaCommand, +}; +use crate::application::commands::captcha::{CaptchaCommandHandler, ManualCaptchaSolver}; +use crate::application::commands::tests_support::{CapturingEventBus, InMemoryDownloadRepo}; +use crate::domain::error::DomainError; +use crate::domain::event::DomainEvent; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType}; +use crate::domain::model::config::{AppConfig, ConfigPatch}; +use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; +use crate::domain::ports::driven::{CaptchaRepository, Clock, ConfigStore, DownloadRepository}; +use crate::domain::ports::driving::CommandHandler; + +struct MemoryCaptchaRepo(Mutex>); + +impl MemoryCaptchaRepo { + fn new() -> Self { + Self(Mutex::new(HashMap::new())) + } +} + +impl CaptchaRepository for MemoryCaptchaRepo { + fn save(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { + self.0 + .lock() + .unwrap() + .insert(challenge.id().clone(), challenge.clone()); + Ok(()) + } + + fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError> { + Ok(self.0.lock().unwrap().get(id).cloned()) + } + + fn list(&self) -> Result, DomainError> { + Ok(self.0.lock().unwrap().values().cloned().collect()) + } + + fn list_pending(&self) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .values() + .filter(|item| item.status() == CaptchaStatus::Pending) + .cloned() + .collect()) + } +} + +struct FixedClock(AtomicU64); + +impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + self.0.load(Ordering::SeqCst) / 1_000 + } + + fn now_unix_ms(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } +} + +struct FixedConfig; + +impl ConfigStore for FixedConfig { + fn get_config(&self) -> Result { + Ok(AppConfig { + captcha_timeout_seconds: 120, + ..AppConfig::default() + }) + } + + fn update_config(&self, _: ConfigPatch) -> Result { + self.get_config() + } +} + +type Fixture = ( + CaptchaCommandHandler, + Arc, + Arc, + Arc, + Arc, +); + +fn fixture() -> Fixture { + let captchas = Arc::new(MemoryCaptchaRepo::new()); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let events = Arc::new(CapturingEventBus::new()); + let clock = Arc::new(FixedClock(AtomicU64::new(1_000))); + let mut download = Download::new( + DownloadId(42), + Url::new("https://hoster.example/file").unwrap(), + "file.zip".into(), + "/tmp/file.zip".into(), + ); + download.start().unwrap(); + downloads.seed(download); + let handler = CaptchaCommandHandler::new( + captchas.clone(), + downloads.clone(), + events.clone(), + Arc::new(FixedConfig), + clock.clone(), + vec![Arc::new(ManualCaptchaSolver)], + ); + (handler, captchas, downloads, events, clock) +} + +async fn enqueue(handler: &CaptchaCommandHandler) -> CaptchaId { + >::handle( + handler, + EnqueueCaptchaCommand { + download_id: DownloadId(42), + challenge_type: CaptchaType::Image, + challenge_url: "https://hoster.example/file".into(), + image_data: Some(vec![137, 80, 78, 71]), + }, + ) + .await + .expect("enqueue") +} + +#[tokio::test] +async fn enqueue_parks_download_and_emits_pending() { + let (handler, captchas, downloads, events, _) = fixture(); + let id = enqueue(&handler).await; + + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Waiting + ); + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::Pending + ); + assert!(events.snapshot().iter().any(|event| matches!(event, DomainEvent::CaptchaPending { challenge_id, download_id } if challenge_id == &id && *download_id == DownloadId(42)))); +} + +#[tokio::test] +async fn manual_solve_logs_metadata_and_requeues_without_persisting_answer() { + let (handler, captchas, downloads, events, clock) = fixture(); + let id = enqueue(&handler).await; + clock.0.store(4_000, Ordering::SeqCst); + + >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: "abc123".into(), + }, + ) + .await + .expect("solve"); + + let stored = captchas.find_by_id(&id).unwrap().unwrap(); + assert_eq!(stored.status(), CaptchaStatus::Solved); + assert_eq!(stored.solver(), Some("manual")); + assert_eq!(stored.duration_ms(), Some(3_000)); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Queued + ); + assert!(events.snapshot().iter().any(|event| matches!(event, DomainEvent::CaptchaSolved { challenge_id, .. } if challenge_id == &id))); +} + +#[tokio::test] +async fn skip_is_terminal_and_does_not_enter_automatic_retry() { + let (handler, captchas, downloads, events, _) = fixture(); + let id = enqueue(&handler).await; + + >::handle( + &handler, + SkipCaptchaCommand { + challenge_id: id.clone(), + }, + ) + .await + .expect("skip"); + + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::Skipped + ); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Error + ); + assert!( + !events + .snapshot() + .iter() + .any(|event| matches!(event, DomainEvent::DownloadFailed { .. })) + ); +} + +#[tokio::test] +async fn timeout_skips_by_default_and_retry_renews_a_pending_challenge() { + let (handler, captchas, _, _, clock) = fixture(); + let id = enqueue(&handler).await; + clock.0.store(5_000, Ordering::SeqCst); + >::handle( + &handler, + RetryCaptchaCommand { + challenge_id: id.clone(), + }, + ) + .await + .expect("retry"); + let retried = captchas.find_by_id(&id).unwrap().unwrap(); + assert_eq!(retried.attempts(), 1); + assert_eq!(retried.expires_at(), 125_000); + + >::handle( + &handler, + TimeoutCaptchaCommand { + challenge_id: id.clone(), + }, + ) + .await + .expect("timeout"); + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::TimedOut + ); +} diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 32018860..ea735fca 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -3,6 +3,8 @@ //! Each command represents an intent to mutate application state. //! Handler implementations live in submodules and add methods to `CommandBus`. +#[cfg(test)] +mod captcha_tests; #[cfg(test)] pub(crate) mod tests_support; diff --git a/src-tauri/src/application/services/queue_manager.rs b/src-tauri/src/application/services/queue_manager.rs index ab4c7c13..e0f9dff8 100644 --- a/src-tauri/src/application/services/queue_manager.rs +++ b/src-tauri/src/application/services/queue_manager.rs @@ -741,6 +741,23 @@ mod tests { assert_eq!(qm.active_count(), 1); } + #[tokio::test] + async fn captcha_pending_releases_the_slot_and_starts_next_download() { + let mut waiting = make_download(1, 5, DownloadState::Queued); + waiting.start().expect("queued to downloading"); + waiting.wait().expect("downloading to waiting"); + let queued = make_download(2, 5, DownloadState::Queued); + let repo = Arc::new(MockDownloadRepo::new(vec![waiting, queued])); + let engine = Arc::new(MockEngine::new()); + let bus = Arc::new(MockEventBus::new()); + let qm = make_manager(repo, Arc::clone(&engine), bus, 1, 1); + + qm.handle_captcha_pending().await.expect("schedule next"); + + assert_eq!(*engine.started.lock().unwrap(), vec![2]); + assert_eq!(qm.active_count(), 1); + } + #[tokio::test] async fn test_on_slot_freed_respects_max_concurrent() { let d = make_download(1, 5, DownloadState::Queued); diff --git a/src-tauri/src/domain/model/captcha.rs b/src-tauri/src/domain/model/captcha.rs index 553a40c8..764f2a50 100644 --- a/src-tauri/src/domain/model/captcha.rs +++ b/src-tauri/src/domain/model/captcha.rs @@ -82,55 +82,98 @@ impl CaptchaChallenge { #[cfg(test)] mod tests { use super::*; + use crate::domain::model::download::DownloadId; fn make_challenge() -> CaptchaChallenge { CaptchaChallenge::new( - 42, + CaptchaId::new("captcha-42"), + DownloadId(7), CaptchaType::Image, "https://example.com/captcha".to_string(), + 1_000, + 61_000, ) + .expect("valid challenge") } #[test] - fn test_captcha_new() { + fn new_challenge_is_pending_and_has_a_deadline() { let c = make_challenge(); - assert_eq!(c.id(), 42); + assert_eq!(c.id().as_str(), "captcha-42"); + assert_eq!(c.download_id(), DownloadId(7)); assert_eq!(c.challenge_type(), CaptchaType::Image); assert_eq!(c.url(), "https://example.com/captcha"); assert!(c.image_data().is_none()); - assert!(!c.is_solved()); - assert!(c.solution().is_none()); + assert_eq!(c.status(), CaptchaStatus::Pending); + assert_eq!(c.expires_at(), 61_000); + assert_eq!(c.attempts(), 0); } #[test] - fn test_captcha_solve() { + fn solve_records_solver_and_duration_without_solution() { let mut c = make_challenge(); - c.solve("abc123".to_string()); - assert!(c.is_solved()); - assert_eq!(c.solution(), Some("abc123")); + c.solve(4_000, "manual").expect("pending can be solved"); + + assert_eq!(c.status(), CaptchaStatus::Solved); + assert_eq!(c.solver(), Some("manual")); + assert_eq!(c.resolved_at(), Some(4_000)); + assert_eq!(c.duration_ms(), Some(3_000)); } #[test] - fn test_captcha_is_solved() { + fn skip_and_timeout_are_explicit_terminal_states() { let mut c = make_challenge(); - assert!(!c.is_solved()); - c.solve("x".to_string()); - assert!(c.is_solved()); + c.skip(2_000, "Skipped by user") + .expect("pending can be skipped"); + assert_eq!(c.status(), CaptchaStatus::Skipped); + assert_eq!(c.failure_reason(), Some("Skipped by user")); + + let mut timed_out = make_challenge(); + timed_out.timeout(61_000).expect("pending can time out"); + assert_eq!(timed_out.status(), CaptchaStatus::TimedOut); + assert_eq!(timed_out.failure_reason(), Some("CAPTCHA timed out")); } #[test] - fn test_captcha_with_image_data() { + fn retry_renews_pending_deadline_and_counts_attempt() { + let mut c = make_challenge(); + c.retry(5_000, 65_000).expect("pending can be retried"); + + assert_eq!(c.status(), CaptchaStatus::Pending); + assert_eq!(c.attempts(), 1); + assert_eq!(c.expires_at(), 65_000); + } + + #[test] + fn image_payload_is_bounded() { let data = vec![0u8, 1, 2, 3]; - let c = make_challenge().with_image_data(data.clone()); + let c = make_challenge() + .with_image_data(data.clone()) + .expect("small image"); assert_eq!(c.image_data(), Some(data.as_slice())); + + let too_large = vec![0; MAX_CAPTCHA_IMAGE_BYTES + 1]; + assert!(make_challenge().with_image_data(too_large).is_err()); } #[test] - fn test_captcha_type_display() { - assert_eq!(CaptchaType::Image.to_string(), "Image"); - assert_eq!(CaptchaType::ReCaptchaV2.to_string(), "reCAPTCHA v2"); - assert_eq!(CaptchaType::ReCaptchaV3.to_string(), "reCAPTCHA v3"); - assert_eq!(CaptchaType::HCaptcha.to_string(), "hCaptcha"); - assert_eq!(CaptchaType::TextInput.to_string(), "TextInput"); + fn persisted_enum_values_round_trip() { + for challenge_type in [ + CaptchaType::Image, + CaptchaType::ReCaptchaV2, + CaptchaType::ReCaptchaV3, + CaptchaType::HCaptcha, + CaptchaType::TextInput, + ] { + assert_eq!(challenge_type.to_string().parse(), Ok(challenge_type)); + } + for status in [ + CaptchaStatus::Pending, + CaptchaStatus::Solved, + CaptchaStatus::Skipped, + CaptchaStatus::TimedOut, + ] { + assert_eq!(status.to_string().parse(), Ok(status)); + } } } diff --git a/src-tauri/src/domain/model/config.rs b/src-tauri/src/domain/model/config.rs index f5795951..08559fef 100644 --- a/src-tauri/src/domain/model/config.rs +++ b/src-tauri/src/domain/model/config.rs @@ -506,6 +506,24 @@ mod tests { assert_eq!(c.link_check_timeout_secs, 10); } + #[test] + fn captcha_timeout_defaults_to_two_minutes_and_is_clamped() { + let mut config = AppConfig::default(); + assert_eq!( + config.captcha_timeout_seconds, + DEFAULT_CAPTCHA_TIMEOUT_SECONDS + ); + + apply_patch( + &mut config, + &ConfigPatch { + captcha_timeout_seconds: Some(0), + ..Default::default() + }, + ); + assert_eq!(config.captcha_timeout_seconds, MIN_CAPTCHA_TIMEOUT_SECONDS); + } + #[test] fn test_apply_patch_updates_link_check_fields() { let mut config = AppConfig::default(); diff --git a/src/views/__tests__/CaptchaView.test.tsx b/src/views/__tests__/CaptchaView.test.tsx new file mode 100644 index 00000000..402dbec9 --- /dev/null +++ b/src/views/__tests__/CaptchaView.test.tsx @@ -0,0 +1,99 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CaptchaView } from "../CaptchaView"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockResolvedValue(vi.fn()) })); + +const mockInvoke = vi.mocked(invoke); + +const pendingCaptcha = { + id: "captcha-1", + downloadId: 42, + challengeType: "image", + challengeUrl: "https://hoster.example/file/42", + imageData: [137, 80, 78, 71], + status: "pending", + solver: null, + attempts: 0, + createdAt: Date.now() - 1_000, + expiresAt: Date.now() + 60_000, + resolvedAt: null, + durationMs: null, + failureReason: null, +}; + +function renderView() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +beforeEach(() => { + window.localStorage.setItem("i18nextLng", "en"); + mockInvoke.mockReset(); + mockInvoke.mockImplementation(async (command: string) => { + if (command === "captcha_list") return [pendingCaptcha]; + return null; + }); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: vi.fn(() => "blob:captcha"), + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn(), + }); +}); + +describe("CaptchaView", () => { + it("renders the pending image, input, timer and manual solver", async () => { + renderView(); + + expect(await screen.findByTestId("captcha-image")).toBeInTheDocument(); + expect(screen.getByLabelText("Captcha answer")).toBeInTheDocument(); + expect(screen.getByTestId("captcha-timer")).toHaveTextContent(/\d{2}:\d{2}/); + expect(screen.getByText("Manual solver")).toBeInTheDocument(); + }); + + it("submits the answer through captcha_solve", async () => { + renderView(); + const user = userEvent.setup(); + + await user.type(await screen.findByLabelText("Captcha answer"), "abc123"); + await user.click(screen.getByRole("button", { name: "Solve" })); + + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("captcha_solve", { + challengeId: "captcha-1", + solution: "abc123", + }), + ); + }); + + it("exposes skip and retry actions", async () => { + renderView(); + const user = userEvent.setup(); + + await user.click(await screen.findByRole("button", { name: "Skip" })); + await user.click(screen.getByRole("button", { name: "Retry" })); + + expect(mockInvoke).toHaveBeenCalledWith("captcha_skip", { challengeId: "captcha-1" }); + expect(mockInvoke).toHaveBeenCalledWith("captcha_retry", { challengeId: "captcha-1" }); + }); + + it("renders an empty queue", async () => { + mockInvoke.mockResolvedValue([]); + renderView(); + + expect(await screen.findByText("No CAPTCHA waiting")).toBeInTheDocument(); + }); +}); From 1cfbf1e591c043437163ef34a92190a418f09f5d Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:34:33 +0200 Subject: [PATCH 2/4] feat(captcha): implement persistent challenge pipeline --- CHANGELOG.md | 3 +- .../driven/config/toml_config_store.rs | 5 + .../src/adapters/driven/event/tauri_bridge.rs | 49 +- .../driven/logging/download_log_bridge.rs | 35 ++ .../driven/network/download_engine.rs | 50 +- .../driven/network/download_engine_tests.rs | 25 + .../adapters/driven/plugin/hoster_contract.rs | 39 +- .../driven/plugin/hoster_contract_tests.rs | 19 +- .../adapters/driven/sqlite/captcha_repo.rs | 130 ++++- .../driven/sqlite/captcha_repo_tests.rs | 19 +- .../driven/sqlite/entities/captcha_log.rs | 100 ++++ .../adapters/driven/sqlite/entities/mod.rs | 1 + .../m20260719_000011_create_captcha_log.rs | 96 ++++ .../adapters/driven/sqlite/migrations/mod.rs | 2 + src-tauri/src/adapters/driven/sqlite/mod.rs | 1 - src-tauri/src/adapters/driving/tauri_ipc.rs | 98 +++- src-tauri/src/application/command_bus.rs | 22 + .../application/commands/cancel_download.rs | 118 ++++- src-tauri/src/application/commands/captcha.rs | 453 ++++++++++++++++++ .../src/application/commands/captcha_tests.rs | 197 +++++++- .../commands/hoster_download_source.rs | 7 + src-tauri/src/application/commands/mod.rs | 46 ++ .../application/commands/remove_download.rs | 7 +- .../src/application/commands/resolve_links.rs | 3 +- .../commands/resolve_links_hoster_tests.rs | 33 +- .../commands/resolve_links_tests.rs | 1 + .../resolve_premium_source_test_support.rs | 1 + .../src/application/commands/tests_support.rs | 11 + src-tauri/src/application/queries/captcha.rs | 32 ++ src-tauri/src/application/queries/mod.rs | 12 + src-tauri/src/application/query_bus.rs | 22 +- .../application/read_models/captcha_view.rs | 91 ++++ src-tauri/src/application/read_models/mod.rs | 1 + .../src/application/services/queue_manager.rs | 8 + .../application/services/startup_recovery.rs | 67 +++ src-tauri/src/domain/error.rs | 7 + src-tauri/src/domain/event.rs | 30 ++ src-tauri/src/domain/model/captcha.rs | 414 +++++++++++++++- src-tauri/src/domain/model/config.rs | 19 + src-tauri/src/domain/model/download.rs | 11 + src-tauri/src/domain/model/mod.rs | 4 +- .../domain/ports/driven/captcha_repository.rs | 24 + .../src/domain/ports/driven/captcha_solver.rs | 19 + .../src/domain/ports/driven/hoster_link.rs | 11 + src-tauri/src/domain/ports/driven/mod.rs | 6 +- src-tauri/src/lib.rs | 48 +- src/api/queries.ts | 7 + .../__tests__/ClipboardIndicator.test.tsx | 1 + src/hooks/__tests__/useAppEffects.test.ts | 1 + src/hooks/__tests__/useDownloadEvents.test.ts | 9 +- src/hooks/useCaptchaQueue.ts | 38 ++ src/hooks/useDownloadEvents.ts | 6 + src/i18n/__tests__/issue30-ui-fr.test.tsx | 24 +- src/i18n/locales/en.json | 39 ++ src/i18n/locales/fr.json | 39 ++ src/layouts/__tests__/AppLayout.test.tsx | 1 + src/stores/__tests__/settingsStore.test.ts | 1 + src/types/captcha.ts | 19 + src/types/events.ts | 10 + src/types/settings.ts | 1 + src/views/CaptchaChallengePanel.tsx | 112 +++++ src/views/CaptchaSolverSettings.tsx | 54 +++ src/views/CaptchaView.tsx | 80 +++- .../__tests__/LinkGrabberView.test.tsx | 1 + .../SettingsView/__tests__/Sections.test.tsx | 1 + .../__tests__/SettingsView.test.tsx | 1 + src/views/__tests__/CaptchaView.test.tsx | 6 +- 67 files changed, 2740 insertions(+), 108 deletions(-) create mode 100644 src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs create mode 100644 src-tauri/src/adapters/driven/sqlite/migrations/m20260719_000011_create_captcha_log.rs create mode 100644 src-tauri/src/application/commands/captcha.rs create mode 100644 src-tauri/src/application/queries/captcha.rs create mode 100644 src-tauri/src/application/read_models/captcha_view.rs create mode 100644 src-tauri/src/domain/ports/driven/captcha_repository.rs create mode 100644 src-tauri/src/domain/ports/driven/captcha_solver.rs create mode 100644 src/hooks/useCaptchaQueue.ts create mode 100644 src/types/captcha.ts create mode 100644 src/views/CaptchaChallengePanel.tsx create mode 100644 src/views/CaptchaSolverSettings.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index aeef47c1..057fd648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - CAPTCHA pipeline: persistent manual challenge queue, solve/skip/retry - actions, timeout handling, and automatic download resumption (MAT-140). + actions, timeout handling, automatic download resumption, and redacted + challenge history (MAT-140). ### Security diff --git a/src-tauri/src/adapters/driven/config/toml_config_store.rs b/src-tauri/src/adapters/driven/config/toml_config_store.rs index 568ff4b4..761ccf88 100644 --- a/src-tauri/src/adapters/driven/config/toml_config_store.rs +++ b/src-tauri/src/adapters/driven/config/toml_config_store.rs @@ -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, @@ -225,6 +228,7 @@ impl From 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, @@ -283,6 +287,7 @@ impl TryFrom 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, history_retention_days: normalize_history_retention_days(d.history_retention_days), account_selection_strategy, proxy_type: d.proxy_type, diff --git a/src-tauri/src/adapters/driven/event/tauri_bridge.rs b/src-tauri/src/adapters/driven/event/tauri_bridge.rs index c57f975f..f91e22dc 100644 --- a/src-tauri/src/adapters/driven/event/tauri_bridge.rs +++ b/src-tauri/src/adapters/driven/event/tauri_bridge.rs @@ -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 { .. } + ) } 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", @@ -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", @@ -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 } @@ -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 }) diff --git a/src-tauri/src/adapters/driven/logging/download_log_bridge.rs b/src-tauri/src/adapters/driven/logging/download_log_bridge.rs index 8a959c13..3da36e9f 100644 --- a/src-tauri/src/adapters/driven/logging/download_log_bridge.rs +++ b/src-tauri/src/adapters/driven/logging/download_log_bridge.rs @@ -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()); } @@ -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()); } @@ -142,6 +176,7 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) { ); } DomainEvent::DownloadProgress { .. } + | DomainEvent::CaptchaRequired { .. } | DomainEvent::DownloadCompletedPersisted { .. } | DomainEvent::DownloadPrioritySet { .. } | DomainEvent::QueueReordered { .. } diff --git a/src-tauri/src/adapters/driven/network/download_engine.rs b/src-tauri/src/adapters/driven/network/download_engine.rs index 13225fd9..5adb9409 100644 --- a/src-tauri/src/adapters/driven/network/download_engine.rs +++ b/src-tauri/src/adapters/driven/network/download_engine.rs @@ -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, @@ -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() @@ -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; } diff --git a/src-tauri/src/adapters/driven/network/download_engine_tests.rs b/src-tauri/src/adapters/driven/network/download_engine_tests.rs index ea77f44e..2a15d0cc 100644 --- a/src-tauri/src/adapters/driven/network/download_engine_tests.rs +++ b/src-tauri/src/adapters/driven/network/download_engine_tests.rs @@ -6,6 +6,8 @@ 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; @@ -13,6 +15,29 @@ 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(); diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs index 0543f220..23550205 100644 --- a/src-tauri/src/adapters/driven/plugin/hoster_contract.rs +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs @@ -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; @@ -31,6 +34,10 @@ struct HosterFile { headers: BTreeMap, traffic_used_bytes: Option, traffic_total_bytes: Option, + #[serde(default)] + requires_captcha: bool, + captcha_type: Option, + captcha_image_data: Option>, } #[cfg(test)] @@ -58,8 +65,31 @@ pub(super) fn parse_hoster_links(payload: &str) -> Result()?; + if file.captcha_image_data.as_ref().is_some_and(|data| { + 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() @@ -75,11 +105,12 @@ pub(super) fn parse_hoster_links(payload: &str) -> Result Result<(), DomainError> { + let active = captcha_log::ActiveModel::from_domain(challenge)?; + block_on(async { + captcha_log::Entity::insert(active) + .on_conflict( + OnConflict::column(captcha_log::Column::Id) + .update_columns([ + captcha_log::Column::DownloadId, + captcha_log::Column::ChallengeType, + captcha_log::Column::ChallengeUrl, + captcha_log::Column::ImageData, + captcha_log::Column::Status, + captcha_log::Column::Solver, + captcha_log::Column::Attempts, + captcha_log::Column::ExpiresAt, + captcha_log::Column::ResolvedAt, + captcha_log::Column::DurationMs, + captcha_log::Column::FailureReason, + ]) + .to_owned(), + ) + .exec(&self.db) + .await + .map_err(map_db_err)?; + Ok(()) + }) + } + + fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError> { + let id = id.to_string(); + block_on(async { + captcha_log::Entity::find_by_id(id) + .one(&self.db) + .await + .map_err(map_db_err)? + .map(captcha_log::Model::into_domain) + .transpose() + }) + } + + fn list(&self) -> Result, DomainError> { + block_on(async { + let models = captcha_log::Model::find_by_statement(Statement::from_string( + DatabaseBackend::Sqlite, + CAPTCHA_METADATA_QUERY.to_string(), + )) + .all(&self.db) + .await + .map_err(map_db_err)?; + models + .into_iter() + .map(captcha_log::Model::into_domain) + .collect() + }) + } + + fn list_pending(&self) -> Result, DomainError> { + block_on(async { + let models = captcha_log::Model::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + PENDING_CAPTCHA_METADATA_QUERY, + [CaptchaStatus::Pending.to_string().into()], + )) + .all(&self.db) + .await + .map_err(map_db_err)?; + models + .into_iter() + .map(captcha_log::Model::into_domain) + .collect() + }) + } + + fn find_next_pending(&self) -> Result, DomainError> { + block_on(async { + captcha_log::Entity::find() + .filter(captcha_log::Column::Status.eq(CaptchaStatus::Pending.to_string())) + .order_by_asc(captcha_log::Column::CreatedAt) + .one(&self.db) + .await + .map_err(map_db_err)? + .map(captcha_log::Model::into_domain) + .transpose() + }) + } + + fn find_pending_by_download( + &self, + download_id: DownloadId, + ) -> Result, DomainError> { + let download_id = i64::try_from(download_id.0).map_err(|_| { + DomainError::ValidationError("CAPTCHA download id exceeds i64::MAX".into()) + })?; + block_on(async { + captcha_log::Entity::find() + .filter(captcha_log::Column::DownloadId.eq(download_id)) + .filter(captcha_log::Column::Status.eq(CaptchaStatus::Pending.to_string())) + .order_by_asc(captcha_log::Column::CreatedAt) + .one(&self.db) + .await + .map_err(map_db_err)? + .map(captcha_log::Model::into_domain) + .transpose() + }) + } +} diff --git a/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs index e455b127..2b5ada24 100644 --- a/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs +++ b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs @@ -16,7 +16,7 @@ fn challenge(id: &str, download_id: u64) -> CaptchaChallenge { 61_000, ) .expect("valid challenge") - .with_image_data(vec![137, 80, 78, 71]) + .with_image_data(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec()) .expect("bounded image") } @@ -29,11 +29,24 @@ async fn captcha_log_round_trip_and_pending_query() { repo.save(&item).expect("save pending"); let pending = repo.list_pending().expect("list pending"); assert_eq!(pending.len(), 1); - assert_eq!(pending[0].image_data(), Some([137, 80, 78, 71].as_slice())); + assert!(pending[0].image_data().is_none()); + assert_eq!(pending[0].url(), "[redacted]"); + assert_eq!( + repo.find_next_pending() + .expect("find next pending") + .expect("pending challenge") + .id(), + item.id() + ); item.solve(4_000, "manual").expect("solve"); repo.save(&item).expect("save solved"); assert!(repo.list_pending().expect("list pending").is_empty()); + assert!( + repo.find_next_pending() + .expect("find next pending") + .is_none() + ); let stored = repo .find_by_id(item.id()) @@ -42,6 +55,8 @@ async fn captcha_log_round_trip_and_pending_query() { assert_eq!(stored.status(), CaptchaStatus::Solved); assert_eq!(stored.solver(), Some("manual")); assert_eq!(stored.duration_ms(), Some(3_000)); + assert!(stored.image_data().is_none()); + assert_eq!(stored.url(), "[redacted]"); } #[tokio::test] diff --git a/src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs b/src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs new file mode 100644 index 00000000..fd398107 --- /dev/null +++ b/src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs @@ -0,0 +1,100 @@ +use sea_orm::entity::prelude::*; + +use crate::domain::error::DomainError; +use crate::domain::model::captcha::{ + CaptchaChallenge, CaptchaChallengeRecord, CaptchaId, CaptchaStatus, CaptchaType, +}; +use crate::domain::model::download::DownloadId; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "captcha_log")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub download_id: i64, + pub challenge_type: String, + pub challenge_url: String, + pub image_data: Option>, + pub status: String, + pub solver: Option, + pub attempts: i32, + pub created_at: i64, + pub expires_at: i64, + pub resolved_at: Option, + pub duration_ms: Option, + pub failure_reason: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl Model { + pub fn into_domain(self) -> Result { + CaptchaChallenge::reconstruct(CaptchaChallengeRecord { + id: CaptchaId::new(self.id), + download_id: DownloadId(to_u64(self.download_id, "download_id")?), + challenge_type: self.challenge_type.parse::()?, + url: self.challenge_url, + image_data: self.image_data, + status: self.status.parse::()?, + solver: self.solver, + attempts: u32::try_from(self.attempts).map_err(|_| invalid_integer("attempts"))?, + created_at: to_u64(self.created_at, "created_at")?, + expires_at: to_u64(self.expires_at, "expires_at")?, + resolved_at: self + .resolved_at + .map(|value| to_u64(value, "resolved_at")) + .transpose()?, + duration_ms: self + .duration_ms + .map(|value| to_u64(value, "duration_ms")) + .transpose()?, + failure_reason: self.failure_reason, + }) + } +} + +impl ActiveModel { + pub fn from_domain(challenge: &CaptchaChallenge) -> Result { + use sea_orm::ActiveValue::Set; + + Ok(Self { + id: Set(challenge.id().to_string()), + download_id: Set(to_i64(challenge.download_id().0, "download id")?), + challenge_type: Set(challenge.challenge_type().to_string()), + challenge_url: Set(challenge.url().to_string()), + image_data: Set(challenge.image_data().map(<[u8]>::to_vec)), + status: Set(challenge.status().to_string()), + solver: Set(challenge.solver().map(str::to_string)), + attempts: Set( + i32::try_from(challenge.attempts()).map_err(|_| invalid_integer("attempts"))? + ), + created_at: Set(to_i64(challenge.created_at(), "created_at")?), + expires_at: Set(to_i64(challenge.expires_at(), "expires_at")?), + resolved_at: Set(challenge + .resolved_at() + .map(|value| to_i64(value, "resolved_at")) + .transpose()?), + duration_ms: Set(challenge + .duration_ms() + .map(|value| to_i64(value, "duration_ms")) + .transpose()?), + failure_reason: Set(challenge.failure_reason().map(str::to_string)), + }) + } +} + +fn to_u64(value: i64, field: &str) -> Result { + u64::try_from(value).map_err(|_| invalid_integer(field)) +} + +fn to_i64(value: u64, field: &str) -> Result { + i64::try_from(value) + .map_err(|_| DomainError::ValidationError(format!("CAPTCHA {field} exceeds i64::MAX"))) +} + +fn invalid_integer(field: &str) -> DomainError { + DomainError::StorageError(format!("captcha_log contains an invalid {field}")) +} diff --git a/src-tauri/src/adapters/driven/sqlite/entities/mod.rs b/src-tauri/src/adapters/driven/sqlite/entities/mod.rs index a25a4603..31affef7 100644 --- a/src-tauri/src/adapters/driven/sqlite/entities/mod.rs +++ b/src-tauri/src/adapters/driven/sqlite/entities/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod captcha_log; pub mod download; pub mod download_segment; pub mod history; diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/m20260719_000011_create_captcha_log.rs b/src-tauri/src/adapters/driven/sqlite/migrations/m20260719_000011_create_captcha_log.rs new file mode 100644 index 00000000..a129d687 --- /dev/null +++ b/src-tauri/src/adapters/driven/sqlite/migrations/m20260719_000011_create_captcha_log.rs @@ -0,0 +1,96 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CaptchaLog::Table) + .if_not_exists() + .col( + ColumnDef::new(CaptchaLog::Id) + .string() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(CaptchaLog::DownloadId) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(CaptchaLog::ChallengeType) + .string() + .not_null(), + ) + .col(ColumnDef::new(CaptchaLog::ChallengeUrl).string().not_null()) + .col(ColumnDef::new(CaptchaLog::ImageData).binary().null()) + .col(ColumnDef::new(CaptchaLog::Status).string().not_null()) + .col(ColumnDef::new(CaptchaLog::Solver).string().null()) + .col(ColumnDef::new(CaptchaLog::Attempts).integer().not_null()) + .col( + ColumnDef::new(CaptchaLog::CreatedAt) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(CaptchaLog::ExpiresAt) + .big_integer() + .not_null(), + ) + .col(ColumnDef::new(CaptchaLog::ResolvedAt).big_integer().null()) + .col(ColumnDef::new(CaptchaLog::DurationMs).big_integer().null()) + .col(ColumnDef::new(CaptchaLog::FailureReason).string().null()) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_captcha_log_status_created") + .table(CaptchaLog::Table) + .col(CaptchaLog::Status) + .col(CaptchaLog::CreatedAt) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_captcha_log_download_status") + .table(CaptchaLog::Table) + .col(CaptchaLog::DownloadId) + .col(CaptchaLog::Status) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CaptchaLog::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum CaptchaLog { + Table, + Id, + DownloadId, + ChallengeType, + ChallengeUrl, + ImageData, + Status, + Solver, + Attempts, + CreatedAt, + ExpiresAt, + ResolvedAt, + DurationMs, + FailureReason, +} diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs index 9b7cc7b1..7bc52989 100644 --- a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs +++ b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs @@ -10,6 +10,7 @@ mod m20260429_000007_create_packages; mod m20260430_000008_add_package_external_id; mod m20260505_000009_add_mirrors; mod m20260716_000010_wire_premium_accounts; +mod m20260719_000011_create_captcha_log; pub struct Migrator; @@ -27,6 +28,7 @@ impl MigratorTrait for Migrator { Box::new(m20260430_000008_add_package_external_id::Migration), Box::new(m20260505_000009_add_mirrors::Migration), Box::new(m20260716_000010_wire_premium_accounts::Migration), + Box::new(m20260719_000011_create_captcha_log::Migration), ] } } diff --git a/src-tauri/src/adapters/driven/sqlite/mod.rs b/src-tauri/src/adapters/driven/sqlite/mod.rs index 29a49d94..60597af0 100644 --- a/src-tauri/src/adapters/driven/sqlite/mod.rs +++ b/src-tauri/src/adapters/driven/sqlite/mod.rs @@ -1,5 +1,4 @@ pub mod account_repo; -#[cfg(test)] pub mod captcha_repo; pub mod connection; pub mod download_read_repo; diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index e4c6cdf8..72c16ef7 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -26,19 +26,20 @@ use crate::application::commands::{ PauseDownloadCommand, PurgeHistoryCommand, RedownloadCommand, RedownloadSource, RemoveDownloadCommand, RemoveDownloadFromPackageCommand, ReorderQueueCommand, ReportBrokenPluginCommand, ResolveLinksCommand, ResolvedLinkDto, ResumeAllDownloadsCommand, - ResumeDownloadCommand, RetryDownloadCommand, SetPackagePasswordCommand, - SetPackagePriorityCommand, SetPriorityCommand, StartDownloadCommand, - TogglePackageAutoExtractCommand, UninstallPluginCommand, UpdateAccountCommand, - UpdateConfigCommand, UpdatePackageCommand, UpdatePluginConfigCommand, ValidateAccountCommand, - ValidationOutcomeDto, VerifyChecksumCommand, VerifyChecksumOutcome, + ResumeDownloadCommand, RetryCaptchaCommand, RetryDownloadCommand, SetPackagePasswordCommand, + SetPackagePriorityCommand, SetPriorityCommand, SkipCaptchaCommand, SolveCaptchaCommand, + StartDownloadCommand, TogglePackageAutoExtractCommand, UninstallPluginCommand, + UpdateAccountCommand, UpdateConfigCommand, UpdatePackageCommand, UpdatePluginConfigCommand, + ValidateAccountCommand, ValidationOutcomeDto, VerifyChecksumCommand, VerifyChecksumOutcome, }; use crate::application::error::AppError; use crate::application::queries::{ - AccountFilter, CountDownloadsByStateQuery, DetectDuplicatesQuery, DuplicateSource, - GetAccountQuery, GetAccountTrafficQuery, GetDownloadDetailQuery, GetDownloadsQuery, - GetHistoryEntryQuery, GetPackageQuery, GetPluginConfigQuery, GetStatsQuery, ListAccountsQuery, - ListHistoryQuery, ListPackageDownloadsQuery, ListPackagesQuery, ListPluginsQuery, - SearchHistoryQuery, TopModulesQuery, + AccountFilter, CaptchaGetPendingQuery, CaptchaListQuery, CountDownloadsByStateQuery, + DetectDuplicatesQuery, DuplicateSource, GetAccountQuery, GetAccountTrafficQuery, + GetDownloadDetailQuery, GetDownloadsQuery, GetHistoryEntryQuery, GetPackageQuery, + GetPluginConfigQuery, GetStatsQuery, ListAccountsQuery, ListHistoryQuery, + ListPackageDownloadsQuery, ListPackagesQuery, ListPluginsQuery, SearchHistoryQuery, + TopModulesQuery, }; /// IPC mirror of [`DuplicateSource`] — serialised as a lowercase tag @@ -61,6 +62,7 @@ impl From for DuplicateSourceDto { } use crate::application::query_bus::QueryBus; use crate::application::read_models::account_view::{AccountTrafficDto, AccountViewDto}; +use crate::application::read_models::captcha_view::CaptchaViewDto; use crate::application::read_models::download_detail_view::DownloadDetailViewDto; use crate::application::read_models::download_view::DownloadViewDto; use crate::application::read_models::history_view::HistoryViewDto; @@ -71,6 +73,7 @@ use crate::application::read_models::plugin_view::PluginViewDto; use crate::application::read_models::stats_view::{ModuleStatsDto, StatsViewDto}; use crate::domain::error::DomainError; use crate::domain::model::account::{AccountId, AccountType}; +use crate::domain::model::captcha::{CaptchaId, MAX_CAPTCHA_SOLUTION_BYTES}; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::download::{DownloadId, DownloadState}; use crate::domain::model::package::{PackageId, PackageSourceType}; @@ -175,6 +178,73 @@ pub async fn download_skip_wait(state: State<'_, AppState>, id: u64) -> Result<( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn captcha_solve( + state: State<'_, AppState>, + challenge_id: String, + solution: String, +) -> Result<(), String> { + if solution.trim().is_empty() || solution.len() > MAX_CAPTCHA_SOLUTION_BYTES { + return Err("CAPTCHA solution is empty or exceeds safety limits".into()); + } + state + .command_bus + .handle_captcha_solve(SolveCaptchaCommand { + challenge_id: parse_captcha_id(challenge_id)?, + solution, + }) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn captcha_skip(state: State<'_, AppState>, challenge_id: String) -> Result<(), String> { + state + .command_bus + .handle_captcha_skip(SkipCaptchaCommand { + challenge_id: parse_captcha_id(challenge_id)?, + }) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn captcha_retry(state: State<'_, AppState>, challenge_id: String) -> Result<(), String> { + state + .command_bus + .handle_captcha_retry(RetryCaptchaCommand { + challenge_id: parse_captcha_id(challenge_id)?, + }) + .await + .map_err(|error| error.to_string()) +} + +fn parse_captcha_id(value: String) -> Result { + CaptchaId::try_new(value).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn captcha_list(state: State<'_, AppState>) -> Result, String> { + state + .query_bus + .handle_captcha_list(CaptchaListQuery) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn captcha_get_pending( + state: State<'_, AppState>, + challenge_id: Option, +) -> Result, String> { + let challenge_id = challenge_id.map(parse_captcha_id).transpose()?; + state + .query_bus + .handle_captcha_get_pending(CaptchaGetPendingQuery { challenge_id }) + .await + .map_err(|error| error.to_string()) +} + /// Per-id failure entry surfaced in [`ChangeDirectoryBulkOutcomeDto`]. #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -1166,6 +1236,9 @@ pub struct SettingsDto { pub dynamic_split_enabled: bool, pub dynamic_split_min_remaining_mb: u64, + // CAPTCHA + pub captcha_timeout_seconds: u32, + // History pub history_retention_days: i64, @@ -1230,6 +1303,7 @@ impl From for SettingsDto { 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, @@ -1279,6 +1353,9 @@ pub struct ConfigPatchDto { pub dynamic_split_enabled: Option, pub dynamic_split_min_remaining_mb: Option, + // CAPTCHA + pub captcha_timeout_seconds: Option, + // History pub history_retention_days: Option, @@ -1343,6 +1420,7 @@ impl TryFrom for ConfigPatch { 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, history_retention_days: d.history_retention_days, account_selection_strategy, proxy_type: d.proxy_type, diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index d76af40d..12f34f59 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::Semaphore; +use crate::application::commands::captcha::CaptchaCommandHandler; use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::services::{AccountRotator, AccountSelector}; use crate::domain::error::DomainError; @@ -104,6 +105,7 @@ pub struct CommandBus { account_operation_locks: Arc, account_clock: Option>, passphrase_codec: Option>, + captcha_handler: Option>, /// Serializes queue-position allocation across handlers. Without this, /// two concurrent move-to-top/move-to-bottom/start-download calls can /// observe the same min/max and write colliding `queue_position` @@ -175,6 +177,7 @@ impl CommandBus { account_operation_locks: Arc::new(AccountOperationLocks::default()), account_clock: None, passphrase_codec: None, + captcha_handler: None, queue_position_lock: tokio::sync::Mutex::new(()), link_check_limiter, } @@ -249,6 +252,25 @@ impl CommandBus { self } + pub fn with_captcha_handler(mut self, handler: Arc) -> Self { + self.captcha_handler = Some(handler); + self + } + + pub(crate) fn captcha_handler( + &self, + ) -> Result<&CaptchaCommandHandler, crate::application::error::AppError> { + self.captcha_handler.as_deref().ok_or_else(|| { + crate::application::error::AppError::Validation( + "CAPTCHA command handler not configured".into(), + ) + }) + } + + pub(crate) fn captcha_handler_opt(&self) -> Option<&CaptchaCommandHandler> { + self.captcha_handler.as_deref() + } + pub fn account_repo(&self) -> Option<&dyn AccountRepository> { self.account_repo.as_deref() } diff --git a/src-tauri/src/application/commands/cancel_download.rs b/src-tauri/src/application/commands/cancel_download.rs index 4a205858..22230909 100644 --- a/src-tauri/src/application/commands/cancel_download.rs +++ b/src-tauri/src/application/commands/cancel_download.rs @@ -15,11 +15,16 @@ impl CommandBus { .find_by_id(cmd.id)? .ok_or_else(|| AppError::NotFound(format!("Download {} not found", cmd.id.0)))?; + let was_waiting_for_captcha = match self.captcha_handler_opt() { + Some(handler) => handler.skip_pending_for_download(cmd.id).await?, + None => false, + }; + // Cancel engine if download is active let is_active = matches!( download.state(), DownloadState::Downloading | DownloadState::Waiting - ); + ) && !was_waiting_for_captcha; if is_active { self.download_engine().cancel(cmd.id)?; } @@ -56,9 +61,11 @@ mod tests { use crate::application::command_bus::CommandBus; use crate::application::commands::CancelDownloadCommand; + use crate::application::commands::captcha::{CaptchaCommandHandler, ManualCaptchaSolver}; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; + use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType}; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; @@ -66,10 +73,62 @@ mod tests { use crate::domain::model::meta::DownloadMeta; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::{ - ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, - EventBus, FileStorage, HttpClient, PluginLoader, + CaptchaRepository, ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, + DownloadRepository, EventBus, FileStorage, HttpClient, PluginLoader, }; + struct MemoryCaptchaRepo(Mutex>); + + impl MemoryCaptchaRepo { + fn new(challenge: CaptchaChallenge) -> Self { + Self(Mutex::new(Some(challenge))) + } + } + + impl CaptchaRepository for MemoryCaptchaRepo { + fn save(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { + *self.0.lock().unwrap() = Some(challenge.clone()); + Ok(()) + } + + fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .as_ref() + .filter(|challenge| challenge.id() == id) + .cloned()) + } + + fn list(&self) -> Result, DomainError> { + Ok(self.0.lock().unwrap().iter().cloned().collect()) + } + + fn list_pending(&self) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|challenge| challenge.status() == CaptchaStatus::Pending) + .cloned() + .collect()) + } + } + + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1 + } + + fn now_unix_ms(&self) -> u64 { + 1_000 + } + } + struct MockDownloadRepo { store: Mutex>, } @@ -427,4 +486,57 @@ mod tests { &[DomainEvent::DownloadRemoved { id: DownloadId(2) }] ); } + + #[tokio::test] + async fn cancel_captcha_wait_does_not_cancel_engine_or_free_the_slot_twice() { + let repo = Arc::new(MockDownloadRepo::new()); + let engine = Arc::new(MockDownloadEngine::new()); + let events = Arc::new(MockEventBus::new()); + let mut download = make_download(3); + download.start().unwrap(); + download.wait().unwrap(); + repo.save(&download).unwrap(); + let captcha = CaptchaChallenge::new( + CaptchaId::new("captcha-3"), + DownloadId(3), + CaptchaType::Image, + "https://hoster.example/file".into(), + 1_000, + 121_000, + ) + .unwrap(); + let captcha_repo = Arc::new(MemoryCaptchaRepo::new(captcha)); + let handler = Arc::new(CaptchaCommandHandler::new( + captcha_repo.clone(), + repo.clone(), + events.clone(), + Arc::new(MockConfigStore), + Arc::new(FixedClock), + vec![Arc::new(ManualCaptchaSolver)], + )); + let bus = make_command_bus(repo.clone(), engine.clone(), events.clone()) + .with_captcha_handler(handler); + + bus.handle_cancel_download(CancelDownloadCommand { id: DownloadId(3) }) + .await + .expect("cancel CAPTCHA wait"); + + assert!(engine.cancelled.lock().unwrap().is_empty()); + assert!(repo.find_by_id(DownloadId(3)).unwrap().is_none()); + assert_eq!( + captcha_repo + .find_by_id(&CaptchaId::new("captcha-3")) + .unwrap() + .unwrap() + .status(), + CaptchaStatus::Skipped + ); + let emitted = events.events.lock().unwrap(); + assert!(!emitted.iter().any( + |event| matches!(event, DomainEvent::DownloadCancelled { id } if *id == DownloadId(3)) + )); + assert!(emitted.iter().any( + |event| matches!(event, DomainEvent::DownloadRemoved { id } if *id == DownloadId(3)) + )); + } } diff --git a/src-tauri/src/application/commands/captcha.rs b/src-tauri/src/application/commands/captcha.rs new file mode 100644 index 00000000..cdafaa66 --- /dev/null +++ b/src-tauri/src/application/commands/captcha.rs @@ -0,0 +1,453 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{ + EnqueueCaptchaCommand, RetryCaptchaCommand, SkipCaptchaCommand, SolveCaptchaCommand, + TimeoutCaptchaCommand, +}; +use crate::application::command_bus::CommandBus; +use crate::application::error::AppError; +use crate::domain::error::DomainError; +use crate::domain::event::DomainEvent; +use crate::domain::model::captcha::{ + CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType, MAX_CAPTCHA_SOLUTION_BYTES, +}; +use crate::domain::ports::driven::{ + CaptchaRepository, CaptchaSolver, CaptchaSolverOutcome, Clock, ConfigStore, DownloadRepository, + EventBus, +}; +use crate::domain::ports::driving::CommandHandler; + +pub struct ManualCaptchaSolver; + +impl CaptchaSolver for ManualCaptchaSolver { + fn name(&self) -> &str { + "manual" + } + + fn solve( + &self, + challenge: &CaptchaChallenge, + solution: &str, + ) -> Result { + if !matches!( + challenge.challenge_type(), + CaptchaType::Image | CaptchaType::TextInput + ) { + return Ok(CaptchaSolverOutcome::Unavailable); + } + if solution.trim().is_empty() || solution.len() > MAX_CAPTCHA_SOLUTION_BYTES { + return Ok(CaptchaSolverOutcome::Rejected); + } + Ok(CaptchaSolverOutcome::Solved) + } +} + +#[derive(Clone)] +pub struct CaptchaCommandHandler { + captchas: Arc, + downloads: Arc, + events: Arc, + config: Arc, + clock: Arc, + solvers: Arc>>, + timers: Arc>>, + mutation_lock: Arc>, +} + +impl CaptchaCommandHandler { + pub fn new( + captchas: Arc, + downloads: Arc, + events: Arc, + config: Arc, + clock: Arc, + solvers: Vec>, + ) -> Self { + Self { + captchas, + downloads, + events, + config, + clock, + solvers: Arc::new(solvers), + timers: Arc::new(Mutex::new(HashMap::new())), + mutation_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } + + pub fn start_listening(self: &Arc) { + let handler = Arc::clone(self); + self.events.subscribe(Box::new(move |event| { + let DomainEvent::CaptchaRequired { + download_id, + challenge_type, + challenge_url, + image_data, + } = event + else { + return; + }; + let handler = Arc::clone(&handler); + let command = EnqueueCaptchaCommand { + download_id: *download_id, + challenge_type: *challenge_type, + challenge_url: challenge_url.clone(), + image_data: image_data.clone(), + }; + tokio::spawn(async move { + if let Err(error) = CommandHandler::handle(handler.as_ref(), command).await { + tracing::error!(error = %error, "failed to enqueue CAPTCHA challenge"); + } + }); + })); + } + + pub async fn restore_pending(&self) -> Result<(), DomainError> { + for challenge in self.captchas.list_pending()? { + self.schedule_timeout(&challenge); + } + Ok(()) + } + + fn timeout_deadline(&self, now_ms: u64) -> Result { + let seconds = self.config.get_config()?.captcha_timeout_seconds; + Ok(now_ms.saturating_add(u64::from(seconds).saturating_mul(1_000))) + } + + fn schedule_timeout(&self, challenge: &CaptchaChallenge) { + self.cancel_timer(challenge.id()); + let token = CancellationToken::new(); + timer_map(&self.timers).insert(challenge.id().clone(), token.clone()); + let handler = self.clone(); + let challenge_id = challenge.id().clone(); + let expected_expires_at = challenge.expires_at(); + let delay_ms = challenge + .expires_at() + .saturating_sub(self.clock.now_unix_ms()); + tokio::spawn(async move { + let mut delay = Duration::from_millis(delay_ms); + loop { + tokio::select! { + _ = token.cancelled() => break, + _ = tokio::time::sleep(delay) => { + let remaining = expected_expires_at + .saturating_sub(handler.clock.now_unix_ms()); + if remaining > 0 { + delay = Duration::from_millis(remaining); + continue; + } + match CommandHandler::handle( + &handler, + TimeoutCaptchaCommand { + challenge_id: challenge_id.clone(), + expected_expires_at, + }, + ).await { + Ok(()) => break, + Err(error) => { + tracing::error!(error = %error, "failed to time out CAPTCHA challenge; retrying"); + delay = Duration::from_secs(5); + } + } + } + } + } + }); + } + + fn cancel_timer(&self, id: &CaptchaId) { + if let Some(token) = timer_map(&self.timers).remove(id) { + token.cancel(); + } + } + + fn find(&self, id: &CaptchaId) -> Result { + self.captchas + .find_by_id(id)? + .ok_or_else(|| DomainError::NotFound(format!("CAPTCHA {id}"))) + } + + fn select_solver( + &self, + challenge: &CaptchaChallenge, + solution: &str, + ) -> Result { + for solver in self.solvers.iter() { + match solver.solve(challenge, solution)? { + CaptchaSolverOutcome::Solved => return Ok(solver.name().to_string()), + CaptchaSolverOutcome::Rejected => { + return Err(DomainError::ValidationError( + "CAPTCHA solution was rejected".into(), + )); + } + CaptchaSolverOutcome::Unavailable => {} + } + } + Err(DomainError::ValidationError( + "No solver supports this CAPTCHA type".into(), + )) + } +} + +impl CommandHandler for CaptchaCommandHandler { + type Output = CaptchaId; + + async fn handle(&self, command: EnqueueCaptchaCommand) -> Result { + let download_id = command.download_id; + let result = self.enqueue(command).await; + if result.is_err() { + self.events.publish(DomainEvent::DownloadFailed { + id: download_id, + error: "CAPTCHA challenge could not be queued".into(), + }); + } + result + } +} + +impl CaptchaCommandHandler { + async fn enqueue(&self, command: EnqueueCaptchaCommand) -> Result { + let _guard = self.mutation_lock.lock().await; + if let Some(existing) = self + .captchas + .find_pending_by_download(command.download_id)? + { + return Ok(existing.id().clone()); + } + let now = self.clock.now_unix_ms(); + let expires_at = self.timeout_deadline(now)?; + let mut challenge = CaptchaChallenge::new( + CaptchaId::new(Uuid::new_v4().to_string()), + command.download_id, + command.challenge_type, + command.challenge_url, + now, + expires_at, + )?; + if let Some(image) = command.image_data { + challenge = challenge.with_image_data(image)?; + } + let mut download = self + .downloads + .find_by_id(command.download_id)? + .ok_or_else(|| DomainError::NotFound(format!("download {}", command.download_id.0)))?; + let active_download = download.clone(); + let waiting_event = download.wait()?; + self.downloads.save(&download)?; + if let Err(error) = self.captchas.save(&challenge) { + if let Err(rollback_error) = self.downloads.save(&active_download) { + tracing::error!(error = %rollback_error, "failed to roll back CAPTCHA enqueue"); + } + return Err(error); + } + self.events.publish(waiting_event); + self.events.publish(DomainEvent::CaptchaPending { + challenge_id: challenge.id().clone(), + download_id: command.download_id, + }); + self.schedule_timeout(&challenge); + Ok(challenge.id().clone()) + } +} + +impl CommandHandler for CaptchaCommandHandler { + type Output = (); + + async fn handle(&self, command: SolveCaptchaCommand) -> Result<(), DomainError> { + let _guard = self.mutation_lock.lock().await; + let mut challenge = self.find(&command.challenge_id)?; + let now = self.clock.now_unix_ms(); + if challenge.is_expired(now) { + return Err(DomainError::ValidationError( + "CAPTCHA challenge has expired".into(), + )); + } + let solver = self.select_solver(&challenge, &command.solution)?; + let mut download = self + .downloads + .find_by_id(challenge.download_id())? + .ok_or_else(|| DomainError::NotFound("CAPTCHA download".into()))?; + let queued_event = download.queue_after_wait()?; + let pending_challenge = challenge.clone(); + challenge.solve(now, &solver)?; + self.captchas.save(&challenge)?; + if let Err(error) = self.downloads.save(&download) { + if let Err(rollback_error) = self.captchas.save(&pending_challenge) { + tracing::error!(error = %rollback_error, "failed to roll back CAPTCHA solve"); + } + return Err(error); + } + self.cancel_timer(challenge.id()); + self.events.publish(queued_event); + self.events.publish(DomainEvent::CaptchaSolved { + challenge_id: challenge.id().clone(), + download_id: challenge.download_id(), + solver, + duration_ms: challenge.duration_ms().unwrap_or_default(), + }); + Ok(()) + } +} + +impl CommandHandler for CaptchaCommandHandler { + type Output = (); + + async fn handle(&self, command: SkipCaptchaCommand) -> Result<(), DomainError> { + self.finish_as_failure(command.challenge_id, None).await + } +} + +impl CommandHandler for CaptchaCommandHandler { + type Output = (); + + async fn handle(&self, command: TimeoutCaptchaCommand) -> Result<(), DomainError> { + self.finish_as_failure(command.challenge_id, Some(command.expected_expires_at)) + .await + } +} + +impl CaptchaCommandHandler { + pub(crate) async fn skip_pending_for_download( + &self, + download_id: crate::domain::model::download::DownloadId, + ) -> Result { + let _guard = self.mutation_lock.lock().await; + let Some(challenge) = self.captchas.find_pending_by_download(download_id)? else { + return Ok(false); + }; + self.finish_as_failure_locked(challenge.id().clone(), None)?; + Ok(true) + } + + async fn finish_as_failure( + &self, + id: CaptchaId, + expected_expires_at: Option, + ) -> Result<(), DomainError> { + let _guard = self.mutation_lock.lock().await; + self.finish_as_failure_locked(id, expected_expires_at) + } + + fn finish_as_failure_locked( + &self, + id: CaptchaId, + expected_expires_at: Option, + ) -> Result<(), DomainError> { + let mut challenge = self.find(&id)?; + let now = self.clock.now_unix_ms(); + let timed_out = expected_expires_at.is_some(); + if timed_out + && (challenge.status() != CaptchaStatus::Pending + || expected_expires_at != Some(challenge.expires_at()) + || !challenge.is_expired(now)) + { + return Ok(()); + } + let pending_challenge = challenge.clone(); + let reason = if timed_out { + challenge.timeout(now)?; + "CAPTCHA timed out" + } else { + challenge.skip(now, "CAPTCHA skipped by user")?; + "CAPTCHA skipped by user" + }; + let download = self.downloads.find_by_id(challenge.download_id())?; + let mut failed_download = download.clone(); + if let Some(download) = failed_download.as_mut() { + download.fail(reason.to_string())?; + } + self.captchas.save(&challenge)?; + if let Some(download) = failed_download.as_ref() + && let Err(error) = self.downloads.save_failed(download, reason) + { + if let Err(rollback_error) = self.captchas.save(&pending_challenge) { + tracing::error!(error = %rollback_error, "failed to roll back CAPTCHA failure"); + } + return Err(error); + } + self.cancel_timer(challenge.id()); + let event = if timed_out { + DomainEvent::CaptchaTimedOut { + challenge_id: challenge.id().clone(), + download_id: challenge.download_id(), + duration_ms: challenge.duration_ms().unwrap_or_default(), + } + } else { + DomainEvent::CaptchaSkipped { + challenge_id: challenge.id().clone(), + download_id: challenge.download_id(), + reason: reason.to_string(), + } + }; + self.events.publish(event); + Ok(()) + } +} + +impl CommandHandler for CaptchaCommandHandler { + type Output = (); + + async fn handle(&self, command: RetryCaptchaCommand) -> Result<(), DomainError> { + let _guard = self.mutation_lock.lock().await; + let mut challenge = self.find(&command.challenge_id)?; + if challenge.status() == CaptchaStatus::Pending { + let now = self.clock.now_unix_ms(); + if challenge.is_expired(now) { + return Err(DomainError::ValidationError( + "CAPTCHA challenge has expired".into(), + )); + } + challenge.retry(now, self.timeout_deadline(now)?)?; + self.captchas.save(&challenge)?; + self.schedule_timeout(&challenge); + return Ok(()); + } + if challenge.status() == CaptchaStatus::Solved { + return Err(DomainError::ValidationError( + "Solved CAPTCHA cannot be retried".into(), + )); + } + let mut download = self + .downloads + .find_by_id(challenge.download_id())? + .ok_or_else(|| DomainError::NotFound("CAPTCHA download".into()))?; + let event = download.retry_manually()?; + self.downloads.save(&download)?; + self.events.publish(event); + Ok(()) + } +} + +impl CommandBus { + pub async fn handle_captcha_solve(&self, command: SolveCaptchaCommand) -> Result<(), AppError> { + CommandHandler::handle(self.captcha_handler()?, command) + .await + .map_err(AppError::Domain) + } + + pub async fn handle_captcha_skip(&self, command: SkipCaptchaCommand) -> Result<(), AppError> { + CommandHandler::handle(self.captcha_handler()?, command) + .await + .map_err(AppError::Domain) + } + + pub async fn handle_captcha_retry(&self, command: RetryCaptchaCommand) -> Result<(), AppError> { + CommandHandler::handle(self.captcha_handler()?, command) + .await + .map_err(AppError::Domain) + } +} + +fn timer_map( + timers: &Mutex>, +) -> std::sync::MutexGuard<'_, HashMap> { + match timers.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} diff --git a/src-tauri/src/application/commands/captcha_tests.rs b/src-tauri/src/application/commands/captcha_tests.rs index 9b403ee0..c39858fd 100644 --- a/src-tauri/src/application/commands/captcha_tests.rs +++ b/src-tauri/src/application/commands/captcha_tests.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use super::{ @@ -16,17 +16,32 @@ use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; use crate::domain::ports::driven::{CaptchaRepository, Clock, ConfigStore, DownloadRepository}; use crate::domain::ports::driving::CommandHandler; -struct MemoryCaptchaRepo(Mutex>); +struct MemoryCaptchaRepo { + items: Mutex>, + fail_next_save: AtomicBool, +} impl MemoryCaptchaRepo { fn new() -> Self { - Self(Mutex::new(HashMap::new())) + Self { + items: Mutex::new(HashMap::new()), + fail_next_save: AtomicBool::new(false), + } + } + + fn fail_next_save(&self) { + self.fail_next_save.store(true, Ordering::SeqCst); } } impl CaptchaRepository for MemoryCaptchaRepo { fn save(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { - self.0 + if self.fail_next_save.swap(false, Ordering::SeqCst) { + return Err(DomainError::StorageError( + "injected CAPTCHA save failure".into(), + )); + } + self.items .lock() .unwrap() .insert(challenge.id().clone(), challenge.clone()); @@ -34,16 +49,16 @@ impl CaptchaRepository for MemoryCaptchaRepo { } fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError> { - Ok(self.0.lock().unwrap().get(id).cloned()) + Ok(self.items.lock().unwrap().get(id).cloned()) } fn list(&self) -> Result, DomainError> { - Ok(self.0.lock().unwrap().values().cloned().collect()) + Ok(self.items.lock().unwrap().values().cloned().collect()) } fn list_pending(&self) -> Result, DomainError> { Ok(self - .0 + .items .lock() .unwrap() .values() @@ -112,6 +127,10 @@ fn fixture() -> Fixture { (handler, captchas, downloads, events, clock) } +fn png_image() -> Vec { + b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec() +} + async fn enqueue(handler: &CaptchaCommandHandler) -> CaptchaId { >::handle( handler, @@ -119,7 +138,7 @@ async fn enqueue(handler: &CaptchaCommandHandler) -> CaptchaId { download_id: DownloadId(42), challenge_type: CaptchaType::Image, challenge_url: "https://hoster.example/file".into(), - image_data: Some(vec![137, 80, 78, 71]), + image_data: Some(png_image()), }, ) .await @@ -146,6 +165,38 @@ async fn enqueue_parks_download_and_emits_pending() { assert!(events.snapshot().iter().any(|event| matches!(event, DomainEvent::CaptchaPending { challenge_id, download_id } if challenge_id == &id && *download_id == DownloadId(42)))); } +#[tokio::test] +async fn enqueue_failure_restores_the_download_and_emits_a_safe_failure() { + let (handler, captchas, downloads, events, _) = fixture(); + captchas.fail_next_save(); + + let result = >::handle( + &handler, + EnqueueCaptchaCommand { + download_id: DownloadId(42), + challenge_type: CaptchaType::Image, + challenge_url: "https://hoster.example/file".into(), + image_data: Some(png_image()), + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Downloading + ); + assert!(events.snapshot().iter().any(|event| matches!( + event, + DomainEvent::DownloadFailed { id, error } + if *id == DownloadId(42) && error == "CAPTCHA challenge could not be queued" + ))); +} + #[tokio::test] async fn manual_solve_logs_metadata_and_requeues_without_persisting_answer() { let (handler, captchas, downloads, events, clock) = fixture(); @@ -166,6 +217,7 @@ async fn manual_solve_logs_metadata_and_requeues_without_persisting_answer() { assert_eq!(stored.status(), CaptchaStatus::Solved); assert_eq!(stored.solver(), Some("manual")); assert_eq!(stored.duration_ms(), Some(3_000)); + assert!(stored.image_data().is_none()); assert_eq!( downloads .find_by_id(DownloadId(42)) @@ -177,6 +229,35 @@ async fn manual_solve_logs_metadata_and_requeues_without_persisting_answer() { assert!(events.snapshot().iter().any(|event| matches!(event, DomainEvent::CaptchaSolved { challenge_id, .. } if challenge_id == &id))); } +#[tokio::test] +async fn solve_rolls_back_the_challenge_when_download_persistence_fails() { + let (handler, captchas, downloads, _, _) = fixture(); + let id = enqueue(&handler).await; + downloads.fail_next_save(); + + let result = >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: "abc123".into(), + }, + ) + .await; + + assert!(result.is_err()); + let stored = captchas.find_by_id(&id).unwrap().unwrap(); + assert_eq!(stored.status(), CaptchaStatus::Pending); + assert!(stored.image_data().is_some()); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Waiting + ); +} + #[tokio::test] async fn skip_is_terminal_and_does_not_enter_automatic_retry() { let (handler, captchas, downloads, events, _) = fixture(); @@ -211,6 +292,66 @@ async fn skip_is_terminal_and_does_not_enter_automatic_retry() { ); } +#[tokio::test] +async fn skip_rolls_back_the_challenge_when_download_persistence_fails() { + let (handler, captchas, downloads, _, _) = fixture(); + let id = enqueue(&handler).await; + downloads.fail_next_save(); + + let result = >::handle( + &handler, + SkipCaptchaCommand { + challenge_id: id.clone(), + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::Pending + ); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Waiting + ); +} + +#[tokio::test] +async fn removing_a_captcha_download_terminalizes_its_pending_challenge() { + let (handler, captchas, downloads, _, _) = fixture(); + let id = enqueue(&handler).await; + + assert!( + handler + .skip_pending_for_download(DownloadId(42)) + .await + .expect("terminalize pending CAPTCHA") + ); + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::Skipped + ); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Error + ); + assert!( + !handler + .skip_pending_for_download(DownloadId(42)) + .await + .expect("already terminal") + ); +} + #[tokio::test] async fn timeout_skips_by_default_and_retry_renews_a_pending_challenge() { let (handler, captchas, _, _, clock) = fixture(); @@ -232,12 +373,50 @@ async fn timeout_skips_by_default_and_retry_renews_a_pending_challenge() { &handler, TimeoutCaptchaCommand { challenge_id: id.clone(), + expected_expires_at: 121_000, }, ) .await - .expect("timeout"); + .expect("stale timeout is ignored"); + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::Pending + ); + + clock.0.store(125_000, Ordering::SeqCst); + >::handle( + &handler, + TimeoutCaptchaCommand { + challenge_id: id.clone(), + expected_expires_at: 125_000, + }, + ) + .await + .expect("current timeout"); assert_eq!( captchas.find_by_id(&id).unwrap().unwrap().status(), CaptchaStatus::TimedOut ); } + +#[tokio::test] +async fn solve_rejects_an_expired_challenge() { + let (handler, captchas, _, _, clock) = fixture(); + let id = enqueue(&handler).await; + clock.0.store(121_000, Ordering::SeqCst); + + let result = >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: "too late".into(), + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::Pending + ); +} diff --git a/src-tauri/src/application/commands/hoster_download_source.rs b/src-tauri/src/application/commands/hoster_download_source.rs index 965cf70c..66400af9 100644 --- a/src-tauri/src/application/commands/hoster_download_source.rs +++ b/src-tauri/src/application/commands/hoster_download_source.rs @@ -11,6 +11,13 @@ use crate::domain::ports::driven::{ pub(super) fn resolved_protected_source( link: ExtractedHosterLink, ) -> Result { + if let Some(captcha) = link.captcha { + return Err(DomainError::CaptchaRequired { + challenge_type: captcha.challenge_type, + challenge_url: link.source_url, + image_data: captcha.image_data, + }); + } let direct_url = link .direct_url .filter(|url| !url.trim().is_empty()) diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index ea735fca..eca64d9c 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod tests_support; mod add_account; mod add_download_to_package; mod cancel_download; +pub mod captcha; mod change_directory; mod check_online; mod clear_downloads_by_state; @@ -64,6 +65,7 @@ mod verify_checksum; use std::path::PathBuf; use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; +use crate::domain::model::captcha::{CaptchaId, CaptchaType}; use crate::domain::model::config::ConfigPatch; use crate::domain::model::download::DownloadId; use crate::domain::model::package::{PackageId, PackageSourceType}; @@ -115,6 +117,50 @@ pub struct RetryDownloadCommand { } impl Command for RetryDownloadCommand {} +#[derive(Debug)] +pub struct EnqueueCaptchaCommand { + pub download_id: DownloadId, + pub challenge_type: CaptchaType, + pub challenge_url: String, + pub image_data: Option>, +} +impl Command for EnqueueCaptchaCommand {} + +pub struct SolveCaptchaCommand { + pub challenge_id: CaptchaId, + pub solution: String, +} +impl Command for SolveCaptchaCommand {} + +impl std::fmt::Debug for SolveCaptchaCommand { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SolveCaptchaCommand") + .field("challenge_id", &self.challenge_id) + .field("solution", &"") + .finish() + } +} + +#[derive(Debug)] +pub struct SkipCaptchaCommand { + pub challenge_id: CaptchaId, +} +impl Command for SkipCaptchaCommand {} + +#[derive(Debug)] +pub struct RetryCaptchaCommand { + pub challenge_id: CaptchaId, +} +impl Command for RetryCaptchaCommand {} + +#[derive(Debug)] +pub struct TimeoutCaptchaCommand { + pub challenge_id: CaptchaId, + pub expected_expires_at: u64, +} +impl Command for TimeoutCaptchaCommand {} + #[derive(Debug)] pub struct PauseAllDownloadsCommand; impl Command for PauseAllDownloadsCommand {} diff --git a/src-tauri/src/application/commands/remove_download.rs b/src-tauri/src/application/commands/remove_download.rs index 21e09bf5..3491d110 100644 --- a/src-tauri/src/application/commands/remove_download.rs +++ b/src-tauri/src/application/commands/remove_download.rs @@ -15,10 +15,15 @@ impl CommandBus { .find_by_id(cmd.id)? .ok_or_else(|| AppError::NotFound(format!("Download {} not found", cmd.id.0)))?; + let was_waiting_for_captcha = match self.captcha_handler_opt() { + Some(handler) => handler.skip_pending_for_download(cmd.id).await?, + None => false, + }; + let is_active = matches!( download.state(), DownloadState::Downloading | DownloadState::Waiting - ); + ) && !was_waiting_for_captcha; if is_active { let _ = self.download_engine().cancel(cmd.id); diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 60bb3e22..51fb8a07 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -376,10 +376,11 @@ fn into_hoster_resolution( service_name: &str, ) -> Result { if link.source_url.trim().is_empty() - || link + || (link .direct_url .as_deref() .is_none_or(|url| url.trim().is_empty()) + && link.captcha.is_none()) { return Err(DomainError::HosterNoFile); } diff --git a/src-tauri/src/application/commands/resolve_links_hoster_tests.rs b/src-tauri/src/application/commands/resolve_links_hoster_tests.rs index a10e5cda..46fb0bfc 100644 --- a/src-tauri/src/application/commands/resolve_links_hoster_tests.rs +++ b/src-tauri/src/application/commands/resolve_links_hoster_tests.rs @@ -10,12 +10,14 @@ use crate::application::commands::tests_support::{ use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::services::{AccountRotator, AccountSelector}; use crate::domain::error::DomainError; +use crate::domain::model::captcha::CaptchaType; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::http::HttpResponse; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::ports::driven::{ - Clock, ConfigStore, DownloadRepository, DownloadSourceResolver, HttpClient, PluginLoader, + Clock, ConfigStore, DownloadRepository, DownloadSourceResolver, ExtractedCaptchaChallenge, + HttpClient, PluginLoader, }; struct FixedClock; @@ -151,6 +153,7 @@ impl PluginLoader for FreeHosterPluginLoader { request_headers: Vec::new(), traffic_used_bytes: None, traffic_total_bytes: None, + captcha: None, }) .collect()); } @@ -187,6 +190,7 @@ impl PluginLoader for FreeHosterPluginLoader { request_headers: vec![("Referer".into(), url.into())], traffic_used_bytes: None, traffic_total_bytes: None, + captcha: None, }) .collect()) } @@ -208,6 +212,33 @@ async fn resolve_free_hoster(url: &str) -> (Vec, Arc>, + fail_next_save: AtomicBool, } impl InMemoryDownloadRepo { pub(crate) fn new() -> Self { Self { store: Mutex::new(HashMap::new()), + fail_next_save: AtomicBool::new(false), } } pub(crate) fn seed(&self, download: Download) { self.store.lock().unwrap().insert(download.id(), download); } + + pub(crate) fn fail_next_save(&self) { + self.fail_next_save.store(true, Ordering::SeqCst); + } } impl DownloadRepository for InMemoryDownloadRepo { @@ -694,6 +700,11 @@ impl DownloadRepository for InMemoryDownloadRepo { } fn save(&self, d: &Download) -> Result<(), DomainError> { + if self.fail_next_save.swap(false, Ordering::SeqCst) { + return Err(DomainError::StorageError( + "injected download save failure".into(), + )); + } self.store.lock().unwrap().insert(d.id(), d.clone()); Ok(()) } diff --git a/src-tauri/src/application/queries/captcha.rs b/src-tauri/src/application/queries/captcha.rs new file mode 100644 index 00000000..cb06f7c0 --- /dev/null +++ b/src-tauri/src/application/queries/captcha.rs @@ -0,0 +1,32 @@ +use crate::application::error::AppError; +use crate::application::query_bus::QueryBus; +use crate::application::read_models::captcha_view::CaptchaViewDto; +use crate::domain::model::captcha::CaptchaStatus; + +impl QueryBus { + pub async fn handle_captcha_list( + &self, + _query: super::CaptchaListQuery, + ) -> Result, AppError> { + Ok(self + .captcha_repo()? + .list()? + .into_iter() + .map(CaptchaViewDto::metadata) + .collect()) + } + + pub async fn handle_captcha_get_pending( + &self, + query: super::CaptchaGetPendingQuery, + ) -> Result, AppError> { + let challenge = match query.challenge_id { + Some(id) => self + .captcha_repo()? + .find_by_id(&id)? + .filter(|challenge| challenge.status() == CaptchaStatus::Pending), + None => self.captcha_repo()?.find_next_pending()?, + }; + Ok(challenge.map(CaptchaViewDto::from)) + } +} diff --git a/src-tauri/src/application/queries/mod.rs b/src-tauri/src/application/queries/mod.rs index be79c304..08ada470 100644 --- a/src-tauri/src/application/queries/mod.rs +++ b/src-tauri/src/application/queries/mod.rs @@ -3,6 +3,7 @@ //! Each query represents a read request. Queries never modify state. //! Handler implementations live in submodules and add methods to `QueryBus`. +mod captcha; mod count_by_state; mod detect_duplicates; mod find_package_by_external_id; @@ -25,6 +26,7 @@ mod search_history; mod top_modules; use crate::domain::model::account::{AccountId, AccountType}; +use crate::domain::model::captcha::CaptchaId; use crate::domain::model::download::DownloadId; use crate::domain::model::package::PackageId; use crate::domain::model::views::{ @@ -93,6 +95,16 @@ impl Query for ListPluginsQuery {} pub struct CountDownloadsByStateQuery; impl Query for CountDownloadsByStateQuery {} +#[derive(Debug)] +pub struct CaptchaListQuery; +impl Query for CaptchaListQuery {} + +#[derive(Debug)] +pub struct CaptchaGetPendingQuery { + pub challenge_id: Option, +} +impl Query for CaptchaGetPendingQuery {} + // Handler: task 26 (archive contents listing) #[derive(Debug)] pub struct ListArchiveContentsQuery { diff --git a/src-tauri/src/application/query_bus.rs b/src-tauri/src/application/query_bus.rs index ac56a8ea..13201e84 100644 --- a/src-tauri/src/application/query_bus.rs +++ b/src-tauri/src/application/query_bus.rs @@ -6,8 +6,9 @@ use std::sync::Arc; use crate::domain::ports::driven::{ - AccountRepository, ArchiveExtractor, DownloadReadRepository, HistoryRepository, - PackageReadRepository, PluginConfigStore, PluginLoader, PluginReadRepository, StatsRepository, + AccountRepository, ArchiveExtractor, CaptchaRepository, DownloadReadRepository, + HistoryRepository, PackageReadRepository, PluginConfigStore, PluginLoader, + PluginReadRepository, StatsRepository, }; /// Central dispatcher for CQRS queries. @@ -24,6 +25,7 @@ pub struct QueryBus { plugin_config_store: Option>, account_repo: Option>, package_read_repo: Option>, + captcha_repo: Option>, } impl QueryBus { @@ -44,6 +46,7 @@ impl QueryBus { plugin_config_store: None, account_repo: None, package_read_repo: None, + captcha_repo: None, } } @@ -84,6 +87,21 @@ impl QueryBus { self.package_read_repo.as_deref() } + pub fn with_captcha_repo(mut self, repo: Arc) -> Self { + self.captcha_repo = Some(repo); + self + } + + pub(crate) fn captcha_repo( + &self, + ) -> Result<&dyn CaptchaRepository, crate::application::error::AppError> { + self.captcha_repo.as_deref().ok_or_else(|| { + crate::application::error::AppError::Validation( + "CAPTCHA repository not configured".into(), + ) + }) + } + pub fn download_read_repo(&self) -> &dyn DownloadReadRepository { self.download_read_repo.as_ref() } diff --git a/src-tauri/src/application/read_models/captcha_view.rs b/src-tauri/src/application/read_models/captcha_view.rs new file mode 100644 index 00000000..941df276 --- /dev/null +++ b/src-tauri/src/application/read_models/captcha_view.rs @@ -0,0 +1,91 @@ +use serde::Serialize; + +use crate::domain::model::captcha::{CaptchaChallenge, captcha_image_mime_type}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptchaViewDto { + pub id: String, + pub download_id: u64, + pub challenge_type: String, + pub challenge_url: String, + pub image_data: Option>, + pub image_mime_type: Option, + pub status: String, + pub solver: Option, + pub attempts: u32, + pub created_at: u64, + pub expires_at: u64, + pub resolved_at: Option, + pub duration_ms: Option, + pub failure_reason: Option, +} + +impl From for CaptchaViewDto { + fn from(challenge: CaptchaChallenge) -> Self { + Self::build(challenge, true) + } +} + +impl CaptchaViewDto { + pub fn metadata(challenge: CaptchaChallenge) -> Self { + Self::build(challenge, false) + } + + fn build(challenge: CaptchaChallenge, include_image: bool) -> Self { + let image_mime_type = include_image + .then(|| challenge.image_data().and_then(captcha_image_mime_type)) + .flatten() + .map(str::to_string); + Self { + id: challenge.id().to_string(), + download_id: challenge.download_id().0, + challenge_type: challenge.challenge_type().to_string(), + challenge_url: challenge.url().to_string(), + image_data: include_image + .then(|| challenge.image_data().map(<[u8]>::to_vec)) + .flatten(), + image_mime_type, + status: challenge.status().to_string(), + solver: challenge.solver().map(str::to_string), + attempts: challenge.attempts(), + created_at: challenge.created_at(), + expires_at: challenge.expires_at(), + resolved_at: challenge.resolved_at(), + duration_ms: challenge.duration_ms(), + failure_reason: challenge.failure_reason().map(str::to_string), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::captcha::{CaptchaId, CaptchaType}; + use crate::domain::model::download::DownloadId; + + fn challenge() -> CaptchaChallenge { + CaptchaChallenge::new( + CaptchaId::new("captcha-1"), + DownloadId(1), + CaptchaType::Image, + "https://hoster.example/file".into(), + 1_000, + 61_000, + ) + .expect("challenge") + .with_image_data(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec()) + .expect("image") + } + + #[test] + fn list_metadata_omits_image_bytes_but_pending_detail_includes_them() { + let metadata = CaptchaViewDto::metadata(challenge()); + let detail = CaptchaViewDto::from(challenge()); + + assert!(metadata.image_data.is_none()); + assert!(metadata.image_mime_type.is_none()); + assert_eq!(detail.image_data.as_ref().map(Vec::len), Some(24)); + assert_eq!(detail.image_mime_type.as_deref(), Some("image/png")); + } +} diff --git a/src-tauri/src/application/read_models/mod.rs b/src-tauri/src/application/read_models/mod.rs index 993c731a..83ba4d17 100644 --- a/src-tauri/src/application/read_models/mod.rs +++ b/src-tauri/src/application/read_models/mod.rs @@ -1,6 +1,7 @@ //! Application-layer read model DTOs with serde serialization. pub mod account_view; +pub mod captcha_view; pub mod download_detail_view; pub mod download_view; pub mod history_view; diff --git a/src-tauri/src/application/services/queue_manager.rs b/src-tauri/src/application/services/queue_manager.rs index e0f9dff8..99877cc5 100644 --- a/src-tauri/src/application/services/queue_manager.rs +++ b/src-tauri/src/application/services/queue_manager.rs @@ -510,6 +510,10 @@ impl QueueManager { } } + async fn handle_captcha_pending(&self) -> Result<(), AppError> { + self.decrement_and_schedule().await + } + pub fn start_listening(self: Arc) { let (tx, mut rx) = tokio::sync::mpsc::channel::(1024); @@ -523,7 +527,9 @@ impl QueueManager { | DomainEvent::DownloadFailed { .. } | DomainEvent::DownloadCancelled { .. } | DomainEvent::DownloadCreated { .. } + | DomainEvent::DownloadQueued { .. } | DomainEvent::DownloadResumed { .. } + | DomainEvent::CaptchaPending { .. } | DomainEvent::DownloadRetrying { .. } | DomainEvent::DownloadPrioritySet { .. } | DomainEvent::QueueReordered { .. } @@ -542,10 +548,12 @@ impl QueueManager { DomainEvent::DownloadPaused { .. } | DomainEvent::DownloadCancelled { .. } => { self.decrement_and_schedule().await } + DomainEvent::CaptchaPending { .. } => self.handle_captcha_pending().await, DomainEvent::DownloadFailed { id, error } => { self.handle_download_failed(*id, error.clone()).await } DomainEvent::DownloadCreated { .. } + | DomainEvent::DownloadQueued { .. } | DomainEvent::DownloadRetrying { .. } | DomainEvent::DownloadPrioritySet { .. } | DomainEvent::QueueReordered { .. } => self.on_slot_freed().await, diff --git a/src-tauri/src/application/services/startup_recovery.rs b/src-tauri/src/application/services/startup_recovery.rs index b67e4af7..43aede99 100644 --- a/src-tauri/src/application/services/startup_recovery.rs +++ b/src-tauri/src/application/services/startup_recovery.rs @@ -8,6 +8,7 @@ use crate::domain::error::DomainError; use crate::domain::model::download::DownloadState; +use crate::domain::ports::driven::CaptchaRepository; use crate::domain::ports::driven::download_repository::DownloadRepository; /// States that imply a running engine task. On fresh startup no task exists, @@ -22,14 +23,38 @@ const ORPHAN_STATES: [DownloadState; 4] = [ /// Transition every download in an active-but-orphaned state to `Error`. /// /// Returns the number of downloads recovered. +#[cfg(test)] pub fn recover_orphaned_downloads( download_repo: &dyn DownloadRepository, +) -> Result { + recover(download_repo, None) +} + +pub fn recover_orphaned_downloads_with_captchas( + download_repo: &dyn DownloadRepository, + captcha_repo: &dyn CaptchaRepository, +) -> Result { + recover(download_repo, Some(captcha_repo)) +} + +fn recover( + download_repo: &dyn DownloadRepository, + captcha_repo: Option<&dyn CaptchaRepository>, ) -> Result { let mut recovered = 0; for state in ORPHAN_STATES { let downloads = download_repo.find_by_state(state)?; for mut download in downloads { + if state == DownloadState::Waiting + && captcha_repo + .map(|repo| repo.find_pending_by_download(download.id())) + .transpose()? + .flatten() + .is_some() + { + continue; + } // fail() is valid from all ORPHAN_STATES — see domain state machine. let error = "Interrupted: app restarted".to_string(); let _event = download.fail(error.clone())?; @@ -44,6 +69,7 @@ pub fn recover_orphaned_downloads( #[cfg(test)] mod tests { use super::*; + use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaType}; use crate::domain::model::download::{Download, DownloadId, Url}; use std::collections::HashMap; use std::sync::Mutex; @@ -96,6 +122,26 @@ mod tests { } } + struct PendingCaptchaRepo(CaptchaChallenge); + + impl CaptchaRepository for PendingCaptchaRepo { + fn save(&self, _: &CaptchaChallenge) -> Result<(), DomainError> { + Ok(()) + } + + fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError> { + Ok((self.0.id() == id).then(|| self.0.clone())) + } + + fn list(&self) -> Result, DomainError> { + Ok(vec![self.0.clone()]) + } + + fn list_pending(&self) -> Result, DomainError> { + Ok(vec![self.0.clone()]) + } + } + // --- Helpers --- fn make_download(id: u64) -> Download { @@ -169,6 +215,27 @@ mod tests { assert_eq!(d.state(), DownloadState::Error); } + #[test] + fn test_recover_preserves_waiting_download_with_pending_captcha() { + let repo = InMemoryRepo::new(vec![make_waiting(1)]); + let captchas = PendingCaptchaRepo( + CaptchaChallenge::new( + CaptchaId::new("captcha-1"), + DownloadId(1), + CaptchaType::TextInput, + "https://hoster.example/file".into(), + 1_000, + 61_000, + ) + .expect("challenge"), + ); + + let count = recover_orphaned_downloads_with_captchas(&repo, &captchas).expect("recovery"); + + assert_eq!(count, 0); + assert_eq!(repo.get(1).unwrap().state(), DownloadState::Waiting); + } + #[test] fn test_recover_checking_transitions_to_error() { let repo = InMemoryRepo::new(vec![make_checking(1)]); diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index 416192b3..141e02a1 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -1,3 +1,4 @@ +use crate::domain::model::captcha::CaptchaType; use crate::domain::model::download::DownloadState; use crate::domain::model::segment::SegmentState; @@ -30,6 +31,11 @@ pub enum DomainError { HosterAuthenticationRequired, HosterDirectUrlExpired, HosterUnexpectedHtml, + CaptchaRequired { + challenge_type: CaptchaType, + challenge_url: String, + image_data: Option>, + }, AdaptiveStreamOnly, /// Computed checksum did not match the expected value. ChecksumMismatch { @@ -93,6 +99,7 @@ impl std::fmt::Display for DomainError { DomainError::HosterUnexpectedHtml => { write!(f, "Hoster returned an HTML page instead of file content") } + DomainError::CaptchaRequired { .. } => write!(f, "CAPTCHA is required"), DomainError::AdaptiveStreamOnly => write!( f, "Video is only available as adaptive stream (DASH/HLS); use download_to_file" diff --git a/src-tauri/src/domain/event.rs b/src-tauri/src/domain/event.rs index f06b0120..ba461119 100644 --- a/src-tauri/src/domain/event.rs +++ b/src-tauri/src/domain/event.rs @@ -1,4 +1,5 @@ use crate::domain::model::account::AccountId; +use crate::domain::model::captcha::{CaptchaId, CaptchaType}; use crate::domain::model::download::DownloadId; use crate::domain::model::link::LinkStatus; use crate::domain::model::package::PackageId; @@ -95,6 +96,9 @@ pub enum DomainEvent { DownloadCreated { id: DownloadId, }, + DownloadQueued { + id: DownloadId, + }, DownloadStarted { id: DownloadId, }, @@ -154,6 +158,32 @@ pub enum DomainEvent { id: DownloadId, expired_naturally: bool, }, + CaptchaRequired { + download_id: DownloadId, + challenge_type: CaptchaType, + challenge_url: String, + image_data: Option>, + }, + CaptchaPending { + challenge_id: CaptchaId, + download_id: DownloadId, + }, + CaptchaSolved { + challenge_id: CaptchaId, + download_id: DownloadId, + solver: String, + duration_ms: u64, + }, + CaptchaSkipped { + challenge_id: CaptchaId, + download_id: DownloadId, + reason: String, + }, + CaptchaTimedOut { + challenge_id: CaptchaId, + download_id: DownloadId, + duration_ms: u64, + }, DownloadChecking { id: DownloadId, }, diff --git a/src-tauri/src/domain/model/captcha.rs b/src-tauri/src/domain/model/captcha.rs index 764f2a50..c756201e 100644 --- a/src-tauri/src/domain/model/captcha.rs +++ b/src-tauri/src/domain/model/captcha.rs @@ -1,4 +1,42 @@ use std::fmt; +use std::str::FromStr; + +use crate::domain::error::DomainError; +use crate::domain::model::download::DownloadId; + +pub const MAX_CAPTCHA_IMAGE_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_CAPTCHA_SOLUTION_BYTES: usize = 4 * 1024; +pub const MAX_CAPTCHA_ID_BYTES: usize = 128; +pub const MAX_CAPTCHA_IMAGE_PIXELS: u64 = 16_000_000; +const MAX_CAPTCHA_URL_BYTES: usize = 8 * 1024; +const REDACTED_CAPTCHA_URL: &str = "[redacted]"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CaptchaId(String); + +impl CaptchaId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn try_new(value: impl Into) -> Result { + let id = Self::new(value); + if id.as_str().trim().is_empty() || id.as_str().len() > MAX_CAPTCHA_ID_BYTES { + return Err(validation("CAPTCHA id is invalid")); + } + Ok(id) + } +} + +impl fmt::Display for CaptchaId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CaptchaType { @@ -12,54 +50,227 @@ pub enum CaptchaType { impl fmt::Display for CaptchaType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match self { - CaptchaType::Image => "Image", - CaptchaType::ReCaptchaV2 => "reCAPTCHA v2", - CaptchaType::ReCaptchaV3 => "reCAPTCHA v3", - CaptchaType::HCaptcha => "hCaptcha", - CaptchaType::TextInput => "TextInput", + CaptchaType::Image => "image", + CaptchaType::ReCaptchaV2 => "recaptcha_v2", + CaptchaType::ReCaptchaV3 => "recaptcha_v3", + CaptchaType::HCaptcha => "hcaptcha", + CaptchaType::TextInput => "text_input", }; write!(f, "{name}") } } -#[derive(Debug, Clone, PartialEq)] +impl FromStr for CaptchaType { + type Err = DomainError; + + fn from_str(value: &str) -> Result { + match value { + "image" => Ok(Self::Image), + "recaptcha_v2" => Ok(Self::ReCaptchaV2), + "recaptcha_v3" => Ok(Self::ReCaptchaV3), + "hcaptcha" => Ok(Self::HCaptcha), + "text_input" => Ok(Self::TextInput), + _ => Err(DomainError::ValidationError(format!( + "unknown CAPTCHA type '{value}'" + ))), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptchaStatus { + Pending, + Solved, + Skipped, + TimedOut, +} + +impl fmt::Display for CaptchaStatus { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Pending => "pending", + Self::Solved => "solved", + Self::Skipped => "skipped", + Self::TimedOut => "timed_out", + }) + } +} + +impl FromStr for CaptchaStatus { + type Err = DomainError; + + fn from_str(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "solved" => Ok(Self::Solved), + "skipped" => Ok(Self::Skipped), + "timed_out" => Ok(Self::TimedOut), + _ => Err(DomainError::ValidationError(format!( + "unknown CAPTCHA status '{value}'" + ))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CaptchaChallenge { - id: u64, + id: CaptchaId, + download_id: DownloadId, challenge_type: CaptchaType, url: String, image_data: Option>, - solved: bool, - solution: Option, + status: CaptchaStatus, + solver: Option, + attempts: u32, + created_at: u64, + expires_at: u64, + resolved_at: Option, + duration_ms: Option, + failure_reason: Option, +} + +pub struct CaptchaChallengeRecord { + pub id: CaptchaId, + pub download_id: DownloadId, + pub challenge_type: CaptchaType, + pub url: String, + pub image_data: Option>, + pub status: CaptchaStatus, + pub solver: Option, + pub attempts: u32, + pub created_at: u64, + pub expires_at: u64, + pub resolved_at: Option, + pub duration_ms: Option, + pub failure_reason: Option, } impl CaptchaChallenge { - pub fn new(id: u64, challenge_type: CaptchaType, url: String) -> Self { - Self { + pub fn new( + id: CaptchaId, + download_id: DownloadId, + challenge_type: CaptchaType, + url: String, + created_at: u64, + expires_at: u64, + ) -> Result { + if id.as_str().trim().is_empty() || id.as_str().len() > MAX_CAPTCHA_ID_BYTES { + return Err(validation("CAPTCHA id is invalid")); + } + if url.trim().is_empty() || url.len() > MAX_CAPTCHA_URL_BYTES { + return Err(validation("CAPTCHA URL is invalid")); + } + if expires_at <= created_at { + return Err(validation("CAPTCHA deadline must be after creation")); + } + Ok(Self { id, + download_id, challenge_type, url, image_data: None, - solved: false, - solution: None, + status: CaptchaStatus::Pending, + solver: None, + attempts: 0, + created_at, + expires_at, + resolved_at: None, + duration_ms: None, + failure_reason: None, + }) + } + + pub fn reconstruct(record: CaptchaChallengeRecord) -> Result { + let mut challenge = Self::new( + record.id, + record.download_id, + record.challenge_type, + record.url, + record.created_at, + record.expires_at, + )?; + if let Some(image) = record.image_data { + challenge = challenge.with_image_data(image)?; } + challenge.status = record.status; + challenge.solver = record.solver; + challenge.attempts = record.attempts; + challenge.resolved_at = record.resolved_at; + challenge.duration_ms = record.duration_ms; + challenge.failure_reason = record.failure_reason; + Ok(challenge) } - pub fn with_image_data(mut self, data: Vec) -> Self { + pub fn with_image_data(mut self, data: Vec) -> Result { + if data.is_empty() + || data.len() > MAX_CAPTCHA_IMAGE_BYTES + || captcha_image_mime_type(&data).is_none() + { + return Err(validation( + "CAPTCHA image is invalid or exceeds safety limits", + )); + } self.image_data = Some(data); - self + Ok(self) + } + + pub fn solve(&mut self, now_ms: u64, solver: &str) -> Result<(), DomainError> { + self.ensure_pending()?; + if solver.trim().is_empty() { + return Err(validation("CAPTCHA solver cannot be empty")); + } + self.status = CaptchaStatus::Solved; + self.solver = Some(solver.to_string()); + self.resolve(now_ms); + Ok(()) + } + + pub fn skip(&mut self, now_ms: u64, reason: &str) -> Result<(), DomainError> { + self.ensure_pending()?; + self.status = CaptchaStatus::Skipped; + self.failure_reason = Some(reason.to_string()); + self.resolve(now_ms); + Ok(()) + } + + pub fn timeout(&mut self, now_ms: u64) -> Result<(), DomainError> { + self.ensure_pending()?; + self.status = CaptchaStatus::TimedOut; + self.failure_reason = Some("CAPTCHA timed out".to_string()); + self.resolve(now_ms); + Ok(()) } - pub fn solve(&mut self, solution: String) { - self.solved = true; - self.solution = Some(solution); + pub fn retry(&mut self, now_ms: u64, expires_at: u64) -> Result<(), DomainError> { + self.ensure_pending()?; + if expires_at <= now_ms { + return Err(validation("CAPTCHA retry deadline must be in the future")); + } + self.attempts = self.attempts.saturating_add(1); + self.expires_at = expires_at; + Ok(()) } - pub fn is_solved(&self) -> bool { - self.solved + fn ensure_pending(&self) -> Result<(), DomainError> { + if self.status != CaptchaStatus::Pending { + return Err(validation("CAPTCHA challenge is no longer pending")); + } + Ok(()) } - pub fn id(&self) -> u64 { - self.id + fn resolve(&mut self, now_ms: u64) { + self.resolved_at = Some(now_ms); + self.duration_ms = Some(now_ms.saturating_sub(self.created_at)); + self.image_data = None; + self.url = REDACTED_CAPTCHA_URL.to_string(); + } + + pub fn id(&self) -> &CaptchaId { + &self.id + } + + pub fn download_id(&self) -> DownloadId { + self.download_id } pub fn challenge_type(&self) -> CaptchaType { @@ -74,11 +285,128 @@ impl CaptchaChallenge { self.image_data.as_deref() } - pub fn solution(&self) -> Option<&str> { - self.solution.as_deref() + pub fn status(&self) -> CaptchaStatus { + self.status + } + + pub fn solver(&self) -> Option<&str> { + self.solver.as_deref() + } + + pub fn attempts(&self) -> u32 { + self.attempts + } + + pub fn created_at(&self) -> u64 { + self.created_at + } + + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + pub fn is_expired(&self, now_ms: u64) -> bool { + now_ms >= self.expires_at + } + + pub fn resolved_at(&self) -> Option { + self.resolved_at + } + + pub fn duration_ms(&self) -> Option { + self.duration_ms + } + + pub fn failure_reason(&self) -> Option<&str> { + self.failure_reason.as_deref() } } +fn validation(message: &str) -> DomainError { + DomainError::ValidationError(message.to_string()) +} + +pub fn captcha_image_mime_type(data: &[u8]) -> Option<&'static str> { + let (mime, width, height) = image_metadata(data)?; + let pixels = u64::from(width).checked_mul(u64::from(height))?; + (width > 0 && height > 0 && pixels <= MAX_CAPTCHA_IMAGE_PIXELS).then_some(mime) +} + +fn image_metadata(data: &[u8]) -> Option<(&'static str, u32, u32)> { + if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") && &data[12..16] == b"IHDR" { + return Some(( + "image/png", + u32::from_be_bytes(data[16..20].try_into().ok()?), + u32::from_be_bytes(data[20..24].try_into().ok()?), + )); + } + if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) { + return Some(( + "image/gif", + u32::from(u16::from_le_bytes(data[6..8].try_into().ok()?)), + u32::from(u16::from_le_bytes(data[8..10].try_into().ok()?)), + )); + } + jpeg_dimensions(data).map(|(width, height)| ("image/jpeg", width, height)) +} + +fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> { + if !data.starts_with(&[0xff, 0xd8]) { + return None; + } + let mut cursor = 2; + while cursor + 1 < data.len() { + while cursor < data.len() && data[cursor] == 0xff { + cursor += 1; + } + let marker = *data.get(cursor)?; + cursor += 1; + if marker == 0xd9 || marker == 0xda { + return None; + } + if marker == 0x01 || (0xd0..=0xd8).contains(&marker) { + continue; + } + let segment_len = usize::from(u16::from_be_bytes([ + *data.get(cursor)?, + *data.get(cursor + 1)?, + ])); + if segment_len < 2 || cursor.checked_add(segment_len)? > data.len() { + return None; + } + if matches!( + marker, + 0xc0 | 0xc1 + | 0xc2 + | 0xc3 + | 0xc5 + | 0xc6 + | 0xc7 + | 0xc9 + | 0xca + | 0xcb + | 0xcd + | 0xce + | 0xcf + ) { + if segment_len < 7 { + return None; + } + let height = u32::from(u16::from_be_bytes([ + *data.get(cursor + 3)?, + *data.get(cursor + 4)?, + ])); + let width = u32::from(u16::from_be_bytes([ + *data.get(cursor + 5)?, + *data.get(cursor + 6)?, + ])); + return Some((width, height)); + } + cursor += segment_len; + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -96,6 +424,13 @@ mod tests { .expect("valid challenge") } + fn png_image(width: u32, height: u32) -> Vec { + let mut image = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + image.extend_from_slice(&width.to_be_bytes()); + image.extend_from_slice(&height.to_be_bytes()); + image + } + #[test] fn new_challenge_is_pending_and_has_a_deadline() { let c = make_challenge(); @@ -146,7 +481,7 @@ mod tests { #[test] fn image_payload_is_bounded() { - let data = vec![0u8, 1, 2, 3]; + let data = png_image(1, 1); let c = make_challenge() .with_image_data(data.clone()) .expect("small image"); @@ -154,6 +489,28 @@ mod tests { let too_large = vec![0; MAX_CAPTCHA_IMAGE_BYTES + 1]; assert!(make_challenge().with_image_data(too_large).is_err()); + assert!( + make_challenge() + .with_image_data(b"".to_vec()) + .is_err() + ); + assert!( + make_challenge() + .with_image_data(png_image(5_000, 5_000)) + .is_err() + ); + } + + #[test] + fn terminal_challenge_drops_ephemeral_image_material() { + let mut challenge = make_challenge() + .with_image_data(png_image(1, 1)) + .expect("small image"); + + challenge.solve(2_000, "manual").expect("solve"); + + assert!(challenge.image_data().is_none()); + assert_eq!(challenge.url(), REDACTED_CAPTCHA_URL); } #[test] @@ -176,4 +533,11 @@ mod tests { assert_eq!(status.to_string().parse(), Ok(status)); } } + + #[test] + fn external_captcha_ids_are_bounded() { + assert!(CaptchaId::try_new("captcha-1").is_ok()); + assert!(CaptchaId::try_new(" ").is_err()); + assert!(CaptchaId::try_new("x".repeat(MAX_CAPTCHA_ID_BYTES + 1)).is_err()); + } } diff --git a/src-tauri/src/domain/model/config.rs b/src-tauri/src/domain/model/config.rs index 08559fef..10ae04fc 100644 --- a/src-tauri/src/domain/model/config.rs +++ b/src-tauri/src/domain/model/config.rs @@ -44,6 +44,10 @@ pub struct AppConfig { /// is dwarfed by HTTP request and rebalance overhead. pub dynamic_split_min_remaining_mb: u64, + // ── CAPTCHA ──────────────────────────────────────────────────── + /// Manual challenge deadline. Expiry applies the safe default: skip. + pub captcha_timeout_seconds: u32, + // ── History ────────────────────────────────────────────────────── /// Number of days history entries are retained before automatic /// hard-delete. `0` disables retention (entries are kept forever). @@ -130,6 +134,9 @@ impl Default for AppConfig { dynamic_split_enabled: true, dynamic_split_min_remaining_mb: 4, + // CAPTCHA + captcha_timeout_seconds: DEFAULT_CAPTCHA_TIMEOUT_SECONDS, + // History history_retention_days: 30, @@ -177,6 +184,10 @@ pub const DEFAULT_LINK_CHECK_PARALLELISM: u32 = 8; /// should stall on a single URL before flipping to `Unknown`. pub const DEFAULT_LINK_CHECK_TIMEOUT_SECS: u32 = 10; +pub const DEFAULT_CAPTCHA_TIMEOUT_SECONDS: u32 = 120; +pub const MIN_CAPTCHA_TIMEOUT_SECONDS: u32 = 10; +pub const MAX_CAPTCHA_TIMEOUT_SECONDS: u32 = 3_600; + /// Lower bound for `link_check_parallelism`. Below 1 the queue stalls. pub const MIN_LINK_CHECK_PARALLELISM: u32 = 1; @@ -221,6 +232,9 @@ pub struct ConfigPatch { pub dynamic_split_enabled: Option, pub dynamic_split_min_remaining_mb: Option, + // CAPTCHA + pub captcha_timeout_seconds: Option, + // History pub history_retention_days: Option, @@ -345,6 +359,11 @@ pub fn apply_patch(config: &mut AppConfig, patch: &ConfigPatch) { config.dynamic_split_min_remaining_mb = v; } + if let Some(v) = patch.captcha_timeout_seconds { + config.captcha_timeout_seconds = + v.clamp(MIN_CAPTCHA_TIMEOUT_SECONDS, MAX_CAPTCHA_TIMEOUT_SECONDS); + } + // History if let Some(v) = patch.history_retention_days { config.history_retention_days = normalize_history_retention_days(v); diff --git a/src-tauri/src/domain/model/download.rs b/src-tauri/src/domain/model/download.rs index b40bdfb0..74000b55 100644 --- a/src-tauri/src/domain/model/download.rs +++ b/src-tauri/src/domain/model/download.rs @@ -677,6 +677,17 @@ impl Download { Ok(DomainEvent::DownloadResumedFromWait { id: self.id }) } + pub fn queue_after_wait(&mut self) -> Result { + if self.state != DownloadState::Waiting { + return Err(DomainError::InvalidTransition { + from: self.state, + to: DownloadState::Queued, + }); + } + self.state = DownloadState::Queued; + Ok(DomainEvent::DownloadQueued { id: self.id }) + } + pub fn start_checking(&mut self) -> Result { if self.state != DownloadState::Downloading { return Err(DomainError::InvalidTransition { diff --git a/src-tauri/src/domain/model/mod.rs b/src-tauri/src/domain/model/mod.rs index 52f1da8f..a9fbc23b 100644 --- a/src-tauri/src/domain/model/mod.rs +++ b/src-tauri/src/domain/model/mod.rs @@ -18,7 +18,9 @@ pub mod views; pub use account::{Account, AccountId, AccountType}; pub use archive::{ArchiveEntry, ArchiveFormat, ExtractSummary, ExtractionConfig}; -pub use captcha::{CaptchaChallenge, CaptchaType}; +pub use captcha::{ + CaptchaChallenge, CaptchaChallengeRecord, CaptchaId, CaptchaStatus, CaptchaType, +}; pub use checksum::ChecksumAlgorithm; pub use config::{AppConfig, ConfigPatch}; pub use credential::Credential; diff --git a/src-tauri/src/domain/ports/driven/captcha_repository.rs b/src-tauri/src/domain/ports/driven/captcha_repository.rs new file mode 100644 index 00000000..386b1fec --- /dev/null +++ b/src-tauri/src/domain/ports/driven/captcha_repository.rs @@ -0,0 +1,24 @@ +use crate::domain::error::DomainError; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId}; +use crate::domain::model::download::DownloadId; + +pub trait CaptchaRepository: Send + Sync { + fn save(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError>; + fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError>; + fn list(&self) -> Result, DomainError>; + fn list_pending(&self) -> Result, DomainError>; + + fn find_next_pending(&self) -> Result, DomainError> { + Ok(self.list_pending()?.into_iter().next()) + } + + fn find_pending_by_download( + &self, + download_id: DownloadId, + ) -> Result, DomainError> { + Ok(self + .list_pending()? + .into_iter() + .find(|challenge| challenge.download_id() == download_id)) + } +} diff --git a/src-tauri/src/domain/ports/driven/captcha_solver.rs b/src-tauri/src/domain/ports/driven/captcha_solver.rs new file mode 100644 index 00000000..20a5a674 --- /dev/null +++ b/src-tauri/src/domain/ports/driven/captcha_solver.rs @@ -0,0 +1,19 @@ +use crate::domain::error::DomainError; +use crate::domain::model::captcha::CaptchaChallenge; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptchaSolverOutcome { + Solved, + Unavailable, + Rejected, +} + +pub trait CaptchaSolver: Send + Sync { + fn name(&self) -> &str; + + fn solve( + &self, + challenge: &CaptchaChallenge, + solution: &str, + ) -> Result; +} diff --git a/src-tauri/src/domain/ports/driven/hoster_link.rs b/src-tauri/src/domain/ports/driven/hoster_link.rs index 4b9e1897..2adadcd5 100644 --- a/src-tauri/src/domain/ports/driven/hoster_link.rs +++ b/src-tauri/src/domain/ports/driven/hoster_link.rs @@ -1,5 +1,13 @@ //! Typed output of a hoster plugin extraction. +use crate::domain::model::captcha::CaptchaType; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExtractedCaptchaChallenge { + pub challenge_type: CaptchaType, + pub image_data: Option>, +} + /// One hoster file resolved by a plugin adapter. /// /// The adapter owns deserialisation of the plugin wire format. Application @@ -16,6 +24,7 @@ pub struct ExtractedHosterLink { pub request_headers: Vec<(String, String)>, pub traffic_used_bytes: Option, pub traffic_total_bytes: Option, + pub captcha: Option, } impl std::fmt::Debug for ExtractedHosterLink { @@ -31,6 +40,7 @@ impl std::fmt::Debug for ExtractedHosterLink { .field("request_headers", &"") .field("traffic_used_bytes", &self.traffic_used_bytes) .field("traffic_total_bytes", &self.traffic_total_bytes) + .field("captcha", &self.captcha.as_ref().map(|c| c.challenge_type)) .finish() } } @@ -50,6 +60,7 @@ mod tests { request_headers: vec![("Authorization".into(), "Bearer secret".into())], traffic_used_bytes: None, traffic_total_bytes: None, + captcha: None, }; let debug = format!("{link:?}"); diff --git a/src-tauri/src/domain/ports/driven/mod.rs b/src-tauri/src/domain/ports/driven/mod.rs index c9f1a6c2..1d440cb1 100644 --- a/src-tauri/src/domain/ports/driven/mod.rs +++ b/src-tauri/src/domain/ports/driven/mod.rs @@ -5,6 +5,8 @@ pub mod account_credential_store; pub mod account_repository; pub mod account_validator; pub mod archive_extractor; +pub mod captcha_repository; +pub mod captcha_solver; pub mod checksum_computer; pub mod clipboard_observer; pub mod clock; @@ -34,6 +36,8 @@ pub use account_credential_store::AccountCredentialStore; pub use account_repository::AccountRepository; pub use account_validator::{AccountValidator, ValidationOutcome}; pub use archive_extractor::ArchiveExtractor; +pub use captcha_repository::CaptchaRepository; +pub use captcha_solver::{CaptchaSolver, CaptchaSolverOutcome}; pub use checksum_computer::ChecksumComputer; pub use clipboard_observer::ClipboardObserver; pub use clock::Clock; @@ -49,7 +53,7 @@ pub use event_bus::EventBus; pub use file_opener::FileOpener; pub use file_storage::FileStorage; pub use history_repository::HistoryRepository; -pub use hoster_link::ExtractedHosterLink; +pub use hoster_link::{ExtractedCaptchaChallenge, ExtractedHosterLink}; pub use http_client::HttpClient; pub use package_read_repository::PackageReadRepository; pub use package_repository::PackageRepository; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c7965164..475453dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,10 +7,10 @@ use std::sync::Arc; use tauri::Manager; use domain::ports::driven::{ - AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, - ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, DownloadRepository, - DownloadSourceResolver, EventBus, FileStorage, HistoryRepository, HttpClient, PassphraseCodec, - PluginLoader, PluginReadRepository, StatsRepository, + AccountCredentialStore, AccountRepository, ArchiveExtractor, CaptchaRepository, CaptchaSolver, + ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, + DownloadRepository, DownloadSourceResolver, EventBus, FileStorage, HistoryRepository, + HttpClient, PassphraseCodec, PluginLoader, PluginReadRepository, StatsRepository, }; // Public API — concrete types for app wiring (main.rs, Tauri setup, integration tests) @@ -43,6 +43,7 @@ pub use adapters::driven::plugin::{ }; pub use adapters::driven::scheduler::{HISTORY_PURGE_STATE_FILE, HistoryPurgeWorker, SystemClock}; pub use adapters::driven::sqlite::account_repo::SqliteAccountRepo; +pub use adapters::driven::sqlite::captcha_repo::SqliteCaptchaRepo; pub use adapters::driven::sqlite::connection; pub use adapters::driven::sqlite::download_read_repo::SqliteDownloadReadRepo; pub use adapters::driven::sqlite::download_repo::SqliteDownloadRepo; @@ -55,6 +56,7 @@ pub use adapters::driven::tray::{ spawn_tray_animator, }; pub use application::command_bus::CommandBus; +pub use application::commands::captcha::{CaptchaCommandHandler, ManualCaptchaSolver}; pub use application::commands::resolve_premium_source::ResolveHosterSourceHandler; pub use application::commands::store_refresh::{read_cache, write_cache}; pub use application::error::AppError; @@ -75,7 +77,8 @@ pub use domain::model::ExtractionConfig; pub use adapters::driving::tauri_ipc::{ self, AppState, account_add, account_delete, account_export, account_get, account_import, account_list, account_traffic_get, account_update, account_validate, browse_file, - browse_folder, clipboard_state, clipboard_toggle, command_get_media_metadata, download_cancel, + browse_folder, captcha_get_pending, captcha_list, captcha_retry, captcha_skip, captcha_solve, + clipboard_state, clipboard_toggle, command_get_media_metadata, download_cancel, download_change_directory, download_change_directory_bulk, download_clear_completed, download_clear_failed, download_count_by_state, download_detail, download_list, download_logs, download_media_start, download_move_to_bottom, download_move_to_top, download_open_file, @@ -164,6 +167,8 @@ pub fn run() { // ── SQLite repositories ───────────────────────────────── let download_repo: Arc = Arc::new(SqliteDownloadRepo::new(db.clone())); + let captcha_repo: Arc = + Arc::new(SqliteCaptchaRepo::new(db.clone())); let download_read_repo: Arc = Arc::new(SqliteDownloadReadRepo::new(db.clone())); let history_repo: Arc = @@ -305,8 +310,9 @@ pub fn run() { // Orphaned downloads (Downloading/Waiting/Checking/Extracting // in SQLite but no engine task) are marked Error so the user // can retry. Runs early, before anything subscribes to events. - match application::services::startup_recovery::recover_orphaned_downloads( + match application::services::startup_recovery::recover_orphaned_downloads_with_captchas( download_repo.as_ref(), + captcha_repo.as_ref(), ) { Ok(0) => {} Ok(n) => tracing::info!("Recovered {n} orphaned download(s) from previous session"), @@ -352,6 +358,18 @@ pub fn run() { queue_manager.clone(), ); + let captcha_solvers: Vec> = + vec![Arc::new(ManualCaptchaSolver)]; + let captcha_handler = Arc::new(CaptchaCommandHandler::new( + captcha_repo.clone(), + download_repo.clone(), + event_bus.clone(), + config_store.clone(), + Arc::new(SystemClock) as Arc, + captcha_solvers, + )); + captcha_handler.start_listening(); + // ── Plugin store client ───────────────────────────────── let registry_url = "https://raw.githubusercontent.com/mpiton/vortex/main/registry/registry.toml"; @@ -424,7 +442,8 @@ pub fn run() { .with_account_clock(account_clock) .with_account_operation_locks(account_operation_locks) .with_package_repo(package_repo.clone()) - .with_passphrase_codec(passphrase_codec), + .with_passphrase_codec(passphrase_codec) + .with_captcha_handler(captcha_handler.clone()), ); // Stats recorder bridge keeps its own handle once the query @@ -441,7 +460,8 @@ pub fn run() { .with_plugin_loader(plugin_loader.clone()) .with_plugin_config_store(plugin_config_store) .with_account_repo(account_repo) - .with_package_read_repo(package_read_repo), + .with_package_read_repo(package_read_repo) + .with_captcha_repo(captcha_repo), ); // ── Register AppState ─────────────────────────────────── @@ -525,6 +545,13 @@ pub fn run() { // ── Queue manager event listener ──────────────────────── queue_manager.clone().start_listening(); + let captcha_restore = captcha_handler.clone(); + tokio::spawn(async move { + if let Err(error) = captcha_restore.restore_pending().await { + tracing::warn!(error = %error, "failed to restore pending CAPTCHA timers"); + } + }); + // Re-schedule any Queued/Retry downloads that survived the // previous session (their engine tasks are gone). let qm_startup = queue_manager.clone(); @@ -568,6 +595,11 @@ pub fn run() { download_resume, download_cancel, download_skip_wait, + captcha_solve, + captcha_skip, + captcha_retry, + captcha_list, + captcha_get_pending, download_change_directory, download_change_directory_bulk, download_retry, diff --git a/src/api/queries.ts b/src/api/queries.ts index 459a970e..d3ca0c51 100644 --- a/src/api/queries.ts +++ b/src/api/queries.ts @@ -54,3 +54,10 @@ export const accountQueries = { detail: (id: string) => [...accountQueries.details(), id] as const, traffic: (id: string) => [...accountQueries.all(), "traffic", id] as const, }; + +export const captchaQueries = { + all: () => ["captcha"] as const, + list: () => [...captchaQueries.all(), "list"] as const, + details: () => [...captchaQueries.all(), "detail"] as const, + detail: (id: string) => [...captchaQueries.details(), id] as const, +}; diff --git a/src/components/__tests__/ClipboardIndicator.test.tsx b/src/components/__tests__/ClipboardIndicator.test.tsx index 33fdc4f6..ccc24c66 100644 --- a/src/components/__tests__/ClipboardIndicator.test.tsx +++ b/src/components/__tests__/ClipboardIndicator.test.tsx @@ -32,6 +32,7 @@ const baseConfig: AppConfig = { preAllocateSpace: false, dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, + captchaTimeoutSeconds: 120, proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/hooks/__tests__/useAppEffects.test.ts b/src/hooks/__tests__/useAppEffects.test.ts index d182788c..309251c9 100644 --- a/src/hooks/__tests__/useAppEffects.test.ts +++ b/src/hooks/__tests__/useAppEffects.test.ts @@ -29,6 +29,7 @@ const baseConfig: AppConfig = { preAllocateSpace: false, dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, + captchaTimeoutSeconds: 120, proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/hooks/__tests__/useDownloadEvents.test.ts b/src/hooks/__tests__/useDownloadEvents.test.ts index 4a57a833..ec7c6bc4 100644 --- a/src/hooks/__tests__/useDownloadEvents.test.ts +++ b/src/hooks/__tests__/useDownloadEvents.test.ts @@ -33,6 +33,7 @@ describe("useDownloadEvents", () => { renderHook(() => useDownloadEvents()); const subscribedEvents = vi.mocked(useTauriEvent).mock.calls.map(([event]) => event); expect(subscribedEvents).toContain("download-created"); + expect(subscribedEvents).toContain("download-queued"); expect(subscribedEvents).toContain("download-started"); expect(subscribedEvents).toContain("download-paused"); expect(subscribedEvents).toContain("download-resumed"); @@ -45,6 +46,10 @@ describe("useDownloadEvents", () => { expect(subscribedEvents).toContain("download-checking"); expect(subscribedEvents).toContain("download-removed"); expect(subscribedEvents).toContain("download-extracting"); + expect(subscribedEvents).toContain("captcha-pending"); + expect(subscribedEvents).toContain("captcha-solved"); + expect(subscribedEvents).toContain("captcha-skipped"); + expect(subscribedEvents).toContain("captcha-timed-out"); }); it("should invalidate download list queries on download-created", () => { @@ -97,8 +102,8 @@ describe("useDownloadEvents", () => { }); }); - it("should subscribe to exactly 15 lifecycle events", () => { + it("should subscribe to exactly 20 lifecycle events", () => { renderHook(() => useDownloadEvents()); - expect(useTauriEvent).toHaveBeenCalledTimes(15); + expect(useTauriEvent).toHaveBeenCalledTimes(20); }); }); diff --git a/src/hooks/useCaptchaQueue.ts b/src/hooks/useCaptchaQueue.ts new file mode 100644 index 00000000..4c14f2ff --- /dev/null +++ b/src/hooks/useCaptchaQueue.ts @@ -0,0 +1,38 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { tauriInvoke } from "@/api/client"; +import { captchaQueries } from "@/api/queries"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; +import type { CaptchaChallengeView } from "@/types/captcha"; +import type { CaptchaEventPayload } from "@/types/events"; + +const CAPTCHA_EVENTS = [ + "captcha-pending", + "captcha-solved", + "captcha-skipped", + "captcha-timed-out", +] as const; + +export function useCaptchaQueue() { + const queryClient = useQueryClient(); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: captchaQueries.all() }); + }; + + useTauriEvent(CAPTCHA_EVENTS[0], invalidate); + useTauriEvent(CAPTCHA_EVENTS[1], invalidate); + useTauriEvent(CAPTCHA_EVENTS[2], invalidate); + useTauriEvent(CAPTCHA_EVENTS[3], invalidate); + + return useQuery({ + queryKey: captchaQueries.list(), + queryFn: () => tauriInvoke("captcha_list"), + }); +} + +export function usePendingCaptcha(challengeId: string | undefined) { + return useQuery({ + enabled: challengeId !== undefined, + queryKey: captchaQueries.detail(challengeId ?? ""), + queryFn: () => tauriInvoke("captcha_get_pending", { challengeId }), + }); +} diff --git a/src/hooks/useDownloadEvents.ts b/src/hooks/useDownloadEvents.ts index c7bdaf26..dc718dc9 100644 --- a/src/hooks/useDownloadEvents.ts +++ b/src/hooks/useDownloadEvents.ts @@ -8,6 +8,7 @@ import type { DownloadRetryingPayload, DownloadWaitingStartedPayload, DownloadWaitingEndedPayload, + CaptchaEventPayload, } from "@/types/events"; export function useDownloadEvents(): void { @@ -16,6 +17,7 @@ export function useDownloadEvents(): void { }; useTauriEvent("download-created", invalidateDownloads); + useTauriEvent("download-queued", invalidateDownloads); useTauriEvent("download-started", invalidateDownloads); useTauriEvent("download-paused", invalidateDownloads); useTauriEvent("download-resumed", invalidateDownloads); @@ -28,6 +30,10 @@ export function useDownloadEvents(): void { useTauriEvent("download-checking", invalidateDownloads); useTauriEvent("download-removed", invalidateDownloads); useTauriEvent("download-extracting", invalidateDownloads); + useTauriEvent("captcha-pending", invalidateDownloads); + useTauriEvent("captcha-solved", invalidateDownloads); + useTauriEvent("captcha-skipped", invalidateDownloads); + useTauriEvent("captcha-timed-out", invalidateDownloads); // The accompanying `download-waiting` / `download-resumed-from-wait` // events already trigger query invalidation; these two only sync the // per-row wait ticket into the store for the countdown UI. diff --git a/src/i18n/__tests__/issue30-ui-fr.test.tsx b/src/i18n/__tests__/issue30-ui-fr.test.tsx index 9914624f..9fd7dfd7 100644 --- a/src/i18n/__tests__/issue30-ui-fr.test.tsx +++ b/src/i18n/__tests__/issue30-ui-fr.test.tsx @@ -126,18 +126,18 @@ describe("issue #30 — French UI translations", () => { expect(screen.getByText("0 actif")).toBeInTheDocument(); }); - it("renders placeholder views in French", () => { - const views = [ - { component: , title: "Captcha" }, - { component: , title: "Planificateur" }, - ]; - - for (const view of views) { - const result = render(view.component); - expect(screen.getByText(view.title)).toBeInTheDocument(); - expect(screen.getByText("Bientôt disponible")).toBeInTheDocument(); - result.unmount(); - } + it("renders the CAPTCHA queue in French", async () => { + mockInvoke.mockResolvedValue([]); + renderWithProviders(); + + expect(screen.getByRole("heading", { name: "Captcha" })).toBeInTheDocument(); + expect(await screen.findAllByText("Aucun CAPTCHA en attente")).not.toHaveLength(0); + }); + + it("renders scheduler placeholder in French", () => { + render(); + expect(screen.getByText("Planificateur")).toBeInTheDocument(); + expect(screen.getByText("Bientôt disponible")).toBeInTheDocument(); }); it("renders the Packages view header in French", async () => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b3a25281..a88bb585 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -14,6 +14,45 @@ "statistics": "Statistics", "settings": "Settings" }, + "captcha": { + "title": "Captcha", + "pendingCount_one": "{{count}} pending", + "pendingCount_other": "{{count}} pending", + "queue": "Pending queue", + "empty": "No CAPTCHA waiting", + "loading": "Loading CAPTCHA queue…", + "error": "Could not load the CAPTCHA queue", + "history": "Recent challenges", + "download": "Download #{{id}}", + "answer": "Captcha answer", + "unsupported": "This challenge requires browser interaction and is not supported yet.", + "actions": { + "solve": "Solve", + "skip": "Skip", + "retry": "Retry" + }, + "types": { + "image": "Image challenge", + "text_input": "Text challenge", + "recaptcha_v2": "reCAPTCHA v2", + "recaptcha_v3": "reCAPTCHA v3", + "hcaptcha": "hCaptcha" + }, + "status": { + "pending": "Pending", + "solved": "Solved", + "skipped": "Skipped", + "timed_out": "Timed out" + }, + "settings": { + "title": "Solver settings", + "description": "Solvers run in order. Only manual solving is enabled in this release.", + "manual": "Manual solver", + "manualDescription": "Enter the answer shown in the challenge image.", + "timeout": "Timeout (seconds)", + "fallback": "Expired challenges are skipped and logged." + } + }, "settings": { "tabs": { "general": "General", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8d37a5a7..b3d95bc5 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -14,6 +14,45 @@ "statistics": "Statistiques", "settings": "Paramètres" }, + "captcha": { + "title": "Captcha", + "pendingCount_one": "{{count}} en attente", + "pendingCount_other": "{{count}} en attente", + "queue": "File en attente", + "empty": "Aucun CAPTCHA en attente", + "loading": "Chargement de la file CAPTCHA…", + "error": "Impossible de charger la file CAPTCHA", + "history": "Défis récents", + "download": "Téléchargement nº {{id}}", + "answer": "Réponse au captcha", + "unsupported": "Ce défi exige une interaction navigateur et n’est pas encore pris en charge.", + "actions": { + "solve": "Résoudre", + "skip": "Ignorer", + "retry": "Réessayer" + }, + "types": { + "image": "Défi image", + "text_input": "Défi texte", + "recaptcha_v2": "reCAPTCHA v2", + "recaptcha_v3": "reCAPTCHA v3", + "hcaptcha": "hCaptcha" + }, + "status": { + "pending": "En attente", + "solved": "Résolu", + "skipped": "Ignoré", + "timed_out": "Expiré" + }, + "settings": { + "title": "Configuration des solveurs", + "description": "Les solveurs s’exécutent dans l’ordre. Seule la résolution manuelle est active dans cette version.", + "manual": "Solveur manuel", + "manualDescription": "Saisissez la réponse affichée dans l’image du défi.", + "timeout": "Délai d’expiration (secondes)", + "fallback": "Les défis expirés sont ignorés et journalisés." + } + }, "settings": { "tabs": { "general": "Général", diff --git a/src/layouts/__tests__/AppLayout.test.tsx b/src/layouts/__tests__/AppLayout.test.tsx index 68d88ca2..3137990c 100644 --- a/src/layouts/__tests__/AppLayout.test.tsx +++ b/src/layouts/__tests__/AppLayout.test.tsx @@ -34,6 +34,7 @@ const baseConfig: AppConfig = { preAllocateSpace: false, dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, + captchaTimeoutSeconds: 120, proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/stores/__tests__/settingsStore.test.ts b/src/stores/__tests__/settingsStore.test.ts index 3957c90c..2504a5a3 100644 --- a/src/stores/__tests__/settingsStore.test.ts +++ b/src/stores/__tests__/settingsStore.test.ts @@ -30,6 +30,7 @@ const baseConfig: AppConfig = { preAllocateSpace: false, dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, + captchaTimeoutSeconds: 120, proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/types/captcha.ts b/src/types/captcha.ts new file mode 100644 index 00000000..25f29bb2 --- /dev/null +++ b/src/types/captcha.ts @@ -0,0 +1,19 @@ +export type CaptchaType = "image" | "text_input" | "recaptcha_v2" | "recaptcha_v3" | "hcaptcha"; +export type CaptchaStatus = "pending" | "solved" | "skipped" | "timed_out"; + +export interface CaptchaChallengeView { + id: string; + downloadId: number; + challengeType: CaptchaType; + challengeUrl: string; + imageData: number[] | null; + imageMimeType: string | null; + status: CaptchaStatus; + solver: string | null; + attempts: number; + createdAt: number; + expiresAt: number; + resolvedAt: number | null; + durationMs: number | null; + failureReason: string | null; +} diff --git a/src/types/events.ts b/src/types/events.ts index 5087e63a..a2869892 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -33,6 +33,11 @@ export interface DownloadWaitingEndedPayload { expiredNaturally: boolean; } +export interface CaptchaEventPayload { + challengeId: string; + downloadId: number; +} + export interface SegmentPayload { downloadId: number; segmentId: number; @@ -92,6 +97,7 @@ export interface AccountExhaustedPayload extends AccountIdPayload { export type TauriEventMap = { "download-created": DownloadIdPayload; + "download-queued": DownloadIdPayload; "download-started": DownloadIdPayload; "download-paused": DownloadIdPayload; "download-resumed": DownloadIdPayload; @@ -107,6 +113,10 @@ export type TauriEventMap = { "download-removed": DownloadIdPayload; "download-extracting": DownloadIdPayload; "download-progress": DownloadProgressPayload; + "captcha-pending": CaptchaEventPayload; + "captcha-solved": CaptchaEventPayload; + "captcha-skipped": CaptchaEventPayload; + "captcha-timed-out": CaptchaEventPayload; "segment-started": SegmentPayload; "segment-completed": SegmentPayload; "segment-failed": SegmentFailedPayload; diff --git a/src/types/settings.ts b/src/types/settings.ts index 9c6e7906..fcf49af6 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -30,6 +30,7 @@ export interface AppConfig { preAllocateSpace: boolean; dynamicSplitEnabled: boolean; dynamicSplitMinRemainingMb: number; + captchaTimeoutSeconds: number; // History historyRetentionDays: number; diff --git a/src/views/CaptchaChallengePanel.tsx b/src/views/CaptchaChallengePanel.tsx new file mode 100644 index 00000000..295f6517 --- /dev/null +++ b/src/views/CaptchaChallengePanel.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { captchaQueries, downloadQueries } from "@/api/queries"; +import { useTauriMutation } from "@/api/hooks"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { useCountdown } from "@/hooks/useCountdown"; +import type { CaptchaChallengeView } from "@/types/captcha"; + +const INVALIDATE_KEYS = [captchaQueries.all(), downloadQueries.all()] as const; + +export function CaptchaChallengePanel({ challenge }: { challenge: CaptchaChallengeView }) { + const { t } = useTranslation(); + const [solution, setSolution] = useState(""); + const countdown = useCountdown(challenge.expiresAt); + const acceptsText = + challenge.challengeType === "image" || challenge.challengeType === "text_input"; + const totalMs = Math.max(1, challenge.expiresAt - challenge.createdAt); + const progress = Math.min(100, (countdown.remainingSeconds * 1_000 * 100) / totalMs); + + useEffect(() => setSolution(""), [challenge.id]); + + const solve = useTauriMutation("captcha_solve", { + invalidateKeys: INVALIDATE_KEYS, + }); + const skip = useTauriMutation("captcha_skip", { + invalidateKeys: INVALIDATE_KEYS, + }); + const retry = useTauriMutation("captcha_retry", { + invalidateKeys: INVALIDATE_KEYS, + }); + const busy = solve.isPending || skip.isPending || retry.isPending; + + return ( + + + {t("captcha.download", { id: challenge.downloadId })} +
+ {t(`captcha.types.${challenge.challengeType}`)} + {countdown.label} +
+ +
+ + {challenge.imageData && challenge.imageMimeType ? ( + + ) : null} + {acceptsText ? ( +
+ + setSolution(event.target.value)} + value={solution} + /> +
+ ) : ( +

{t("captcha.unsupported")}

+ )} +
+ {acceptsText ? ( + + ) : null} + + +
+
+
+ ); +} + +function CaptchaImage({ bytes, mimeType }: { bytes: number[]; mimeType: string }) { + const [source, setSource] = useState(null); + + useEffect(() => { + const url = URL.createObjectURL(new Blob([Uint8Array.from(bytes)], { type: mimeType })); + setSource(url); + return () => URL.revokeObjectURL(url); + }, [bytes, mimeType]); + + return source ? ( + CAPTCHA + ) : null; +} diff --git a/src/views/CaptchaSolverSettings.tsx b/src/views/CaptchaSolverSettings.tsx new file mode 100644 index 00000000..3293327b --- /dev/null +++ b/src/views/CaptchaSolverSettings.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const DEFAULT_TIMEOUT_SECONDS = 120; + +export function CaptchaSolverSettings() { + const { t } = useTranslation(); + const configured = useSettingsStore( + (state) => state.config?.captchaTimeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, + ); + const updateConfig = useSettingsStore((state) => state.updateConfig); + const [timeout, setTimeout] = useState(configured); + + useEffect(() => setTimeout(configured), [configured]); + + const persistTimeout = () => { + const normalized = Math.min(3_600, Math.max(10, timeout || DEFAULT_TIMEOUT_SECONDS)); + setTimeout(normalized); + void updateConfig({ captchaTimeoutSeconds: normalized }); + }; + + return ( + + + {t("captcha.settings.title")} + {t("captcha.settings.description")} + + +
+

{t("captcha.settings.manual")}

+

{t("captcha.settings.manualDescription")}

+
+
+ + setTimeout(Number(event.target.value))} + type="number" + value={timeout} + /> +

{t("captcha.settings.fallback")}

+
+
+
+ ); +} diff --git a/src/views/CaptchaView.tsx b/src/views/CaptchaView.tsx index a81f97d3..cc1e00ce 100644 --- a/src/views/CaptchaView.tsx +++ b/src/views/CaptchaView.tsx @@ -1,6 +1,80 @@ -import { Shield } from "lucide-react"; -import { PlaceholderView } from "./PlaceholderView"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useCaptchaQueue, usePendingCaptcha } from "@/hooks/useCaptchaQueue"; +import { CaptchaChallengePanel } from "./CaptchaChallengePanel"; +import { CaptchaSolverSettings } from "./CaptchaSolverSettings"; export function CaptchaView() { - return ; + const { t } = useTranslation(); + const [selectedId, setSelectedId] = useState(null); + const { data = [], isLoading, error } = useCaptchaQueue(); + const pending = data.filter((challenge) => challenge.status === "pending"); + const history = data.filter((challenge) => challenge.status !== "pending"); + const selected = pending.find((challenge) => challenge.id === selectedId) ?? pending[0]; + const { data: selectedDetail } = usePendingCaptcha(selected?.id); + + return ( +
+
+

{t("captcha.title")}

+ {t("captcha.pendingCount", { count: pending.length })} +
+ + {isLoading ?

{t("captcha.loading")}

: null} + {error ?

{t("captcha.error")}

: null} + + {!isLoading && !error ? ( +
+ + + {t("captcha.queue")} + + + {pending.length === 0 ? ( +

{t("captcha.empty")}

+ ) : ( + pending.map((challenge) => ( + + )) + )} +
+
+ +
+ {selected ? : null} + + {history.length > 0 ? ( + + + {t("captcha.history")} + + + {history.slice(0, 10).map((challenge) => ( +
+ {t("captcha.download", { id: challenge.downloadId })} + {t(`captcha.status.${challenge.status}`)} +
+ ))} +
+
+ ) : null} +
+
+ ) : null} +
+ ); } diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index e6553da0..f6f34891 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -30,6 +30,7 @@ const baseConfig: AppConfig = { preAllocateSpace: false, dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, + captchaTimeoutSeconds: 120, proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/views/SettingsView/__tests__/Sections.test.tsx b/src/views/SettingsView/__tests__/Sections.test.tsx index 0c89c11d..b2b2a4c2 100644 --- a/src/views/SettingsView/__tests__/Sections.test.tsx +++ b/src/views/SettingsView/__tests__/Sections.test.tsx @@ -46,6 +46,7 @@ const mockConfig: AppConfig = { preAllocateSpace: true, dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, + captchaTimeoutSeconds: 120, historyRetentionDays: 30, proxyType: "none", proxyUrl: null, diff --git a/src/views/SettingsView/__tests__/SettingsView.test.tsx b/src/views/SettingsView/__tests__/SettingsView.test.tsx index 23d81442..6344411e 100644 --- a/src/views/SettingsView/__tests__/SettingsView.test.tsx +++ b/src/views/SettingsView/__tests__/SettingsView.test.tsx @@ -35,6 +35,7 @@ const mockConfig: AppConfig = { preAllocateSpace: true, dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, + captchaTimeoutSeconds: 120, proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/views/__tests__/CaptchaView.test.tsx b/src/views/__tests__/CaptchaView.test.tsx index 402dbec9..c05f607a 100644 --- a/src/views/__tests__/CaptchaView.test.tsx +++ b/src/views/__tests__/CaptchaView.test.tsx @@ -16,6 +16,7 @@ const pendingCaptcha = { challengeType: "image", challengeUrl: "https://hoster.example/file/42", imageData: [137, 80, 78, 71], + imageMimeType: "image/png", status: "pending", solver: null, attempts: 0, @@ -41,7 +42,10 @@ beforeEach(() => { window.localStorage.setItem("i18nextLng", "en"); mockInvoke.mockReset(); mockInvoke.mockImplementation(async (command: string) => { - if (command === "captcha_list") return [pendingCaptcha]; + if (command === "captcha_list") { + return [{ ...pendingCaptcha, imageData: null, imageMimeType: null }]; + } + if (command === "captcha_get_pending") return pendingCaptcha; return null; }); Object.defineProperty(URL, "createObjectURL", { From 74ad5a7967ab4a8d1c5dd9e2fede60560d47148b Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:48:23 +0200 Subject: [PATCH 3/4] fix: address PR review comments --- CHANGELOG.md | 4 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + .../driven/config/toml_config_store.rs | 27 ++- .../src/adapters/driven/event/tauri_bridge.rs | 11 ++ .../driven/logging/download_log_bridge.rs | 22 ++- .../driven/network/download_engine.rs | 2 +- .../driven/network/download_engine_tests.rs | 33 +++- .../adapters/driven/plugin/hoster_contract.rs | 17 +- .../driven/plugin/hoster_contract_tests.rs | 51 +++++- .../adapters/driven/sqlite/captcha_repo.rs | 2 +- .../driven/sqlite/captcha_repo_tests.rs | 12 ++ .../application/commands/cancel_download.rs | 67 ++++++- src-tauri/src/application/commands/captcha.rs | 75 ++++++-- .../src/application/commands/captcha_tests.rs | 167 +++++++++++++++++- .../application/commands/remove_download.rs | 118 ++++++++++++- src-tauri/src/application/queries/captcha.rs | 109 ++++++++++++ .../application/read_models/captcha_view.rs | 8 +- src-tauri/src/domain/error.rs | 37 +++- src-tauri/src/domain/event.rs | 6 +- src-tauri/src/domain/model/captcha.rs | 46 +++++ src-tauri/src/domain/model/download.rs | 25 +++ .../src/domain/ports/driven/hoster_link.rs | 27 ++- src/hooks/__tests__/useCaptchaQueue.test.tsx | 62 +++++++ src/hooks/useCaptchaQueue.ts | 13 +- src/hooks/useDownloadEvents.ts | 9 +- src/types/captcha.ts | 2 +- src/types/events.ts | 19 +- src/views/CaptchaChallengePanel.tsx | 29 +-- src/views/CaptchaSolverSettings.tsx | 16 +- src/views/CaptchaView.tsx | 11 +- src/views/__tests__/CaptchaView.test.tsx | 50 ++++-- 32 files changed, 978 insertions(+), 101 deletions(-) create mode 100644 src/hooks/__tests__/useCaptchaQueue.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 057fd648..ac79e78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - CAPTCHA pipeline: persistent manual challenge queue, solve/skip/retry - actions, timeout handling, automatic download resumption, and redacted - challenge history (MAT-140). + actions, timeout handling, automatic download resumption, compact image + transport, bounded plugin inputs, and redacted challenge history (MAT-140). ### Security diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 245ab50c..89aff260 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7429,6 +7429,7 @@ version = "0.3.0-beta.1" dependencies = [ "aes-gcm", "anyhow", + "base64 0.22.1", "bincode", "bytes", "bzip2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f6d3553e..ffbe8a13 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,6 +60,7 @@ pbkdf2 = "0.13.0" hmac = "0.13.0" rand = "0.10.1" url = "2.5.8" +base64 = "0.22.1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/adapters/driven/config/toml_config_store.rs b/src-tauri/src/adapters/driven/config/toml_config_store.rs index 761ccf88..0f94e324 100644 --- a/src-tauri/src/adapters/driven/config/toml_config_store.rs +++ b/src-tauri/src/adapters/driven/config/toml_config_store.rs @@ -9,7 +9,8 @@ use std::sync::Mutex; use crate::domain::error::DomainError; use crate::domain::model::account::AccountSelectionStrategy; use crate::domain::model::config::{ - AppConfig, ConfigPatch, apply_patch, normalize_history_retention_days, + AppConfig, ConfigPatch, MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS, apply_patch, + normalize_history_retention_days, }; use crate::domain::ports::driven::ConfigStore; @@ -287,7 +288,9 @@ impl TryFrom 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, + captcha_timeout_seconds: d + .captcha_timeout_seconds + .clamp(MIN_CAPTCHA_TIMEOUT_SECONDS, MAX_CAPTCHA_TIMEOUT_SECONDS), history_retention_days: normalize_history_retention_days(d.history_retention_days), account_selection_strategy, proxy_type: d.proxy_type, @@ -316,6 +319,7 @@ impl TryFrom for AppConfig { #[cfg(test)] mod tests { use super::*; + use crate::domain::model::config::{MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS}; /// Non-empty bootstrap key used by tests that don't assert on `api_key` /// but still exercise a fresh-config code path, which now requires one. @@ -559,6 +563,25 @@ mod tests { assert_eq!(config.history_retention_days, 0); } + #[test] + fn test_loading_config_clamps_hand_edited_captcha_timeout() { + for (persisted, expected) in [ + (1, MIN_CAPTCHA_TIMEOUT_SECONDS), + (u32::MAX, MAX_CAPTCHA_TIMEOUT_SECONDS), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, format!("captcha_timeout_seconds = {persisted}\n")).unwrap(); + + let store = TomlConfigStore::new(path, None, None); + + assert_eq!( + store.get_config().unwrap().captcha_timeout_seconds, + expected + ); + } + } + #[test] fn test_history_retention_days_is_persisted_and_reloaded() { // Round-trips the new history retention preference through diff --git a/src-tauri/src/adapters/driven/event/tauri_bridge.rs b/src-tauri/src/adapters/driven/event/tauri_bridge.rs index f91e22dc..493399d6 100644 --- a/src-tauri/src/adapters/driven/event/tauri_bridge.rs +++ b/src-tauri/src/adapters/driven/event/tauri_bridge.rs @@ -365,6 +365,7 @@ fn to_tauri_event(event: &DomainEvent) -> (&'static str, serde_json::Value) { #[cfg(test)] mod tests { use super::*; + use crate::domain::model::captcha::CaptchaType; use crate::domain::model::download::DownloadId; #[test] @@ -396,6 +397,16 @@ mod tests { )); } + #[test] + fn test_internal_captcha_required_event_is_not_forwarded() { + assert!(!should_forward_to_frontend(&DomainEvent::CaptchaRequired { + download_id: DownloadId(8), + challenge_type: CaptchaType::Image, + challenge_url: "https://hoster.example/captcha".into(), + image_data: Some(std::sync::Arc::<[u8]>::from(vec![1, 2, 3])), + })); + } + #[test] fn test_event_name_download_variants() { assert_eq!( diff --git a/src-tauri/src/adapters/driven/logging/download_log_bridge.rs b/src-tauri/src/adapters/driven/logging/download_log_bridge.rs index 3da36e9f..4ee62791 100644 --- a/src-tauri/src/adapters/driven/logging/download_log_bridge.rs +++ b/src-tauri/src/adapters/driven/logging/download_log_bridge.rs @@ -84,7 +84,7 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) { reason, .. } => { - store.push(download_id.0, format!("[WARN] {reason}")); + store.push(download_id.0, format!("[WARN] CAPTCHA skipped: {reason}")); } DomainEvent::CaptchaTimedOut { download_id, @@ -208,6 +208,7 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) { mod tests { use super::record_download_event; use crate::domain::event::DomainEvent; + use crate::domain::model::captcha::CaptchaId; use crate::domain::model::download::DownloadId; use super::DownloadLogStore; @@ -230,6 +231,25 @@ mod tests { ); } + #[test] + fn prefixes_skipped_captcha_log_lines() { + let store = DownloadLogStore::new(8); + + record_download_event( + &store, + &DomainEvent::CaptchaSkipped { + challenge_id: CaptchaId::new("captcha-42"), + download_id: DownloadId(42), + reason: "user choice".into(), + }, + ); + + assert_eq!( + store.recent(42, 10), + vec!["[WARN] CAPTCHA skipped: user choice".to_string()] + ); + } + #[test] fn ignores_unscoped_events() { let store = DownloadLogStore::new(8); diff --git a/src-tauri/src/adapters/driven/network/download_engine.rs b/src-tauri/src/adapters/driven/network/download_engine.rs index 5adb9409..bae7a0ac 100644 --- a/src-tauri/src/adapters/driven/network/download_engine.rs +++ b/src-tauri/src/adapters/driven/network/download_engine.rs @@ -62,7 +62,7 @@ fn source_resolution_event( download_id, challenge_type: *challenge_type, challenge_url: challenge_url.clone(), - image_data: image_data.clone(), + image_data: image_data.as_deref().map(Arc::<[u8]>::from), }; } DomainEvent::DownloadFailed { diff --git a/src-tauri/src/adapters/driven/network/download_engine_tests.rs b/src-tauri/src/adapters/driven/network/download_engine_tests.rs index 2a15d0cc..85c0bf36 100644 --- a/src-tauri/src/adapters/driven/network/download_engine_tests.rs +++ b/src-tauri/src/adapters/driven/network/download_engine_tests.rs @@ -33,11 +33,42 @@ fn captcha_resolution_failure_emits_the_typed_challenge() { download_id: DownloadId(42), challenge_type: CaptchaType::Image, challenge_url: "https://hoster.example/file".into(), - image_data: Some(vec![1, 2, 3]), + image_data: Some(Arc::<[u8]>::from(vec![1, 2, 3])), } ); } +#[test] +fn cloned_captcha_events_share_the_ephemeral_image_buffer() { + 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, + ); + let cloned = event.clone(); + + let DomainEvent::CaptchaRequired { + image_data: Some(original), + .. + } = &event + else { + panic!("expected CAPTCHA event"); + }; + let DomainEvent::CaptchaRequired { + image_data: Some(copy), + .. + } = &cloned + else { + panic!("expected cloned CAPTCHA event"); + }; + + assert_eq!(original.as_ptr(), copy.as_ptr()); +} + #[test] fn mock_file_storage_does_not_probe_the_host_filesystem() { let temp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs index 23550205..c855e0ef 100644 --- a/src-tauri/src/adapters/driven/plugin/hoster_contract.rs +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs @@ -10,7 +10,7 @@ use crate::domain::model::captcha::{ }; use crate::domain::ports::driven::{ExtractedCaptchaChallenge, ExtractedHosterLink}; -const MAX_HOSTER_PAYLOAD_BYTES: usize = 8 * 1024 * 1024; +const MAX_HOSTER_PAYLOAD_BYTES: usize = MAX_CAPTCHA_IMAGE_BYTES * 4 + 64 * 1024; const MAX_HOSTER_FILES: usize = 500; const MAX_URL_BYTES: usize = 8 * 1024; const MAX_FILENAME_BYTES: usize = 4 * 1024; @@ -71,11 +71,16 @@ pub(super) fn parse_hoster_links(payload: &str) -> Result()?; - if file.captcha_image_data.as_ref().is_some_and(|data| { - data.is_empty() - || data.len() > MAX_CAPTCHA_IMAGE_BYTES - || captcha_image_mime_type(data).is_none() - }) { + let image_data = file.captcha_image_data.as_deref(); + let image_required = + matches!(challenge_type, CaptchaType::Image | CaptchaType::TextInput); + if (image_required && image_data.is_none()) + || image_data.is_some_and(|data| { + data.is_empty() + || data.len() > MAX_CAPTCHA_IMAGE_BYTES + || captcha_image_mime_type(data).is_none() + }) + { return Err(limit_error()); } Some(ExtractedCaptchaChallenge { diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs index 01d7cdd5..3359abef 100644 --- a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs @@ -4,7 +4,7 @@ use super::ExtismPluginLoader; use super::capabilities::SharedHostResources; use super::hoster_contract::{parse_hoster_link, parse_hoster_links}; use crate::domain::error::DomainError; -use crate::domain::model::captcha::CaptchaType; +use crate::domain::model::captcha::{CaptchaType, MAX_CAPTCHA_IMAGE_BYTES}; use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::ports::driven::PluginLoader; @@ -66,6 +66,55 @@ fn test_parse_hoster_link_rejects_untrusted_captcha_image_formats() { assert!(matches!(result, Err(DomainError::PluginError(_)))); } +#[test] +fn test_parse_hoster_link_requires_images_for_visual_challenges() { + for challenge_type in ["image", "text_input"] { + let payload = serde_json::json!({ + "files": [{ + "url": "https://hoster.example/file", + "requires_captcha": true, + "captcha_type": challenge_type + }] + }) + .to_string(); + + assert!(matches!( + parse_hoster_link(&payload), + Err(DomainError::PluginError(_)) + )); + } +} + +#[test] +fn test_parse_hoster_link_allows_token_challenges_without_images() { + let parsed = parse_hoster_link( + r#"{"files":[{"url":"https://hoster.example/file","requires_captcha":true,"captcha_type":"recaptcha_v2"}]}"#, + ) + .expect("token challenge does not require an image"); + + assert_eq!( + parsed.captcha.expect("captcha metadata").challenge_type, + CaptchaType::ReCaptchaV2 + ); +} + +#[test] +fn test_parse_hoster_link_accepts_the_largest_valid_image_serialization() { + let mut image = vec![255; MAX_CAPTCHA_IMAGE_BYTES]; + image[..24].copy_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01"); + let payload = serde_json::json!({ + "files": [{ + "url": "https://hoster.example/file", + "requires_captcha": true, + "captcha_type": "image", + "captcha_image_data": image + }] + }) + .to_string(); + + assert!(parse_hoster_link(&payload).is_ok()); +} + #[test] fn test_parse_hoster_links_preserves_every_gofile_entry() { let parsed = parse_hoster_links( diff --git a/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs b/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs index 2a164452..c81fe346 100644 --- a/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs @@ -18,7 +18,7 @@ const CAPTCHA_METADATA_QUERY: &str = "SELECT id, download_id, challenge_type, \ const PENDING_CAPTCHA_METADATA_QUERY: &str = "SELECT id, download_id, challenge_type, \ '[redacted]' AS challenge_url, NULL AS image_data, status, solver, attempts, created_at, expires_at, \ resolved_at, duration_ms, failure_reason FROM captcha_log WHERE status = ? \ - ORDER BY created_at ASC"; + ORDER BY created_at ASC LIMIT 200"; pub struct SqliteCaptchaRepo { db: DatabaseConnection, diff --git a/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs index 2b5ada24..fc748d1c 100644 --- a/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs +++ b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs @@ -76,3 +76,15 @@ async fn captcha_log_never_has_a_solution_column() { assert!(!names.iter().any(|name| name.contains("solution"))); } + +#[tokio::test(flavor = "multi_thread")] +async fn pending_query_is_defensively_capped() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteCaptchaRepo::new(db); + for index in 0..201 { + repo.save(&challenge(&format!("captcha-{index}"), index)) + .expect("save pending challenge"); + } + + assert_eq!(repo.list_pending().expect("list pending").len(), 200); +} diff --git a/src-tauri/src/application/commands/cancel_download.rs b/src-tauri/src/application/commands/cancel_download.rs index 22230909..147fa835 100644 --- a/src-tauri/src/application/commands/cancel_download.rs +++ b/src-tauri/src/application/commands/cancel_download.rs @@ -15,10 +15,7 @@ impl CommandBus { .find_by_id(cmd.id)? .ok_or_else(|| AppError::NotFound(format!("Download {} not found", cmd.id.0)))?; - let was_waiting_for_captcha = match self.captcha_handler_opt() { - Some(handler) => handler.skip_pending_for_download(cmd.id).await?, - None => false, - }; + let was_waiting_for_captcha = self.has_pending_captcha_for_download(cmd.id).await?; // Cancel engine if download is active let is_active = matches!( @@ -29,15 +26,14 @@ impl CommandBus { self.download_engine().cancel(cmd.id)?; } + self.delete_download_with_captcha_cleanup(&download).await?; + // Cleanup metadata file (best-effort, log on failure) let meta_path = format!("{}.vortex-meta", download.destination_path()); if let Err(e) = self.file_storage().delete_meta(Path::new(&meta_path)) { tracing::warn!("Failed to delete meta for download {:?}: {e}", cmd.id); } - // Remove from persistence - self.download_repo().delete(cmd.id)?; - // Only emit DownloadCancelled for active downloads. // QueueManager's decrement_and_schedule reacts to this event; // emitting it for non-active downloads would underflow active_count. @@ -57,6 +53,7 @@ impl CommandBus { mod tests { use std::collections::HashMap; use std::path::Path; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use crate::application::command_bus::CommandBus; @@ -131,14 +128,20 @@ mod tests { struct MockDownloadRepo { store: Mutex>, + fail_next_delete: AtomicBool, } impl MockDownloadRepo { fn new() -> Self { Self { store: Mutex::new(HashMap::new()), + fail_next_delete: AtomicBool::new(false), } } + + fn fail_next_delete(&self) { + self.fail_next_delete.store(true, Ordering::SeqCst); + } } impl DownloadRepository for MockDownloadRepo { @@ -152,6 +155,9 @@ mod tests { } fn delete(&self, id: DownloadId) -> Result<(), DomainError> { + if self.fail_next_delete.swap(false, Ordering::SeqCst) { + return Err(DomainError::StorageError("injected delete failure".into())); + } self.store.lock().unwrap().remove(&id.0); Ok(()) } @@ -539,4 +545,51 @@ mod tests { |event| matches!(event, DomainEvent::DownloadRemoved { id } if *id == DownloadId(3)) )); } + + #[tokio::test] + async fn cancel_delete_failure_leaves_the_captcha_pending() { + let repo = Arc::new(MockDownloadRepo::new()); + let engine = Arc::new(MockDownloadEngine::new()); + let events = Arc::new(MockEventBus::new()); + let mut download = make_download(4); + download.start().unwrap(); + download.wait().unwrap(); + repo.save(&download).unwrap(); + let captcha_id = CaptchaId::new("captcha-4"); + let captcha = CaptchaChallenge::new( + captcha_id.clone(), + DownloadId(4), + CaptchaType::Image, + "https://hoster.example/file".into(), + 1_000, + 121_000, + ) + .unwrap(); + let captcha_repo = Arc::new(MemoryCaptchaRepo::new(captcha)); + let handler = Arc::new(CaptchaCommandHandler::new( + captcha_repo.clone(), + repo.clone(), + events.clone(), + Arc::new(MockConfigStore), + Arc::new(FixedClock), + vec![Arc::new(ManualCaptchaSolver)], + )); + let bus = make_command_bus(repo.clone(), engine, events).with_captcha_handler(handler); + repo.fail_next_delete(); + + let result = bus + .handle_cancel_download(CancelDownloadCommand { id: DownloadId(4) }) + .await; + + assert!(result.is_err()); + assert!(repo.find_by_id(DownloadId(4)).unwrap().is_some()); + assert_eq!( + captcha_repo + .find_by_id(&captcha_id) + .unwrap() + .unwrap() + .status(), + CaptchaStatus::Pending + ); + } } diff --git a/src-tauri/src/application/commands/captcha.rs b/src-tauri/src/application/commands/captcha.rs index cdafaa66..e5693e60 100644 --- a/src-tauri/src/application/commands/captcha.rs +++ b/src-tauri/src/application/commands/captcha.rs @@ -97,7 +97,7 @@ impl CaptchaCommandHandler { download_id: *download_id, challenge_type: *challenge_type, challenge_url: challenge_url.clone(), - image_data: image_data.clone(), + image_data: image_data.as_ref().map(|image| image.to_vec()), }; tokio::spawn(async move { if let Err(error) = CommandHandler::handle(handler.as_ref(), command).await { @@ -177,20 +177,22 @@ impl CaptchaCommandHandler { challenge: &CaptchaChallenge, solution: &str, ) -> Result { + let mut last_error = None; for solver in self.solvers.iter() { - match solver.solve(challenge, solution)? { - CaptchaSolverOutcome::Solved => return Ok(solver.name().to_string()), - CaptchaSolverOutcome::Rejected => { + match solver.solve(challenge, solution) { + Ok(CaptchaSolverOutcome::Solved) => return Ok(solver.name().to_string()), + Ok(CaptchaSolverOutcome::Rejected) => { return Err(DomainError::ValidationError( "CAPTCHA solution was rejected".into(), )); } - CaptchaSolverOutcome::Unavailable => {} + Ok(CaptchaSolverOutcome::Unavailable) => {} + Err(error) => last_error = Some(error), } } - Err(DomainError::ValidationError( - "No solver supports this CAPTCHA type".into(), - )) + Err(last_error.unwrap_or_else(|| { + DomainError::ValidationError("No solver supports this CAPTCHA type".into()) + })) } } @@ -312,6 +314,17 @@ impl CommandHandler for CaptchaCommandHandler { } impl CaptchaCommandHandler { + pub(crate) async fn has_pending_for_download( + &self, + download_id: crate::domain::model::download::DownloadId, + ) -> Result { + let _guard = self.mutation_lock.lock().await; + Ok(self + .captchas + .find_pending_by_download(download_id)? + .is_some()) + } + pub(crate) async fn skip_pending_for_download( &self, download_id: crate::domain::model::download::DownloadId, @@ -356,11 +369,14 @@ impl CaptchaCommandHandler { challenge.skip(now, "CAPTCHA skipped by user")?; "CAPTCHA skipped by user" }; - let download = self.downloads.find_by_id(challenge.download_id())?; - let mut failed_download = download.clone(); - if let Some(download) = failed_download.as_mut() { - download.fail(reason.to_string())?; - } + let failed_download = match self.downloads.find_by_id(challenge.download_id())? { + Some(mut download) => match download.fail(reason.to_string()) { + Ok(_) => Some(download), + Err(DomainError::InvalidTransition { .. }) => None, + Err(error) => return Err(error), + }, + None => None, + }; self.captchas.save(&challenge)?; if let Some(download) = failed_download.as_ref() && let Err(error) = self.downloads.save_failed(download, reason) @@ -424,6 +440,39 @@ impl CommandHandler for CaptchaCommandHandler { } impl CommandBus { + pub(crate) async fn has_pending_captcha_for_download( + &self, + download_id: crate::domain::model::download::DownloadId, + ) -> Result { + match self.captcha_handler_opt() { + Some(handler) => handler + .has_pending_for_download(download_id) + .await + .map_err(AppError::Domain), + None => Ok(false), + } + } + + pub(crate) async fn delete_download_with_captcha_cleanup( + &self, + download: &crate::domain::model::download::Download, + ) -> Result<(), AppError> { + self.download_repo().delete(download.id())?; + let Some(handler) = self.captcha_handler_opt() else { + return Ok(()); + }; + if let Err(error) = handler.skip_pending_for_download(download.id()).await { + if let Err(rollback_error) = self.download_repo().save(download) { + tracing::error!( + error = %rollback_error, + "failed to restore download after CAPTCHA cleanup failure" + ); + } + return Err(AppError::Domain(error)); + } + Ok(()) + } + pub async fn handle_captcha_solve(&self, command: SolveCaptchaCommand) -> Result<(), AppError> { CommandHandler::handle(self.captcha_handler()?, command) .await diff --git a/src-tauri/src/application/commands/captcha_tests.rs b/src-tauri/src/application/commands/captcha_tests.rs index c39858fd..17018176 100644 --- a/src-tauri/src/application/commands/captcha_tests.rs +++ b/src-tauri/src/application/commands/captcha_tests.rs @@ -14,6 +14,7 @@ use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaStatus, use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; use crate::domain::ports::driven::{CaptchaRepository, Clock, ConfigStore, DownloadRepository}; +use crate::domain::ports::driven::{CaptchaSolver, CaptchaSolverOutcome}; use crate::domain::ports::driving::CommandHandler; struct MemoryCaptchaRepo { @@ -104,6 +105,10 @@ type Fixture = ( ); fn fixture() -> Fixture { + fixture_with_solvers(vec![Arc::new(ManualCaptchaSolver)]) +} + +fn fixture_with_solvers(solvers: Vec>) -> Fixture { let captchas = Arc::new(MemoryCaptchaRepo::new()); let downloads = Arc::new(InMemoryDownloadRepo::new()); let events = Arc::new(CapturingEventBus::new()); @@ -122,11 +127,27 @@ fn fixture() -> Fixture { events.clone(), Arc::new(FixedConfig), clock.clone(), - vec![Arc::new(ManualCaptchaSolver)], + solvers, ); (handler, captchas, downloads, events, clock) } +struct FailingCaptchaSolver; + +impl CaptchaSolver for FailingCaptchaSolver { + fn name(&self) -> &str { + "failing" + } + + fn solve( + &self, + _challenge: &CaptchaChallenge, + _solution: &str, + ) -> Result { + Err(DomainError::PluginError("solver unavailable".into())) + } +} + fn png_image() -> Vec { b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec() } @@ -229,6 +250,30 @@ async fn manual_solve_logs_metadata_and_requeues_without_persisting_answer() { assert!(events.snapshot().iter().any(|event| matches!(event, DomainEvent::CaptchaSolved { challenge_id, .. } if challenge_id == &id))); } +#[tokio::test] +async fn solve_falls_through_when_an_earlier_solver_errors() { + let (handler, captchas, _, _, _) = fixture_with_solvers(vec![ + Arc::new(FailingCaptchaSolver), + Arc::new(ManualCaptchaSolver), + ]); + let id = enqueue(&handler).await; + + >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: "abc123".into(), + }, + ) + .await + .expect("manual solver should run after the failing solver"); + + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().solver(), + Some("manual") + ); +} + #[tokio::test] async fn solve_rolls_back_the_challenge_when_download_persistence_fails() { let (handler, captchas, downloads, _, _) = fixture(); @@ -399,6 +444,126 @@ async fn timeout_skips_by_default_and_retry_renews_a_pending_challenge() { ); } +#[tokio::test] +async fn timeout_terminalizes_a_challenge_even_if_the_download_is_already_terminal() { + let (handler, captchas, downloads, _, clock) = fixture(); + let id = enqueue(&handler).await; + let mut completed = Download::new( + DownloadId(42), + Url::new("https://hoster.example/file").unwrap(), + "file.zip".into(), + "/tmp/file.zip".into(), + ); + completed.start().unwrap(); + completed.complete().unwrap(); + downloads.seed(completed); + clock.0.store(121_000, Ordering::SeqCst); + + >::handle( + &handler, + TimeoutCaptchaCommand { + challenge_id: id.clone(), + expected_expires_at: 121_000, + }, + ) + .await + .expect("terminal download must not keep the CAPTCHA pending"); + + assert_eq!( + captchas.find_by_id(&id).unwrap().unwrap().status(), + CaptchaStatus::TimedOut + ); +} + +#[tokio::test] +async fn retry_requeues_a_skipped_challenge_download() { + let (handler, _, downloads, events, _) = fixture(); + let id = enqueue(&handler).await; + >::handle( + &handler, + SkipCaptchaCommand { + challenge_id: id.clone(), + }, + ) + .await + .unwrap(); + + >::handle( + &handler, + RetryCaptchaCommand { challenge_id: id }, + ) + .await + .expect("skipped CAPTCHA can be retried manually"); + + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Retry + ); + assert!(events.snapshot().iter().any(|event| matches!( + event, + DomainEvent::DownloadRetrying { id, attempt: 1 } if *id == DownloadId(42) + ))); +} + +#[tokio::test] +async fn retry_requeues_a_timed_out_challenge_download() { + let (handler, _, downloads, _, clock) = fixture(); + let id = enqueue(&handler).await; + clock.0.store(121_000, Ordering::SeqCst); + >::handle( + &handler, + TimeoutCaptchaCommand { + challenge_id: id.clone(), + expected_expires_at: 121_000, + }, + ) + .await + .unwrap(); + + >::handle( + &handler, + RetryCaptchaCommand { challenge_id: id }, + ) + .await + .expect("timed-out CAPTCHA can be retried manually"); + + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Retry + ); +} + +#[tokio::test] +async fn retry_rejects_a_solved_challenge() { + let (handler, _, _, _, _) = fixture(); + let id = enqueue(&handler).await; + >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: "abc123".into(), + }, + ) + .await + .unwrap(); + + let result = >::handle( + &handler, + RetryCaptchaCommand { challenge_id: id }, + ) + .await; + + assert!(matches!(result, Err(DomainError::ValidationError(_)))); +} + #[tokio::test] async fn solve_rejects_an_expired_challenge() { let (handler, captchas, _, _, clock) = fixture(); diff --git a/src-tauri/src/application/commands/remove_download.rs b/src-tauri/src/application/commands/remove_download.rs index 3491d110..f67e8b76 100644 --- a/src-tauri/src/application/commands/remove_download.rs +++ b/src-tauri/src/application/commands/remove_download.rs @@ -15,10 +15,7 @@ impl CommandBus { .find_by_id(cmd.id)? .ok_or_else(|| AppError::NotFound(format!("Download {} not found", cmd.id.0)))?; - let was_waiting_for_captcha = match self.captcha_handler_opt() { - Some(handler) => handler.skip_pending_for_download(cmd.id).await?, - None => false, - }; + let was_waiting_for_captcha = self.has_pending_captcha_for_download(cmd.id).await?; let is_active = matches!( download.state(), @@ -29,6 +26,8 @@ impl CommandBus { let _ = self.download_engine().cancel(cmd.id); } + self.delete_download_with_captcha_cleanup(&download).await?; + if cmd.delete_files { // Remove the downloaded content file let dest = Path::new(download.destination_path()); @@ -40,8 +39,6 @@ impl CommandBus { let _ = self.file_storage().delete_meta(Path::new(&meta_path)); } - self.download_repo().delete(cmd.id)?; - // Only emit DownloadCancelled for active downloads. // QueueManager's decrement_and_schedule reacts to this event; // emitting for non-active downloads would underflow active_count. @@ -65,9 +62,11 @@ mod tests { use crate::application::command_bus::CommandBus; use crate::application::commands::RemoveDownloadCommand; + use crate::application::commands::captcha::{CaptchaCommandHandler, ManualCaptchaSolver}; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; + use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType}; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; @@ -75,10 +74,56 @@ mod tests { use crate::domain::model::meta::DownloadMeta; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::{ - ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, - EventBus, FileStorage, HttpClient, PluginLoader, + CaptchaRepository, ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, + DownloadRepository, EventBus, FileStorage, HttpClient, PluginLoader, }; + struct MemoryCaptchaRepo(Mutex>); + + impl CaptchaRepository for MemoryCaptchaRepo { + fn save(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { + *self.0.lock().unwrap() = Some(challenge.clone()); + Ok(()) + } + + fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .as_ref() + .filter(|challenge| challenge.id() == id) + .cloned()) + } + + fn list(&self) -> Result, DomainError> { + Ok(self.0.lock().unwrap().iter().cloned().collect()) + } + + fn list_pending(&self) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|challenge| challenge.status() == CaptchaStatus::Pending) + .cloned() + .collect()) + } + } + + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1 + } + + fn now_unix_ms(&self) -> u64 { + 1_000 + } + } + struct MockDownloadRepo { store: Mutex>, } @@ -303,6 +348,7 @@ mod tests { struct TestHarness { bus: CommandBus, + download_repo: Arc, engine: Arc, event_bus: Arc, file_storage: Arc, @@ -348,12 +394,13 @@ mod tests { } fn make_harness(repo: MockDownloadRepo) -> TestHarness { + let download_repo = Arc::new(repo); let engine = Arc::new(MockDownloadEngine::new()); let event_bus = Arc::new(MockEventBus::new()); let file_storage = Arc::new(MockFileStorage::new()); let bus = CommandBus::new( - Arc::new(repo), + download_repo.clone(), engine.clone(), event_bus.clone(), file_storage.clone(), @@ -369,6 +416,7 @@ mod tests { TestHarness { bus, + download_repo, engine, event_bus, file_storage, @@ -461,4 +509,56 @@ mod tests { let events = harness.event_bus.events.lock().unwrap(); assert!(events.contains(&DomainEvent::DownloadRemoved { id: DownloadId(1) })); } + + #[tokio::test] + async fn remove_captcha_wait_terminalizes_the_pending_challenge() { + let mut download = make_active_download(); + download.wait().unwrap(); + let harness = make_harness(MockDownloadRepo::new().with_download(download)); + let captcha_id = CaptchaId::new("captcha-remove"); + let captcha_repo = Arc::new(MemoryCaptchaRepo(Mutex::new(Some( + CaptchaChallenge::new( + captcha_id.clone(), + DownloadId(1), + CaptchaType::Image, + "https://hoster.example/file".into(), + 1_000, + 121_000, + ) + .unwrap(), + )))); + let handler = Arc::new(CaptchaCommandHandler::new( + captcha_repo.clone(), + harness.download_repo.clone(), + harness.event_bus.clone(), + Arc::new(MockConfigStore), + Arc::new(FixedClock), + vec![Arc::new(ManualCaptchaSolver)], + )); + let bus = harness.bus.with_captcha_handler(handler); + + bus.handle_remove_download(RemoveDownloadCommand { + id: DownloadId(1), + delete_files: false, + }) + .await + .expect("remove CAPTCHA wait"); + + assert!( + harness + .download_repo + .find_by_id(DownloadId(1)) + .unwrap() + .is_none() + ); + assert!(harness.engine.cancelled.lock().unwrap().is_empty()); + assert_eq!( + captcha_repo + .find_by_id(&captcha_id) + .unwrap() + .unwrap() + .status(), + CaptchaStatus::Skipped + ); + } } diff --git a/src-tauri/src/application/queries/captcha.rs b/src-tauri/src/application/queries/captcha.rs index cb06f7c0..9563a0d7 100644 --- a/src-tauri/src/application/queries/captcha.rs +++ b/src-tauri/src/application/queries/captcha.rs @@ -30,3 +30,112 @@ impl QueryBus { Ok(challenge.map(CaptchaViewDto::from)) } } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::application::queries::{CaptchaGetPendingQuery, CaptchaListQuery}; + use crate::application::test_support::{NoopHistoryRepo, make_history_query_bus}; + use crate::domain::error::DomainError; + use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaType}; + use crate::domain::model::download::DownloadId; + use crate::domain::ports::driven::CaptchaRepository; + + struct MemoryCaptchaRepo(Mutex>); + + impl CaptchaRepository for MemoryCaptchaRepo { + fn save(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { + self.0.lock().unwrap().push(challenge.clone()); + Ok(()) + } + + fn find_by_id(&self, id: &CaptchaId) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .find(|challenge| challenge.id() == id) + .cloned()) + } + + fn list(&self) -> Result, DomainError> { + Ok(self.0.lock().unwrap().clone()) + } + + fn list_pending(&self) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|challenge| challenge.status() == CaptchaStatus::Pending) + .cloned() + .collect()) + } + } + + fn challenge(id: &str, created_at: u64) -> CaptchaChallenge { + CaptchaChallenge::new( + CaptchaId::new(id), + DownloadId(created_at), + CaptchaType::Image, + "https://hoster.example/captcha".into(), + created_at, + created_at + 60_000, + ) + .unwrap() + .with_image_data(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec()) + .unwrap() + } + + fn bus(items: Vec) -> QueryBus { + make_history_query_bus(Arc::new(NoopHistoryRepo)) + .with_captcha_repo(Arc::new(MemoryCaptchaRepo(Mutex::new(items)))) + } + + #[tokio::test] + async fn list_returns_metadata_without_ephemeral_images() { + let results = bus(vec![challenge("captcha-1", 1_000)]) + .handle_captcha_list(CaptchaListQuery) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "captcha-1"); + assert!(results[0].image_data.is_none()); + } + + #[tokio::test] + async fn get_pending_supports_explicit_id_and_oldest_fallback() { + let mut solved = challenge("captcha-solved", 500); + solved.solve(600, "manual").unwrap(); + let bus = bus(vec![challenge("captcha-oldest", 1_000), solved]); + + let explicit = bus + .handle_captcha_get_pending(CaptchaGetPendingQuery { + challenge_id: Some(CaptchaId::new("captcha-oldest")), + }) + .await + .unwrap() + .expect("pending by id"); + let solved = bus + .handle_captcha_get_pending(CaptchaGetPendingQuery { + challenge_id: Some(CaptchaId::new("captcha-solved")), + }) + .await + .unwrap(); + let next = bus + .handle_captcha_get_pending(CaptchaGetPendingQuery { challenge_id: None }) + .await + .unwrap() + .expect("next pending"); + + assert_eq!(explicit.id, "captcha-oldest"); + assert!(solved.is_none()); + assert_eq!(next.id, "captcha-oldest"); + assert!(next.image_data.is_some()); + } +} diff --git a/src-tauri/src/application/read_models/captcha_view.rs b/src-tauri/src/application/read_models/captcha_view.rs index 941df276..aa131276 100644 --- a/src-tauri/src/application/read_models/captcha_view.rs +++ b/src-tauri/src/application/read_models/captcha_view.rs @@ -1,3 +1,4 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::Serialize; use crate::domain::model::captcha::{CaptchaChallenge, captcha_image_mime_type}; @@ -9,7 +10,7 @@ pub struct CaptchaViewDto { pub download_id: u64, pub challenge_type: String, pub challenge_url: String, - pub image_data: Option>, + pub image_data: Option, pub image_mime_type: Option, pub status: String, pub solver: Option, @@ -43,7 +44,7 @@ impl CaptchaViewDto { challenge_type: challenge.challenge_type().to_string(), challenge_url: challenge.url().to_string(), image_data: include_image - .then(|| challenge.image_data().map(<[u8]>::to_vec)) + .then(|| challenge.image_data().map(|image| STANDARD.encode(image))) .flatten(), image_mime_type, status: challenge.status().to_string(), @@ -82,10 +83,11 @@ mod tests { fn list_metadata_omits_image_bytes_but_pending_detail_includes_them() { let metadata = CaptchaViewDto::metadata(challenge()); let detail = CaptchaViewDto::from(challenge()); + let payload = serde_json::to_value(&detail).unwrap(); assert!(metadata.image_data.is_none()); assert!(metadata.image_mime_type.is_none()); - assert_eq!(detail.image_data.as_ref().map(Vec::len), Some(24)); + assert_eq!(payload["imageData"], "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"); assert_eq!(detail.image_mime_type.as_deref(), Some("image/png")); } } diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index 141e02a1..be48873d 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -2,7 +2,7 @@ use crate::domain::model::captcha::CaptchaType; use crate::domain::model::download::DownloadState; use crate::domain::model::segment::SegmentState; -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone, PartialEq)] pub enum DomainError { InvalidTransition { from: DownloadState, @@ -47,6 +47,24 @@ pub enum DomainError { UnsupportedChecksumFormat(String), } +impl std::fmt::Debug for DomainError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CaptchaRequired { + challenge_type, + image_data, + .. + } => formatter + .debug_struct("CaptchaRequired") + .field("challenge_type", challenge_type) + .field("challenge_url", &"") + .field("image_data_len", &image_data.as_ref().map(Vec::len)) + .finish(), + _ => std::fmt::Display::fmt(self, formatter), + } + } +} + impl std::fmt::Display for DomainError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -231,4 +249,21 @@ mod tests { assert!(msg.contains("MD5")); assert!(msg.contains("SHA-256")); } + + #[test] + fn captcha_required_debug_output_redacts_ephemeral_material() { + let error = DomainError::CaptchaRequired { + challenge_type: CaptchaType::Image, + challenge_url: "https://hoster.example/captcha?token=secret".into(), + image_data: Some(vec![222, 173, 190, 239]), + }; + + let debug = format!("{error:?}"); + + assert!(debug.contains("CaptchaRequired")); + assert!(debug.contains("Image")); + assert!(debug.contains("4")); + assert!(!debug.contains("token=secret")); + assert!(!debug.contains("222")); + } } diff --git a/src-tauri/src/domain/event.rs b/src-tauri/src/domain/event.rs index ba461119..be3efa16 100644 --- a/src-tauri/src/domain/event.rs +++ b/src-tauri/src/domain/event.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::domain::model::account::AccountId; use crate::domain::model::captcha::{CaptchaId, CaptchaType}; use crate::domain::model::download::DownloadId; @@ -158,11 +160,13 @@ pub enum DomainEvent { id: DownloadId, expired_naturally: bool, }, + /// Internal hand-off from the download engine to the CAPTCHA command + /// handler. The Tauri bridge deliberately never forwards this event. CaptchaRequired { download_id: DownloadId, challenge_type: CaptchaType, challenge_url: String, - image_data: Option>, + image_data: Option>, }, CaptchaPending { challenge_id: CaptchaId, diff --git a/src-tauri/src/domain/model/captcha.rs b/src-tauri/src/domain/model/captcha.rs index c756201e..75163309 100644 --- a/src-tauri/src/domain/model/captcha.rs +++ b/src-tauri/src/domain/model/captcha.rs @@ -227,6 +227,9 @@ impl CaptchaChallenge { pub fn skip(&mut self, now_ms: u64, reason: &str) -> Result<(), DomainError> { self.ensure_pending()?; + if reason.trim().is_empty() { + return Err(validation("CAPTCHA skip reason cannot be empty")); + } self.status = CaptchaStatus::Skipped; self.failure_reason = Some(reason.to_string()); self.resolve(now_ms); @@ -431,6 +434,20 @@ mod tests { image } + fn gif_image(width: u16, height: u16) -> Vec { + let mut image = b"GIF89a".to_vec(); + image.extend_from_slice(&width.to_le_bytes()); + image.extend_from_slice(&height.to_le_bytes()); + image + } + + fn jpeg_image(width: u16, height: u16) -> Vec { + let mut image = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 0x07, 0x08]; + image.extend_from_slice(&height.to_be_bytes()); + image.extend_from_slice(&width.to_be_bytes()); + image + } + #[test] fn new_challenge_is_pending_and_has_a_deadline() { let c = make_challenge(); @@ -469,6 +486,15 @@ mod tests { assert_eq!(timed_out.failure_reason(), Some("CAPTCHA timed out")); } + #[test] + fn skip_rejects_a_blank_reason_without_terminalizing_the_challenge() { + let mut challenge = make_challenge(); + + assert!(challenge.skip(2_000, " ").is_err()); + assert_eq!(challenge.status(), CaptchaStatus::Pending); + assert!(challenge.failure_reason().is_none()); + } + #[test] fn retry_renews_pending_deadline_and_counts_attempt() { let mut c = make_challenge(); @@ -501,6 +527,26 @@ mod tests { ); } + #[test] + fn gif_dimensions_accept_valid_headers_and_reject_malformed_ones() { + assert_eq!(captcha_image_mime_type(&gif_image(2, 3)), Some("image/gif")); + assert_eq!(captcha_image_mime_type(b"GIF89a\x02\0\x03"), None); + assert_eq!(captcha_image_mime_type(&gif_image(0, 3)), None); + } + + #[test] + fn jpeg_dimensions_accept_valid_headers_and_reject_malformed_ones() { + assert_eq!( + captcha_image_mime_type(&jpeg_image(2, 3)), + Some("image/jpeg") + ); + assert_eq!(captcha_image_mime_type(&jpeg_image(0, 3)), None); + assert_eq!( + captcha_image_mime_type(&[0xff, 0xd8, 0xff, 0xc0, 0x00, 0x20, 0x08]), + None + ); + } + #[test] fn terminal_challenge_drops_ephemeral_image_material() { let mut challenge = make_challenge() diff --git a/src-tauri/src/domain/model/download.rs b/src-tauri/src/domain/model/download.rs index 74000b55..78dfd3c0 100644 --- a/src-tauri/src/domain/model/download.rs +++ b/src-tauri/src/domain/model/download.rs @@ -938,6 +938,31 @@ mod tests { assert!(d4.complete().is_ok()); } + #[test] + fn queue_after_wait_returns_the_queued_event() { + let mut download = make_download(); + download.start().unwrap(); + download.wait().unwrap(); + + let event = download.queue_after_wait().unwrap(); + + assert_eq!(download.state(), DownloadState::Queued); + assert_eq!(event, DomainEvent::DownloadQueued { id: DownloadId(1) }); + } + + #[test] + fn queue_after_wait_rejects_non_waiting_downloads() { + let mut download = make_download(); + + assert!(matches!( + download.queue_after_wait(), + Err(DomainError::InvalidTransition { + from: DownloadState::Queued, + to: DownloadState::Queued, + }) + )); + } + #[test] fn test_download_state_all_invalid_transitions() { // Can't pause from Queued diff --git a/src-tauri/src/domain/ports/driven/hoster_link.rs b/src-tauri/src/domain/ports/driven/hoster_link.rs index 2adadcd5..84e60c85 100644 --- a/src-tauri/src/domain/ports/driven/hoster_link.rs +++ b/src-tauri/src/domain/ports/driven/hoster_link.rs @@ -2,12 +2,22 @@ use crate::domain::model::captcha::CaptchaType; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct ExtractedCaptchaChallenge { pub challenge_type: CaptchaType, pub image_data: Option>, } +impl std::fmt::Debug for ExtractedCaptchaChallenge { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExtractedCaptchaChallenge") + .field("challenge_type", &self.challenge_type) + .field("image_data_len", &self.image_data.as_ref().map(Vec::len)) + .finish() + } +} + /// One hoster file resolved by a plugin adapter. /// /// The adapter owns deserialisation of the plugin wire format. Application @@ -69,4 +79,19 @@ mod tests { assert!(!debug.contains("secret-token")); assert!(!debug.contains("Bearer secret")); } + + #[test] + fn captcha_debug_output_reports_size_without_leaking_image_bytes() { + let captcha = ExtractedCaptchaChallenge { + challenge_type: CaptchaType::Image, + image_data: Some(vec![222, 173, 190, 239]), + }; + + let debug = format!("{captcha:?}"); + + assert!(debug.contains("Image")); + assert!(debug.contains("4")); + assert!(!debug.contains("222")); + assert!(!debug.contains("173")); + } } diff --git a/src/hooks/__tests__/useCaptchaQueue.test.tsx b/src/hooks/__tests__/useCaptchaQueue.test.tsx new file mode 100644 index 00000000..44440b66 --- /dev/null +++ b/src/hooks/__tests__/useCaptchaQueue.test.tsx @@ -0,0 +1,62 @@ +import type { PropsWithChildren } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { tauriInvoke } from "@/api/client"; +import { useCaptchaQueue, usePendingCaptcha } from "@/hooks/useCaptchaQueue"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; + +vi.mock("@/api/client", () => ({ tauriInvoke: vi.fn() })); +vi.mock("@/hooks/useTauriEvent", () => ({ useTauriEvent: vi.fn() })); + +const invoke = vi.mocked(tauriInvoke); + +function wrapper(client: QueryClient) { + return function QueryWrapper({ children }: PropsWithChildren) { + return {children}; + }; +} + +describe("useCaptchaQueue", () => { + beforeEach(() => { + vi.clearAllMocks(); + invoke.mockResolvedValue([]); + }); + + it("subscribes to every CAPTCHA event and invalidates the queue", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidate = vi.spyOn(client, "invalidateQueries"); + + renderHook(() => useCaptchaQueue(), { wrapper: wrapper(client) }); + + expect(vi.mocked(useTauriEvent).mock.calls.map(([name]) => name)).toEqual([ + "captcha-pending", + "captcha-solved", + "captcha-skipped", + "captcha-timed-out", + ]); + for (const [, callback] of vi.mocked(useTauriEvent).mock.calls) { + act(() => callback({ challengeId: "captcha-1", downloadId: 42 })); + } + expect(invalidate).toHaveBeenCalledTimes(4); + expect(invalidate).toHaveBeenLastCalledWith({ queryKey: ["captcha"] }); + }); + + it("only fetches pending detail when an id is enabled", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender } = renderHook(({ id }: { id?: string }) => usePendingCaptcha(id), { + initialProps: { id: undefined } as { id?: string }, + wrapper: wrapper(client), + }); + + expect(invoke).not.toHaveBeenCalled(); + + rerender({ id: "captcha-42" }); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("captcha_get_pending", { + challengeId: "captcha-42", + }), + ); + }); +}); diff --git a/src/hooks/useCaptchaQueue.ts b/src/hooks/useCaptchaQueue.ts index 4c14f2ff..0db9fe5c 100644 --- a/src/hooks/useCaptchaQueue.ts +++ b/src/hooks/useCaptchaQueue.ts @@ -3,7 +3,12 @@ import { tauriInvoke } from "@/api/client"; import { captchaQueries } from "@/api/queries"; import { useTauriEvent } from "@/hooks/useTauriEvent"; import type { CaptchaChallengeView } from "@/types/captcha"; -import type { CaptchaEventPayload } from "@/types/events"; +import type { + CaptchaEventPayload, + CaptchaSkippedEventPayload, + CaptchaSolvedEventPayload, + CaptchaTimedOutEventPayload, +} from "@/types/events"; const CAPTCHA_EVENTS = [ "captcha-pending", @@ -19,9 +24,9 @@ export function useCaptchaQueue() { }; useTauriEvent(CAPTCHA_EVENTS[0], invalidate); - useTauriEvent(CAPTCHA_EVENTS[1], invalidate); - useTauriEvent(CAPTCHA_EVENTS[2], invalidate); - useTauriEvent(CAPTCHA_EVENTS[3], invalidate); + useTauriEvent(CAPTCHA_EVENTS[1], invalidate); + useTauriEvent(CAPTCHA_EVENTS[2], invalidate); + useTauriEvent(CAPTCHA_EVENTS[3], invalidate); return useQuery({ queryKey: captchaQueries.list(), diff --git a/src/hooks/useDownloadEvents.ts b/src/hooks/useDownloadEvents.ts index dc718dc9..06aef572 100644 --- a/src/hooks/useDownloadEvents.ts +++ b/src/hooks/useDownloadEvents.ts @@ -9,6 +9,9 @@ import type { DownloadWaitingStartedPayload, DownloadWaitingEndedPayload, CaptchaEventPayload, + CaptchaSkippedEventPayload, + CaptchaSolvedEventPayload, + CaptchaTimedOutEventPayload, } from "@/types/events"; export function useDownloadEvents(): void { @@ -31,9 +34,9 @@ export function useDownloadEvents(): void { useTauriEvent("download-removed", invalidateDownloads); useTauriEvent("download-extracting", invalidateDownloads); useTauriEvent("captcha-pending", invalidateDownloads); - useTauriEvent("captcha-solved", invalidateDownloads); - useTauriEvent("captcha-skipped", invalidateDownloads); - useTauriEvent("captcha-timed-out", invalidateDownloads); + useTauriEvent("captcha-solved", invalidateDownloads); + useTauriEvent("captcha-skipped", invalidateDownloads); + useTauriEvent("captcha-timed-out", invalidateDownloads); // The accompanying `download-waiting` / `download-resumed-from-wait` // events already trigger query invalidation; these two only sync the // per-row wait ticket into the store for the countdown UI. diff --git a/src/types/captcha.ts b/src/types/captcha.ts index 25f29bb2..fe06a7c2 100644 --- a/src/types/captcha.ts +++ b/src/types/captcha.ts @@ -6,7 +6,7 @@ export interface CaptchaChallengeView { downloadId: number; challengeType: CaptchaType; challengeUrl: string; - imageData: number[] | null; + imageData: string | null; imageMimeType: string | null; status: CaptchaStatus; solver: string | null; diff --git a/src/types/events.ts b/src/types/events.ts index a2869892..1f056489 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -38,6 +38,19 @@ export interface CaptchaEventPayload { downloadId: number; } +export interface CaptchaSolvedEventPayload extends CaptchaEventPayload { + solver: string; + durationMs: number; +} + +export interface CaptchaSkippedEventPayload extends CaptchaEventPayload { + reason: string; +} + +export interface CaptchaTimedOutEventPayload extends CaptchaEventPayload { + durationMs: number; +} + export interface SegmentPayload { downloadId: number; segmentId: number; @@ -114,9 +127,9 @@ export type TauriEventMap = { "download-extracting": DownloadIdPayload; "download-progress": DownloadProgressPayload; "captcha-pending": CaptchaEventPayload; - "captcha-solved": CaptchaEventPayload; - "captcha-skipped": CaptchaEventPayload; - "captcha-timed-out": CaptchaEventPayload; + "captcha-solved": CaptchaSolvedEventPayload; + "captcha-skipped": CaptchaSkippedEventPayload; + "captcha-timed-out": CaptchaTimedOutEventPayload; "segment-started": SegmentPayload; "segment-completed": SegmentPayload; "segment-failed": SegmentFailedPayload; diff --git a/src/views/CaptchaChallengePanel.tsx b/src/views/CaptchaChallengePanel.tsx index 295f6517..e43bc33b 100644 --- a/src/views/CaptchaChallengePanel.tsx +++ b/src/views/CaptchaChallengePanel.tsx @@ -18,7 +18,8 @@ export function CaptchaChallengePanel({ challenge }: { challenge: CaptchaChallen const acceptsText = challenge.challengeType === "image" || challenge.challengeType === "text_input"; const totalMs = Math.max(1, challenge.expiresAt - challenge.createdAt); - const progress = Math.min(100, (countdown.remainingSeconds * 1_000 * 100) / totalMs); + const remainingMs = Math.max(0, challenge.expiresAt - Date.now()); + const progress = Math.min(100, (remainingMs * 100) / totalMs); useEffect(() => setSolution(""), [challenge.id]); @@ -45,7 +46,12 @@ export function CaptchaChallengePanel({ challenge }: { challenge: CaptchaChallen {challenge.imageData && challenge.imageMimeType ? ( - + CAPTCHA ) : null} {acceptsText ? (
@@ -91,22 +97,3 @@ export function CaptchaChallengePanel({ challenge }: { challenge: CaptchaChallen ); } - -function CaptchaImage({ bytes, mimeType }: { bytes: number[]; mimeType: string }) { - const [source, setSource] = useState(null); - - useEffect(() => { - const url = URL.createObjectURL(new Blob([Uint8Array.from(bytes)], { type: mimeType })); - setSource(url); - return () => URL.revokeObjectURL(url); - }, [bytes, mimeType]); - - return source ? ( - CAPTCHA - ) : null; -} diff --git a/src/views/CaptchaSolverSettings.tsx b/src/views/CaptchaSolverSettings.tsx index 3293327b..9d910a03 100644 --- a/src/views/CaptchaSolverSettings.tsx +++ b/src/views/CaptchaSolverSettings.tsx @@ -12,13 +12,13 @@ export function CaptchaSolverSettings() { (state) => state.config?.captchaTimeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, ); const updateConfig = useSettingsStore((state) => state.updateConfig); - const [timeout, setTimeout] = useState(configured); + const [timeoutSeconds, setTimeoutSeconds] = useState(configured); - useEffect(() => setTimeout(configured), [configured]); + useEffect(() => setTimeoutSeconds(configured), [configured]); const persistTimeout = () => { - const normalized = Math.min(3_600, Math.max(10, timeout || DEFAULT_TIMEOUT_SECONDS)); - setTimeout(normalized); + const normalized = Math.min(3_600, Math.max(10, timeoutSeconds || DEFAULT_TIMEOUT_SECONDS)); + setTimeoutSeconds(normalized); void updateConfig({ captchaTimeoutSeconds: normalized }); }; @@ -42,9 +42,13 @@ export function CaptchaSolverSettings() { max={3_600} min={10} onBlur={persistTimeout} - onChange={(event) => setTimeout(Number(event.target.value))} + onChange={(event) => { + if (!Number.isNaN(event.currentTarget.valueAsNumber)) { + setTimeoutSeconds(event.currentTarget.valueAsNumber); + } + }} type="number" - value={timeout} + value={timeoutSeconds} />

{t("captcha.settings.fallback")}

diff --git a/src/views/CaptchaView.tsx b/src/views/CaptchaView.tsx index cc1e00ce..04fddf73 100644 --- a/src/views/CaptchaView.tsx +++ b/src/views/CaptchaView.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useCaptchaQueue, usePendingCaptcha } from "@/hooks/useCaptchaQueue"; +import { cn } from "@/lib/utils"; import { CaptchaChallengePanel } from "./CaptchaChallengePanel"; import { CaptchaSolverSettings } from "./CaptchaSolverSettings"; @@ -10,7 +11,9 @@ export function CaptchaView() { const { t } = useTranslation(); const [selectedId, setSelectedId] = useState(null); const { data = [], isLoading, error } = useCaptchaQueue(); - const pending = data.filter((challenge) => challenge.status === "pending"); + const pending = data + .filter((challenge) => challenge.status === "pending") + .sort((left, right) => left.createdAt - right.createdAt); const history = data.filter((challenge) => challenge.status !== "pending"); const selected = pending.find((challenge) => challenge.id === selectedId) ?? pending[0]; const { data: selectedDetail } = usePendingCaptcha(selected?.id); @@ -37,7 +40,11 @@ export function CaptchaView() { ) : ( pending.map((challenge) => (