use axum::{ body::Body, extract::Request, http::{HeaderValue, StatusCode}, middleware, response::IntoResponse, Extension, }; use base64::Engine as _; use clap::Subcommand; use dav_server::DavHandler; use std::path::PathBuf; #[derive(Subcommand)] pub enum WebdavCommand { #[command(name = "webdav-start")] Start { #[arg(short, long, default_value = "8002")] 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, virtual_mode, db, } => { let username = user.split(':').next().unwrap_or(&user).to_string(); let password = user.split(':').nth(1).map(|s| s.to_string()); let default_root = format!("/Users/accusys/momentry/var/sftpgo/data/{}", username); let home_dir = PathBuf::from( std::env::var("MB_WEBDAV_ROOT").unwrap_or(default_root), ); if !home_dir.exists() { return Err(anyhow::anyhow!( "User home directory not found: {}", home_dir.display() )); } println!("=== MarkBase WebDAV Server (VFS) ==="); println!("User: {}", username); if password.is_some() { println!("Auth: password protected"); } 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, 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(()) } async fn run_webdav_server( port: u16, home_dir: PathBuf, user: String, password: Option, virtual_mode: bool, db: Option, ) -> anyhow::Result<()> { use axum::{routing::any, Router}; use tokio::net::TcpListener; 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 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 { let expected = req.extensions().get::().cloned(); 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, expected) { (Some((u, p)), Some(exp)) => { u == exp.username && exp.password.as_ref().map_or(true, |exp_p| p == *exp_p) } _ => false, }; if !valid { return ( StatusCode::UNAUTHORIZED, [( "WWW-Authenticate", HeaderValue::from_static("Basic realm=\"MarkBase WebDAV\""), )], Body::from("Unauthorized"), ) .into_response(); } next.run(req).await } 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, })); let addr = format!("0.0.0.0:{}", port); let listener = TcpListener::bind(&addr).await?; println!("WebDAV server listening on http://{}", addr); println!("User: {}", user); if virtual_mode { println!("Mode: Virtual (SQLite tag-based)"); } else { println!("Mode: Local"); } println!(); println!("Press Ctrl+C to stop"); axum::serve(listener, app).await?; Ok(()) } async fn handle_dav( Extension(dav): Extension, Extension(_creds): Extension, req: Request, ) -> impl IntoResponse { dav.handle(req).await }