Add desktop tools, antivirus, and editor modules
- Add desktop tools module with drive cleaner, Windows optimizer, and Brave browser installer - Add antivirus module with ClamAV integration and Windows Defender management - Add tools.html template for settings page integration - Add standalone editor.html for file editing - Re-export new types from desktop and security
This commit is contained in:
parent
a21292daa3
commit
46d6ff6268
6 changed files with 3412 additions and 0 deletions
|
|
@ -1,6 +1,22 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
//! Desktop Module
|
||||
//!
|
||||
//! This module provides desktop-specific functionality including:
|
||||
//! - Drive synchronization with cloud storage
|
||||
//! - System tray management
|
||||
//! - Local file operations
|
||||
//! - Desktop tools (cleaner, optimizer, etc.)
|
||||
|
||||
pub mod drive;
|
||||
pub mod sync;
|
||||
pub mod tools;
|
||||
pub mod tray;
|
||||
|
||||
// Re-exports
|
||||
pub use drive::*;
|
||||
pub use sync::*;
|
||||
pub use tools::{
|
||||
CleanupCategory, CleanupStats, DesktopToolsConfig, DesktopToolsManager, DiskInfo,
|
||||
InstallationStatus, OptimizationStatus, OptimizationTask, TaskStatus,
|
||||
};
|
||||
pub use tray::{RunningMode, ServiceMonitor, TrayManager};
|
||||
|
|
|
|||
1002
src/desktop/tools.rs
Normal file
1002
src/desktop/tools.rs
Normal file
File diff suppressed because it is too large
Load diff
849
src/security/antivirus.rs
Normal file
849
src/security/antivirus.rs
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
//! Antivirus Module
|
||||
//!
|
||||
//! This module provides antivirus and security scanning functionality including:
|
||||
//! - Integration with ClamAV (open source antivirus)
|
||||
//! - Windows Defender management (enable/disable)
|
||||
//! - Threat detection and reporting
|
||||
//! - Vulnerability scanning
|
||||
//! - Real-time protection status
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Threat severity levels
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThreatSeverity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Threat status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThreatStatus {
|
||||
Detected,
|
||||
Quarantined,
|
||||
Removed,
|
||||
Allowed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Detected threat information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Threat {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub threat_type: String,
|
||||
pub severity: ThreatSeverity,
|
||||
pub status: ThreatStatus,
|
||||
pub file_path: Option<String>,
|
||||
pub detected_at: chrono::DateTime<chrono::Utc>,
|
||||
pub description: Option<String>,
|
||||
pub action_taken: Option<String>,
|
||||
}
|
||||
|
||||
/// Vulnerability information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Vulnerability {
|
||||
pub id: String,
|
||||
pub cve_id: Option<String>,
|
||||
pub name: String,
|
||||
pub severity: ThreatSeverity,
|
||||
pub affected_component: String,
|
||||
pub description: String,
|
||||
pub remediation: Option<String>,
|
||||
pub detected_at: chrono::DateTime<chrono::Utc>,
|
||||
pub is_patched: bool,
|
||||
}
|
||||
|
||||
/// Scan result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanResult {
|
||||
pub scan_id: String,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub status: ScanStatus,
|
||||
pub files_scanned: u64,
|
||||
pub threats_found: Vec<Threat>,
|
||||
pub scan_type: ScanType,
|
||||
pub target_path: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Scan status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ScanStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Scan type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScanType {
|
||||
Quick,
|
||||
Full,
|
||||
Custom,
|
||||
Memory,
|
||||
Rootkit,
|
||||
}
|
||||
|
||||
/// Protection status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProtectionStatus {
|
||||
pub real_time_protection: bool,
|
||||
pub windows_defender_enabled: bool,
|
||||
pub general_bots_protection: bool,
|
||||
pub last_scan: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub last_definition_update: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub threats_blocked_today: u32,
|
||||
pub quarantined_items: u32,
|
||||
}
|
||||
|
||||
/// Antivirus configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AntivirusConfig {
|
||||
/// Path to ClamAV installation
|
||||
pub clamav_path: Option<PathBuf>,
|
||||
/// Enable real-time protection
|
||||
pub real_time_protection: bool,
|
||||
/// Quarantine directory
|
||||
pub quarantine_dir: PathBuf,
|
||||
/// Log directory
|
||||
pub log_dir: PathBuf,
|
||||
/// Auto-quarantine threats
|
||||
pub auto_quarantine: bool,
|
||||
/// Excluded paths from scanning
|
||||
pub excluded_paths: Vec<PathBuf>,
|
||||
/// Excluded file extensions
|
||||
pub excluded_extensions: Vec<String>,
|
||||
/// Maximum file size to scan (in MB)
|
||||
pub max_file_size_mb: u64,
|
||||
/// Scan archives
|
||||
pub scan_archives: bool,
|
||||
/// Definition update URL
|
||||
pub definition_update_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AntivirusConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clamav_path: None,
|
||||
real_time_protection: true,
|
||||
quarantine_dir: PathBuf::from("./data/quarantine"),
|
||||
log_dir: PathBuf::from("./logs/antivirus"),
|
||||
auto_quarantine: true,
|
||||
excluded_paths: vec![],
|
||||
excluded_extensions: vec![],
|
||||
max_file_size_mb: 100,
|
||||
scan_archives: true,
|
||||
definition_update_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Antivirus Manager
|
||||
pub struct AntivirusManager {
|
||||
config: AntivirusConfig,
|
||||
threats: Arc<RwLock<Vec<Threat>>>,
|
||||
vulnerabilities: Arc<RwLock<Vec<Vulnerability>>>,
|
||||
active_scans: Arc<RwLock<HashMap<String, ScanResult>>>,
|
||||
protection_status: Arc<RwLock<ProtectionStatus>>,
|
||||
}
|
||||
|
||||
impl AntivirusManager {
|
||||
/// Create a new antivirus manager
|
||||
pub fn new(config: AntivirusConfig) -> Result<Self> {
|
||||
// Ensure directories exist
|
||||
std::fs::create_dir_all(&config.quarantine_dir)
|
||||
.context("Failed to create quarantine directory")?;
|
||||
std::fs::create_dir_all(&config.log_dir).context("Failed to create log directory")?;
|
||||
|
||||
let protection_status = ProtectionStatus {
|
||||
real_time_protection: config.real_time_protection,
|
||||
windows_defender_enabled: Self::check_windows_defender_status(),
|
||||
general_bots_protection: true,
|
||||
last_scan: None,
|
||||
last_definition_update: None,
|
||||
threats_blocked_today: 0,
|
||||
quarantined_items: 0,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
threats: Arc::new(RwLock::new(Vec::new())),
|
||||
vulnerabilities: Arc::new(RwLock::new(Vec::new())),
|
||||
active_scans: Arc::new(RwLock::new(HashMap::new())),
|
||||
protection_status: Arc::new(RwLock::new(protection_status)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if Windows Defender is enabled
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_windows_defender_status() -> bool {
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
"Get-MpPreference | Select-Object -ExpandProperty DisableRealtimeMonitoring",
|
||||
])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
!result.trim().eq_ignore_ascii_case("true")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_windows_defender_status() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Disable Windows Defender (requires admin privileges)
|
||||
#[cfg(target_os = "windows")]
|
||||
pub async fn disable_windows_defender(&self) -> Result<bool> {
|
||||
info!("Attempting to disable Windows Defender...");
|
||||
|
||||
// This requires administrator privileges
|
||||
let script = r#"
|
||||
Set-MpPreference -DisableRealtimeMonitoring $true
|
||||
Set-MpPreference -DisableBehaviorMonitoring $true
|
||||
Set-MpPreference -DisableBlockAtFirstSeen $true
|
||||
Set-MpPreference -DisableIOAVProtection $true
|
||||
Set-MpPreference -DisablePrivacyMode $true
|
||||
Set-MpPreference -SignatureDisableUpdateOnStartupWithoutEngine $true
|
||||
Set-MpPreference -DisableArchiveScanning $true
|
||||
Set-MpPreference -DisableIntrusionPreventionSystem $true
|
||||
Set-MpPreference -DisableScriptScanning $true
|
||||
"#;
|
||||
|
||||
let output = Command::new("powershell")
|
||||
.args(["-Command", script])
|
||||
.output()
|
||||
.context("Failed to execute PowerShell command")?;
|
||||
|
||||
if output.status.success() {
|
||||
let mut status = self.protection_status.write().await;
|
||||
status.windows_defender_enabled = false;
|
||||
info!("Windows Defender disabled successfully");
|
||||
Ok(true)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
error!("Failed to disable Windows Defender: {}", error);
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to disable Windows Defender: {}",
|
||||
error
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub async fn disable_windows_defender(&self) -> Result<bool> {
|
||||
warn!("Windows Defender management is only available on Windows");
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Enable Windows Defender (requires admin privileges)
|
||||
#[cfg(target_os = "windows")]
|
||||
pub async fn enable_windows_defender(&self) -> Result<bool> {
|
||||
info!("Attempting to enable Windows Defender...");
|
||||
|
||||
let script = r#"
|
||||
Set-MpPreference -DisableRealtimeMonitoring $false
|
||||
Set-MpPreference -DisableBehaviorMonitoring $false
|
||||
Set-MpPreference -DisableBlockAtFirstSeen $false
|
||||
Set-MpPreference -DisableIOAVProtection $false
|
||||
Set-MpPreference -DisableArchiveScanning $false
|
||||
Set-MpPreference -DisableIntrusionPreventionSystem $false
|
||||
Set-MpPreference -DisableScriptScanning $false
|
||||
"#;
|
||||
|
||||
let output = Command::new("powershell")
|
||||
.args(["-Command", script])
|
||||
.output()
|
||||
.context("Failed to execute PowerShell command")?;
|
||||
|
||||
if output.status.success() {
|
||||
let mut status = self.protection_status.write().await;
|
||||
status.windows_defender_enabled = true;
|
||||
info!("Windows Defender enabled successfully");
|
||||
Ok(true)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
error!("Failed to enable Windows Defender: {}", error);
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to enable Windows Defender: {}",
|
||||
error
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub async fn enable_windows_defender(&self) -> Result<bool> {
|
||||
warn!("Windows Defender management is only available on Windows");
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Start a scan
|
||||
pub async fn start_scan(
|
||||
&self,
|
||||
scan_type: ScanType,
|
||||
target_path: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let scan_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let scan_result = ScanResult {
|
||||
scan_id: scan_id.clone(),
|
||||
started_at: now,
|
||||
completed_at: None,
|
||||
status: ScanStatus::Pending,
|
||||
files_scanned: 0,
|
||||
threats_found: vec![],
|
||||
scan_type,
|
||||
target_path: target_path.map(|s| s.to_string()),
|
||||
error_message: None,
|
||||
};
|
||||
|
||||
{
|
||||
let mut scans = self.active_scans.write().await;
|
||||
scans.insert(scan_id.clone(), scan_result);
|
||||
}
|
||||
|
||||
// Spawn scan task
|
||||
let scan_id_clone = scan_id.clone();
|
||||
let scans = self.active_scans.clone();
|
||||
let threats = self.threats.clone();
|
||||
let config = self.config.clone();
|
||||
let target = target_path.map(|s| s.to_string());
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::run_scan(scan_id_clone, scan_type, target, scans, threats, config).await;
|
||||
});
|
||||
|
||||
info!("Started {:?} scan with ID: {}", scan_type, scan_id);
|
||||
Ok(scan_id)
|
||||
}
|
||||
|
||||
/// Run the actual scan
|
||||
async fn run_scan(
|
||||
scan_id: String,
|
||||
scan_type: ScanType,
|
||||
target_path: Option<String>,
|
||||
scans: Arc<RwLock<HashMap<String, ScanResult>>>,
|
||||
threats: Arc<RwLock<Vec<Threat>>>,
|
||||
config: AntivirusConfig,
|
||||
) {
|
||||
// Update status to running
|
||||
{
|
||||
let mut scans_guard = scans.write().await;
|
||||
if let Some(scan) = scans_guard.get_mut(&scan_id) {
|
||||
scan.status = ScanStatus::Running;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine scan target
|
||||
let scan_path = match scan_type {
|
||||
ScanType::Quick => target_path.unwrap_or_else(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
"C:\\Users".to_string()
|
||||
} else {
|
||||
"/home".to_string()
|
||||
}
|
||||
}),
|
||||
ScanType::Full => target_path.unwrap_or_else(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
"C:\\".to_string()
|
||||
} else {
|
||||
"/".to_string()
|
||||
}
|
||||
}),
|
||||
ScanType::Custom => target_path.unwrap_or_else(|| ".".to_string()),
|
||||
ScanType::Memory => "memory".to_string(),
|
||||
ScanType::Rootkit => "/".to_string(),
|
||||
};
|
||||
|
||||
// Try ClamAV scan
|
||||
let result = Self::run_clamav_scan(&scan_path, &config).await;
|
||||
|
||||
// Update scan results
|
||||
let mut scans_guard = scans.write().await;
|
||||
if let Some(scan) = scans_guard.get_mut(&scan_id) {
|
||||
scan.completed_at = Some(chrono::Utc::now());
|
||||
|
||||
match result {
|
||||
Ok((files_scanned, found_threats)) => {
|
||||
scan.status = ScanStatus::Completed;
|
||||
scan.files_scanned = files_scanned;
|
||||
scan.threats_found = found_threats.clone();
|
||||
|
||||
// Add threats to global list
|
||||
if !found_threats.is_empty() {
|
||||
let mut threats_guard = threats.blocking_write();
|
||||
threats_guard.extend(found_threats);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
scan.status = ScanStatus::Failed;
|
||||
scan.error_message = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run ClamAV scan
|
||||
async fn run_clamav_scan(path: &str, config: &AntivirusConfig) -> Result<(u64, Vec<Threat>)> {
|
||||
// Find clamscan executable
|
||||
let clamscan = config
|
||||
.clamav_path
|
||||
.clone()
|
||||
.map(|p| p.join("clamscan"))
|
||||
.unwrap_or_else(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
PathBuf::from("C:\\Program Files\\ClamAV\\clamscan.exe")
|
||||
} else {
|
||||
PathBuf::from("/usr/bin/clamscan")
|
||||
}
|
||||
});
|
||||
|
||||
if !clamscan.exists() {
|
||||
// Try system PATH
|
||||
let output = Command::new("which")
|
||||
.arg("clamscan")
|
||||
.output()
|
||||
.unwrap_or_else(|_| {
|
||||
Command::new("where")
|
||||
.arg("clamscan")
|
||||
.output()
|
||||
.unwrap_or_else(|_| std::process::Output {
|
||||
status: std::process::ExitStatus::default(),
|
||||
stdout: vec![],
|
||||
stderr: vec![],
|
||||
})
|
||||
});
|
||||
|
||||
if output.stdout.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ClamAV not found. Please install ClamAV first."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&clamscan);
|
||||
cmd.arg("-r") // Recursive
|
||||
.arg("--infected") // Only show infected files
|
||||
.arg("--no-summary");
|
||||
|
||||
if config.scan_archives {
|
||||
cmd.arg("--scan-archive=yes");
|
||||
}
|
||||
|
||||
// Add excluded paths
|
||||
for excluded in &config.excluded_paths {
|
||||
cmd.arg(format!("--exclude-dir={}", excluded.display()));
|
||||
}
|
||||
|
||||
cmd.arg(path);
|
||||
|
||||
let output = cmd.output().context("Failed to run ClamAV scan")?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut threats = Vec::new();
|
||||
let mut files_scanned: u64 = 0;
|
||||
|
||||
// Parse ClamAV output
|
||||
for line in stdout.lines() {
|
||||
if line.contains("FOUND") {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() >= 2 {
|
||||
let file_path = parts[0].trim();
|
||||
let threat_name = parts[1].trim().replace(" FOUND", "");
|
||||
|
||||
threats.push(Threat {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: threat_name.clone(),
|
||||
threat_type: Self::classify_threat(&threat_name),
|
||||
severity: Self::assess_severity(&threat_name),
|
||||
status: ThreatStatus::Detected,
|
||||
file_path: Some(file_path.to_string()),
|
||||
detected_at: chrono::Utc::now(),
|
||||
description: Some(format!("Detected by ClamAV: {}", threat_name)),
|
||||
action_taken: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
files_scanned += 1;
|
||||
}
|
||||
|
||||
Ok((files_scanned, threats))
|
||||
}
|
||||
|
||||
/// Classify threat type
|
||||
fn classify_threat(name: &str) -> String {
|
||||
let name_lower = name.to_lowercase();
|
||||
if name_lower.contains("trojan") {
|
||||
"Trojan".to_string()
|
||||
} else if name_lower.contains("virus") {
|
||||
"Virus".to_string()
|
||||
} else if name_lower.contains("worm") {
|
||||
"Worm".to_string()
|
||||
} else if name_lower.contains("ransomware") {
|
||||
"Ransomware".to_string()
|
||||
} else if name_lower.contains("spyware") {
|
||||
"Spyware".to_string()
|
||||
} else if name_lower.contains("adware") {
|
||||
"Adware".to_string()
|
||||
} else if name_lower.contains("rootkit") {
|
||||
"Rootkit".to_string()
|
||||
} else if name_lower.contains("pup") || name_lower.contains("pua") {
|
||||
"PUP".to_string()
|
||||
} else {
|
||||
"Malware".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Assess threat severity
|
||||
fn assess_severity(name: &str) -> ThreatSeverity {
|
||||
let name_lower = name.to_lowercase();
|
||||
if name_lower.contains("ransomware") || name_lower.contains("rootkit") {
|
||||
ThreatSeverity::Critical
|
||||
} else if name_lower.contains("trojan") || name_lower.contains("backdoor") {
|
||||
ThreatSeverity::High
|
||||
} else if name_lower.contains("virus") || name_lower.contains("worm") {
|
||||
ThreatSeverity::Medium
|
||||
} else {
|
||||
ThreatSeverity::Low
|
||||
}
|
||||
}
|
||||
|
||||
/// Quarantine a file
|
||||
pub async fn quarantine_file(&self, file_path: &Path) -> Result<()> {
|
||||
if !file_path.exists() {
|
||||
return Err(anyhow::anyhow!("File not found: {:?}", file_path));
|
||||
}
|
||||
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let quarantine_path = self.config.quarantine_dir.join(format!(
|
||||
"{}_{}",
|
||||
chrono::Utc::now().timestamp(),
|
||||
file_name
|
||||
));
|
||||
|
||||
std::fs::rename(file_path, &quarantine_path)
|
||||
.context("Failed to move file to quarantine")?;
|
||||
|
||||
info!("File quarantined: {:?} -> {:?}", file_path, quarantine_path);
|
||||
|
||||
// Update protection status
|
||||
let mut status = self.protection_status.write().await;
|
||||
status.quarantined_items += 1;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a threat
|
||||
pub async fn remove_threat(&self, threat_id: &str) -> Result<()> {
|
||||
let mut threats = self.threats.write().await;
|
||||
|
||||
if let Some(pos) = threats.iter().position(|t| t.id == threat_id) {
|
||||
let threat = &threats[pos];
|
||||
|
||||
if let Some(ref file_path) = threat.file_path {
|
||||
let path = Path::new(file_path);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path).context("Failed to remove infected file")?;
|
||||
info!("Removed infected file: {}", file_path);
|
||||
}
|
||||
}
|
||||
|
||||
threats[pos].status = ThreatStatus::Removed;
|
||||
threats[pos].action_taken = Some("File removed".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all detected threats
|
||||
pub async fn get_threats(&self) -> Vec<Threat> {
|
||||
self.threats.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get threats by status
|
||||
pub async fn get_threats_by_status(&self, status: ThreatStatus) -> Vec<Threat> {
|
||||
self.threats
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|t| t.status == status)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all vulnerabilities
|
||||
pub async fn get_vulnerabilities(&self) -> Vec<Vulnerability> {
|
||||
self.vulnerabilities.read().await.clone()
|
||||
}
|
||||
|
||||
/// Scan for vulnerabilities
|
||||
pub async fn scan_vulnerabilities(&self) -> Result<Vec<Vulnerability>> {
|
||||
let mut vulnerabilities = Vec::new();
|
||||
|
||||
// Check for common vulnerabilities
|
||||
|
||||
// 1. Check for outdated software (Windows)
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Check Windows Update status
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
"Get-HotFix | Sort-Object -Property InstalledOn -Descending | Select-Object -First 1",
|
||||
])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
// Check if updates are old
|
||||
if result.is_empty() {
|
||||
vulnerabilities.push(Vulnerability {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
cve_id: None,
|
||||
name: "Missing Windows Updates".to_string(),
|
||||
severity: ThreatSeverity::High,
|
||||
affected_component: "Windows Update".to_string(),
|
||||
description: "System may be missing critical security updates".to_string(),
|
||||
remediation: Some(
|
||||
"Run Windows Update to install latest patches".to_string(),
|
||||
),
|
||||
detected_at: chrono::Utc::now(),
|
||||
is_patched: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for weak file permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let sensitive_paths = vec!["/etc/passwd", "/etc/shadow", "/etc/ssh/sshd_config"];
|
||||
|
||||
for path_str in sensitive_paths {
|
||||
let path = Path::new(path_str);
|
||||
if path.exists() {
|
||||
if let Ok(metadata) = std::fs::metadata(path) {
|
||||
let mode = metadata.permissions().mode();
|
||||
if mode & 0o002 != 0 {
|
||||
// World writable
|
||||
vulnerabilities.push(Vulnerability {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
cve_id: None,
|
||||
name: format!("Weak permissions on {}", path_str),
|
||||
severity: ThreatSeverity::High,
|
||||
affected_component: path_str.to_string(),
|
||||
description: "Sensitive file has world-writable permissions"
|
||||
.to_string(),
|
||||
remediation: Some(format!("chmod o-w {}", path_str)),
|
||||
detected_at: chrono::Utc::now(),
|
||||
is_patched: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store vulnerabilities
|
||||
{
|
||||
let mut vulns = self.vulnerabilities.write().await;
|
||||
vulns.extend(vulnerabilities.clone());
|
||||
}
|
||||
|
||||
Ok(vulnerabilities)
|
||||
}
|
||||
|
||||
/// Get protection status
|
||||
pub async fn get_protection_status(&self) -> ProtectionStatus {
|
||||
self.protection_status.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get scan result by ID
|
||||
pub async fn get_scan_result(&self, scan_id: &str) -> Option<ScanResult> {
|
||||
self.active_scans.read().await.get(scan_id).cloned()
|
||||
}
|
||||
|
||||
/// Get all scans
|
||||
pub async fn get_all_scans(&self) -> Vec<ScanResult> {
|
||||
self.active_scans.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Cancel a scan
|
||||
pub async fn cancel_scan(&self, scan_id: &str) -> Result<()> {
|
||||
let mut scans = self.active_scans.write().await;
|
||||
if let Some(scan) = scans.get_mut(scan_id) {
|
||||
if scan.status == ScanStatus::Running || scan.status == ScanStatus::Pending {
|
||||
scan.status = ScanStatus::Cancelled;
|
||||
scan.completed_at = Some(chrono::Utc::now());
|
||||
info!("Scan {} cancelled", scan_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Scan is not running"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Scan not found"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update virus definitions
|
||||
pub async fn update_definitions(&self) -> Result<()> {
|
||||
info!("Updating virus definitions...");
|
||||
|
||||
// Try freshclam (ClamAV updater)
|
||||
let freshclam = if cfg!(target_os = "windows") {
|
||||
"freshclam.exe"
|
||||
} else {
|
||||
"freshclam"
|
||||
};
|
||||
|
||||
let output = Command::new(freshclam)
|
||||
.output()
|
||||
.context("Failed to run freshclam")?;
|
||||
|
||||
if output.status.success() {
|
||||
let mut status = self.protection_status.write().await;
|
||||
status.last_definition_update = Some(chrono::Utc::now());
|
||||
info!("Virus definitions updated successfully");
|
||||
Ok(())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(anyhow::anyhow!("Failed to update definitions: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set real-time protection
|
||||
pub async fn set_realtime_protection(&self, enabled: bool) -> Result<()> {
|
||||
let mut status = self.protection_status.write().await;
|
||||
status.real_time_protection = enabled;
|
||||
info!("Real-time protection set to: {}", enabled);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// API response types for the security endpoints
|
||||
pub mod api {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ThreatsResponse {
|
||||
pub threats: Vec<Threat>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VulnerabilitiesResponse {
|
||||
pub vulnerabilities: Vec<Vulnerability>,
|
||||
pub total: usize,
|
||||
pub critical: usize,
|
||||
pub high: usize,
|
||||
pub medium: usize,
|
||||
pub low: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ScanRequest {
|
||||
pub scan_type: ScanType,
|
||||
pub target_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ScanResponse {
|
||||
pub scan_id: String,
|
||||
pub status: ScanStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ActionResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DefenderStatusRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_classify_threat() {
|
||||
assert_eq!(
|
||||
AntivirusManager::classify_threat("Win.Trojan.Generic"),
|
||||
"Trojan"
|
||||
);
|
||||
assert_eq!(
|
||||
AntivirusManager::classify_threat("Ransomware.WannaCry"),
|
||||
"Ransomware"
|
||||
);
|
||||
assert_eq!(
|
||||
AntivirusManager::classify_threat("PUP.Optional.Adware"),
|
||||
"PUP"
|
||||
);
|
||||
assert_eq!(
|
||||
AntivirusManager::classify_threat("Unknown.Malware"),
|
||||
"Malware"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assess_severity() {
|
||||
assert_eq!(
|
||||
AntivirusManager::assess_severity("Ransomware.Test"),
|
||||
ThreatSeverity::Critical
|
||||
);
|
||||
assert_eq!(
|
||||
AntivirusManager::assess_severity("Trojan.Generic"),
|
||||
ThreatSeverity::High
|
||||
);
|
||||
assert_eq!(
|
||||
AntivirusManager::assess_severity("Virus.Test"),
|
||||
ThreatSeverity::Medium
|
||||
);
|
||||
assert_eq!(
|
||||
AntivirusManager::assess_severity("PUP.Adware"),
|
||||
ThreatSeverity::Low
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_antivirus_manager_creation() {
|
||||
let config = AntivirusConfig::default();
|
||||
let manager = AntivirusManager::new(config);
|
||||
assert!(manager.is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,19 @@
|
|||
//! - Internal Certificate Authority (CA) management
|
||||
//! - Certificate lifecycle management
|
||||
//! - Security utilities and helpers
|
||||
//! - Antivirus and threat detection (ClamAV integration)
|
||||
//! - Windows Defender management
|
||||
|
||||
pub mod antivirus;
|
||||
pub mod ca;
|
||||
pub mod integration;
|
||||
pub mod mutual_tls;
|
||||
pub mod tls;
|
||||
|
||||
pub use antivirus::{
|
||||
AntivirusConfig, AntivirusManager, ProtectionStatus, ScanResult, ScanStatus, ScanType, Threat,
|
||||
ThreatSeverity, ThreatStatus, Vulnerability,
|
||||
};
|
||||
pub use ca::{CaConfig, CaManager, CertificateRequest, CertificateResponse};
|
||||
pub use integration::{
|
||||
create_https_client, get_tls_integration, init_tls_integration, to_secure_url, TlsIntegration,
|
||||
|
|
|
|||
1019
templates/tools.html
Normal file
1019
templates/tools.html
Normal file
File diff suppressed because it is too large
Load diff
519
ui/suite/editor.html
Normal file
519
ui/suite/editor.html
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Editor - General Bots</title>
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent-color: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--border-color: #475569;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-title-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.editor-title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-path {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toolbar-group:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
width: 50px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 16px 8px;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
font-family: "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-editor {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 16px;
|
||||
font-family: "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.csv-editor {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.csv-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.csv-table th,
|
||||
.csv-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.csv-table th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.csv-table .row-num {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.csv-input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.csv-input:focus {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dirty-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--warning);
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
border-left: 4px solid var(--accent-color);
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.notification.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="editor-container">
|
||||
<!-- Header -->
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">
|
||||
<span class="editor-title-icon">📝</span>
|
||||
<div>
|
||||
<span
|
||||
class="editor-title-text"
|
||||
id="editor-filename"
|
||||
hx-get="/api/v1/editor/filename"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"></div>
|
||||
Untitled
|
||||
</span>
|
||||
<div
|
||||
class="editor-path"
|
||||
id="editor-filepath"
|
||||
hx-get="/api/v1/editor/filepath"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="dirty-indicator"
|
||||
id="dirty-indicator"
|
||||
style="display: none;"
|
||||
title="Unsaved changes">
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#drive"
|
||||
class="btn btn-small"
|
||||
hx-get="/api/drive/list"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true">
|
||||
✕ Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="btn btn-primary btn-small"
|
||||
hx-post="/api/v1/editor/save"
|
||||
hx-include="#text-editor"
|
||||
hx-indicator="#save-spinner"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="showSaveNotification(event)">
|
||||
<span class="htmx-indicator spinner" id="save-spinner"></span>
|
||||
💾 Save
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-get="/api/v1/editor/save-as"
|
||||
hx-target="#save-dialog"
|
||||
hx-swap="innerHTML">
|
||||
Save As
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/undo"
|
||||
hx-target="#editor-content"
|
||||
hx-swap="innerHTML">
|
||||
↩️ Undo
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/redo"
|
||||
hx-target="#editor-content"
|
||||
hx-swap="innerHTML">
|
||||
↪️ Redo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group" id="text-tools">
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/format"
|
||||
hx-include="#text-editor"
|
||||
hx-target="#text-editor"
|
||||
hx-swap="innerHTML">
|
||||
{ } Format
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group" id="csv-tools" style="display: none;">
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/csv/add-row"
|
||||
hx-target="#csv-table-body"
|
||||
hx-swap="beforeend">
|
||||
➕ Row
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/csv/add-column"
|
||||
hx-target="#csv-editor"
|
||||
hx-swap="innerHTML">
|
||||
➕ Column
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Content - loaded via HTMX based on file type -->
|
||||
<div class="editor-content" id="editor-content">
|
||||
<!-- Text Editor (default) -->
|
||||
<div class="editor-wrapper" id="text-editor-wrapper">
|
||||
<div
|
||||
class="line-numbers"
|
||||
id="line-numbers"
|
||||
hx-get="/api/v1/editor/line-numbers"
|
||||
hx-trigger="keyup from:#text-editor delay:100ms"
|
||||
hx-swap="innerHTML">
|
||||
1
|
||||
</div>
|
||||
<textarea
|
||||
class="text-editor"
|
||||
id="text-editor"
|
||||
name="content"
|
||||
spellcheck="false"
|
||||
hx-post="/api/v1/editor/autosave"
|
||||
hx-trigger="keyup changed delay:5s"
|
||||
hx-swap="none"
|
||||
hx-indicator="#autosave-indicator"
|
||||
placeholder="Start typing or open a file..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- CSV Editor (shown for .csv files) -->
|
||||
<div class="csv-editor" id="csv-editor" style="display: none;">
|
||||
<table class="csv-table">
|
||||
<thead id="csv-table-head">
|
||||
<tr>
|
||||
<th class="row-num">#</th>
|
||||
<th>
|
||||
<input
|
||||
type="text"
|
||||
class="csv-input"
|
||||
name="header_0"
|
||||
value="Column 1"
|
||||
hx-post="/api/v1/editor/csv/update-header"
|
||||
hx-trigger="change"
|
||||
hx-swap="none">
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
id="csv-table-body"
|
||||
hx-get="/api/v1/editor/csv/rows"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<div class="status-left">
|
||||
<span
|
||||
id="file-type"
|
||||
hx-get="/api/v1/editor/filetype"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
📄 Plain Text
|
||||
</span>
|
||||
<span>UTF-8</span>
|
||||
<span
|
||||
id="autosave-indicator"
|
||||
class="htmx-indicator"
|
||||
style="font-size: 11px;">
|
||||
Saving...
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-right">
|
||||
<span
|
||||
id="cursor-position"
|
||||
hx-get="/api/v1/editor/position"
|
||||
hx-trigger="click from:#text-editor, keyup from:#text-editor"
|
||||
hx-swap="innerHTML">
|
||||
Ln 1, Col 1
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Dialog (loaded via HTMX) -->
|
||||
<div id="save-dialog"></div>
|
||||
|
||||
<!-- Notification -->
|
||||
<div class="notification" id="notification"></div>
|
||||
|
||||
<script>
|
||||
// Minimal JS for notification display (could be replaced with htmx extension)
|
||||
function showSaveNotification(event) {
|
||||
const notification = document.getElementById('notification');
|
||||
if (event.detail.successful) {
|
||||
notification.textContent = '✓ File saved';
|
||||
notification.className = 'notification success show';
|
||||
document.getElementById('dirty-indicator').style.display = 'none';
|
||||
} else {
|
||||
notification.textContent = '✗ Save failed';
|
||||
notification.className = 'notification error show';
|
||||
}
|
||||
setTimeout(() => notification.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
// Mark as dirty on edit
|
||||
document.getElementById('text-editor')?.addEventListener('input', function() {
|
||||
document.getElementById('dirty-indicator').style.display = 'inline-block';
|
||||
});
|
||||
|
||||
// Keyboard shortcuts using htmx triggers
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
htmx.trigger(document.querySelector('[hx-post="/api/v1/editor/save"]'), 'click');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue