Merge pull request #133 from zjean/feature/wopi-upstream
This commit is contained in:
@@ -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"
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Utc>,
|
||||
|
||||
// ── Enriched metadata (resolved via JOIN) ──
|
||||
|
||||
/// Display name of the file or folder
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_name: Option<String>,
|
||||
@@ -45,7 +46,6 @@ pub struct FavoriteItemDto {
|
||||
pub modified_at: Option<DateTime<Utc>>,
|
||||
|
||||
// ── 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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,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<String>,
|
||||
}
|
||||
|
||||
impl From<File> for FileDto {
|
||||
@@ -64,6 +69,7 @@ impl From<File> 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +108,7 @@ impl FileDto {
|
||||
icon_special_class: String::new(),
|
||||
category: "Document".to_string(),
|
||||
size_formatted: "0 Bytes".to_string(),
|
||||
owner_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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<Utc>,
|
||||
|
||||
// ── Enriched metadata (resolved via JOIN) ──
|
||||
|
||||
/// Display name of the file or folder
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_name: Option<String>,
|
||||
@@ -41,7 +42,6 @@ pub struct RecentItemDto {
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
// ── 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();
|
||||
|
||||
@@ -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<u64>;
|
||||
async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result<u64>;
|
||||
}
|
||||
|
||||
@@ -47,17 +47,30 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||
|
||||
/// Renames a folder (ownership verified against caller_id)
|
||||
async fn rename_folder(&self, id: &str, dto: RenameFolderDto, caller_id: &str)
|
||||
-> Result<FolderDto, DomainError>;
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: RenameFolderDto,
|
||||
caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError>;
|
||||
|
||||
/// Moves a folder to another parent (ownership verified against caller_id)
|
||||
async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError>;
|
||||
async fn move_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: MoveFolderDto,
|
||||
caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError>;
|
||||
|
||||
/// 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<FolderDto, DomainError>;
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: String,
|
||||
) -> Result<FolderDto, DomainError>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -112,7 +112,11 @@ impl FolderService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result<FolderDto, DomainError> {
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_name: String,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
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<FolderDto, DomainError> {
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: String,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
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<FolderDto, DomainError> {
|
||||
async fn move_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: MoveFolderDto,
|
||||
caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
// 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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn FolderStoragePort>,
|
||||
current_folder_id: Option<String>,
|
||||
criteria: Arc<SearchCriteriaDto>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(Vec<FileDto>, Vec<FolderDto>)>> + Send>> {
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<(Vec<FileDto>, Vec<FolderDto>)>> + 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<FileDto> = 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<FolderDto> = 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<FolderDto> = folders
|
||||
let filtered_files: Vec<FileDto> = 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<FolderDto> = 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<FolderDto> = 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));
|
||||
|
||||
@@ -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<RwLock<HashMap<String, LockEntry>>>,
|
||||
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<String> {
|
||||
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<Self>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<WopiTokenClaims, DomainError> {
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
|
||||
let token_data = decode::<WopiTokenClaims>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
+60
-1
@@ -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::<bool>().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::<i64>()
|
||||
{
|
||||
config.wopi.token_ttl_secs = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_WOPI_LOCK_TTL_SECS")
|
||||
&& let Ok(val) = v.parse::<u64>()
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>>,
|
||||
pub contact_use_case: Option<Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>>,
|
||||
pub wopi_token_service:
|
||||
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
||||
pub wopi_lock_service:
|
||||
Option<Arc<crate::application::services::wopi_lock_service::WopiLockService>>,
|
||||
pub wopi_discovery_service:
|
||||
Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-3
@@ -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<FolderDto, DomainError> {
|
||||
async fn move_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_dto: MoveFolderDto,
|
||||
_caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
|
||||
@@ -423,7 +430,11 @@ impl FolderUseCase for StubFolderUseCase {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result<FolderDto, DomainError> {
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_name: String,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
// 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<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
owner_id: Option<String>,
|
||||
) -> FileResult<Self> {
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,15 @@ impl Folder {
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
) -> FolderResult<Self> {
|
||||
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)
|
||||
|
||||
@@ -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<Folder, DomainError>;
|
||||
async fn create_home_folder(&self, user_id: &str, name: String) -> Result<Folder, DomainError>;
|
||||
}
|
||||
|
||||
@@ -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<u64> {
|
||||
async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result<u64> {
|
||||
if items.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ impl FileBlobReadRepository {
|
||||
mime_type: String,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
owner_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
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<String>, // 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,48 +140,61 @@ 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<Vec<File>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, Option<String>, i64, String, i64, i64)> =
|
||||
if let Some(fid) = folder_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
Option<String>,
|
||||
)> = 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
|
||||
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,
|
||||
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
|
||||
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)| {
|
||||
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()
|
||||
}
|
||||
@@ -289,12 +306,26 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
let row = if folder_path.is_empty() {
|
||||
// File at root level (no parent folder)
|
||||
sqlx::query_as::<_, (String, String, Option<String>, Option<String>, i64, String, i64, i64)>(
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
Option<String>,
|
||||
),
|
||||
>(
|
||||
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
|
||||
@@ -305,12 +336,26 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.await
|
||||
} else {
|
||||
// File inside a folder — look up by folder path + filename
|
||||
sqlx::query_as::<_, (String, String, Option<String>, Option<String>, i64, String, i64, i64)>(
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
Option<String>,
|
||||
),
|
||||
>(
|
||||
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
|
||||
@@ -325,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),
|
||||
}
|
||||
|
||||
@@ -55,16 +55,18 @@ impl FileBlobWriteRepository {
|
||||
) -> Result<Option<String>, 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),
|
||||
@@ -81,6 +83,7 @@ impl FileBlobWriteRepository {
|
||||
mime_type: String,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
owner_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
||||
File::with_timestamps(
|
||||
@@ -92,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}")))
|
||||
}
|
||||
@@ -179,7 +183,17 @@ 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,
|
||||
Some(user_id),
|
||||
)
|
||||
}
|
||||
|
||||
async fn save_file_from_temp(
|
||||
@@ -261,6 +275,7 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
content_type,
|
||||
row.1,
|
||||
row.2,
|
||||
Some(user_id),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -288,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(
|
||||
@@ -371,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<File, DomainError> {
|
||||
@@ -400,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> {
|
||||
@@ -532,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/)
|
||||
|
||||
@@ -175,9 +175,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
let rows: Vec<(String, String, String, Option<String>, 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<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
let rows: Vec<(String, String, String, Option<String>, 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>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
let rows: Vec<(String, String, String, Option<String>, 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<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
@@ -360,9 +363,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
let rows: Vec<(String, String, String, Option<String>, 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<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
@@ -503,14 +509,13 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||
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<Folder, DomainError> {
|
||||
async fn create_home_folder(&self, user_id: &str, name: String) -> Result<Folder, DomainError> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<WopiAction>
|
||||
actions: Arc<RwLock<HashMap<String, Vec<WopiAction>>>>,
|
||||
last_fetched: Arc<RwLock<Option<Instant>>>,
|
||||
cache_ttl: Duration,
|
||||
/// HTTP client with timeout (shared across requests).
|
||||
http_client: reqwest::Client,
|
||||
/// Mutex to prevent concurrent refresh stampede.
|
||||
refreshing: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
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<Option<String>, 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<bool, DomainError> {
|
||||
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<Vec<String>, 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<HashMap<String, Vec<WopiAction>>, DomainError> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut actions: HashMap<String, Vec<WopiAction>> = 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#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<wopi-discovery>
|
||||
<net-zone name="external-https">
|
||||
<app name="Word">
|
||||
<action name="view" ext="docx" urlsrc="https://collabora/cool/word/view?WOPISrc=WOPI_SOURCE&lang=UI_LLCC"/>
|
||||
<action name="edit" ext="docx" urlsrc="https://collabora/cool/word/edit?WOPISrc=WOPI_SOURCE&lang=UI_LLCC"/>
|
||||
</app>
|
||||
<app name="Excel">
|
||||
<action name="edit" ext="xlsx" urlsrc="https://collabora/cool/calc/edit?WOPISrc=WOPI_SOURCE"/>
|
||||
</app>
|
||||
<app name="Impress">
|
||||
<action name="view" ext="pptx" urlsrc="https://collabora/cool/impress/view?WOPISrc=WOPI_SOURCE"/>
|
||||
</app>
|
||||
</net-zone>
|
||||
</wopi-discovery>"#;
|
||||
|
||||
#[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&<lang=UI_LLCC&><ui=UI_LLCC&>";
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
pagination: Query<PaginationRequestDto>,
|
||||
) -> 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,
|
||||
|
||||
@@ -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<T> = Result<T, (axum::http::StatusCode, String)>;
|
||||
|
||||
@@ -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)))?;
|
||||
}
|
||||
|
||||
@@ -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<WopiTokenService>,
|
||||
pub lock_service: Arc<WopiLockService>,
|
||||
pub discovery_service: Arc<WopiDiscoveryService>,
|
||||
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<String>,
|
||||
Query(token_query): Query<WopiTokenQuery>,
|
||||
State(state): State<WopiState>,
|
||||
) -> 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<String>,
|
||||
Query(token_query): Query<WopiTokenQuery>,
|
||||
State(state): State<WopiState>,
|
||||
) -> 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<String>,
|
||||
Query(token_query): Query<WopiTokenQuery>,
|
||||
headers: HeaderMap,
|
||||
State(state): State<WopiState>,
|
||||
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<String>,
|
||||
Query(token_query): Query<WopiTokenQuery>,
|
||||
headers: HeaderMap,
|
||||
State(state): State<WopiState>,
|
||||
) -> 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<EditorUrlParams>,
|
||||
State(state): State<WopiState>,
|
||||
) -> 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<String>,
|
||||
Query(token_query): Query<WopiTokenQuery>,
|
||||
State(state): State<WopiState>,
|
||||
) -> 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#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{safe_name} - OxiCloud Editor</title>
|
||||
<style>
|
||||
body {{ margin: 0; overflow: hidden; }}
|
||||
iframe {{ width: 100%; height: 100vh; border: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form id="wopi_form" action="{editor_url}" method="post" target="wopi_frame">
|
||||
<input name="access_token" value="{token}" type="hidden"/>
|
||||
<input name="access_token_ttl" value="{ttl}" type="hidden"/>
|
||||
</form>
|
||||
<iframe name="wopi_frame" allowfullscreen
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox">
|
||||
</iframe>
|
||||
<script>document.getElementById('wopi_form').submit();</script>
|
||||
</body>
|
||||
</html>"#
|
||||
);
|
||||
|
||||
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<WopiState>) -> 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::<String>::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<crate::common::di::AppState>,
|
||||
Router<crate::common::di::AppState>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
+51
@@ -40,6 +40,9 @@ use interfaces::{create_api_routes, create_public_api_routes, web::create_web_ro
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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<dyn std::error::Error>> {
|
||||
.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
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<script defer src="/js/features/sharing/fileSharing.js"></script>
|
||||
<script defer src="/js/views/shared/sharedView.js"></script>
|
||||
<script defer src="/js/features/files/inlineViewer.js"></script>
|
||||
<script defer src="/js/features/files/wopiEditor.js"></script>
|
||||
<script defer src="/js/core/icons.js"></script>
|
||||
<script defer src="/js/app/navigation.js"></script>
|
||||
<script defer src="/js/app/authSession.js"></script>
|
||||
|
||||
@@ -48,6 +48,12 @@ const ui = {
|
||||
<div class="context-menu-item" id="view-file-option">
|
||||
<i class="fas fa-eye"></i> <span data-i18n="actions.view">View</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="wopi-edit-file-option" style="display:none">
|
||||
<i class="fas fa-file-word"></i> <span>Edit in Office</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="wopi-edit-file-tab-option" style="display:none">
|
||||
<i class="fas fa-external-link-alt"></i> <span>Edit in Office (new tab)</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="download-file-option">
|
||||
<i class="fas fa-download"></i> <span data-i18n="actions.download">Download</span>
|
||||
</div>
|
||||
@@ -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`;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '<i class="fas fa-spinner fa-spin" style="font-size:48px;color:#666;"></i>';
|
||||
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();
|
||||
Reference in New Issue
Block a user