//! POST /admin/crawler/run — trigger an out-of-cycle metadata pass //! POST /admin/crawler/browser/restart — coordinated restart //! POST /admin/crawler/session — refresh PHPSESSID //! POST /admin/crawler/session/clear-expired — clear sticky expired flag //! //! All four mutate live in-process state on `CrawlerControl` (browser //! manager, session controller, manual-pass mutex). They each emit an //! `admin_audit` row and `status.poke()` so SSE subscribers see the //! change instantly without polling. use axum::extract::State; use axum::routing::post; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; use crate::error::{AppError, AppResult}; use crate::repo; use super::require_crawler; pub(super) fn routes() -> Router { Router::new() .route("/admin/crawler/run", post(run_now)) .route("/admin/crawler/reconcile", post(reconcile_now)) .route("/admin/crawler/browser/restart", post(restart_browser)) .route("/admin/crawler/session", post(update_session)) .route( "/admin/crawler/session/clear-expired", post(clear_session_expired), ) } #[derive(Debug, Serialize)] struct RunResponse { started: bool, } async fn run_now( State(state): State, admin: RequireAdmin, ) -> AppResult> { let c = require_crawler(&state)?; let mp = c.metadata_pass.as_ref().ok_or_else(|| { AppError::ServiceUnavailable("no source configured (CRAWLER_START_URL unset)".into()) })?; // Operator-click dedup. A pass holds the lock for its entire run // (minutes); a second click while it's in flight returns 409 instead // of fanning a second pass onto the single browser lease. The daily // cron does NOT take this lock — cron is single-fire by definition, // and its own contention with a manual pass already serialises // through the browser lease + advisory lock at a lower layer. let pass_guard = c .manual_pass_lock .clone() .try_lock_owned() .map_err(|_| AppError::Conflict("manual metadata pass already running".into()))?; let mp = std::sync::Arc::clone(mp); // Fire-and-forget: the pass can run for minutes; the dashboard // streams progress over SSE. The guard moves into the task so the // lock is released only when the pass finishes (or the task panics). tokio::spawn(async move { let _pass_guard = pass_guard; if let Err(e) = mp.run().await { tracing::warn!(error = ?e, "manual metadata pass failed"); } }); repo::admin_audit::insert(&state.db, admin.0.id, "crawler_run", "crawler", None, json!({})) .await?; Ok(Json(RunResponse { started: true })) } async fn reconcile_now( State(state): State, admin: RequireAdmin, ) -> AppResult> { let c = require_crawler(&state)?; let rp = c.reconcile_pass.as_ref().ok_or_else(|| { AppError::ServiceUnavailable("no source configured (CRAWLER_START_URL unset)".into()) })?; // Share `manual_pass_lock` with the metadata pass: reconcile's list walk // and a metadata pass both contend for the single browser lease, so they // must not run concurrently. A click while either is in flight gets 409. let pass_guard = c .manual_pass_lock .clone() .try_lock_owned() .map_err(|_| AppError::Conflict("a metadata or reconcile pass is already running".into()))?; let rp = std::sync::Arc::clone(rp); // Fire-and-forget: the full list walk runs for minutes; progress streams // over SSE (the Reconciling phase). The guard moves into the task so the // lock releases only when the walk + enqueue finish. tokio::spawn(async move { let _pass_guard = pass_guard; match rp.run().await { Ok(stats) => tracing::info!(?stats, "manual reconcile pass complete"), Err(e) => tracing::warn!(error = ?e, "manual reconcile pass failed"), } }); repo::admin_audit::insert( &state.db, admin.0.id, "crawler_reconcile", "crawler", None, json!({}), ) .await?; Ok(Json(RunResponse { started: true })) } #[derive(Debug, Serialize)] struct RestartResponse { ok: bool, error: Option, } async fn restart_browser( State(state): State, admin: RequireAdmin, ) -> AppResult> { let c = require_crawler(&state)?; let result = c.browser_manager.coordinated_restart(c.drain_deadline).await; // A successful coordinated_restart re-runs on_launch, which re-injects // PHPSESSID and re-probes — i.e. the session is live. Drop the sticky // `session_expired` flag so chapter workers stop idling without // requiring a second click on "Clear expired". if result.is_ok() { c.session.clear_expired(); } // Push the post-restart browser phase to live subscribers immediately. c.status.poke(); repo::admin_audit::insert( &state.db, admin.0.id, "crawler_browser_restart", "crawler", None, json!({ "ok": result.is_ok() }), ) .await?; Ok(Json(match result { Ok(()) => RestartResponse { ok: true, error: None, }, Err(e) => RestartResponse { ok: false, error: Some(format!("{e:#}")), }, })) } #[derive(Debug, Deserialize)] struct UpdateSessionRequest { phpsessid: String, } #[derive(Debug, Serialize)] struct UpdateSessionResponse { /// Whether the post-update browser relaunch + session probe succeeded. valid: bool, error: Option, } async fn update_session( State(state): State, admin: RequireAdmin, Json(body): Json, ) -> AppResult> { let c = require_crawler(&state)?; // Fingerprint BEFORE move so the raw value never reaches tracing or // the audit row. SHA256-prefix is opaque to anyone reading the audit // log but deterministic enough to correlate two updates of the same // session value. let fingerprint = phpsessid_fingerprint(&body.phpsessid); c.session .update(&body.phpsessid) .await .map_err(|e| AppError::InvalidInput(format!("{e:#}")))?; // Relaunch the browser so on_launch re-injects the new cookie and // re-probes — the restart's success IS the session-validity signal. let probe = c.browser_manager.coordinated_restart(c.drain_deadline).await; // Session + browser state changed — push to live subscribers. c.status.poke(); repo::admin_audit::insert( &state.db, admin.0.id, "crawler_session_update", "crawler", None, json!({ "valid": probe.is_ok(), "phpsessid_fingerprint": fingerprint }), ) .await?; Ok(Json(match probe { Ok(()) => UpdateSessionResponse { valid: true, error: None, }, Err(e) => UpdateSessionResponse { valid: false, error: Some(format!("{e:#}")), }, })) } #[derive(Debug, Serialize)] struct ClearExpiredResponse { cleared: bool, } async fn clear_session_expired( State(state): State, admin: RequireAdmin, ) -> AppResult> { let c = require_crawler(&state)?; c.session.clear_expired(); // session.expired flipped — push to live subscribers. c.status.poke(); repo::admin_audit::insert( &state.db, admin.0.id, "crawler_session_clear_expired", "crawler", None, json!({}), ) .await?; Ok(Json(ClearExpiredResponse { cleared: true })) } /// Opaque, short fingerprint of a PHPSESSID for the admin audit log. /// The first 8 hex chars of SHA-256 — enough to correlate two updates /// of the same value without revealing the raw cookie. Reading the audit /// row does not give an operator anything that can re-construct the /// session. fn phpsessid_fingerprint(sid: &str) -> String { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(sid.as_bytes()); let digest = h.finalize(); let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); hex }