use chrono::Utc; use reqwest::Client; use rusqlite::params; use std::time::{Duration, Instant}; use tracing::{error, info}; use uuid::Uuid; use super::{log_job_end, log_job_start}; use crate::db::Db; pub async fn run_link_checker( db: Db, interval_mins: u64, mut shutdown_rx: tokio::sync::watch::Receiver, ) { let client = Client::builder() .timeout(Duration::from_secs(10)) .user_agent("bzod-link-checker/0.1") .redirect(reqwest::redirect::Policy::limited(10)) .build() .unwrap_or_default(); loop { // Sleep first to give server time to start up tokio::select! { _ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {} _ = shutdown_rx.changed() => { info!("Link checker shutting down..."); break; } } info!("Running background link health check..."); let job_id = log_job_start(&db.system, "link_checker"); match perform_link_check(&db, &client).await { Ok(_) => log_job_end(&db.system, &job_id, "success", None), Err(e) => { let err_str = e.to_string(); error!("Error performing link health check: {}", err_str); log_job_end(&db.system, &job_id, "failed", Some(&err_str)); } } } } pub async fn perform_link_check( db: &Db, client: &Client, ) -> Result<(), Box> { let user_ids: Vec = { let conn = db.users.lock().unwrap(); let mut stmt = conn.prepare("SELECT id FROM users;")?; let rows = stmt.query_map([], |row| row.get(0))?; rows.filter_map(|r| r.ok()).collect() }; for user_id in user_ids { let conn = match super::open_user_content_conn(db, user_id) { Ok(c) => c, Err(_) => continue, }; let urls = crate::db::content::list_urls_for_health_check(&conn)?; for (id, dest) in urls { let (status, detail_status, status_code, latency_ms, err_msg) = check_url_health(client, &dest).await; { crate::db::content::update_url_health_extended( &conn, &id, &status, &detail_status, Some(latency_ms), )?; } // Log to system.db.health_checks { let sys_conn = db.system.lock().unwrap(); let hc_id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); let is_healthy = if status == "healthy" { 1 } else { 0 }; let _ = sys_conn.execute( "INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", params![hc_id, "url", id, now, status_code, err_msg, is_healthy], ); } // Rate limiting sleep between external requests tokio::time::sleep(Duration::from_millis(200)).await; } } Ok(()) } /// Check URL health with detailed classification and latency measurement. /// /// Returns: (general_status, detail_status, status_code, latency_ms, error_message) async fn check_url_health( client: &Client, url: &str, ) -> (String, String, Option, i64, Option) { let start = Instant::now(); let res = client.get(url).timeout(Duration::from_secs(5)).send().await; let latency_ms = start.elapsed().as_millis() as i64; match res { Ok(response) => { let status = response.status(); let code = status.as_u16(); if status.is_success() || status.is_redirection() { ( "healthy".to_string(), "healthy".to_string(), Some(code), latency_ms, None, ) } else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { ( "dead".to_string(), "dead".to_string(), Some(code), latency_ms, Some(format!("HTTP {}", code)), ) } else { ( "suspect".to_string(), format!("http_{}", code), Some(code), latency_ms, Some(format!("HTTP {}", code)), ) } } Err(err) => { let err_str = err.to_string(); let detail = if err.is_timeout() { "timeout".to_string() } else if err.is_connect() { if err_str.contains("dns") || err_str.contains("resolve") { "dns_failure".to_string() } else if err_str.contains("tls") || err_str.contains("ssl") || err_str.contains("certificate") { "tls_error".to_string() } else { "connection_refused".to_string() } } else if err.is_redirect() { "redirect_loop".to_string() } else { "unknown_error".to_string() }; let general = if err.is_timeout() || err.is_connect() { "suspect" } else { "dead" }; (general.to_string(), detail, None, latency_ms, Some(err_str)) } } }