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

@@ -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")]

View File

@@ -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<Mutex<Connection>>,
backend: Box<dyn VfsBackend>,
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<Self, VfsError> {
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<String> {
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<String> {
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<String> {
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<String> {
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<String>, Option<String>) {
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<Vec<(String, String)>, 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<Vec<String>, VfsError> {
let tag = folder_to_tag(folder);
Ok(self.files_with_tag(&tag))
}
pub fn list_tags_for_file(&self, filename: &str) -> Result<Vec<String>, 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<String> {
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<dyn VfsBackend> {
Box::new(VirtualFs {
db: self.db.clone(),
backend: self.backend.clone_boxed(),
root: self.root.clone(),
})
}
fn read_dir(&self, path: &Path) -> Result<Vec<VfsDirEntry>, 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<Box<dyn VfsFile>, 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<VfsStat, VfsError> {
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<VfsStat, VfsError> {
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<PathBuf, VfsError> {
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<PathBuf, VfsError> {
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()));
}
}