use axum::{ body::Body, extract::{Path, Query, State}, http::{header, StatusCode, HeaderMap}, response::{Html, IntoResponse, Json, Response}, }; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::OnceLock; 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); "; static MYFILES_UPLOAD_PATH: OnceLock = OnceLock::new(); pub fn init_upload_path(path: String) { let _ = MYFILES_UPLOAD_PATH.set(path); } fn upload_base_path() -> &'static str { MYFILES_UPLOAD_PATH.get().map(|s| s.as_str()) .unwrap_or("/Users/accusys/momentry/var/sftpgo/data") } fn user_db_path(state: &AppState, username: &str) -> PathBuf { PathBuf::from(&state.upload_path) .join(username) .join("webdav_virtual.sqlite") } fn user_root(base_path: &str, username: &str) -> PathBuf { PathBuf::from(base_path).join(username) } fn ensure_schema(db_path: &PathBuf) -> anyhow::Result { 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, } #[derive(Deserialize)] pub struct FolderRequest { pub name: String, pub description: Option, } #[derive(Deserialize)] pub struct TagRequest { pub file: String, pub tag: String, } pub async fn list_folders( State(state): State, Path(username): Path, ) -> Result>, (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, Path(username): Path, Json(req): Json, ) -> Result, (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, Path((username, folder_name)): Path<(String, String)>, ) -> Result, (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 delete_file( State(state): State, Path((username, filename)): Path<(String, String)>, ) -> Result, (StatusCode, String)> { let root = user_root(&state.upload_path, &username); let file_path = root.join(&filename); let db_path = user_db_path(&state, &username); if tokio::fs::remove_file(&file_path).await.is_err() { return Err((StatusCode::NOT_FOUND, "File not found".to_string())); } // Remove tags associated with this file if let Ok(conn) = ensure_schema(&db_path) { let _ = conn.execute("DELETE FROM file_tags WHERE filename = ?1", params![filename]); } Ok(Json(serde_json::json!({"status": "ok", "deleted": filename}))) } pub async fn list_files( State(state): State, Path(username): Path, Query(q): Query>, ) -> Result>, (StatusCode, String)> { let root = user_root(&state.upload_path, &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 = if let Some(folder) = &folder_filter { let tag = folder.trim_start_matches('/'); let rows: Vec = 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 = 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, Path(username): Path, Json(req): Json, ) -> Result, (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, Path(username): Path, Json(req): Json, ) -> Result, (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, Path((username, filename)): Path<(String, String)>, ) -> Result>, (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 = 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 preview_file( Path((username, filename)): Path<(String, String)>, ) -> Response { let root = user_root(upload_base_path(), &username); let file_path = root.join(&filename); if !file_path.exists() || !file_path.is_file() { return (StatusCode::NOT_FOUND, "File not found").into_response(); } let ext = file_path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let mime = match ext.as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml", "pdf" => "application/pdf", "mp4" | "m4v" => "video/mp4", "webm" => "video/webm", "mov" => "video/quicktime", "avi" => "video/x-msvideo", "mkv" => "video/x-matroska", "mp3" => "audio/mpeg", "m4a" => "audio/mp4", "wav" => "audio/wav", "flac" => "audio/flac", "ogg" => "audio/ogg", "aac" => "audio/aac", "txt" | "md" | "json" | "yaml" | "yml" | "toml" | "log" | "csv" | "xml" | "html" | "js" | "ts" | "rs" | "py" | "sh" => "text/plain; charset=utf-8", _ => "application/octet-stream", }; let is_text = mime.starts_with("text/"); if is_text { match tokio::fs::read_to_string(&file_path).await { Ok(content) => { let headers = [(header::CONTENT_TYPE, "text/plain; charset=utf-8")]; (headers, content).into_response() } Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response(), } } else { match tokio::fs::read(&file_path).await { Ok(data) => { let headers = [(header::CONTENT_TYPE, mime)]; (headers, Body::from(data)).into_response() } Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response(), } } } pub async fn ui_page() -> Html { Html(MYFILES_HTML.to_string()) } const MYFILES_HTML: &str = r#" MyFiles — MarkBase

📁 MyFiles

"#;