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
This commit is contained in:
+170
-11
@@ -1,11 +1,13 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{Html, Json},
|
||||
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;
|
||||
|
||||
@@ -26,18 +28,25 @@ 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 {
|
||||
let root = std::env::var("MB_WEBDAV_PARENT")
|
||||
.unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string());
|
||||
PathBuf::from(root)
|
||||
PathBuf::from(&state.upload_path)
|
||||
.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 user_root(base_path: &str, username: &str) -> PathBuf {
|
||||
PathBuf::from(base_path).join(username)
|
||||
}
|
||||
|
||||
fn ensure_schema(db_path: &PathBuf) -> anyhow::Result<Connection> {
|
||||
@@ -159,12 +168,32 @@ pub async fn delete_folder(
|
||||
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(&username);
|
||||
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()))?;
|
||||
|
||||
@@ -296,6 +325,64 @@ pub async fn file_tags(
|
||||
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())
|
||||
}
|
||||
@@ -345,6 +432,18 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; b
|
||||
.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>
|
||||
@@ -396,6 +495,17 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; b
|
||||
</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';
|
||||
@@ -456,7 +566,7 @@ function renderFiles(files) {
|
||||
}
|
||||
tagHtml += `<span class="tag" onclick="showTagModal('${f.name}')" style="cursor:pointer">+ tag</span>`;
|
||||
card.innerHTML = `
|
||||
<div class="name">${f.name}</div>
|
||||
<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>
|
||||
`;
|
||||
@@ -527,6 +637,55 @@ async function removeTag(file, tag) {
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user