- 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
311 lines
10 KiB
Rust
311 lines
10 KiB
Rust
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<String>,
|
|
},
|
|
|
|
#[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<String>,
|
|
#[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<String>,
|
|
#[arg(long, help = "Folder name (for listing files in a folder)")]
|
|
folder: Option<String>,
|
|
#[arg(long, help = "Filename (for listing tags of a file)")]
|
|
file: Option<String>,
|
|
#[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<String>,
|
|
virtual_mode: bool,
|
|
db: Option<String>,
|
|
) -> 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<dyn crate::vfs::VfsBackend> = 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::<crate::webdav::WebdavCredentials>().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<DavHandler>,
|
|
Extension(_creds): Extension<crate::webdav::WebdavCredentials>,
|
|
req: Request,
|
|
) -> impl IntoResponse {
|
|
dav.handle(req).await
|
|
} |