mirror of
https://github.com/P-Aimon-Pen/ROFMAD
synced 2026-06-21 13:45:02 +00:00
508 lines
20 KiB
Rust
Executable File
508 lines
20 KiB
Rust
Executable File
// src/web/handlers/hosts.rs - Host-related page handlers
|
|
|
|
use askama::Template;
|
|
use axum::{
|
|
extract::{Path, Query, State, Form},
|
|
response::{Html, IntoResponse},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use sqlx::Row;
|
|
use crate::sql::ProjectContext;
|
|
use super::utils::{WebResult, WebError, parse_host_summary, get_bool_from_row, xml_ports_to_port_info, calculate_host_progress, parse_action_outputs, find_host_screenshots, get_combined_tags_with_source, TagWithSource, HostSummary, PortInfo, ActionOutput, ScreenshotInfo, HostProgress};
|
|
|
|
|
|
// Add to the existing HostDetail struct in handlers.rs
|
|
#[derive(Serialize)]
|
|
pub struct HostDetail {
|
|
pub ip: String,
|
|
pub hostname: String,
|
|
pub short_hostname: String,
|
|
pub tags: Vec<TagWithSource>, // Combined manual + refined with source info
|
|
pub automated_tags: Vec<String>, // Raw automated tags for bottom section
|
|
pub ad_domain: String,
|
|
pub ports: Vec<PortInfo>,
|
|
pub progress: HostProgress,
|
|
|
|
// Raw nmap scan data - simplified to avoid filter issues
|
|
pub nmap_scan_raw: String,
|
|
|
|
// Action outputs split by whether they produced tags
|
|
pub action_findings: Vec<ActionOutput>,
|
|
pub action_other: Vec<ActionOutput>,
|
|
|
|
// HTTP services and screenshots
|
|
pub http_services: HashMap<String, crate::http::probe::HttpServiceInfo>,
|
|
pub screenshots: Vec<ScreenshotInfo>,
|
|
|
|
// Nuclei findings
|
|
pub nuclei_results: Vec<crate::sql::nuclei::NucleiPortResult>,
|
|
pub nuclei_findings_count: usize,
|
|
pub nuclei_scanned_ports: Vec<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct HostsListParams {
|
|
pub sort: Option<String>,
|
|
pub dir: Option<String>,
|
|
pub filter: Option<String>,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "hosts.html")]
|
|
pub struct HostsListTemplate {
|
|
pub hosts: Vec<HostSummary>,
|
|
pub sort: String,
|
|
pub dir: String,
|
|
pub filter: String,
|
|
// Precomputed filter URLs (preserve current sort)
|
|
pub url_filter_clear: String,
|
|
pub url_filter_interesting: String,
|
|
pub url_filter_not_interesting: String,
|
|
pub url_filter_analyzed_ready: String,
|
|
pub url_filter_analyzed_done: String,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "hosts_rows.html")]
|
|
pub struct HostsRowsTemplate {
|
|
pub hosts: Vec<HostSummary>,
|
|
}
|
|
|
|
fn make_filter_url(filter_val: &str, cur_sort: &str, cur_dir: &str) -> String {
|
|
if filter_val.is_empty() {
|
|
format!("/hosts?sort={}&dir={}", cur_sort, cur_dir)
|
|
} else {
|
|
format!("/hosts?sort={}&dir={}&filter={}", cur_sort, cur_dir, filter_val)
|
|
}
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "host_detail.html")]
|
|
pub struct HostDetailTemplate {
|
|
pub host: HostDetail,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct InterestingForm {
|
|
pub ip: String,
|
|
pub interesting: bool,
|
|
}
|
|
|
|
// Add new form struct for analyzed status
|
|
#[derive(Deserialize)]
|
|
pub struct AnalyzedForm {
|
|
pub ip: String,
|
|
pub analyzed: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct NucleiReviewedForm {
|
|
pub ip: String,
|
|
pub nuclei_reviewed: bool,
|
|
}
|
|
|
|
// Shared helper: whitelists params, builds SQL, returns sorted host list
|
|
async fn fetch_hosts_sorted(
|
|
ctx: &ProjectContext,
|
|
params: &HostsListParams,
|
|
) -> Result<(Vec<HostSummary>, &'static str, &'static str, &'static str), WebError> {
|
|
let sort: &'static str = match params.sort.as_deref() {
|
|
Some("hostname") => "hostname",
|
|
Some("ports") => "ports",
|
|
Some("http") => "http",
|
|
Some("analyzed") => "analyzed",
|
|
Some("interesting") => "interesting",
|
|
Some("nuclei_reviewed") => "nuclei_reviewed",
|
|
_ => "ip",
|
|
};
|
|
let dir: &'static str = match params.dir.as_deref() {
|
|
Some("desc") => "desc",
|
|
_ => "asc",
|
|
};
|
|
let filter: &'static str = match params.filter.as_deref() {
|
|
Some("interesting") => "interesting",
|
|
Some("not_interesting") => "not_interesting",
|
|
Some("analyzed_ready") => "analyzed_ready",
|
|
Some("analyzed_done") => "analyzed_done",
|
|
_ => "",
|
|
};
|
|
|
|
let filter_clause = match filter {
|
|
"interesting" => " AND interesting = 1",
|
|
"not_interesting" => " AND (interesting = 0 OR interesting IS NULL)",
|
|
"analyzed_ready" => " AND full_scan_complete = 1 AND service_scan_complete = 1 \
|
|
AND http_discovery_complete = 1 AND screenshots_complete = 1 \
|
|
AND COALESCE(user_workflow_complete, 0) = 1 \
|
|
AND (analyzed = 0 OR analyzed IS NULL)",
|
|
"analyzed_done" => " AND analyzed = 1",
|
|
_ => "",
|
|
};
|
|
|
|
// All values in order_clause are static strings — no user input reaches the query
|
|
let order_clause: &str = match (sort, dir) {
|
|
("hostname", "asc") => "CASE WHEN hostname IS NULL OR hostname = '' THEN 1 ELSE 0 END ASC, LOWER(COALESCE(hostname,'')) ASC",
|
|
("hostname", _) => "CASE WHEN hostname IS NULL OR hostname = '' THEN 1 ELSE 0 END ASC, LOWER(COALESCE(hostname,'')) DESC",
|
|
("ports", "asc") => "CASE WHEN ports IS NULL OR ports = '' THEN 0 ELSE LENGTH(ports) - LENGTH(REPLACE(ports, ',', '')) + 1 END ASC",
|
|
("ports", _) => "CASE WHEN ports IS NULL OR ports = '' THEN 0 ELSE LENGTH(ports) - LENGTH(REPLACE(ports, ',', '')) + 1 END DESC",
|
|
("http", "asc") => "(SELECT COUNT(*) FROM http_services WHERE run_id = targets.run_id AND ip = targets.ip) ASC",
|
|
("http", _) => "(SELECT COUNT(*) FROM http_services WHERE run_id = targets.run_id AND ip = targets.ip) DESC",
|
|
("analyzed", "asc") => "COALESCE(analyzed, 0) ASC, ip ASC",
|
|
("analyzed", _) => "COALESCE(analyzed, 0) DESC, ip ASC",
|
|
("interesting", "asc") => "COALESCE(interesting, 0) ASC, ip ASC",
|
|
("interesting", _) => "COALESCE(interesting, 0) DESC, ip ASC",
|
|
("nuclei_reviewed", "asc") => "COALESCE(nuclei_reviewed, 0) ASC, ip ASC",
|
|
("nuclei_reviewed", _) => "COALESCE(nuclei_reviewed, 0) DESC, ip ASC",
|
|
(_, "desc") => "ip DESC",
|
|
_ => "ip ASC",
|
|
};
|
|
|
|
let query_str = format!(
|
|
"SELECT ip, \
|
|
COALESCE(hostname,'') as hostname, \
|
|
COALESCE(ports,'') as ports, \
|
|
COALESCE(ad_domain,'') as ad_domain, \
|
|
COALESCE(full_scan_complete, 0) as full_scan_complete, \
|
|
COALESCE(service_scan_complete, 0) as service_scan_complete, \
|
|
COALESCE(http_discovery_complete, 0) as http_discovery_complete, \
|
|
COALESCE(screenshots_complete, 0) as screenshots_complete, \
|
|
(SELECT COUNT(*) FROM action_outputs WHERE run_id = targets.run_id AND ip = targets.ip) as action_count, \
|
|
COALESCE(analyzed, 0) as analyzed, \
|
|
COALESCE(interesting, 0) as interesting, \
|
|
COALESCE(nuclei_reviewed, 0) as nuclei_reviewed, \
|
|
CASE WHEN EXISTS (SELECT 1 FROM action_outputs WHERE run_id = targets.run_id AND ip = targets.ip AND action_name LIKE 'nuclei_%') THEN 1 ELSE 0 END as has_nuclei_findings \
|
|
FROM targets WHERE run_id = ?{} ORDER BY {}",
|
|
filter_clause, order_clause
|
|
);
|
|
|
|
let rows = sqlx::query(&query_str)
|
|
.bind(&ctx.run_id)
|
|
.fetch_all(&ctx.pool)
|
|
.await
|
|
.map_err(|e| WebError::DatabaseError(e.to_string()))?;
|
|
|
|
let mut hosts = Vec::new();
|
|
for row in rows {
|
|
hosts.push(parse_host_summary(row, ctx).await);
|
|
}
|
|
|
|
Ok((hosts, sort, dir, filter))
|
|
}
|
|
|
|
// Context: Provides list view of all discovered hosts with server-side sort and filter
|
|
// Operation: Builds SQL from whitelisted query params, renders hosts listing template
|
|
pub async fn hosts_list(
|
|
Query(params): Query<HostsListParams>,
|
|
State(ctx): State<ProjectContext>,
|
|
) -> WebResult<impl IntoResponse> {
|
|
let (hosts, sort, dir, filter) = fetch_hosts_sorted(&ctx, ¶ms).await?;
|
|
|
|
let template = HostsListTemplate {
|
|
hosts,
|
|
sort: sort.to_string(),
|
|
dir: dir.to_string(),
|
|
filter: filter.to_string(),
|
|
url_filter_clear: make_filter_url("", sort, dir),
|
|
url_filter_interesting: make_filter_url("interesting", sort, dir),
|
|
url_filter_not_interesting: make_filter_url("not_interesting", sort, dir),
|
|
url_filter_analyzed_ready: make_filter_url("analyzed_ready", sort, dir),
|
|
url_filter_analyzed_done: make_filter_url("analyzed_done", sort, dir),
|
|
};
|
|
Ok(Html(template.render().map_err(|e| WebError::ParseError(e.to_string()))?))
|
|
}
|
|
|
|
// Context: Returns only the <tbody> row HTML for AJAX table refresh (no full page reload)
|
|
// Operation: Runs same SQL as hosts_list, renders fragment template for client-side tbody replacement
|
|
pub async fn hosts_rows(
|
|
Query(params): Query<HostsListParams>,
|
|
State(ctx): State<ProjectContext>,
|
|
) -> WebResult<impl IntoResponse> {
|
|
let (hosts, _sort, _dir, _filter) = fetch_hosts_sorted(&ctx, ¶ms).await?;
|
|
let template = HostsRowsTemplate { hosts };
|
|
Ok(Html(template.render().map_err(|e| WebError::ParseError(e.to_string()))?))
|
|
}
|
|
|
|
// Context: Builds port list for host detail using XML as sole structured source
|
|
// Operation: Parses nmap_xml JSON map, extracts service_scan XML, converts to PortInfo.
|
|
fn parse_ports_from_xml(nmap_xml: &str) -> Vec<PortInfo> {
|
|
if !nmap_xml.is_empty() && nmap_xml != "{}" {
|
|
if let Ok(map) = serde_json::from_str::<HashMap<String, String>>(nmap_xml) {
|
|
if let Some(xml) = map.get("service_scan") {
|
|
if let Some(result) = crate::tag::parse_nmap_xml(xml) {
|
|
let mut ports = xml_ports_to_port_info(result.ports);
|
|
ports.sort_by_key(|p| p.port.parse::<u32>().unwrap_or(0));
|
|
return ports;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
// Context: Displays comprehensive information for a specific host including scan results
|
|
// Operation: Aggregates host data, ports, services, screenshots and renders detail template
|
|
pub async fn host_detail(Path(ip): Path<String>, State(ctx): State<ProjectContext>) -> WebResult<impl IntoResponse> {
|
|
let row = sqlx::query("SELECT * FROM targets WHERE run_id = ? AND ip = ?")
|
|
.bind(&ctx.run_id)
|
|
.bind(&ip)
|
|
.fetch_optional(&ctx.pool)
|
|
.await
|
|
.map_err(|e| WebError::DatabaseError(e.to_string()))?
|
|
.ok_or(WebError::NotFound(format!("Host {} not found", ip)))?;
|
|
|
|
// Extract basic data
|
|
let hostname: Option<String> = row.get("hostname");
|
|
|
|
// Get combined tags with source info
|
|
let tags = get_combined_tags_with_source(&ctx, &ip).await.unwrap_or_default();
|
|
|
|
// Get automated tags separately
|
|
let automated_tags_set = ctx.get_automated_tags(&ip).await.unwrap_or_default();
|
|
let mut automated_tags: Vec<String> = automated_tags_set.into_iter().collect();
|
|
automated_tags.sort();
|
|
|
|
let ad_domain: Option<String> = row.get("ad_domain");
|
|
|
|
// Raw nmap text — kept solely for the human-readable display block
|
|
let nmap_scan_str: String = row.try_get("nmap_scan").unwrap_or_else(|_| "{}".to_string());
|
|
let nmap_display_text = if let Ok(nmap_json) = serde_json::from_str::<HashMap<String, String>>(&nmap_scan_str) {
|
|
if let Some(service_scan) = nmap_json.get("service_scan") {
|
|
service_scan.clone()
|
|
} else {
|
|
let mut parts: Vec<_> = nmap_json.into_values().collect();
|
|
parts.sort();
|
|
parts.join("\n\n")
|
|
}
|
|
} else {
|
|
nmap_scan_str.clone()
|
|
};
|
|
|
|
// XML is the sole structured source for port/service data
|
|
let nmap_xml_str: String = row.try_get("nmap_xml").unwrap_or_else(|_| "{}".to_string());
|
|
let ports = parse_ports_from_xml(&nmap_xml_str);
|
|
|
|
// Fetch action outputs from normalized table
|
|
let all_action_outputs = ctx.get_action_outputs_for_host(&ip).await
|
|
.map(parse_action_outputs)
|
|
.unwrap_or_default();
|
|
let action_count = all_action_outputs.len();
|
|
let (action_findings, action_other): (Vec<_>, Vec<_>) = all_action_outputs.into_iter().partition(|a| a.tagged);
|
|
|
|
// Calculate progress with corrected logic
|
|
let progress = calculate_host_progress(
|
|
get_bool_from_row(&row, "full_scan_complete"),
|
|
get_bool_from_row(&row, "service_scan_complete"),
|
|
get_bool_from_row(&row, "http_discovery_complete"),
|
|
get_bool_from_row(&row, "screenshots_complete"),
|
|
action_count,
|
|
);
|
|
|
|
// Use the dedicated table
|
|
let http_services_raw = ctx.get_http_services(&ip).await.unwrap_or_default();
|
|
let mut http_services = HashMap::new();
|
|
for (port, service_json) in http_services_raw {
|
|
if let Ok(service_info) = serde_json::from_str::<crate::http::probe::HttpServiceInfo>(&service_json) {
|
|
http_services.insert(port, service_info);
|
|
}
|
|
}
|
|
|
|
// Find screenshots
|
|
let screenshots_dir = crate::config::paths::get_screenshots_directory(&ctx.db_path, &ctx.run_id);
|
|
let screenshots = find_host_screenshots(&ip, &http_services, &screenshots_dir);
|
|
|
|
// Nuclei findings
|
|
let mut nuclei_results = ctx.get_nuclei_results_for_host(&ip).await.unwrap_or_default();
|
|
for result in &mut nuclei_results {
|
|
if let Some(svc) = http_services.get(&result.port) {
|
|
result.url = format!("{}://{}:{}", svc.protocol, ip, result.port);
|
|
}
|
|
}
|
|
let nuclei_findings_count: usize = nuclei_results.iter().map(|r| r.findings.len()).sum();
|
|
let nuclei_scanned_ports: Vec<String> = nuclei_results.iter().map(|r| r.port.clone()).collect();
|
|
|
|
let hostname_str = hostname.unwrap_or_default();
|
|
let ad_domain_str = ad_domain.unwrap_or_default();
|
|
let short_hostname = crate::web::handlers::utils::strip_domain_suffix(&hostname_str, &ad_domain_str);
|
|
|
|
let host_detail = HostDetail {
|
|
ip: ip.clone(),
|
|
hostname: hostname_str,
|
|
short_hostname,
|
|
tags,
|
|
automated_tags,
|
|
ad_domain: ad_domain_str,
|
|
ports,
|
|
progress,
|
|
nmap_scan_raw: nmap_display_text,
|
|
action_findings,
|
|
action_other,
|
|
http_services,
|
|
screenshots,
|
|
nuclei_results,
|
|
nuclei_findings_count,
|
|
nuclei_scanned_ports,
|
|
};
|
|
|
|
let template = HostDetailTemplate { host: host_detail };
|
|
Ok(Html(template.render().map_err(|e| WebError::ParseError(e.to_string()))?))
|
|
}
|
|
|
|
// Handler for marking hosts as analyzed
|
|
// Context: Allows marking hosts as analyzed for workflow progress tracking
|
|
// Operation: Updates host analysis status with retry logic for database consistency
|
|
pub async fn mark_host_analyzed(
|
|
State(ctx): State<ProjectContext>,
|
|
Form(form): Form<AnalyzedForm>
|
|
) -> WebResult<impl IntoResponse> {
|
|
// Add timeout and retry logic
|
|
let mut attempts = 0;
|
|
let max_attempts = 3;
|
|
|
|
while attempts < max_attempts {
|
|
match ctx.mark_host_analyzed(&form.ip, form.analyzed).await {
|
|
Ok(_) => {
|
|
return Ok(axum::Json(serde_json::json!({
|
|
"success": true,
|
|
"ip": form.ip,
|
|
"analyzed": form.analyzed,
|
|
"attempts": attempts + 1
|
|
})));
|
|
}
|
|
Err(e) if attempts < max_attempts - 1 => {
|
|
attempts += 1;
|
|
eprintln!("Attempt {} failed for host {}: {}", attempts, form.ip, e);
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Final attempt failed for host {}: {}", form.ip, e);
|
|
return Err(WebError::DatabaseError(format!("Failed to update analysis status after {} attempts: {}", max_attempts, e)));
|
|
}
|
|
}
|
|
}
|
|
|
|
unreachable!()
|
|
}
|
|
|
|
// Context: Provides current analysis status for host progress tracking
|
|
// Operation: Queries database and returns JSON with host analysis boolean status
|
|
pub async fn get_host_analysis_status(
|
|
Path(ip): Path<String>,
|
|
State(ctx): State<ProjectContext>
|
|
) -> WebResult<impl IntoResponse> {
|
|
let analyzed = ctx.is_host_analyzed(&ip).await
|
|
.map_err(|e| WebError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(axum::Json(serde_json::json!({
|
|
"ip": ip,
|
|
"analyzed": analyzed
|
|
})))
|
|
}
|
|
|
|
// Context: Enables marking hosts as interesting for prioritized analysis tracking
|
|
// Operation: Updates host interesting flag in database and returns confirmation
|
|
pub async fn mark_host_interesting(
|
|
State(ctx): State<ProjectContext>,
|
|
Form(form): Form<InterestingForm>
|
|
) -> WebResult<impl IntoResponse> {
|
|
ctx.mark_host_interesting(&form.ip, form.interesting).await
|
|
.map_err(|e| WebError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(axum::Json(serde_json::json!({
|
|
"success": true,
|
|
"ip": form.ip,
|
|
"interesting": form.interesting
|
|
})))
|
|
}
|
|
|
|
// Context: Retrieves current interesting status for host prioritization display
|
|
// Operation: Queries database and returns JSON with host interesting boolean flag
|
|
pub async fn get_host_interesting_status(
|
|
Path(ip): Path<String>,
|
|
State(ctx): State<ProjectContext>
|
|
) -> WebResult<impl IntoResponse> {
|
|
let interesting = ctx.is_host_interesting(&ip).await
|
|
.map_err(|e| WebError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(axum::Json(serde_json::json!({
|
|
"ip": ip,
|
|
"interesting": interesting
|
|
})))
|
|
}
|
|
|
|
pub async fn mark_host_nuclei_reviewed(
|
|
State(ctx): State<ProjectContext>,
|
|
Form(form): Form<NucleiReviewedForm>,
|
|
) -> WebResult<impl IntoResponse> {
|
|
ctx.mark_host_nuclei_reviewed(&form.ip, form.nuclei_reviewed).await
|
|
.map_err(|e| WebError::DatabaseError(e.to_string()))?;
|
|
Ok(axum::Json(serde_json::json!({
|
|
"success": true, "ip": form.ip, "nuclei_reviewed": form.nuclei_reviewed
|
|
})))
|
|
}
|
|
|
|
pub async fn get_host_nuclei_reviewed_status(
|
|
Path(ip): Path<String>,
|
|
State(ctx): State<ProjectContext>,
|
|
) -> WebResult<impl IntoResponse> {
|
|
let reviewed = ctx.is_host_nuclei_reviewed(&ip).await
|
|
.map_err(|e| WebError::DatabaseError(e.to_string()))?;
|
|
Ok(axum::Json(serde_json::json!({ "ip": ip, "nuclei_reviewed": reviewed })))
|
|
}
|
|
|
|
// Context: Serves screenshot images with security validation for web interface display
|
|
// Operation: Validates filename format and serves PNG files from project-specific screenshots directory
|
|
pub async fn screenshot(Path(filename): Path<String>, State(ctx): State<ProjectContext>) -> WebResult<impl IntoResponse> {
|
|
// Enhanced filename validation for security
|
|
if filename.contains("..") || filename.contains("/") || filename.contains("\\") {
|
|
return Err(WebError::InvalidData("Invalid filename".to_string()));
|
|
}
|
|
|
|
// Must be PNG with expected format
|
|
if !filename.ends_with(".png") {
|
|
return Err(WebError::InvalidData("Invalid file type".to_string()));
|
|
}
|
|
|
|
// Validate filename format: IP_PORT_PROTOCOL_TIMESTAMP.png
|
|
let name_without_ext = filename.strip_suffix(".png").unwrap();
|
|
let parts: Vec<&str> = name_without_ext.split('_').collect();
|
|
|
|
if parts.len() < 7 {
|
|
return Err(WebError::InvalidData("Invalid filename format".to_string()));
|
|
}
|
|
|
|
// Validate IP format (4 numeric parts)
|
|
for i in 0..4 {
|
|
if parts[i].parse::<u8>().is_err() {
|
|
return Err(WebError::InvalidData("Invalid IP format in filename".to_string()));
|
|
}
|
|
}
|
|
|
|
// Validate port
|
|
if parts[4].parse::<u16>().is_err() {
|
|
return Err(WebError::InvalidData("Invalid port in filename".to_string()));
|
|
}
|
|
|
|
// Validate protocol
|
|
if !["http", "https"].contains(&parts[5]) {
|
|
return Err(WebError::InvalidData("Invalid protocol in filename".to_string()));
|
|
}
|
|
|
|
// Validate access within current project and get file path
|
|
if !crate::config::paths::validate_screenshot_access(&ctx.db_path, &ctx.run_id, &filename) {
|
|
return Err(WebError::NotFound("Screenshot not found in current project".to_string()));
|
|
}
|
|
|
|
let file_path = crate::config::paths::get_screenshot_file_path(&ctx.db_path, &ctx.run_id, &filename);
|
|
|
|
if std::path::Path::new(&file_path).exists() {
|
|
let contents = tokio::fs::read(&file_path)
|
|
.await
|
|
.map_err(|e| WebError::NotFound(format!("File read error: {}", e)))?;
|
|
|
|
let content_type = "image/png";
|
|
|
|
Ok(([(axum::http::header::CONTENT_TYPE, content_type)], contents))
|
|
} else {
|
|
Err(WebError::NotFound("Screenshot not found".to_string()))
|
|
}
|
|
} |