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,
|
port: u16,
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
user: String,
|
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<()> {
|
pub async fn handle_webdav_command(cmd: WebdavCommand) -> anyhow::Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
WebdavCommand::Start { port, user } => {
|
WebdavCommand::Start {
|
||||||
// Parse username and optional password (format: "name:password")
|
port,
|
||||||
|
user,
|
||||||
|
virtual_mode,
|
||||||
|
db,
|
||||||
|
} => {
|
||||||
let username = user.split(':').next().unwrap_or(&user).to_string();
|
let username = user.split(':').next().unwrap_or(&user).to_string();
|
||||||
let password = user.split(':').nth(1).map(|s| s.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!("Port: {}", port);
|
||||||
println!("Home: {}", home_dir.display());
|
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!();
|
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(())
|
Ok(())
|
||||||
@@ -61,22 +207,36 @@ async fn run_webdav_server(
|
|||||||
home_dir: PathBuf,
|
home_dir: PathBuf,
|
||||||
user: String,
|
user: String,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
virtual_mode: bool,
|
||||||
|
db: Option<String>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use axum::{routing::any, Router};
|
use axum::{routing::any, Router};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
let vfs = Box::new(crate::vfs::local_fs::LocalFs::new());
|
let dav_handler = if virtual_mode {
|
||||||
let upload_hook = None;
|
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(
|
async fn webdav_auth_middleware(
|
||||||
req: Request,
|
req: Request,
|
||||||
next: middleware::Next,
|
next: middleware::Next,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Get credentials from extensions (without consuming)
|
|
||||||
let expected = req.extensions().get::<crate::webdav::WebdavCredentials>().cloned();
|
let expected = req.extensions().get::<crate::webdav::WebdavCredentials>().cloned();
|
||||||
|
|
||||||
let auth = req
|
let auth = req
|
||||||
.headers()
|
.headers()
|
||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
@@ -107,7 +267,8 @@ async fn run_webdav_server(
|
|||||||
HeaderValue::from_static("Basic realm=\"MarkBase WebDAV\""),
|
HeaderValue::from_static("Basic realm=\"MarkBase WebDAV\""),
|
||||||
)],
|
)],
|
||||||
Body::from("Unauthorized"),
|
Body::from("Unauthorized"),
|
||||||
).into_response();
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
@@ -116,19 +277,23 @@ async fn run_webdav_server(
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", any(handle_dav))
|
.route("/", any(handle_dav))
|
||||||
.route("/*path", any(handle_dav))
|
.route("/*path", any(handle_dav))
|
||||||
|
.layer(Extension(dav_handler))
|
||||||
|
.layer(middleware::from_fn(webdav_auth_middleware))
|
||||||
.layer(Extension(crate::webdav::WebdavCredentials {
|
.layer(Extension(crate::webdav::WebdavCredentials {
|
||||||
username: user.clone(),
|
username: user.clone(),
|
||||||
password,
|
password,
|
||||||
}))
|
}));
|
||||||
.layer(Extension(dav_handler))
|
|
||||||
.layer(middleware::from_fn(webdav_auth_middleware));
|
|
||||||
|
|
||||||
let addr = format!("0.0.0.0:{}", port);
|
let addr = format!("0.0.0.0:{}", port);
|
||||||
let listener = TcpListener::bind(&addr).await?;
|
let listener = TcpListener::bind(&addr).await?;
|
||||||
|
|
||||||
println!("WebDAV server listening on http://{}", addr);
|
println!("WebDAV server listening on http://{}", addr);
|
||||||
println!("Root: {}", home_dir.display());
|
|
||||||
println!("User: {}", user);
|
println!("User: {}", user);
|
||||||
|
if virtual_mode {
|
||||||
|
println!("Mode: Virtual (SQLite tag-based)");
|
||||||
|
} else {
|
||||||
|
println!("Mode: Local");
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
println!("Press Ctrl+C to stop");
|
println!("Press Ctrl+C to stop");
|
||||||
|
|
||||||
@@ -143,4 +308,4 @@ async fn handle_dav(
|
|||||||
req: Request,
|
req: Request,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
dav.handle(req).await
|
dav.handle(req).await
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod command;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
pub mod import_markdown;
|
pub mod import_markdown;
|
||||||
|
pub mod myfiles;
|
||||||
pub mod pg_client;
|
pub mod pg_client;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod render;
|
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/", any(handle_webdav_multi))
|
.route("/webdav/", any(handle_webdav_multi))
|
||||||
.route("/webdav/*path", 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(webdav_parent))
|
||||||
.layer(Extension(upload_hook))
|
.layer(Extension(upload_hook))
|
||||||
.layer(Extension(webdav_versioning))
|
.layer(Extension(webdav_versioning))
|
||||||
@@ -2624,3 +2635,86 @@ fn unauthorized_response() -> axum::response::Response {
|
|||||||
axum::body::Body::from("Unauthorized"),
|
axum::body::Body::from("Unauthorized"),
|
||||||
).into_response()
|
).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")]
|
#[cfg(feature = "smb-server")]
|
||||||
pub mod smb_server_backend;
|
pub mod smb_server_backend;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
pub mod virtual_fs;
|
||||||
#[cfg(feature = "async-vfs")]
|
#[cfg(feature = "async-vfs")]
|
||||||
pub mod async_fs;
|
pub mod async_fs;
|
||||||
#[cfg(feature = "async-vfs")]
|
#[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
|
/// Expected credentials for WebDAV Basic Auth validation
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WebdavCredentials {
|
pub struct WebdavCredentials {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
@@ -82,6 +82,7 @@ pub struct VfsDavFs {
|
|||||||
props_data: Arc<RwLock<HashMap<String, Vec<DavProp>>>>,
|
props_data: Arc<RwLock<HashMap<String, Vec<DavProp>>>>,
|
||||||
props_path: PathBuf,
|
props_path: PathBuf,
|
||||||
enable_acl: bool,
|
enable_acl: bool,
|
||||||
|
virtual_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for VfsDavFs {
|
impl Clone for VfsDavFs {
|
||||||
@@ -95,6 +96,7 @@ impl Clone for VfsDavFs {
|
|||||||
props_data: self.props_data.clone(),
|
props_data: self.props_data.clone(),
|
||||||
props_path: self.props_path.clone(),
|
props_path: self.props_path.clone(),
|
||||||
enable_acl: self.enable_acl,
|
enable_acl: self.enable_acl,
|
||||||
|
virtual_mode: self.virtual_mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,18 +172,16 @@ impl VfsDavFs {
|
|||||||
upload_hook: Option<Arc<UploadHook>>,
|
upload_hook: Option<Arc<UploadHook>>,
|
||||||
user_uuid: String,
|
user_uuid: String,
|
||||||
) -> Box<Self> {
|
) -> Box<Self> {
|
||||||
let props_path = Self::dead_props_path(&root);
|
Self::new_inner(vfs, root, upload_hook, user_uuid, None, false)
|
||||||
let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root)));
|
}
|
||||||
Box::new(Self {
|
|
||||||
vfs,
|
pub fn new_virtual(
|
||||||
root,
|
vfs: Box<dyn VfsBackend>,
|
||||||
upload_hook,
|
root: PathBuf,
|
||||||
user_uuid,
|
upload_hook: Option<Arc<UploadHook>>,
|
||||||
versioning: None,
|
user_uuid: String,
|
||||||
props_data,
|
) -> Box<Self> {
|
||||||
props_path,
|
Self::new_inner(vfs, root, upload_hook, user_uuid, None, true)
|
||||||
enable_acl: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_versioning(
|
pub fn with_versioning(
|
||||||
@@ -190,6 +190,17 @@ impl VfsDavFs {
|
|||||||
upload_hook: Option<Arc<UploadHook>>,
|
upload_hook: Option<Arc<UploadHook>>,
|
||||||
user_uuid: String,
|
user_uuid: String,
|
||||||
versioning: Arc<WebDavVersioning>,
|
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> {
|
) -> Box<Self> {
|
||||||
let props_path = Self::dead_props_path(&root);
|
let props_path = Self::dead_props_path(&root);
|
||||||
let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root)));
|
let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root)));
|
||||||
@@ -198,10 +209,11 @@ impl VfsDavFs {
|
|||||||
root,
|
root,
|
||||||
upload_hook,
|
upload_hook,
|
||||||
user_uuid,
|
user_uuid,
|
||||||
versioning: Some(versioning),
|
versioning,
|
||||||
props_data,
|
props_data,
|
||||||
props_path,
|
props_path,
|
||||||
enable_acl: true,
|
enable_acl: true,
|
||||||
|
virtual_mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,10 +230,15 @@ impl VfsDavFs {
|
|||||||
let relative = path.as_rel_ospath();
|
let relative = path.as_rel_ospath();
|
||||||
let full = self.root.join(relative);
|
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)?;
|
let root_canonical = self.root.canonicalize().map_err(|_| FsError::NotFound)?;
|
||||||
|
|
||||||
// If path exists, use canonicalized version
|
|
||||||
if let Ok(canonical) = full.canonicalize() {
|
if let Ok(canonical) = full.canonicalize() {
|
||||||
if !canonical.starts_with(&root_canonical) {
|
if !canonical.starts_with(&root_canonical) {
|
||||||
return Err(FsError::NotFound);
|
return Err(FsError::NotFound);
|
||||||
@@ -229,15 +246,12 @@ impl VfsDavFs {
|
|||||||
return Ok(canonical);
|
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 = full.parent().ok_or(FsError::NotFound)?;
|
||||||
let parent_canonical = parent.canonicalize().map_err(|_| FsError::NotFound)?;
|
let parent_canonical = parent.canonicalize().map_err(|_| FsError::NotFound)?;
|
||||||
if !parent_canonical.starts_with(&root_canonical) {
|
if !parent_canonical.starts_with(&root_canonical) {
|
||||||
return Err(FsError::NotFound);
|
return Err(FsError::NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanity check: ensure relative path doesn't contain ".."
|
|
||||||
if relative.components().any(|c| c == std::path::Component::ParentDir) {
|
if relative.components().any(|c| c == std::path::Component::ParentDir) {
|
||||||
return Err(FsError::NotFound);
|
return Err(FsError::NotFound);
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1101,16 @@ pub fn create_webdav_handler(
|
|||||||
upload_hook: Option<Arc<UploadHook>>,
|
upload_hook: Option<Arc<UploadHook>>,
|
||||||
user_uuid: String,
|
user_uuid: String,
|
||||||
) -> dav_server::DavHandler {
|
) -> 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(
|
pub fn create_webdav_handler_with_versioning(
|
||||||
@@ -1097,7 +1120,7 @@ pub fn create_webdav_handler_with_versioning(
|
|||||||
user_uuid: String,
|
user_uuid: String,
|
||||||
versioning: Arc<WebDavVersioning>,
|
versioning: Arc<WebDavVersioning>,
|
||||||
) -> dav_server::DavHandler {
|
) -> 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(
|
pub fn create_webdav_handler_persisted(
|
||||||
@@ -1108,7 +1131,7 @@ pub fn create_webdav_handler_persisted(
|
|||||||
versioning: Option<Arc<WebDavVersioning>>,
|
versioning: Option<Arc<WebDavVersioning>>,
|
||||||
locks_file: PathBuf,
|
locks_file: PathBuf,
|
||||||
) -> dav_server::DavHandler {
|
) -> 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(
|
fn create_webdav_handler_inner(
|
||||||
@@ -1118,10 +1141,12 @@ fn create_webdav_handler_inner(
|
|||||||
user_uuid: String,
|
user_uuid: String,
|
||||||
versioning: Option<Arc<WebDavVersioning>>,
|
versioning: Option<Arc<WebDavVersioning>>,
|
||||||
locks_file: Option<PathBuf>,
|
locks_file: Option<PathBuf>,
|
||||||
|
virtual_mode: bool,
|
||||||
) -> dav_server::DavHandler {
|
) -> dav_server::DavHandler {
|
||||||
let dav_fs = match versioning {
|
let dav_fs = match (versioning, virtual_mode) {
|
||||||
Some(v) => VfsDavFs::with_versioning(vfs, root, upload_hook, user_uuid, v),
|
(Some(v), _) => VfsDavFs::with_versioning(vfs, root, upload_hook, user_uuid, v),
|
||||||
None => VfsDavFs::new(vfs, root, upload_hook, user_uuid),
|
(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 {
|
let locksystem: Box<dyn DavLockSystem> = match locks_file {
|
||||||
Some(path) => PersistedLs::new(path),
|
Some(path) => PersistedLs::new(path),
|
||||||
@@ -1131,6 +1156,7 @@ fn create_webdav_handler_inner(
|
|||||||
.filesystem(dav_fs)
|
.filesystem(dav_fs)
|
||||||
.locksystem(locksystem)
|
.locksystem(locksystem)
|
||||||
.strip_prefix("")
|
.strip_prefix("")
|
||||||
|
.autoindex(true)
|
||||||
.build_handler()
|
.build_handler()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user