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
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
533
markbase-core/src/myfiles.rs
Normal file
533
markbase-core/src/myfiles.rs
Normal 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>"#;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
737
markbase-core/src/vfs/virtual_fs.rs
Normal file
737
markbase-core/src/vfs/virtual_fs.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user