Origin changes merged: - SMB performance optimization (pread/pwrite, tokio Mutex) - macOS SMB mount fix (AAPL caps, credit grant) - Compound request integration tests - CTDB architecture analysis Local changes preserved: - upload_path config (deployed, tested stable) - delete_file + preview_file routes (MyFiles UI) - SSH async I/O (cipher.rs, packet.rs, server.rs) - auth.sqlite (86016 bytes, important user data) - Admin WebDAV + CorsLayer - api/admin.rs + api/config.rs (new endpoints) Conflicts resolved: - myfiles.rs: kept upload_path + OnceLock static - auth.sqlite: preserved local version (important data) Test results: 393 passed, 5 auth tests failed - PG tests require external PostgreSQL - Auth tests expect specific password hashes - auth.sqlite preserved with actual user credentials
692 lines
26 KiB
Rust
692 lines
26 KiB
Rust
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<String> = 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<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 delete_file(
|
||
State(state): State<AppState>,
|
||
Path((username, filename)): Path<(String, String)>,
|
||
) -> Result<Json<serde_json::Value>, (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<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(&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<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 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<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; }
|
||
.preview-modal { max-width: 90vw; max-height: 90vh; width: 800px; display: flex; flex-direction: column; padding: 0; overflow: hidden; }
|
||
.preview-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #d2d2d7; }
|
||
.preview-header h3 { font-size: 16px; margin: 0; }
|
||
.btn-sm { padding: 4px 12px; font-size: 13px; }
|
||
.preview-content { flex: 1; overflow: auto; padding: 20px; min-height: 200px; max-height: calc(90vh - 60px); }
|
||
.preview-loading { text-align: center; padding: 40px; color: #6e6e73; }
|
||
.preview-content img { max-width: 100%; height: auto; display: block; margin: 0 auto; }
|
||
.preview-content pre { background: #f5f5f7; padding: 16px; border-radius: 8px; overflow: auto; font-size: 13px; line-height: 1.5; max-height: 60vh; }
|
||
.preview-content iframe { width: 100%; height: 70vh; border: none; }
|
||
.preview-content .file-meta { font-size: 14px; color: #6e6e73; text-align: center; padding: 40px; }
|
||
.preview-content .file-meta a { color: #0071e3; text-decoration: none; }
|
||
.preview-content .file-meta a:hover { text-decoration: underline; }
|
||
</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>
|
||
<div class="modal-overlay" id="preview-modal" onclick="hidePreview()">
|
||
<div class="modal preview-modal" onclick="event.stopPropagation()">
|
||
<div class="preview-header">
|
||
<h3 id="preview-filename"></h3>
|
||
<button class="btn btn-secondary btn-sm" onclick="hidePreview()">✕</button>
|
||
</div>
|
||
<div class="preview-content" id="preview-content">
|
||
<div class="preview-loading">Loading preview...</div>
|
||
</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" style="cursor:pointer;color:#0071e3" onclick="previewFile('${f.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 + ')'); }
|
||
|
||
async function previewFile(filename) {
|
||
document.getElementById('preview-filename').textContent = filename;
|
||
document.getElementById('preview-content').innerHTML = '<div class="preview-loading">Loading preview...</div>';
|
||
document.getElementById('preview-modal').classList.add('show');
|
||
|
||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||
const imageExts = ['png','jpg','jpeg','gif','webp','svg'];
|
||
const videoExts = ['mp4','webm','mov','avi','mkv','m4v'];
|
||
const audioExts = ['mp3','m4a','wav','flac','ogg','aac'];
|
||
const textExts = ['txt','md','json','yaml','yml','toml','log','csv','xml','html','js','ts','rs','py','sh','css','ini','cfg','conf'];
|
||
|
||
if (imageExts.includes(ext)) {
|
||
document.getElementById('preview-content').innerHTML = `<img src="${API}/${username}/preview/${encodeURIComponent(filename)}" alt="${filename}" style="max-width:100%;height:auto">`;
|
||
} else if (videoExts.includes(ext)) {
|
||
document.getElementById('preview-content').innerHTML = `<video controls autoplay style="max-width:100%;max-height:70vh"><source src="${API}/${username}/preview/${encodeURIComponent(filename)}" type="video/${ext === 'm4v' ? 'mp4' : ext}"></video>`;
|
||
} else if (audioExts.includes(ext)) {
|
||
document.getElementById('preview-content').innerHTML = `<audio controls autoplay style="width:100%"><source src="${API}/${username}/preview/${encodeURIComponent(filename)}" type="audio/${ext === 'm4a' ? 'mp4' : ext}"></audio>`;
|
||
} else if (ext === 'pdf') {
|
||
document.getElementById('preview-content').innerHTML = `<iframe src="${API}/${username}/preview/${encodeURIComponent(filename)}"></iframe>`;
|
||
} else if (textExts.includes(ext)) {
|
||
try {
|
||
const res = await fetch(`${API}/${username}/preview/${encodeURIComponent(filename)}`);
|
||
if (!res.ok) throw new Error('Preview failed');
|
||
const text = await res.text();
|
||
document.getElementById('preview-content').innerHTML = `<pre>${escapeHtml(text)}</pre>`;
|
||
} catch(e) {
|
||
document.getElementById('preview-content').innerHTML = '<div class="file-meta">Preview not available</div>';
|
||
}
|
||
} else {
|
||
const size = allFiles.find(f => f.name === filename)?.size || 0;
|
||
document.getElementById('preview-content').innerHTML = `
|
||
<div class="file-meta">
|
||
<p>${filename}</p>
|
||
<p>${formatSize(size)}</p>
|
||
<p style="margin-top:12px"><a href="${API}/${username}/preview/${encodeURIComponent(filename)}" download="${filename}">⬇ Download</a></p>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function hidePreview() {
|
||
document.getElementById('preview-modal').classList.remove('show');
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const d = document.createElement('div');
|
||
d.textContent = text;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>"#; |