// =================================================================== // Repeater — Burp-style manual HTTP request sender. // Full control: method, URL, headers, body. Returns full response. // =================================================================== use std::collections::HashMap; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize)] pub struct RepeaterRequest { pub method: String, pub url: String, #[serde(default)] pub headers: HashMap, #[serde(default)] pub body: Option, #[serde(default = "default_timeout")] pub timeout_ms: u64, #[serde(default)] pub follow_redirects: bool, #[serde(default)] pub ignore_tls: bool, } fn default_timeout() -> u64 { 15_000 } #[derive(Debug, Clone, Serialize)] pub struct RepeaterResponse { pub status: u16, pub status_text: String, pub http_version: String, pub headers: Vec<(String, String)>, pub body: String, pub body_len: usize, pub content_type: Option, pub time_ms: u128, pub final_url: String, pub redirected: bool, pub is_text: bool, } #[tauri::command] pub async fn repeater_send(req: RepeaterRequest) -> Result { let client = reqwest::Client::builder() .danger_accept_invalid_certs(req.ignore_tls) .timeout(Duration::from_millis(req.timeout_ms)) .redirect(if req.follow_redirects { reqwest::redirect::Policy::limited(10) } else { reqwest::redirect::Policy::none() }) .user_agent("Mozilla/5.0 (PocketPentester-Repeater)") .build() .map_err(|e| e.to_string())?; let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes()) .map_err(|e| format!("bad method: {e}"))?; let mut builder = client.request(method, &req.url); for (k, v) in &req.headers { if k.trim().is_empty() { continue; } builder = builder.header(k.trim(), v); } if let Some(body) = &req.body { if !body.is_empty() { builder = builder.body(body.clone()); } } let start = Instant::now(); let resp = builder.send().await.map_err(|e| e.to_string())?; let status = resp.status(); let version = format!("{:?}", resp.version()); let final_url = resp.url().to_string(); let redirected = final_url != req.url; let headers: Vec<(String, String)> = resp.headers().iter() .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); let content_type = resp.headers().get("content-type") .and_then(|v| v.to_str().ok()).map(String::from); let is_text = content_type.as_deref().map(|c| { c.starts_with("text/") || c.contains("json") || c.contains("xml") || c.contains("javascript") || c.contains("html") || c.contains("form-urlencoded") }).unwrap_or(true); let body = resp.text().await.unwrap_or_default(); let elapsed = start.elapsed().as_millis(); Ok(RepeaterResponse { status: status.as_u16(), status_text: status.canonical_reason().unwrap_or("").into(), http_version: version, headers, body_len: body.len(), body, content_type, time_ms: elapsed, final_url, redirected, is_text, }) } #[tauri::command] pub fn repeater_to_curl(req: RepeaterRequest) -> String { let mut parts: Vec = vec!["curl".into(), "-i".into()]; if req.ignore_tls { parts.push("-k".into()); } if req.follow_redirects { parts.push("-L".into()); } if req.method.to_uppercase() != "GET" { parts.push("-X".into()); parts.push(req.method.to_uppercase()); } for (k, v) in &req.headers { if k.trim().is_empty() { continue; } parts.push("-H".into()); parts.push(format!("'{}: {}'", k.trim(), v.replace('\'', "'\\''"))); } if let Some(body) = &req.body { if !body.is_empty() { parts.push("--data-raw".into()); parts.push(format!("'{}'", body.replace('\'', "'\\''"))); } } parts.push(format!("'{}'", req.url)); parts.join(" ") }