Files
markbase/markbase-core/src/myfiles.rs
Warren 4fa8fd8c1f
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled
Merge origin SMB fixes with local Phase 21-22 features
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
2026-06-30 07:25:04 +08:00

692 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>"#;