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
}
}

View File

@@ -9,6 +9,7 @@ pub mod command;
pub mod config;
pub mod download;
pub mod import_markdown;
pub mod myfiles;
pub mod pg_client;
pub mod provider;
pub mod render;

View File

@@ -0,0 +1,533 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{Html, Json},
};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::server::AppState;
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);
";
fn user_db_path(state: &AppState, username: &str) -> PathBuf {
let root = std::env::var("MB_WEBDAV_PARENT")
.unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string());
PathBuf::from(root)
.join(username)
.join("webdav_virtual.sqlite")
}
fn user_root(username: &str) -> PathBuf {
let root = std::env::var("MB_WEBDAV_PARENT")
.unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string());
PathBuf::from(root).join(username)
}
fn ensure_schema(db_path: &PathBuf) -> anyhow::Result<Connection> {
let conn = Connection::open(db_path)
.map_err(|e| anyhow::anyhow!("Failed to open DB: {}", e))?;
conn.execute_batch(SCHEMA)
.map_err(|e| anyhow::anyhow!("Failed to create schema: {}", e))?;
Ok(conn)
}
#[derive(Serialize)]
pub struct FolderInfo {
pub name: String,
pub description: String,
pub file_count: usize,
}
#[derive(Serialize)]
pub struct FileInfo {
pub name: String,
pub size: u64,
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct FolderRequest {
pub name: String,
pub description: Option<String>,
}
#[derive(Deserialize)]
pub struct TagRequest {
pub file: String,
pub tag: String,
}
pub async fn list_folders(
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<Json<Vec<FolderInfo>>, (StatusCode, String)> {
let db_path = user_db_path(&state, &username);
let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut stmt = conn
.prepare("SELECT folder, description FROM virtual_folders ORDER BY folder")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let folders: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.filter_map(|r| r.ok())
.collect();
let mut result = Vec::new();
for (folder, desc) in folders {
let tag = folder.trim_start_matches('/').to_string();
let count: usize = conn
.query_row(
"SELECT COUNT(*) FROM file_tags WHERE tag = ?1",
params![tag],
|row| row.get(0),
)
.unwrap_or(0);
result.push(FolderInfo {
name: folder,
description: desc,
file_count: count,
});
}
Ok(Json(result))
}
pub async fn create_folder(
State(state): State<AppState>,
Path(username): Path<String>,
Json(req): Json<FolderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db_path = user_db_path(&state, &username);
let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let folder = if req.name.starts_with('/') {
req.name
} else {
format!("/{}", req.name)
};
let desc = req.description.unwrap_or_default();
conn.execute(
"INSERT OR IGNORE INTO virtual_folders (folder, description) VALUES (?1, ?2)",
params![folder, desc],
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"status": "ok",
"folder": folder,
"description": desc
})))
}
pub async fn delete_folder(
State(state): State<AppState>,
Path((username, folder_name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db_path = user_db_path(&state, &username);
let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let folder = if folder_name.starts_with('/') {
folder_name
} else {
format!("/{}", folder_name)
};
let tag = folder.trim_start_matches('/').to_string();
conn.execute("DELETE FROM file_tags WHERE tag = ?1", params![tag])
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
conn.execute("DELETE FROM virtual_folders WHERE folder = ?1", params![folder])
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({"status": "ok", "deleted": folder})))
}
pub async fn list_files(
State(state): State<AppState>,
Path(username): Path<String>,
Query(q): Query<serde_json::Map<String, serde_json::Value>>,
) -> Result<Json<Vec<FileInfo>>, (StatusCode, String)> {
let root = user_root(&username);
let db_path = user_db_path(&state, &username);
let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let folder_filter = q.get("folder").and_then(|v| v.as_str()).map(|s| s.to_string());
let filenames: Vec<String> = if let Some(folder) = &folder_filter {
let tag = folder.trim_start_matches('/');
let rows: Vec<String> = conn
.prepare("SELECT filename FROM file_tags WHERE tag = ?1 ORDER BY filename")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.query_map(params![tag], |row| row.get::<_, String>(0))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.filter_map(|r| r.ok())
.collect();
rows
} else {
let mut entries = Vec::new();
if let Ok(rd) = std::fs::read_dir(&root) {
for entry in rd.flatten() {
if entry.path().is_file() {
if let Some(name) = entry.file_name().to_str() {
entries.push(name.to_string());
}
}
}
}
entries.sort();
entries
};
let mut result = Vec::new();
for fname in filenames {
let size = std::fs::metadata(root.join(&fname))
.map(|m| m.len())
.unwrap_or(0);
let mut tags_stmt = conn
.prepare("SELECT tag FROM file_tags WHERE filename = ?1 ORDER BY tag")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let tags: Vec<String> = tags_stmt
.query_map(params![fname], |row| row.get::<_, String>(0))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.filter_map(|r| r.ok())
.collect();
result.push(FileInfo {
name: fname,
size,
tags,
});
}
Ok(Json(result))
}
pub async fn add_tag(
State(state): State<AppState>,
Path(username): Path<String>,
Json(req): Json<TagRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db_path = user_db_path(&state, &username);
let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let folder = if req.tag.starts_with('/') {
req.tag.clone()
} else {
format!("/{}", req.tag)
};
let tag = folder.trim_start_matches('/').to_string();
conn.execute(
"INSERT OR IGNORE INTO virtual_folders (folder, description) VALUES (?1, '')",
params![folder],
)
.ok();
conn.execute(
"INSERT OR IGNORE INTO file_tags (filename, tag) VALUES (?1, ?2)",
params![req.file, tag],
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"status": "ok",
"file": req.file,
"tag": tag
})))
}
pub async fn remove_tag(
State(state): State<AppState>,
Path(username): Path<String>,
Json(req): Json<TagRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db_path = user_db_path(&state, &username);
let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let tag = req.tag.trim_start_matches('/');
conn.execute(
"DELETE FROM file_tags WHERE filename = ?1 AND tag = ?2",
params![req.file, tag],
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"status": "ok",
"file": req.file,
"removed_tag": tag
})))
}
pub async fn file_tags(
State(state): State<AppState>,
Path((username, filename)): Path<(String, String)>,
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
let db_path = user_db_path(&state, &username);
let conn = ensure_schema(&db_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut stmt = conn
.prepare("SELECT tag FROM file_tags WHERE filename = ?1 ORDER BY tag")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let tags: Vec<String> = stmt
.query_map(params![filename], |row| row.get::<_, String>(0))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.filter_map(|r| r.ok())
.collect();
Ok(Json(tags))
}
pub async fn ui_page() -> Html<String> {
Html(MYFILES_HTML.to_string())
}
const MYFILES_HTML: &str = r#"<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MyFiles — MarkBase</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f7; color: #1d1d1f; }
.header { background: #fff; border-bottom: 1px solid #d2d2d7; padding: 12px 20px; display: flex; align-items: center; justify-content: space-between; }
.header h1 { font-size: 20px; font-weight: 600; }
.header .user-info { font-size: 14px; color: #6e6e73; }
.container { display: flex; max-width: 1200px; margin: 0 auto; padding: 20px; gap: 20px; }
.sidebar { width: 220px; flex-shrink: 0; }
.sidebar h2 { font-size: 14px; color: #6e6e73; text-transform: uppercase; margin-bottom: 10px; }
.folder-list { list-style: none; }
.folder-list li { padding: 8px 12px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 14px; }
.folder-list li:hover { background: #e8e8ed; }
.folder-list li.active { background: #0071e3; color: #fff; }
.folder-list .count { margin-left: auto; font-size: 12px; opacity: 0.6; }
.main { flex: 1; min-width: 0; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
.toolbar input[type="text"] { flex: 1; padding: 8px 12px; border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px; }
.btn { padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; }
.btn-primary { background: #0071e3; color: #fff; }
.btn-primary:hover { background: #0058b0; }
.btn-secondary { background: #e8e8ed; color: #1d1d1f; }
.btn-secondary:hover { background: #d2d2d7; }
.btn-danger { background: #ff3b30; color: #fff; }
.btn-danger:hover { background: #cc2918; }
.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.file-card { background: #fff; border-radius: 12px; padding: 12px; border: 1px solid #e8e8ed; }
.file-card .name { font-size: 14px; font-weight: 500; word-break: break-all; margin-bottom: 4px; }
.file-card .size { font-size: 12px; color: #6e6e73; margin-bottom: 8px; }
.file-card .tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #e8e8ed; }
.tag.blue { background: #d1e8ff; color: #0058b0; }
.empty { text-align: center; padding: 60px; color: #6e6e73; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.show { display: flex; }
.modal { background: #fff; border-radius: 16px; padding: 24px; min-width: 320px; max-width: 400px; }
.modal h3 { font-size: 18px; margin-bottom: 16px; }
.modal label { font-size: 14px; color: #6e6e73; display: block; margin-bottom: 4px; }
.modal input { width: 100%; padding: 8px 12px; border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px; margin-bottom: 12px; }
.modal .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
</style>
</head>
<body>
<div class="header">
<h1>📁 MyFiles</h1>
<div class="user-info" id="user-info">Loading...</div>
</div>
<div class="container">
<div class="sidebar">
<h2>Folders</h2>
<ul class="folder-list" id="folder-list">
<li class="active" data-folder="">All Files</li>
</ul>
<div style="margin-top:16px">
<button class="btn btn-secondary" onclick="showNewFolderModal()" style="width:100%">+ New Folder</button>
</div>
</div>
<div class="main">
<div class="toolbar">
<input type="text" id="search" placeholder="Search files..." oninput="loadFiles()">
<button class="btn btn-primary" onclick="showUploadModal()">Upload</button>
</div>
<div class="file-grid" id="file-grid"></div>
<div class="empty" id="empty-state" style="display:none">No files found</div>
</div>
</div>
<div class="modal-overlay" id="folder-modal">
<div class="modal">
<h3>New Folder</h3>
<label>Folder Name</label>
<input type="text" id="folder-name" placeholder="e.g. photos">
<label>Description</label>
<input type="text" id="folder-desc" placeholder="Optional description">
<div class="actions">
<button class="btn btn-secondary" onclick="hideFolderModal()">Cancel</button>
<button class="btn btn-primary" onclick="createFolder()">Create</button>
</div>
</div>
</div>
<div class="modal-overlay" id="tag-modal">
<div class="modal">
<h3>Tag File</h3>
<p style="margin-bottom:12px;font-size:14px" id="tag-filename"></p>
<label>Tag / Folder</label>
<input type="text" id="tag-name" placeholder="e.g. photos">
<div class="actions">
<button class="btn btn-secondary" onclick="hideTagModal()">Cancel</button>
<button class="btn btn-primary" onclick="addTag()">Tag</button>
</div>
</div>
</div>
<script>
const API = '/api/v2/myfiles';
let username = 'demo';
let currentFolder = '';
let allFiles = [];
async function init() {
try {
const res = await fetch('/api/v2/auth/verify');
const data = await res.json();
if (data.user) username = data.user;
} catch(e) {}
document.getElementById('user-info').textContent = 'User: ' + username;
loadFolders();
loadFiles();
}
async function loadFolders() {
try {
const res = await fetch(`${API}/${username}/folders`);
const folders = await res.json();
const list = document.getElementById('folder-list');
list.innerHTML = '<li class="active" data-folder="" onclick="selectFolder(\'\')">All Files</li>';
for (const f of folders) {
const li = document.createElement('li');
li.dataset.folder = f.name;
li.onclick = () => selectFolder(f.name);
li.innerHTML = `📁 ${f.name} <span class="count">${f.file_count}</span>`;
list.appendChild(li);
}
} catch(e) { console.error(e); }
}
async function loadFiles() {
const search = document.getElementById('search').value;
let url = `${API}/${username}/files`;
if (currentFolder) url += `?folder=${encodeURIComponent(currentFolder)}`;
try {
const res = await fetch(url);
allFiles = await res.json();
const filtered = search ? allFiles.filter(f => f.name.toLowerCase().includes(search.toLowerCase())) : allFiles;
renderFiles(filtered);
} catch(e) { console.error(e); }
}
function renderFiles(files) {
const grid = document.getElementById('file-grid');
const empty = document.getElementById('empty-state');
grid.innerHTML = '';
if (files.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
for (const f of files) {
const card = document.createElement('div');
card.className = 'file-card';
let tagHtml = '';
for (const t of f.tags) {
tagHtml += `<span class="tag blue" onclick="event.stopPropagation();removeTag('${f.name}','${t}')" style="cursor:pointer">${t} ×</span>`;
}
tagHtml += `<span class="tag" onclick="showTagModal('${f.name}')" style="cursor:pointer">+ tag</span>`;
card.innerHTML = `
<div class="name">${f.name}</div>
<div class="size">${formatSize(f.size)}</div>
<div class="tags">${tagHtml}</div>
`;
grid.appendChild(card);
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes/1048576).toFixed(1) + ' MB';
return (bytes/1073741824).toFixed(1) + ' GB';
}
function selectFolder(folder) {
currentFolder = folder;
document.querySelectorAll('.folder-list li').forEach(li => li.classList.remove('active'));
const target = document.querySelector(`[data-folder="${folder}"]`);
if (target) target.classList.add('active');
loadFiles();
}
function showNewFolderModal() { document.getElementById('folder-modal').classList.add('show'); }
function hideFolderModal() { document.getElementById('folder-modal').classList.remove('show'); }
async function createFolder() {
const name = document.getElementById('folder-name').value.trim();
const desc = document.getElementById('folder-desc').value.trim();
if (!name) return;
await fetch(`${API}/${username}/folders`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, description: desc})
});
document.getElementById('folder-name').value = '';
document.getElementById('folder-desc').value = '';
hideFolderModal();
loadFolders();
}
function showTagModal(filename) {
document.getElementById('tag-filename').textContent = filename;
document.getElementById('tag-name').value = '';
document.getElementById('tag-modal').classList.add('show');
}
function hideTagModal() { document.getElementById('tag-modal').classList.remove('show'); }
async function addTag() {
const filename = document.getElementById('tag-filename').textContent;
const tag = document.getElementById('tag-name').value.trim();
if (!tag) return;
await fetch(`${API}/${username}/tags`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({file: filename, tag})
});
hideTagModal();
loadFolders();
loadFiles();
}
async function removeTag(file, tag) {
await fetch(`${API}/${username}/tags`, {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({file, tag})
});
loadFolders();
loadFiles();
}
function showUploadModal() { alert('Upload via WebDAV at http://webdav.momentry.ddns.net (user: ' + username + ')'); }
init();
</script>
</body>
</html>"#;

View File

@@ -290,6 +290,17 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
.route("/webdav", any(handle_webdav_multi))
.route("/webdav/", any(handle_webdav_multi))
.route("/webdav/*path", any(handle_webdav_multi))
// Admin WebDAV (browse all user directories)
.route("/admin-webdav", any(handle_webdav_admin))
.route("/admin-webdav/", any(handle_webdav_admin))
.route("/admin-webdav/*path", any(handle_webdav_admin))
// MyFiles User Interface (Web UI + API)
.route("/myfiles", get(crate::myfiles::ui_page))
.route("/api/v2/myfiles/:username/folders", get(crate::myfiles::list_folders).post(crate::myfiles::create_folder))
.route("/api/v2/myfiles/:username/folders/:folder_name", delete(crate::myfiles::delete_folder))
.route("/api/v2/myfiles/:username/files", get(crate::myfiles::list_files))
.route("/api/v2/myfiles/:username/tags", post(crate::myfiles::add_tag).delete(crate::myfiles::remove_tag))
.route("/api/v2/myfiles/:username/files/:filename/tags", get(crate::myfiles::file_tags))
.layer(Extension(webdav_parent))
.layer(Extension(upload_hook))
.layer(Extension(webdav_versioning))
@@ -2624,3 +2635,86 @@ fn unauthorized_response() -> axum::response::Response {
axum::body::Body::from("Unauthorized"),
).into_response()
}
static ADMIN_WEBDAV_HANDLER: LazyLock<Option<dav_server::DavHandler>> = LazyLock::new(|| {
let parent = std::env::var("MB_WEBDAV_PARENT")
.unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string());
let parent_path = std::path::PathBuf::from(&parent);
if !parent_path.exists() {
return None;
}
let vfs: Box<dyn crate::vfs::VfsBackend> = Box::new(crate::vfs::local_fs::LocalFs::new());
let locks_dir = parent_path.join(".webdav_locks");
let _ = std::fs::create_dir_all(&locks_dir);
let locks_file = locks_dir.join("admin.json");
Some(crate::webdav::create_webdav_handler_persisted(
vfs,
parent_path,
None,
"admin".to_string(),
None,
locks_file,
))
});
async fn handle_webdav_admin(
Extension(upload_hook): Extension<Arc<crate::ssh_server::upload_hook::UploadHook>>,
req: axum::extract::Request,
) -> axum::response::Response {
let admin_users = std::env::var("MB_WEBDAV_ADMIN_USERS")
.unwrap_or_else(|_| "admin:admin123".to_string());
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 {
Some(ref creds) => {
admin_users.split(',')
.filter_map(|entry| {
let mut parts = entry.splitn(2, ':');
let u = parts.next()?.to_string();
let p = parts.next().unwrap_or("").to_string();
Some((u, p))
})
.any(|(u, p)| u == creds.0 && p == creds.1)
}
None => false,
};
if !valid {
return unauthorized_response();
}
let handler = match ADMIN_WEBDAV_HANDLER.as_ref() {
Some(h) => h.clone(),
None => return unauthorized_response(),
};
let (mut parts, body) = req.into_parts();
let new_path = parts.uri.path().strip_prefix("/admin-webdav").unwrap_or("/");
let new_path = if new_path.is_empty() || !new_path.starts_with('/') {
format!("/{}", new_path)
} else {
new_path.to_string()
};
let builder = axum::http::Uri::builder().path_and_query(new_path.as_str());
if let Ok(uri) = builder.build() {
parts.uri = uri;
}
let req = axum::http::Request::from_parts(parts, body);
let dav_resp = handler.handle(req).await;
let (parts, body) = dav_resp.into_parts();
let axum_body = axum::body::Body::from_stream(body);
axum::response::Response::from_parts(parts, axum_body)
}

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()));
}
}

View File

@@ -36,7 +36,7 @@ fn map_vfs_error(e: VfsError) -> FsError {
}
/// Expected credentials for WebDAV Basic Auth validation
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct WebdavCredentials {
pub username: String,
pub password: Option<String>,
@@ -82,6 +82,7 @@ pub struct VfsDavFs {
props_data: Arc<RwLock<HashMap<String, Vec<DavProp>>>>,
props_path: PathBuf,
enable_acl: bool,
virtual_mode: bool,
}
impl Clone for VfsDavFs {
@@ -95,6 +96,7 @@ impl Clone for VfsDavFs {
props_data: self.props_data.clone(),
props_path: self.props_path.clone(),
enable_acl: self.enable_acl,
virtual_mode: self.virtual_mode,
}
}
}
@@ -170,18 +172,16 @@ impl VfsDavFs {
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
) -> Box<Self> {
let props_path = Self::dead_props_path(&root);
let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root)));
Box::new(Self {
vfs,
root,
upload_hook,
user_uuid,
versioning: None,
props_data,
props_path,
enable_acl: true,
})
Self::new_inner(vfs, root, upload_hook, user_uuid, None, false)
}
pub fn new_virtual(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
) -> Box<Self> {
Self::new_inner(vfs, root, upload_hook, user_uuid, None, true)
}
pub fn with_versioning(
@@ -190,6 +190,17 @@ impl VfsDavFs {
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Arc<WebDavVersioning>,
) -> Box<Self> {
Self::new_inner(vfs, root, upload_hook, user_uuid, Some(versioning), false)
}
fn new_inner(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Option<Arc<WebDavVersioning>>,
virtual_mode: bool,
) -> Box<Self> {
let props_path = Self::dead_props_path(&root);
let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root)));
@@ -198,10 +209,11 @@ impl VfsDavFs {
root,
upload_hook,
user_uuid,
versioning: Some(versioning),
versioning,
props_data,
props_path,
enable_acl: true,
virtual_mode,
})
}
@@ -218,10 +230,15 @@ impl VfsDavFs {
let relative = path.as_rel_ospath();
let full = self.root.join(relative);
// Path traversal protection: canonicalize root once
if self.virtual_mode {
if relative.components().any(|c| c == std::path::Component::ParentDir) {
return Err(FsError::NotFound);
}
return Ok(full);
}
let root_canonical = self.root.canonicalize().map_err(|_| FsError::NotFound)?;
// If path exists, use canonicalized version
if let Ok(canonical) = full.canonicalize() {
if !canonical.starts_with(&root_canonical) {
return Err(FsError::NotFound);
@@ -229,15 +246,12 @@ impl VfsDavFs {
return Ok(canonical);
}
// Path doesn't exist yet (e.g., new file/dir being created).
// Validate parent directory is within root and no .. traversal.
let parent = full.parent().ok_or(FsError::NotFound)?;
let parent_canonical = parent.canonicalize().map_err(|_| FsError::NotFound)?;
if !parent_canonical.starts_with(&root_canonical) {
return Err(FsError::NotFound);
}
// Sanity check: ensure relative path doesn't contain ".."
if relative.components().any(|c| c == std::path::Component::ParentDir) {
return Err(FsError::NotFound);
}
@@ -1087,7 +1101,16 @@ pub fn create_webdav_handler(
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
) -> dav_server::DavHandler {
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None)
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None, false)
}
pub fn create_webdav_handler_virtual(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
) -> dav_server::DavHandler {
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None, true)
}
pub fn create_webdav_handler_with_versioning(
@@ -1097,7 +1120,7 @@ pub fn create_webdav_handler_with_versioning(
user_uuid: String,
versioning: Arc<WebDavVersioning>,
) -> dav_server::DavHandler {
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, Some(versioning), None)
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, Some(versioning), None, false)
}
pub fn create_webdav_handler_persisted(
@@ -1108,7 +1131,7 @@ pub fn create_webdav_handler_persisted(
versioning: Option<Arc<WebDavVersioning>>,
locks_file: PathBuf,
) -> dav_server::DavHandler {
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, versioning, Some(locks_file))
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, versioning, Some(locks_file), false)
}
fn create_webdav_handler_inner(
@@ -1118,10 +1141,12 @@ fn create_webdav_handler_inner(
user_uuid: String,
versioning: Option<Arc<WebDavVersioning>>,
locks_file: Option<PathBuf>,
virtual_mode: bool,
) -> dav_server::DavHandler {
let dav_fs = match versioning {
Some(v) => VfsDavFs::with_versioning(vfs, root, upload_hook, user_uuid, v),
None => VfsDavFs::new(vfs, root, upload_hook, user_uuid),
let dav_fs = match (versioning, virtual_mode) {
(Some(v), _) => VfsDavFs::with_versioning(vfs, root, upload_hook, user_uuid, v),
(None, true) => VfsDavFs::new_virtual(vfs, root, upload_hook, user_uuid),
(None, false) => VfsDavFs::new(vfs, root, upload_hook, user_uuid),
};
let locksystem: Box<dyn DavLockSystem> = match locks_file {
Some(path) => PersistedLs::new(path),
@@ -1131,6 +1156,7 @@ fn create_webdav_handler_inner(
.filesystem(dav_fs)
.locksystem(locksystem)
.strip_prefix("")
.autoindex(true)
.build_handler()
}