Files
markbase/markbase-core/src/cli/interface/webdav.rs
Warren 60e4329eed
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled
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
2026-06-22 10:38:25 +08:00

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
}