Add VirtualFs tag-mode WebDAV + MyFiles UI + Admin WebDAV endpoint
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled

- 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
This commit is contained in:
Warren
2026-06-22 10:38:25 +08:00
parent 37d0fe1a3c
commit 60e4329eed
7 changed files with 1596 additions and 39 deletions

View File

@@ -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<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 } => {
// 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<String>,
virtual_mode: bool,
db: Option<String>,
) -> 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<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 {
// Get credentials from extensions (without consuming)
let expected = req.extensions().get::<crate::webdav::WebdavCredentials>().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
}
}