From 807370e1948b201ae9884607259619719bbdc682 Mon Sep 17 00:00:00 2001 From: Jan Wiebe Date: Sat, 21 Feb 2026 13:33:18 +0100 Subject: [PATCH 1/2] style: apply cargo fmt formatting to existing codebase Run `cargo fmt` across all Rust source files to enforce consistent formatting (import ordering, line wrapping, match arm braces). Co-Authored-By: Claude Opus 4.6 --- src/application/dtos/display_helpers.rs | 952 +++++++++--------- src/application/dtos/favorites_dto.rs | 11 +- src/application/dtos/file_dto.rs | 5 +- src/application/dtos/folder_dto.rs | 1 - src/application/dtos/recent_dto.rs | 11 +- src/application/ports/favorites_ports.rs | 6 +- src/application/ports/inbound.rs | 21 +- .../services/auth_application_service.rs | 9 +- src/application/services/batch_operations.rs | 24 +- src/application/services/favorites_service.rs | 4 +- src/application/services/folder_service.rs | 38 +- src/application/services/search_service.rs | 108 +- src/common/stubs.rs | 17 +- src/domain/entities/folder.rs | 10 +- src/domain/repositories/folder_repository.rs | 6 +- .../pg/favorites_pg_repository.rs | 43 +- .../pg/file_blob_read_repository.rs | 68 +- .../pg/file_blob_write_repository.rs | 33 +- .../repositories/pg/folder_db_repository.rs | 191 ++-- .../pg/recent_items_pg_repository.rs | 35 +- .../api/handlers/favorites_handler.rs | 7 +- src/interfaces/api/handlers/folder_handler.rs | 21 +- src/interfaces/api/handlers/webdav_handler.rs | 12 +- 23 files changed, 902 insertions(+), 731 deletions(-) diff --git a/src/application/dtos/display_helpers.rs b/src/application/dtos/display_helpers.rs index 9062ee5f..9f562bfb 100644 --- a/src/application/dtos/display_helpers.rs +++ b/src/application/dtos/display_helpers.rs @@ -1,457 +1,495 @@ -/// Shared display helpers for DTOs. -/// -/// These functions centralise the mime→icon / mime→category / size→human-string -/// logic so that every API response carries pre-computed display fields and the -/// frontend does **not** need to duplicate these mappings. -/// -/// The approach is: try MIME first (specific matches beat prefix matches), -/// then fall back to the file extension when the MIME is generic -/// (`application/octet-stream` or empty). - -// ─── Private: extract lowercase extension from a filename ──────────── - -fn ext_of(name: &str) -> Option<&str> { - let name = name.rsplit('/').next().unwrap_or(name); // strip path - let after_dot = name.rsplit('.').next()?; - // Reject the whole name (no dot) or empty after dot - if after_dot.len() == name.len() || after_dot.is_empty() { - return None; - } - Some(after_dot) -} - -// ─── Icon class (FontAwesome) ──────────────────────────────────────── - -/// Returns the FontAwesome icon class for a file, considering both MIME -/// and filename extension as fallback. -/// -/// Use this instead of the old `mime_to_icon_class` whenever the filename -/// is available. -pub fn icon_class_for(name: &str, mime: &str) -> &'static str { - // 1. Try specific MIME matches first - match mime { - "application/pdf" => return "fas fa-file-pdf", - // MS Office & OpenDocument – Word - "application/msword" - | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - | "application/vnd.oasis.opendocument.text" => return "fas fa-file-word", - // Excel - "application/vnd.ms-excel" - | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - | "application/vnd.oasis.opendocument.spreadsheet" - | "text/csv" => return "fas fa-file-excel", - // PowerPoint - "application/vnd.ms-powerpoint" - | "application/vnd.openxmlformats-officedocument.presentationml.presentation" - | "application/vnd.oasis.opendocument.presentation" => return "fas fa-file-powerpoint", - // Archives - "application/zip" - | "application/x-rar-compressed" - | "application/vnd.rar" - | "application/x-7z-compressed" - | "application/gzip" - | "application/x-gzip" - | "application/x-tar" - | "application/x-bzip2" - | "application/x-xz" - | "application/x-compress" => return "fas fa-file-archive", - // JSON / JavaScript / code transported as application/* - "application/json" - | "application/ld+json" - | "application/javascript" - | "application/typescript" - | "application/x-httpd-php" - | "application/xml" - | "application/xhtml+xml" - | "application/sql" - | "application/x-yaml" - | "application/toml" - | "application/x-sh" - | "application/x-shellscript" - | "application/x-csh" => return "fas fa-file-code", - // Installers / disk images - "application/x-apple-diskimage" - | "application/x-ms-dos-executable" - | "application/x-msdownload" - | "application/x-msi" - | "application/vnd.debian.binary-package" - | "application/x-rpm" - | "application/vnd.appimage" => return "fas fa-hdd", - _ => {} - } - - // 2. MIME prefix matches - if mime.starts_with("image/") { - return "fas fa-file-image"; - } else if mime.starts_with("video/") { - return "fas fa-file-video"; - } else if mime.starts_with("audio/") { - return "fas fa-file-audio"; - } else if mime.starts_with("text/x-script") - || mime.starts_with("text/x-python") - || mime.starts_with("text/x-java") - || mime.starts_with("text/x-c") - || mime.starts_with("text/x-rust") - || mime.starts_with("text/x-go") - || mime.starts_with("text/x-ruby") - || mime.starts_with("text/x-shellscript") - || mime.starts_with("text/x-php") - || mime.contains("javascript") - || mime.contains("typescript") - { - return "fas fa-file-code"; - } else if mime.starts_with("text/markdown") { - return "fas fa-file-alt"; - } else if mime.starts_with("text/") { - return "fas fa-file-alt"; - } - - // 3. Extension-based fallback (for application/octet-stream, empty, etc.) - if let Some(ext) = ext_of(name) { - return match ext.to_ascii_lowercase().as_str() { - "pdf" => "fas fa-file-pdf", - "doc" | "docx" | "odt" | "rtf" => "fas fa-file-word", - "xls" | "xlsx" | "ods" | "csv" => "fas fa-file-excel", - "ppt" | "pptx" | "odp" | "key" => "fas fa-file-powerpoint", - "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" | "heic" | "heif" | "avif" => "fas fa-file-image", - "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "fas fa-file-video", - "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "fas fa-file-audio", - "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "fas fa-file-archive", - "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => "fas fa-hdd", - "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" - | "py" | "pyw" | "rs" | "go" | "java" | "kt" | "kts" | "scala" - | "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" | "cs" - | "rb" | "php" | "swift" | "r" | "lua" | "pl" | "pm" - | "html" | "htm" | "css" | "scss" | "sass" | "less" - | "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" - | "sql" | "graphql" | "proto" | "vue" | "svelte" => "fas fa-file-code", - "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-terminal", - "md" | "markdown" | "rst" | "txt" => "fas fa-file-alt", - _ => "fas fa-file", - }; - } - - "fas fa-file" -} - -// ─── Icon special class (CSS styling) ──────────────────────────────── - -/// Returns the CSS class for styling the icon container, considering both -/// MIME and filename extension. -/// -/// The returned class maps to CSS rules in `style.css` that set colours, -/// backgrounds and decorative pseudo-elements per file type. -pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str { - // 1. Specific MIME matches - match mime { - "application/pdf" => return "pdf-icon", - "application/msword" - | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - | "application/vnd.oasis.opendocument.text" => return "doc-icon", - "application/vnd.ms-excel" - | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - | "application/vnd.oasis.opendocument.spreadsheet" - | "text/csv" => return "spreadsheet-icon", - "application/vnd.ms-powerpoint" - | "application/vnd.openxmlformats-officedocument.presentationml.presentation" - | "application/vnd.oasis.opendocument.presentation" => return "presentation-icon", - "application/zip" - | "application/x-rar-compressed" - | "application/vnd.rar" - | "application/x-7z-compressed" - | "application/gzip" - | "application/x-gzip" - | "application/x-tar" - | "application/x-bzip2" - | "application/x-xz" - | "application/x-compress" => return "archive-icon", - "application/json" | "application/ld+json" => return "code-icon json-icon", - "application/javascript" => return "code-icon js-icon", - "application/typescript" => return "code-icon ts-icon", - "application/xml" | "application/xhtml+xml" => return "code-icon html-icon", - "application/sql" => return "code-icon sql-icon", - "application/x-yaml" | "application/toml" => return "code-icon config-icon", - "application/x-httpd-php" => return "code-icon php-icon", - "application/x-sh" | "application/x-shellscript" | "application/x-csh" => { - return "script-icon" - } - "application/x-apple-diskimage" - | "application/x-ms-dos-executable" - | "application/x-msdownload" - | "application/x-msi" - | "application/vnd.debian.binary-package" - | "application/x-rpm" - | "application/vnd.appimage" => return "installer-icon", - _ => {} - } - - // 2. MIME prefix matches - if mime.starts_with("image/") { - return "image-icon"; - } else if mime.starts_with("video/") { - return "video-icon"; - } else if mime.starts_with("audio/") { - return "audio-icon"; - } else if mime.starts_with("text/x-python") { - return "code-icon py-icon"; - } else if mime.starts_with("text/x-rust") { - return "code-icon rust-icon"; - } else if mime.starts_with("text/x-java") || mime.starts_with("text/x-c") { - return "code-icon"; - } else if mime.starts_with("text/x-go") { - return "code-icon go-icon"; - } else if mime.starts_with("text/x-ruby") { - return "code-icon ruby-icon"; - } else if mime.starts_with("text/x-shellscript") { - return "script-icon"; - } else if mime.starts_with("text/x-script") || mime.starts_with("text/x-php") { - return "code-icon"; - } else if mime.starts_with("text/markdown") { - return "code-icon md-icon"; - } else if mime.starts_with("text/html") { - return "code-icon html-icon"; - } else if mime.starts_with("text/css") { - return "code-icon css-icon"; - } else if mime.contains("javascript") { - return "code-icon js-icon"; - } else if mime.contains("typescript") { - return "code-icon ts-icon"; - } else if mime.starts_with("text/") { - return "doc-icon"; - } - - // 3. Extension-based fallback - if let Some(ext) = ext_of(name) { - return match ext.to_ascii_lowercase().as_str() { - "pdf" => "pdf-icon", - "doc" | "docx" | "odt" | "rtf" => "doc-icon", - "xls" | "xlsx" | "ods" | "csv" => "spreadsheet-icon", - "ppt" | "pptx" | "odp" | "key" => "presentation-icon", - "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" | "heic" | "heif" | "avif" => "image-icon", - "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "video-icon", - "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "audio-icon", - "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "archive-icon", - "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => "installer-icon", - "py" | "pyw" => "code-icon py-icon", - "rs" => "code-icon rust-icon", - "go" => "code-icon go-icon", - "java" | "kt" | "kts" | "scala" => "code-icon java-icon", - "js" | "jsx" | "mjs" | "cjs" => "code-icon js-icon", - "ts" | "tsx" => "code-icon ts-icon", - "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" => "code-icon c-icon", - "cs" => "code-icon cs-icon", - "rb" => "code-icon ruby-icon", - "php" => "code-icon php-icon", - "swift" => "code-icon swift-icon", - "r" | "lua" | "pl" | "pm" => "code-icon", - "html" | "htm" => "code-icon html-icon", - "css" | "scss" | "sass" | "less" => "code-icon css-icon", - "json" => "code-icon json-icon", - "xml" => "code-icon html-icon", - "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => "code-icon config-icon", - "sql" | "graphql" | "proto" => "code-icon sql-icon", - "vue" | "svelte" => "code-icon js-icon", - "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "script-icon", - "md" | "markdown" | "rst" => "code-icon md-icon", - "txt" => "doc-icon", - _ => "", - }; - } - - "" -} - -// ─── Category label ────────────────────────────────────────────────── - -/// Returns a human-readable category label, considering MIME + extension. -pub fn category_for(name: &str, mime: &str) -> &'static str { - // 1. Specific MIME matches - match mime { - "application/pdf" => return "PDF", - "application/msword" - | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - | "application/vnd.oasis.opendocument.text" => return "Document", - "application/vnd.ms-excel" - | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - | "application/vnd.oasis.opendocument.spreadsheet" - | "text/csv" => return "Spreadsheet", - "application/vnd.ms-powerpoint" - | "application/vnd.openxmlformats-officedocument.presentationml.presentation" - | "application/vnd.oasis.opendocument.presentation" => return "Presentation", - "application/zip" - | "application/x-rar-compressed" - | "application/vnd.rar" - | "application/x-7z-compressed" - | "application/gzip" - | "application/x-tar" => return "Archive", - "application/json" - | "application/javascript" - | "application/typescript" - | "application/xml" - | "application/sql" - | "application/x-sh" - | "application/x-shellscript" => return "Code", - "application/x-apple-diskimage" - | "application/x-ms-dos-executable" - | "application/x-msdownload" - | "application/x-msi" => return "Installer", - _ => {} - } - - // 2. MIME prefix - if mime.starts_with("image/") { - return "Image"; - } else if mime.starts_with("video/") { - return "Video"; - } else if mime.starts_with("audio/") { - return "Audio"; - } else if mime.starts_with("text/x-") || mime.contains("script") || mime.contains("javascript") { - return "Code"; - } else if mime.starts_with("text/markdown") { - return "Markdown"; - } else if mime.starts_with("text/html") || mime.starts_with("text/css") { - return "Code"; - } else if mime.starts_with("text/") { - return "Text"; - } - - // 3. Extension fallback - if let Some(ext) = ext_of(name) { - return match ext.to_ascii_lowercase().as_str() { - "pdf" => "PDF", - "doc" | "docx" | "odt" | "rtf" | "txt" => "Document", - "xls" | "xlsx" | "ods" | "csv" => "Spreadsheet", - "ppt" | "pptx" | "odp" | "key" => "Presentation", - "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "heic" | "avif" => "Image", - "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "Video", - "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "Audio", - "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "Archive", - "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" => "Installer", - "js" | "jsx" | "ts" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs" - | "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" - | "html" | "htm" | "css" | "scss" | "json" | "xml" | "yaml" | "yml" - | "toml" | "sql" | "sh" | "bash" | "bat" | "ps1" | "vue" | "svelte" => "Code", - "md" | "markdown" | "rst" => "Markdown", - _ => "Document", - }; - } - - "Document" -} - -/// Formats a byte count into a human-readable string (1024-based). -/// -/// Matches the JavaScript `formatFileSize()` output exactly so the frontend -/// does not need its own per-file formatting. -/// -/// Examples: `"0 Bytes"`, `"1.5 KB"`, `"3.27 MB"`. -pub fn format_file_size(bytes: u64) -> String { - if bytes == 0 { - return "0 Bytes".to_string(); - } - - const K: f64 = 1024.0; - const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"]; - - let i = ((bytes as f64).ln() / K.ln()).floor() as usize; - let i = i.min(SIZES.len() - 1); - - let value = bytes as f64 / K.powi(i as i32); - - // Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour) - let formatted = format!("{:.2}", value); - let formatted = formatted - .trim_end_matches('0') - .trim_end_matches('.'); - - format!("{} {}", formatted, SIZES[i]) -} - -/// Formats a byte count for quota display. When bytes is 0, returns "∞" (unlimited). -/// -/// Matches the JavaScript `formatQuotaSize()` output. -pub fn format_quota_size(bytes: u64) -> String { - if bytes == 0 { - return "∞".to_string(); - } - format_file_size(bytes) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_file_size() { - assert_eq!(format_file_size(0), "0 Bytes"); - assert_eq!(format_file_size(500), "500 Bytes"); - assert_eq!(format_file_size(1024), "1 KB"); - assert_eq!(format_file_size(1536), "1.5 KB"); - assert_eq!(format_file_size(1_048_576), "1 MB"); - assert_eq!(format_file_size(3_423_744), "3.27 MB"); - assert_eq!(format_file_size(1_073_741_824), "1 GB"); - } - - #[test] - fn test_format_quota_size() { - // Unlimited quota (0) should show infinity symbol - assert_eq!(format_quota_size(0), "∞"); - // Non-zero values should format normally - assert_eq!(format_quota_size(500), "500 Bytes"); - assert_eq!(format_quota_size(1_073_741_824), "1 GB"); - } - - #[test] - fn test_icon_class_for_with_extension_fallback() { - // Specific MIME types - assert_eq!(icon_class_for("doc.pdf", "application/pdf"), "fas fa-file-pdf"); - assert_eq!(icon_class_for("file.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), "fas fa-file-word"); - - // Extension fallback when MIME is generic - assert_eq!(icon_class_for("script.py", "application/octet-stream"), "fas fa-file-code"); - assert_eq!(icon_class_for("app.dmg", "application/octet-stream"), "fas fa-hdd"); - assert_eq!(icon_class_for("archive.zip", "application/octet-stream"), "fas fa-file-archive"); - assert_eq!(icon_class_for("data.xlsx", "application/octet-stream"), "fas fa-file-excel"); - assert_eq!(icon_class_for("run.sh", "application/octet-stream"), "fas fa-terminal"); - } - - #[test] - fn test_icon_special_class_for() { - // MIME-based - assert_eq!(icon_special_class_for("", "image/png"), "image-icon"); - assert_eq!(icon_special_class_for("", "application/pdf"), "pdf-icon"); - assert_eq!(icon_special_class_for("", "application/json"), "code-icon json-icon"); - - // Extension-based fallback - assert_eq!(icon_special_class_for("main.py", "application/octet-stream"), "code-icon py-icon"); - assert_eq!(icon_special_class_for("lib.rs", "application/octet-stream"), "code-icon rust-icon"); - assert_eq!(icon_special_class_for("style.css", "application/octet-stream"), "code-icon css-icon"); - assert_eq!(icon_special_class_for("data.xlsx", "application/octet-stream"), "spreadsheet-icon"); - assert_eq!(icon_special_class_for("backup.tar", "application/octet-stream"), "archive-icon"); - assert_eq!(icon_special_class_for("setup.dmg", "application/octet-stream"), "installer-icon"); - } - - #[test] - fn test_category_for() { - // MIME-based - assert_eq!(category_for("", "image/jpeg"), "Image"); - assert_eq!(category_for("", "video/webm"), "Video"); - assert_eq!(category_for("", "audio/ogg"), "Audio"); - assert_eq!(category_for("", "application/pdf"), "PDF"); - assert_eq!(category_for("", "application/zip"), "Archive"); - - // Extension-based fallback - assert_eq!(category_for("main.rs", "application/octet-stream"), "Code"); - assert_eq!(category_for("photo.jpg", "application/octet-stream"), "Image"); - assert_eq!(category_for("notes.md", "application/octet-stream"), "Markdown"); - } - - #[test] - fn test_ext_of() { - assert_eq!(ext_of("file.txt"), Some("txt")); - assert_eq!(ext_of("archive.tar.gz"), Some("gz")); - assert_eq!(ext_of("no_extension"), None); - assert_eq!(ext_of(".gitignore"), Some("gitignore")); // dot file treated as having extension - assert_eq!(ext_of("path/to/file.rs"), Some("rs")); - } -} +/// Shared display helpers for DTOs. +/// +/// These functions centralise the mime→icon / mime→category / size→human-string +/// logic so that every API response carries pre-computed display fields and the +/// frontend does **not** need to duplicate these mappings. +/// +/// The approach is: try MIME first (specific matches beat prefix matches), +/// then fall back to the file extension when the MIME is generic +/// (`application/octet-stream` or empty). + +// ─── Private: extract lowercase extension from a filename ──────────── + +fn ext_of(name: &str) -> Option<&str> { + let name = name.rsplit('/').next().unwrap_or(name); // strip path + let after_dot = name.rsplit('.').next()?; + // Reject the whole name (no dot) or empty after dot + if after_dot.len() == name.len() || after_dot.is_empty() { + return None; + } + Some(after_dot) +} + +// ─── Icon class (FontAwesome) ──────────────────────────────────────── + +/// Returns the FontAwesome icon class for a file, considering both MIME +/// and filename extension as fallback. +/// +/// Use this instead of the old `mime_to_icon_class` whenever the filename +/// is available. +pub fn icon_class_for(name: &str, mime: &str) -> &'static str { + // 1. Try specific MIME matches first + match mime { + "application/pdf" => return "fas fa-file-pdf", + // MS Office & OpenDocument – Word + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.oasis.opendocument.text" => return "fas fa-file-word", + // Excel + "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.oasis.opendocument.spreadsheet" + | "text/csv" => return "fas fa-file-excel", + // PowerPoint + "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/vnd.oasis.opendocument.presentation" => return "fas fa-file-powerpoint", + // Archives + "application/zip" + | "application/x-rar-compressed" + | "application/vnd.rar" + | "application/x-7z-compressed" + | "application/gzip" + | "application/x-gzip" + | "application/x-tar" + | "application/x-bzip2" + | "application/x-xz" + | "application/x-compress" => return "fas fa-file-archive", + // JSON / JavaScript / code transported as application/* + "application/json" + | "application/ld+json" + | "application/javascript" + | "application/typescript" + | "application/x-httpd-php" + | "application/xml" + | "application/xhtml+xml" + | "application/sql" + | "application/x-yaml" + | "application/toml" + | "application/x-sh" + | "application/x-shellscript" + | "application/x-csh" => return "fas fa-file-code", + // Installers / disk images + "application/x-apple-diskimage" + | "application/x-ms-dos-executable" + | "application/x-msdownload" + | "application/x-msi" + | "application/vnd.debian.binary-package" + | "application/x-rpm" + | "application/vnd.appimage" => return "fas fa-hdd", + _ => {} + } + + // 2. MIME prefix matches + if mime.starts_with("image/") { + return "fas fa-file-image"; + } else if mime.starts_with("video/") { + return "fas fa-file-video"; + } else if mime.starts_with("audio/") { + return "fas fa-file-audio"; + } else if mime.starts_with("text/x-script") + || mime.starts_with("text/x-python") + || mime.starts_with("text/x-java") + || mime.starts_with("text/x-c") + || mime.starts_with("text/x-rust") + || mime.starts_with("text/x-go") + || mime.starts_with("text/x-ruby") + || mime.starts_with("text/x-shellscript") + || mime.starts_with("text/x-php") + || mime.contains("javascript") + || mime.contains("typescript") + { + return "fas fa-file-code"; + } else if mime.starts_with("text/markdown") { + return "fas fa-file-alt"; + } else if mime.starts_with("text/") { + return "fas fa-file-alt"; + } + + // 3. Extension-based fallback (for application/octet-stream, empty, etc.) + if let Some(ext) = ext_of(name) { + return match ext.to_ascii_lowercase().as_str() { + "pdf" => "fas fa-file-pdf", + "doc" | "docx" | "odt" | "rtf" => "fas fa-file-word", + "xls" | "xlsx" | "ods" | "csv" => "fas fa-file-excel", + "ppt" | "pptx" | "odp" | "key" => "fas fa-file-powerpoint", + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" + | "heic" | "heif" | "avif" => "fas fa-file-image", + "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "fas fa-file-video", + "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "fas fa-file-audio", + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => { + "fas fa-file-archive" + } + "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => { + "fas fa-hdd" + } + "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "py" | "pyw" | "rs" | "go" | "java" + | "kt" | "kts" | "scala" | "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" | "cs" | "rb" + | "php" | "swift" | "r" | "lua" | "pl" | "pm" | "html" | "htm" | "css" | "scss" + | "sass" | "less" | "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" + | "conf" | "sql" | "graphql" | "proto" | "vue" | "svelte" => "fas fa-file-code", + "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-terminal", + "md" | "markdown" | "rst" | "txt" => "fas fa-file-alt", + _ => "fas fa-file", + }; + } + + "fas fa-file" +} + +// ─── Icon special class (CSS styling) ──────────────────────────────── + +/// Returns the CSS class for styling the icon container, considering both +/// MIME and filename extension. +/// +/// The returned class maps to CSS rules in `style.css` that set colours, +/// backgrounds and decorative pseudo-elements per file type. +pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str { + // 1. Specific MIME matches + match mime { + "application/pdf" => return "pdf-icon", + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.oasis.opendocument.text" => return "doc-icon", + "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.oasis.opendocument.spreadsheet" + | "text/csv" => return "spreadsheet-icon", + "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/vnd.oasis.opendocument.presentation" => return "presentation-icon", + "application/zip" + | "application/x-rar-compressed" + | "application/vnd.rar" + | "application/x-7z-compressed" + | "application/gzip" + | "application/x-gzip" + | "application/x-tar" + | "application/x-bzip2" + | "application/x-xz" + | "application/x-compress" => return "archive-icon", + "application/json" | "application/ld+json" => return "code-icon json-icon", + "application/javascript" => return "code-icon js-icon", + "application/typescript" => return "code-icon ts-icon", + "application/xml" | "application/xhtml+xml" => return "code-icon html-icon", + "application/sql" => return "code-icon sql-icon", + "application/x-yaml" | "application/toml" => return "code-icon config-icon", + "application/x-httpd-php" => return "code-icon php-icon", + "application/x-sh" | "application/x-shellscript" | "application/x-csh" => { + return "script-icon"; + } + "application/x-apple-diskimage" + | "application/x-ms-dos-executable" + | "application/x-msdownload" + | "application/x-msi" + | "application/vnd.debian.binary-package" + | "application/x-rpm" + | "application/vnd.appimage" => return "installer-icon", + _ => {} + } + + // 2. MIME prefix matches + if mime.starts_with("image/") { + return "image-icon"; + } else if mime.starts_with("video/") { + return "video-icon"; + } else if mime.starts_with("audio/") { + return "audio-icon"; + } else if mime.starts_with("text/x-python") { + return "code-icon py-icon"; + } else if mime.starts_with("text/x-rust") { + return "code-icon rust-icon"; + } else if mime.starts_with("text/x-java") || mime.starts_with("text/x-c") { + return "code-icon"; + } else if mime.starts_with("text/x-go") { + return "code-icon go-icon"; + } else if mime.starts_with("text/x-ruby") { + return "code-icon ruby-icon"; + } else if mime.starts_with("text/x-shellscript") { + return "script-icon"; + } else if mime.starts_with("text/x-script") || mime.starts_with("text/x-php") { + return "code-icon"; + } else if mime.starts_with("text/markdown") { + return "code-icon md-icon"; + } else if mime.starts_with("text/html") { + return "code-icon html-icon"; + } else if mime.starts_with("text/css") { + return "code-icon css-icon"; + } else if mime.contains("javascript") { + return "code-icon js-icon"; + } else if mime.contains("typescript") { + return "code-icon ts-icon"; + } else if mime.starts_with("text/") { + return "doc-icon"; + } + + // 3. Extension-based fallback + if let Some(ext) = ext_of(name) { + return match ext.to_ascii_lowercase().as_str() { + "pdf" => "pdf-icon", + "doc" | "docx" | "odt" | "rtf" => "doc-icon", + "xls" | "xlsx" | "ods" | "csv" => "spreadsheet-icon", + "ppt" | "pptx" | "odp" | "key" => "presentation-icon", + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" + | "heic" | "heif" | "avif" => "image-icon", + "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "video-icon", + "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "audio-icon", + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "archive-icon", + "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => { + "installer-icon" + } + "py" | "pyw" => "code-icon py-icon", + "rs" => "code-icon rust-icon", + "go" => "code-icon go-icon", + "java" | "kt" | "kts" | "scala" => "code-icon java-icon", + "js" | "jsx" | "mjs" | "cjs" => "code-icon js-icon", + "ts" | "tsx" => "code-icon ts-icon", + "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" => "code-icon c-icon", + "cs" => "code-icon cs-icon", + "rb" => "code-icon ruby-icon", + "php" => "code-icon php-icon", + "swift" => "code-icon swift-icon", + "r" | "lua" | "pl" | "pm" => "code-icon", + "html" | "htm" => "code-icon html-icon", + "css" | "scss" | "sass" | "less" => "code-icon css-icon", + "json" => "code-icon json-icon", + "xml" => "code-icon html-icon", + "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => "code-icon config-icon", + "sql" | "graphql" | "proto" => "code-icon sql-icon", + "vue" | "svelte" => "code-icon js-icon", + "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "script-icon", + "md" | "markdown" | "rst" => "code-icon md-icon", + "txt" => "doc-icon", + _ => "", + }; + } + + "" +} + +// ─── Category label ────────────────────────────────────────────────── + +/// Returns a human-readable category label, considering MIME + extension. +pub fn category_for(name: &str, mime: &str) -> &'static str { + // 1. Specific MIME matches + match mime { + "application/pdf" => return "PDF", + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.oasis.opendocument.text" => return "Document", + "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.oasis.opendocument.spreadsheet" + | "text/csv" => return "Spreadsheet", + "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/vnd.oasis.opendocument.presentation" => return "Presentation", + "application/zip" + | "application/x-rar-compressed" + | "application/vnd.rar" + | "application/x-7z-compressed" + | "application/gzip" + | "application/x-tar" => return "Archive", + "application/json" + | "application/javascript" + | "application/typescript" + | "application/xml" + | "application/sql" + | "application/x-sh" + | "application/x-shellscript" => return "Code", + "application/x-apple-diskimage" + | "application/x-ms-dos-executable" + | "application/x-msdownload" + | "application/x-msi" => return "Installer", + _ => {} + } + + // 2. MIME prefix + if mime.starts_with("image/") { + return "Image"; + } else if mime.starts_with("video/") { + return "Video"; + } else if mime.starts_with("audio/") { + return "Audio"; + } else if mime.starts_with("text/x-") || mime.contains("script") || mime.contains("javascript") + { + return "Code"; + } else if mime.starts_with("text/markdown") { + return "Markdown"; + } else if mime.starts_with("text/html") || mime.starts_with("text/css") { + return "Code"; + } else if mime.starts_with("text/") { + return "Text"; + } + + // 3. Extension fallback + if let Some(ext) = ext_of(name) { + return match ext.to_ascii_lowercase().as_str() { + "pdf" => "PDF", + "doc" | "docx" | "odt" | "rtf" | "txt" => "Document", + "xls" | "xlsx" | "ods" | "csv" => "Spreadsheet", + "ppt" | "pptx" | "odp" | "key" => "Presentation", + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "heic" + | "avif" => "Image", + "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "Video", + "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "Audio", + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "Archive", + "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" => "Installer", + "js" | "jsx" | "ts" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs" + | "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" | "html" | "htm" + | "css" | "scss" | "json" | "xml" | "yaml" | "yml" | "toml" | "sql" | "sh" | "bash" + | "bat" | "ps1" | "vue" | "svelte" => "Code", + "md" | "markdown" | "rst" => "Markdown", + _ => "Document", + }; + } + + "Document" +} + +/// Formats a byte count into a human-readable string (1024-based). +/// +/// Matches the JavaScript `formatFileSize()` output exactly so the frontend +/// does not need its own per-file formatting. +/// +/// Examples: `"0 Bytes"`, `"1.5 KB"`, `"3.27 MB"`. +pub fn format_file_size(bytes: u64) -> String { + if bytes == 0 { + return "0 Bytes".to_string(); + } + + const K: f64 = 1024.0; + const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"]; + + let i = ((bytes as f64).ln() / K.ln()).floor() as usize; + let i = i.min(SIZES.len() - 1); + + let value = bytes as f64 / K.powi(i as i32); + + // Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour) + let formatted = format!("{:.2}", value); + let formatted = formatted.trim_end_matches('0').trim_end_matches('.'); + + format!("{} {}", formatted, SIZES[i]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_file_size() { + assert_eq!(format_file_size(0), "0 Bytes"); + assert_eq!(format_file_size(500), "500 Bytes"); + assert_eq!(format_file_size(1024), "1 KB"); + assert_eq!(format_file_size(1536), "1.5 KB"); + assert_eq!(format_file_size(1_048_576), "1 MB"); + assert_eq!(format_file_size(3_423_744), "3.27 MB"); + assert_eq!(format_file_size(1_073_741_824), "1 GB"); + } + + #[test] + fn test_icon_class_for_with_extension_fallback() { + // Specific MIME types + assert_eq!( + icon_class_for("doc.pdf", "application/pdf"), + "fas fa-file-pdf" + ); + assert_eq!( + icon_class_for( + "file.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ), + "fas fa-file-word" + ); + + // Extension fallback when MIME is generic + assert_eq!( + icon_class_for("script.py", "application/octet-stream"), + "fas fa-file-code" + ); + assert_eq!( + icon_class_for("app.dmg", "application/octet-stream"), + "fas fa-hdd" + ); + assert_eq!( + icon_class_for("archive.zip", "application/octet-stream"), + "fas fa-file-archive" + ); + assert_eq!( + icon_class_for("data.xlsx", "application/octet-stream"), + "fas fa-file-excel" + ); + assert_eq!( + icon_class_for("run.sh", "application/octet-stream"), + "fas fa-terminal" + ); + } + + #[test] + fn test_icon_special_class_for() { + // MIME-based + assert_eq!(icon_special_class_for("", "image/png"), "image-icon"); + assert_eq!(icon_special_class_for("", "application/pdf"), "pdf-icon"); + assert_eq!( + icon_special_class_for("", "application/json"), + "code-icon json-icon" + ); + + // Extension-based fallback + assert_eq!( + icon_special_class_for("main.py", "application/octet-stream"), + "code-icon py-icon" + ); + assert_eq!( + icon_special_class_for("lib.rs", "application/octet-stream"), + "code-icon rust-icon" + ); + assert_eq!( + icon_special_class_for("style.css", "application/octet-stream"), + "code-icon css-icon" + ); + assert_eq!( + icon_special_class_for("data.xlsx", "application/octet-stream"), + "spreadsheet-icon" + ); + assert_eq!( + icon_special_class_for("backup.tar", "application/octet-stream"), + "archive-icon" + ); + assert_eq!( + icon_special_class_for("setup.dmg", "application/octet-stream"), + "installer-icon" + ); + } + + #[test] + fn test_category_for() { + // MIME-based + assert_eq!(category_for("", "image/jpeg"), "Image"); + assert_eq!(category_for("", "video/webm"), "Video"); + assert_eq!(category_for("", "audio/ogg"), "Audio"); + assert_eq!(category_for("", "application/pdf"), "PDF"); + assert_eq!(category_for("", "application/zip"), "Archive"); + + // Extension-based fallback + assert_eq!(category_for("main.rs", "application/octet-stream"), "Code"); + assert_eq!( + category_for("photo.jpg", "application/octet-stream"), + "Image" + ); + assert_eq!( + category_for("notes.md", "application/octet-stream"), + "Markdown" + ); + } + + #[test] + fn test_ext_of() { + assert_eq!(ext_of("file.txt"), Some("txt")); + assert_eq!(ext_of("archive.tar.gz"), Some("gz")); + assert_eq!(ext_of("no_extension"), None); + assert_eq!(ext_of(".gitignore"), Some("gitignore")); // dot file treated as having extension + assert_eq!(ext_of("path/to/file.rs"), Some("rs")); + } +} diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index e7a73e5a..d665bbc0 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -1,7 +1,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for}; +use super::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; /// DTO for favorites item, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. @@ -23,7 +25,6 @@ pub struct FavoriteItemDto { pub created_at: DateTime, // ── Enriched metadata (resolved via JOIN) ── - /// Display name of the file or folder #[serde(skip_serializing_if = "Option::is_none")] pub item_name: Option, @@ -45,7 +46,6 @@ pub struct FavoriteItemDto { pub modified_at: Option>, // ── Pre-computed display fields ── - /// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder") pub icon_class: String, @@ -70,7 +70,10 @@ impl FavoriteItemDto { self.size_formatted = "--".to_string(); } else { let name = self.item_name.as_deref().unwrap_or(""); - let mime = self.item_mime_type.as_deref().unwrap_or("application/octet-stream"); + let mime = self + .item_mime_type + .as_deref() + .unwrap_or("application/octet-stream"); self.icon_class = icon_class_for(name, mime).to_string(); self.icon_special_class = icon_special_class_for(name, mime).to_string(); self.category = category_for(name, mime).to_string(); diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index f1e3a202..ca955c26 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -1,7 +1,9 @@ use crate::domain::entities::file::File; use serde::{Deserialize, Serialize}; -use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for}; +use super::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; /// DTO for file responses #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,7 +33,6 @@ pub struct FileDto { pub modified_at: u64, // ── Pre-computed display fields ── - /// FontAwesome icon CSS class (e.g. "fas fa-file-image") pub icon_class: String, diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 7b67c0de..2c6466d8 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -54,7 +54,6 @@ pub struct FolderDto { pub is_root: bool, // ── Pre-computed display fields ── - /// FontAwesome icon CSS class (always "fas fa-folder") pub icon_class: String, diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 3abf7ddf..4b6f00f4 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -1,7 +1,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for}; +use super::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; /// DTO for recent items, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. @@ -23,7 +25,6 @@ pub struct RecentItemDto { pub accessed_at: DateTime, // ── Enriched metadata (resolved via JOIN) ── - /// Display name of the file or folder #[serde(skip_serializing_if = "Option::is_none")] pub item_name: Option, @@ -41,7 +42,6 @@ pub struct RecentItemDto { pub parent_id: Option, // ── Pre-computed display fields ── - /// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder") pub icon_class: String, @@ -66,7 +66,10 @@ impl RecentItemDto { self.size_formatted = "--".to_string(); } else { let name = self.item_name.as_deref().unwrap_or(""); - let mime = self.item_mime_type.as_deref().unwrap_or("application/octet-stream"); + let mime = self + .item_mime_type + .as_deref() + .unwrap_or("application/octet-stream"); self.icon_class = icon_class_for(name, mime).to_string(); self.icon_special_class = icon_special_class_for(name, mime).to_string(); self.category = category_for(name, mime).to_string(); diff --git a/src/application/ports/favorites_ports.rs b/src/application/ports/favorites_ports.rs index d361834a..cb8d71e6 100644 --- a/src/application/ports/favorites_ports.rs +++ b/src/application/ports/favorites_ports.rs @@ -56,9 +56,5 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static { /// Insert multiple items in a single transaction. /// Returns the number of rows actually inserted (ignoring duplicates). - async fn add_favorites_batch( - &self, - user_id: &str, - items: &[(String, String)], - ) -> Result; + async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result; } diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 0e972258..a8bd53c5 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -47,17 +47,30 @@ pub trait FolderUseCase: Send + Sync + 'static { ) -> Result, DomainError>; /// Renames a folder (ownership verified against caller_id) - async fn rename_folder(&self, id: &str, dto: RenameFolderDto, caller_id: &str) - -> Result; + async fn rename_folder( + &self, + id: &str, + dto: RenameFolderDto, + caller_id: &str, + ) -> Result; /// Moves a folder to another parent (ownership verified against caller_id) - async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result; + async fn move_folder( + &self, + id: &str, + dto: MoveFolderDto, + caller_id: &str, + ) -> Result; /// Deletes a folder (ownership verified against caller_id) async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError>; /// Creates a root-level home folder for a user during registration. - async fn create_home_folder(&self, user_id: &str, name: String) -> Result; + async fn create_home_folder( + &self, + user_id: &str, + name: String, + ) -> Result; } /** diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index d3908d2a..57b585bf 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -317,7 +317,8 @@ impl AuthApplicationService { let created_user = self.user_storage.create_user(user).await?; // Create personal folder for the user - self.create_personal_folder(&dto.username, created_user.id()).await; + self.create_personal_folder(&dto.username, created_user.id()) + .await; tracing::info!("User registered: {}", created_user.id()); Ok(UserDto::from(created_user)) @@ -659,7 +660,8 @@ impl AuthApplicationService { let created_user = self.user_storage.create_user(user).await?; // 5. Create personal folder for the new admin - self.create_personal_folder(&dto.username, created_user.id()).await; + self.create_personal_folder(&dto.username, created_user.id()) + .await; tracing::info!("Custom admin created: {}", created_user.id()); Ok(UserDto::from(created_user)) @@ -765,7 +767,8 @@ impl AuthApplicationService { } // Create personal folder - self.create_personal_folder(&dto.username, created.id()).await; + self.create_personal_folder(&dto.username, created.id()) + .await; tracing::info!("Admin created user: {} ({})", dto.username, created.id()); Ok(UserDto::from(created)) diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 95b55345..5631e284 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -702,22 +702,20 @@ impl BatchOperationService { // Add individual files at the root of the ZIP for file_id in &file_ids { match self.file_retrieval.get_file(file_id).await { - Ok(file_dto) => { - match self.file_retrieval.get_file_content(file_id).await { - Ok(content) => { - if let Err(e) = zip.start_file(&file_dto.name, options) { - info!("Could not start zip entry for {}: {}", file_dto.name, e); - continue; - } - if let Err(e) = zip.write_all(&content) { - info!("Could not write zip entry for {}: {}", file_dto.name, e); - } + Ok(file_dto) => match self.file_retrieval.get_file_content(file_id).await { + Ok(content) => { + if let Err(e) = zip.start_file(&file_dto.name, options) { + info!("Could not start zip entry for {}: {}", file_dto.name, e); + continue; } - Err(e) => { - info!("Could not read file content {}: {}", file_id, e); + if let Err(e) = zip.write_all(&content) { + info!("Could not write zip entry for {}: {}", file_dto.name, e); } } - } + Err(e) => { + info!("Could not read file content {}: {}", file_id, e); + } + }, Err(e) => { info!("Could not get file metadata {}: {}", file_id, e); } diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index 4bab5eb7..d498ee4e 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -1,4 +1,6 @@ -use crate::application::dtos::favorites_dto::{BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto}; +use crate::application::dtos::favorites_dto::{ + BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, +}; use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; use async_trait::async_trait; diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index e4c61da8..3dc4b74e 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -112,7 +112,11 @@ impl FolderService { Ok(()) } - async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result { + async fn create_home_folder( + &self, + _user_id: &str, + _name: String, + ) -> Result { Ok(FolderDto::empty()) } } @@ -159,7 +163,11 @@ impl FolderUseCase for FolderService { } /// Creates a root-level home folder for a user during registration. - async fn create_home_folder(&self, user_id: &str, name: String) -> Result { + async fn create_home_folder( + &self, + user_id: &str, + name: String, + ) -> Result { let folder = self .folder_storage .create_home_folder(user_id, name) @@ -256,12 +264,7 @@ impl FolderUseCase for FolderService { let (folders, total_items) = self .folder_storage - .list_folders_paginated( - parent_id, - pagination.offset(), - pagination.limit(), - true, - ) + .list_folders_paginated(parent_id, pagination.offset(), pagination.limit(), true) .await .map_err(|e| { DomainError::internal_error( @@ -354,7 +357,9 @@ impl FolderUseCase for FolderService { if existing_folder.owner_id() != Some(caller_id) { tracing::warn!( "rename_folder: user '{}' attempted to rename folder '{}' owned by '{:?}'", - caller_id, id, existing_folder.owner_id() + caller_id, + id, + existing_folder.owner_id() ); return Err(DomainError::not_found("Folder", id)); } @@ -412,7 +417,12 @@ impl FolderUseCase for FolderService { } /// Moves a folder to a new parent after verifying ownership. - async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result { + async fn move_folder( + &self, + id: &str, + dto: MoveFolderDto, + caller_id: &str, + ) -> Result { // Verify the source folder exists and belongs to the caller let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| { DomainError::internal_error( @@ -424,7 +434,9 @@ impl FolderUseCase for FolderService { if source_folder.owner_id() != Some(caller_id) { tracing::warn!( "move_folder: user '{}' attempted to move folder '{}' owned by '{:?}'", - caller_id, id, source_folder.owner_id() + caller_id, + id, + source_folder.owner_id() ); return Err(DomainError::not_found("Folder", id)); } @@ -517,7 +529,9 @@ impl FolderUseCase for FolderService { if folder.owner_id() != Some(caller_id) { tracing::warn!( "delete_folder: user '{}' attempted to delete folder '{}' owned by '{:?}'", - caller_id, id, folder.owner_id() + caller_id, + id, + folder.owner_id() ); return Err(DomainError::not_found("Folder", id)); } diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index d87c1f0b..37370c37 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -5,9 +5,11 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use tokio::time; +use crate::application::dtos::display_helpers::{ + category_for, icon_class_for, icon_special_class_for, +}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; -use crate::application::dtos::display_helpers::{icon_class_for, icon_special_class_for, category_for}; use crate::application::dtos::search_dto::{ SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto, SearchSuggestionItem, SearchSuggestionsDto, @@ -287,67 +289,67 @@ impl SearchService { folder_repo: Arc, current_folder_id: Option, criteria: Arc, - ) -> std::pin::Pin, Vec)>> + Send>> { + ) -> std::pin::Pin< + Box, Vec)>> + Send>, + > { Box::pin(async move { - // List files in the current folder - let files = file_repo - .list_files(current_folder_id.as_deref()) - .await?; + // List files in the current folder + let files = file_repo.list_files(current_folder_id.as_deref()).await?; - let filtered_files: Vec = files - .into_iter() - .map(FileDto::from) - .filter(|file| passes_file_filter(file, &criteria)) - .collect(); - - let mut all_files = filtered_files; - let mut all_folders: Vec = Vec::new(); - - // If recursive, process subfolders in parallel - if criteria.recursive { - let folders = folder_repo - .list_folders(current_folder_id.as_deref()) - .await?; - - let folder_dtos: Vec = folders + let filtered_files: Vec = files .into_iter() - .map(FolderDto::from) - .filter(|f| passes_folder_filter(f, &criteria)) + .map(FileDto::from) + .filter(|file| passes_file_filter(file, &criteria)) .collect(); - all_folders.extend(folder_dtos.iter().cloned()); + let mut all_files = filtered_files; + let mut all_folders: Vec = Vec::new(); - // Spawn parallel tasks for each subfolder - let mut handles = Vec::with_capacity(folder_dtos.len()); - for subfolder in &folder_dtos { - let fr = file_repo.clone(); - let fdr = folder_repo.clone(); - let crit = criteria.clone(); - let folder_id = subfolder.id.clone(); + // If recursive, process subfolders in parallel + if criteria.recursive { + let folders = folder_repo + .list_folders(current_folder_id.as_deref()) + .await?; - handles.push(tokio::spawn(async move { - Self::search_parallel(fr, fdr, Some(folder_id), crit).await - })); - } + let folder_dtos: Vec = folders + .into_iter() + .map(FolderDto::from) + .filter(|f| passes_folder_filter(f, &criteria)) + .collect(); - // Collect results from all parallel tasks - for handle in handles { - match handle.await { - Ok(Ok((sub_files, sub_folders))) => { - all_files.extend(sub_files); - all_folders.extend(sub_folders); - } - Ok(Err(e)) => { - tracing::warn!("Parallel search subtask error: {}", e); - } - Err(e) => { - tracing::warn!("Parallel search task join error: {}", e); + all_folders.extend(folder_dtos.iter().cloned()); + + // Spawn parallel tasks for each subfolder + let mut handles = Vec::with_capacity(folder_dtos.len()); + for subfolder in &folder_dtos { + let fr = file_repo.clone(); + let fdr = folder_repo.clone(); + let crit = criteria.clone(); + let folder_id = subfolder.id.clone(); + + handles.push(tokio::spawn(async move { + Self::search_parallel(fr, fdr, Some(folder_id), crit).await + })); + } + + // Collect results from all parallel tasks + for handle in handles { + match handle.await { + Ok(Ok((sub_files, sub_folders))) => { + all_files.extend(sub_files); + all_folders.extend(sub_folders); + } + Ok(Err(e)) => { + tracing::warn!("Parallel search subtask error: {}", e); + } + Err(e) => { + tracing::warn!("Parallel search task join error: {}", e); + } } } } - } - Ok((all_files, all_folders)) + Ok((all_files, all_folders)) }) // end Box::pin } @@ -560,13 +562,11 @@ impl SearchUseCase for SearchService { match criteria.sort_by.as_str() { "name" => { enriched_files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - enriched_folders - .sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + enriched_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); } "name_desc" => { enriched_files.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase())); - enriched_folders - .sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase())); + enriched_folders.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase())); } "date" => { enriched_files.sort_by(|a, b| a.modified_at.cmp(&b.modified_at)); diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 4011150e..8c00846d 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -19,7 +19,9 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::pagination::{PaginatedResponseDto, PaginationRequestDto}; -use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto}; +use crate::application::dtos::search_dto::{ + SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto, +}; use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort}; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, @@ -415,7 +417,12 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn move_folder(&self, _id: &str, _dto: MoveFolderDto, _caller_id: &str) -> Result { + async fn move_folder( + &self, + _id: &str, + _dto: MoveFolderDto, + _caller_id: &str, + ) -> Result { Ok(FolderDto::default()) } @@ -423,7 +430,11 @@ impl FolderUseCase for StubFolderUseCase { Ok(()) } - async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result { + async fn create_home_folder( + &self, + _user_id: &str, + _name: String, + ) -> Result { Ok(FolderDto::default()) } } diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 5f70166b..c719fac8 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -102,7 +102,15 @@ impl Folder { created_at: u64, modified_at: u64, ) -> FolderResult { - Self::with_timestamps_and_owner(id, name, storage_path, parent_id, None, created_at, modified_at) + Self::with_timestamps_and_owner( + id, + name, + storage_path, + parent_id, + None, + created_at, + modified_at, + ) } /// Creates a folder with specific timestamps and owner (for DB reconstruction) diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index ff6e99e3..3f1d962a 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -102,9 +102,5 @@ pub trait FolderRepository: Send + Sync + 'static { /// Creates a root-level home folder for a user. /// This is used during user registration to create the user's personal folder. - async fn create_home_folder( - &self, - user_id: &str, - name: String, - ) -> Result; + async fn create_home_folder(&self, user_id: &str, name: String) -> Result; } diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index d758027c..4b88bf7f 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -60,23 +60,26 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { let favorites = rows .iter() - .map(|row| FavoriteItemDto { - id: row.get("id"), - user_id: row.get("user_id"), - item_id: row.get("item_id"), - item_type: row.get("item_type"), - created_at: row.get("created_at"), - item_name: row.try_get("item_name").ok(), - item_size: row.try_get("item_size").ok(), - item_mime_type: row.try_get("item_mime_type").ok(), - parent_id: row.try_get("parent_id").ok(), - modified_at: row.try_get("modified_at").ok(), - // Temporary defaults; with_display_fields() computes the real values - icon_class: String::new(), - icon_special_class: String::new(), - category: String::new(), - size_formatted: String::new(), - }.with_display_fields()) + .map(|row| { + FavoriteItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + created_at: row.get("created_at"), + item_name: row.try_get("item_name").ok(), + item_size: row.try_get("item_size").ok(), + item_mime_type: row.try_get("item_mime_type").ok(), + parent_id: row.try_get("parent_id").ok(), + modified_at: row.try_get("modified_at").ok(), + // Temporary defaults; with_display_fields() computes the real values + icon_class: String::new(), + icon_special_class: String::new(), + category: String::new(), + size_formatted: String::new(), + } + .with_display_fields() + }) .collect(); Ok(favorites) @@ -163,11 +166,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { Ok(row.try_get("is_favorite").unwrap_or(false)) } - async fn add_favorites_batch( - &self, - user_id: &str, - items: &[(String, String)], - ) -> Result { + async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result { if items.is_empty() { return Ok(0); } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index e973db62..00d956ec 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -140,10 +140,18 @@ impl FileReadPort for FileBlobReadRepository { } async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { - let rows: Vec<(String, String, Option, Option, i64, String, i64, i64)> = - if let Some(fid) = folder_id { - sqlx::query_as( - r#" + let rows: Vec<( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + )> = if let Some(fid) = folder_id { + sqlx::query_as( + r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, @@ -153,13 +161,13 @@ impl FileReadPort for FileBlobReadRepository { WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed ORDER BY fi.name "#, - ) - .bind(fid) - .fetch_all(self.pool.as_ref()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(fid) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query_as( + r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, @@ -169,11 +177,11 @@ impl FileReadPort for FileBlobReadRepository { WHERE fi.folder_id IS NULL AND NOT fi.is_trashed ORDER BY fi.name "#, - ) - .fetch_all(self.pool.as_ref()) - .await - } - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; + ) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; rows.into_iter() .map(|(id, name, fid, fpath, size, mime, ca, ma)| { @@ -289,7 +297,19 @@ impl FileReadPort for FileBlobReadRepository { let row = if folder_path.is_empty() { // File at root level (no parent folder) - sqlx::query_as::<_, (String, String, Option, Option, i64, String, i64, i64)>( + sqlx::query_as::< + _, + ( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + ), + >( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, @@ -305,7 +325,19 @@ impl FileReadPort for FileBlobReadRepository { .await } else { // File inside a folder — look up by folder path + filename - sqlx::query_as::<_, (String, String, Option, Option, i64, String, i64, i64)>( + sqlx::query_as::< + _, + ( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + ), + >( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index b46ebb49..7eecb9d4 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -55,16 +55,18 @@ impl FileBlobWriteRepository { ) -> Result, DomainError> { match folder_id { Some(fid) => { - let path: String = sqlx::query_scalar( - "SELECT path FROM storage.folders WHERE id = $1::uuid", - ) - .bind(fid) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("FileBlobWrite", format!("folder path: {e}")) - })? - .ok_or_else(|| DomainError::not_found("Folder", fid))?; + let path: String = + sqlx::query_scalar("SELECT path FROM storage.folders WHERE id = $1::uuid") + .bind(fid) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error( + "FileBlobWrite", + format!("folder path: {e}"), + ) + })? + .ok_or_else(|| DomainError::not_found("Folder", fid))?; Ok(Some(path)) } None => Ok(None), @@ -179,7 +181,16 @@ impl FileWritePort for FileBlobWriteRepository { ); let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?; - Self::row_to_file(row.0, name, folder_id, folder_path, size, content_type, row.1, row.2) + Self::row_to_file( + row.0, + name, + folder_id, + folder_path, + size, + content_type, + row.1, + row.2, + ) } async fn save_file_from_temp( diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index b6855d04..676a8651 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -175,9 +175,10 @@ impl FolderRepository for FolderDbRepository { } async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -185,13 +186,13 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id = $1::uuid AND NOT is_trashed ORDER BY name "#, - ) - .bind(pid) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -199,11 +200,11 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id IS NULL AND NOT is_trashed ORDER BY name "#, - ) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; + ) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() .map(|(id, name, path, pid, uid, ca, ma)| { @@ -217,9 +218,10 @@ impl FolderRepository for FolderDbRepository { parent_id: Option<&str>, owner_id: &str, ) -> Result, DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -227,14 +229,14 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed ORDER BY name "#, - ) - .bind(pid) - .bind(owner_id) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -242,12 +244,12 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed ORDER BY name "#, - ) - .bind(owner_id) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; + ) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; rows.into_iter() .map(|(id, name, path, pid, uid, ca, ma)| { @@ -284,9 +286,10 @@ impl FolderRepository for FolderDbRepository { None }; - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -295,15 +298,15 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $2 OFFSET $3 "#, - ) - .bind(pid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -312,13 +315,13 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $1 OFFSET $2 "#, - ) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; + ) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; let folders: Result, DomainError> = rows .into_iter() @@ -360,9 +363,10 @@ impl FolderRepository for FolderDbRepository { None }; - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -371,16 +375,16 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $3 OFFSET $4 "#, - ) - .bind(pid) - .bind(owner_id) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -389,14 +393,16 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $2 OFFSET $3 "#, - ) - .bind(owner_id) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; + ) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| { + DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")) + })?; let folders: Result, DomainError> = rows .into_iter() @@ -503,14 +509,13 @@ impl FolderRepository for FolderDbRepository { } async fn get_folder_path(&self, id: &str) -> Result { - let path: String = sqlx::query_scalar( - "SELECT path FROM storage.folders WHERE id = $1::uuid", - ) - .bind(id) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("get_path: {e}")))? - .ok_or_else(|| DomainError::not_found("Folder", id))?; + let path: String = + sqlx::query_scalar("SELECT path FROM storage.folders WHERE id = $1::uuid") + .bind(id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("get_path: {e}")))? + .ok_or_else(|| DomainError::not_found("Folder", id))?; Ok(StoragePath::from_string(&path)) } @@ -615,11 +620,7 @@ impl FolderRepository for FolderDbRepository { Ok(()) } - async fn create_home_folder( - &self, - user_id: &str, - name: String, - ) -> Result { + async fn create_home_folder(&self, user_id: &str, name: String) -> Result { let row = sqlx::query_as::<_, (String, String, i64, i64)>( r#" INSERT INTO storage.folders (name, parent_id, user_id) @@ -638,7 +639,15 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; match row { - Some((id, path, ca, ma)) => Self::row_to_folder(id, name.clone(), path, None, Some(user_id.to_string()), ca, ma), + Some((id, path, ca, ma)) => Self::row_to_folder( + id, + name.clone(), + path, + None, + Some(user_id.to_string()), + ca, + ma, + ), None => { // Already exists — fetch it let existing = sqlx::query_as::<_, (String, String, i64, i64)>( @@ -656,7 +665,15 @@ impl FolderRepository for FolderDbRepository { .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?; - Self::row_to_folder(existing.0, name, existing.1, None, Some(user_id.to_string()), existing.2, existing.3) + Self::row_to_folder( + existing.0, + name, + existing.1, + None, + Some(user_id.to_string()), + existing.2, + existing.3, + ) } } } diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index fabf4b92..c5249652 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -61,22 +61,25 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { let items = rows .iter() - .map(|row| RecentItemDto { - id: row.get("id"), - user_id: row.get("user_id"), - item_id: row.get("item_id"), - item_type: row.get("item_type"), - accessed_at: row.get("accessed_at"), - item_name: row.try_get("item_name").ok(), - item_size: row.try_get("item_size").ok(), - item_mime_type: row.try_get("item_mime_type").ok(), - parent_id: row.try_get("parent_id").ok(), - // Temporary defaults; with_display_fields() computes the real values - icon_class: String::new(), - icon_special_class: String::new(), - category: String::new(), - size_formatted: String::new(), - }.with_display_fields()) + .map(|row| { + RecentItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + accessed_at: row.get("accessed_at"), + item_name: row.try_get("item_name").ok(), + item_size: row.try_get("item_size").ok(), + item_mime_type: row.try_get("item_mime_type").ok(), + parent_id: row.try_get("parent_id").ok(), + // Temporary defaults; with_display_fields() computes the real values + icon_class: String::new(), + icon_special_class: String::new(), + category: String::new(), + size_formatted: String::new(), + } + .with_display_fields() + }) .collect(); Ok(items) diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 564f99d6..948504b8 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -1,4 +1,9 @@ -use axum::{Json, extract::{Path, State}, http::StatusCode, response::IntoResponse}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; use serde::Deserialize; use std::sync::Arc; use tracing::{error, info}; diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index f20de467..84cf59bc 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -90,9 +90,12 @@ impl FolderHandler { if owner != &auth_user.id { tracing::warn!( "get_folder: user '{}' attempted to access folder '{}' owned by '{}'", - auth_user.id, id, owner + auth_user.id, + id, + owner ); - return (StatusCode::NOT_FOUND, "Folder not found".to_string()).into_response(); + return (StatusCode::NOT_FOUND, "Folder not found".to_string()) + .into_response(); } } (StatusCode::OK, Json(folder)).into_response() @@ -144,7 +147,10 @@ impl FolderHandler { Path(id): Path, pagination: Query, ) -> axum::response::Response { - match service.list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination).await { + match service + .list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination) + .await + { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Err(err) => { let status = match err.kind { @@ -168,7 +174,10 @@ impl FolderHandler { parent_id: Option<&str>, auth_user: &AuthUser, ) -> axum::response::Response { - match service.list_folders_for_owner(parent_id, &auth_user.id).await { + match service + .list_folders_for_owner(parent_id, &auth_user.id) + .await + { Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), Err(err) => { let status = match err.kind { @@ -360,7 +369,9 @@ impl FolderHandler { if folder.owner_id.as_deref() != Some(&auth_user.id) { tracing::warn!( "download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'", - auth_user.id, id, folder.owner_id + auth_user.id, + id, + folder.owner_id ); return ( StatusCode::NOT_FOUND, diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 20aac22c..a75b3e54 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -765,7 +765,11 @@ async fn handle_move( }; folder_service - .move_folder(&folder.id, move_dto, folder.owner_id.as_deref().unwrap_or("webdav")) + .move_folder( + &folder.id, + move_dto, + folder.owner_id.as_deref().unwrap_or("webdav"), + ) .await .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; @@ -775,7 +779,11 @@ async fn handle_move( }; folder_service - .rename_folder(&folder.id, rename_dto, folder.owner_id.as_deref().unwrap_or("webdav")) + .rename_folder( + &folder.id, + rename_dto, + folder.owner_id.as_deref().unwrap_or("webdav"), + ) .await .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; } From 4e2c9d259287163f7a495f15704c946cd97fa27e Mon Sep 17 00:00:00 2001 From: Jan Wiebe Date: Sat, 21 Feb 2026 13:39:27 +0100 Subject: [PATCH 2/2] feat(wopi): add WOPI protocol support for collaborative editing Implement the Web Application Open Platform Interface (WOPI) protocol to enable collaborative document editing with Collabora Online and OnlyOffice through OxiCloud. Backend: - WOPI token service with HMAC-SHA256 signed access tokens - WOPI lock service with in-memory lock management and expiry - WOPI discovery service for auto-detecting editor capabilities - WOPI HTTP handler: CheckFileInfo, GetFile, PutFile, Lock/Unlock - File entity extended with owner_id for WOPI file-info responses - Configuration via WOPI_* environment variables - Services wired through DI in AppState Frontend: - WOPI editor component with modal and new-tab viewing modes - Context menu integration for opening files in online editors - Inline viewer integration for document preview Infrastructure: - Docker Compose file for local Collabora/OnlyOffice dev setup Co-Authored-By: Claude Opus 4.6 --- docker-compose.wopi.yml | 31 ++ src/application/dtos/file_dto.rs | 6 + src/application/services/mod.rs | 2 + src/application/services/wopi_lock_service.rs | 234 ++++++++ .../services/wopi_token_service.rs | 175 ++++++ src/common/config.rs | 61 +- src/common/di.rs | 55 ++ src/domain/entities/file.rs | 16 + .../pg/file_blob_read_repository.rs | 31 +- .../pg/file_blob_write_repository.rs | 41 +- src/infrastructure/services/mod.rs | 1 + .../services/wopi_discovery_service.rs | 362 ++++++++++++ src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/wopi_handler.rs | 526 ++++++++++++++++++ src/main.rs | 51 ++ static/index.html | 1 + static/js/app/ui.js | 17 + static/js/features/files/contextMenus.js | 35 +- static/js/features/files/inlineViewer.js | 7 + static/js/features/files/wopiEditor.js | 238 ++++++++ 20 files changed, 1877 insertions(+), 14 deletions(-) create mode 100644 docker-compose.wopi.yml create mode 100644 src/application/services/wopi_lock_service.rs create mode 100644 src/application/services/wopi_token_service.rs create mode 100644 src/infrastructure/services/wopi_discovery_service.rs create mode 100644 src/interfaces/api/handlers/wopi_handler.rs create mode 100644 static/js/features/files/wopiEditor.js diff --git a/docker-compose.wopi.yml b/docker-compose.wopi.yml new file mode 100644 index 00000000..ac963e5b --- /dev/null +++ b/docker-compose.wopi.yml @@ -0,0 +1,31 @@ +# WOPI editor services for local development. +# Usage: docker compose -f docker-compose.dev.yml -f docker-compose.wopi.yml up -d +# Then run OxiCloud natively: cargo run + +services: + collabora: + image: collabora/code:latest + restart: unless-stopped + cap_add: + - MKNOD + environment: + # Allow OxiCloud running on host to use Collabora + - "aliasgroup1=http://host.docker.internal:8086" + # Disable SSL and SSL termination (dev only, plain HTTP on localhost) + - "extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:net.frame_ancestors=http://localhost:* http://127.0.0.1:*" + # Admin console (optional) + - "username=admin" + - "password=admin" + ports: + - "9980:9980" + + # Uncomment to use OnlyOffice instead of / alongside Collabora: + # onlyoffice: + # image: onlyoffice/documentserver:latest + # restart: unless-stopped + # environment: + # - "WOPI_ENABLED=true" + # - "JWT_SECRET=oxicloud-dev-secret" + # - "JWT_ENABLED=true" + # ports: + # - "8088:80" diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index ca955c26..2f05e6fb 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -44,6 +44,10 @@ pub struct FileDto { /// Human-readable formatted size (e.g. "3.27 MB") pub size_formatted: String, + + /// Owner user ID (omitted from JSON when None) + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_id: Option, } impl From for FileDto { @@ -65,6 +69,7 @@ impl From for FileDto { icon_special_class: icon_special_class_for(name, mime).to_string(), category: category_for(name, mime).to_string(), size_formatted: format_file_size(size), + owner_id: file.owner_id().map(String::from), } } } @@ -103,6 +108,7 @@ impl FileDto { icon_special_class: String::new(), category: "Document".to_string(), size_formatted: "0 Bytes".to_string(), + owner_id: None, } } } diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index f96cf8e3..de320ebb 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -15,6 +15,8 @@ pub mod search_service; pub mod share_service; pub mod storage_usage_service; pub mod trash_service; +pub mod wopi_lock_service; +pub mod wopi_token_service; #[cfg(test)] mod trash_service_test; diff --git a/src/application/services/wopi_lock_service.rs b/src/application/services/wopi_lock_service.rs new file mode 100644 index 00000000..043d81f8 --- /dev/null +++ b/src/application/services/wopi_lock_service.rs @@ -0,0 +1,234 @@ +//! In-memory WOPI lock service. +//! +//! Manages file locks required by the WOPI protocol for concurrent editing. +//! Uses an in-memory HashMap — suitable for single-instance deployments. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// A lock entry for a file. +#[derive(Debug, Clone)] +struct LockEntry { + lock_id: String, + expires_at: Instant, +} + +/// Error returned when a lock operation conflicts. +#[derive(Debug)] +pub struct LockConflict { + /// The lock ID currently held on the file + pub existing_lock_id: String, +} + +/// In-memory WOPI lock manager. +#[derive(Clone)] +pub struct WopiLockService { + locks: Arc>>, + lock_duration: Duration, +} + +impl WopiLockService { + pub fn new(lock_ttl_secs: u64) -> Self { + Self { + locks: Arc::new(RwLock::new(HashMap::new())), + lock_duration: Duration::from_secs(lock_ttl_secs), + } + } + + /// Lock a file. If already locked with the same lock_id, refreshes the timer. + pub async fn lock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict> { + let mut locks = self.locks.write().await; + if let Some(entry) = locks.get(file_id) { + if entry.lock_id == lock_id || entry.expires_at <= Instant::now() { + // Same lock or expired — allow + } else { + return Err(LockConflict { + existing_lock_id: entry.lock_id.clone(), + }); + } + } + locks.insert( + file_id.to_string(), + LockEntry { + lock_id: lock_id.to_string(), + expires_at: Instant::now() + self.lock_duration, + }, + ); + Ok(()) + } + + /// Unlock a file. The lock_id must match. + pub async fn unlock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict> { + let mut locks = self.locks.write().await; + if let Some(entry) = locks.get(file_id) + && entry.lock_id != lock_id + && entry.expires_at > Instant::now() + { + return Err(LockConflict { + existing_lock_id: entry.lock_id.clone(), + }); + } + locks.remove(file_id); + Ok(()) + } + + /// Refresh the lock timer. The file must be locked with the given lock_id. + pub async fn refresh_lock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict> { + let mut locks = self.locks.write().await; + match locks.get(file_id) { + None => { + // No lock exists — WOPI spec requires 409 with empty lock + return Err(LockConflict { + existing_lock_id: String::new(), + }); + } + Some(entry) if entry.expires_at <= Instant::now() => { + // Lock expired — treat as unlocked + locks.remove(file_id); + return Err(LockConflict { + existing_lock_id: String::new(), + }); + } + Some(entry) if entry.lock_id != lock_id => { + // Different lock holder + return Err(LockConflict { + existing_lock_id: entry.lock_id.clone(), + }); + } + Some(_) => { + // Matching lock — refresh the timer + } + } + locks.insert( + file_id.to_string(), + LockEntry { + lock_id: lock_id.to_string(), + expires_at: Instant::now() + self.lock_duration, + }, + ); + Ok(()) + } + + /// Get the current lock ID for a file, if locked. + pub async fn get_lock(&self, file_id: &str) -> Option { + let locks = self.locks.read().await; + locks.get(file_id).and_then(|entry| { + if entry.expires_at > Instant::now() { + Some(entry.lock_id.clone()) + } else { + None + } + }) + } + + /// Remove expired locks. Call this periodically. + pub async fn cleanup_expired(&self) { + let mut locks = self.locks.write().await; + let now = Instant::now(); + locks.retain(|_, entry| entry.expires_at > now); + } + + /// Start a background task that cleans up expired locks every 60 seconds. + pub fn start_cleanup_task(self: &Arc) { + let service = Arc::clone(self); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + service.cleanup_expired().await; + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_lock_and_unlock() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + assert_eq!(svc.get_lock("file-1").await, Some("lock-abc".to_string())); + svc.unlock("file-1", "lock-abc") + .await + .expect("Should unlock"); + assert_eq!(svc.get_lock("file-1").await, None); + } + + #[tokio::test] + async fn test_lock_conflict() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + let result = svc.lock("file-1", "lock-xyz").await; + assert!(result.is_err()); + let conflict = result.unwrap_err(); + assert_eq!(conflict.existing_lock_id, "lock-abc"); + } + + #[tokio::test] + async fn test_same_lock_refreshes() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + svc.lock("file-1", "lock-abc") + .await + .expect("Same lock should succeed"); + } + + #[tokio::test] + async fn test_refresh_lock() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + svc.refresh_lock("file-1", "lock-abc") + .await + .expect("Should refresh"); + assert_eq!(svc.get_lock("file-1").await, Some("lock-abc".to_string())); + } + + #[tokio::test] + async fn test_unlock_conflict() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + let result = svc.unlock("file-1", "wrong-lock").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_lock_returns_none_for_unlocked() { + let svc = WopiLockService::new(1800); + assert_eq!(svc.get_lock("file-1").await, None); + } + + #[tokio::test] + async fn test_refresh_lock_on_unlocked_file_returns_conflict() { + let svc = WopiLockService::new(1800); + let result = svc.refresh_lock("file-1", "lock-abc").await; + assert!(result.is_err()); + let conflict = result.unwrap_err(); + assert_eq!(conflict.existing_lock_id, ""); + } + + #[tokio::test] + async fn test_refresh_lock_on_expired_lock_returns_conflict() { + let svc = WopiLockService::new(0); // 0 seconds = immediate expiry + svc.lock("file-1", "lock-old").await.expect("Should lock"); + tokio::time::sleep(Duration::from_millis(10)).await; + let result = svc.refresh_lock("file-1", "lock-old").await; + assert!(result.is_err()); + let conflict = result.unwrap_err(); + assert_eq!(conflict.existing_lock_id, ""); + } + + #[tokio::test] + async fn test_expired_lock_allows_new_lock() { + let svc = WopiLockService::new(0); // 0 seconds = immediate expiry + svc.lock("file-1", "lock-old").await.expect("Should lock"); + tokio::time::sleep(Duration::from_millis(10)).await; + // Expired lock should not block a new lock from a different holder + svc.lock("file-1", "lock-new") + .await + .expect("Expired lock should allow new lock"); + } +} diff --git a/src/application/services/wopi_token_service.rs b/src/application/services/wopi_token_service.rs new file mode 100644 index 00000000..4c2e9d36 --- /dev/null +++ b/src/application/services/wopi_token_service.rs @@ -0,0 +1,175 @@ +//! WOPI access token service. +//! +//! Generates and validates WOPI-scoped JWT tokens that are separate from +//! the regular authentication tokens. Uses the same `jsonwebtoken` crate +//! but with a distinct `scope: "wopi"` claim to prevent token confusion. + +use chrono::Utc; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; + +use crate::common::errors::{DomainError, ErrorKind}; + +/// JWT claims for WOPI access tokens. +#[derive(Debug, Serialize, Deserialize)] +pub struct WopiTokenClaims { + /// User ID + pub sub: String, + /// File ID this token grants access to + pub file_id: String, + /// Whether the user can write (edit) the file + pub can_write: bool, + /// Token scope — always "wopi" to distinguish from auth tokens + pub scope: String, + /// Display name for the editor UI + pub username: String, + /// Expiration timestamp (seconds since Unix epoch) + pub exp: i64, + /// Issued at timestamp + pub iat: i64, +} + +/// Service for generating and validating WOPI access tokens. +pub struct WopiTokenService { + secret: String, + token_ttl_secs: i64, +} + +impl WopiTokenService { + pub fn new(secret: String, token_ttl_secs: i64) -> Self { + Self { + secret, + token_ttl_secs, + } + } + + /// Generate a WOPI access token for a specific file and user. + /// + /// Returns `(token_string, expiration_unix_ms)`. + pub fn generate_token( + &self, + file_id: &str, + user_id: &str, + username: &str, + can_write: bool, + ) -> Result<(String, i64), DomainError> { + let now = Utc::now().timestamp(); + let claims = WopiTokenClaims { + sub: user_id.to_string(), + file_id: file_id.to_string(), + can_write, + scope: "wopi".to_string(), + username: username.to_string(), + exp: now + self.token_ttl_secs, + iat: now, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.secret.as_bytes()), + ) + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "WopiTokenService", + format!("Failed to generate WOPI token: {}", e), + ) + })?; + + let expires_at_unix_ms = claims.exp * 1000; + Ok((token, expires_at_unix_ms)) + } + + /// Validate a WOPI access token and extract its claims. + pub fn validate_token(&self, token: &str) -> Result { + let validation = Validation::new(Algorithm::HS256); + + let token_data = decode::( + token, + &DecodingKey::from_secret(self.secret.as_bytes()), + &validation, + ) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + "WOPI token expired", + ), + _ => DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + format!("Invalid WOPI token: {}", e), + ), + })?; + + let claims = token_data.claims; + + if claims.scope != "wopi" { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + "Token is not a WOPI token", + )); + } + + Ok(claims) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service() -> WopiTokenService { + WopiTokenService::new("test_secret_at_least_32_bytes_long!!".to_string(), 3600) + } + + #[test] + fn test_generate_and_validate() { + let svc = service(); + let (token, ttl_ms) = svc + .generate_token("file-123", "user-456", "test_user", true) + .expect("Should generate token"); + + let claims = svc.validate_token(&token).expect("Should validate"); + assert_eq!(claims.file_id, "file-123"); + assert_eq!(claims.sub, "user-456"); + assert!(claims.can_write); + assert_eq!(claims.scope, "wopi"); + assert_eq!(claims.username, "test_user"); + + // access_token_ttl must be absolute UNIX time in milliseconds. + assert_eq!(ttl_ms, claims.exp * 1000); + assert!(ttl_ms > claims.iat * 1000); + } + + #[test] + fn test_reject_invalid_token() { + let svc = service(); + let result = svc.validate_token("garbage"); + assert!(result.is_err()); + } + + #[test] + fn test_reject_wrong_secret() { + let svc1 = service(); + let svc2 = WopiTokenService::new("different_secret_also_32_bytes!!".to_string(), 3600); + + let (token, _) = svc1 + .generate_token("file-1", "user-1", "test_user", false) + .expect("Should generate"); + let result = svc2.validate_token(&token); + assert!(result.is_err()); + } + + #[test] + fn test_read_only_token() { + let svc = service(); + let (token, _) = svc + .generate_token("file-1", "user-1", "test_user", false) + .expect("Should generate"); + let claims = svc.validate_token(&token).expect("Should validate"); + assert!(!claims.can_write); + } +} diff --git a/src/common/config.rs b/src/common/config.rs index 2017ec28..b6e076cd 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -228,7 +228,7 @@ impl Default for DatabaseConfig { fn default() -> Self { Self { // Updated connection string with default credentials that PostgreSQL often uses - connection_string: "postgres://postgres:postgres@localhost:5432/oxicloud".to_string(), + connection_string: "postgres://postgres:postgres@localhost:5439/oxicloud".to_string(), max_connections: 20, min_connections: 5, connect_timeout_secs: 10, @@ -350,6 +350,35 @@ impl OidcConfig { } } +/// WOPI (Web Application Open Platform Interface) configuration +#[derive(Debug, Clone)] +pub struct WopiConfig { + /// Whether WOPI integration is enabled + pub enabled: bool, + /// URL to the WOPI client's discovery endpoint + /// e.g., "http://collabora:9980/hosting/discovery" + pub discovery_url: String, + /// Secret key for signing WOPI access tokens + /// Falls back to JWT secret if empty + pub secret: String, + /// Access token TTL in seconds (default: 86400 = 24 hours) + pub token_ttl_secs: i64, + /// Lock expiration in seconds (default: 1800 = 30 minutes) + pub lock_ttl_secs: u64, +} + +impl Default for WopiConfig { + fn default() -> Self { + Self { + enabled: false, + discovery_url: String::new(), + secret: String::new(), + token_ttl_secs: 86400, + lock_ttl_secs: 1800, + } + } +} + /// Feature configuration (feature flags) #[derive(Debug, Clone)] pub struct FeaturesConfig { @@ -401,6 +430,8 @@ pub struct AppConfig { pub features: FeaturesConfig, /// OIDC configuration pub oidc: OidcConfig, + /// WOPI configuration + pub wopi: WopiConfig, } impl Default for AppConfig { @@ -419,6 +450,7 @@ impl Default for AppConfig { auth: AuthConfig::default(), features: FeaturesConfig::default(), oidc: OidcConfig::default(), + wopi: WopiConfig::default(), } } } @@ -581,6 +613,33 @@ impl AppConfig { config.oidc.enabled = false; } + // WOPI configuration + if let Ok(v) = env::var("OXICLOUD_WOPI_ENABLED") { + config.wopi.enabled = v.parse::().unwrap_or(false); + } + if let Ok(v) = env::var("OXICLOUD_WOPI_DISCOVERY_URL") { + config.wopi.discovery_url = v; + } + if let Ok(v) = env::var("OXICLOUD_WOPI_SECRET") { + config.wopi.secret = v; + } + if let Ok(v) = env::var("OXICLOUD_WOPI_TOKEN_TTL_SECS") + && let Ok(val) = v.parse::() + { + config.wopi.token_ttl_secs = val; + } + if let Ok(v) = env::var("OXICLOUD_WOPI_LOCK_TTL_SECS") + && let Ok(val) = v.parse::() + { + config.wopi.lock_ttl_secs = val; + } + + // WOPI secret fallback: use JWT secret if WOPI secret not set + if config.wopi.enabled && config.wopi.secret.is_empty() { + config.wopi.secret = config.auth.jwt_secret.clone(); + tracing::info!("WOPI secret not set, falling back to JWT secret"); + } + config } diff --git a/src/common/di.rs b/src/common/di.rs index 768c6557..32543d07 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -515,6 +515,9 @@ impl AppServiceFactory { calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, + wopi_token_service: None, + wopi_lock_service: None, + wopi_discovery_service: None, }; // 9b. Wire admin settings service when auth is available @@ -632,6 +635,46 @@ impl AppServiceFactory { tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); } + // 11. Wire WOPI services if enabled + if self.config.wopi.enabled { + let discovery_url = &self.config.wopi.discovery_url; + if discovery_url.is_empty() { + tracing::error!( + "WOPI is enabled but WOPI_DISCOVERY_URL is empty — WOPI services will NOT be available" + ); + } else { + use crate::application::services::wopi_lock_service::WopiLockService; + use crate::application::services::wopi_token_service::WopiTokenService; + use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; + + let wopi_secret = if self.config.wopi.secret.is_empty() { + self.config.auth.jwt_secret.clone() + } else { + self.config.wopi.secret.clone() + }; + + let wopi_token_service = Arc::new(WopiTokenService::new( + wopi_secret, + self.config.wopi.token_ttl_secs, + )); + + let wopi_lock_service = + Arc::new(WopiLockService::new(self.config.wopi.lock_ttl_secs)); + wopi_lock_service.start_cleanup_task(); + + let wopi_discovery_service = Arc::new(WopiDiscoveryService::new( + discovery_url.clone(), + 86400, // 24 hour cache TTL + )); + + app_state.wopi_token_service = Some(wopi_token_service); + app_state.wopi_lock_service = Some(wopi_lock_service); + app_state.wopi_discovery_service = Some(wopi_discovery_service); + + tracing::info!("WOPI services initialized (discovery: {})", discovery_url); + } + } + Ok(app_state) } } @@ -710,6 +753,12 @@ pub struct AppState { pub addressbook_use_case: Option>, pub contact_use_case: Option>, + pub wopi_token_service: + Option>, + pub wopi_lock_service: + Option>, + pub wopi_discovery_service: + Option>, } impl Default for AppState { @@ -844,6 +893,9 @@ impl Default for AppState { calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, + wopi_token_service: None, + wopi_lock_service: None, + wopi_discovery_service: None, } } } @@ -871,6 +923,9 @@ impl AppState { calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, + wopi_token_service: None, + wopi_lock_service: None, + wopi_discovery_service: None, } } diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index a353d438..93458330 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -41,6 +41,9 @@ pub struct File { /// Last modification timestamp (seconds since UNIX epoch) modified_at: u64, + + /// Owner user ID (from storage.files.user_id) + owner_id: Option, } // We no longer need this module, now we use a String directly @@ -57,6 +60,7 @@ impl Default for File { folder_id: None, created_at: 0, modified_at: 0, + owner_id: None, } } } @@ -94,6 +98,7 @@ impl File { folder_id, created_at: now, modified_at: now, + owner_id: None, }) } @@ -124,6 +129,7 @@ impl File { folder_id: parent_id, created_at, modified_at, + owner_id: None, }) } @@ -137,6 +143,7 @@ impl File { folder_id: Option, created_at: u64, modified_at: u64, + owner_id: Option, ) -> FileResult { // Validate file name if name.is_empty() || name.contains('/') || name.contains('\\') { @@ -156,6 +163,7 @@ impl File { folder_id, created_at, modified_at, + owner_id, }) } @@ -196,6 +204,10 @@ impl File { self.modified_at } + pub fn owner_id(&self) -> Option<&str> { + self.owner_id.as_deref() + } + #[allow(clippy::too_many_arguments)] pub fn from_dto( id: String, @@ -221,6 +233,7 @@ impl File { folder_id, created_at, modified_at, + owner_id: None, } } @@ -258,6 +271,7 @@ impl File { folder_id: self.folder_id.clone(), created_at: self.created_at, modified_at: now, + owner_id: self.owner_id.clone(), }) } @@ -291,6 +305,7 @@ impl File { folder_id, created_at: self.created_at, modified_at: now, + owner_id: self.owner_id.clone(), }) } @@ -311,6 +326,7 @@ impl File { folder_id: self.folder_id.clone(), created_at: self.created_at, modified_at: now, + owner_id: self.owner_id.clone(), } } } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 00d956ec..defd82f7 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -61,6 +61,7 @@ impl FileBlobReadRepository { mime_type: String, created_at: i64, modified_at: i64, + owner_id: Option, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); File::with_timestamps( @@ -72,6 +73,7 @@ impl FileBlobReadRepository { folder_id, created_at as u64, modified_at as u64, + owner_id, ) .map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}"))) } @@ -110,6 +112,7 @@ impl FileReadPort for FileBlobReadRepository { i64, // created_at i64, // updated_at String, // blob_hash + Option, // user_id (owner) ), >( r#" @@ -117,7 +120,8 @@ impl FileReadPort for FileBlobReadRepository { fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, - fi.blob_hash + fi.blob_hash, + fi.user_id::text FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.id = $1::uuid AND NOT fi.is_trashed @@ -136,7 +140,9 @@ impl FileReadPort for FileBlobReadRepository { .unwrap() .insert(id.to_string(), row.8.clone()); - Self::row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7) + Self::row_to_file( + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.9, + ) } async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { @@ -149,13 +155,15 @@ impl FileReadPort for FileBlobReadRepository { String, i64, i64, + Option, )> = if let Some(fid) = folder_id { sqlx::query_as( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, - EXTRACT(EPOCH FROM fi.updated_at)::bigint + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed @@ -171,7 +179,8 @@ impl FileReadPort for FileBlobReadRepository { SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, - EXTRACT(EPOCH FROM fi.updated_at)::bigint + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id IS NULL AND NOT fi.is_trashed @@ -184,8 +193,8 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; rows.into_iter() - .map(|(id, name, fid, fpath, size, mime, ca, ma)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma) + .map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid) }) .collect() } @@ -308,13 +317,15 @@ impl FileReadPort for FileBlobReadRepository { String, i64, i64, + Option, ), >( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, - EXTRACT(EPOCH FROM fi.updated_at)::bigint + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed @@ -336,13 +347,15 @@ impl FileReadPort for FileBlobReadRepository { String, i64, i64, + Option, ), >( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, - EXTRACT(EPOCH FROM fi.updated_at)::bigint + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed @@ -357,7 +370,7 @@ impl FileReadPort for FileBlobReadRepository { match row { Some(r) => Ok(Some(Self::row_to_file( - r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, + r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, )?)), None => Ok(None), } diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 7eecb9d4..72e458cc 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -83,6 +83,7 @@ impl FileBlobWriteRepository { mime_type: String, created_at: i64, modified_at: i64, + owner_id: Option, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); File::with_timestamps( @@ -94,6 +95,7 @@ impl FileBlobWriteRepository { folder_id, created_at as u64, modified_at as u64, + owner_id, ) .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}"))) } @@ -190,6 +192,7 @@ impl FileWritePort for FileBlobWriteRepository { content_type, row.1, row.2, + Some(user_id), ) } @@ -272,6 +275,7 @@ impl FileWritePort for FileBlobWriteRepository { content_type, row.1, row.2, + Some(user_id), ) } @@ -299,7 +303,17 @@ impl FileWritePort for FileBlobWriteRepository { .ok_or_else(|| DomainError::not_found("File", file_id))?; let folder_path = self.lookup_folder_path(row.2.as_deref()).await?; - Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6) + Self::row_to_file( + row.0, + row.1, + row.2, + folder_path, + row.3, + row.4, + row.5, + row.6, + None, + ) } async fn copy_file( @@ -382,7 +396,17 @@ impl FileWritePort for FileBlobWriteRepository { ); let folder_path = self.lookup_folder_path(row.2.as_deref()).await?; - Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6) + Self::row_to_file( + row.0, + row.1, + row.2, + folder_path, + row.3, + row.4, + row.5, + row.6, + None, + ) } async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { @@ -411,7 +435,17 @@ impl FileWritePort for FileBlobWriteRepository { .ok_or_else(|| DomainError::not_found("File", file_id))?; let folder_path = self.lookup_folder_path(row.2.as_deref()).await?; - Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6) + Self::row_to_file( + row.0, + row.1, + row.2, + folder_path, + row.3, + row.4, + row.5, + row.6, + None, + ) } async fn delete_file(&self, id: &str) -> Result<(), DomainError> { @@ -543,6 +577,7 @@ impl FileWritePort for FileBlobWriteRepository { content_type, row.1, row.2, + Some(user_id), )?; // The target_path is not meaningful for blob storage (content goes to .blobs/) diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 002247b2..3efe9479 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -10,4 +10,5 @@ pub mod password_hasher; pub mod path_service; pub mod thumbnail_service; pub mod trash_cleanup_service; +pub mod wopi_discovery_service; pub mod zip_service; diff --git a/src/infrastructure/services/wopi_discovery_service.rs b/src/infrastructure/services/wopi_discovery_service.rs new file mode 100644 index 00000000..d617cc7f --- /dev/null +++ b/src/infrastructure/services/wopi_discovery_service.rs @@ -0,0 +1,362 @@ +//! WOPI Discovery service. +//! +//! Fetches and caches the WOPI discovery XML from the editor (Collabora/OnlyOffice). +//! The discovery document describes which file types the editor supports and +//! provides the action URLs for view/edit operations. + +use quick_xml::Reader; +use quick_xml::events::Event; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +use crate::common::errors::{DomainError, ErrorKind}; + +/// A single WOPI action from the discovery XML. +#[derive(Clone, Debug)] +pub struct WopiAction { + /// Action name: "view", "edit", "editnew", etc. + pub name: String, + /// File extension: "docx", "xlsx", etc. + pub ext: String, + /// Template URL with placeholders (WOPI_SOURCE, UI_LLCC) + pub urlsrc: String, +} + +/// Caches parsed WOPI discovery data from the editor. +pub struct WopiDiscoveryService { + discovery_url: String, + /// Map: extension -> Vec + actions: Arc>>>, + last_fetched: Arc>>, + cache_ttl: Duration, + /// HTTP client with timeout (shared across requests). + http_client: reqwest::Client, + /// Mutex to prevent concurrent refresh stampede. + refreshing: Arc>, +} + +impl WopiDiscoveryService { + pub fn new(discovery_url: String, cache_ttl_secs: u64) -> Self { + let http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client for WOPI discovery"); + + Self { + discovery_url, + actions: Arc::new(RwLock::new(HashMap::new())), + last_fetched: Arc::new(RwLock::new(None)), + cache_ttl: Duration::from_secs(cache_ttl_secs), + http_client, + refreshing: Arc::new(tokio::sync::Mutex::new(())), + } + } + + /// Fetch and parse the discovery XML from the WOPI client. + pub async fn refresh_discovery(&self) -> Result<(), DomainError> { + tracing::info!("Fetching WOPI discovery from {}", self.discovery_url); + + let response = self + .http_client + .get(&self.discovery_url) + .send() + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "WopiDiscovery", + format!("Failed to fetch discovery XML: {}", e), + ) + })?; + + let response = response.error_for_status().map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "WopiDiscovery", + format!("Discovery endpoint returned error: {}", e), + ) + })?; + + let xml_text = response.text().await.map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "WopiDiscovery", + format!("Failed to read discovery response: {}", e), + ) + })?; + + let actions = Self::parse_discovery_xml(&xml_text)?; + + tracing::info!( + "WOPI discovery loaded: {} extensions supported", + actions.len() + ); + + *self.actions.write().await = actions; + *self.last_fetched.write().await = Some(Instant::now()); + + Ok(()) + } + + /// Ensure the discovery cache is fresh, refreshing if needed. + /// Uses a mutex so only one caller refreshes at a time (stampede prevention). + async fn ensure_fresh(&self) -> Result<(), DomainError> { + let needs_refresh = { + let last = self.last_fetched.read().await; + match *last { + None => true, + Some(t) => t.elapsed() > self.cache_ttl, + } + }; + if needs_refresh { + let _guard = self.refreshing.lock().await; + // Re-check after acquiring the lock (another caller may have refreshed) + let still_stale = { + let last = self.last_fetched.read().await; + match *last { + None => true, + Some(t) => t.elapsed() > self.cache_ttl, + } + }; + if still_stale { + self.refresh_discovery().await?; + } + } + Ok(()) + } + + /// Get the editor action URL for a given file extension and action. + /// + /// Replaces `WOPI_SOURCE` placeholder with the provided `wopi_src` URL. + pub async fn get_action_url( + &self, + extension: &str, + action: &str, + wopi_src: &str, + ) -> Result, DomainError> { + self.ensure_fresh().await?; + + let actions = self.actions.read().await; + let ext_lower = extension.to_lowercase(); + + if let Some(ext_actions) = actions.get(&ext_lower) + && let Some(wopi_action) = ext_actions.iter().find(|a| a.name == action) + { + let mut url = wopi_action + .urlsrc + .replace("WOPI_SOURCE", &urlencoding::encode(wopi_src)) + .replace("UI_LLCC", "en-US"); + + // Clean up unused placeholder parameters + url = Self::clean_placeholder_params(&url); + + // Some discovery documents return a bare `cool.html?` URL without + // embedding WOPISrc in the template. Ensure WOPISrc is always present. + if !Self::has_query_param(&url, "WOPISrc") { + url = Self::append_query_param(&url, "WOPISrc", &urlencoding::encode(wopi_src)); + } + + return Ok(Some(url)); + } + + Ok(None) + } + + /// Check if an extension is supported for a given action. + pub async fn supports_action( + &self, + extension: &str, + action: &str, + ) -> Result { + self.ensure_fresh().await?; + let actions = self.actions.read().await; + let ext_lower = extension.to_lowercase(); + Ok(actions + .get(&ext_lower) + .is_some_and(|acts| acts.iter().any(|a| a.name == action))) + } + + /// Get list of all supported extensions. + pub async fn get_supported_extensions(&self) -> Result, DomainError> { + self.ensure_fresh().await?; + let actions = self.actions.read().await; + Ok(actions.keys().cloned().collect()) + } + + /// Parse the WOPI discovery XML into a map of extension -> actions. + fn parse_discovery_xml(xml: &str) -> Result>, DomainError> { + let mut reader = Reader::from_str(xml); + let mut actions: HashMap> = HashMap::new(); + + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) + if e.name().as_ref() == b"action" => + { + let mut name = String::new(); + let mut ext = String::new(); + let mut urlsrc = String::new(); + + for attr in e.attributes().flatten() { + match attr.key.as_ref() { + b"name" => name = String::from_utf8_lossy(&attr.value).to_string(), + b"ext" => ext = String::from_utf8_lossy(&attr.value).to_string(), + b"urlsrc" => urlsrc = String::from_utf8_lossy(&attr.value).to_string(), + _ => {} + } + } + + if !ext.is_empty() && !urlsrc.is_empty() { + actions + .entry(ext.to_lowercase()) + .or_default() + .push(WopiAction { + name, + ext: ext.to_lowercase(), + urlsrc, + }); + } + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(DomainError::new( + ErrorKind::InternalError, + "WopiDiscovery", + format!("Failed to parse discovery XML: {}", e), + )); + } + _ => {} + } + buf.clear(); + } + + Ok(actions) + } + + /// Remove unused placeholder parameters from the URL. + fn clean_placeholder_params(url: &str) -> String { + let mut result = url.to_string(); + while let Some(start) = result.find('<') { + if let Some(end) = result[start..].find('>') { + result = format!("{}{}", &result[..start], &result[start + end + 1..]); + } else { + break; + } + } + result = result + .trim_end_matches('&') + .trim_end_matches('?') + .to_string(); + result + } + + fn has_query_param(url: &str, key: &str) -> bool { + if let Some((_, query)) = url.split_once('?') { + for part in query.split('&') { + let name = part.split('=').next().unwrap_or(""); + if name == key { + return true; + } + } + } + false + } + + fn append_query_param(url: &str, key: &str, value: &str) -> String { + let separator = if url.contains('?') { + if url.ends_with('?') || url.ends_with('&') { + "" + } else { + "&" + } + } else { + "?" + }; + format!("{}{}{}={}", url, separator, key, value) + } +} + +// Minimal inline URL encoding implementation (no external crate dependency). +// Matches the pattern used by oidc_service.rs in this codebase. +mod urlencoding { + pub fn encode(input: &str) -> String { + let mut result = String::with_capacity(input.len() * 3); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + _ => { + result.push('%'); + result.push_str(&format!("{:02X}", byte)); + } + } + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_DISCOVERY: &str = r#" + + + + + + + + + + + + + +"#; + + #[test] + fn test_parse_discovery_xml() { + let actions = + WopiDiscoveryService::parse_discovery_xml(SAMPLE_DISCOVERY).expect("Should parse"); + + assert!(actions.contains_key("docx")); + assert!(actions.contains_key("xlsx")); + assert!(actions.contains_key("pptx")); + + let docx_actions = &actions["docx"]; + assert_eq!(docx_actions.len(), 2); + assert!(docx_actions.iter().any(|a| a.name == "view")); + assert!(docx_actions.iter().any(|a| a.name == "edit")); + } + + #[test] + fn test_clean_placeholder_params() { + let url = + "https://example.com/edit?WOPISrc=http%3A%2F%2Flocalhost&"; + let cleaned = WopiDiscoveryService::clean_placeholder_params(url); + assert!(!cleaned.contains('<')); + assert!(!cleaned.contains('>')); + assert!(cleaned.contains("WOPISrc=")); + } + + #[test] + fn test_append_wopisrc_when_missing() { + let base = "http://127.0.0.1:9980/browser/hash/cool.html?"; + assert!(!WopiDiscoveryService::has_query_param(base, "WOPISrc")); + + let appended = WopiDiscoveryService::append_query_param( + base, + "WOPISrc", + "http%3A%2F%2F127.0.0.1%3A8086%2Fwopi%2Ffiles%2Fabc", + ); + + assert!(WopiDiscoveryService::has_query_param(&appended, "WOPISrc")); + assert!(appended.contains("WOPISrc=http%3A%2F%2F127.0.0.1%3A8086%2Fwopi%2Ffiles%2Fabc")); + } +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 52f0e1d0..c0abb3d4 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -14,6 +14,7 @@ pub mod search_handler; pub mod share_handler; pub mod trash_handler; pub mod webdav_handler; +pub mod wopi_handler; /// Tipo de resultado para controladores de API pub type ApiResult = Result; diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs new file mode 100644 index 00000000..6224a5a2 --- /dev/null +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -0,0 +1,526 @@ +//! WOPI protocol handler. +//! +//! Implements the WOPI host endpoints called by document editors +//! (Collabora Online, OnlyOffice) to access and modify files. +//! +//! These endpoints use `?access_token=` query parameter auth, NOT the +//! regular JWT auth middleware. +//! +//! Reference: doc/wopi-integration.md + +use crate::interfaces::middleware::auth::AuthUser; +use axum::{ + Router, + body::Bytes, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::application::services::wopi_lock_service::WopiLockService; +use crate::application::services::wopi_token_service::WopiTokenService; +use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; + +/// Shared state for WOPI handlers. +#[derive(Clone)] +pub struct WopiState { + pub token_service: Arc, + pub lock_service: Arc, + pub discovery_service: Arc, + pub app_state: crate::common::di::AppState, + /// Public base URL for host page origin and postMessage origin + pub public_base_url: String, + /// Base URL used for WOPISrc callbacks from Collabora to OxiCloud + pub wopi_base_url: String, +} + +/// Query parameter for WOPI access token. +#[derive(Deserialize)] +pub struct WopiTokenQuery { + pub access_token: String, +} + +/// CheckFileInfo response (WOPI spec). +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct CheckFileInfoResponse { + pub base_file_name: String, + pub owner_id: String, + pub size: u64, + pub user_id: String, + pub version: String, + pub supports_locks: bool, + pub supports_update: bool, + pub supports_rename: bool, + pub user_can_write: bool, + pub user_friendly_name: String, + pub post_message_origin: String, + pub last_modified_time: String, + pub close_url: String, +} + +/// GET /wopi/files/{file_id} — CheckFileInfo +async fn check_file_info( + Path(file_id): Path, + Query(token_query): Query, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // Fetch file metadata + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(&file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + // Convert u64 timestamp to RFC 3339 string + let last_modified = chrono::DateTime::from_timestamp(file.modified_at as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + + let response = CheckFileInfoResponse { + base_file_name: file.name.clone(), + owner_id: file.owner_id.clone().unwrap_or_else(|| claims.sub.clone()), + size: file.size, + user_id: claims.sub.clone(), + version: file.modified_at.to_string(), + supports_locks: true, + supports_update: claims.can_write, + supports_rename: false, + user_can_write: claims.can_write, + user_friendly_name: claims.username.clone(), + post_message_origin: state.public_base_url.clone(), + last_modified_time: last_modified, + close_url: state.public_base_url.clone(), + }; + + axum::Json(response).into_response() +} + +/// GET /wopi/files/{file_id}/contents — GetFile +async fn get_file( + Path(file_id): Path, + Query(token_query): Query, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + match state + .app_state + .applications + .file_retrieval_service + .get_file_content(&file_id) + .await + { + Ok(content) => (StatusCode::OK, content).into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +/// POST /wopi/files/{file_id}/contents — PutFile +async fn put_file( + Path(file_id): Path, + Query(token_query): Query, + headers: HeaderMap, + State(state): State, + body: Bytes, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id || !claims.can_write { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // Check lock + let request_lock = headers + .get("X-WOPI-Lock") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let current_lock = state.lock_service.get_lock(&file_id).await; + + if let Some(ref current) = current_lock { + match &request_lock { + Some(req_lock) if req_lock == current => { + // Lock matches — proceed + } + _ => { + // Lock mismatch + return ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", current.as_str())], + "Lock mismatch", + ) + .into_response(); + } + } + } + + // Get file path for update_file + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(&file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + // Save the file content using path-based update + match state + .app_state + .applications + .file_upload_service + .update_file(&file.path, &body) + .await + { + Ok(_) => StatusCode::OK.into_response(), + Err(e) => { + tracing::error!("WOPI PutFile failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// POST /wopi/files/{file_id} — Dispatches lock operations based on X-WOPI-Override header +async fn file_operations( + Path(file_id): Path, + Query(token_query): Query, + headers: HeaderMap, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let override_header = headers + .get("X-WOPI-Override") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let lock_id = headers + .get("X-WOPI-Lock") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + match override_header { + "LOCK" => { + if lock_id.is_empty() { + return StatusCode::BAD_REQUEST.into_response(); + } + match state.lock_service.lock(&file_id, lock_id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(conflict) => ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", conflict.existing_lock_id.as_str())], + "", + ) + .into_response(), + } + } + "UNLOCK" => match state.lock_service.unlock(&file_id, lock_id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(conflict) => ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", conflict.existing_lock_id.as_str())], + "", + ) + .into_response(), + }, + "REFRESH_LOCK" => match state.lock_service.refresh_lock(&file_id, lock_id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(conflict) => ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", conflict.existing_lock_id.as_str())], + "", + ) + .into_response(), + }, + "GET_LOCK" => { + let current = state.lock_service.get_lock(&file_id).await; + let lock_val = current.unwrap_or_default(); + (StatusCode::OK, [("X-WOPI-Lock", lock_val.as_str())], "").into_response() + } + _ => (StatusCode::NOT_IMPLEMENTED, "Unknown WOPI override").into_response(), + } +} + +/// Parameters for the editor URL API endpoint. +#[derive(Deserialize)] +pub struct EditorUrlParams { + pub file_id: String, + #[serde(default = "default_action")] + pub action: String, +} + +fn default_action() -> String { + "edit".to_string() +} + +/// Response from the editor URL API endpoint. +#[derive(Serialize)] +pub struct EditorUrlResponse { + pub editor_url: String, + pub access_token: String, + pub access_token_ttl: i64, +} + +/// GET /api/wopi/editor-url — Returns the editor iframe URL + WOPI token. +/// +/// This endpoint is behind normal auth middleware. The authenticated user +/// requests a WOPI session for a specific file. +pub async fn get_editor_url( + AuthUser { + id: user_id, + username, + }: AuthUser, + Query(params): Query, + State(state): State, +) -> Response { + // Get file info to determine extension + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(¶ms.file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + // Extract extension from filename + let extension = file.name.rsplit('.').next().unwrap_or("").to_lowercase(); + + // Build WOPISrc + let wopi_src = format!("{}/wopi/files/{}", state.wopi_base_url, params.file_id); + + // Get editor action URL from discovery + let editor_url = match state + .discovery_service + .get_action_url(&extension, ¶ms.action, &wopi_src) + .await + { + Ok(Some(url)) => url, + Ok(None) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + format!("No editor available for .{} files", extension), + ) + .into_response(); + } + Err(e) => { + tracing::error!("WOPI discovery error: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + // Determine write permission: owner can write, others read-only. + // If no owner_id on the file, default to allowing write. + let can_write = match &file.owner_id { + Some(owner) => owner == &user_id, + None => true, + }; + + // Generate WOPI access token + let (access_token, access_token_ttl) = + match state + .token_service + .generate_token(¶ms.file_id, &user_id, &username, can_write) + { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to generate WOPI token: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + axum::Json(EditorUrlResponse { + editor_url, + access_token, + access_token_ttl, + }) + .into_response() +} + +/// GET /wopi/edit/{file_id} — Server-rendered host page for new-tab editing. +/// +/// Returns a minimal HTML page that POSTs the access token to the editor iframe. +async fn host_page( + Path(file_id): Path, + Query(token_query): Query, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // Get file info for extension + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(&file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + let extension = file.name.rsplit('.').next().unwrap_or("").to_lowercase(); + let action = if claims.can_write { "edit" } else { "view" }; + let wopi_src = format!("{}/wopi/files/{}", state.wopi_base_url, file_id); + + let editor_url = match state + .discovery_service + .get_action_url(&extension, action, &wopi_src) + .await + { + Ok(Some(url)) => url, + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + let (token, ttl) = match state.token_service.generate_token( + &file_id, + &claims.sub, + &claims.username, + claims.can_write, + ) { + Ok(t) => t, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + // Escape HTML entities in file name + let safe_name = file + .name + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + + let html = format!( + r#" + + + + {safe_name} - OxiCloud Editor + + + +
+ + +
+ + + +"# + ); + + Html(html).into_response() +} + +/// GET /wopi/supported-extensions — Returns extensions the editor supports. +/// +/// Public endpoint (no auth) so the frontend can dynamically show/hide +/// the "Edit in Office" context menu option. +async fn get_supported_extensions(State(state): State) -> Response { + match state.discovery_service.get_supported_extensions().await { + Ok(exts) => axum::Json(exts).into_response(), + Err(e) => { + tracing::error!("Failed to get supported extensions: {}", e); + axum::Json(Vec::::new()).into_response() + } + } +} + +/// Build all WOPI routes. +/// +/// Returns a tuple: (wopi_protocol_router, wopi_api_router) +/// - wopi_protocol_router: mounted at `/wopi` (no auth middleware) +/// - wopi_api_router: mounted at `/api/wopi` (behind auth middleware) +pub fn wopi_routes( + wopi_state: WopiState, +) -> ( + Router, + Router, +) { + let protocol_router = Router::new() + // CheckFileInfo + .route("/files/{file_id}", get(check_file_info)) + // Lock/Unlock/RefreshLock/GetLock + .route("/files/{file_id}", post(file_operations)) + // GetFile + .route("/files/{file_id}/contents", get(get_file)) + // PutFile + .route("/files/{file_id}/contents", post(put_file)) + // Host page for new-tab editing + .route("/edit/{file_id}", get(host_page)) + // Supported extensions (public, no auth) + .route("/supported-extensions", get(get_supported_extensions)) + .with_state(wopi_state.clone()); + + let api_router = Router::new() + .route("/editor-url", get(get_editor_url)) + .with_state(wopi_state); + + (protocol_router, api_router) +} diff --git a/src/main.rs b/src/main.rs index d3f5b9f1..e24daa77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,9 @@ use interfaces::{create_api_routes, create_public_api_routes, web::create_web_ro #[tokio::main] async fn main() -> Result<(), Box> { + // Load .env file if present (for local development) + dotenvy::dotenv().ok(); + // Initialize tracing tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( @@ -98,6 +101,38 @@ async fn main() -> Result<(), Box> { let carddav_router = carddav_handler::carddav_routes(); let webdav_router = webdav_handler::webdav_routes(); + // Build WOPI routes if enabled + use oxicloud::interfaces::api::handlers::wopi_handler; + let wopi_routes = if config.wopi.enabled { + if let (Some(token_svc), Some(lock_svc), Some(discovery_svc)) = ( + &app_state.wopi_token_service, + &app_state.wopi_lock_service, + &app_state.wopi_discovery_service, + ) { + let wopi_base_url = std::env::var("OXICLOUD_WOPI_BASE_URL") + .map(|v| v.trim_end_matches('/').to_string()) + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| config.base_url()); + + let wopi_state = wopi_handler::WopiState { + token_service: token_svc.clone(), + lock_service: lock_svc.clone(), + discovery_service: discovery_svc.clone(), + app_state: app_state.clone(), + public_base_url: config.base_url(), + wopi_base_url, + }; + + let (protocol, api) = wopi_handler::wopi_routes(wopi_state); + Some((protocol, api)) + } else { + None + } + } else { + None + }; + // Apply auth middleware to protected API routes when auth is enabled if config.features.enable_auth && app_state.auth_service.is_some() { use interfaces::api::handlers::auth_handler::auth_routes; @@ -139,6 +174,17 @@ async fn main() -> Result<(), Box> { .merge(webdav_protected) .merge(web_routes) .layer(TraceLayer::new_for_http()); + + // Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware) + if let Some((wopi_protocol, wopi_api)) = wopi_routes { + let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state( + Arc::new(app_state.clone()), + auth_middleware, + )); + app = app + .nest("/wopi", wopi_protocol) + .nest("/api/wopi", wopi_api_protected); + } } else { // Auth disabled — no middleware applied tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible"); @@ -151,6 +197,11 @@ async fn main() -> Result<(), Box> { .merge(webdav_router) .merge(web_routes) .layer(TraceLayer::new_for_http()); + + // Mount WOPI routes (no auth middleware when auth is disabled) + if let Some((wopi_protocol, wopi_api)) = wopi_routes { + app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api); + } } // Apply the redirect middleware for legacy routes diff --git a/static/index.html b/static/index.html index 2457b0ab..c443188d 100644 --- a/static/index.html +++ b/static/index.html @@ -34,6 +34,7 @@ + diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 7dc48e51..859fbc1d 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -48,6 +48,12 @@ const ui = {
View
+ +
Download
@@ -721,6 +727,11 @@ const ui = { if (window.recent) { document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } })); } + // WOPI editor intercept: open Office documents in the WOPI editor + if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) { + window.wopiEditor.openInModal(file.id, file.name, 'edit'); + return; + } if (self.isViewableFile(file)) { if (window.inlineViewer) window.inlineViewer.openFile(file); else window.fileOps.downloadFile(file.id, file.name); @@ -838,6 +849,9 @@ const ui = { if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') { window.contextMenus.syncFavoriteOptionLabels(); } + if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') { + window.contextMenus.syncWopiOptionVisibility().catch(function(){}); + } menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; menu.style.display = 'block'; @@ -1268,6 +1282,9 @@ function showContextMenuAtElement(triggerElement, menuId) { if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') { window.contextMenus.syncFavoriteOptionLabels(); } + if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') { + window.contextMenus.syncWopiOptionVisibility().catch(function(){}); + } menu.style.left = `${left}px`; menu.style.top = `${top}px`; diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index 0f62bfe7..b37dc8cb 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -15,6 +15,23 @@ const contextMenus = { : (isFavorite ? 'Remove from favorites' : 'Add to favorites'); }, + /** + * Show or hide WOPI editor options based on current target file + */ + async syncWopiOptionVisibility() { + const wopiEdit = document.getElementById('wopi-edit-file-option'); + const wopiEditTab = document.getElementById('wopi-edit-file-tab-option'); + if (!wopiEdit || !wopiEditTab) return; + + const targetFile = window.app && window.app.contextMenuTargetFile; + const show = targetFile && + window.wopiEditor && + await window.wopiEditor.canEdit(targetFile.name); + + wopiEdit.style.display = show ? '' : 'none'; + wopiEditTab.style.display = show ? '' : 'none'; + }, + syncFavoriteOptionLabels() { if (!window.favorites) return; @@ -137,7 +154,23 @@ const contextMenus = { } window.ui.closeFileContextMenu(); }); - + + document.getElementById('wopi-edit-file-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFile) { + const file = window.app.contextMenuTargetFile; + window.wopiEditor.openInModal(file.id, file.name, 'edit'); + } + window.ui.closeFileContextMenu(); + }); + + document.getElementById('wopi-edit-file-tab-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFile) { + const file = window.app.contextMenuTargetFile; + window.wopiEditor.openInTab(file.id, file.name, 'edit'); + } + window.ui.closeFileContextMenu(); + }); + document.getElementById('download-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { window.fileOps.downloadFile( diff --git a/static/js/features/files/inlineViewer.js b/static/js/features/files/inlineViewer.js index 6ca10638..b732ea79 100644 --- a/static/js/features/files/inlineViewer.js +++ b/static/js/features/files/inlineViewer.js @@ -90,6 +90,13 @@ class InlineViewer { openFile(file) { console.log('Opening file:', file); + + // WOPI editor intercept: open Office documents in the WOPI editor + if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) { + window.wopiEditor.openInModal(file.id, file.name, 'edit'); + return; + } + this.currentFile = file; // Get container diff --git a/static/js/features/files/wopiEditor.js b/static/js/features/files/wopiEditor.js new file mode 100644 index 00000000..035a8b62 --- /dev/null +++ b/static/js/features/files/wopiEditor.js @@ -0,0 +1,238 @@ +/** + * OxiCloud WOPI Editor Integration + * + * Opens document files in Collabora Online / OnlyOffice via WOPI protocol. + * Supports two modes: in-app modal (default) and new browser tab. + */ +class WopiEditor { + constructor() { + this.editorModal = null; + this._escHandler = null; + this._messageHandler = null; + this._supportedExtensions = null; + } + + /** + * Check if a file can be opened in a WOPI editor by extension. + * Fetches supported extensions from the server (cached after first call). + */ + async canEdit(filename) { + var ext = filename.split('.').pop().toLowerCase(); + var supported = await this._getSupportedExtensions(); + return supported.includes(ext); + } + + /** + * Open file in a modal overlay (default mode). + */ + async openInModal(fileId, fileName, action) { + action = action || 'edit'; + try { + var data = await this._getEditorUrl(fileId, action); + this._showModal(data, fileName); + } catch (error) { + console.error('Failed to open WOPI editor:', error); + if (window.showNotification) { + window.showNotification('Could not open the document editor.', 'error'); + } + } + } + + /** + * Open file in a new browser tab. + */ + async openInTab(fileId, fileName, action) { + action = action || 'edit'; + try { + var data = await this._getEditorUrl(fileId, action); + var hostUrl = '/wopi/edit/' + encodeURIComponent(fileId) + + '?access_token=' + encodeURIComponent(data.access_token); + window.open(hostUrl, '_blank'); + } catch (error) { + console.error('Failed to open WOPI editor in tab:', error); + if (window.showNotification) { + window.showNotification('Could not open the document editor.', 'error'); + } + } + } + + /** + * Fetch editor URL and WOPI token from the backend. + */ + async _getEditorUrl(fileId, action) { + var token = localStorage.getItem('oxicloud_token') || ''; + var response = await fetch( + '/api/wopi/editor-url?file_id=' + encodeURIComponent(fileId) + '&action=' + encodeURIComponent(action), + { + headers: { 'Authorization': 'Bearer ' + token } + } + ); + if (!response.ok) { + var text = await response.text(); + throw new Error('Editor URL request failed: ' + response.status + ' ' + text); + } + return response.json(); + } + + /** + * Show the editor in a full-screen modal with iframe. + */ + _showModal(editorData, fileName) { + this.closeEditor(); + + var modal = document.createElement('div'); + modal.id = 'wopi-editor-modal'; + modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;background:#fff;'; + + var header = document.createElement('div'); + header.style.cssText = 'height:40px;background:#333;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-family:sans-serif;font-size:14px;'; + + var title = document.createElement('span'); + title.textContent = fileName; + header.appendChild(title); + + var closeBtn = document.createElement('button'); + closeBtn.textContent = '\u2715'; + closeBtn.style.cssText = 'background:none;border:none;color:#fff;cursor:pointer;font-size:18px;padding:4px 8px;'; + closeBtn.onclick = this.closeEditor.bind(this); + header.appendChild(closeBtn); + + var form = document.createElement('form'); + form.id = 'wopi_form'; + form.target = 'wopi_frame'; + form.action = editorData.editor_url; + form.method = 'post'; + form.style.display = 'none'; + + var tokenInput = document.createElement('input'); + tokenInput.name = 'access_token'; + tokenInput.value = editorData.access_token; + tokenInput.type = 'hidden'; + form.appendChild(tokenInput); + + var ttlInput = document.createElement('input'); + ttlInput.name = 'access_token_ttl'; + ttlInput.value = editorData.access_token_ttl; + ttlInput.type = 'hidden'; + form.appendChild(ttlInput); + + var frameHolder = document.createElement('div'); + frameHolder.style.cssText = 'position:absolute;top:40px;left:0;right:0;bottom:0;'; + + // Loading spinner (removed once the editor signals ready) + var spinner = document.createElement('div'); + spinner.id = 'wopi-loading-spinner'; + spinner.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background:#f5f5f5;z-index:1;'; + spinner.innerHTML = ''; + frameHolder.appendChild(spinner); + + var iframe = document.createElement('iframe'); + iframe.name = 'wopi_frame'; + iframe.title = 'Document Editor'; + iframe.style.cssText = 'width:100%;height:100%;border:none;'; + iframe.setAttribute('allowfullscreen', 'true'); + // Fix 9: allow clipboard access for copy/paste inside the editor + iframe.setAttribute('allow', 'clipboard-read; clipboard-write'); + iframe.setAttribute('sandbox', + 'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox'); + frameHolder.appendChild(iframe); + + modal.appendChild(header); + modal.appendChild(form); + modal.appendChild(frameHolder); + document.body.appendChild(modal); + + // ESC key handler + this._escHandler = function(e) { + if (e.key === 'Escape') this.closeEditor(); + }.bind(this); + document.addEventListener('keydown', this._escHandler); + + // Fix 7: Listen for postMessage from the editor iframe + this._messageHandler = function(e) { + var data; + try { + data = JSON.parse(e.data); + } catch (_) { + return; // Not a JSON message — ignore + } + var msgId = data.MessageId || data.messageId || ''; + if (msgId === 'UI_Close' || msgId === 'close') { + this.closeEditor(); + } else if (msgId === 'App_LoadingStatus') { + var status = data.Values && data.Values.Status; + if (status === 'Document_Loaded' || status === 'Frame_Ready') { + var sp = document.getElementById('wopi-loading-spinner'); + if (sp) sp.remove(); + } + } + }.bind(this); + window.addEventListener('message', this._messageHandler); + + form.submit(); + this.editorModal = modal; + } + + /** + * Close the editor modal and refresh the file list. + */ + closeEditor() { + var modal = document.getElementById('wopi-editor-modal'); + if (modal) modal.remove(); + if (this._escHandler) { + document.removeEventListener('keydown', this._escHandler); + this._escHandler = null; + } + if (this._messageHandler) { + window.removeEventListener('message', this._messageHandler); + this._messageHandler = null; + } + this.editorModal = null; + // Refresh file list to pick up any saves + if (typeof loadFiles === 'function') { + loadFiles(); + } + } + + /** + * Fetch supported extensions from the server (cached). + */ + async _getSupportedExtensions() { + if (this._supportedExtensions !== null) { + return this._supportedExtensions; + } + return this._fetchSupportedExtensions(); + } + + /** + * Fetch supported extensions from /wopi/supported-extensions. + * Falls back to a hardcoded list on failure. + */ + async _fetchSupportedExtensions() { + try { + var response = await fetch('/wopi/supported-extensions'); + if (response.ok) { + var exts = await response.json(); + if (Array.isArray(exts) && exts.length > 0) { + this._supportedExtensions = exts; + return exts; + } + } + } catch (_) { + // Ignore — fall through to hardcoded list + } + // Fallback hardcoded list + this._supportedExtensions = [ + 'docx', 'doc', 'odt', 'rtf', 'txt', + 'xlsx', 'xls', 'ods', 'csv', + 'pptx', 'ppt', 'odp', + ]; + return this._supportedExtensions; + } +} + +// Global instance +window.wopiEditor = new WopiEditor(); + +// Prefetch supported extensions so canEdit() is fast on first use +window.wopiEditor._fetchSupportedExtensions();