From 60e4329eedf7f6a678eed3a1600ba6c8d47ba506 Mon Sep 17 00:00:00 2001 From: Warren Date: Mon, 22 Jun 2026 10:38:25 +0800 Subject: [PATCH] Add VirtualFs tag-mode WebDAV + MyFiles UI + Admin WebDAV endpoint - VirtualFs: SQLite-backed virtual folders (tag mode), 16 unit tests - MyFiles module: API endpoints + Web UI for folder/tag management - Admin WebDAV: /admin-webdav/*path with Basic Auth + URI prefix rewrite - CLI: webdav-folder/tag/untag/list/start --virtual-mode commands - Deployed and tested on M5Max48: PROPFIND, PUT, GET, DELETE all working --- markbase-core/src/cli/interface/webdav.rs | 193 +++++- markbase-core/src/lib.rs | 1 + markbase-core/src/myfiles.rs | 533 ++++++++++++++++ markbase-core/src/server.rs | 94 +++ markbase-core/src/vfs/mod.rs | 1 + markbase-core/src/vfs/virtual_fs.rs | 737 ++++++++++++++++++++++ markbase-core/src/webdav.rs | 76 ++- 7 files changed, 1596 insertions(+), 39 deletions(-) create mode 100644 markbase-core/src/myfiles.rs create mode 100644 markbase-core/src/vfs/virtual_fs.rs diff --git a/markbase-core/src/cli/interface/webdav.rs b/markbase-core/src/cli/interface/webdav.rs index 79549f8..eef2859 100644 --- a/markbase-core/src/cli/interface/webdav.rs +++ b/markbase-core/src/cli/interface/webdav.rs @@ -19,13 +19,65 @@ pub enum WebdavCommand { port: u16, #[arg(short, long)] user: String, + #[arg(long, help = "Enable SQLite virtual directory mode")] + virtual_mode: bool, + #[arg(long, help = "SQLite database path for virtual directories")] + db: Option, + }, + + #[command(name = "webdav-folder")] + Folder { + #[arg(long, help = "Action: add, remove")] + action: String, + #[arg(long, help = "Virtual folder name (e.g. photos)")] + name: String, + #[arg(long, help = "Description for the folder")] + description: Option, + #[arg(long, default_value = "data/webdav_virtual.sqlite")] + db: String, + }, + + #[command(name = "webdav-tag")] + Tag { + #[arg(long, help = "Filename to tag (relative to root)")] + file: String, + #[arg(long, help = "Tag name (= virtual folder name)")] + tag: String, + #[arg(long, default_value = "data/webdav_virtual.sqlite")] + db: String, + }, + + #[command(name = "webdav-untag")] + Untag { + #[arg(long, help = "Filename to untag")] + file: String, + #[arg(long, help = "Tag name to remove")] + tag: String, + #[arg(long, default_value = "data/webdav_virtual.sqlite")] + db: String, + }, + + #[command(name = "webdav-list")] + List { + #[arg(long, help = "List folders, or files in a folder")] + what: Option, + #[arg(long, help = "Folder name (for listing files in a folder)")] + folder: Option, + #[arg(long, help = "Filename (for listing tags of a file)")] + file: Option, + #[arg(long, default_value = "data/webdav_virtual.sqlite")] + db: String, }, } pub async fn handle_webdav_command(cmd: WebdavCommand) -> anyhow::Result<()> { match cmd { - WebdavCommand::Start { port, user } => { - // Parse username and optional password (format: "name:password") + WebdavCommand::Start { + port, + user, + virtual_mode, + db, + } => { let username = user.split(':').next().unwrap_or(&user).to_string(); let password = user.split(':').nth(1).map(|s| s.to_string()); @@ -48,9 +100,103 @@ pub async fn handle_webdav_command(cmd: WebdavCommand) -> anyhow::Result<()> { } println!("Port: {}", port); println!("Home: {}", home_dir.display()); + if virtual_mode { + let db_path = db.clone().unwrap_or_else(|| "data/webdav_virtual.sqlite".to_string()); + println!("Virtual mode: enabled (db: {})", db_path); + } println!(); - run_webdav_server(port, home_dir, username, password).await?; + run_webdav_server(port, home_dir, username, password, virtual_mode, db).await?; + } + + WebdavCommand::Folder { + action, + name, + description, + db, + } => { + let vfs = crate::vfs::virtual_fs::VirtualFs::new(&db, PathBuf::from("/tmp"))?; + let folder = if name.starts_with('/') { + name + } else { + format!("/{}", name) + }; + match action.as_str() { + "add" => { + let desc = description.unwrap_or_default(); + vfs.add_folder(&folder, &desc)?; + println!("Added virtual folder: {} ({})", folder, desc); + } + "remove" => { + vfs.remove_folder(&folder)?; + println!("Removed virtual folder: {}", folder); + } + _ => { + return Err(anyhow::anyhow!("Unknown action: {} (use add or remove)", action)); + } + } + } + + WebdavCommand::Tag { file, tag, db } => { + let vfs = crate::vfs::virtual_fs::VirtualFs::new(&db, PathBuf::from("/tmp"))?; + vfs.tag_file(&file, &tag)?; + println!("Tagged '{}' with '{}'", file, tag); + } + + WebdavCommand::Untag { file, tag, db } => { + let vfs = crate::vfs::virtual_fs::VirtualFs::new(&db, PathBuf::from("/tmp"))?; + vfs.untag_file(&file, &tag)?; + println!("Untagged '{}' from '{}'", file, tag); + } + + WebdavCommand::List { + what, + folder, + file, + db, + } => { + let vfs = crate::vfs::virtual_fs::VirtualFs::new(&db, PathBuf::from("/tmp"))?; + match what.as_deref() { + Some("folders") | None => { + let folders = vfs.list_folders()?; + if folders.is_empty() { + println!("No virtual folders."); + } else { + println!("{:<30} {}", "Folder", "Description"); + println!("{}", "-".repeat(60)); + for (f, d) in folders { + println!("{:<30} {}", f, d); + } + } + } + Some("files") => { + let folder_name = folder.ok_or_else(|| anyhow::anyhow!("--folder required for listing files"))?; + let files = vfs.list_files_in_folder(&folder_name)?; + if files.is_empty() { + println!("No files in folder '{}'", folder_name); + } else { + println!("Files in folder '{}':", folder_name); + for f in files { + println!(" {}", f); + } + } + } + Some("tags") => { + let filename = file.ok_or_else(|| anyhow::anyhow!("--file required for listing tags"))?; + let tags = vfs.list_tags_for_file(&filename)?; + if tags.is_empty() { + println!("No tags for file '{}'", filename); + } else { + println!("Tags for file '{}':", filename); + for t in tags { + println!(" {}", t); + } + } + } + Some(other) => { + return Err(anyhow::anyhow!("Unknown list type: {} (use folders, files, or tags)", other)); + } + } } } Ok(()) @@ -61,22 +207,36 @@ async fn run_webdav_server( home_dir: PathBuf, user: String, password: Option, + virtual_mode: bool, + db: Option, ) -> anyhow::Result<()> { use axum::{routing::any, Router}; use tokio::net::TcpListener; - let vfs = Box::new(crate::vfs::local_fs::LocalFs::new()); - let upload_hook = None; + let dav_handler = if virtual_mode { + let db_path = db.unwrap_or_else(|| "data/webdav_virtual.sqlite".to_string()); + let vfs = crate::vfs::virtual_fs::VirtualFs::new(&db_path, home_dir.clone())?; - let dav_handler = crate::webdav::create_webdav_handler(vfs, home_dir.clone(), upload_hook, user.clone()); + let folders = vfs.list_folders().unwrap_or_default(); + println!("Virtual folders ({}):", folders.len()); + for (f, d) in &folders { + println!(" {} - {}", f, d); + } + println!("Default root: {}", home_dir.display()); + println!(); + let vfs_boxed: Box = Box::new(vfs); + crate::webdav::create_webdav_handler_virtual(vfs_boxed, PathBuf::from("/"), None, user.clone()) + } else { + let vfs = Box::new(crate::vfs::local_fs::LocalFs::new()); + crate::webdav::create_webdav_handler(vfs, home_dir.clone(), None, user.clone()) + }; async fn webdav_auth_middleware( req: Request, next: middleware::Next, ) -> impl IntoResponse { - // Get credentials from extensions (without consuming) let expected = req.extensions().get::().cloned(); - + let auth = req .headers() .get("Authorization") @@ -107,7 +267,8 @@ async fn run_webdav_server( HeaderValue::from_static("Basic realm=\"MarkBase WebDAV\""), )], Body::from("Unauthorized"), - ).into_response(); + ) + .into_response(); } next.run(req).await @@ -116,19 +277,23 @@ async fn run_webdav_server( let app = Router::new() .route("/", any(handle_dav)) .route("/*path", any(handle_dav)) + .layer(Extension(dav_handler)) + .layer(middleware::from_fn(webdav_auth_middleware)) .layer(Extension(crate::webdav::WebdavCredentials { username: user.clone(), password, - })) - .layer(Extension(dav_handler)) - .layer(middleware::from_fn(webdav_auth_middleware)); + })); let addr = format!("0.0.0.0:{}", port); let listener = TcpListener::bind(&addr).await?; println!("WebDAV server listening on http://{}", addr); - println!("Root: {}", home_dir.display()); println!("User: {}", user); + if virtual_mode { + println!("Mode: Virtual (SQLite tag-based)"); + } else { + println!("Mode: Local"); + } println!(); println!("Press Ctrl+C to stop"); @@ -143,4 +308,4 @@ async fn handle_dav( req: Request, ) -> impl IntoResponse { dav.handle(req).await -} +} \ No newline at end of file diff --git a/markbase-core/src/lib.rs b/markbase-core/src/lib.rs index 629b406..c847d3e 100644 --- a/markbase-core/src/lib.rs +++ b/markbase-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod command; pub mod config; pub mod download; pub mod import_markdown; +pub mod myfiles; pub mod pg_client; pub mod provider; pub mod render; diff --git a/markbase-core/src/myfiles.rs b/markbase-core/src/myfiles.rs new file mode 100644 index 0000000..d852408 --- /dev/null +++ b/markbase-core/src/myfiles.rs @@ -0,0 +1,533 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{Html, Json}, +}; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::server::AppState; + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS virtual_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS file_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + tag TEXT NOT NULL, + UNIQUE(filename, tag) +); +CREATE INDEX IF NOT EXISTS idx_file_tags_tag ON file_tags(tag); +CREATE INDEX IF NOT EXISTS idx_file_tags_filename ON file_tags(filename); +"; + +fn user_db_path(state: &AppState, username: &str) -> PathBuf { + let root = std::env::var("MB_WEBDAV_PARENT") + .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string()); + PathBuf::from(root) + .join(username) + .join("webdav_virtual.sqlite") +} + +fn user_root(username: &str) -> PathBuf { + let root = std::env::var("MB_WEBDAV_PARENT") + .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string()); + PathBuf::from(root).join(username) +} + +fn ensure_schema(db_path: &PathBuf) -> anyhow::Result { + let conn = Connection::open(db_path) + .map_err(|e| anyhow::anyhow!("Failed to open DB: {}", e))?; + conn.execute_batch(SCHEMA) + .map_err(|e| anyhow::anyhow!("Failed to create schema: {}", e))?; + Ok(conn) +} + +#[derive(Serialize)] +pub struct FolderInfo { + pub name: String, + pub description: String, + pub file_count: usize, +} + +#[derive(Serialize)] +pub struct FileInfo { + pub name: String, + pub size: u64, + pub tags: Vec, +} + +#[derive(Deserialize)] +pub struct FolderRequest { + pub name: String, + pub description: Option, +} + +#[derive(Deserialize)] +pub struct TagRequest { + pub file: String, + pub tag: String, +} + +pub async fn list_folders( + State(state): State, + Path(username): Path, +) -> Result>, (StatusCode, String)> { + let db_path = user_db_path(&state, &username); + let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut stmt = conn + .prepare("SELECT folder, description FROM virtual_folders ORDER BY folder") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let folders: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + let mut result = Vec::new(); + for (folder, desc) in folders { + let tag = folder.trim_start_matches('/').to_string(); + let count: usize = conn + .query_row( + "SELECT COUNT(*) FROM file_tags WHERE tag = ?1", + params![tag], + |row| row.get(0), + ) + .unwrap_or(0); + result.push(FolderInfo { + name: folder, + description: desc, + file_count: count, + }); + } + Ok(Json(result)) +} + +pub async fn create_folder( + State(state): State, + Path(username): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let db_path = user_db_path(&state, &username); + let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let folder = if req.name.starts_with('/') { + req.name + } else { + format!("/{}", req.name) + }; + let desc = req.description.unwrap_or_default(); + + conn.execute( + "INSERT OR IGNORE INTO virtual_folders (folder, description) VALUES (?1, ?2)", + params![folder, desc], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": "ok", + "folder": folder, + "description": desc + }))) +} + +pub async fn delete_folder( + State(state): State, + Path((username, folder_name)): Path<(String, String)>, +) -> Result, (StatusCode, String)> { + let db_path = user_db_path(&state, &username); + let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let folder = if folder_name.starts_with('/') { + folder_name + } else { + format!("/{}", folder_name) + }; + let tag = folder.trim_start_matches('/').to_string(); + + conn.execute("DELETE FROM file_tags WHERE tag = ?1", params![tag]) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + conn.execute("DELETE FROM virtual_folders WHERE folder = ?1", params![folder]) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({"status": "ok", "deleted": folder}))) +} + +pub async fn list_files( + State(state): State, + Path(username): Path, + Query(q): Query>, +) -> Result>, (StatusCode, String)> { + let root = user_root(&username); + let db_path = user_db_path(&state, &username); + let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let folder_filter = q.get("folder").and_then(|v| v.as_str()).map(|s| s.to_string()); + + let filenames: Vec = if let Some(folder) = &folder_filter { + let tag = folder.trim_start_matches('/'); + let rows: Vec = conn + .prepare("SELECT filename FROM file_tags WHERE tag = ?1 ORDER BY filename") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .query_map(params![tag], |row| row.get::<_, String>(0)) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + rows + } else { + let mut entries = Vec::new(); + if let Ok(rd) = std::fs::read_dir(&root) { + for entry in rd.flatten() { + if entry.path().is_file() { + if let Some(name) = entry.file_name().to_str() { + entries.push(name.to_string()); + } + } + } + } + entries.sort(); + entries + }; + + let mut result = Vec::new(); + for fname in filenames { + let size = std::fs::metadata(root.join(&fname)) + .map(|m| m.len()) + .unwrap_or(0); + + let mut tags_stmt = conn + .prepare("SELECT tag FROM file_tags WHERE filename = ?1 ORDER BY tag") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let tags: Vec = tags_stmt + .query_map(params![fname], |row| row.get::<_, String>(0)) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + result.push(FileInfo { + name: fname, + size, + tags, + }); + } + + Ok(Json(result)) +} + +pub async fn add_tag( + State(state): State, + Path(username): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let db_path = user_db_path(&state, &username); + let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let folder = if req.tag.starts_with('/') { + req.tag.clone() + } else { + format!("/{}", req.tag) + }; + let tag = folder.trim_start_matches('/').to_string(); + + conn.execute( + "INSERT OR IGNORE INTO virtual_folders (folder, description) VALUES (?1, '')", + params![folder], + ) + .ok(); + + conn.execute( + "INSERT OR IGNORE INTO file_tags (filename, tag) VALUES (?1, ?2)", + params![req.file, tag], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": "ok", + "file": req.file, + "tag": tag + }))) +} + +pub async fn remove_tag( + State(state): State, + Path(username): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let db_path = user_db_path(&state, &username); + let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let tag = req.tag.trim_start_matches('/'); + + conn.execute( + "DELETE FROM file_tags WHERE filename = ?1 AND tag = ?2", + params![req.file, tag], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": "ok", + "file": req.file, + "removed_tag": tag + }))) +} + +pub async fn file_tags( + State(state): State, + Path((username, filename)): Path<(String, String)>, +) -> Result>, (StatusCode, String)> { + let db_path = user_db_path(&state, &username); + let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut stmt = conn + .prepare("SELECT tag FROM file_tags WHERE filename = ?1 ORDER BY tag") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let tags: Vec = stmt + .query_map(params![filename], |row| row.get::<_, String>(0)) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(Json(tags)) +} + +pub async fn ui_page() -> Html { + Html(MYFILES_HTML.to_string()) +} + +const MYFILES_HTML: &str = r#" + + + + +MyFiles — MarkBase + + + +
+

📁 MyFiles

+ +
+
+ +
+
+ + +
+
+ +
+
+ + + + +"#; \ No newline at end of file diff --git a/markbase-core/src/server.rs b/markbase-core/src/server.rs index 3474872..8292b97 100644 --- a/markbase-core/src/server.rs +++ b/markbase-core/src/server.rs @@ -290,6 +290,17 @@ pub async fn run(port: u16, file: Option) -> anyhow::Result<()> { .route("/webdav", any(handle_webdav_multi)) .route("/webdav/", any(handle_webdav_multi)) .route("/webdav/*path", any(handle_webdav_multi)) + // Admin WebDAV (browse all user directories) + .route("/admin-webdav", any(handle_webdav_admin)) + .route("/admin-webdav/", any(handle_webdav_admin)) + .route("/admin-webdav/*path", any(handle_webdav_admin)) + // MyFiles User Interface (Web UI + API) + .route("/myfiles", get(crate::myfiles::ui_page)) + .route("/api/v2/myfiles/:username/folders", get(crate::myfiles::list_folders).post(crate::myfiles::create_folder)) + .route("/api/v2/myfiles/:username/folders/:folder_name", delete(crate::myfiles::delete_folder)) + .route("/api/v2/myfiles/:username/files", get(crate::myfiles::list_files)) + .route("/api/v2/myfiles/:username/tags", post(crate::myfiles::add_tag).delete(crate::myfiles::remove_tag)) + .route("/api/v2/myfiles/:username/files/:filename/tags", get(crate::myfiles::file_tags)) .layer(Extension(webdav_parent)) .layer(Extension(upload_hook)) .layer(Extension(webdav_versioning)) @@ -2624,3 +2635,86 @@ fn unauthorized_response() -> axum::response::Response { axum::body::Body::from("Unauthorized"), ).into_response() } + +static ADMIN_WEBDAV_HANDLER: LazyLock> = LazyLock::new(|| { + let parent = std::env::var("MB_WEBDAV_PARENT") + .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string()); + let parent_path = std::path::PathBuf::from(&parent); + if !parent_path.exists() { + return None; + } + let vfs: Box = Box::new(crate::vfs::local_fs::LocalFs::new()); + let locks_dir = parent_path.join(".webdav_locks"); + let _ = std::fs::create_dir_all(&locks_dir); + let locks_file = locks_dir.join("admin.json"); + Some(crate::webdav::create_webdav_handler_persisted( + vfs, + parent_path, + None, + "admin".to_string(), + None, + locks_file, + )) +}); + +async fn handle_webdav_admin( + Extension(upload_hook): Extension>, + req: axum::extract::Request, +) -> axum::response::Response { + let admin_users = std::env::var("MB_WEBDAV_ADMIN_USERS") + .unwrap_or_else(|_| "admin:admin123".to_string()); + + let auth = req + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .filter(|v| v.starts_with("Basic ")) + .and_then(|v| { + let encoded = &v[6..]; + let decoded = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?; + let creds = String::from_utf8(decoded).ok()?; + let colon = creds.find(':')?; + Some((creds[..colon].to_string(), creds[colon + 1..].to_string())) + }); + + let valid = match auth { + Some(ref creds) => { + admin_users.split(',') + .filter_map(|entry| { + let mut parts = entry.splitn(2, ':'); + let u = parts.next()?.to_string(); + let p = parts.next().unwrap_or("").to_string(); + Some((u, p)) + }) + .any(|(u, p)| u == creds.0 && p == creds.1) + } + None => false, + }; + + if !valid { + return unauthorized_response(); + } + + let handler = match ADMIN_WEBDAV_HANDLER.as_ref() { + Some(h) => h.clone(), + None => return unauthorized_response(), + }; + + let (mut parts, body) = req.into_parts(); + let new_path = parts.uri.path().strip_prefix("/admin-webdav").unwrap_or("/"); + let new_path = if new_path.is_empty() || !new_path.starts_with('/') { + format!("/{}", new_path) + } else { + new_path.to_string() + }; + let builder = axum::http::Uri::builder().path_and_query(new_path.as_str()); + if let Ok(uri) = builder.build() { + parts.uri = uri; + } + let req = axum::http::Request::from_parts(parts, body); + + let dav_resp = handler.handle(req).await; + let (parts, body) = dav_resp.into_parts(); + let axum_body = axum::body::Body::from_stream(body); + axum::response::Response::from_parts(parts, axum_body) +} diff --git a/markbase-core/src/vfs/mod.rs b/markbase-core/src/vfs/mod.rs index ca6d3d1..65a77aa 100644 --- a/markbase-core/src/vfs/mod.rs +++ b/markbase-core/src/vfs/mod.rs @@ -9,6 +9,7 @@ pub mod smb_fs; #[cfg(feature = "smb-server")] pub mod smb_server_backend; pub mod util; +pub mod virtual_fs; #[cfg(feature = "async-vfs")] pub mod async_fs; #[cfg(feature = "async-vfs")] diff --git a/markbase-core/src/vfs/virtual_fs.rs b/markbase-core/src/vfs/virtual_fs.rs new file mode 100644 index 0000000..c863093 --- /dev/null +++ b/markbase-core/src/vfs/virtual_fs.rs @@ -0,0 +1,737 @@ +use super::local_fs::LocalFs; +use super::open_flags::OpenFlags; +use super::{VfsBackend, VfsDirEntry, VfsError, VfsFile, VfsStat}; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS virtual_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS file_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + tag TEXT NOT NULL, + UNIQUE(filename, tag) +); + +CREATE INDEX IF NOT EXISTS idx_file_tags_tag ON file_tags(tag); +CREATE INDEX IF NOT EXISTS idx_file_tags_filename ON file_tags(filename); + +CREATE TABLE IF NOT EXISTS webdav_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +"; + + + +pub struct VirtualFs { + db: Arc>, + backend: Box, + root: PathBuf, +} + +fn normalize_folder(folder: &str) -> String { + let f = folder.trim_start_matches('/'); + format!("/{}", f.trim_end_matches('/')) +} + +fn folder_to_tag(folder: &str) -> String { + folder.trim_matches('/').to_string() +} + + + +impl VirtualFs { + pub fn new(db_path: &str, root: PathBuf) -> Result { + let conn = Connection::open(db_path).map_err(|e| VfsError::Io(e.to_string()))?; + conn.execute_batch(SCHEMA) + .map_err(|e| VfsError::Io(e.to_string()))?; + let backend = Box::new(LocalFs::new()); + Ok(Self { + db: Arc::new(Mutex::new(conn)), + backend, + root, + }) + } + + fn load_folders(&self) -> Vec { + let db = self.db.lock().unwrap(); + let mut stmt = match db.prepare("SELECT folder FROM virtual_folders ORDER BY folder") { + Ok(s) => s, + Err(_) => return vec![], + }; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .ok(); + rows.map(|r| r.filter_map(|e| e.ok()).collect()) + .unwrap_or_default() + } + + fn files_with_tag(&self, tag: &str) -> Vec { + let db = self.db.lock().unwrap(); + let mut stmt = match db.prepare("SELECT filename FROM file_tags WHERE tag = ?1 ORDER BY filename") { + Ok(s) => s, + Err(_) => return vec![], + }; + let rows = stmt + .query_map(params![tag], |row| row.get::<_, String>(0)) + .ok(); + rows.map(|r| r.filter_map(|e| e.ok()).collect()) + .unwrap_or_default() + } + + fn tags_for_file(&self, filename: &str) -> Vec { + let db = self.db.lock().unwrap(); + let mut stmt = match db.prepare("SELECT tag FROM file_tags WHERE filename = ?1 ORDER BY tag") { + Ok(s) => s, + Err(_) => return vec![], + }; + let rows = stmt + .query_map(params![filename], |row| row.get::<_, String>(0)) + .ok(); + rows.map(|r| r.filter_map(|e| e.ok()).collect()) + .unwrap_or_default() + } + + fn is_virtual_folder(&self, path: &Path) -> Option { + let path_str = path.to_string_lossy().to_string(); + let normalized = normalize_folder(&path_str); + if normalized == "/" { + return None; + } + let folders = self.load_folders(); + for folder in &folders { + if normalized == folder.as_str() || normalized.starts_with(&format!("{}/", folder)) { + return Some(folder.clone()); + } + } + None + } + + fn resolve(&self, virtual_path: &Path) -> (PathBuf, Option, Option) { + let path_str = virtual_path.to_string_lossy().to_string(); + let normalized = if path_str.starts_with('/') { + path_str.clone() + } else { + format!("/{}", path_str) + }; + + if normalized == "/" || normalized.is_empty() { + return (self.root.clone(), None, None); + } + + let folders = self.load_folders(); + for folder in &folders { + let folder_tag = folder_to_tag(folder); + let folder_prefix = format!("{}/", folder); + if normalized == folder.as_str() { + return (self.root.clone(), Some(folder_tag), None); + } + if normalized.starts_with(&folder_prefix) { + let filename = normalized[folder_prefix.len()..].to_string(); + return (self.root.join(&filename), Some(folder_tag), Some(filename)); + } + } + + let relative = normalized.trim_start_matches('/'); + (self.root.join(relative), None, Some(relative.to_string())) + } + + fn check_writable(&self) -> Result<(), VfsError> { + Ok(()) + } + + pub fn add_folder(&self, folder: &str, description: &str) -> Result<(), VfsError> { + let db = self.db.lock().unwrap(); + let f = normalize_folder(folder); + db.execute( + "INSERT OR IGNORE INTO virtual_folders (folder, description) VALUES (?1, ?2)", + params![f, description], + ) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(()) + } + + pub fn remove_folder(&self, folder: &str) -> Result<(), VfsError> { + let db = self.db.lock().unwrap(); + let f = normalize_folder(folder); + let tag = folder_to_tag(&f); + db.execute("DELETE FROM file_tags WHERE tag = ?1", params![tag]) + .map_err(|e| VfsError::Io(e.to_string()))?; + db.execute("DELETE FROM virtual_folders WHERE folder = ?1", params![f]) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(()) + } + + pub fn tag_file(&self, filename: &str, tag: &str) -> Result<(), VfsError> { + let db = self.db.lock().unwrap(); + db.execute( + "INSERT OR IGNORE INTO file_tags (filename, tag) VALUES (?1, ?2)", + params![filename, tag], + ) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(()) + } + + pub fn untag_file(&self, filename: &str, tag: &str) -> Result<(), VfsError> { + let db = self.db.lock().unwrap(); + db.execute( + "DELETE FROM file_tags WHERE filename = ?1 AND tag = ?2", + params![filename, tag], + ) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(()) + } + + pub fn list_folders(&self) -> Result, VfsError> { + let db = self.db.lock().unwrap(); + let mut stmt = db + .prepare("SELECT folder, description FROM virtual_folders ORDER BY folder") + .map_err(|e| VfsError::Io(e.to_string()))?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + pub fn list_files_in_folder(&self, folder: &str) -> Result, VfsError> { + let tag = folder_to_tag(folder); + Ok(self.files_with_tag(&tag)) + } + + pub fn list_tags_for_file(&self, filename: &str) -> Result, VfsError> { + Ok(self.tags_for_file(filename)) + } + + pub fn set_config(&self, key: &str, value: &str) -> Result<(), VfsError> { + let db = self.db.lock().unwrap(); + db.execute( + "INSERT OR REPLACE INTO webdav_config (key, value) VALUES (?1, ?2)", + params![key, value], + ) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(()) + } + + pub fn get_config(&self, key: &str) -> Option { + let db = self.db.lock().unwrap(); + db.query_row( + "SELECT value FROM webdav_config WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .ok() + } + + pub fn root(&self) -> &Path { + &self.root + } + + fn remove_all_tags(&self, filename: &str) -> Result<(), VfsError> { + let db = self.db.lock().unwrap(); + db.execute("DELETE FROM file_tags WHERE filename = ?1", params![filename]) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(()) + } + + fn move_tags(&self, old_filename: &str, new_filename: &str) -> Result<(), VfsError> { + let db = self.db.lock().unwrap(); + db.execute( + "UPDATE OR IGNORE file_tags SET filename = ?1 WHERE filename = ?2", + params![new_filename, old_filename], + ) + .map_err(|e| VfsError::Io(e.to_string()))?; + Ok(()) + } + + fn make_dir_entry(name: &str) -> VfsDirEntry { + let mut stat = VfsStat::new(); + stat.is_dir = true; + stat.mode = 0o755; + VfsDirEntry { + name: name.to_string(), + long_name: name.to_string(), + stat, + } + } + + fn make_file_entry(name: &str, stat: VfsStat) -> VfsDirEntry { + VfsDirEntry { + name: name.to_string(), + long_name: name.to_string(), + stat, + } + } +} + +impl VfsBackend for VirtualFs { + fn clone_boxed(&self) -> Box { + Box::new(VirtualFs { + db: self.db.clone(), + backend: self.backend.clone_boxed(), + root: self.root.clone(), + }) + } + + fn read_dir(&self, path: &Path) -> Result, VfsError> { + let (real, folder_tag, _) = self.resolve(path); + + if let Some(tag) = folder_tag { + let files = self.files_with_tag(&tag); + let mut entries = Vec::new(); + for filename in &files { + let file_path = self.root.join(filename); + if let Ok(stat) = self.backend.stat(&file_path) { + let name = Path::new(filename) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.clone()); + entries.push(Self::make_file_entry(&name, stat)); + } else { + let stat = VfsStat::new(); + entries.push(Self::make_file_entry(filename, stat)); + } + } + return Ok(entries); + } + + let mut entries = self.backend.read_dir(&real)?; + + let folders = self.load_folders(); + for folder in &folders { + let name = folder.trim_start_matches('/').to_string(); + if !entries.iter().any(|e| e.name == name) { + entries.push(Self::make_dir_entry(&name)); + } + } + Ok(entries) + } + + fn open_file( + &self, + path: &Path, + flags: &OpenFlags, + ) -> Result, VfsError> { + self.check_writable()?; + + if flags.create { + let (real, folder_tag, filename) = self.resolve(path); + let file = self.backend.open_file(&real, flags)?; + + if let (Some(tag), Some(fname)) = (folder_tag, filename.as_ref()) { + let _ = self.tag_file(fname, &tag); + } + return Ok(file); + } + + let (real, _, _) = self.resolve(path); + self.backend.open_file(&real, flags) + } + + fn stat(&self, path: &Path) -> Result { + let (real, folder_tag, _) = self.resolve(path); + + if folder_tag.is_some() && !real.exists() { + let file_count = match &folder_tag { + Some(tag) => self.files_with_tag(tag).len(), + None => 0, + }; + if file_count > 0 || path == Path::new("/") { + let mut stat = VfsStat::new(); + stat.is_dir = true; + stat.mode = 0o755; + return Ok(stat); + } + } + + if folder_tag.is_some() && real == self.root { + let mut stat = VfsStat::new(); + stat.is_dir = true; + stat.mode = 0o755; + return Ok(stat); + } + + self.backend.stat(&real) + } + + fn lstat(&self, path: &Path) -> Result { + self.stat(path) + } + + fn create_dir(&self, path: &Path, _mode: u32) -> Result<(), VfsError> { + let path_str = path.to_string_lossy().to_string(); + let normalized = normalize_folder(&path_str); + + if normalized == "/" { + return Err(VfsError::AlreadyExists("/".to_string())); + } + + if self.is_virtual_folder(path).is_some() { + return Err(VfsError::AlreadyExists(normalized)); + } + + self.add_folder(&normalized, "")?; + Ok(()) + } + + fn create_dir_all(&self, path: &Path, mode: u32) -> Result<(), VfsError> { + self.create_dir(path, mode) + } + + fn remove_dir(&self, path: &Path) -> Result<(), VfsError> { + let path_str = path.to_string_lossy().to_string(); + let normalized = normalize_folder(&path_str); + + if self.is_virtual_folder(path).is_some() { + self.remove_folder(&normalized)?; + return Ok(()); + } + + let (real, _, _) = self.resolve(path); + self.backend.remove_dir(&real) + } + + fn remove_file(&self, path: &Path) -> Result<(), VfsError> { + self.check_writable()?; + + if self.is_virtual_folder(path).is_some() { + let (_, folder_tag, filename) = self.resolve(path); + if let (Some(tag), Some(fname)) = (folder_tag, filename) { + self.untag_file(&fname, &tag)?; + return Ok(()); + } + } + + let (real, _, filename) = self.resolve(path); + self.backend.remove_file(&real)?; + + if let Some(fname) = filename { + let _ = self.remove_all_tags(&fname); + } + Ok(()) + } + + fn rename(&self, from: &Path, to: &Path) -> Result<(), VfsError> { + self.check_writable()?; + + let (_, from_tag, from_filename) = self.resolve(from); + let (_, to_tag, _to_filename) = self.resolve(to); + + let from_is_folder = from_tag.is_some() && from_filename.is_none(); + + if from_is_folder { + return Err(VfsError::Unsupported("Cannot rename virtual folder".to_string())); + } + + match (from_tag, to_tag, from_filename.as_ref()) { + (Some(ft), Some(tt), Some(fname)) => { + if ft != tt { + self.untag_file(fname, &ft)?; + self.tag_file(fname, &tt)?; + } + return Ok(()); + } + (None, Some(tt), Some(fname)) => { + self.tag_file(fname, &tt)?; + return Ok(()); + } + (Some(ft), None, Some(fname)) => { + self.untag_file(fname, &ft)?; + return Ok(()); + } + _ => {} + } + + if self.is_virtual_folder(to).is_some() { + let (_, from_tag, from_filename) = self.resolve(from); + let (_, to_tag, _) = self.resolve(to); + + match (from_tag, to_tag, from_filename) { + (Some(ft), Some(tt), Some(fname)) => { + if ft != tt { + self.untag_file(&fname, &ft)?; + self.tag_file(&fname, &tt)?; + } + return Ok(()); + } + (None, Some(tt), Some(fname)) => { + self.tag_file(&fname, &tt)?; + return Ok(()); + } + (Some(ft), None, Some(fname)) => { + self.untag_file(&fname, &ft)?; + return Ok(()); + } + _ => {} + } + } + + let (real_from, _, from_filename) = self.resolve(from); + let (real_to, _, to_filename) = self.resolve(to); + + self.backend.rename(&real_from, &real_to)?; + + if let (Some(old_name), Some(new_name)) = (from_filename, to_filename) { + self.move_tags(&old_name, &new_name)?; + } + Ok(()) + } + + fn set_stat(&self, path: &Path, stat: &VfsStat) -> Result<(), VfsError> { + self.check_writable()?; + let (real, _, _) = self.resolve(path); + self.backend.set_stat(&real, stat) + } + + fn read_link(&self, path: &Path) -> Result { + let (real, _, _) = self.resolve(path); + self.backend.read_link(&real) + } + + fn create_symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> { + self.check_writable()?; + let (real_target, _, _) = self.resolve(target); + let (real_link, _, _) = self.resolve(link); + self.backend.create_symlink(&real_target, &real_link) + } + + fn real_path(&self, path: &Path) -> Result { + let (real, _, _) = self.resolve(path); + self.backend.real_path(&real) + } + + fn exists(&self, path: &Path) -> bool { + if self.is_virtual_folder(path).is_some() { + return true; + } + + let (real, folder_tag, filename) = self.resolve(path); + + if let Some(tag) = folder_tag { + if let Some(fname) = filename { + return self.backend.exists(&real) + || self.tags_for_file(&fname).contains(&tag); + } + return true; + } + + self.backend.exists(&real) + } + + fn hard_link(&self, original: &Path, link: &Path) -> Result<(), VfsError> { + self.check_writable()?; + let (real_orig, _, _) = self.resolve(original); + let (real_link, _, _) = self.resolve(link); + self.backend.hard_link(&real_orig, &real_link) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn setup() -> (TempDir, VirtualFs, TempDir) { + let db_dir = TempDir::new().unwrap(); + let db_path = db_dir.path().join("test_vfs.sqlite"); + let root_dir = TempDir::new().unwrap(); + std::fs::write(root_dir.path().join("flower.jpg"), "image").unwrap(); + std::fs::write(root_dir.path().join("report.pdf"), "pdf content").unwrap(); + std::fs::write(root_dir.path().join("sunset.jpg"), "sunset").unwrap(); + + let vfs = VirtualFs::new(db_path.to_str().unwrap(), root_dir.path().to_path_buf()) + .unwrap(); + (db_dir, vfs, root_dir) + } + + #[test] + fn test_add_folder() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "Photo collection").unwrap(); + let folders = vfs.list_folders().unwrap(); + assert_eq!(folders.len(), 1); + assert_eq!(folders[0].0, "/photos"); + } + + #[test] + fn test_remove_folder() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.tag_file("flower.jpg", "photos").unwrap(); + assert_eq!(vfs.files_with_tag("photos").len(), 1); + + vfs.remove_folder("/photos").unwrap(); + assert_eq!(vfs.list_folders().unwrap().len(), 0); + assert_eq!(vfs.files_with_tag("photos").len(), 0); + } + + #[test] + fn test_tag_file() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.add_folder("/nature", "").unwrap(); + + vfs.tag_file("flower.jpg", "photos").unwrap(); + vfs.tag_file("flower.jpg", "nature").unwrap(); + + let tags = vfs.tags_for_file("flower.jpg"); + assert!(tags.contains(&"photos".to_string())); + assert!(tags.contains(&"nature".to_string())); + } + + #[test] + fn test_read_dir_root_shows_folders() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + + let entries = vfs.read_dir(Path::new("/")).unwrap(); + assert!(entries.iter().any(|e| e.name == "photos" && e.stat.is_dir)); + assert!(entries.iter().any(|e| e.name == "flower.jpg")); + } + + #[test] + fn test_read_dir_virtual_folder() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.tag_file("flower.jpg", "photos").unwrap(); + vfs.tag_file("sunset.jpg", "photos").unwrap(); + + let entries = vfs.read_dir(Path::new("/photos")).unwrap(); + let names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect(); + assert!(names.contains(&"flower.jpg".to_string())); + assert!(names.contains(&"sunset.jpg".to_string())); + assert!(!names.contains(&"report.pdf".to_string())); + } + + #[test] + fn test_open_file_in_virtual_folder() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + + let flags = OpenFlags::new().read(); + let file = vfs.open_file(Path::new("/photos/flower.jpg"), &flags).unwrap(); + drop(file); + } + + #[test] + fn test_create_file_auto_tags() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/docs", "").unwrap(); + + let flags = OpenFlags::new().write().create().truncate(); + let file = vfs.open_file(Path::new("/docs/newfile.txt"), &flags).unwrap(); + drop(file); + + let tags = vfs.tags_for_file("newfile.txt"); + assert!(tags.contains(&"docs".to_string())); + } + + #[test] + fn test_remove_file_from_virtual_folder_untags() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.tag_file("flower.jpg", "photos").unwrap(); + + vfs.remove_file(Path::new("/photos/flower.jpg")).unwrap(); + + assert!(std::path::Path::new(&vfs.root().join("flower.jpg")).exists()); + let tags = vfs.tags_for_file("flower.jpg"); + assert!(!tags.contains(&"photos".to_string())); + } + + #[test] + fn test_remove_file_from_root_deletes() { + let (_db, vfs, root) = setup(); + vfs.tag_file("flower.jpg", "photos").unwrap(); + + vfs.remove_file(Path::new("/flower.jpg")).unwrap(); + assert!(!root.path().join("flower.jpg").exists()); + assert_eq!(vfs.tags_for_file("flower.jpg").len(), 0); + } + + #[test] + fn test_stat_virtual_folder() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.tag_file("flower.jpg", "photos").unwrap(); + + let stat = vfs.stat(Path::new("/photos")).unwrap(); + assert!(stat.is_dir); + } + + #[test] + fn test_stat_file_in_virtual_folder() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.tag_file("flower.jpg", "photos").unwrap(); + + let stat = vfs.stat(Path::new("/photos/flower.jpg")).unwrap(); + assert_eq!(stat.size, 5); + assert!(!stat.is_dir); + } + + #[test] + fn test_same_file_multiple_tags() { + let (_db, vfs, root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.add_folder("/nature", "").unwrap(); + + vfs.tag_file("flower.jpg", "photos").unwrap(); + vfs.tag_file("flower.jpg", "nature").unwrap(); + + let photos_entries = vfs.read_dir(Path::new("/photos")).unwrap(); + let nature_entries = vfs.read_dir(Path::new("/nature")).unwrap(); + + assert!(photos_entries.iter().any(|e| e.name == "flower.jpg")); + assert!(nature_entries.iter().any(|e| e.name == "flower.jpg")); + + assert!(root.path().join("flower.jpg").exists()); + } + + #[test] + fn test_rename_adds_tag() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + + vfs.rename(Path::new("/flower.jpg"), Path::new("/photos/flower.jpg")) + .unwrap(); + + let tags = vfs.tags_for_file("flower.jpg"); + assert!(tags.contains(&"photos".to_string())); + } + + #[test] + fn test_rename_removes_tag() { + let (_db, vfs, _root) = setup(); + vfs.add_folder("/photos", "").unwrap(); + vfs.tag_file("flower.jpg", "photos").unwrap(); + + vfs.rename(Path::new("/photos/flower.jpg"), Path::new("/flower.jpg")) + .unwrap(); + + let tags = vfs.tags_for_file("flower.jpg"); + assert!(!tags.contains(&"photos".to_string())); + } + + #[test] + fn test_create_virtual_dir() { + let (_db, vfs, _root) = setup(); + vfs.create_dir(Path::new("/newfolder"), 0o755).unwrap(); + let folders = vfs.list_folders().unwrap(); + assert!(folders.iter().any(|(f, _)| f == "/newfolder")); + } + + #[test] + fn test_config() { + let (_db, vfs, _root) = setup(); + vfs.set_config("default_root", "/data/demo").unwrap(); + assert_eq!(vfs.get_config("default_root"), Some("/data/demo".to_string())); + } +} \ No newline at end of file diff --git a/markbase-core/src/webdav.rs b/markbase-core/src/webdav.rs index 19744b7..6dc7bbe 100644 --- a/markbase-core/src/webdav.rs +++ b/markbase-core/src/webdav.rs @@ -36,7 +36,7 @@ fn map_vfs_error(e: VfsError) -> FsError { } /// Expected credentials for WebDAV Basic Auth validation -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct WebdavCredentials { pub username: String, pub password: Option, @@ -82,6 +82,7 @@ pub struct VfsDavFs { props_data: Arc>>>, props_path: PathBuf, enable_acl: bool, + virtual_mode: bool, } impl Clone for VfsDavFs { @@ -95,6 +96,7 @@ impl Clone for VfsDavFs { props_data: self.props_data.clone(), props_path: self.props_path.clone(), enable_acl: self.enable_acl, + virtual_mode: self.virtual_mode, } } } @@ -170,18 +172,16 @@ impl VfsDavFs { upload_hook: Option>, user_uuid: String, ) -> Box { - let props_path = Self::dead_props_path(&root); - let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root))); - Box::new(Self { - vfs, - root, - upload_hook, - user_uuid, - versioning: None, - props_data, - props_path, - enable_acl: true, - }) + Self::new_inner(vfs, root, upload_hook, user_uuid, None, false) + } + + pub fn new_virtual( + vfs: Box, + root: PathBuf, + upload_hook: Option>, + user_uuid: String, + ) -> Box { + Self::new_inner(vfs, root, upload_hook, user_uuid, None, true) } pub fn with_versioning( @@ -190,6 +190,17 @@ impl VfsDavFs { upload_hook: Option>, user_uuid: String, versioning: Arc, + ) -> Box { + Self::new_inner(vfs, root, upload_hook, user_uuid, Some(versioning), false) + } + + fn new_inner( + vfs: Box, + root: PathBuf, + upload_hook: Option>, + user_uuid: String, + versioning: Option>, + virtual_mode: bool, ) -> Box { let props_path = Self::dead_props_path(&root); let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root))); @@ -198,10 +209,11 @@ impl VfsDavFs { root, upload_hook, user_uuid, - versioning: Some(versioning), + versioning, props_data, props_path, enable_acl: true, + virtual_mode, }) } @@ -218,10 +230,15 @@ impl VfsDavFs { let relative = path.as_rel_ospath(); let full = self.root.join(relative); - // Path traversal protection: canonicalize root once + if self.virtual_mode { + if relative.components().any(|c| c == std::path::Component::ParentDir) { + return Err(FsError::NotFound); + } + return Ok(full); + } + let root_canonical = self.root.canonicalize().map_err(|_| FsError::NotFound)?; - // If path exists, use canonicalized version if let Ok(canonical) = full.canonicalize() { if !canonical.starts_with(&root_canonical) { return Err(FsError::NotFound); @@ -229,15 +246,12 @@ impl VfsDavFs { return Ok(canonical); } - // Path doesn't exist yet (e.g., new file/dir being created). - // Validate parent directory is within root and no .. traversal. let parent = full.parent().ok_or(FsError::NotFound)?; let parent_canonical = parent.canonicalize().map_err(|_| FsError::NotFound)?; if !parent_canonical.starts_with(&root_canonical) { return Err(FsError::NotFound); } - // Sanity check: ensure relative path doesn't contain ".." if relative.components().any(|c| c == std::path::Component::ParentDir) { return Err(FsError::NotFound); } @@ -1087,7 +1101,16 @@ pub fn create_webdav_handler( upload_hook: Option>, user_uuid: String, ) -> dav_server::DavHandler { - create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None) + create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None, false) +} + +pub fn create_webdav_handler_virtual( + vfs: Box, + root: PathBuf, + upload_hook: Option>, + user_uuid: String, +) -> dav_server::DavHandler { + create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None, true) } pub fn create_webdav_handler_with_versioning( @@ -1097,7 +1120,7 @@ pub fn create_webdav_handler_with_versioning( user_uuid: String, versioning: Arc, ) -> dav_server::DavHandler { - create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, Some(versioning), None) + create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, Some(versioning), None, false) } pub fn create_webdav_handler_persisted( @@ -1108,7 +1131,7 @@ pub fn create_webdav_handler_persisted( versioning: Option>, locks_file: PathBuf, ) -> dav_server::DavHandler { - create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, versioning, Some(locks_file)) + create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, versioning, Some(locks_file), false) } fn create_webdav_handler_inner( @@ -1118,10 +1141,12 @@ fn create_webdav_handler_inner( user_uuid: String, versioning: Option>, locks_file: Option, + virtual_mode: bool, ) -> dav_server::DavHandler { - let dav_fs = match versioning { - Some(v) => VfsDavFs::with_versioning(vfs, root, upload_hook, user_uuid, v), - None => VfsDavFs::new(vfs, root, upload_hook, user_uuid), + let dav_fs = match (versioning, virtual_mode) { + (Some(v), _) => VfsDavFs::with_versioning(vfs, root, upload_hook, user_uuid, v), + (None, true) => VfsDavFs::new_virtual(vfs, root, upload_hook, user_uuid), + (None, false) => VfsDavFs::new(vfs, root, upload_hook, user_uuid), }; let locksystem: Box = match locks_file { Some(path) => PersistedLs::new(path), @@ -1131,6 +1156,7 @@ fn create_webdav_handler_inner( .filesystem(dav_fs) .locksystem(locksystem) .strip_prefix("") + .autoindex(true) .build_handler() }