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")]
|
#![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 drive;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
pub mod tools;
|
||||||
pub mod tray;
|
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};
|
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
|
//! - Internal Certificate Authority (CA) management
|
||||||
//! - Certificate lifecycle management
|
//! - Certificate lifecycle management
|
||||||
//! - Security utilities and helpers
|
//! - Security utilities and helpers
|
||||||
|
//! - Antivirus and threat detection (ClamAV integration)
|
||||||
|
//! - Windows Defender management
|
||||||
|
|
||||||
|
pub mod antivirus;
|
||||||
pub mod ca;
|
pub mod ca;
|
||||||
pub mod integration;
|
pub mod integration;
|
||||||
pub mod mutual_tls;
|
pub mod mutual_tls;
|
||||||
pub mod 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 ca::{CaConfig, CaManager, CertificateRequest, CertificateResponse};
|
||||||
pub use integration::{
|
pub use integration::{
|
||||||
create_https_client, get_tls_integration, init_tls_integration, to_secure_url, TlsIntegration,
|
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