From 46695c0f75610acdad4d610f4bd937de806bb11a Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sat, 10 Jan 2026 20:32:56 -0300 Subject: [PATCH] feat(security): add BASIC keywords for security protection tools Add security_protection.rs with 8 new BASIC keywords: - SECURITY TOOL STATUS - Check if tool is installed/running - SECURITY RUN SCAN - Execute security scan - SECURITY GET REPORT - Get latest scan report - SECURITY UPDATE DEFINITIONS - Update signatures - SECURITY START SERVICE - Start security service - SECURITY STOP SERVICE - Stop security service - SECURITY INSTALL TOOL - Install security tool - SECURITY HARDENING SCORE - Get Lynis hardening index Also: - Registered protection routes in main.rs - Added Security Protection category to keywords list - All functions use proper error handling (no unwrap/expect) --- Cargo.toml | 5 + src/basic/keywords/mod.rs | 29 ++ src/basic/keywords/security_protection.rs | 325 ++++++++++++++++++++++ src/docs/mod.rs | 113 +++++++- src/drive/mod.rs | 96 +++++++ src/main.rs | 1 + src/sheet/mod.rs | 303 ++++++++++++++++++++ src/slides/mod.rs | 80 ++++++ 8 files changed, 947 insertions(+), 5 deletions(-) create mode 100644 src/basic/keywords/security_protection.rs diff --git a/Cargo.toml b/Cargo.toml index b2feed6a9..2b2bcfd6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -207,6 +207,11 @@ qrcode = { version = "0.14", default-features = false } # Excel/Spreadsheet Support calamine = "0.26" rust_xlsxwriter = "0.79" +spreadsheet-ods = "1.0" + +# Word/PowerPoint Support +docx-rs = "0.4" +ppt-rs = { version = "0.2", default-features = false } # Error handling thiserror = "2.0" diff --git a/src/basic/keywords/mod.rs b/src/basic/keywords/mod.rs index aa52e26f6..ab3be8234 100644 --- a/src/basic/keywords/mod.rs +++ b/src/basic/keywords/mod.rs @@ -55,6 +55,7 @@ pub mod procedures; pub mod qrcode; pub mod remember; pub mod save_from_unstructured; +pub mod security_protection; pub mod send_mail; pub mod send_template; pub mod set; @@ -85,6 +86,12 @@ pub mod webhook; pub use app_server::configure_app_server_routes; pub use db_api::configure_db_routes; pub use mcp_client::{McpClient, McpRequest, McpResponse, McpServer, McpTool}; +pub use security_protection::{ + security_get_report, security_hardening_score, security_install_tool, security_run_scan, + security_service_is_running, security_start_service, security_stop_service, + security_tool_is_installed, security_tool_status, security_update_definitions, + SecurityScanResult, SecurityToolResult, +}; pub use mcp_directory::{McpDirectoryScanResult, McpDirectoryScanner, McpServerConfig}; pub use table_access::{ check_field_write_access, check_table_access, filter_fields_by_role, load_table_access_info, @@ -201,6 +208,14 @@ pub fn get_all_keywords() -> Vec { "OPTION A OR B".to_string(), "DECIDE".to_string(), "ESCALATE".to_string(), + "SECURITY TOOL STATUS".to_string(), + "SECURITY RUN SCAN".to_string(), + "SECURITY GET REPORT".to_string(), + "SECURITY UPDATE DEFINITIONS".to_string(), + "SECURITY START SERVICE".to_string(), + "SECURITY STOP SERVICE".to_string(), + "SECURITY INSTALL TOOL".to_string(), + "SECURITY HARDENING SCORE".to_string(), ] } @@ -325,5 +340,19 @@ pub fn get_keyword_categories() -> std::collections::HashMap ], ); + categories.insert( + "Security Protection".to_string(), + vec![ + "SECURITY TOOL STATUS".to_string(), + "SECURITY RUN SCAN".to_string(), + "SECURITY GET REPORT".to_string(), + "SECURITY UPDATE DEFINITIONS".to_string(), + "SECURITY START SERVICE".to_string(), + "SECURITY STOP SERVICE".to_string(), + "SECURITY INSTALL TOOL".to_string(), + "SECURITY HARDENING SCORE".to_string(), + ], + ); + categories } diff --git a/src/basic/keywords/security_protection.rs b/src/basic/keywords/security_protection.rs new file mode 100644 index 000000000..f7f78b24d --- /dev/null +++ b/src/basic/keywords/security_protection.rs @@ -0,0 +1,325 @@ +use crate::security::protection::{ProtectionManager, ProtectionTool, ProtectionConfig}; +use crate::shared::state::AppState; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityToolResult { + pub tool: String, + pub success: bool, + pub installed: bool, + pub version: Option, + pub running: Option, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityScanResult { + pub tool: String, + pub success: bool, + pub status: String, + pub findings_count: usize, + pub warnings_count: usize, + pub score: Option, + pub report_path: Option, +} + +pub async fn security_tool_status( + _state: Arc, + tool_name: &str, +) -> Result { + let tool = parse_tool_name(tool_name)?; + let manager = ProtectionManager::new(ProtectionConfig::default()); + + match manager.check_tool_status(tool).await { + Ok(status) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: true, + installed: status.installed, + version: status.version, + running: status.service_running, + message: if status.installed { + "Tool is installed".to_string() + } else { + "Tool is not installed".to_string() + }, + }), + Err(e) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: false, + installed: false, + version: None, + running: None, + message: format!("Failed to check status: {e}"), + }), + } +} + +pub async fn security_run_scan( + _state: Arc, + tool_name: &str, +) -> Result { + let tool = parse_tool_name(tool_name)?; + let manager = ProtectionManager::new(ProtectionConfig::default()); + + match manager.run_scan(tool).await { + Ok(result) => Ok(SecurityScanResult { + tool: tool_name.to_lowercase(), + success: true, + status: result.status, + findings_count: result.findings.len(), + warnings_count: result.warnings, + score: result.score, + report_path: result.report_path, + }), + Err(e) => Ok(SecurityScanResult { + tool: tool_name.to_lowercase(), + success: false, + status: "error".to_string(), + findings_count: 0, + warnings_count: 0, + score: None, + report_path: None, + }), + } +} + +pub async fn security_get_report( + _state: Arc, + tool_name: &str, +) -> Result { + let tool = parse_tool_name(tool_name)?; + let manager = ProtectionManager::new(ProtectionConfig::default()); + + manager + .get_report(tool) + .await + .map_err(|e| format!("Failed to get report: {e}")) +} + +pub async fn security_update_definitions( + _state: Arc, + tool_name: &str, +) -> Result { + let tool = parse_tool_name(tool_name)?; + let manager = ProtectionManager::new(ProtectionConfig::default()); + + match manager.update_definitions(tool).await { + Ok(()) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: true, + installed: true, + version: None, + running: None, + message: "Definitions updated successfully".to_string(), + }), + Err(e) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: false, + installed: true, + version: None, + running: None, + message: format!("Failed to update definitions: {e}"), + }), + } +} + +pub async fn security_start_service( + _state: Arc, + tool_name: &str, +) -> Result { + let tool = parse_tool_name(tool_name)?; + let manager = ProtectionManager::new(ProtectionConfig::default()); + + match manager.start_service(tool).await { + Ok(()) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: true, + installed: true, + version: None, + running: Some(true), + message: "Service started successfully".to_string(), + }), + Err(e) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: false, + installed: true, + version: None, + running: Some(false), + message: format!("Failed to start service: {e}"), + }), + } +} + +pub async fn security_stop_service( + _state: Arc, + tool_name: &str, +) -> Result { + let tool = parse_tool_name(tool_name)?; + let manager = ProtectionManager::new(ProtectionConfig::default()); + + match manager.stop_service(tool).await { + Ok(()) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: true, + installed: true, + version: None, + running: Some(false), + message: "Service stopped successfully".to_string(), + }), + Err(e) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: false, + installed: true, + version: None, + running: None, + message: format!("Failed to stop service: {e}"), + }), + } +} + +pub async fn security_install_tool( + _state: Arc, + tool_name: &str, +) -> Result { + let tool = parse_tool_name(tool_name)?; + let manager = ProtectionManager::new(ProtectionConfig::default()); + + match manager.install_tool(tool).await { + Ok(()) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: true, + installed: true, + version: None, + running: None, + message: "Tool installed successfully".to_string(), + }), + Err(e) => Ok(SecurityToolResult { + tool: tool_name.to_lowercase(), + success: false, + installed: false, + version: None, + running: None, + message: format!("Failed to install tool: {e}"), + }), + } +} + +pub async fn security_hardening_score(_state: Arc) -> Result { + let manager = ProtectionManager::new(ProtectionConfig::default()); + + match manager.run_scan(ProtectionTool::Lynis).await { + Ok(result) => result.score.ok_or_else(|| "No hardening score available".to_string()), + Err(e) => Err(format!("Failed to get hardening score: {e}")), + } +} + +pub fn security_tool_is_installed(status: &SecurityToolResult) -> bool { + status.installed +} + +pub fn security_service_is_running(status: &SecurityToolResult) -> bool { + status.running.unwrap_or(false) +} + +fn parse_tool_name(name: &str) -> Result { + ProtectionTool::from_str(name) + .ok_or_else(|| format!("Unknown security tool: {name}. Valid tools: lynis, rkhunter, chkrootkit, suricata, lmd, clamav")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tool_name_valid() { + assert!(parse_tool_name("lynis").is_ok()); + assert!(parse_tool_name("LYNIS").is_ok()); + assert!(parse_tool_name("Lynis").is_ok()); + assert!(parse_tool_name("rkhunter").is_ok()); + assert!(parse_tool_name("chkrootkit").is_ok()); + assert!(parse_tool_name("suricata").is_ok()); + assert!(parse_tool_name("lmd").is_ok()); + assert!(parse_tool_name("clamav").is_ok()); + } + + #[test] + fn test_parse_tool_name_invalid() { + assert!(parse_tool_name("unknown").is_err()); + assert!(parse_tool_name("").is_err()); + assert!(parse_tool_name("invalid_tool").is_err()); + } + + #[test] + fn test_security_tool_is_installed() { + let installed = SecurityToolResult { + tool: "lynis".to_string(), + success: true, + installed: true, + version: Some("3.0.9".to_string()), + running: None, + message: "Tool is installed".to_string(), + }; + assert!(security_tool_is_installed(&installed)); + + let not_installed = SecurityToolResult { + tool: "lynis".to_string(), + success: true, + installed: false, + version: None, + running: None, + message: "Tool is not installed".to_string(), + }; + assert!(!security_tool_is_installed(¬_installed)); + } + + #[test] + fn test_security_service_is_running() { + let running = SecurityToolResult { + tool: "suricata".to_string(), + success: true, + installed: true, + version: None, + running: Some(true), + message: "Service running".to_string(), + }; + assert!(security_service_is_running(&running)); + + let stopped = SecurityToolResult { + tool: "suricata".to_string(), + success: true, + installed: true, + version: None, + running: Some(false), + message: "Service stopped".to_string(), + }; + assert!(!security_service_is_running(&stopped)); + + let unknown = SecurityToolResult { + tool: "lynis".to_string(), + success: true, + installed: true, + version: None, + running: None, + message: "No service".to_string(), + }; + assert!(!security_service_is_running(&unknown)); + } + + #[test] + fn test_security_scan_result_serialization() { + let result = SecurityScanResult { + tool: "lynis".to_string(), + success: true, + status: "completed".to_string(), + findings_count: 5, + warnings_count: 12, + score: Some(78), + report_path: Some("/var/log/lynis-report.dat".to_string()), + }; + + let json = serde_json::to_string(&result).expect("Failed to serialize"); + assert!(json.contains("\"tool\":\"lynis\"")); + assert!(json.contains("\"score\":78")); + } +} diff --git a/src/docs/mod.rs b/src/docs/mod.rs index ccbe64d12..1380b29fe 100644 --- a/src/docs/mod.rs +++ b/src/docs/mod.rs @@ -7,7 +7,6 @@ //! - AI-powered writing assistance //! - Export to multiple formats (PDF, DOCX, HTML, TXT, MD) - use crate::core::urls::ApiUrls; use crate::shared::state::AppState; use aws_sdk_s3::primitives::ByteStream; @@ -31,6 +30,11 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::broadcast; use uuid::Uuid; +use docx_rs::{ + Docx, Paragraph, Run, Table, TableRow, TableCell, + AlignmentType, BreakType, RunFonts, TableBorders, BorderType, + WidthType, TableCellWidth, +}; // ============================================================================= // COLLABORATION TYPES @@ -1129,11 +1133,110 @@ pub async fn handle_export_pdf( } pub async fn handle_export_docx( - State(_state): State>, - Query(_params): Query, + State(state): State>, + headers: HeaderMap, + Query(params): Query, ) -> impl IntoResponse { - // DOCX export would require a library like docx-rs - Html("

DOCX export coming soon

".to_string()) + let (_user_id, user_identifier) = match get_current_user(&state, &headers).await { + Ok(u) => u, + Err(_) => return ( + axum::http::StatusCode::UNAUTHORIZED, + [(axum::http::header::CONTENT_TYPE, "text/plain")], + Vec::new(), + ), + }; + + let doc_id = match params.id { + Some(id) => id, + None => return ( + axum::http::StatusCode::BAD_REQUEST, + [(axum::http::header::CONTENT_TYPE, "text/plain")], + Vec::new(), + ), + }; + + match load_document_from_drive(&state, &user_identifier, &doc_id).await { + Ok(Some(doc)) => { + match html_to_docx(&doc.title, &doc.content) { + Ok(bytes) => ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")], + bytes, + ), + Err(_) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + [(axum::http::header::CONTENT_TYPE, "text/plain")], + Vec::new(), + ), + } + } + _ => ( + axum::http::StatusCode::NOT_FOUND, + [(axum::http::header::CONTENT_TYPE, "text/plain")], + Vec::new(), + ), + } +} + +fn html_to_docx(title: &str, html_content: &str) -> Result, String> { + let mut docx = Docx::new(); + + let title_para = Paragraph::new() + .add_run( + Run::new() + .add_text(title) + .size(48) + .bold() + .fonts(RunFonts::new().ascii("Calibri")) + ) + .align(AlignmentType::Center); + docx = docx.add_paragraph(title_para); + + docx = docx.add_paragraph(Paragraph::new()); + + let plain_text = strip_html(html_content); + let paragraphs: Vec<&str> = plain_text.split("\n\n").collect(); + + for para_text in paragraphs { + let trimmed = para_text.trim(); + if trimmed.is_empty() { + continue; + } + + let is_heading = trimmed.starts_with('#'); + let (text, size, bold) = if is_heading { + let level = trimmed.chars().take_while(|c| *c == '#').count(); + let heading_text = trimmed.trim_start_matches('#').trim(); + let heading_size = match level { + 1 => 36, + 2 => 28, + 3 => 24, + _ => 22, + }; + (heading_text, heading_size, true) + } else { + (trimmed, 22, false) + }; + + let mut run = Run::new() + .add_text(text) + .size(size) + .fonts(RunFonts::new().ascii("Calibri")); + + if bold { + run = run.bold(); + } + + let para = Paragraph::new().add_run(run); + docx = docx.add_paragraph(para); + } + + let mut buffer = Vec::new(); + docx.build() + .pack(&mut std::io::Cursor::new(&mut buffer)) + .map_err(|e| format!("Failed to generate DOCX: {}", e))?; + + Ok(buffer) } pub async fn handle_export_md( diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 481fd17a7..50aa2b6bc 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -174,10 +174,24 @@ pub struct BucketInfo { pub is_gbai: bool, } +#[derive(Debug, Deserialize)] +pub struct OpenRequest { + pub bucket: String, + pub path: String, +} + +#[derive(Debug, Serialize)] +pub struct OpenResponse { + pub app: String, + pub url: String, + pub content_type: String, +} + pub fn configure() -> Router> { Router::new() .route("/api/files/buckets", get(list_buckets)) .route("/api/files/list", get(list_files)) + .route("/api/files/open", post(open_file)) .route("/api/files/read", post(read_file)) .route("/api/files/write", post(write_file)) .route("/api/files/save", post(write_file)) @@ -209,6 +223,88 @@ pub fn configure() -> Router> { .route("/api/docs/import", post(document_processing::import_document)) } +pub async fn open_file( + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let ext = req.path + .rsplit('.') + .next() + .unwrap_or("") + .to_lowercase(); + + let params = format!("bucket={}&path={}", + urlencoding::encode(&req.bucket), + urlencoding::encode(&req.path)); + + let (app, url, content_type) = match ext.as_str() { + // Designer - BASIC dialogs + "bas" => ("designer", format!("/suite/designer.html?{params}"), "text/x-basic"), + + // Sheet - Spreadsheets + "csv" => ("sheet", format!("/suite/sheet/sheet.html?{params}"), "text/csv"), + "tsv" => ("sheet", format!("/suite/sheet/sheet.html?{params}"), "text/tab-separated-values"), + "xlsx" => ("sheet", format!("/suite/sheet/sheet.html?{params}"), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + "xls" => ("sheet", format!("/suite/sheet/sheet.html?{params}"), "application/vnd.ms-excel"), + "ods" => ("sheet", format!("/suite/sheet/sheet.html?{params}"), "application/vnd.oasis.opendocument.spreadsheet"), + "numbers" => ("sheet", format!("/suite/sheet/sheet.html?{params}"), "application/vnd.apple.numbers"), + + // Docs - Documents + "docx" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + "doc" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/msword"), + "odt" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/vnd.oasis.opendocument.text"), + "rtf" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/rtf"), + "pdf" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/pdf"), + "md" => ("docs", format!("/suite/docs/docs.html?{params}"), "text/markdown"), + "markdown" => ("docs", format!("/suite/docs/docs.html?{params}"), "text/markdown"), + "txt" => ("docs", format!("/suite/docs/docs.html?{params}"), "text/plain"), + "tex" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/x-tex"), + "latex" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/x-latex"), + "epub" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/epub+zip"), + "pages" => ("docs", format!("/suite/docs/docs.html?{params}"), "application/vnd.apple.pages"), + + // Slides - Presentations + "pptx" => ("slides", format!("/suite/slides/slides.html?{params}"), "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + "ppt" => ("slides", format!("/suite/slides/slides.html?{params}"), "application/vnd.ms-powerpoint"), + "odp" => ("slides", format!("/suite/slides/slides.html?{params}"), "application/vnd.oasis.opendocument.presentation"), + "key" => ("slides", format!("/suite/slides/slides.html?{params}"), "application/vnd.apple.keynote"), + + // Images - use video player (supports images too) + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "tiff" | "tif" | "heic" | "heif" => + ("video", format!("/suite/video/video.html?{params}"), "image/*"), + + // Video + "mp4" | "webm" | "mov" | "avi" | "mkv" | "wmv" | "flv" | "m4v" => + ("video", format!("/suite/video/video.html?{params}"), "video/*"), + + // Audio - use player + "mp3" | "wav" | "ogg" | "oga" | "flac" | "aac" | "m4a" | "wma" | "aiff" | "aif" => + ("player", format!("/suite/player/player.html?{params}"), "audio/*"), + + // Archives - direct download + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => + ("download", format!("/api/files/download?{params}"), "application/octet-stream"), + + // Code/Config - Editor + "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "conf" | "config" | + "js" | "ts" | "jsx" | "tsx" | "css" | "scss" | "sass" | "less" | + "html" | "htm" | "vue" | "svelte" | + "py" | "rb" | "php" | "java" | "c" | "cpp" | "h" | "hpp" | "cs" | + "rs" | "go" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" | "sh" | "bash" | + "sql" | "graphql" | "proto" | + "dockerfile" | "makefile" | "gitignore" | "env" | "log" => + ("editor", format!("/suite/editor/editor.html?{params}"), "text/plain"), + + // Default - Editor for unknown text files + _ => ("editor", format!("/suite/editor/editor.html?{params}"), "application/octet-stream"), + }; + + Ok(Json(OpenResponse { + app: app.to_string(), + url, + content_type: content_type.to_string(), + })) +} + pub async fn list_buckets( State(state): State>, ) -> Result>, (StatusCode, Json)> { diff --git a/src/main.rs b/src/main.rs index 6a011d57b..e22ef7693 100644 --- a/src/main.rs +++ b/src/main.rs @@ -394,6 +394,7 @@ async fn run_axum_server( api_router = api_router.merge(botserver::designer::configure_designer_routes()); api_router = api_router.merge(botserver::dashboards::configure_dashboards_routes()); api_router = api_router.merge(botserver::monitoring::configure()); + api_router = api_router.merge(crate::security::configure_protection_routes()); api_router = api_router.merge(botserver::settings::configure_settings_routes()); api_router = api_router.merge(botserver::basic::keywords::configure_db_routes()); api_router = api_router.merge(botserver::basic::keywords::configure_app_server_routes()); diff --git a/src/sheet/mod.rs b/src/sheet/mod.rs index 7295ad652..3f71a09a3 100644 --- a/src/sheet/mod.rs +++ b/src/sheet/mod.rs @@ -9,7 +9,9 @@ use axum::{ routing::{get, post}, Json, Router, }; +use calamine::{open_workbook_auto, Reader, Data}; use chrono::{DateTime, Datelike, Local, NaiveDate, Utc}; +use rust_xlsxwriter::{Workbook, Format, Color, FormatAlign, FormatBorder}; use futures_util::{SinkExt, StreamExt}; use log::{error, info}; use serde::{Deserialize, Serialize}; @@ -248,6 +250,12 @@ pub struct LoadQuery { pub id: String, } +#[derive(Debug, Deserialize)] +pub struct LoadFromDriveRequest { + pub bucket: String, + pub path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchQuery { pub q: Option, @@ -447,6 +455,7 @@ pub fn configure_sheet_routes() -> Router> { .route("/api/sheet/list", get(handle_list_sheets)) .route("/api/sheet/search", get(handle_search_sheets)) .route("/api/sheet/load", get(handle_load_sheet)) + .route("/api/sheet/load-from-drive", post(handle_load_from_drive)) .route("/api/sheet/save", post(handle_save_sheet)) .route("/api/sheet/delete", post(handle_delete_sheet)) .route("/api/sheet/cell", post(handle_update_cell)) @@ -700,6 +709,175 @@ pub async fn handle_load_sheet( } } +pub async fn handle_load_from_drive( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let drive = state.drive.as_ref().ok_or_else(|| { + (StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({ "error": "Drive not available" }))) + })?; + + let result = drive + .get_object() + .bucket(&req.bucket) + .key(&req.path) + .send() + .await + .map_err(|e| { + (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("File not found: {e}") }))) + })?; + + let bytes = result.body.collect().await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to read file: {e}") }))) + })? + .into_bytes(); + + let ext = req.path.rsplit('.').next().unwrap_or("").to_lowercase(); + let file_name = req.path.rsplit('/').next().unwrap_or("Spreadsheet"); + let sheet_name = file_name.rsplit('.').last().unwrap_or("Spreadsheet").to_string(); + + let worksheets = match ext.as_str() { + "csv" | "tsv" => { + let delimiter = if ext == "tsv" { b'\t' } else { b',' }; + parse_csv_to_worksheets(&bytes, delimiter, &sheet_name)? + } + "xlsx" | "xls" | "ods" | "xlsb" | "xlsm" => { + parse_excel_to_worksheets(&bytes, &ext)? + } + _ => { + return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Unsupported format: .{ext}") })))); + } + }; + + let user_id = get_current_user_id(); + let sheet = Spreadsheet { + id: Uuid::new_v4().to_string(), + name: sheet_name, + owner_id: user_id, + worksheets, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + Ok(Json(sheet)) +} + +fn parse_csv_to_worksheets( + bytes: &[u8], + delimiter: u8, + sheet_name: &str, +) -> Result, (StatusCode, Json)> { + let content = String::from_utf8_lossy(bytes); + let mut data: HashMap = HashMap::new(); + + for (row_idx, line) in content.lines().enumerate() { + let cols: Vec<&str> = if delimiter == b'\t' { + line.split('\t').collect() + } else { + line.split(',').collect() + }; + + for (col_idx, value) in cols.iter().enumerate() { + let clean_value = value.trim().trim_matches('"').to_string(); + if !clean_value.is_empty() { + let key = format!("{row_idx},{col_idx}"); + data.insert(key, CellData { + value: Some(clean_value), + formula: None, + style: None, + format: None, + note: None, + }); + } + } + } + + Ok(vec![Worksheet { + name: sheet_name.to_string(), + data, + column_widths: None, + row_heights: None, + frozen_rows: None, + frozen_cols: None, + merged_cells: None, + filters: None, + hidden_rows: None, + validations: None, + conditional_formats: None, + charts: None, + }]) +} + +fn parse_excel_to_worksheets( + bytes: &[u8], + _ext: &str, +) -> Result, (StatusCode, Json)> { + use std::io::Cursor; + + let cursor = Cursor::new(bytes); + let mut workbook = open_workbook_auto(cursor).map_err(|e| { + (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Failed to parse spreadsheet: {e}") }))) + })?; + + let sheet_names: Vec = workbook.sheet_names().to_vec(); + let mut worksheets = Vec::new(); + + for sheet_name in sheet_names { + let range = workbook.worksheet_range(&sheet_name).map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to read sheet {sheet_name}: {e}") }))) + })?; + + let mut data: HashMap = HashMap::new(); + + for (row_idx, row) in range.rows().enumerate() { + for (col_idx, cell) in row.iter().enumerate() { + let value = match cell { + Data::Empty => continue, + Data::String(s) => s.clone(), + Data::Int(i) => i.to_string(), + Data::Float(f) => f.to_string(), + Data::Bool(b) => b.to_string(), + Data::DateTime(dt) => dt.to_string(), + Data::Error(e) => format!("#ERR:{e:?}"), + Data::DateTimeIso(s) => s.clone(), + Data::DurationIso(s) => s.clone(), + }; + + let key = format!("{row_idx},{col_idx}"); + data.insert(key, CellData { + value: Some(value), + formula: None, + style: None, + format: None, + note: None, + }); + } + } + + worksheets.push(Worksheet { + name: sheet_name, + data, + column_widths: None, + row_heights: None, + frozen_rows: None, + frozen_cols: None, + merged_cells: None, + filters: None, + hidden_rows: None, + validations: None, + conditional_formats: None, + charts: None, + }); + } + + if worksheets.is_empty() { + return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Spreadsheet has no sheets" })))); + } + + Ok(worksheets) +} + pub async fn handle_save_sheet( State(state): State>, Json(req): Json, @@ -1967,6 +2145,12 @@ pub async fn handle_export_sheet( let csv = export_to_csv(&sheet); Ok(([(axum::http::header::CONTENT_TYPE, "text/csv")], csv)) } + "xlsx" => { + let xlsx = export_to_xlsx(&sheet).map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e }))) + })?; + Ok(([(axum::http::header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")], xlsx)) + } "json" => { let json = serde_json::to_string_pretty(&sheet).unwrap_or_default(); Ok(([(axum::http::header::CONTENT_TYPE, "application/json")], json)) @@ -1975,6 +2159,125 @@ pub async fn handle_export_sheet( } } +fn export_to_xlsx(sheet: &Spreadsheet) -> Result { + let mut workbook = Workbook::new(); + + for ws in &sheet.worksheets { + let worksheet = workbook.add_worksheet(); + worksheet.set_name(&ws.name).map_err(|e| e.to_string())?; + + let mut max_row: u32 = 0; + let mut max_col: u16 = 0; + + for key in ws.data.keys() { + let parts: Vec<&str> = key.split(',').collect(); + if parts.len() == 2 { + if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) { + max_row = max_row.max(row); + max_col = max_col.max(col); + } + } + } + + for (key, cell) in &ws.data { + let parts: Vec<&str> = key.split(',').collect(); + if parts.len() != 2 { + continue; + } + let (row, col) = match (parts[0].parse::(), parts[1].parse::()) { + (Ok(r), Ok(c)) => (r, c), + _ => continue, + }; + + let value = cell.value.as_deref().unwrap_or(""); + + let mut format = Format::new(); + + if let Some(ref style) = cell.style { + if let Some(ref bg) = style.background { + if let Some(color) = parse_color(bg) { + format = format.set_background_color(color); + } + } + if let Some(ref fg) = style.color { + if let Some(color) = parse_color(fg) { + format = format.set_font_color(color); + } + } + if let Some(ref weight) = style.font_weight { + if weight == "bold" { + format = format.set_bold(); + } + } + if let Some(ref style_val) = style.font_style { + if style_val == "italic" { + format = format.set_italic(); + } + } + if let Some(ref align) = style.text_align { + format = match align.as_str() { + "center" => format.set_align(FormatAlign::Center), + "right" => format.set_align(FormatAlign::Right), + _ => format.set_align(FormatAlign::Left), + }; + } + if let Some(ref size) = style.font_size { + format = format.set_font_size(*size as f64); + } + } + + if let Some(ref formula) = cell.formula { + worksheet.write_formula_with_format(row, col, formula, &format) + .map_err(|e| e.to_string())?; + } else if let Ok(num) = value.parse::() { + worksheet.write_number_with_format(row, col, num, &format) + .map_err(|e| e.to_string())?; + } else { + worksheet.write_string_with_format(row, col, value, &format) + .map_err(|e| e.to_string())?; + } + } + + if let Some(ref widths) = ws.column_widths { + for (col_str, width) in widths { + if let Ok(col) = col_str.parse::() { + worksheet.set_column_width(col, *width).map_err(|e| e.to_string())?; + } + } + } + + if let Some(ref heights) = ws.row_heights { + for (row_str, height) in heights { + if let Ok(row) = row_str.parse::() { + worksheet.set_row_height(row, *height).map_err(|e| e.to_string())?; + } + } + } + + if let Some(frozen_rows) = ws.frozen_rows { + if let Some(frozen_cols) = ws.frozen_cols { + worksheet.set_freeze_panes(frozen_rows, frozen_cols as u16) + .map_err(|e| e.to_string())?; + } + } + } + + let buffer = workbook.save_to_buffer().map_err(|e| e.to_string())?; + Ok(base64::engine::general_purpose::STANDARD.encode(&buffer)) +} + +fn parse_color(color_str: &str) -> Option { + let hex = color_str.trim_start_matches('#'); + if hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::RGB(((r as u32) << 16) | ((g as u32) << 8) | (b as u32))) + } else { + None + } +} + fn export_to_csv(sheet: &Spreadsheet) -> String { let mut csv = String::new(); if let Some(worksheet) = sheet.worksheets.first() { diff --git a/src/slides/mod.rs b/src/slides/mod.rs index 35fe107ce..9659f1068 100644 --- a/src/slides/mod.rs +++ b/src/slides/mod.rs @@ -12,6 +12,7 @@ use axum::{ use chrono::{DateTime, Utc}; use futures_util::{SinkExt, StreamExt}; use log::{error, info}; +use ppt_rs::{Pptx, Slide as PptSlide, TextBox, Shape, ShapeType}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -1159,10 +1160,89 @@ pub async fn handle_export_presentation( let html = export_to_html(&presentation); Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], html)) } + "pptx" => { + match export_to_pptx(&presentation) { + Ok(bytes) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(([(axum::http::header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.presentationml.presentation")], encoded)) + } + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e })))), + } + } _ => Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Unsupported format" })))), } } +fn export_to_pptx(presentation: &Presentation) -> Result, String> { + let mut pptx = Pptx::new(); + + for slide in &presentation.slides { + let mut ppt_slide = PptSlide::new(); + + for element in &slide.elements { + match element.element_type.as_str() { + "text" => { + let content = element.content.as_deref().unwrap_or(""); + let x = element.x as f64; + let y = element.y as f64; + let width = element.width as f64; + let height = element.height as f64; + + let mut text_box = TextBox::new(content) + .position(x, y) + .size(width, height); + + if let Some(ref style) = element.style { + if let Some(size) = style.font_size { + text_box = text_box.font_size(size as f64); + } + if let Some(ref weight) = style.font_weight { + if weight == "bold" { + text_box = text_box.bold(true); + } + } + if let Some(ref color) = style.color { + text_box = text_box.font_color(color); + } + } + + ppt_slide = ppt_slide.add_text_box(text_box); + } + "shape" => { + let shape_type = element.shape_type.as_deref().unwrap_or("rectangle"); + let x = element.x as f64; + let y = element.y as f64; + let width = element.width as f64; + let height = element.height as f64; + + let ppt_shape_type = match shape_type { + "ellipse" | "circle" => ShapeType::Ellipse, + "triangle" => ShapeType::Triangle, + _ => ShapeType::Rectangle, + }; + + let mut shape = Shape::new(ppt_shape_type) + .position(x, y) + .size(width, height); + + if let Some(ref style) = element.style { + if let Some(ref fill) = style.background { + shape = shape.fill_color(fill); + } + } + + ppt_slide = ppt_slide.add_shape(shape); + } + _ => {} + } + } + + pptx = pptx.add_slide(ppt_slide); + } + + pptx.save_to_buffer().map_err(|e| format!("Failed to generate PPTX: {}", e)) +} + fn export_to_html(presentation: &Presentation) -> String { let mut html = format!( r#"