pub mod terminal;
use crate::auth::{AuthManager, GlobalMount, User};
use crate::config::{AppConfig, ConfigManager};
use crate::tools::diff::compare_files_text;
use crate::tools::paranoid::ParanoidEngine;
use crate::tools::tasks::{TaskInfo, TaskManager};
use crate::vfs::archive::ArchiveHandler;
use crate::vfs::checksum::calculate_checksum;
use crate::vfs::local::LocalFs;
use crate::vfs::sftp::SftpClient;
use crate::vfs::webdav::WebDavClient;
use crate::vfs::DirectoryListing;
use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
routing::{delete, get, post, put},
Router,
};
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
#[derive(RustEmbed)]
#[folder = "frontend/"]
struct Asset;
#[derive(RustEmbed)]
#[folder = "manuals/"]
struct ManualsAsset;
#[derive(Clone)]
pub struct AppState {
pub config: Arc<AppConfig>,
pub auth: Arc<AuthManager>,
pub oidc: Arc<crate::auth::oidc::OidcManager>,
pub tasks: Arc<TaskManager>,
pub tags: Arc<crate::tools::tags::TagManager>,
pub vaults: Arc<crate::vfs::vault::VaultManager>,
pub backup: Arc<crate::tools::sync::BackupManager>,
pub plugins: Arc<crate::plugins::PluginManager>,
}
pub fn create_router(state: AppState) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.route("/api/health", get(handle_health))
.route("/api/system/status", get(handle_system_status))
.route("/api/system/exit", post(handle_system_exit))
.route("/api/system/restart", post(handle_system_restart))
.route("/api/auth/oidc/config", get(handle_oidc_config))
.route("/api/auth/oidc/login", get(handle_oidc_login))
.route("/api/auth/oidc/callback", get(handle_oidc_callback))
.route("/api/auth/login", post(handle_login))
.route("/api/auth/logout", post(handle_logout))
.route("/api/auth/unlock", post(handle_unlock_session))
.route("/api/auth/security-settings", get(handle_get_security_settings).post(handle_update_security_settings))
.route("/api/auth/me", get(handle_get_me))
.route("/api/auth/profile", post(handle_update_profile))
.route("/api/auth/users", get(handle_list_users).post(handle_create_user))
.route("/api/auth/users/:username", delete(handle_delete_user).post(handle_update_user_rbac))
.route("/api/auth/tokens", get(handle_list_api_tokens).post(handle_create_api_token))
.route("/api/auth/tokens/:id", delete(handle_revoke_api_token))
.route("/api/vault/create", post(handle_create_vault))
.route("/api/vault/unlock", post(handle_unlock_vault))
.route("/api/vault/lock", post(handle_lock_vault))
.route("/api/vault/status", get(handle_vault_status))
.route("/api/storage/roots", get(handle_get_storage_roots))
.route("/api/mounts/accessible", get(handle_list_accessible_mounts))
.route("/api/mounts/all", get(handle_list_all_mounts))
.route("/api/mounts", post(handle_create_or_update_mount))
.route("/api/mounts/:id", delete(handle_delete_mount))
.route("/api/bookmarks", get(handle_list_bookmarks).post(handle_create_bookmark))
.route("/api/bookmarks/:id", delete(handle_delete_bookmark))
.route("/api/user/preferences", get(handle_get_user_preferences).post(handle_save_user_preferences).delete(handle_reset_user_preferences))
.route("/share/:token", get(handle_public_share_page))
.route("/api/shares", get(handle_list_shares).post(handle_create_share))
.route("/api/shares/:id", put(handle_update_share).delete(handle_delete_share))
.route("/api/shares/:id/revoke", post(handle_revoke_share))
.route("/api/shares/:id/logs", get(handle_get_share_logs))
.route("/api/public/shares/:token", get(handle_public_get_share_meta))
.route("/api/public/shares/:token/verify", post(handle_public_verify_share))
.route("/api/public/shares/:token/verify-email", post(handle_public_verify_email))
.route("/api/public/shares/:token/preview", get(handle_public_preview_share))
.route("/api/public/shares/:token/download", get(handle_public_download_share))
.route("/api/public/shares/:token/upload", post(handle_public_upload_share))
.route("/api/fs/list", get(handle_list_dir))
.route("/api/fs/tags", get(handle_get_file_tags))
.route("/api/fs/tags/all", get(handle_get_all_tags))
.route("/api/fs/tags/set", post(handle_set_tags))
.route("/api/fs/read", get(handle_read_file))
.route("/api/fs/write", post(handle_write_file))
.route("/api/fs/mkdir", post(handle_mkdir))
.route("/api/fs/rename", post(handle_rename))
.route("/api/fs/batch-rename", post(handle_batch_rename))
.route("/api/fs/delete", post(handle_delete))
.route("/api/fs/copy", post(handle_copy))
.route("/api/fs/deltacopy", post(handle_deltacopy))
.route("/api/fs/move", post(handle_move))
.route("/api/fs/chmod", post(handle_chmod))
.route("/api/fs/chown", post(handle_chown))
.route("/api/fs/upload", post(handle_upload))
.route("/api/fs/download", get(handle_download))
.route("/api/fs/download/batch", post(handle_download_batch))
.route("/api/fs/archive/create", post(handle_archive_create))
.route("/api/fs/archive/extract", post(handle_archive_extract))
.route("/api/fs/checksum", post(handle_calculate_checksum))
.route("/api/remotes/test", post(handle_test_remote))
.route("/api/remotes/proton/status", get(handle_proton_status))
.route("/api/tools/diff/files", post(handle_diff_files))
.route("/api/tools/diff/folders", post(handle_diff_folders))
.route("/api/tools/convert", post(handle_convert_file))
.route("/api/tools/paranoid/dry-run", post(handle_paranoid_dry_run))
.route("/api/tools/sync/analyze", post(handle_sync_analyze))
.route("/api/tools/sync/execute", post(handle_sync_execute))
.route("/api/tools/sync/profiles", get(handle_list_backup_profiles).post(handle_save_backup_profile))
.route("/api/tools/sync/profiles/:id", delete(handle_delete_backup_profile))
.route("/api/tools/sync/profiles/:id/run", post(handle_run_backup_profile))
.route("/api/tools/sync/profiles/:id/toggle", post(handle_toggle_backup_profile))
.route("/api/tools/sync/history", get(handle_get_backup_history))
.route("/api/tools/disk-usage", get(handle_disk_usage))
.route("/api/tools/disks", get(handle_get_disks))
.route("/api/system/disks", get(handle_get_disks))
.route("/api/tools/split", post(handle_split_file))
.route("/api/tools/combine", post(handle_combine_files))
.route("/api/tools/pdf/info", get(handle_pdf_info))
.route("/api/tools/pdf/merge", post(handle_pdf_merge))
.route("/api/tools/pdf/split", post(handle_pdf_split))
.route("/api/tools/pdf/reorder", post(handle_pdf_reorder))
.route("/api/tools/syncthing/status", get(handle_syncthing_status))
.route("/api/tools/syncthing/scan", post(handle_syncthing_scan))
.route("/api/tools/search", post(handle_search))
.route("/api/tools/duplicates/scan", post(handle_duplicates_scan))
.route("/api/tools/duplicates/clean", post(handle_duplicates_clean))
.route("/api/tools/metadata/read", get(handle_metadata_read))
.route("/api/tools/metadata/update", post(handle_metadata_update))
.route("/api/tools/metadata/batch", post(handle_metadata_batch))
.route("/api/tools/logviewer/tail", get(handle_logviewer_tail))
.route("/api/tools/trash/summary", get(handle_trash_summary))
.route("/api/tools/trash/items", get(handle_trash_items))
.route("/api/tools/trash/restore", post(handle_trash_restore))
.route("/api/tools/trash/empty", post(handle_trash_empty))
.route("/api/tools/trash/delete", post(handle_trash_delete))
.route("/api/tools/trash/open-native", post(handle_trash_open_native))
.route("/api/actions/run", post(handle_run_action))
.route("/api/tools/notedog/info", get(handle_notedog_info))
.route("/api/tools/notedog/templates", get(handle_notedog_templates))
.route("/api/tools/notedog/versions", get(handle_notedog_versions))
.route("/api/tools/notedog/version/save", post(handle_notedog_save_version))
.route("/api/tools/notedog/decrypt", post(handle_notedog_decrypt))
.route("/api/tools/notedog/encrypt", post(handle_notedog_encrypt))
.route("/api/tools/notedog/create", post(handle_notedog_create_note))
.route("/api/tools/notedog/section/encrypt", post(handle_notedog_section_encrypt))
.route("/api/tools/notedog/section/decrypt", post(handle_notedog_section_decrypt))
.route("/api/tools/notedog/notebook/encrypt", post(handle_notedog_notebook_encrypt))
.route("/api/tools/notedog/notebook/decrypt", post(handle_notedog_notebook_decrypt))
.route("/api/notes", get(handle_list_db_notes).post(handle_create_db_note))
.route("/api/notes/:id", get(handle_get_db_note).put(handle_update_db_note).delete(handle_delete_db_note))
.route("/api/notes/:id/attachments", get(handle_list_db_note_attachments).post(handle_upload_db_note_attachment))
.route("/api/notes/attachments/upload", post(handle_upload_db_note_attachment_standalone))
.route("/api/notes/attachments/:attachment_id", get(handle_get_db_note_attachment_binary).delete(handle_delete_db_note_attachment))
.route("/api/notes/attachments/:attachment_id/:filename", get(handle_get_db_note_attachment_binary_with_name))
.route("/api/notes/migrate/export", post(handle_notes_migrate_export))
.route("/api/notes/migrate/import", post(handle_notes_migrate_import))
.route("/api/manuals", get(handle_list_manuals))
.route("/api/manuals/:name", get(handle_get_manual))
.route("/api/tools/tetradog/scores", get(handle_tetradog_get_scores).post(handle_tetradog_submit_score).delete(handle_tetradog_clear_scores))
.route("/api/chewtoys/tetradog/scores", get(handle_tetradog_get_scores).post(handle_tetradog_submit_score))
.route("/api/plugins", get(handle_list_plugins))
.route("/api/plugins/install", post(handle_install_plugin))
.route("/api/plugins/:id/toggle", post(handle_toggle_plugin))
.route("/api/plugins/:id", delete(handle_delete_plugin))
.route("/api/plugins/:id/assets/*subpath", get(handle_plugin_asset))
.route("/api/git/status", get(handle_git_status))
.route("/api/git/diff", get(handle_git_diff))
.route("/api/git/stage", post(handle_git_stage))
.route("/api/git/unstage", post(handle_git_unstage))
.route("/api/git/commit", post(handle_git_commit))
.route("/api/git/push", post(handle_git_push))
.route("/api/git/pull", post(handle_git_pull))
.route("/api/git/log", get(handle_git_log))
.route("/api/tasks", get(handle_list_tasks))
.route("/api/tasks/:id", get(handle_get_task))
.route("/api/tasks/:id/cancel", post(handle_cancel_task))
.route("/api/tasks/:id/pause", post(handle_pause_task))
.route("/api/tasks/:id/resume", post(handle_resume_task))
.route("/api/tasks/clear-completed", post(handle_clear_completed_tasks))
.route("/api/ws/terminal", get(terminal::handle_terminal_ws))
.route("/api/config", get(handle_get_config))
.route("/api/system/users-groups", get(handle_get_system_users_groups))
.route("/api/system/config-file", get(handle_get_config_file).post(handle_save_config_file))
.route("/api/system/reload-config", post(handle_reload_config))
.route("/api/system/autostart", get(handle_get_autostart).post(handle_set_autostart))
.route("/api/system/open-with", post(handle_open_with))
.route("/api/system/run-custom-action", post(handle_run_custom_action))
.fallback(handle_static_asset)
.layer(DefaultBodyLimit::max(state.config.server.upload_max_size_mb * 1024 * 1024))
.layer(cors)
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.with_state(state)
}
#[derive(Deserialize)]
struct LoginRequest {
username: String,
password: String,
}
#[derive(Serialize)]
struct LoginResponse {
token: String,
user: User,
}
async fn handle_oidc_config(State(state): State<AppState>) -> Json<crate::auth::oidc::OidcPublicConfig> {
Json(state.oidc.get_public_config())
}
async fn handle_oidc_login(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if !state.oidc.is_enabled() {
return Err((StatusCode::NOT_FOUND, "OIDC authentication is not enabled".to_string()));
}
let scheme = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http");
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost:8080");
let dynamic_redirect_uri = format!("{}://{}/api/auth/oidc/callback", scheme, host);
match state.oidc.generate_auth_url(&dynamic_redirect_uri).await {
Ok((auth_url, _state)) => {
Ok(axum::response::Redirect::temporary(&auth_url))
}
Err(e) => {
tracing::error!("Failed to generate OIDC authorization URL: {}", e);
Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to initiate SSO login: {}", e)))
}
}
}
#[derive(Deserialize)]
struct OidcCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
async fn handle_oidc_callback(
State(state): State<AppState>,
Query(query): Query<OidcCallbackQuery>,
) -> impl IntoResponse {
if let Some(err) = query.error {
let desc = query.error_description.unwrap_or_default();
tracing::warn!("OIDC provider returned error: {} - {}", err, desc);
return axum::response::Redirect::temporary(&format!("/?error={}", urlencoding_simple(&err))).into_response();
}
let code = match query.code {
Some(c) if !c.trim().is_empty() => c,
_ => return axum::response::Redirect::temporary("/?error=missing_code").into_response(),
};
let state_param = match query.state {
Some(s) if !s.trim().is_empty() => s,
_ => return axum::response::Redirect::temporary("/?error=missing_state").into_response(),
};
match state.oidc.exchange_code_and_login(&code, &state_param).await {
Ok((token, user)) => {
tracing::info!(username = %user.username, role = %user.role, "SSO login successful");
let cookie_header = format!(
"cd_token={}; Path=/; SameSite=Lax; Max-Age=2592000",
token
);
let mut response = axum::response::Redirect::temporary(&format!("/?token={}&sso_success=1", token)).into_response();
if let Ok(hv) = axum::http::HeaderValue::from_str(&cookie_header) {
response.headers_mut().insert(axum::http::header::SET_COOKIE, hv);
}
response
}
Err(e) => {
tracing::error!("OIDC callback error: {}", e);
axum::response::Redirect::temporary(&format!("/?error={}", urlencoding_simple(&e.to_string()))).into_response()
}
}
}
fn urlencoding_simple(s: &str) -> String {
let mut encoded = String::new();
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
encoded.push(b as char);
} else {
encoded.push_str(&format!("%{:02X}", b));
}
}
encoded
}
async fn handle_login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, String)> {
match state.auth.authenticate(&payload.username, &payload.password) {
Ok(user) => {
tracing::info!(username = %user.username, is_pam = user.is_pam, role = %user.role, "User successfully authenticated");
let token = state.auth.generate_token(&user).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create token: {}", e))
})?;
Ok(Json(LoginResponse { token, user }))
}
Err(e) => {
tracing::warn!(username = %payload.username, error = %e, "Authentication failed");
Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string()))
}
}
}
pub fn get_system_hostname() -> String {
if let Ok(h) = std::env::var("CD_HOSTNAME") {
let trimmed = h.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
if let Ok(h) = std::env::var("HOSTNAME") {
let trimmed = h.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
#[cfg(unix)]
{
let mut buf = [0u8; 256];
let res = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
if res == 0 {
if let Ok(s) = std::ffi::CStr::from_bytes_until_nul(&buf) {
if let Ok(str_slice) = s.to_str() {
let trimmed = str_slice.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
}
}
#[cfg(windows)]
{
if let Ok(h) = std::env::var("COMPUTERNAME") {
let trimmed = h.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
"localhost".to_string()
}
#[derive(Serialize)]
pub struct SystemStatusResponse {
pub version: String,
pub standalone: bool,
pub auth_enabled: bool,
pub current_user: String,
pub home_dir: String,
pub hostname: String,
pub os: String,
pub arch: String,
pub custom_hostname: Option<String>,
pub show_hostname_badge: bool,
pub hostname_color: Option<String>,
pub hostname_style: Option<String>,
pub hostname_icon: Option<String>,
pub hostname_size: Option<String>,
pub window_title: Option<String>,
}
async fn handle_health(State(state): State<AppState>) -> Json<serde_json::Value> {
let hostname = get_system_hostname();
let node_name = if !state.config.ui.hostname_badge.trim().is_empty() {
state.config.ui.hostname_badge.clone()
} else if !state.config.server.server_name.trim().is_empty() {
state.config.server.server_name.clone()
} else {
hostname.clone()
};
Json(serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"hostname": hostname,
"node_name": node_name,
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"standalone": state.config.server.standalone,
"auth_enabled": state.config.server.enable_auth && !state.config.server.standalone,
"time": chrono::Utc::now().to_rfc3339()
}))
}
async fn handle_system_status(State(state): State<AppState>) -> Json<SystemStatusResponse> {
let current_user = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "user".to_string());
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
let hostname = get_system_hostname();
let custom_hostname = if !state.config.ui.hostname_badge.trim().is_empty() {
Some(state.config.ui.hostname_badge.clone())
} else if !state.config.server.server_name.trim().is_empty() {
Some(state.config.server.server_name.clone())
} else {
None
};
let window_title = if !state.config.ui.window_title.trim().is_empty() {
Some(state.config.ui.window_title.clone())
} else {
None
};
Json(SystemStatusResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
standalone: state.config.server.standalone,
auth_enabled: state.config.server.enable_auth && !state.config.server.standalone,
current_user,
home_dir,
hostname,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
custom_hostname,
show_hostname_badge: state.config.ui.show_hostname_badge,
hostname_color: if !state.config.ui.hostname_color.trim().is_empty() { Some(state.config.ui.hostname_color.clone()) } else { None },
hostname_style: if !state.config.ui.hostname_style.trim().is_empty() { Some(state.config.ui.hostname_style.clone()) } else { None },
hostname_icon: if !state.config.ui.hostname_icon.trim().is_empty() { Some(state.config.ui.hostname_icon.clone()) } else { None },
hostname_size: if !state.config.ui.hostname_size.trim().is_empty() { Some(state.config.ui.hostname_size.clone()) } else { None },
window_title,
})
}
#[allow(dead_code)]
fn extract_claims_or_local(
state: &AppState,
headers: &HeaderMap,
) -> Result<crate::auth::Claims, (StatusCode, String)> {
if !state.config.server.enable_auth || state.config.server.standalone {
let current_user = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "user".to_string());
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
return Ok(crate::auth::Claims {
sub: current_user,
role: "admin".to_string(),
home_dir,
is_pam: false,
allowed_roots: Some("[\"*\"]".to_string()),
token_id: None,
exp: 9999999999,
});
}
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
if let Ok(claims) = state.auth.verify_token(token_str) {
return Ok(claims);
}
}
if let Some(cookie_hdr) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
for pair in cookie_hdr.split(';') {
let pair = pair.trim();
if let Some(tok) = pair.strip_prefix("cd_token=").or_else(|| pair.strip_prefix("token=")) {
if let Ok(claims) = state.auth.verify_token(tok) {
return Ok(claims);
}
}
}
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
pub fn normalize_path(path: &Path) -> PathBuf {
let clean = crate::vfs::local::clean_path_buf(path);
let mut components = Vec::new();
for component in clean.components() {
match component {
std::path::Component::Prefix(..) => {
components.clear();
components.push(component);
}
std::path::Component::RootDir => {
if let Some(std::path::Component::Prefix(..)) = components.first() {
components.truncate(1);
components.push(component);
} else {
components.clear();
components.push(component);
}
}
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if let Some(last) = components.last() {
if last != &std::path::Component::RootDir && !matches!(last, std::path::Component::Prefix(..)) {
components.pop();
}
}
}
std::path::Component::Normal(..) => components.push(component),
}
}
if components.len() == 1 && matches!(components.first(), Some(std::path::Component::Prefix(..))) {
components.push(std::path::Component::RootDir);
}
components.into_iter().collect()
}
pub fn path_starts_with_case_insensitive(path: &Path, prefix: &Path) -> bool {
let path = crate::vfs::local::clean_path_buf(path);
let prefix = crate::vfs::local::clean_path_buf(prefix);
let path_comps: Vec<_> = path.components().collect();
let prefix_comps: Vec<_> = prefix.components().collect();
if prefix_comps.len() > path_comps.len() {
return false;
}
let is_windows_path = cfg!(windows)
|| path_comps.first().map_or(false, |c| match c {
std::path::Component::Prefix(..) => true,
std::path::Component::Normal(n) => {
let s = n.to_string_lossy();
s.len() == 2 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1] == b':'
}
_ => false,
})
|| prefix_comps.first().map_or(false, |c| match c {
std::path::Component::Prefix(..) => true,
std::path::Component::Normal(n) => {
let s = n.to_string_lossy();
s.len() == 2 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1] == b':'
}
_ => false,
});
for (p_comp, pre_comp) in path_comps.iter().zip(prefix_comps.iter()) {
match (p_comp, pre_comp) {
(std::path::Component::Prefix(p_prefix), std::path::Component::Prefix(pre_prefix)) => {
let p_kind = p_prefix.kind();
let pre_kind = pre_prefix.kind();
match (p_kind, pre_kind) {
(std::path::Prefix::Disk(d1), std::path::Prefix::Disk(d2))
| (std::path::Prefix::VerbatimDisk(d1), std::path::Prefix::Disk(d2))
| (std::path::Prefix::Disk(d1), std::path::Prefix::VerbatimDisk(d2))
| (std::path::Prefix::VerbatimDisk(d1), std::path::Prefix::VerbatimDisk(d2)) => {
if !d1.eq_ignore_ascii_case(&d2) {
return false;
}
}
(std::path::Prefix::UNC(s1, sh1), std::path::Prefix::UNC(s2, sh2))
| (std::path::Prefix::VerbatimUNC(s1, sh1), std::path::Prefix::UNC(s2, sh2))
| (std::path::Prefix::UNC(s1, sh1), std::path::Prefix::VerbatimUNC(s2, sh2))
| (std::path::Prefix::VerbatimUNC(s1, sh1), std::path::Prefix::VerbatimUNC(s2, sh2)) => {
if !s1.to_string_lossy().eq_ignore_ascii_case(&s2.to_string_lossy())
|| !sh1.to_string_lossy().eq_ignore_ascii_case(&sh2.to_string_lossy())
{
return false;
}
}
_ => {
if p_prefix.as_os_str().to_string_lossy().to_ascii_lowercase()
!= pre_prefix.as_os_str().to_string_lossy().to_ascii_lowercase()
{
return false;
}
}
}
}
(std::path::Component::RootDir, std::path::Component::RootDir) => {}
(std::path::Component::Normal(p_str), std::path::Component::Normal(pre_str)) => {
if is_windows_path {
if !p_str.to_string_lossy().eq_ignore_ascii_case(&pre_str.to_string_lossy()) {
return false;
}
} else {
if p_str != pre_str {
return false;
}
}
}
_ => {
if p_comp != pre_comp {
return false;
}
}
}
}
true
}
pub fn validate_path_access(
state: &AppState,
headers: &HeaderMap,
raw_path: &str,
is_write: bool,
) -> Result<String, (StatusCode, String)> {
let claims = extract_claims_or_local(state, headers)?;
let user_role = claims.role.as_str();
let user_home = &claims.home_dir;
let allowed_roots_json = claims.allowed_roots.as_deref().unwrap_or("[\"*\"]");
let allowed_roots: Vec<String> = serde_json::from_str(allowed_roots_json).unwrap_or_else(|_| vec!["*".to_string()]);
if (user_role.eq_ignore_ascii_case("readonly")) && is_write {
return Err((StatusCode::FORBIDDEN, "Read-only users cannot modify or delete files".to_string()));
}
if raw_path.contains("://") {
return Ok(raw_path.to_string());
}
let expanded = if raw_path == "~" {
user_home.clone()
} else if let Some(stripped) = raw_path.strip_prefix("~/") {
Path::new(user_home).join(stripped).to_string_lossy().to_string()
} else if let Some(stripped) = raw_path.strip_prefix(r"~\") {
Path::new(user_home).join(stripped).to_string_lossy().to_string()
} else {
#[cfg(windows)]
{
if (raw_path == "/" || raw_path == "\\") && user_home != "/" {
user_home.clone()
} else if (raw_path.starts_with('/') || raw_path.starts_with('\\'))
&& raw_path.len() >= 3
&& raw_path.as_bytes()[1].is_ascii_alphabetic()
&& raw_path.as_bytes()[2] == b':'
{
raw_path[1..].to_string()
} else {
raw_path.to_string()
}
}
#[cfg(not(windows))]
{
if (raw_path.starts_with('/') || raw_path.starts_with('\\'))
&& raw_path.len() >= 3
&& raw_path.as_bytes()[1].is_ascii_alphabetic()
&& raw_path.as_bytes()[2] == b':'
{
raw_path[1..].to_string()
} else {
raw_path.to_string()
}
}
};
let normalized = normalize_path(Path::new(&expanded));
let norm_str = normalized.to_string_lossy().to_string();
let is_admin = user_role.eq_ignore_ascii_case("admin");
if (state.config.storage.allow_entire_system || state.config.server.standalone || allowed_roots.contains(&"*".to_string())) && is_admin {
return Ok(norm_str);
}
let norm_home = normalize_path(Path::new(user_home));
if path_starts_with_case_insensitive(&normalized, &norm_home) {
return Ok(norm_str);
}
for root in &state.config.storage.roots {
let role_ok = root.allowed_roles.is_empty() || root.allowed_roles.iter().any(|r| r.eq_ignore_ascii_case(user_role));
let user_ok = is_admin || allowed_roots.contains(&"*".to_string()) || allowed_roots.contains(&root.id) || allowed_roots.contains(&root.path);
if role_ok && user_ok {
let norm_root = normalize_path(Path::new(&root.path));
if path_starts_with_case_insensitive(&normalized, &norm_root) {
if is_write && root.read_only {
return Err((StatusCode::FORBIDDEN, format!("Storage root '{}' is configured as read-only", root.name)));
}
return Ok(norm_str);
}
}
}
Err((
StatusCode::FORBIDDEN,
format!("Access denied: Path '{}' is outside your authorized storage roots", raw_path),
))
}
async fn handle_get_storage_roots(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::config::StorageRoot>>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let user_role = claims.role.as_str();
let user_home = claims.home_dir.clone();
let allowed_roots_json = claims.allowed_roots.as_deref().unwrap_or("[\"*\"]");
let allowed_roots: Vec<String> = serde_json::from_str(allowed_roots_json).unwrap_or_else(|_| vec!["*".to_string()]);
let mut accessible = Vec::new();
let is_admin = user_role.eq_ignore_ascii_case("admin");
let is_readonly = user_role.eq_ignore_ascii_case("readonly");
accessible.push(crate::config::StorageRoot {
id: "home".to_string(),
name: "Home".to_string(),
path: user_home.clone(),
read_only: is_readonly,
allowed_roles: vec![],
});
for root in &state.config.storage.roots {
let role_ok = root.allowed_roles.is_empty() || root.allowed_roles.iter().any(|r| r.eq_ignore_ascii_case(user_role));
let user_ok = is_admin || allowed_roots.contains(&"*".to_string()) || allowed_roots.contains(&root.id) || allowed_roots.contains(&root.path);
if role_ok && user_ok {
let mut r = root.clone();
if is_readonly {
r.read_only = true;
}
if r.path != user_home && !accessible.iter().any(|existing| existing.path == r.path) {
accessible.push(r);
}
}
}
if (state.config.storage.allow_entire_system || state.config.server.standalone || allowed_roots.contains(&"*".to_string())) && is_admin {
#[cfg(windows)]
{
for b in b'A'..=b'Z' {
let drive = format!("{}:\\", b as char);
if std::path::Path::new(&drive).exists() {
let drive_lower = (b as char).to_ascii_lowercase();
let drive_id = format!("drive-{}", drive_lower);
if !accessible.iter().any(|existing| existing.path.eq_ignore_ascii_case(&drive)) {
accessible.push(crate::config::StorageRoot {
id: drive_id,
name: format!("Local Disk ({}:)", b as char),
path: drive,
read_only: false,
allowed_roles: vec!["admin".to_string()],
});
}
}
}
}
#[cfg(not(windows))]
{
if !accessible.iter().any(|existing| existing.path == "/") {
accessible.push(crate::config::StorageRoot {
id: "system-root".to_string(),
name: "Root Filesystem (/)".to_string(),
path: "/".to_string(),
read_only: false,
allowed_roles: vec!["admin".to_string()],
});
}
}
}
Ok(Json(accessible))
}
async fn handle_logout() -> Json<serde_json::Value> {
Json(serde_json::json!({ "success": true, "message": "Logged out" }))
}
async fn handle_system_exit(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if !state.config.server.standalone {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role != "admin" && claims.role != "Admin" {
return Err((
StatusCode::FORBIDDEN,
"Only administrators or standalone desktop sessions can terminate the process".to_string(),
));
}
}
tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
std::process::exit(0);
});
Ok(Json(serde_json::json!({
"success": true,
"message": "Brum is exiting cleanly"
})))
}
async fn handle_get_me(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<User>, (StatusCode, String)> {
if !state.config.server.enable_auth || state.config.server.standalone {
let current_user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string());
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
if let Ok(Some(mut user)) = state.auth.get_user_by_username(¤t_user) {
user.resolve_avatar();
return Ok(Json(user));
}
let avatar_url = crate::auth::resolve_system_avatar(¤t_user, &home_dir);
return Ok(Json(User {
id: 1,
username: current_user.clone(),
nickname: Some(current_user),
email: None,
avatar_url,
role: "admin".to_string(),
home_dir,
is_pam: false,
is_disabled: false,
allowed_services: "[\"*\"]".to_string(),
allowed_roots: "[\"*\"]".to_string(),
can_install_plugins: true,
allowed_plugins: "[\"*\"]".to_string(),
blocked_plugins: "[]".to_string(),
}));
}
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
match state.auth.verify_token(token_str) {
Ok(claims) => {
if let Ok(Some(mut user)) = state.auth.get_user_by_username(&claims.sub) {
user.resolve_avatar();
return Ok(Json(user));
}
if let Ok(mut synced_user) = state.auth.sync_pam_user_to_db(&claims.sub, &claims.role, &claims.home_dir) {
synced_user.resolve_avatar();
return Ok(Json(synced_user));
}
let is_admin = claims.role == "admin";
let avatar_url = crate::auth::resolve_system_avatar(&claims.sub, &claims.home_dir);
Ok(Json(User {
id: 0,
username: claims.sub,
nickname: None,
email: None,
avatar_url,
role: claims.role,
home_dir: claims.home_dir,
is_pam: claims.is_pam,
is_disabled: false,
allowed_services: "[\"*\"]".to_string(),
allowed_roots: claims.allowed_roots.unwrap_or_else(|| "[\"*\"]".to_string()),
can_install_plugins: is_admin,
allowed_plugins: "[\"*\"]".to_string(),
blocked_plugins: "[]".to_string(),
}))
}
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid token".to_string())),
}
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))
}
}
#[derive(Deserialize)]
struct UpdateProfileRequest {
nickname: Option<String>,
email: Option<String>,
avatar_url: Option<String>,
new_password: Option<String>,
}
async fn handle_update_profile(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateProfileRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let username = if !state.config.server.enable_auth || state.config.server.standalone {
let current_user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string());
if state.auth.get_user_by_username(¤t_user).ok().flatten().is_none() {
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
let _ = state.auth.create_user(¤t_user, "local_no_password", "admin", &home_dir, Some("[\"*\"]"));
}
current_user
} else {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if state.auth.get_user_by_username(&claims.sub).ok().flatten().is_none() {
let _ = state.auth.sync_pam_user_to_db(&claims.sub, &claims.role, &claims.home_dir);
}
claims.sub
} else {
return Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()));
}
};
let _ = state.auth.update_user_profile(
&username,
payload.nickname.as_deref(),
payload.email.as_deref(),
payload.avatar_url.as_deref(),
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update profile: {}", e)))?;
if let Some(ref new_pass) = payload.new_password {
if !new_pass.trim().is_empty() {
let _ = state.auth.update_user_password(&username, new_pass)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update password: {}", e)))?;
}
}
Ok(Json(serde_json::json!({ "success": true, "message": "Profile updated successfully" })))
}
#[derive(Deserialize)]
struct UpdateUserRbacRequest {
role: String,
allowed_services: Vec<String>,
allowed_roots: Option<Vec<String>>,
home_dir: Option<String>,
#[serde(default)]
can_install_plugins: Option<bool>,
#[serde(default)]
allowed_plugins: Option<Vec<String>>,
#[serde(default)]
blocked_plugins: Option<Vec<String>>,
#[serde(default)]
is_disabled: Option<bool>,
}
async fn handle_update_user_rbac(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(username): AxumPath<String>,
Json(payload): Json<UpdateUserRbacRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can modify user roles and permissions".to_string()));
}
let services_json = serde_json::to_string(&payload.allowed_services).unwrap_or_else(|_| "[\"*\"]".to_string());
let roots_json = payload.allowed_roots.map(|r| serde_json::to_string(&r).unwrap_or_else(|_| "[\"*\"]".to_string()));
let allowed_plugins_json = payload.allowed_plugins.map(|p| serde_json::to_string(&p).unwrap_or_else(|_| "[\"*\"]".to_string()));
let blocked_plugins_json = payload.blocked_plugins.map(|p| serde_json::to_string(&p).unwrap_or_else(|_| "[]".to_string()));
let updated = state.auth.update_user_rbac(
&username,
&payload.role,
&services_json,
roots_json.as_deref(),
payload.home_dir.as_deref(),
payload.can_install_plugins,
allowed_plugins_json.as_deref(),
blocked_plugins_json.as_deref(),
payload.is_disabled.unwrap_or(false),
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update user RBAC: {}", e)))?;
return Ok(Json(serde_json::json!({ "success": updated })));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_list_users(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<User>>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
if let Ok(claims) = state.auth.verify_token(token_str) {
if claims.is_pam {
let _ = state.auth.sync_pam_user_to_db(&claims.sub, &claims.role, &claims.home_dir);
}
}
}
state.auth.list_users().map(Json).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list users: {}", e))
})
}
#[derive(Deserialize)]
struct CreateUserRequest {
username: String,
password: String,
role: String,
home_dir: Option<String>,
allowed_roots: Option<Vec<String>>,
}
async fn handle_create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, (StatusCode, String)> {
let final_home = payload.home_dir.unwrap_or_else(|| {
state.config.storage.default_user_home_template.replace("{username}", &payload.username)
});
let _ = std::fs::create_dir_all(&final_home);
let roots_json = payload.allowed_roots.map(|r| serde_json::to_string(&r).unwrap_or_else(|_| "[\"*\"]".to_string()));
state
.auth
.create_user(&payload.username, &payload.password, &payload.role, &final_home, roots_json.as_deref())
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to create user: {}", e)))
}
async fn handle_delete_user(
State(state): State<AppState>,
AxumPath(username): AxumPath<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
state.auth.delete_user(&username).map(|deleted| {
Json(serde_json::json!({ "success": deleted }))
}).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete user: {}", e)))
}
#[derive(Deserialize)]
struct CreateMountRequest {
name: String,
protocol: String,
target_uri: String,
options_json: Option<String>,
allowed_users: Option<Vec<String>>,
}
async fn handle_list_accessible_mounts(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<GlobalMount>>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let is_admin = claims.role == "admin";
let mounts = state.auth.list_accessible_mounts(&claims.sub, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list accessible mounts: {}", e)))?;
Ok(Json(mounts))
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
}
async fn handle_list_all_mounts(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<GlobalMount>>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can view all mounts".to_string()));
}
let mounts = state.auth.list_all_mounts()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list all mounts: {}", e)))?;
Ok(Json(mounts))
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
}
async fn handle_create_or_update_mount(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateMountRequest>,
) -> Result<Json<GlobalMount>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can create or configure global mounts".to_string()));
}
let allowed_users_str = if let Some(users) = payload.allowed_users {
serde_json::to_string(&users).unwrap_or_else(|_| "[\"*\"]".to_string())
} else {
"[\"*\"]".to_string()
};
let options_str = payload.options_json.unwrap_or_else(|| "{}".to_string());
let mount = state.auth.create_or_update_mount(
&payload.name,
&payload.protocol,
&payload.target_uri,
&options_str,
&allowed_users_str,
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save global mount: {}", e)))?;
Ok(Json(mount))
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
}
async fn handle_delete_mount(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can delete global mounts".to_string()));
}
state.auth.delete_mount(id)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete mount: {}", e)))?;
Ok(Json(serde_json::json!({ "success": true, "message": "Mount removed" })))
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
}
#[derive(Deserialize)]
struct CreateBookmarkRequest {
name: String,
protocol: String,
path: String,
password: Option<String>,
}
#[derive(Deserialize)]
struct UnlockRequest {
password: String,
username: Option<String>,
}
async fn handle_unlock_session(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UnlockRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
let mut username = payload.username.clone();
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
if let Ok(claims) = state.auth.verify_token_allow_expired(token_str) {
username = Some(claims.sub);
}
}
if let Some(uname) = username {
match state.auth.authenticate(&uname, &payload.password) {
Ok(user) => {
let new_token = state.auth.generate_token(&user).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"success": true,
"message": "Session unlocked",
"token": new_token,
"user": user
})))
}
Err(e) => Err((StatusCode::UNAUTHORIZED, e.to_string())),
}
} else {
Err((StatusCode::UNAUTHORIZED, "Missing username or authorization token".to_string()))
}
}
#[derive(Deserialize)]
struct CreateVaultRequest {
path: String,
password: String,
}
async fn handle_create_vault(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateVaultRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &payload.path, true)?;
let p = Path::new(&valid_path);
crate::vfs::vault::VaultManager::create_vault(p, &payload.password)
.map(|_| Json(serde_json::json!({ "success": true, "path": valid_path })))
.map_err(|e| (StatusCode::BAD_REQUEST, e))
}
#[derive(Deserialize)]
struct UnlockVaultRequest {
path: String,
password: String,
auto_lock_secs: Option<u64>,
}
async fn handle_unlock_vault(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UnlockVaultRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &payload.path, false)?;
let p = Path::new(&valid_path);
let auto_lock = payload.auto_lock_secs.unwrap_or(900);
state.vaults.unlock_vault(p, &payload.password, auto_lock)
.map(|norm_path| Json(serde_json::json!({ "success": true, "path": norm_path })))
.map_err(|e| (StatusCode::UNAUTHORIZED, e))
}
#[derive(Deserialize)]
struct LockVaultRequest {
path: String,
}
async fn handle_lock_vault(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<LockVaultRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &payload.path, false)?;
state.vaults.lock_vault(&valid_path);
Ok(Json(serde_json::json!({ "success": true, "path": valid_path })))
}
#[derive(Deserialize)]
struct VaultStatusQuery {
path: String,
}
async fn handle_vault_status(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<VaultStatusQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &query.path, false)?;
let is_unlocked = state.vaults.is_unlocked(&valid_path);
Ok(Json(serde_json::json!({ "unlocked": is_unlocked, "path": valid_path })))
}
async fn handle_list_bookmarks(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::auth::UserBookmark>>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
let username = if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
match state.auth.verify_token(token_str) {
Ok(c) => c.sub,
Err(_) => "bolt".to_string(),
}
} else {
"bolt".to_string()
};
let bookmarks = state.auth.list_bookmarks(&username)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list bookmarks: {}", e)))?;
Ok(Json(bookmarks))
}
async fn handle_create_bookmark(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateBookmarkRequest>,
) -> Result<Json<crate::auth::UserBookmark>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
let username = if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
match state.auth.verify_token(token_str) {
Ok(c) => c.sub,
Err(_) => "bolt".to_string(),
}
} else {
"bolt".to_string()
};
let bm = state.auth.create_bookmark(
&username,
&payload.name,
&payload.protocol,
&payload.path,
payload.password.as_deref(),
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save bookmark: {}", e)))?;
Ok(Json(bm))
}
async fn handle_delete_bookmark(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
let (username, is_admin) = if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
match state.auth.verify_token(token_str) {
Ok(c) => (c.sub.clone(), c.role == "admin"),
Err(_) => ("bolt".to_string(), false),
}
} else {
("bolt".to_string(), false)
};
state.auth.delete_bookmark(id, &username, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete bookmark: {}", e)))?;
Ok(Json(serde_json::json!({ "success": true, "message": "Bookmark removed" })))
}
async fn handle_get_user_preferences(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let prefs = state.auth.get_user_preferences(&claims.sub)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to load user preferences: {}", e)))?;
if let Some(p_str) = prefs {
let val: serde_json::Value = serde_json::from_str(&p_str)
.unwrap_or_else(|_| serde_json::json!({}));
Ok(Json(val))
} else {
Ok(Json(serde_json::json!({})))
}
}
async fn handle_save_user_preferences(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let p_str = serde_json::to_string(&payload)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid preferences JSON: {}", e)))?;
state.auth.save_user_preferences(&claims.sub, &p_str)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save user preferences: {}", e)))?;
Ok(Json(serde_json::json!({ "success": true })))
}
async fn handle_reset_user_preferences(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
state.auth.reset_user_preferences(&claims.sub)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to reset user preferences: {}", e)))?;
Ok(Json(serde_json::json!({ "success": true, "message": "User preferences reset to default" })))
}
async fn handle_get_security_settings(
State(state): State<AppState>,
) -> Result<Json<crate::auth::SecuritySettings>, (StatusCode, String)> {
let settings = state.auth.get_security_settings()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get security settings: {}", e)))?;
Ok(Json(settings))
}
async fn handle_update_security_settings(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::auth::SecuritySettings>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can update security settings".to_string()));
}
state.auth.update_security_settings(&payload)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update security settings: {}", e)))?;
Ok(Json(serde_json::json!({ "success": true, "message": "Security settings saved" })))
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
}
#[derive(Deserialize)]
pub struct CreateApiTokenRequest {
pub name: String,
pub role: Option<String>,
pub expires_in_days: Option<i64>,
pub allowed_roots: Option<Vec<String>>,
}
async fn handle_list_api_tokens(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::auth::ApiTokenInfo>>, (StatusCode, String)> {
if !state.config.server.standalone && state.config.server.enable_auth {
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header
.strip_prefix("Bearer ")
.ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization scheme".to_string()))?;
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let username_filter = if claims.role == "admin" {
None
} else {
Some(claims.sub.as_str())
};
let tokens = state.auth.list_api_tokens(username_filter).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(tokens))
} else {
let tokens = state.auth.list_api_tokens(None).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(tokens))
}
}
async fn handle_create_api_token(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateApiTokenRequest>,
) -> Result<Json<crate::auth::GeneratedApiToken>, (StatusCode, String)> {
let name = payload.name.trim();
if name.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Token name is required".to_string()));
}
let (username, default_role) = if !state.config.server.standalone && state.config.server.enable_auth {
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header
.strip_prefix("Bearer ")
.ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization scheme".to_string()))?;
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
(claims.sub, claims.role)
} else {
("admin".to_string(), "admin".to_string())
};
let role = payload.role.unwrap_or(default_role);
let allowed_roots = payload.allowed_roots.map(|r| serde_json::to_string(&r).unwrap_or_else(|_| "[\"*\"]".to_string()));
let res = state.auth.create_api_token(
&username,
name,
&role,
allowed_roots,
payload.expires_in_days,
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(res))
}
async fn handle_revoke_api_token(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(token_id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let is_admin_or_owner = if !state.config.server.standalone && state.config.server.enable_auth {
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header
.strip_prefix("Bearer ")
.ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization scheme".to_string()))?;
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if claims.role == "admin" {
None
} else {
Some(claims.sub)
}
} else {
None
};
let revoked = state.auth.revoke_api_token(&token_id, is_admin_or_owner.as_deref())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if revoked {
Ok(Json(serde_json::json!({ "success": true, "message": "API token revoked" })))
} else {
Err((StatusCode::NOT_FOUND, "Token not found or unauthorized".to_string()))
}
}
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(first_ip) = forwarded.split(',').next() {
let trimmed = first_ip.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
let trimmed = real_ip.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
"127.0.0.1".to_string()
}
fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).map(|s| s.to_string())
}
fn safe_join_share_path(base_dir: &Path, rel_path: &str) -> Result<PathBuf, (StatusCode, String)> {
let clean_rel = Path::new(rel_path);
for comp in clean_rel.components() {
match comp {
std::path::Component::Normal(_) => {},
std::path::Component::CurDir => {},
_ => return Err((StatusCode::BAD_REQUEST, "Invalid file path in share request".to_string())),
}
}
let target = base_dir.join(clean_rel);
let base_canonical = base_dir.canonicalize()
.map_err(|_| (StatusCode::NOT_FOUND, "Shared directory not found on host".to_string()))?;
let target_canonical = target.canonicalize()
.map_err(|_| (StatusCode::NOT_FOUND, "Requested file not found in share".to_string()))?;
if !target_canonical.starts_with(&base_canonical) {
return Err((StatusCode::FORBIDDEN, "Access to path outside shared directory is forbidden".to_string()));
}
Ok(target_canonical)
}
#[derive(Serialize)]
struct ShareDirEntry {
name: String,
rel_path: String,
is_dir: bool,
size: u64,
mime: String,
mtime: u64,
}
fn list_share_dir_entries(base_path: &Path) -> Vec<ShareDirEntry> {
let mut results = Vec::new();
if let Ok(entries) = std::fs::read_dir(base_path) {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let is_dir = path.is_dir();
let size = if is_dir { 0 } else { entry.metadata().map(|m| m.len()).unwrap_or(0) };
let mtime = entry.metadata().ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let mime = if is_dir {
"inode/directory".to_string()
} else {
mime_guess::from_path(&path).first_or_octet_stream().to_string()
};
results.push(ShareDirEntry {
name: name.clone(),
rel_path: name,
is_dir,
size,
mime,
mtime,
});
}
}
results.sort_by(|a, b| {
match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
});
results
}
#[derive(Deserialize)]
struct CreateShareRequest {
path: String,
name: Option<String>,
is_dir: bool,
allow_upload: Option<bool>,
allow_view: Option<bool>,
allow_download: Option<bool>,
password: Option<String>,
expires_in_hours: Option<u64>,
max_downloads: Option<u64>,
allowed_emails: Option<String>,
require_email: Option<bool>,
watermark_enabled: Option<bool>,
watermark_text: Option<String>,
}
#[derive(Deserialize)]
struct UpdateShareRequest {
name: Option<String>,
allow_upload: Option<bool>,
allow_view: Option<bool>,
allow_download: Option<bool>,
password: Option<Option<String>>,
expires_in_hours: Option<Option<u64>>,
max_downloads: Option<u64>,
allowed_emails: Option<String>,
require_email: Option<bool>,
watermark_enabled: Option<bool>,
watermark_text: Option<String>,
status: Option<String>,
}
#[derive(Deserialize)]
struct RevokeShareRequest {
revoke: Option<bool>,
}
#[derive(Deserialize)]
struct VerifyPassRequest {
password: String,
}
#[derive(Deserialize)]
struct VerifyEmailRequest {
email: String,
}
#[derive(Deserialize)]
struct SharePublicQuery {
file: Option<String>,
password: Option<String>,
email: Option<String>,
}
async fn handle_create_share(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateShareRequest>,
) -> Result<Json<crate::auth::ShareItem>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
let claims = state.auth.verify_token(token_str)
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?;
let name = payload.name.unwrap_or_else(|| {
Path::new(&payload.path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
let expires_at = payload.expires_in_hours.map(|hrs| {
(chrono::Utc::now() + chrono::Duration::hours(hrs as i64)).to_rfc3339()
});
let share = state.auth.create_share(
&claims.sub,
&payload.path,
&name,
payload.is_dir,
payload.allow_upload.unwrap_or(false),
payload.allow_view.unwrap_or(true),
payload.allow_download.unwrap_or(true),
payload.password.as_deref(),
expires_at.as_deref(),
payload.max_downloads.unwrap_or(0),
payload.allowed_emails.as_deref(),
payload.require_email.unwrap_or(false),
payload.watermark_enabled.unwrap_or(false),
payload.watermark_text.as_deref(),
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create share: {}", e)))?;
Ok(Json(share))
}
async fn handle_update_share(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
Json(payload): Json<UpdateShareRequest>,
) -> Result<Json<crate::auth::ShareItem>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
let claims = state.auth.verify_token(token_str)
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?;
let is_admin = claims.role == "admin";
let name = payload.name.unwrap_or_else(|| "Share".to_string());
let expires_at_str = payload.expires_in_hours.map(|opt| {
opt.map(|hrs| (chrono::Utc::now() + chrono::Duration::hours(hrs as i64)).to_rfc3339())
});
let new_password_ref = payload.password.as_ref().map(|opt| opt.as_deref());
let expires_at_ref = expires_at_str.as_ref().map(|opt| opt.as_deref());
let share = state.auth.update_share(
id,
&claims.sub,
is_admin,
&name,
payload.allow_upload.unwrap_or(false),
payload.allow_view.unwrap_or(true),
payload.allow_download.unwrap_or(true),
new_password_ref,
expires_at_ref,
payload.max_downloads.unwrap_or(0),
payload.allowed_emails.as_deref(),
payload.require_email.unwrap_or(false),
payload.watermark_enabled.unwrap_or(false),
payload.watermark_text.as_deref(),
payload.status.as_deref(),
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update share: {}", e)))?;
Ok(Json(share))
}
async fn handle_revoke_share(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
Json(payload): Json<RevokeShareRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
let claims = state.auth.verify_token(token_str)
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?;
let is_admin = claims.role == "admin";
let revoke = payload.revoke.unwrap_or(true);
state.auth.revoke_share(id, &claims.sub, is_admin, revoke)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to toggle revoke share: {}", e)))?;
Ok(Json(serde_json::json!({ "success": true, "status": if revoke { "revoked" } else { "active" } })))
}
async fn handle_get_share_logs(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<crate::auth::ShareAccessLog>>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
let claims = state.auth.verify_token(token_str)
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?;
let is_admin = claims.role == "admin";
let logs = state.auth.get_share_logs(id, &claims.sub, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch access logs: {}", e)))?;
Ok(Json(logs))
}
async fn handle_list_shares(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::auth::ShareItem>>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
let claims = state.auth.verify_token(token_str)
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?;
let is_admin = claims.role == "admin";
let list = state.auth.list_shares(&claims.sub, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list shares: {}", e)))?;
Ok(Json(list))
}
async fn handle_delete_share(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header".to_string()))?;
let token_str = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
let claims = state.auth.verify_token(token_str)
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?;
let is_admin = claims.role == "admin";
state.auth.delete_share(id, &claims.sub, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete share: {}", e)))?;
Ok(Json(serde_json::json!({ "success": true })))
}
async fn handle_public_get_share_meta(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(token): AxumPath<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let share = state.auth.get_share_by_token(&token)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?
.ok_or((StatusCode::NOT_FOUND, "Share not found".to_string()))?;
if share.status == "revoked" {
return Err((StatusCode::GONE, "This share link has been revoked by the owner".to_string()));
}
if let Some(ref exp) = share.expires_at {
if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) {
if chrono::Utc::now() > exp_time {
return Err((StatusCode::GONE, "This share link has expired".to_string()));
}
}
}
if share.max_downloads > 0 && share.download_count >= share.max_downloads {
return Err((StatusCode::GONE, "This share link has reached its maximum download limit".to_string()));
}
if !share.has_password && !share.require_email {
let ip = extract_client_ip(&headers);
let ua = extract_user_agent(&headers);
let _ = state.auth.log_share_access(share.id, &token, None, &ip, ua.as_deref(), "visit", None);
}
let p = Path::new(&share.path);
let size = if p.is_file() {
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
} else {
0
};
let files = if share.is_dir && share.allow_view && p.exists() {
list_share_dir_entries(p)
} else {
Vec::new()
};
Ok(Json(serde_json::json!({
"id": share.id,
"token": share.token,
"name": share.name,
"is_dir": share.is_dir,
"allow_upload": share.allow_upload,
"allow_view": share.allow_view,
"allow_download": share.allow_download,
"has_password": share.has_password,
"require_email": share.require_email,
"watermark_enabled": share.watermark_enabled,
"watermark_text": share.watermark_text,
"status": share.status,
"size": size,
"created_at": share.created_at,
"expires_at": share.expires_at,
"files": files,
})))
}
async fn handle_public_verify_share(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(token): AxumPath<String>,
Json(payload): Json<VerifyPassRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid = state.auth.verify_share_password(&token, &payload.password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Verification error: {}", e)))?;
if valid {
if let Ok(Some(share)) = state.auth.get_share_by_token(&token) {
let ip = extract_client_ip(&headers);
let ua = extract_user_agent(&headers);
let _ = state.auth.log_share_access(share.id, &token, None, &ip, ua.as_deref(), "visit", None);
}
Ok(Json(serde_json::json!({ "valid": true })))
} else {
Err((StatusCode::UNAUTHORIZED, "Incorrect password for this share".to_string()))
}
}
async fn handle_public_verify_email(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(token): AxumPath<String>,
Json(payload): Json<VerifyEmailRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid = state.auth.verify_share_email(&token, &payload.email)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Email verification error: {}", e)))?;
if valid {
if let Ok(Some(share)) = state.auth.get_share_by_token(&token) {
let ip = extract_client_ip(&headers);
let ua = extract_user_agent(&headers);
let _ = state.auth.log_share_access(share.id, &token, Some(&payload.email), &ip, ua.as_deref(), "visit", None);
}
Ok(Json(serde_json::json!({ "valid": true })))
} else {
Err((StatusCode::FORBIDDEN, "Email address is not authorized for this showcase share".to_string()))
}
}
async fn handle_public_preview_share(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(token): AxumPath<String>,
Query(query): Query<SharePublicQuery>,
) -> Result<Response, (StatusCode, String)> {
let share = state.auth.get_share_by_token(&token)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?
.ok_or((StatusCode::NOT_FOUND, "Share not found or expired".to_string()))?;
if share.status == "revoked" {
return Err((StatusCode::GONE, "This share link has been revoked".to_string()));
}
if let Some(ref exp) = share.expires_at {
if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) {
if chrono::Utc::now() > exp_time {
return Err((StatusCode::GONE, "This share link has expired".to_string()));
}
}
}
if !share.allow_view {
return Err((StatusCode::FORBIDDEN, "Online file preview is disabled for this share".to_string()));
}
if share.has_password {
let pass = query.password.as_deref().unwrap_or("");
let valid = state.auth.verify_share_password(&token, pass).unwrap_or(false);
if !valid {
return Err((StatusCode::UNAUTHORIZED, "Password required for preview".to_string()));
}
}
if share.require_email {
let email = query.email.as_deref().unwrap_or("");
let valid = state.auth.verify_share_email(&token, email).unwrap_or(false);
if !valid {
return Err((StatusCode::FORBIDDEN, "Authorized email required for preview".to_string()));
}
}
let target_path = if share.is_dir {
if let Some(ref file_param) = query.file {
safe_join_share_path(Path::new(&share.path), file_param)?
} else {
return Err((StatusCode::BAD_REQUEST, "File parameter is required when previewing a folder share".to_string()));
}
} else {
PathBuf::from(&share.path)
};
if !target_path.exists() || !target_path.is_file() {
return Err((StatusCode::NOT_FOUND, "Requested file not found on server".to_string()));
}
let file_name = target_path.file_name().unwrap_or_default().to_string_lossy().to_string();
let ip = extract_client_ip(&headers);
let ua = extract_user_agent(&headers);
let _ = state.auth.log_share_access(
share.id,
&token,
query.email.as_deref(),
&ip,
ua.as_deref(),
"preview",
Some(&file_name),
);
let metadata = tokio::fs::metadata(&target_path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file metadata: {}", e))
})?;
let mtime_sec = metadata.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let etag = format!("\"{:x}-{:x}\"", metadata.len(), mtime_sec);
let mime = mime_guess::from_path(&target_path).first_or_octet_stream().to_string();
let disposition = format!("inline; filename=\"{}\"", file_name);
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
build_local_file_range_response(&target_path, metadata.len(), mime, disposition, etag, range_header).await
}
async fn handle_public_download_share(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(token): AxumPath<String>,
Query(query): Query<SharePublicQuery>,
) -> Result<Response, (StatusCode, String)> {
let share = state.auth.get_share_by_token(&token)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?
.ok_or((StatusCode::NOT_FOUND, "Share not found or expired".to_string()))?;
if share.status == "revoked" {
return Err((StatusCode::GONE, "This share link has been revoked".to_string()));
}
if let Some(ref exp) = share.expires_at {
if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) {
if chrono::Utc::now() > exp_time {
return Err((StatusCode::GONE, "This share link has expired".to_string()));
}
}
}
if !share.allow_download {
return Err((StatusCode::FORBIDDEN, "Download is disabled for this showcase link (view-only mode)".to_string()));
}
if share.max_downloads > 0 && share.download_count >= share.max_downloads {
return Err((StatusCode::GONE, "This share link has reached its maximum download limit".to_string()));
}
if share.has_password {
let pass = query.password.as_deref().unwrap_or("");
let valid = state.auth.verify_share_password(&token, pass).unwrap_or(false);
if !valid {
return Err((StatusCode::UNAUTHORIZED, "Password required for download".to_string()));
}
}
if share.require_email {
let email = query.email.as_deref().unwrap_or("");
let valid = state.auth.verify_share_email(&token, email).unwrap_or(false);
if !valid {
return Err((StatusCode::FORBIDDEN, "Authorized email required for download".to_string()));
}
}
let path = Path::new(&share.path);
if !path.exists() {
return Err((StatusCode::NOT_FOUND, "Target file or folder not found on server".to_string()));
}
let _ = state.auth.increment_share_downloads(&token);
let ip = extract_client_ip(&headers);
let ua = extract_user_agent(&headers);
if share.is_dir {
if let Some(ref file_param) = query.file {
let target_file = safe_join_share_path(path, file_param)?;
if !target_file.is_file() {
return Err((StatusCode::NOT_FOUND, "Specified file not found in folder share".to_string()));
}
let file_name = target_file.file_name().unwrap_or_default().to_string_lossy().to_string();
let _ = state.auth.log_share_access(
share.id,
&token,
query.email.as_deref(),
&ip,
ua.as_deref(),
"download",
Some(&file_name),
);
let metadata = tokio::fs::metadata(&target_file).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file metadata: {}", e))
})?;
let mtime_sec = metadata.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let etag = format!("\"{:x}-{:x}\"", metadata.len(), mtime_sec);
let mime = mime_guess::from_path(&target_file).first_or_octet_stream().to_string();
let disposition = format!("attachment; filename=\"{}\"", file_name);
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
return build_local_file_range_response(&target_file, metadata.len(), mime, disposition, etag, range_header).await;
}
let _ = state.auth.log_share_access(
share.id,
&token,
query.email.as_deref(),
&ip,
ua.as_deref(),
"download",
Some(&format!("{}.zip", share.name)),
);
let temp_zip = tempfile::Builder::new()
.prefix("brum_share_")
.suffix(".zip")
.tempfile()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Temp file error: {}", e)))?;
let temp_path = temp_zip.path().to_str().unwrap().to_string();
ArchiveHandler::create_zip(&[share.path.clone()], &temp_path)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Zip creation failed: {}", e)))?;
let file_bytes = tokio::fs::read(&temp_path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read generated zip: {}", e))
})?;
let zip_name = format!("{}.zip", share.name);
let response = Response::builder()
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", zip_name))
.header(header::CONTENT_LENGTH, file_bytes.len().to_string())
.body(Body::from(file_bytes))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)))?;
return Ok(response);
}
let file_name = share.name.clone();
let _ = state.auth.log_share_access(
share.id,
&token,
query.email.as_deref(),
&ip,
ua.as_deref(),
"download",
Some(&file_name),
);
let metadata = tokio::fs::metadata(path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file metadata: {}", e))
})?;
let mtime_sec = metadata.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let etag = format!("\"{:x}-{:x}\"", metadata.len(), mtime_sec);
let mime = mime_guess::from_path(path).first_or_octet_stream().to_string();
let disposition = format!("attachment; filename=\"{}\"", share.name);
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
build_local_file_range_response(path, metadata.len(), mime, disposition, etag, range_header).await
}
async fn handle_public_upload_share(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(token): AxumPath<String>,
Query(query): Query<SharePublicQuery>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let share = state.auth.get_share_by_token(&token)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?
.ok_or((StatusCode::NOT_FOUND, "Share not found or expired".to_string()))?;
if share.status == "revoked" {
return Err((StatusCode::GONE, "This share link has been revoked".to_string()));
}
if !share.is_dir || !share.allow_upload {
return Err((StatusCode::FORBIDDEN, "Guest uploads are not enabled for this share".to_string()));
}
if let Some(ref exp) = share.expires_at {
if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) {
if chrono::Utc::now() > exp_time {
return Err((StatusCode::GONE, "This share link has expired".to_string()));
}
}
}
if share.has_password {
let pass = query.password.as_deref().unwrap_or("");
let valid = state.auth.verify_share_password(&token, pass).unwrap_or(false);
if !valid {
return Err((StatusCode::UNAUTHORIZED, "Password required for upload".to_string()));
}
}
if share.require_email {
let email = query.email.as_deref().unwrap_or("");
let valid = state.auth.verify_share_email(&token, email).unwrap_or(false);
if !valid {
return Err((StatusCode::FORBIDDEN, "Authorized email required for upload".to_string()));
}
}
let target_dir = Path::new(&share.path);
if !target_dir.exists() || !target_dir.is_dir() {
return Err((StatusCode::NOT_FOUND, "Target dropbox folder does not exist on server".to_string()));
}
let ip = extract_client_ip(&headers);
let ua = extract_user_agent(&headers);
let mut saved_count = 0;
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
let file_name = field.file_name().unwrap_or("uploaded_file").to_string();
let safe_name = Path::new(&file_name).file_name().unwrap_or_default().to_string_lossy().to_string();
if safe_name.is_empty() {
continue;
}
let target_path = target_dir.join(&safe_name);
let data = field.bytes().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
std::fs::write(&target_path, &data).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write file {}: {}", safe_name, e)))?;
saved_count += 1;
let _ = state.auth.log_share_access(
share.id,
&token,
query.email.as_deref(),
&ip,
ua.as_deref(),
"upload",
Some(&safe_name),
);
}
Ok(Json(serde_json::json!({ "success": true, "uploaded_files": saved_count })))
}
async fn handle_public_share_page(
AxumPath(token): AxumPath<String>,
) -> Response {
let html = format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CommanderDog Showcase Portal</title>
<link rel="icon" type="image/png" href="/assets/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
:root {{
--bg-dark: #121214;
--bg-panel: #18181b;
--bg-header: #202024;
--bg-card: #27272a;
--accent: #f59e0b;
--accent-hover: #fbbf24;
--accent-glow: rgba(245, 158, 11, 0.2);
--text-main: #f4f4f5;
--text-muted: #a1a1aa;
--text-dim: #71717a;
--border: #3f3f46;
--radius: 8px;
--font-mono: 'JetBrains Mono', monospace;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }}
body {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
}}
.portal-nav {{
background: var(--bg-header);
border-bottom: 1px solid var(--border);
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
position: sticky;
top: 0;
z-index: 50;
}}
.nav-left {{
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}}
.nav-brand-logo {{
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--accent-glow);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}}
.nav-titles {{
display: flex;
flex-direction: column;
min-width: 0;
}}
.nav-title {{
font-size: 14px;
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.nav-subtitle {{
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
}}
.nav-right {{
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}}
.badge {{
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}}
.badge-showcase {{
background: rgba(16, 185, 129, 0.15);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}}
.badge-watermark {{
background: rgba(245, 158, 11, 0.15);
color: var(--accent);
border: 1px solid rgba(245, 158, 11, 0.3);
}}
.badge-dropbox {{
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
}}
.btn {{
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s ease;
text-decoration: none;
user-select: none;
}}
.btn-accent {{
background: var(--accent);
color: #121214;
}}
.btn-accent:hover {{
background: var(--accent-hover);
}}
.btn-outline {{
background: transparent;
border-color: var(--border);
color: var(--text-main);
}}
.btn-outline:hover {{
background: rgba(255, 255, 255, 0.05);
border-color: var(--accent);
}}
.btn-icon {{
padding: 6px;
width: 32px;
height: 32px;
}}
.portal-main {{
flex: 1;
padding: 24px;
max-width: 1280px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}}
.toolbar-row {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}}
.search-input {{
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-main);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
min-width: 240px;
flex: 1;
max-width: 360px;
}}
.search-input:focus {{
border-color: var(--accent);
}}
.grid-view {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}}
.list-view {{
display: flex;
flex-direction: column;
gap: 8px;
}}
.file-card {{
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
position: relative;
}}
.file-card:hover {{
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
}}
.file-preview-thumb {{
height: 120px;
background: var(--bg-header);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}}
.file-preview-thumb img {{
max-width: 100%;
max-height: 100%;
object-fit: cover;
}}
.file-preview-thumb .type-icon {{
font-size: 40px;
color: var(--accent);
}}
.file-meta-row {{
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--text-muted);
}}
.file-title {{
font-weight: 600;
font-size: 13px;
word-break: break-all;
line-height: 1.3;
}}
.list-row {{
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
transition: border-color 0.15s ease;
}}
.list-row:hover {{
border-color: var(--accent);
}}
.modal-backdrop {{
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85);
backdrop-filter: blur(8px);
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
}}
.modal-box {{
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 100%;
max-width: 480px;
padding: 24px;
box-shadow: 0 16px 40px rgba(0,0,0,0.6);
}}
.viewer-modal {{
width: 100%;
height: 100%;
max-width: 1200px;
max-height: 92vh;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
position: relative;
}}
.viewer-header {{
padding: 12px 16px;
background: var(--bg-header);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}}
.viewer-stage {{
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #090a0f;
position: relative;
overflow: auto;
user-select: none;
}}
.watermark-overlay {{
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 10;
overflow: hidden;
}}
.dropzone {{
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 24px;
text-align: center;
background: rgba(0,0,0,0.2);
cursor: pointer;
transition: all 0.2s ease;
}}
.dropzone.drag-over {{
border-color: var(--accent);
background: var(--accent-glow);
}}
@media (max-width: 600px) {{
.portal-nav {{ padding: 8px 12px; }}
.portal-main {{ padding: 12px; }}
.grid-view {{ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }}
.file-preview-thumb {{ height: 90px; }}
.search-input {{ min-width: 100%; max-width: 100%; }}
}}
</style>
</head>
<body>
<!-- Navigation Header -->
<header class="portal-nav">
<div class="nav-left">
<div class="nav-brand-logo">📦</div>
<div class="nav-titles">
<div class="nav-title" id="share-name-display">Brum Showcase Portal</div>
<div class="nav-subtitle">
<span id="share-meta-summary">Loading...</span>
<span id="badge-container"></span>
</div>
</div>
</div>
<div class="nav-right" id="nav-actions">
<button class="btn btn-accent" id="btn-portal-download" style="display: none;" onclick="downloadMainShare()">
⬇️ Download All
</button>
</div>
</header>
<!-- Main Showcase Container -->
<main class="portal-main" id="portal-main">
<div id="loading-state" style="text-align: center; padding: 60px 20px; color: var(--text-muted);">
<div style="font-size: 28px; margin-bottom: 12px;">⏳</div>
<div>Loading shared items...</div>
</div>
<div id="error-state" style="display: none; text-align: center; padding: 60px 20px; color: #ef4444;">
<div style="font-size: 36px; margin-bottom: 12px;">❌</div>
<div id="error-message" style="font-size: 15px; font-weight: 600;">Share link unavailable</div>
</div>
<!-- Active Showcase Content -->
<div id="showcase-content" style="display: none; flex-direction: column; gap: 20px;">
<div class="toolbar-row">
<input type="text" id="file-search-input" class="search-input" placeholder="🔍 Filter files in showcase..." oninput="filterFiles(this.value)">
<div style="display: flex; gap: 6px; align-items: center;">
<button class="btn btn-outline btn-icon" id="btn-view-grid" onclick="setViewMode('grid')" title="Grid View">▦</button>
<button class="btn btn-outline btn-icon" id="btn-view-list" onclick="setViewMode('list')" title="List View">☰</button>
</div>
</div>
<!-- Single File Card (if sharing single file) -->
<div id="single-file-section" style="display: none;">
<div class="file-card" style="max-width: 460px; margin: 0 auto; text-align: center;" onclick="openSingleFilePreview()">
<div class="file-preview-thumb" id="single-thumb">
<span class="type-icon" id="single-type-icon">📄</span>
</div>
<div class="file-title" id="single-title">file_name</div>
<div class="file-meta-row" style="justify-content: center; gap: 12px;">
<span id="single-size">0 B</span>
<span id="single-date">--</span>
</div>
<button class="btn btn-accent" id="single-preview-btn" style="margin-top: 8px;">👁️ Preview in Viewer</button>
</div>
</div>
<!-- Directory Gallery View -->
<div id="gallery-container" class="grid-view"></div>
<!-- Guest Upload Dropzone -->
<div id="guest-dropbox-container" style="display: none; margin-top: 20px;">
<div class="dropzone" id="dropzone" onclick="document.getElementById('file-input').click()">
<input type="file" id="file-input" multiple style="display:none;" onchange="handleGuestUpload(this.files)">
<div style="font-size: 28px; margin-bottom: 8px;">📥</div>
<div style="font-weight: 700; font-size: 14px;">Guest Upload Dropbox</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">Drag & drop files here or click to browse</div>
<div id="upload-status-text" style="font-size: 12px; color: var(--accent); margin-top: 8px;"></div>
</div>
</div>
</div>
</main>
<!-- Password Gate Modal -->
<div class="modal-backdrop" id="password-gate-modal">
<div class="modal-box">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 14px;">
<span style="font-size: 24px;">🔒</span>
<div style="font-weight: 700; font-size: 16px;">Password Protected Share</div>
</div>
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 14px;">Enter the password provided by the author to unlock this showcase:</p>
<input type="password" id="gate-pass-input" class="search-input" style="width: 100%; margin-bottom: 14px;" placeholder="Enter password..." onkeydown="if(event.key==='Enter') submitPasswordGate()">
<div id="gate-pass-error" style="color: #ef4444; font-size: 12px; margin-bottom: 10px; display: none;"></div>
<button class="btn btn-accent" style="width: 100%;" onclick="submitPasswordGate()">Unlock Showcase</button>
</div>
</div>
<!-- Email Whitelist Gate Modal -->
<div class="modal-backdrop" id="email-gate-modal">
<div class="modal-box">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 14px;">
<span style="font-size: 24px;">🛡️</span>
<div style="font-weight: 700; font-size: 16px;">Authorized Guest Access</div>
</div>
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 14px;">This showcase requires an authorized guest email. Enter your email to proceed:</p>
<input type="email" id="gate-email-input" class="search-input" style="width: 100%; margin-bottom: 14px;" placeholder="name@company.com" onkeydown="if(event.key==='Enter') submitEmailGate()">
<div id="gate-email-error" style="color: #ef4444; font-size: 12px; margin-bottom: 10px; display: none;"></div>
<button class="btn btn-accent" style="width: 100%;" onclick="submitEmailGate()">Verify & Enter</button>
</div>
</div>
<!-- Internal Showcase Viewer Modal (Lightbox / Document Reader / Media Player) -->
<div class="modal-backdrop" id="viewer-modal">
<div class="modal-box viewer-modal">
<div class="viewer-header">
<div style="display: flex; align-items: center; gap: 8px; min-width: 0;">
<span id="viewer-icon" style="font-size: 18px;">📄</span>
<div id="viewer-filename" style="font-weight: 700; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">filename</div>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<button class="btn btn-accent btn-sm" id="btn-viewer-download" style="display: none;" onclick="downloadCurrentPreviewFile()">⬇️ Download</button>
<button class="btn btn-outline btn-icon" onclick="closeViewerModal()" title="Close Viewer">✕</button>
</div>
</div>
<div class="viewer-stage" id="viewer-stage" oncontextmenu="return !shareMeta?.watermark_enabled;">
<!-- Dynamic Watermark Canvas Overlay -->
<canvas id="watermark-canvas" class="watermark-overlay" style="display: none;"></canvas>
<div id="viewer-mount" style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: auto;"></div>
</div>
</div>
</div>
<script>
const token = "{token}";
let shareMeta = null;
let verifiedPassword = sessionStorage.getItem(`brum_share_pass_${{token}}`) || '';
let verifiedEmail = sessionStorage.getItem(`brum_share_email_${{token}}`) || '';
let currentViewMode = 'grid';
let currentPreviewFile = null;
function formatBytes(bytes) {{
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}}
function getFileType(name, mime) {{
const ext = name.split('.').pop().toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext) || mime.startsWith('image/')) return 'image';
if (['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(ext) || mime.startsWith('video/')) return 'video';
if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext) || mime.startsWith('audio/')) return 'audio';
if (ext === 'pdf' || mime === 'application/pdf') return 'pdf';
if (['stl', 'obj', 'gltf', 'glb', '3mf', 'step', 'stp', 'iges', 'igs', 'dxf', 'ply', 'off'].includes(ext)) return '3d';
if (['txt', 'md', 'json', 'rs', 'js', 'ts', 'html', 'css', 'toml', 'yaml', 'yml', 'py', 'c', 'cpp', 'h', 'sh', 'sql', 'log'].includes(ext) || mime.startsWith('text/')) return 'text';
return 'generic';
}}
async function loadShare() {{
try {{
const res = await fetch(`/api/public/shares/${{token}}`);
if (!res.ok) {{
const err = await res.text();
showError(err || 'Share link unavailable');
return;
}}
shareMeta = await res.json();
if (shareMeta.require_email && !verifiedEmail) {{
document.getElementById('email-gate-modal').style.display = 'flex';
return;
}}
if (shareMeta.has_password && !verifiedPassword) {{
document.getElementById('password-gate-modal').style.display = 'flex';
return;
}}
initShowcaseView();
}} catch (e) {{
showError('Network error connecting to share portal');
}}
}}
function showError(msg) {{
document.getElementById('loading-state').style.display = 'none';
document.getElementById('showcase-content').style.display = 'none';
document.getElementById('error-state').style.display = 'block';
document.getElementById('error-message').textContent = msg;
}}
async function submitPasswordGate() {{
const pass = document.getElementById('gate-pass-input').value;
if (!pass) return;
const errEl = document.getElementById('gate-pass-error');
errEl.style.display = 'none';
try {{
const res = await fetch(`/api/public/shares/${{token}}/verify`, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ password: pass }})
}});
if (res.ok) {{
verifiedPassword = pass;
sessionStorage.setItem(`brum_share_pass_${{token}}`, pass);
document.getElementById('password-gate-modal').style.display = 'none';
initShowcaseView();
}} else {{
errEl.textContent = 'Incorrect password. Please try again.';
errEl.style.display = 'block';
}}
}} catch (e) {{
errEl.textContent = 'Verification error';
errEl.style.display = 'block';
}}
}}
async function submitEmailGate() {{
const email = document.getElementById('gate-email-input').value.trim();
if (!email) return;
const errEl = document.getElementById('gate-email-error');
errEl.style.display = 'none';
try {{
const res = await fetch(`/api/public/shares/${{token}}/verify-email`, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ email: email }})
}});
if (res.ok) {{
verifiedEmail = email;
sessionStorage.setItem(`brum_share_email_${{token}}`, email);
document.getElementById('email-gate-modal').style.display = 'none';
if (shareMeta.has_password && !verifiedPassword) {{
document.getElementById('password-gate-modal').style.display = 'flex';
}} else {{
initShowcaseView();
}}
}} else {{
errEl.textContent = 'Email address not authorized for this showcase.';
errEl.style.display = 'block';
}}
}} catch (e) {{
errEl.textContent = 'Verification network error';
errEl.style.display = 'block';
}}
}}
function initShowcaseView() {{
document.getElementById('loading-state').style.display = 'none';
document.getElementById('showcase-content').style.display = 'flex';
document.getElementById('share-name-display').textContent = shareMeta.name;
const badges = [];
if (!shareMeta.allow_download) badges.push('<span class="badge badge-showcase">Showcase Mode (View Only)</span>');
if (shareMeta.watermark_enabled) badges.push('<span class="badge badge-watermark">Watermarked</span>');
if (shareMeta.allow_upload) badges.push('<span class="badge badge-dropbox">Guest Dropbox</span>');
document.getElementById('badge-container').innerHTML = badges.join(' ');
if (shareMeta.allow_download) {{
const btnDl = document.getElementById('btn-portal-download');
btnDl.style.display = 'inline-flex';
btnDl.textContent = shareMeta.is_dir ? '⬇️ Download Folder (.zip)' : '⬇️ Download File';
}}
if (shareMeta.is_dir) {{
document.getElementById('share-meta-summary').textContent = `${{shareMeta.files ? shareMeta.files.length : 0}} items`;
renderGallery(shareMeta.files || []);
if (shareMeta.allow_upload) {{
document.getElementById('guest-dropbox-container').style.display = 'block';
setupDropzone();
}}
}} else {{
document.getElementById('share-meta-summary').textContent = formatBytes(shareMeta.size);
document.getElementById('single-file-section').style.display = 'block';
document.getElementById('single-title').textContent = shareMeta.name;
document.getElementById('single-size').textContent = formatBytes(shareMeta.size);
document.getElementById('single-date').textContent = shareMeta.created_at ? new Date(shareMeta.created_at).toLocaleDateString() : '';
}}
}}
function renderGallery(files) {{
const container = document.getElementById('gallery-container');
container.className = currentViewMode === 'grid' ? 'grid-view' : 'list-view';
if (!files || files.length === 0) {{
container.innerHTML = '<div style="grid-column: 1/-1; text-align: center; color: var(--text-muted); padding: 40px;">No files available in this showcase.</div>';
return;
}}
container.innerHTML = files.map(f => {{
const type = getFileType(f.name, f.mime);
let icon = '📄';
if (type === 'image') icon = '🖼️';
else if (type === 'video') icon = '🎬';
else if (type === 'audio') icon = '🎵';
else if (type === 'pdf') icon = '📑';
else if (type === '3d') icon = '🧊';
else if (f.is_dir) icon = '📁';
const previewUrl = `/api/public/shares/${{token}}/preview?file=${{encodeURIComponent(f.rel_path)}}${{verifiedPassword ? '&password=' + encodeURIComponent(verifiedPassword) : ''}}${{verifiedEmail ? '&email=' + encodeURIComponent(verifiedEmail) : ''}}`;
if (currentViewMode === 'grid') {{
const thumbHtml = type === 'image'
? `<img src="${{previewUrl}}" alt="${{f.name}}" loading="lazy" onerror="this.outerHTML='<span class=\"type-icon\">🖼️</span>'">`
: `<span class="type-icon">${{icon}}</span>`;
return `
<div class="file-card" onclick="openFilePreview('${{encodeURIComponent(f.rel_path)}}', '${{escapeHtml(f.name)}}', '${{f.mime}}', ${{f.size}})">
<div class="file-preview-thumb">${{thumbHtml}}</div>
<div class="file-title" title="${{escapeHtml(f.name)}}">${{escapeHtml(f.name)}}</div>
<div class="file-meta-row">
<span>${{formatBytes(f.size)}}</span>
<span>${{f.mtime ? new Date(f.mtime * 1000).toLocaleDateString() : ''}}</span>
</div>
</div>
`;
}} else {{
return `
<div class="list-row" onclick="openFilePreview('${{encodeURIComponent(f.rel_path)}}', '${{escapeHtml(f.name)}}', '${{f.mime}}', ${{f.size}})">
<div style="display: flex; align-items: center; gap: 12px; min-width: 0;">
<span style="font-size: 20px;">${{icon}}</span>
<div style="font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${{escapeHtml(f.name)}}</div>
</div>
<div style="display: flex; align-items: center; gap: 16px; font-size: 12px; color: var(--text-muted);">
<span>${{formatBytes(f.size)}}</span>
<span>${{f.mtime ? new Date(f.mtime * 1000).toLocaleDateString() : ''}}</span>
</div>
</div>
`;
}}
}}).join('');
}}
function filterFiles(query) {{
if (!shareMeta || !shareMeta.files) return;
const q = query.toLowerCase().trim();
const filtered = shareMeta.files.filter(f => f.name.toLowerCase().includes(q));
renderGallery(filtered);
}}
function setViewMode(mode) {{
currentViewMode = mode;
renderGallery(shareMeta.files || []);
}}
function openSingleFilePreview() {{
openFilePreview('', shareMeta.name, 'application/octet-stream', shareMeta.size);
}}
async function openFilePreview(relPath, filename, mime, size) {{
currentPreviewFile = {{ relPath, filename, mime, size }};
const type = getFileType(filename, mime);
const mount = document.getElementById('viewer-mount');
mount.innerHTML = '<div style="color:var(--text-muted); padding:20px;">Loading preview...</div>';
document.getElementById('viewer-filename').textContent = filename;
document.getElementById('viewer-icon').textContent = type === 'image' ? '🖼️' : (type === 'video' ? '🎬' : (type === 'audio' ? '🎵' : (type === '3d' ? '🧊' : '📄')));
const btnDl = document.getElementById('btn-viewer-download');
if (shareMeta.allow_download) {{
btnDl.style.display = 'inline-flex';
}} else {{
btnDl.style.display = 'none';
}}
document.getElementById('viewer-modal').style.display = 'flex';
const fileParam = relPath ? `&file=${{relPath}}` : '';
const passParam = verifiedPassword ? `&password=${{encodeURIComponent(verifiedPassword)}}` : '';
const emailParam = verifiedEmail ? `&email=${{encodeURIComponent(verifiedEmail)}}` : '';
const previewUrl = `/api/public/shares/${{token}}/preview?${{fileParam ? fileParam.slice(1) : ''}}${{passParam}}${{emailParam}}`;
// Render Dynamic Watermark
if (shareMeta.watermark_enabled) {{
renderWatermarkOverlay();
}} else {{
document.getElementById('watermark-canvas').style.display = 'none';
}}
if (type === 'image') {{
mount.innerHTML = `<img src="${{previewUrl}}" style="max-width:100%; max-height:100%; object-fit:contain; border-radius:4px;" draggable="false" oncontextmenu="return false;">`;
}} else if (type === 'video') {{
mount.innerHTML = `<video src="${{previewUrl}}" controls autoplay playsinline style="max-width:100%; max-height:100%;"></video>`;
}} else if (type === 'audio') {{
mount.innerHTML = `
<div style="padding: 40px; text-align: center; background: var(--bg-card); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 48px; margin-bottom: 16px;">🎵</div>
<div style="font-weight: 700; margin-bottom: 12px;">${{escapeHtml(filename)}}</div>
<audio src="${{previewUrl}}" controls autoplay style="width: 100%; max-width: 380px;"></audio>
</div>
`;
}} else if (type === 'pdf') {{
mount.innerHTML = `<iframe src="${{previewUrl}}" style="width:100%; height:100%; border:none;"></iframe>`;
}} else if (type === '3d') {{
mount.innerHTML = `
<div style="width:100%; height:100%; position:relative; display:flex; flex-direction:column; background:#121214; border-radius:6px; overflow:hidden;">
<canvas id="showcase-3d-canvas" style="width:100%; height:100%; display:block;"></canvas>
<div style="position:absolute; bottom:8px; left:8px; font-size:10px; color:var(--text-muted); background:rgba(0,0,0,0.6); padding:4px 8px; border-radius:4px; pointer-events:none;">
3D CAD Preview • Drag to rotate • Wheel to zoom
</div>
</div>
`;
initShowcase3dViewer(previewUrl, filename);
}} else if (type === 'text') {{
try {{
const txtRes = await fetch(previewUrl);
const txt = await txtRes.text();
mount.innerHTML = `
<pre style="width:100%; height:100%; margin:0; padding:16px; background:#0d0e12; color:#f4f4f5; font-family:var(--font-mono); font-size:12px; line-height:1.5; overflow:auto; white-space:pre-wrap;">${{escapeHtml(txt)}}</pre>
`;
}} catch (e) {{
mount.innerHTML = '<div style="color:#ef4444;">Failed to load text preview</div>';
}}
}} else {{
mount.innerHTML = `
<div style="padding: 40px; text-align: center; color: var(--text-muted);">
<div style="font-size: 48px; margin-bottom: 12px;">📄</div>
<div style="font-weight: 600; font-size: 14px; margin-bottom: 8px;">${{escapeHtml(filename)}}</div>
<div style="font-size: 12px; margin-bottom: 16px;">Inline preview not supported for this file format.</div>
${{shareMeta.allow_download ? `<button class="btn btn-accent" onclick="downloadCurrentPreviewFile()">⬇️ Download File (${{formatBytes(size)}})</button>` : ''}}
</div>
`;
}}
}}
async function initShowcase3dViewer(url, filename) {{
const canvas = document.getElementById('showcase-3d-canvas');
if (!canvas || !window.THREE) return;
const parent = canvas.parentElement;
const width = parent.clientWidth || 600;
const height = parent.clientHeight || 450;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x121214);
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
camera.position.set(100, 100, 100);
const renderer = new THREE.WebGLRenderer({{ canvas: canvas, antialias: true, preserveDrawingBuffer: true }});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
const dl = new THREE.DirectionalLight(0xffffff, 1.5);
dl.position.set(100, 150, 100);
scene.add(dl);
let theta = Math.PI / 4, phi = Math.PI / 3, radius = 100;
let target = new THREE.Vector3(0, 0, 0);
function updateCam() {{
camera.position.set(
target.x + radius * Math.sin(phi) * Math.sin(theta),
target.y + radius * Math.cos(phi),
target.z + radius * Math.sin(phi) * Math.cos(theta)
);
camera.lookAt(target);
}}
let isDragging = false, startX = 0, startY = 0, startTheta = 0, startPhi = 0;
canvas.addEventListener('mousedown', e => {{
isDragging = true;
startX = e.clientX; startY = e.clientY;
startTheta = theta; startPhi = phi;
}});
window.addEventListener('mousemove', e => {{
if (!isDragging) return;
theta = startTheta - (e.clientX - startX) * 0.006;
phi = Math.max(0.01, Math.min(Math.PI - 0.01, startPhi - (e.clientY - startY) * 0.006));
updateCam();
}});
window.addEventListener('mouseup', () => {{ isDragging = false; }});
canvas.addEventListener('wheel', e => {{
e.preventDefault();
radius = Math.max(1, Math.min(10000, radius * (e.deltaY > 0 ? 1.1 : 0.9)));
updateCam();
}}, {{ passive: false }});
function animate() {{
if (document.getElementById('showcase-3d-canvas') === canvas) {{
renderer.render(scene, camera);
requestAnimationFrame(animate);
}}
}}
animate();
try {{
const ext = filename.split('.').pop().toLowerCase();
const res = await fetch(url);
if (!res.ok) return;
let geom = null;
if (ext === 'stl') {{
const buffer = await res.arrayBuffer();
const dv = new DataView(buffer);
let isBin = buffer.byteLength >= 84 && (84 + dv.getUint32(80, true) * 50 === buffer.byteLength);
if (isBin) {{
const count = dv.getUint32(80, true);
const pos = new Float32Array(count * 9);
let off = 84, pIdx = 0;
for (let i = 0; i < count; i++) {{
off += 12;
for (let v = 0; v < 3; v++) {{
pos[pIdx++] = dv.getFloat32(off, true);
pos[pIdx++] = dv.getFloat32(off + 4, true);
pos[pIdx++] = dv.getFloat32(off + 8, true);
off += 12;
}}
off += 2;
}}
geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geom.computeVertexNormals();
}}
}} else if (ext === 'obj') {{
const txt = await res.text();
const verts = [], pos = [];
for (const line of txt.split('\n')) {{
const p = line.trim().split(/\s+/);
if (p[0] === 'v') verts.push([parseFloat(p[1]), parseFloat(p[2]), parseFloat(p[3])]);
else if (p[0] === 'f' && p.length >= 4) {{
const i0 = parseInt(p[1]) - 1, i1 = parseInt(p[2]) - 1, i2 = parseInt(p[3]) - 1;
if (verts[i0] && verts[i1] && verts[i2]) {{
pos.push(...verts[i0], ...verts[i1], ...verts[i2]);
}}
}}
}}
if (pos.length > 0) {{
geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pos), 3));
geom.computeVertexNormals();
}}
}}
if (geom) {{
geom.computeBoundingBox();
const box = geom.boundingBox;
const center = new THREE.Vector3();
box.getCenter(center);
geom.translate(-center.x, -box.min.y, -center.z);
geom.computeBoundingBox();
geom.computeBoundingSphere();
const size = new THREE.Vector3();
geom.boundingBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z) || 50;
radius = maxDim * 2.2;
target.set(0, size.y / 2, 0);
updateCam();
const mat = new THREE.MeshStandardMaterial({{ color: 0x475569, roughness: 0.35, metalness: 0.3, side: THREE.DoubleSide }});
const mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
const grid = new THREE.GridHelper(Math.ceil(maxDim * 2 / 10) * 10, 20, 0xf59e0b, 0x27272a);
scene.add(grid);
}}
}} catch (e) {{
console.error('Showcase 3D viewer error:', e);
}}
}}
function renderWatermarkOverlay() {{
const canvas = document.getElementById('watermark-canvas');
const stage = document.getElementById('viewer-stage');
canvas.style.display = 'block';
canvas.width = stage.clientWidth || 800;
canvas.height = stage.clientHeight || 600;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
let text = shareMeta.watermark_text || 'CONFIDENTIAL • {{email}} • {{date}}';
const guestId = verifiedEmail || 'GUEST';
const today = new Date().toISOString().slice(0, 10);
text = text.replace(/\{{email\}}/gi, guestId).replace(/\{{date\}}/gi, today);
ctx.font = 'bold 15px sans-serif';
ctx.fillStyle = 'rgba(245, 158, 11, 0.18)';
ctx.textAlign = 'center';
const stepX = 240;
const stepY = 160;
ctx.rotate(-25 * Math.PI / 180);
for (let x = -canvas.width; x < canvas.width * 2; x += stepX) {{
for (let y = -canvas.height; y < canvas.height * 2; y += stepY) {{
ctx.fillText(text, x, y);
}}
}}
ctx.setTransform(1, 0, 0, 1, 0, 0);
}}
function closeViewerModal() {{
document.getElementById('viewer-modal').style.display = 'none';
document.getElementById('viewer-mount').innerHTML = '';
currentPreviewFile = null;
}}
function downloadMainShare() {{
const passParam = verifiedPassword ? `?password=${{encodeURIComponent(verifiedPassword)}}` : '';
const emailParam = verifiedEmail ? `${{passParam ? '&' : '?'}}email=${{encodeURIComponent(verifiedEmail)}}` : '';
window.location.href = `/api/public/shares/${{token}}/download${{passParam}}${{emailParam}}`;
}}
function downloadCurrentPreviewFile() {{
if (!currentPreviewFile) return;
const fileParam = currentPreviewFile.relPath ? `?file=${{encodeURIComponent(currentPreviewFile.relPath)}}` : '';
const passParam = verifiedPassword ? `${{fileParam ? '&' : '?'}}password=${{encodeURIComponent(verifiedPassword)}}` : '';
const emailParam = verifiedEmail ? `${{fileParam || passParam ? '&' : '?'}}email=${{encodeURIComponent(verifiedEmail)}}` : '';
window.location.href = `/api/public/shares/${{token}}/download${{fileParam}}${{passParam}}${{emailParam}}`;
}}
function setupDropzone() {{
const dz = document.getElementById('dropzone');
dz.ondragover = (e) => {{ e.preventDefault(); dz.classList.add('drag-over'); }};
dz.ondragleave = () => dz.classList.remove('drag-over');
dz.ondrop = (e) => {{
e.preventDefault();
dz.classList.remove('drag-over');
if (e.dataTransfer.files) handleGuestUpload(e.dataTransfer.files);
}};
}}
async function handleGuestUpload(files) {{
if (!files || files.length === 0) return;
const status = document.getElementById('upload-status-text');
status.textContent = `Uploading ${{files.length}} file(s)...`;
const fd = new FormData();
for (let f of files) fd.append('files', f);
const passParam = verifiedPassword ? `?password=${{encodeURIComponent(verifiedPassword)}}` : '';
const emailParam = verifiedEmail ? `${{passParam ? '&' : '?'}}email=${{encodeURIComponent(verifiedEmail)}}` : '';
try {{
const res = await fetch(`/api/public/shares/${{token}}/upload${{passParam}}${{emailParam}}`, {{
method: 'POST',
body: fd
}});
if (res.ok) {{
status.textContent = `✅ Successfully uploaded ${{files.length}} file(s)!`;
setTimeout(() => {{ loadShare(); }}, 1000);
}} else {{
status.textContent = `❌ Upload failed: ${{await res.text()}}`;
}}
}} catch (e) {{
status.textContent = '❌ Upload failed due to network error';
}}
}}
function escapeHtml(str) {{
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}}
window.addEventListener('resize', () => {{
if (shareMeta && shareMeta.watermark_enabled && document.getElementById('viewer-modal').style.display === 'flex') {{
renderWatermarkOverlay();
}}
}});
window.addEventListener('keydown', (e) => {{
if (e.key === 'Escape') {{
closeViewerModal();
}}
}});
loadShare();
</script>
</body>
</html>"#, token = token);
Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "HTML load error").into_response())
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct ListQuery {
path: Option<String>,
show_hidden: Option<bool>,
flat: Option<bool>,
max_depth: Option<usize>,
max_entries: Option<usize>,
host: Option<String>,
port: Option<u16>,
user: Option<String>,
pass: Option<String>,
windows_native_ops: Option<bool>,
windows_native_file_ops: Option<bool>,
custom_trash_dir: Option<String>,
}
#[derive(Deserialize)]
struct GetTagsQuery {
path: Option<String>,
}
async fn handle_get_file_tags(
State(state): State<AppState>,
Query(query): Query<GetTagsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if let Some(p) = query.path {
let tag_info = state.tags.get_tags_for_path(&p);
Ok(Json(serde_json::json!({ "success": true, "tag": tag_info })))
} else {
let all = state.tags.get_all_tags().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(serde_json::json!({ "success": true, "tags": all })))
}
}
async fn handle_get_all_tags(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let all = state.tags.get_all_tags().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(serde_json::json!({ "success": true, "tags": all })))
}
async fn handle_set_tags(
State(state): State<AppState>,
Json(payload): Json<crate::tools::tags::SetTagsRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let count = state.tags.set_tags(payload).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(serde_json::json!({ "success": true, "count": count })))
}
pub fn expand_tilde(path_str: &str) -> String {
if path_str == "~" {
dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|| "~".to_string())
} else if let Some(stripped) = path_str.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(stripped).to_string_lossy().to_string()
} else {
path_str.to_string()
}
} else {
path_str.to_string()
}
}
async fn handle_list_dir(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Json<DirectoryListing>, (StatusCode, String)> {
let raw_path = query.path.unwrap_or_else(|| state.config.server.root_path.clone());
let target_path = validate_path_access(&state, &headers, &raw_path, false)?;
let show_hidden = query.show_hidden.unwrap_or(state.config.ui.show_hidden_files);
if target_path == "trash://" || target_path == "recycle://" || target_path == "shell:recyclebinfolder" {
let claims = extract_claims_or_local(&state, &headers)?;
let custom_trash = query.custom_trash_dir.as_deref().or(state.config.paranoid.custom_trash_dir.as_deref());
let use_native = query.windows_native_ops
.or(query.windows_native_file_ops)
.unwrap_or(state.config.paranoid.windows_native_file_ops);
let listing = crate::tools::trash::TrashManager::list_trash_directory_entries(
custom_trash,
Some(&claims.home_dir),
use_native,
);
return Ok(Json(listing));
} else if target_path.starts_with("vault://") {
let rest = target_path.strip_prefix("vault://").unwrap();
let (vault_file, subpath) = match rest.split_once('#') {
Some((v, s)) => (v, s),
None => (rest, ""),
};
state.vaults.list_vault_contents(vault_file, subpath)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, e))
} else if target_path.starts_with("archive://") {
let rest = target_path.strip_prefix("archive://").unwrap();
let parts: Vec<&str> = rest.split('#').collect();
let archive_file = parts[0];
let subpath = if parts.len() > 1 { parts[1] } else { "" };
ArchiveHandler::list_archive_contents(archive_file, subpath)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to list archive: {}", e)))
} else if target_path.starts_with("sftp://") {
let params = SftpClient::parse_uri(&target_path, query.user.as_deref(), query.pass.as_deref())
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SFTP URI: {}", e)))?;
SftpClient::list_dir(¶ms)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("SFTP list failed: {}", e)))
} else if target_path.starts_with("webdav://") || target_path.starts_with("http://") || target_path.starts_with("https://") {
let url = if target_path.starts_with("webdav://") {
format!("http://{}", target_path.strip_prefix("webdav://").unwrap())
} else {
target_path
};
WebDavClient::list_dir(&url, query.user.as_deref(), query.pass.as_deref()).await
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to list WebDAV: {}", e)))
} else if target_path.starts_with("proton://") {
let entries = crate::vfs::proton::ProtonDriveClient::list_directory(&target_path).await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Proton Drive list failed: {}", e)))?;
let total_files = entries.iter().filter(|e| !e.is_dir).count();
let total_dirs = entries.iter().filter(|e| e.is_dir).count();
let total_size = entries.iter().map(|e| e.size).sum();
let parent_path = if target_path == "proton://" || target_path == "proton:///" {
None
} else {
let clean = target_path.trim_start_matches("proton://").trim_start_matches('/');
let p = std::path::Path::new(clean);
p.parent().and_then(|par| {
let s = par.to_string_lossy().to_string();
if s.is_empty() { Some("proton:///".to_string()) } else { Some(format!("proton:///{}", s)) }
})
};
Ok(Json(DirectoryListing {
current_path: target_path,
parent_path,
entries,
total_files,
total_dirs,
total_size,
protocol: "proton".to_string(),
is_truncated: None,
max_limit: None,
}))
} else if target_path.starts_with("smb://") {
let params = crate::vfs::smb::SmbClient::parse_uri(&target_path, query.user.as_deref(), query.pass.as_deref())
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SMB URI: {}", e)))?;
crate::vfs::smb::SmbClient::list_dir(¶ms)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("SMB list failed: {}", e)))
} else if target_path.starts_with("nfs://") {
let params = crate::vfs::nfs::NfsClient::parse_uri(&target_path)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid NFS URI: {}", e)))?;
crate::vfs::nfs::NfsClient::list_dir(¶ms, show_hidden)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("NFS list failed: {}", e)))
} else if query.flat.unwrap_or(false) {
let max_d = query.max_depth;
let max_e = query.max_entries;
let p = target_path.clone();
tokio::task::spawn_blocking(move || {
LocalFs::list_branch_view(&p, show_hidden, max_d, max_e)
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Branch view task failed: {}", e)))?
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to list branch view: {}", e)))
} else {
let is_trash_files = target_path.ends_with(".local/share/Trash/files")
|| target_path.ends_with(r".local\share\Trash\files")
|| target_path.ends_with("/brum_trash/files");
if is_trash_files {
let trash_p = LocalFs::resolve_local_path(&target_path);
if !trash_p.exists() {
let _ = std::fs::create_dir_all(&trash_p);
}
}
LocalFs::list_dir(&target_path, show_hidden)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to list local directory: {}", e)))
}
}
#[derive(Deserialize)]
struct ReadFileQuery {
path: String,
max_bytes: Option<usize>,
}
async fn handle_read_file(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<ReadFileQuery>,
) -> Result<Json<crate::vfs::FileContentResponse>, (StatusCode, String)> {
let target_path = validate_path_access(&state, &headers, &query.path, false)?;
let max_b = query.max_bytes.unwrap_or(10_000_000);
if target_path.starts_with("vault://") {
let rest = target_path.strip_prefix("vault://").unwrap();
let (vault_file, subpath) = match rest.split_once('#') {
Some((v, s)) => (v, s),
None => (rest, ""),
};
state.vaults.read_vault_file(vault_file, subpath)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, e))
} else if target_path.starts_with("archive://") {
let rest = target_path.strip_prefix("archive://").unwrap();
let parts: Vec<&str> = rest.split('#').collect();
let archive_file = parts[0];
let subpath = if parts.len() > 1 { parts[1] } else { "" };
ArchiveHandler::read_archive_entry(archive_file, subpath, max_b)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read archive item: {}", e)))
} else if target_path.starts_with("smb://") {
let params = crate::vfs::smb::SmbClient::parse_uri(&target_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SMB URI: {}", e)))?;
crate::vfs::smb::SmbClient::read_file(¶ms)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read SMB file: {}", e)))
} else if target_path.starts_with("sftp://") {
let params = SftpClient::parse_uri(&target_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SFTP URI: {}", e)))?;
let bytes = SftpClient::download_file(¶ms.host, params.port, ¶ms.user, params.password.as_deref(), ¶ms.remote_path, max_b)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read SFTP file: {}", e)))?;
let mime = mime_guess::from_path(¶ms.remote_path).first_or_octet_stream().to_string();
let is_text = mime.starts_with("text/") || mime.contains("json") || mime.contains("javascript") || mime.contains("xml") || mime.contains("yaml") || mime.contains("toml");
use base64::Engine;
let content = if is_text {
String::from_utf8(bytes.clone()).unwrap_or_else(|_| base64::engine::general_purpose::STANDARD.encode(&bytes))
} else {
base64::engine::general_purpose::STANDARD.encode(&bytes)
};
let file_name = params.remote_path.rsplit('/').next().unwrap_or(¶ms.remote_path).to_string();
Ok(Json(crate::vfs::FileContentResponse {
path: target_path,
name: file_name,
content,
size: bytes.len() as u64,
mime_type: mime,
is_binary: !is_text,
}))
} else if target_path.starts_with("manual://") {
let clean_name = target_path.strip_prefix("manual://").unwrap().trim_start_matches('/').replace("..", "");
let disk_path = Path::new("manuals").join(&clean_name);
let content_str = if disk_path.is_file() {
fs::read_to_string(&disk_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else if let Some(file) = ManualsAsset::get(&clean_name) {
String::from_utf8_lossy(&file.data).to_string()
} else {
return Err((StatusCode::NOT_FOUND, format!("Manual '{}' not found", clean_name)));
};
Ok(Json(crate::vfs::FileContentResponse {
path: target_path,
name: clean_name,
content: content_str.clone(),
is_binary: false,
size: content_str.len() as u64,
mime_type: "text/markdown".to_string(),
}))
} else {
LocalFs::read_file(&target_path, max_b)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read file: {}", e)))
}
}
#[derive(Deserialize)]
struct WriteFileRequest {
path: String,
content: String,
atomic: Option<bool>,
is_base64: Option<bool>,
}
async fn handle_write_file(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<WriteFileRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let target_path = validate_path_access(&state, &headers, &payload.path, true)?;
if target_path.starts_with("manual://") {
return Err((StatusCode::FORBIDDEN, "Built-in repository user and QA testing manuals are read-only".to_string()));
}
let raw_bytes: Vec<u8> = if payload.is_base64.unwrap_or(false) {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(&payload.content)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 payload: {}", e)))?
} else {
payload.content.into_bytes()
};
if target_path.starts_with("vault://") {
let rest = target_path.strip_prefix("vault://").unwrap();
let (vault_file, subpath) = match rest.split_once('#') {
Some((v, s)) => (v, s),
None => (rest, ""),
};
state.vaults.write_vault_file(vault_file, subpath, &raw_bytes)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
} else if target_path.starts_with("smb://") {
let params = crate::vfs::smb::SmbClient::parse_uri(&target_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SMB URI: {}", e)))?;
crate::vfs::smb::SmbClient::write_file(¶ms, &raw_bytes)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save SMB file: {}", e)))
} else if target_path.starts_with("sftp://") {
let params = SftpClient::parse_uri(&target_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SFTP URI: {}", e)))?;
SftpClient::write_file(¶ms, &raw_bytes)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save SFTP file: {}", e)))
} else {
let atomic = payload.atomic.unwrap_or(state.config.paranoid.atomic_writes);
LocalFs::write_file(&target_path, &raw_bytes, atomic)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save file: {}", e)))
}
}
#[derive(Deserialize)]
struct MkdirRequest {
path: String,
}
async fn handle_mkdir(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<MkdirRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let target_path = validate_path_access(&state, &headers, &payload.path, true)?;
if target_path.starts_with("vault://") {
let rest = target_path.strip_prefix("vault://").unwrap();
let (vault_file, subpath) = match rest.split_once('#') {
Some((v, s)) => (v, s),
None => (rest, ""),
};
state.vaults.mkdir_vault(vault_file, subpath)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
} else if target_path.starts_with("smb://") {
let params = crate::vfs::smb::SmbClient::parse_uri(&target_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SMB URI: {}", e)))?;
crate::vfs::smb::SmbClient::mkdir(¶ms)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to create SMB folder: {}", e)))
} else if target_path.starts_with("sftp://") {
let params = crate::vfs::sftp::SftpClient::parse_uri(&target_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SFTP URI: {}", e)))?;
crate::vfs::sftp::SftpClient::mkdir(¶ms)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to create SFTP folder: {}", e)))
} else if target_path.starts_with("nfs://") {
let params = crate::vfs::nfs::NfsClient::parse_uri(&target_path)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid NFS URI: {}", e)))?;
let mount = crate::vfs::nfs::NfsClient::ensure_mounted(¶ms)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("NFS mount error: {}", e)))?;
let local_target = mount.join(params.subpath.trim_start_matches('/'));
LocalFs::create_dir(&local_target.to_string_lossy())
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to create NFS folder: {}", e)))
} else {
LocalFs::create_dir(&target_path)
.map(|_| Json(serde_json::json!({ "success": true, "path": target_path })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to create folder: {}", e)))
}
}
#[derive(Deserialize)]
struct RenameRequest {
from: String,
to: String,
windows_native_file_ops: Option<bool>,
detect_locking_processes: Option<bool>,
}
async fn handle_rename(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<RenameRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let from_path = validate_path_access(&state, &headers, &payload.from, true)?;
let to_path = validate_path_access(&state, &headers, &payload.to, true)?;
let use_native_ops = payload.windows_native_file_ops.unwrap_or(state.config.paranoid.windows_native_file_ops);
let detect_locks = payload.detect_locking_processes.unwrap_or(state.config.paranoid.detect_locking_processes);
if from_path.starts_with("smb://") {
let params_from = crate::vfs::smb::SmbClient::parse_uri(&from_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SMB URI: {}", e)))?;
let target_subpath = if to_path.starts_with("smb://") {
let params_to = crate::vfs::smb::SmbClient::parse_uri(&to_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SMB URI: {}", e)))?;
params_to.subpath
} else {
to_path
};
crate::vfs::smb::SmbClient::rename(¶ms_from, &target_subpath)
.map(|_| Json(serde_json::json!({ "success": true })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to rename SMB item: {}", e)))
} else if from_path.starts_with("sftp://") {
let params_from = crate::vfs::sftp::SftpClient::parse_uri(&from_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SFTP URI: {}", e)))?;
let target_remote = if to_path.starts_with("sftp://") {
let params_to = crate::vfs::sftp::SftpClient::parse_uri(&to_path, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SFTP URI: {}", e)))?;
params_to.remote_path
} else {
to_path
};
crate::vfs::sftp::SftpClient::rename(¶ms_from, &target_remote)
.map(|_| Json(serde_json::json!({ "success": true })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to rename SFTP item: {}", e)))
} else if from_path.starts_with("nfs://") {
let params_from = crate::vfs::nfs::NfsClient::parse_uri(&from_path)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid NFS URI: {}", e)))?;
let mount = crate::vfs::nfs::NfsClient::ensure_mounted(¶ms_from)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("NFS mount error: {}", e)))?;
let local_from = mount.join(params_from.subpath.trim_start_matches('/'));
let local_to = if to_path.starts_with("nfs://") {
let params_to = crate::vfs::nfs::NfsClient::parse_uri(&to_path)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid NFS URI: {}", e)))?;
mount.join(params_to.subpath.trim_start_matches('/'))
} else {
std::path::PathBuf::from(&to_path)
};
LocalFs::rename_entry_with_opts(&local_from.to_string_lossy(), &local_to.to_string_lossy(), use_native_ops, detect_locks)
.map(|_| Json(serde_json::json!({ "success": true })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to rename NFS item: {}", e)))
} else {
LocalFs::rename_entry_with_opts(&from_path, &to_path, use_native_ops, detect_locks)
.map(|_| Json(serde_json::json!({ "success": true })))
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to rename: {}", e)))
}
}
#[derive(Deserialize)]
struct BatchRenameItem {
from: String,
to: String,
}
#[derive(Deserialize)]
struct BatchRenameRequest {
renames: Vec<BatchRenameItem>,
}
async fn handle_batch_rename(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchRenameRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut renamed = 0;
let mut errors = Vec::new();
let use_native_ops = state.config.paranoid.windows_native_file_ops;
let detect_locks = state.config.paranoid.detect_locking_processes;
for item in payload.renames {
if item.from != item.to {
let from_res = validate_path_access(&state, &headers, &item.from, true);
let to_res = validate_path_access(&state, &headers, &item.to, true);
match (from_res, to_res) {
(Ok(from_path), Ok(to_path)) => {
match LocalFs::rename_entry_with_opts(&from_path, &to_path, use_native_ops, detect_locks) {
Ok(_) => renamed += 1,
Err(e) => errors.push(format!("{}: {}", item.from, e)),
}
}
(Err((_, e)), _) | (_, Err((_, e))) => {
errors.push(format!("{}: {}", item.from, e));
}
}
}
}
if errors.is_empty() {
Ok(Json(serde_json::json!({ "success": true, "renamed": renamed })))
} else {
Err((StatusCode::MULTI_STATUS, format!("Batch rename errors: {}", errors.join("; "))))
}
}
#[derive(Deserialize)]
struct DeleteRequest {
paths: Vec<String>,
use_trash: Option<bool>,
custom_trash_dir: Option<String>,
windows_native_file_ops: Option<bool>,
detect_locking_processes: Option<bool>,
}
async fn handle_delete(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<DeleteRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let use_trash = payload.use_trash.unwrap_or(state.config.paranoid.trash_enabled);
let custom_trash = payload.custom_trash_dir.as_deref().or(state.config.paranoid.custom_trash_dir.as_deref());
let use_native_ops = payload.windows_native_file_ops.unwrap_or(state.config.paranoid.windows_native_file_ops);
let detect_locks = payload.detect_locking_processes.unwrap_or(state.config.paranoid.detect_locking_processes);
let mut deleted = Vec::new();
let mut errors = Vec::new();
for path in &payload.paths {
let valid_path = match validate_path_access(&state, &headers, path, true) {
Ok(p) => p,
Err((_, e)) => {
errors.push(format!("{}: {}", path, e));
continue;
}
};
if valid_path.starts_with("vault://") {
let rest = valid_path.strip_prefix("vault://").unwrap();
let (vault_file, subpath) = match rest.split_once('#') {
Some((v, s)) => (v, s),
None => (rest, ""),
};
match state.vaults.delete_vault_file(vault_file, subpath) {
Ok(_) => deleted.push(path.clone()),
Err(e) => errors.push(format!("{}: {}", path, e)),
}
} else if valid_path.starts_with("smb://") {
match crate::vfs::smb::SmbClient::parse_uri(&valid_path, None, None) {
Ok(params) => {
match crate::vfs::smb::SmbClient::delete(¶ms, false) {
Ok(_) => deleted.push(path.clone()),
Err(e) => errors.push(format!("{}: {}", path, e)),
}
}
Err(e) => errors.push(format!("{}: {}", path, e)),
}
} else if valid_path.starts_with("sftp://") {
match crate::vfs::sftp::SftpClient::parse_uri(&valid_path, None, None) {
Ok(params) => {
match crate::vfs::sftp::SftpClient::delete(¶ms, false) {
Ok(_) => deleted.push(path.clone()),
Err(e) => errors.push(format!("{}: {}", path, e)),
}
}
Err(e) => errors.push(format!("{}: {}", path, e)),
}
} else if valid_path.starts_with("nfs://") {
match crate::vfs::nfs::NfsClient::parse_uri(&valid_path) {
Ok(params) => {
match crate::vfs::nfs::NfsClient::ensure_mounted(¶ms) {
Ok(mount) => {
let local_target = mount.join(params.subpath.trim_start_matches('/'));
match LocalFs::delete_entry_with_opts(&local_target.to_string_lossy(), false, None, use_native_ops, detect_locks) {
Ok(_) => deleted.push(path.clone()),
Err(e) => errors.push(format!("{}: {}", path, e)),
}
}
Err(e) => errors.push(format!("{}: {}", path, e)),
}
}
Err(e) => errors.push(format!("{}: {}", path, e)),
}
} else {
match LocalFs::delete_entry_with_opts(&valid_path, use_trash, custom_trash, use_native_ops, detect_locks) {
Ok(_) => deleted.push(path.clone()),
Err(e) => errors.push(format!("{}: {}", path, e)),
}
}
}
if errors.is_empty() {
Ok(Json(serde_json::json!({ "success": true, "deleted": deleted })))
} else {
Err((StatusCode::MULTI_STATUS, format!("Encountered errors: {}", errors.join("; "))))
}
}
#[derive(Deserialize)]
struct ChmodRequest {
paths: Vec<String>,
mode: u32,
recursive: Option<bool>,
}
async fn handle_chmod(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<ChmodRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let rec = payload.recursive.unwrap_or(false);
for p in &payload.paths {
let valid_path = validate_path_access(&state, &headers, p, true)?;
LocalFs::chmod_entry(&valid_path, payload.mode, rec)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Chmod failed for {}: {}", p, e)))?;
}
Ok(Json(serde_json::json!({ "success": true, "mode": format!("{:04o}", payload.mode) })))
}
#[derive(Deserialize)]
struct ChownRequest {
paths: Vec<String>,
owner: Option<String>,
group: Option<String>,
recursive: Option<bool>,
}
async fn handle_chown(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<ChownRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let rec = payload.recursive.unwrap_or(false);
let uid_val: Option<u32> = if let Some(ref o) = payload.owner {
if let Ok(u) = o.parse::<u32>() {
Some(u)
} else if let Ok(passwd) = fs::read_to_string("/etc/passwd") {
passwd.lines().find_map(|l| {
let parts: Vec<&str> = l.split(':').collect();
if parts.len() >= 3 && parts[0] == o {
parts[2].parse::<u32>().ok()
} else {
None
}
})
} else {
None
}
} else {
None
};
let gid_val: Option<u32> = if let Some(ref g) = payload.group {
if let Ok(gid) = g.parse::<u32>() {
Some(gid)
} else if let Ok(grp) = fs::read_to_string("/etc/group") {
grp.lines().find_map(|l| {
let parts: Vec<&str> = l.split(':').collect();
if parts.len() >= 3 && parts[0] == g {
parts[2].parse::<u32>().ok()
} else {
None
}
})
} else {
None
}
} else {
None
};
for p in &payload.paths {
let valid_path = validate_path_access(&state, &headers, p, true)?;
LocalFs::chown_entry(&valid_path, uid_val, gid_val, rec)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Chown failed for {}: {}", p, e)))?;
}
Ok(Json(serde_json::json!({ "success": true, "uid": uid_val, "gid": gid_val })))
}
#[derive(Deserialize)]
struct TransferRequest {
sources: Vec<String>,
destination: String,
paranoid: Option<bool>,
conflict_resolution: Option<String>,
}
async fn handle_copy(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<TransferRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut validated_sources = Vec::new();
for s in &payload.sources {
validated_sources.push(validate_path_access(&state, &headers, s, false)?);
}
let validated_dest = validate_path_access(&state, &headers, &payload.destination, true)?;
let paranoid = payload.paranoid.unwrap_or(state.config.paranoid.verify_after_transfer);
let conflict_resolution = payload.conflict_resolution.clone();
let task_id = state.tasks.create_task(
&format!("Copy {} items", validated_sources.len()),
"copy",
&validated_sources.join(", "),
&validated_dest,
0,
).await;
let tasks_mgr = state.tasks.clone();
let tid = task_id.clone();
let sources = validated_sources;
let destination = validated_dest;
tokio::spawn(async move {
crate::vfs::transfer::VfsTransfer::execute_batch_transfer(
tasks_mgr,
tid,
sources,
destination,
false,
paranoid,
conflict_resolution,
).await;
});
Ok(Json(serde_json::json!({ "success": true, "task_id": task_id, "copied_count": payload.sources.len() })))
}
#[derive(Deserialize)]
struct DeltaCopyRequest {
sources: Vec<String>,
destination: String,
options: Option<crate::tools::deltacopy::DeltaCopyOptions>,
}
async fn handle_deltacopy(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<DeltaCopyRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut validated_sources = Vec::new();
for s in &payload.sources {
validated_sources.push(validate_path_access(&state, &headers, s, false)?);
}
let validated_dest = validate_path_access(&state, &headers, &payload.destination, true)?;
let opts = payload.options.unwrap_or_default();
let tasks_mgr = state.tasks.clone();
let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
tokio::spawn(async move {
let _ = crate::tools::deltacopy::DeltaCopyEngine::run_deltacopy(
tasks_mgr,
validated_sources,
validated_dest,
opts,
cancel_token,
).await;
});
Ok(Json(serde_json::json!({
"success": true,
"message": "DeltaCopy transfer started in background queue",
})))
}
async fn handle_move(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<TransferRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut validated_sources = Vec::new();
for s in &payload.sources {
validated_sources.push(validate_path_access(&state, &headers, s, true)?);
}
let validated_dest = validate_path_access(&state, &headers, &payload.destination, true)?;
let paranoid = payload.paranoid.unwrap_or(state.config.paranoid.verify_after_transfer);
let conflict_resolution = payload.conflict_resolution.clone();
let task_id = state.tasks.create_task(
&format!("Move {} items", validated_sources.len()),
"move",
&validated_sources.join(", "),
&validated_dest,
0,
).await;
let tasks_mgr = state.tasks.clone();
let tid = task_id.clone();
let sources = validated_sources;
let destination = validated_dest;
tokio::spawn(async move {
crate::vfs::transfer::VfsTransfer::execute_batch_transfer(
tasks_mgr,
tid,
sources,
destination,
true,
paranoid,
conflict_resolution,
).await;
});
Ok(Json(serde_json::json!({ "success": true, "task_id": task_id, "moved_count": payload.sources.len() })))
}
#[derive(Deserialize)]
struct TestRemoteRequest {
protocol: String,
host: String,
port: Option<u16>,
user: Option<String>,
pass: Option<String>,
bucket: Option<String>,
region: Option<String>,
}
async fn handle_test_remote(
Json(payload): Json<TestRemoteRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
match payload.protocol.to_lowercase().as_str() {
"sftp" => {
let port = payload.port.unwrap_or(22);
let user = payload.user.unwrap_or_else(|| "root".to_string());
let params = crate::vfs::sftp::SftpParams {
host: payload.host,
port,
user,
password: payload.pass.filter(|p| !p.trim().is_empty()),
key_path: None,
remote_path: "/".to_string(),
};
match SftpClient::list_dir(¶ms) {
Ok(listing) => Ok(Json(serde_json::json!({
"success": true,
"message": format!("Connected successfully to SFTP server (found {} items)", listing.entries.len()),
}))),
Err(e) => Err((StatusCode::BAD_REQUEST, format!("SFTP connection failed: {}", e))),
}
}
"webdav" => {
let url = payload.host;
match WebDavClient::list_dir(&url, payload.user.as_deref(), payload.pass.as_deref()).await {
Ok(listing) => Ok(Json(serde_json::json!({
"success": true,
"message": format!("Connected successfully to WebDAV storage (found {} items)", listing.entries.len()),
}))),
Err(e) => Err((StatusCode::BAD_REQUEST, format!("WebDAV connection failed: {}", e))),
}
}
"s3" => {
let s3_conf = crate::vfs::s3::S3Config {
endpoint: payload.host,
bucket: payload.bucket.unwrap_or_default(),
region: payload.region.unwrap_or_else(|| "us-east-1".to_string()),
access_key_id: payload.user.unwrap_or_default(),
secret_access_key: payload.pass.unwrap_or_default(),
path_style: Some(true),
};
let client = crate::vfs::s3::S3Client::new(s3_conf);
match client.test_connection().await {
Ok(_) => Ok(Json(serde_json::json!({
"success": true,
"message": "Connected successfully to S3 / Cloud Object Storage bucket!",
}))),
Err(e) => Err((StatusCode::BAD_REQUEST, format!("S3 connection failed: {}", e))),
}
}
"proton" => {
let status = crate::vfs::proton::ProtonDriveClient::check_status().await;
if status.installed {
if status.authenticated {
Ok(Json(serde_json::json!({
"success": true,
"message": format!("Proton Drive integration ready! Backend: {} (Version: {})", status.cli_type, status.version.unwrap_or_default()),
})))
} else {
Ok(Json(serde_json::json!({
"success": false,
"message": format!("Proton Drive CLI detected ({}) but not yet logged in. Run '{} login' in terminal.", status.cli_type, status.cli_type),
})))
}
} else {
Err((StatusCode::BAD_REQUEST, "Proton Drive CLI or rclone not detected on host. Install 'proton-drive' or configure an rclone proton remote.".to_string()))
}
}
"smb" => {
let port = payload.port.unwrap_or(445);
let share = payload.bucket.unwrap_or_else(|| "share".to_string());
let params = crate::vfs::smb::SmbParams {
host: payload.host,
port,
share,
subpath: "".to_string(),
username: payload.user,
password: payload.pass,
domain: payload.region,
};
crate::vfs::smb::SmbClient::test_connection(¶ms)
.map(|msg| Json(serde_json::json!({ "success": true, "message": msg })))
.map_err(|e| (StatusCode::BAD_REQUEST, e))
}
"nfs" => {
let port = payload.port.unwrap_or(2049);
let export_path = payload.bucket.unwrap_or_else(|| "/".to_string());
let params = crate::vfs::nfs::NfsParams {
host: payload.host,
port,
export_path,
subpath: "".to_string(),
version: payload.region,
};
crate::vfs::nfs::NfsClient::test_connection(¶ms)
.map(|msg| Json(serde_json::json!({ "success": true, "message": msg })))
.map_err(|e| (StatusCode::BAD_REQUEST, e))
}
_ => Err((StatusCode::BAD_REQUEST, "Unsupported protocol".to_string())),
}
}
async fn handle_proton_status() -> Json<crate::vfs::proton::ProtonStatus> {
Json(crate::vfs::proton::ProtonDriveClient::check_status().await)
}
async fn handle_list_tasks(
State(state): State<AppState>,
) -> Json<Vec<TaskInfo>> {
Json(state.tasks.list_tasks().await)
}
async fn handle_get_task(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<TaskInfo>, (StatusCode, String)> {
match state.tasks.get_task(&id).await {
Some(task) => Ok(Json(task)),
None => Err((StatusCode::NOT_FOUND, "Task not found".to_string())),
}
}
async fn handle_cancel_task(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
let cancelled = state.tasks.cancel_task(&id).await;
Json(serde_json::json!({ "success": cancelled }))
}
async fn handle_pause_task(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
let paused = state.tasks.pause_task(&id).await;
Json(serde_json::json!({ "success": paused }))
}
async fn handle_resume_task(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
let resumed = state.tasks.resume_task(&id).await;
Json(serde_json::json!({ "success": resumed }))
}
async fn handle_clear_completed_tasks(
State(state): State<AppState>,
) -> Json<serde_json::Value> {
state.tasks.clear_completed().await;
Json(serde_json::json!({ "success": true }))
}
async fn handle_upload(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let raw_dest = query.get("destination").cloned().unwrap_or_else(|| "~".to_string());
let dest_dir = validate_path_access(&state, &headers, &raw_dest, true)?;
let mut uploaded_files = Vec::new();
let mut uploaded_hashes: HashMap<String, String> = HashMap::new();
let is_silent = query.get("silent").map(|v| v == "true" || v == "1").unwrap_or(false)
|| query.get("no_task").map(|v| v == "true" || v == "1").unwrap_or(false);
let task_id_opt = if !is_silent {
Some(state.tasks.create_task("Upload Files", "upload", "Browser", &dest_dir, 0).await)
} else {
None
};
let conflict_mode = query.get("conflict").or_else(|| query.get("conflict_resolution")).map(|s| s.as_str()).unwrap_or("overwrite");
while let Ok(Some(field)) = multipart.next_field().await {
let file_name = field.file_name().unwrap_or("upload.bin").to_string();
if let Ok(data) = field.bytes().await {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&data);
let sha256_hex = hex::encode(hasher.finalize());
uploaded_hashes.insert(file_name.clone(), sha256_hex.clone());
let file_size = data.len() as u64;
if let Some(ref tid) = task_id_opt {
state.tasks.update_task_details(
tid,
Some(&file_name),
file_size,
file_size,
uploaded_files.len() as u64 + 1,
uploaded_files.len() as u64 + 1,
file_size,
0,
Some(uploaded_files.len() as u64 + 1),
Some(&format!("SHA-256 Match: {}", sha256_hex)),
Some(&format!("Uploaded {} | SHA-256 Match: {}", file_name, sha256_hex)),
).await;
}
if dest_dir.starts_with("smb://") {
let params = match crate::vfs::smb::SmbClient::parse_uri(&dest_dir, None, None) {
Ok(mut p) => {
p.subpath = if p.subpath.is_empty() { file_name.clone() } else { format!("{}/{}", p.subpath, file_name) };
p
}
Err(e) => {
let err_msg = format!("Invalid SMB destination: {}", e);
if let Some(ref tid) = task_id_opt {
state.tasks.fail_task(tid, &err_msg).await;
}
return Err((StatusCode::BAD_REQUEST, err_msg));
}
};
if let Err(e) = crate::vfs::smb::SmbClient::write_file(¶ms, &data) {
let err_msg = format!("Failed to write SMB upload: {}", e);
if let Some(ref tid) = task_id_opt {
state.tasks.fail_task(tid, &err_msg).await;
}
return Err((StatusCode::INTERNAL_SERVER_ERROR, err_msg));
}
} else if dest_dir.starts_with("sftp://") {
let params = match SftpClient::parse_uri(&dest_dir, None, None) {
Ok(mut p) => {
p.remote_path = if p.remote_path.is_empty() || p.remote_path == "/" {
format!("/{}", file_name)
} else {
format!("{}/{}", p.remote_path.trim_end_matches('/'), file_name)
};
p
}
Err(e) => {
let err_msg = format!("Invalid SFTP destination: {}", e);
if let Some(ref tid) = task_id_opt {
state.tasks.fail_task(tid, &err_msg).await;
}
return Err((StatusCode::BAD_REQUEST, err_msg));
}
};
if let Err(e) = SftpClient::write_file(¶ms, &data) {
let err_msg = format!("Failed to write SFTP upload: {}", e);
if let Some(ref tid) = task_id_opt {
state.tasks.fail_task(tid, &err_msg).await;
}
return Err((StatusCode::INTERNAL_SERVER_ERROR, err_msg));
}
} else {
let raw_target = Path::new(&dest_dir).join(&file_name);
let target_path = match conflict_mode {
"skip" if raw_target.exists() => {
continue;
}
"rename" if raw_target.exists() => {
crate::vfs::transfer::generate_unique_destination_path(&raw_target)
}
_ => raw_target,
};
if let Err(e) = LocalFs::write_file(&target_path.to_string_lossy(), &data, true) {
let err_msg = format!("Failed to write upload: {}", e);
if let Some(ref tid) = task_id_opt {
state.tasks.fail_task(tid, &err_msg).await;
}
return Err((StatusCode::INTERNAL_SERVER_ERROR, err_msg));
}
}
uploaded_files.push(file_name);
}
}
if let Some(ref tid) = task_id_opt {
state.tasks.complete_task(tid).await;
}
Ok(Json(serde_json::json!({
"success": true,
"uploaded": uploaded_files,
"hashes": uploaded_hashes,
"task_id": task_id_opt
})))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HttpRange {
pub start: u64,
pub end: u64, }
impl HttpRange {
pub fn parse(range_header: &str, total_len: u64) -> Option<Result<HttpRange, ()>> {
if total_len == 0 {
return Some(Err(()));
}
let range_str = range_header.trim();
if !range_str.starts_with("bytes=") {
return None;
}
let spec = &range_str["bytes=".len()..].trim();
let spec = spec.split(',').next()?.trim();
if let Some((start_str, end_str)) = spec.split_once('-') {
let start_str = start_str.trim();
let end_str = end_str.trim();
if start_str.is_empty() {
if let Ok(suffix_len) = end_str.parse::<u64>() {
if suffix_len == 0 {
return Some(Err(()));
}
let actual_len = suffix_len.min(total_len);
let start = total_len - actual_len;
let end = total_len - 1;
return Some(Ok(HttpRange { start, end }));
}
} else if end_str.is_empty() {
if let Ok(start) = start_str.parse::<u64>() {
if start >= total_len {
return Some(Err(())); }
let end = total_len - 1;
return Some(Ok(HttpRange { start, end }));
}
} else {
if let (Ok(start), Ok(end)) = (start_str.parse::<u64>(), end_str.parse::<u64>()) {
if start > end || start >= total_len {
return Some(Err(())); }
let end = end.min(total_len - 1);
return Some(Ok(HttpRange { start, end }));
}
}
}
None
}
}
fn build_bytes_range_response(
file_bytes: Vec<u8>,
mime: String,
disposition: String,
etag: Option<String>,
range_header: Option<&str>,
) -> Result<Response, (StatusCode, String)> {
let total_len = file_bytes.len() as u64;
if let Some(range_raw) = range_header {
if let Some(range_res) = HttpRange::parse(range_raw, total_len) {
match range_res {
Ok(range) => {
let start = range.start as usize;
let end = (range.end as usize).min(file_bytes.len().saturating_sub(1));
let slice = if start <= end && start < file_bytes.len() {
file_bytes[start..=end].to_vec()
} else {
Vec::new()
};
let slice_len = slice.len();
let mut builder = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, total_len))
.header(header::CONTENT_LENGTH, slice_len.to_string())
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_ENCODING, "identity");
if let Some(et) = etag {
builder = builder
.header(header::ETAG, et)
.header(header::CACHE_CONTROL, "public, max-age=86400, must-revalidate");
}
return builder
.body(Body::from(slice))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)));
}
Err(()) => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", total_len))
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_ENCODING, "identity")
.body(Body::empty())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)));
}
}
}
}
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, total_len.to_string())
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_ENCODING, "identity");
if let Some(et) = etag {
builder = builder
.header(header::ETAG, et)
.header(header::CACHE_CONTROL, "public, max-age=86400, must-revalidate");
}
builder
.body(Body::from(file_bytes))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)))
}
async fn build_local_file_range_response(
path: &Path,
total_len: u64,
mime: String,
disposition: String,
etag: String,
range_header: Option<&str>,
) -> Result<Response, (StatusCode, String)> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;
if let Some(range_raw) = range_header {
if let Some(range_res) = HttpRange::parse(range_raw, total_len) {
match range_res {
Ok(range) => {
let slice_len = range.end - range.start + 1;
let mut file = tokio::fs::File::open(path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open file: {}", e))
})?;
file.seek(std::io::SeekFrom::Start(range.start)).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to seek file: {}", e))
})?;
let stream = ReaderStream::new(file.take(slice_len));
let body = Body::from_stream(stream);
let response = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", range.start, range.end, total_len))
.header(header::CONTENT_LENGTH, slice_len.to_string())
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "public, max-age=86400, must-revalidate")
.header(header::CONTENT_ENCODING, "identity")
.body(body)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)))?;
return Ok(response);
}
Err(()) => {
let response = Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", total_len))
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_ENCODING, "identity")
.body(Body::empty())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)))?;
return Ok(response);
}
}
}
}
let file = tokio::fs::File::open(path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open file: {}", e))
})?;
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, total_len.to_string())
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "public, max-age=86400, must-revalidate")
.header(header::CONTENT_ENCODING, "identity")
.body(body)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)))?;
Ok(response)
}
async fn handle_download(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response, (StatusCode, String)> {
let raw_path = query.get("path").ok_or((StatusCode::BAD_REQUEST, "Missing path param".to_string()))?;
let mut modified_headers = headers.clone();
if !modified_headers.contains_key(header::AUTHORIZATION) {
if let Some(tok) = query.get("token").or_else(|| query.get("auth")) {
if let Ok(hv) = header::HeaderValue::from_str(&format!("Bearer {}", tok)) {
modified_headers.insert(header::AUTHORIZATION, hv);
}
}
}
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
let path_str = validate_path_access(&state, &modified_headers, raw_path, false)?;
if path_str.starts_with("vault://") {
let rest = path_str.strip_prefix("vault://").unwrap();
let (vault_file, subpath) = match rest.split_once('#') {
Some((v, s)) => (v, s),
None => (rest, ""),
};
let file_res = state.vaults.read_vault_file(vault_file, subpath)
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
use base64::Engine;
let file_bytes = if file_res.is_binary && file_res.content.starts_with("data:application/octet-stream;base64,") {
let b64 = file_res.content.strip_prefix("data:application/octet-stream;base64,").unwrap();
base64::engine::general_purpose::STANDARD.decode(b64).unwrap_or_default()
} else {
file_res.content.into_bytes()
};
let file_name = file_res.name;
let mime = file_res.mime_type;
let is_media_type = mime.starts_with("image/")
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
|| mime == "application/pdf"
|| mime.starts_with("text/");
let is_inline = query.get("inline").map(|v| v == "true" || v == "1").unwrap_or(is_media_type);
let disposition = if is_inline {
format!("inline; filename=\"{}\"", file_name)
} else {
format!("attachment; filename=\"{}\"", file_name)
};
return build_bytes_range_response(file_bytes, mime, disposition, None, range_header);
} else if path_str.starts_with("archive://") {
let rest = path_str.strip_prefix("archive://").unwrap();
let (archive_file, subpath) = match rest.split_once('#') {
Some((a, s)) => (a, s),
None => (rest, ""),
};
let file_res = ArchiveHandler::read_archive_entry(archive_file, subpath, 0)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read archive entry: {}", e)))?;
use base64::Engine;
let file_bytes = if file_res.is_binary {
base64::engine::general_purpose::STANDARD.decode(&file_res.content).unwrap_or_default()
} else {
file_res.content.into_bytes()
};
let file_name = file_res.name;
let mime = file_res.mime_type;
let is_media_type = mime.starts_with("image/")
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
|| mime == "application/pdf"
|| mime.starts_with("text/");
let is_inline = query.get("inline").map(|v| v == "true" || v == "1").unwrap_or(is_media_type);
let disposition = if is_inline {
format!("inline; filename=\"{}\"", file_name)
} else {
format!("attachment; filename=\"{}\"", file_name)
};
return build_bytes_range_response(file_bytes, mime, disposition, None, range_header);
} else if path_str.starts_with("smb://") {
let params = crate::vfs::smb::SmbClient::parse_uri(&path_str, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SMB URI: {}", e)))?;
let file_bytes = crate::vfs::smb::SmbClient::read_bytes(¶ms)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to download SMB file: {}", e)))?;
let file_name = params.subpath.rsplit('/').next().unwrap_or(¶ms.subpath).to_string();
let mime = mime_guess::from_path(&file_name).first_or_octet_stream().to_string();
let is_media_type = mime.starts_with("image/")
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
|| mime == "application/pdf"
|| mime.starts_with("text/");
let is_inline = query.get("inline").map(|v| v == "true" || v == "1").unwrap_or(is_media_type);
let disposition = if is_inline {
format!("inline; filename=\"{}\"", file_name)
} else {
format!("attachment; filename=\"{}\"", file_name)
};
return build_bytes_range_response(file_bytes, mime, disposition, None, range_header);
} else if path_str.starts_with("sftp://") {
let params = SftpClient::parse_uri(&path_str, None, None)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid SFTP URI: {}", e)))?;
let file_bytes = SftpClient::download_file(¶ms.host, params.port, ¶ms.user, params.password.as_deref(), ¶ms.remote_path, 0)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to download SFTP file: {}", e)))?;
let file_name = params.remote_path.rsplit('/').next().unwrap_or(¶ms.remote_path).to_string();
let mime = mime_guess::from_path(&file_name).first_or_octet_stream().to_string();
let is_media_type = mime.starts_with("image/")
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
|| mime == "application/pdf"
|| mime.starts_with("text/");
let is_inline = query.get("inline").map(|v| v == "true" || v == "1").unwrap_or(is_media_type);
let disposition = if is_inline {
format!("inline; filename=\"{}\"", file_name)
} else {
format!("attachment; filename=\"{}\"", file_name)
};
return build_bytes_range_response(file_bytes, mime, disposition, None, range_header);
}
let path = Path::new(&path_str);
if !path.exists() {
return Err((StatusCode::NOT_FOUND, "File or folder not found".to_string()));
}
if path.is_dir() {
let temp_zip = tempfile::Builder::new()
.prefix("brum_folder_")
.suffix(".zip")
.tempfile()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Temp file error: {}", e)))?;
let temp_path = temp_zip.path().to_str().unwrap().to_string();
ArchiveHandler::create_zip(&[path_str.clone()], &temp_path)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Zip creation failed: {}", e)))?;
let file_bytes = tokio::fs::read(&temp_path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read generated zip: {}", e))
})?;
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
let zip_name = if dir_name.is_empty() { "folder.zip".to_string() } else { format!("{}.zip", dir_name) };
let response = Response::builder()
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", zip_name))
.header(header::CONTENT_LENGTH, file_bytes.len().to_string())
.body(Body::from(file_bytes))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)))?;
return Ok(response);
}
let metadata = tokio::fs::metadata(path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file metadata: {}", e))
})?;
let mtime_sec = metadata.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let etag = format!("\"{:x}-{:x}\"", metadata.len(), mtime_sec);
if let Some(inm) = headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) {
if inm == etag || inm == "*" {
return Ok(Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "public, max-age=86400, must-revalidate")
.body(Body::empty())
.unwrap());
}
}
let file_name = path.file_name().unwrap_or_default().to_string_lossy();
let mime = mime_guess::from_path(path).first_or_octet_stream().to_string();
let is_media_type = mime.starts_with("image/")
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
|| mime == "application/pdf"
|| mime.starts_with("text/");
let is_inline = query.get("inline").map(|v| v == "true" || v == "1").unwrap_or(is_media_type);
let disposition = if is_inline {
format!("inline; filename=\"{}\"", file_name)
} else {
format!("attachment; filename=\"{}\"", file_name)
};
build_local_file_range_response(path, metadata.len(), mime, disposition, etag, range_header).await
}
#[derive(Deserialize)]
struct BatchDownloadRequest {
paths: Vec<String>,
}
async fn handle_download_batch(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchDownloadRequest>,
) -> Result<Response, (StatusCode, String)> {
if payload.paths.is_empty() {
return Err((StatusCode::BAD_REQUEST, "No paths specified for download".to_string()));
}
let mut validated_paths = Vec::new();
for p in &payload.paths {
validated_paths.push(validate_path_access(&state, &headers, p, false)?);
}
let temp_zip = tempfile::Builder::new()
.prefix("brum_batch_")
.suffix(".zip")
.tempfile()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Temp file error: {}", e)))?;
let temp_path = temp_zip.path().to_str().unwrap().to_string();
ArchiveHandler::create_zip(&validated_paths, &temp_path)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Batch zip creation failed: {}", e)))?;
let file_bytes = std::fs::read(&temp_path).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read generated zip: {}", e))
})?;
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let zip_name = if validated_paths.len() == 1 {
let p = Path::new(&validated_paths[0]);
let base = p.file_name().unwrap_or_default().to_string_lossy();
format!("{}.zip", base)
} else {
format!("brum_download_{}.zip", timestamp)
};
let response = Response::builder()
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", zip_name))
.body(Body::from(file_bytes))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Response build error: {}", e)))?;
Ok(response)
}
#[derive(Deserialize)]
struct ArchiveCreateRequest {
sources: Vec<String>,
target_path: String,
format: String,
}
async fn handle_archive_create(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<ArchiveCreateRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut validated_sources = Vec::new();
for s in &payload.sources {
validated_sources.push(validate_path_access(&state, &headers, s, false)?);
}
let validated_target = validate_path_access(&state, &headers, &payload.target_path, true)?;
match payload.format.to_lowercase().as_str() {
"zip" => ArchiveHandler::create_zip(&validated_sources, &validated_target)
.map(|_| Json(serde_json::json!({ "success": true, "archive": validated_target })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Zip failed: {}", e))),
"targz" | "tar.gz" => ArchiveHandler::create_targz(&validated_sources, &validated_target)
.map(|_| Json(serde_json::json!({ "success": true, "archive": validated_target })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Tar.gz failed: {}", e))),
_ => Err((StatusCode::BAD_REQUEST, "Unsupported archive format".to_string())),
}
}
#[derive(Deserialize)]
struct ArchiveExtractRequest {
archive_path: String,
target_dir: String,
}
async fn handle_archive_extract(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<ArchiveExtractRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_archive = validate_path_access(&state, &headers, &payload.archive_path, false)?;
let valid_target = validate_path_access(&state, &headers, &payload.target_dir, true)?;
ArchiveHandler::extract_archive(&valid_archive, &valid_target)
.map(|_| Json(serde_json::json!({ "success": true })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Extract failed: {}", e)))
}
#[derive(Deserialize)]
struct ChecksumRequest {
path: String,
algorithm: Option<String>,
}
async fn handle_calculate_checksum(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<ChecksumRequest>,
) -> Result<Json<crate::vfs::checksum::ChecksumResult>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &payload.path, false)?;
let algo = payload.algorithm.unwrap_or_else(|| "sha256".to_string());
calculate_checksum(&valid_path, &algo)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Checksum failed: {}", e)))
}
#[derive(Deserialize)]
struct DiffFilesRequest {
file_left: String,
file_right: String,
}
async fn handle_diff_files(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<DiffFilesRequest>,
) -> Result<Json<crate::tools::diff::FileDiffResult>, (StatusCode, String)> {
let left = validate_path_access(&state, &headers, &payload.file_left, false)?;
let right = validate_path_access(&state, &headers, &payload.file_right, false)?;
let text_l = std::fs::read_to_string(&left)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Cannot read left file {}: {}", left, e)))?;
let text_r = std::fs::read_to_string(&right)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Cannot read right file {}: {}", right, e)))?;
let diff = compare_files_text(&left, &text_l, &right, &text_r);
Ok(Json(diff))
}
#[derive(Deserialize)]
struct DiffFoldersRequest {
dir_left: String,
dir_right: String,
recursive: Option<bool>,
deep_hash: Option<bool>,
selected_items: Option<Vec<String>>,
}
async fn handle_diff_folders(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<DiffFoldersRequest>,
) -> Result<Json<crate::tools::diff::FolderDiffResult>, (StatusCode, String)> {
let left = validate_path_access(&state, &headers, &payload.dir_left, false)?;
let right = validate_path_access(&state, &headers, &payload.dir_right, false)?;
let recursive = payload.recursive.unwrap_or(false);
let deep = payload.deep_hash.unwrap_or(false);
crate::tools::diff::compare_folders(&left, &right, recursive, deep, payload.selected_items)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Folder diff failed: {}", e)))
}
async fn handle_convert_file(
Json(payload): Json<crate::tools::converter::ConvertRequest>,
) -> Result<Json<crate::tools::converter::ConvertResponse>, (StatusCode, String)> {
crate::tools::converter::ConvertEngine::convert_file(&payload)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Conversion failed: {}", e)))
}
#[derive(Deserialize)]
struct DryRunRequest {
action: String,
sources: Vec<String>,
destination: Option<String>,
}
async fn handle_paranoid_dry_run(
Json(payload): Json<DryRunRequest>,
) -> Result<Json<crate::tools::paranoid::DryRunPreview>, (StatusCode, String)> {
ParanoidEngine::dry_run(&payload.action, &payload.sources, payload.destination.as_deref())
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Dry run failed: {}", e)))
}
async fn handle_list_manuals() -> Json<crate::tools::notedog::NoteDogNotebook> {
Json(crate::tools::notedog::get_builtin_manuals_notebook())
}
async fn handle_get_manual(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Result<Json<crate::vfs::FileContentResponse>, (StatusCode, String)> {
let clean_name = name.trim_start_matches('/').replace("..", "");
let disk_path = Path::new("manuals").join(&clean_name);
let content_str = if disk_path.is_file() {
fs::read_to_string(&disk_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else if let Some(file) = ManualsAsset::get(&clean_name) {
String::from_utf8_lossy(&file.data).to_string()
} else {
return Err((StatusCode::NOT_FOUND, format!("Manual '{}' not found", clean_name)));
};
Ok(Json(crate::vfs::FileContentResponse {
path: format!("manual://{}", clean_name),
name: clean_name,
content: content_str.clone(),
is_binary: false,
size: content_str.len() as u64,
mime_type: "text/markdown".to_string(),
}))
}
#[derive(Deserialize)]
struct NoteDogInfoQuery {
folder: Option<String>,
}
async fn handle_notedog_info(
State(state): State<AppState>,
axum::extract::Query(query): axum::extract::Query<NoteDogInfoQuery>,
) -> Result<Json<crate::tools::notedog::NoteDogInfo>, (StatusCode, String)> {
let cfg_folder = state.config.notedog.notes_folder.as_str();
let info = crate::tools::notedog::scan_notedog_hierarchy(query.folder.as_deref(), Some(cfg_folder));
Ok(Json(info))
}
async fn handle_notedog_templates() -> Json<Vec<crate::tools::notedog::NoteTemplate>> {
Json(crate::tools::notedog::get_builtin_templates())
}
#[derive(Deserialize)]
struct NoteDogVersionsQuery {
path: String,
}
async fn handle_notedog_versions(
axum::extract::Query(query): axum::extract::Query<NoteDogVersionsQuery>,
) -> Json<Vec<crate::tools::notedog::NoteVersionItem>> {
let p = Path::new(&query.path);
Json(crate::tools::notedog::list_note_versions(p))
}
#[derive(Deserialize)]
struct NoteDogSaveVersionRequest {
path: String,
}
async fn handle_notedog_save_version(
Json(payload): Json<NoteDogSaveVersionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let p = Path::new(&payload.path);
if p.exists() {
if let Ok(bytes) = std::fs::read(p) {
let _ = crate::tools::notedog::create_note_snapshot(p, &bytes);
}
}
Ok(Json(serde_json::json!({ "success": true })))
}
#[derive(Deserialize)]
struct NoteDogDecryptRequest {
path: String,
passphrase: Option<String>,
}
#[derive(Serialize)]
struct NoteDogDecryptResponse {
content: String,
path: String,
}
async fn handle_notedog_decrypt(
Json(payload): Json<NoteDogDecryptRequest>,
) -> Result<Json<NoteDogDecryptResponse>, (StatusCode, String)> {
let p = Path::new(&payload.path);
if !p.exists() {
return Err((StatusCode::NOT_FOUND, "Note file not found".to_string()));
}
let pass = payload.passphrase.as_deref().unwrap_or("notedog");
match crate::tools::notedog::decrypt_note_file(p, pass) {
Ok(content) => Ok(Json(NoteDogDecryptResponse {
content,
path: payload.path,
})),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
}
#[derive(Deserialize)]
struct NoteDogEncryptRequest {
path: String,
content: String,
passphrase: Option<String>,
convert_to_plain: Option<bool>,
}
#[derive(Serialize)]
struct NoteDogEncryptResponse {
success: bool,
path: String,
is_encrypted: bool,
}
async fn handle_notedog_encrypt(
Json(payload): Json<NoteDogEncryptRequest>,
) -> Result<Json<NoteDogEncryptResponse>, (StatusCode, String)> {
let p = Path::new(&payload.path);
let pass = payload.passphrase.as_deref().unwrap_or("notedog");
if payload.convert_to_plain.unwrap_or(false) {
match crate::tools::notedog::decrypt_and_save_plain_note(p, pass) {
Ok(new_path) => Ok(Json(NoteDogEncryptResponse {
success: true,
path: new_path.to_string_lossy().to_string(),
is_encrypted: false,
})),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
} else {
match crate::tools::notedog::encrypt_and_save_note(p, &payload.content, pass) {
Ok(new_path) => Ok(Json(NoteDogEncryptResponse {
success: true,
path: new_path.to_string_lossy().to_string(),
is_encrypted: true,
})),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
}
}
#[derive(Deserialize)]
struct NoteDogCreateNoteRequest {
section_path: String,
title: String,
is_encrypted: Option<bool>,
passphrase: Option<String>,
initial_content: Option<String>,
}
async fn handle_notedog_create_note(
Json(payload): Json<NoteDogCreateNoteRequest>,
) -> Result<Json<crate::tools::notedog::NoteDogFile>, (StatusCode, String)> {
let sec_p = Path::new(&payload.section_path);
match crate::tools::notedog::create_new_note(
sec_p,
&payload.title,
payload.is_encrypted.unwrap_or(false),
payload.passphrase.as_deref(),
payload.initial_content.as_deref(),
) {
Ok(note) => Ok(Json(note)),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
}
#[derive(Deserialize)]
struct NoteDogSectionActionRequest {
path: String,
passphrase: String,
cached_pass: Option<String>,
}
async fn handle_notedog_section_encrypt(
Json(payload): Json<NoteDogSectionActionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let p = Path::new(&payload.path);
match crate::tools::notedog::encrypt_section_dir(p, &payload.passphrase, payload.cached_pass.as_deref()) {
Ok(count) => Ok(Json(serde_json::json!({ "success": true, "encrypted_count": count }))),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
}
async fn handle_notedog_section_decrypt(
Json(payload): Json<NoteDogSectionActionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let p = Path::new(&payload.path);
match crate::tools::notedog::decrypt_section_dir(p, &payload.passphrase) {
Ok(count) => Ok(Json(serde_json::json!({ "success": true, "decrypted_count": count }))),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
}
#[derive(Deserialize)]
struct NoteDogNotebookActionRequest {
path: String,
passphrase: String,
cached_pass: Option<String>,
}
async fn handle_notedog_notebook_encrypt(
Json(payload): Json<NoteDogNotebookActionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let p = Path::new(&payload.path);
match crate::tools::notedog::encrypt_notebook_dir(p, &payload.passphrase, payload.cached_pass.as_deref()) {
Ok(count) => Ok(Json(serde_json::json!({ "success": true, "encrypted_count": count }))),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
}
async fn handle_notedog_notebook_decrypt(
Json(payload): Json<NoteDogNotebookActionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let p = Path::new(&payload.path);
match crate::tools::notedog::decrypt_notebook_dir(p, &payload.passphrase) {
Ok(count) => Ok(Json(serde_json::json!({ "success": true, "decrypted_count": count }))),
Err(err) => Err((StatusCode::BAD_REQUEST, err)),
}
}
#[derive(Deserialize)]
struct ListDbNotesQuery {
search: Option<String>,
tag: Option<String>,
category: Option<String>,
section: Option<String>,
archived: Option<bool>,
}
async fn handle_list_db_notes(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<ListDbNotesQuery>,
) -> Result<Json<Vec<crate::auth::DbNote>>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let is_admin = claims.role == "admin";
let notes = state.auth.list_db_notes(
&claims.sub,
is_admin,
query.search.as_deref(),
query.tag.as_deref(),
query.category.as_deref(),
query.section.as_deref(),
query.archived.unwrap_or(false),
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list notes: {}", e)))?;
Ok(Json(notes))
}
#[derive(Deserialize)]
struct CreateDbNoteRequest {
title: String,
content: Option<String>,
category: Option<String>,
section: Option<String>,
tags: Option<String>,
is_pinned: Option<bool>,
is_encrypted: Option<bool>,
color: Option<String>,
}
async fn handle_create_db_note(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateDbNoteRequest>,
) -> Result<Json<crate::auth::DbNote>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let title = payload.title.trim();
if title.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Title cannot be empty".to_string()));
}
let content = payload.content.as_deref().unwrap_or("");
let category = payload.category.as_deref().unwrap_or("General");
let section = payload.section.as_deref().unwrap_or("Default");
let tags = payload.tags.as_deref().unwrap_or("[]");
let is_pinned = payload.is_pinned.unwrap_or(false);
let is_encrypted = payload.is_encrypted.unwrap_or(false);
let color = payload.color.as_deref();
let note = state.auth.create_db_note(
&claims.sub,
title,
content,
category,
section,
tags,
is_pinned,
is_encrypted,
color,
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create note: {}", e)))?;
Ok(Json(note))
}
async fn handle_get_db_note(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<crate::auth::DbNote>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let is_admin = claims.role == "admin";
let note = state.auth.get_db_note(id, &claims.sub, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Note not found".to_string()))?;
Ok(Json(note))
}
#[derive(Deserialize)]
struct UpdateDbNoteRequest {
title: Option<String>,
content: Option<String>,
category: Option<String>,
section: Option<String>,
tags: Option<String>,
is_pinned: Option<bool>,
is_archived: Option<bool>,
color: Option<Option<String>>,
}
async fn handle_update_db_note(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
Json(payload): Json<UpdateDbNoteRequest>,
) -> Result<Json<crate::auth::DbNote>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let is_admin = claims.role == "admin";
let color_opt = payload.color.as_ref().map(|opt| opt.as_deref());
let note = state.auth.update_db_note(
id,
&claims.sub,
is_admin,
payload.title.as_deref(),
payload.content.as_deref(),
payload.category.as_deref(),
payload.section.as_deref(),
payload.tags.as_deref(),
payload.is_pinned,
payload.is_archived,
color_opt,
).map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to update note: {}", e)))?;
Ok(Json(note))
}
async fn handle_delete_db_note(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let is_admin = claims.role == "admin";
let attachments_to_delete = state.auth.delete_db_note(id, &claims.sub, is_admin)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to delete note: {}", e)))?;
for path_str in attachments_to_delete {
let _ = std::fs::remove_file(Path::new(&path_str));
}
Ok(Json(serde_json::json!({ "success": true })))
}
async fn handle_list_db_note_attachments(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<crate::auth::DbNoteAttachment>>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let is_admin = claims.role == "admin";
let note = state.auth.get_db_note(id, &claims.sub, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Note not found".to_string()))?;
Ok(Json(note.attachments))
}
async fn handle_upload_db_note_attachment(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
multipart: Multipart,
) -> Result<Json<crate::auth::DbNoteAttachment>, (StatusCode, String)> {
save_uploaded_note_attachment(&state, &headers, Some(id), multipart).await
}
#[derive(Deserialize)]
struct UploadAttachmentQuery {
note_id: Option<i64>,
}
async fn handle_upload_db_note_attachment_standalone(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<UploadAttachmentQuery>,
multipart: Multipart,
) -> Result<Json<crate::auth::DbNoteAttachment>, (StatusCode, String)> {
save_uploaded_note_attachment(&state, &headers, query.note_id, multipart).await
}
async fn save_uploaded_note_attachment(
state: &AppState,
headers: &HeaderMap,
note_id: Option<i64>,
mut multipart: Multipart,
) -> Result<Json<crate::auth::DbNoteAttachment>, (StatusCode, String)> {
use sha2::{Digest, Sha256};
let claims = extract_claims_or_local(state, headers)?;
let attachments_dir = crate::tools::notedog::get_notes_attachments_dir();
let _ = std::fs::create_dir_all(&attachments_dir);
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
let original_name = field.file_name().unwrap_or("attachment").to_string();
let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
let data = field.bytes().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let size_bytes = data.len() as i64;
let mut hasher = Sha256::new();
hasher.update(&data);
let sha256 = format!("{:x}", hasher.finalize());
let clean_filename: String = original_name.chars().map(|c| if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect();
let unique_name = format!("{}_{}", uuid::Uuid::new_v4().to_string().replace('-', "")[..12].to_string(), clean_filename);
let storage_path = attachments_dir.join(&unique_name);
std::fs::write(&storage_path, &data)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write attachment file: {}", e)))?;
let att = state.auth.create_db_note_attachment(
note_id,
&claims.sub,
&original_name,
&content_type,
size_bytes,
&storage_path.to_string_lossy(),
Some(&sha256),
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save attachment to DB: {}", e)))?;
return Ok(Json(att));
}
Err((StatusCode::BAD_REQUEST, "No file provided in multipart payload".to_string()))
}
async fn serve_attachment_binary(
state: &AppState,
headers: &HeaderMap,
attachment_id: i64,
) -> Result<Response, (StatusCode, String)> {
let claims = extract_claims_or_local(state, headers)?;
let is_admin = claims.role == "admin";
let att = state.auth.get_db_note_attachment(attachment_id, &claims.sub, is_admin)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Attachment not found".to_string()))?;
let p = Path::new(&att.storage_path);
if !p.exists() || !p.is_file() {
return Err((StatusCode::NOT_FOUND, "Attachment file missing on server".to_string()));
}
let bytes = std::fs::read(p)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file: {}", e)))?;
let content_type = if !att.mime_type.is_empty() && att.mime_type != "application/octet-stream" {
att.mime_type
} else {
mime_guess::from_path(&att.filename)
.first_or_octet_stream()
.to_string()
};
let filename_header = format!("inline; filename=\"{}\"", att.filename);
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, filename_header)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::from(bytes))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(response)
}
async fn handle_get_db_note_attachment_binary(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(attachment_id): AxumPath<i64>,
) -> Result<Response, (StatusCode, String)> {
serve_attachment_binary(&state, &headers, attachment_id).await
}
async fn handle_get_db_note_attachment_binary_with_name(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath((attachment_id, _filename)): AxumPath<(i64, String)>,
) -> Result<Response, (StatusCode, String)> {
serve_attachment_binary(&state, &headers, attachment_id).await
}
async fn handle_delete_db_note_attachment(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(attachment_id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let is_admin = claims.role == "admin";
let storage_path = state.auth.delete_db_note_attachment(attachment_id, &claims.sub, is_admin)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to delete attachment: {}", e)))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Attachment not found or access denied".to_string()))?;
let _ = std::fs::remove_file(Path::new(&storage_path));
Ok(Json(serde_json::json!({ "success": true })))
}
async fn handle_notes_migrate_export(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let is_admin = claims.role == "admin";
let notes = state.auth.list_db_notes(&claims.sub, is_admin, None, None, None, None, false)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let root = crate::tools::notedog::get_notedog_root_dir(None, Some(&state.config.notedog.notes_folder));
let mut exported_count = 0;
for note in notes {
let cat = if note.category.is_empty() { "General".to_string() } else { note.category };
let sec = if note.section.is_empty() { "Default".to_string() } else { note.section };
let target_dir = root.join(&cat).join(&sec);
let _ = std::fs::create_dir_all(&target_dir);
let sanitized_title: String = note.title.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { c } else { '_' })
.collect();
let base_name = if sanitized_title.trim().is_empty() { "Untitled_Note" } else { sanitized_title.trim() };
let file_path = target_dir.join(format!("{}.md", base_name));
if std::fs::write(&file_path, note.content.as_bytes()).is_ok() {
let _ = crate::tools::notedog::create_note_snapshot(&file_path, note.content.as_bytes());
exported_count += 1;
}
}
Ok(Json(serde_json::json!({
"success": true,
"exported_count": exported_count,
"target_directory": root.to_string_lossy()
})))
}
async fn handle_notes_migrate_import(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let info = crate::tools::notedog::scan_notedog_hierarchy(None, Some(&state.config.notedog.notes_folder));
let mut imported_count = 0;
for nb in info.notebooks {
for sec in nb.sections {
for note in sec.notes {
if !note.is_encrypted {
if let Ok(content) = std::fs::read_to_string(¬e.path) {
let title = note.name;
if state.auth.create_db_note(
&claims.sub,
&title,
&content,
&nb.name,
&sec.name,
"[]",
false,
false,
None,
).is_ok() {
imported_count += 1;
}
}
}
}
}
}
Ok(Json(serde_json::json!({
"success": true,
"imported_count": imported_count
})))
}
#[derive(Deserialize)]
struct TetraDogScoresQuery {
limit: Option<usize>,
mode: Option<String>,
user_only: Option<bool>,
}
async fn handle_tetradog_get_scores(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<TetraDogScoresQuery>,
) -> Result<Json<crate::tools::tetradog::TetraLeaderboardResponse>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let db_arc = state.auth.db();
let conn = db_arc.lock().map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database lock poisoned".to_string()))?;
let limit = query.limit.unwrap_or(50);
let mode = query.mode.as_deref();
let user_only = query.user_only.unwrap_or(false);
let resp = crate::tools::tetradog::get_leaderboard(&conn, &claims.sub, limit, mode, user_only)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(resp))
}
async fn handle_tetradog_submit_score(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::tetradog::SubmitScoreRequest>,
) -> Result<Json<crate::tools::tetradog::SubmitScoreResponse>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let db_arc = state.auth.db();
let conn = db_arc.lock().map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database lock poisoned".to_string()))?;
let resp = crate::tools::tetradog::submit_score(&conn, &claims.sub, payload)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(resp))
}
async fn handle_tetradog_clear_scores(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let db_arc = state.auth.db();
let conn = db_arc.lock().map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database lock poisoned".to_string()))?;
let user_filter = if claims.role == "admin" { None } else { Some(claims.sub.as_str()) };
let cleared = crate::tools::tetradog::clear_scores(&conn, user_filter)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(serde_json::json!({ "success": true, "cleared_count": cleared })))
}
#[derive(Deserialize)]
struct SyncRequest {
source: String,
destination: String,
options: crate::tools::sync::SyncOptions,
}
async fn handle_sync_analyze(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SyncRequest>,
) -> Result<Json<crate::tools::sync::SyncAnalysis>, (StatusCode, String)> {
let source = validate_path_access(&state, &headers, &payload.source, false)?;
let destination = validate_path_access(&state, &headers, &payload.destination, false)?;
crate::tools::sync::DirectorySyncEngine::analyze(&source, &destination, &payload.options)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Sync analysis failed: {}", e)))
}
async fn handle_sync_execute(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SyncRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let source = validate_path_access(&state, &headers, &payload.source, false)?;
let destination = validate_path_access(&state, &headers, &payload.destination, true)?;
crate::tools::sync::DirectorySyncEngine::execute_sync(
state.tasks.clone(),
&source,
&destination,
payload.options,
).await
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Sync execution failed: {}", e)))
}
async fn handle_list_backup_profiles(
State(state): State<AppState>,
) -> Json<Vec<crate::tools::sync::BackupProfile>> {
Json(state.backup.list_profiles())
}
async fn handle_save_backup_profile(
State(state): State<AppState>,
headers: HeaderMap,
Json(profile): Json<crate::tools::sync::BackupProfile>,
) -> Result<Json<crate::tools::sync::BackupProfile>, (StatusCode, String)> {
validate_path_access(&state, &headers, &profile.source_dir, false)?;
validate_path_access(&state, &headers, &profile.dest_dir, true)?;
state.backup.save_profile(profile)
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save backup profile: {}", e)))
}
async fn handle_delete_backup_profile(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
state.backup.delete_profile(&id)
.map(|_| Json(serde_json::json!({ "success": true })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete backup profile: {}", e)))
}
async fn handle_run_backup_profile(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mgr = state.backup.clone();
let tasks = state.tasks.clone();
tokio::spawn(async move {
if let Err(e) = mgr.execute_profile_job(&id, tasks).await {
tracing::error!("Manual backup profile execution error for '{}': {}", id, e);
}
});
Ok(Json(serde_json::json!({ "success": true, "message": "Backup job initiated in background" })))
}
async fn handle_toggle_backup_profile(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
state.backup.toggle_profile(&id)
.map(|new_enabled| Json(serde_json::json!({ "success": true, "enabled": new_enabled })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to toggle backup profile: {}", e)))
}
async fn handle_get_backup_history(
State(state): State<AppState>,
Query(query): Query<HashMap<String, String>>,
) -> Json<Vec<crate::tools::sync::BackupHistoryItem>> {
let profile_id = query.get("profile_id").map(|s| s.as_str());
let limit = query.get("limit").and_then(|l| l.parse::<usize>().ok()).unwrap_or(50);
Json(state.backup.list_history(profile_id, limit))
}
async fn handle_disk_usage(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
) -> Result<Json<crate::tools::disk_usage::DiskUsageReport>, (StatusCode, String)> {
let raw_path = query.get("path").ok_or((StatusCode::BAD_REQUEST, "Missing path param".to_string()))?;
let path = validate_path_access(&state, &headers, raw_path, false)?;
tokio::task::spawn_blocking(move || {
crate::tools::disk_usage::DiskUsageEngine::analyze(&path)
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Task join error: {}", e)))?
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Disk usage analysis failed: {}", e)))
}
async fn handle_get_disks(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::tools::disk_usage::DiskMountInfo>>, (StatusCode, String)> {
let _claims = extract_claims_or_local(&state, &headers)?;
let roots = state.config.storage.roots.clone();
let disks = tokio::task::spawn_blocking(move || {
crate::tools::disk_usage::get_system_disks(&roots)
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Disk enumeration failed: {}", e)))?;
Ok(Json(disks))
}
async fn handle_search(
Json(payload): Json<crate::tools::search::SearchRequest>,
) -> Result<Json<Vec<crate::tools::search::SearchResultItem>>, (StatusCode, String)> {
crate::tools::search::SearchEngine::search(payload)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Search failed: {}", e)))
}
async fn handle_duplicates_scan(
Json(payload): Json<crate::tools::duplicates::DuplicateScanRequest>,
) -> Json<crate::tools::duplicates::DuplicateScanResponse> {
Json(crate::tools::duplicates::scan_duplicates(payload))
}
async fn handle_duplicates_clean(
Json(payload): Json<crate::tools::duplicates::DuplicateCleanRequest>,
) -> Json<crate::tools::duplicates::DuplicateCleanResponse> {
Json(crate::tools::duplicates::clean_duplicates(payload))
}
#[derive(Deserialize)]
struct ReadMetadataQuery {
path: String,
}
async fn handle_metadata_read(
Query(query): Query<ReadMetadataQuery>,
) -> Result<Json<crate::tools::metadata::FileMetadataResponse>, (StatusCode, String)> {
crate::tools::metadata::read_file_metadata(&query.path)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, e))
}
async fn handle_metadata_update(
Json(payload): Json<crate::tools::metadata::UpdateMetadataRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
crate::tools::metadata::update_file_metadata(payload)
.map(|_| Json(serde_json::json!({ "success": true })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
}
async fn handle_metadata_batch(
Json(payload): Json<crate::tools::metadata::BatchMetadataRequest>,
) -> Json<crate::tools::metadata::BatchMetadataResponse> {
Json(crate::tools::metadata::batch_update_metadata(payload))
}
async fn handle_logviewer_tail(
Query(payload): Query<crate::tools::logviewer::LogTailRequest>,
) -> Result<Json<crate::tools::logviewer::LogTailResponse>, (StatusCode, String)> {
crate::tools::logviewer::tail_log_file(payload)
.map(Json)
.map_err(|e| (StatusCode::BAD_REQUEST, e))
}
#[derive(Deserialize)]
struct TrashQuery {
custom_trash_dir: Option<String>,
windows_native_ops: Option<bool>,
windows_native_file_ops: Option<bool>,
}
async fn handle_trash_summary(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<TrashQuery>,
) -> Result<Json<crate::tools::trash::TrashSummary>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let custom_trash = query.custom_trash_dir.as_deref().or(state.config.paranoid.custom_trash_dir.as_deref());
let use_native = query.windows_native_ops
.or(query.windows_native_file_ops)
.unwrap_or(state.config.paranoid.windows_native_file_ops);
match crate::tools::trash::TrashManager::get_trash_summary(custom_trash, Some(&claims.home_dir), use_native) {
Ok(summary) => Ok(Json(summary)),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get trash summary: {}", e))),
}
}
async fn handle_trash_items(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<TrashQuery>,
) -> Result<Json<Vec<crate::tools::trash::TrashItem>>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
let custom_trash = query.custom_trash_dir.as_deref().or(state.config.paranoid.custom_trash_dir.as_deref());
let use_native = query.windows_native_ops
.or(query.windows_native_file_ops)
.unwrap_or(state.config.paranoid.windows_native_file_ops);
match crate::tools::trash::TrashManager::list_trash_items(custom_trash, Some(&claims.home_dir), use_native) {
Ok(items) => Ok(Json(items)),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list trash items: {}", e))),
}
}
async fn handle_trash_restore(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::trash::TrashRestoreRequest>,
) -> Result<Json<crate::tools::trash::TrashActionResult>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role.eq_ignore_ascii_case("readonly") {
return Err((StatusCode::FORBIDDEN, "Read-only users cannot restore files".to_string()));
}
let custom_trash = payload.custom_trash_dir.as_deref().or(state.config.paranoid.custom_trash_dir.as_deref());
let use_native_ops = payload.windows_native_ops.unwrap_or(state.config.paranoid.windows_native_file_ops);
let res = crate::tools::trash::TrashManager::restore_items(payload.items, custom_trash, Some(&claims.home_dir), use_native_ops);
Ok(Json(res))
}
async fn handle_trash_empty(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::trash::TrashEmptyRequest>,
) -> Result<Json<crate::tools::trash::TrashActionResult>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role.eq_ignore_ascii_case("readonly") {
return Err((StatusCode::FORBIDDEN, "Read-only users cannot empty trash".to_string()));
}
let custom_trash = payload.custom_trash_dir.as_deref().or(state.config.paranoid.custom_trash_dir.as_deref());
let use_native_ops = payload.windows_native_ops.unwrap_or(state.config.paranoid.windows_native_file_ops);
let res = crate::tools::trash::TrashManager::empty_trash(custom_trash, Some(&claims.home_dir), use_native_ops);
Ok(Json(res))
}
async fn handle_trash_open_native(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let _claims = extract_claims_or_local(&state, &headers)?;
#[cfg(windows)]
{
let status = std::process::Command::new("explorer.exe")
.arg("shell:RecycleBinFolder")
.spawn();
match status {
Ok(_) => Ok(Json(serde_json::json!({ "success": true, "message": "Opened Windows Recycle Bin" }))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open Windows Recycle Bin: {}", e))),
}
}
#[cfg(not(windows))]
{
let status = open::that("trash:///");
match status {
Ok(_) => Ok(Json(serde_json::json!({ "success": true, "message": "Opened Trash" }))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open Trash: {}", e))),
}
}
}
async fn handle_trash_delete(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::trash::TrashDeleteRequest>,
) -> Result<Json<crate::tools::trash::TrashActionResult>, (StatusCode, String)> {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role.eq_ignore_ascii_case("readonly") {
return Err((StatusCode::FORBIDDEN, "Read-only users cannot delete items".to_string()));
}
let custom_trash = payload.custom_trash_dir.as_deref().or(state.config.paranoid.custom_trash_dir.as_deref());
let use_native_ops = payload.windows_native_ops.unwrap_or(state.config.paranoid.windows_native_file_ops);
let res = crate::tools::trash::TrashManager::delete_items(payload.items, custom_trash, Some(&claims.home_dir), use_native_ops);
Ok(Json(res))
}
async fn handle_run_action(
Json(payload): Json<crate::tools::actions::ActionExecutionRequest>,
) -> Result<Json<crate::tools::actions::ActionExecutionResult>, (StatusCode, String)> {
crate::tools::actions::ActionRunner::execute(payload).await
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Action execution error: {}", e)))
}
async fn handle_syncthing_status(
State(state): State<AppState>,
) -> Json<crate::tools::syncthing::SyncthingStatusResponse> {
let client = crate::tools::syncthing::SyncthingClient::new(state.config.syncthing.clone());
Json(client.get_status().await)
}
#[derive(Deserialize)]
struct SyncthingScanRequest {
folder_id: Option<String>,
subpath: Option<String>,
}
async fn handle_syncthing_scan(
State(state): State<AppState>,
Json(payload): Json<SyncthingScanRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let client = crate::tools::syncthing::SyncthingClient::new(state.config.syncthing.clone());
match client.trigger_scan(payload.folder_id.as_deref(), payload.subpath.as_deref()).await {
Ok(msg) => Ok(Json(serde_json::json!({ "success": true, "message": msg }))),
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
}
}
async fn handle_get_config(
State(state): State<AppState>,
) -> Json<AppConfig> {
Json((*state.config).clone())
}
#[derive(Serialize)]
struct SystemUsersGroups {
users: Vec<String>,
groups: Vec<String>,
}
async fn handle_get_system_users_groups() -> Json<SystemUsersGroups> {
let mut users = Vec::new();
let mut groups = Vec::new();
if let Ok(passwd) = fs::read_to_string("/etc/passwd") {
for line in passwd.lines() {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 3 {
users.push(parts[0].to_string());
}
}
}
if let Ok(grp) = fs::read_to_string("/etc/group") {
for line in grp.lines() {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 3 {
groups.push(parts[0].to_string());
}
}
}
users.sort();
groups.sort();
Json(SystemUsersGroups { users, groups })
}
#[derive(Serialize)]
struct ConfigFileResponse {
path: String,
content: String,
is_writable: bool,
}
#[derive(Deserialize)]
struct SaveConfigFileRequest {
content: String,
}
fn resolve_active_config_path() -> PathBuf {
crate::config::ConfigManager::active_config_path()
}
async fn handle_get_config_file(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<ConfigFileResponse>, (StatusCode, String)> {
if !state.config.server.standalone {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role != "admin" && claims.role != "Admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can view raw configuration".to_string()));
}
}
let path = resolve_active_config_path();
let is_writable = match fs::OpenOptions::new().write(true).open(&path) {
Ok(_) => true,
Err(_) => false,
};
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
toml::to_string_pretty(&*state.config).unwrap_or_default()
}
};
Ok(Json(ConfigFileResponse {
path: path.to_string_lossy().to_string(),
content,
is_writable,
}))
}
async fn handle_save_config_file(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SaveConfigFileRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if !state.config.server.standalone {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role != "admin" && claims.role != "Admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can edit configuration".to_string()));
}
}
let _: AppConfig = toml::from_str(&payload.content).map_err(|e| {
(StatusCode::BAD_REQUEST, format!("Invalid TOML syntax: {}", e))
})?;
let path = resolve_active_config_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
fs::write(&path, &payload.content).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write config file {}: {}", path.display(), e))
})?;
tracing::info!("Configuration file saved to {}", path.display());
Ok(Json(serde_json::json!({
"success": true,
"path": path.to_string_lossy().to_string(),
"message": "Configuration saved successfully. Server restart required for some changes."
})))
}
async fn handle_reload_config(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if !state.config.server.standalone {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role != "admin" && claims.role != "Admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can reload configuration".to_string()));
}
}
let new_cfg = ConfigManager::load_all();
Ok(Json(serde_json::json!({
"success": true,
"message": "Configuration reloaded into memory",
"config": new_cfg
})))
}
async fn handle_system_restart(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if !state.config.server.standalone {
let claims = extract_claims_or_local(&state, &headers)?;
if claims.role != "admin" && claims.role != "Admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can restart the server".to_string()));
}
}
tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
if let Ok(exe) = std::env::current_exe() {
let args: Vec<String> = std::env::args().skip(1).collect();
let _ = std::process::Command::new(exe).args(args).exec();
}
}
#[cfg(windows)]
{
if let Ok(exe) = std::env::current_exe() {
let args: Vec<String> = std::env::args().skip(1).collect();
let _ = std::process::Command::new(exe).args(args).spawn();
}
}
std::process::exit(0);
});
Ok(Json(serde_json::json!({
"success": true,
"message": "Brum server is restarting..."
})))
}
#[derive(Deserialize)]
struct GitPathQuery {
path: String,
}
#[derive(Deserialize)]
struct GitDiffQuery {
path: String,
file: Option<String>,
#[serde(default)]
staged: Option<bool>,
}
#[derive(Deserialize)]
struct GitLogQuery {
path: String,
#[serde(default)]
count: Option<usize>,
}
#[derive(Deserialize)]
struct GitFilesRequest {
path: String,
#[serde(default)]
files: Vec<String>,
}
#[derive(Deserialize)]
struct GitCommitRequest {
path: String,
message: String,
}
#[derive(Deserialize)]
struct GitPushRequest {
path: String,
remote: Option<String>,
branch: Option<String>,
}
#[derive(Deserialize)]
struct GitPullRequest {
path: String,
}
async fn handle_git_status(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<GitPathQuery>,
) -> Result<Json<crate::tools::git::GitStatusResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::get_git_status(Path::new(&query.path)).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_git_diff(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<GitDiffQuery>,
) -> Result<Json<crate::tools::git::GitDiffResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::get_git_diff(Path::new(&query.path), query.file.as_deref(), query.staged.unwrap_or(false)).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_git_stage(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<GitFilesRequest>,
) -> Result<Json<crate::tools::git::GitActionResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::git_stage(Path::new(&payload.path), &payload.files).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_git_unstage(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<GitFilesRequest>,
) -> Result<Json<crate::tools::git::GitActionResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::git_unstage(Path::new(&payload.path), &payload.files).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_git_commit(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<GitCommitRequest>,
) -> Result<Json<crate::tools::git::GitActionResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::git_commit(Path::new(&payload.path), &payload.message).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_git_push(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<GitPushRequest>,
) -> Result<Json<crate::tools::git::GitActionResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::git_push(Path::new(&payload.path), payload.remote.as_deref(), payload.branch.as_deref()).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_git_pull(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<GitPullRequest>,
) -> Result<Json<crate::tools::git::GitActionResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::git_pull(Path::new(&payload.path)).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_git_log(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<GitLogQuery>,
) -> Result<Json<Vec<crate::tools::git::GitCommitInfo>>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let res = crate::tools::git::get_git_log(Path::new(&query.path), query.count.unwrap_or(30)).await;
return Ok(Json(res));
}
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
async fn handle_split_file(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::splitter::SplitRequest>,
) -> Result<Json<crate::tools::splitter::SplitResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let source_path = Path::new(&payload.source_path);
let dest_dir = payload.dest_dir.as_ref().map(|p| Path::new(p));
let chunk_size = payload.chunk_size_mb.max(1) * 1024 * 1024;
let gen_chk = payload.generate_checksum.unwrap_or(true);
crate::tools::splitter::split_file_sync(source_path, dest_dir, chunk_size, gen_chk)
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
}
async fn handle_combine_files(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::splitter::CombineRequest>,
) -> Result<Json<crate::tools::splitter::CombineResponse>, (StatusCode, String)> {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let _ = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
let parts: Vec<std::path::PathBuf> = payload.parts.iter().map(std::path::PathBuf::from).collect();
let dest_path = Path::new(&payload.dest_path);
crate::tools::splitter::combine_files_sync(&parts, dest_path, payload.expected_sha256.as_deref())
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
} else {
Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()))
}
}
async fn handle_static_asset(headers: HeaderMap, uri: axum::http::Uri) -> Response {
let path = uri.path().trim_start_matches('/');
let file_path = if path.is_empty() { "index.html" } else { path };
let is_font_or_media = file_path.starts_with("assets/fonts/")
|| file_path.starts_with("assets/vendor/")
|| file_path.ends_with(".woff2")
|| file_path.ends_with(".woff")
|| file_path.ends_with(".ttf")
|| file_path.ends_with(".svg")
|| file_path.ends_with(".png")
|| file_path.ends_with(".webp")
|| file_path.ends_with(".ico");
let cache_control = if is_font_or_media {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
let local_file_path = Path::new("frontend").join(file_path);
if local_file_path.is_file() {
if let Ok(bytes) = fs::read(&local_file_path) {
use sha2::{Digest, Sha256};
let sha = Sha256::digest(&bytes);
let etag = format!("\"{}\"", hex::encode(sha));
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
if let Ok(val) = if_none_match.to_str() {
if val.trim() == etag {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, cache_control)
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::NOT_MODIFIED.into_response());
}
}
}
let mime = mime_guess::from_path(&local_file_path).first_or_octet_stream().to_string();
let data_len = bytes.len();
return Response::builder()
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, data_len.to_string())
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, cache_control)
.body(Body::from(bytes))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Asset load error").into_response());
}
}
match Asset::get(file_path) {
Some(content) => {
let etag = format!("\"{}\"", hex::encode(content.metadata.sha256_hash()));
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
if let Ok(val) = if_none_match.to_str() {
if val.trim() == etag {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, cache_control)
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::NOT_MODIFIED.into_response());
}
}
}
let mime = mime_guess::from_path(file_path).first_or_octet_stream().to_string();
let data_len = content.data.len();
Response::builder()
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, data_len.to_string())
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, cache_control)
.body(Body::from(content.data))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Asset load error").into_response())
}
None => {
let local_index = Path::new("frontend").join("index.html");
if local_index.is_file() {
if let Ok(bytes) = fs::read(&local_index) {
use sha2::{Digest, Sha256};
let sha = Sha256::digest(&bytes);
let etag = format!("\"{}\"", hex::encode(sha));
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
if let Ok(val) = if_none_match.to_str() {
if val.trim() == etag {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::NOT_MODIFIED.into_response());
}
}
}
let data_len = bytes.len();
return Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CONTENT_LENGTH, data_len.to_string())
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(bytes))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Index load error").into_response());
}
}
if let Some(index) = Asset::get("index.html") {
let etag = format!("\"{}\"", hex::encode(index.metadata.sha256_hash()));
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
if let Ok(val) = if_none_match.to_str() {
if val.trim() == etag {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::NOT_MODIFIED.into_response());
}
}
}
let data_len = index.data.len();
Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CONTENT_LENGTH, data_len.to_string())
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(index.data))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Index load error").into_response())
} else {
(StatusCode::NOT_FOUND, "Resource not found").into_response()
}
}
}
}
#[derive(Deserialize)]
struct PdfInfoQuery {
path: String,
}
async fn handle_pdf_info(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<PdfInfoQuery>,
) -> Result<Json<crate::tools::pdf::PdfInfo>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &query.path, false)?;
let local_path = crate::vfs::local::LocalFs::resolve_local_path(&valid_path);
crate::tools::pdf::PdfEngine::get_info(&local_path)
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
}
async fn handle_pdf_merge(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::pdf::MergeRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut local_sources = Vec::new();
for src in &payload.sources {
let valid_src = validate_path_access(&state, &headers, src, false)?;
local_sources.push(crate::vfs::local::LocalFs::resolve_local_path(&valid_src));
}
let valid_dst = validate_path_access(&state, &headers, &payload.destination, true)?;
let local_dst = crate::vfs::local::LocalFs::resolve_local_path(&valid_dst);
crate::tools::pdf::PdfEngine::merge(&local_sources, &local_dst, payload.add_bookmarks.unwrap_or(true))
.map(|_| Json(serde_json::json!({ "success": true, "destination": valid_dst })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
}
async fn handle_pdf_split(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::pdf::SplitRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_src = validate_path_access(&state, &headers, &payload.source, false)?;
let local_src = crate::vfs::local::LocalFs::resolve_local_path(&valid_src);
let valid_dst_dir = validate_path_access(&state, &headers, &payload.destination_dir, true)?;
let local_dst_dir = crate::vfs::local::LocalFs::resolve_local_path(&valid_dst_dir);
crate::tools::pdf::PdfEngine::split(
&local_src,
&local_dst_dir,
&payload.split_mode,
payload.page_ranges.as_deref(),
payload.chunk_size,
payload.output_prefix.as_deref(),
)
.map(|files| {
let files_str: Vec<String> = files.iter().map(|p| p.to_string_lossy().to_string()).collect();
Json(serde_json::json!({ "success": true, "files": files_str }))
})
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
}
async fn handle_pdf_reorder(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<crate::tools::pdf::PageReorderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_src = validate_path_access(&state, &headers, &payload.source, false)?;
let local_src = crate::vfs::local::LocalFs::resolve_local_path(&valid_src);
let valid_dst = validate_path_access(&state, &headers, &payload.destination, true)?;
let local_dst = crate::vfs::local::LocalFs::resolve_local_path(&valid_dst);
crate::tools::pdf::PdfEngine::reorder_and_rotate(&local_src, &payload.pages, &local_dst)
.map(|_| Json(serde_json::json!({ "success": true, "destination": valid_dst })))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
}
#[derive(Serialize, Deserialize)]
struct AutostartStatus {
enabled: bool,
platform: String,
target_path: Option<String>,
}
#[derive(Deserialize)]
struct SetAutostartRequest {
enabled: bool,
minimized: Option<bool>,
}
async fn handle_get_autostart() -> Json<AutostartStatus> {
#[cfg(target_os = "windows")]
{
let output = std::process::Command::new("reg")
.args(["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "Brum"])
.output();
let enabled = output.map_or(false, |o| o.status.success());
Json(AutostartStatus {
enabled,
platform: "windows".to_string(),
target_path: std::env::current_exe().ok().map(|p| p.to_string_lossy().to_string()),
})
}
#[cfg(not(target_os = "windows"))]
{
let autostart_file = dirs::config_dir()
.map(|c| c.join("autostart/brum.desktop"));
let enabled = autostart_file.as_ref().map_or(false, |p| p.exists());
Json(AutostartStatus {
enabled,
platform: std::env::consts::OS.to_string(),
target_path: autostart_file.map(|p| p.to_string_lossy().to_string()),
})
}
}
async fn handle_set_autostart(
Json(payload): Json<SetAutostartRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let exe_path = std::env::current_exe().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let flags = if payload.minimized.unwrap_or(false) { " --minimized" } else { "" };
let exec_cmd = format!("\"{}\"{}", exe_path.display(), flags);
#[cfg(target_os = "windows")]
{
if payload.enabled {
let status = std::process::Command::new("reg")
.args(["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "Brum", "/t", "REG_SZ", "/d", &exec_cmd, "/f"])
.status()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !status.success() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to set Windows autostart registry key".to_string()));
}
} else {
let _ = std::process::Command::new("reg")
.args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "Brum", "/f"])
.status();
let _ = std::process::Command::new("reg")
.args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "CommanderDog", "/f"])
.status();
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(config_dir) = dirs::config_dir() {
let auto_dir = config_dir.join("autostart");
let desktop_path = auto_dir.join("brum.desktop");
let legacy_path = auto_dir.join("commanderdog.desktop");
if payload.enabled {
let _ = std::fs::create_dir_all(&auto_dir);
let content = format!(
"[Desktop Entry]\nType=Application\nName=Brum\nComment=Multi-Pane Web Environment (File Commander/Manager)\nExec={}\nIcon=brum\nTerminal=false\nCategories=Utility;FileManager;\n",
exec_cmd
);
std::fs::write(&desktop_path, content).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if legacy_path.exists() {
let _ = std::fs::remove_file(legacy_path);
}
} else {
if desktop_path.exists() {
let _ = std::fs::remove_file(&desktop_path);
}
if legacy_path.exists() {
let _ = std::fs::remove_file(&legacy_path);
}
}
}
}
Ok(Json(serde_json::json!({ "success": true, "enabled": payload.enabled })))
}
#[derive(Deserialize)]
struct OpenWithRequest {
file_path: String,
command: Option<String>,
}
async fn handle_open_with(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<OpenWithRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &payload.file_path, false)?;
let local_path = crate::vfs::local::LocalFs::resolve_local_path(&valid_path);
if !local_path.exists() {
return Err((StatusCode::NOT_FOUND, format!("Target file does not exist: {}", local_path.display())));
}
let path_str = local_path.to_string_lossy().to_string();
let dir_str = if local_path.is_dir() {
path_str.clone()
} else {
local_path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| path_str.clone())
};
if let Some(cmd) = payload.command {
if !cmd.trim().is_empty() {
let replaced = cmd
.replace("%1", &format!("\"{}\"", path_str))
.replace("{file}", &format!("\"{}\"", path_str))
.replace("{dir}", &format!("\"{}\"", dir_str));
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/C", &replaced])
.current_dir(&dir_str)
.spawn()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to spawn process: {}", e)))?;
}
#[cfg(not(target_os = "windows"))]
{
let _ = std::process::Command::new("sh")
.args(["-c", &replaced])
.current_dir(&dir_str)
.spawn()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to spawn process: {}", e)))?;
}
return Ok(Json(serde_json::json!({ "success": true, "command": replaced, "working_dir": dir_str })));
}
}
open::that_detached(&local_path).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open file with default system handler: {}", e))
})?;
Ok(Json(serde_json::json!({ "success": true, "path": path_str })))
}
#[derive(Deserialize)]
struct RunCustomActionRequest {
command: String,
target_path: String,
selection: Option<Vec<String>>,
target_pane_path: Option<String>,
}
async fn handle_run_custom_action(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<RunCustomActionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let valid_path = validate_path_access(&state, &headers, &payload.target_path, false)?;
let local_path = crate::vfs::local::LocalFs::resolve_local_path(&valid_path);
let target_str = local_path.to_string_lossy().to_string();
let dir_str = if local_path.is_dir() {
target_str.clone()
} else {
local_path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| target_str.clone())
};
let selection_joined = payload.selection
.as_ref()
.map(|list| list.iter().map(|s| format!("\"{}\"", s)).collect::<Vec<_>>().join(" "))
.unwrap_or_else(|| format!("\"{}\"", target_str));
let target_pane_str = payload.target_pane_path.unwrap_or_default();
let exec_cmd = payload.command
.replace("{file}", &format!("\"{}\"", target_str))
.replace("{dir}", &format!("\"{}\"", dir_str))
.replace("{selection}", &selection_joined)
.replace("{target_pane}", &format!("\"{}\"", target_pane_str))
.replace("%1", &format!("\"{}\"", target_str));
#[cfg(target_os = "windows")]
let child = std::process::Command::new("cmd")
.args(["/C", &exec_cmd])
.current_dir(&dir_str)
.spawn();
#[cfg(not(target_os = "windows"))]
let child = std::process::Command::new("sh")
.args(["-c", &exec_cmd])
.current_dir(&dir_str)
.spawn();
child.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to run action: {}", e)))?;
Ok(Json(serde_json::json!({
"success": true,
"executed": exec_cmd,
"working_dir": dir_str
})))
}
#[derive(Serialize)]
struct ListPluginsResponse {
plugins: Vec<crate::plugins::PluginInfo>,
can_install: bool,
allow_user_installs: bool,
}
async fn handle_list_plugins(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<ListPluginsResponse>, (StatusCode, String)> {
let mut is_admin = false;
let mut can_install = false;
let mut allowed_plugins_json = "[\"*\"]".to_string();
let mut blocked_plugins_json = "[]".to_string();
if state.config.server.standalone || !state.config.server.enable_auth {
is_admin = true;
can_install = true;
} else {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
if let Ok(claims) = state.auth.verify_token(token_str) {
if claims.role == "admin" {
is_admin = true;
can_install = true;
} else if let Ok(Some(user)) = state.auth.get_user_by_username(&claims.sub) {
can_install = user.can_install_plugins || state.config.plugins.allow_user_installs;
allowed_plugins_json = user.allowed_plugins;
blocked_plugins_json = user.blocked_plugins;
}
}
}
}
let all_plugins = state.plugins.scan_plugins()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to scan plugins: {}", e)))?;
let filtered = all_plugins.into_iter()
.filter(|p| state.plugins.is_allowed_for_user(&p.id, is_admin, &allowed_plugins_json, &blocked_plugins_json))
.collect();
Ok(Json(ListPluginsResponse {
plugins: filtered,
can_install,
allow_user_installs: state.config.plugins.allow_user_installs,
}))
}
async fn handle_install_plugin(
State(state): State<AppState>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<crate::plugins::PluginInfo>, (StatusCode, String)> {
let mut is_admin = false;
let mut can_install = false;
let mut username = "local".to_string();
if state.config.server.standalone || !state.config.server.enable_auth {
is_admin = true;
can_install = true;
username = "admin".to_string();
} else {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
if let Ok(claims) = state.auth.verify_token(token_str) {
username = claims.sub.clone();
if claims.role == "admin" {
is_admin = true;
can_install = true;
} else if let Ok(Some(user)) = state.auth.get_user_by_username(&claims.sub) {
can_install = user.can_install_plugins || state.config.plugins.allow_user_installs;
}
}
}
}
if !can_install && !is_admin {
return Err((StatusCode::FORBIDDEN, "Permission denied: you do not have permission to install plugins".to_string()));
}
let raw_bytes = body.to_vec();
if raw_bytes.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Empty payload received for plugin installation".to_string()));
}
let zip_bytes = if raw_bytes.starts_with(&[0x50, 0x4B]) {
raw_bytes
} else if let Some(pos) = raw_bytes.windows(4).position(|w| w == [0x50, 0x4B, 0x03, 0x04]) {
if let Some(eocd_pos) = raw_bytes.windows(4).rposition(|w| w == [0x50, 0x4B, 0x05, 0x06]) {
if eocd_pos + 22 <= raw_bytes.len() {
let comment_len = u16::from_le_bytes([raw_bytes[eocd_pos + 20], raw_bytes[eocd_pos + 21]]) as usize;
let end_pos = (eocd_pos + 22 + comment_len).min(raw_bytes.len());
raw_bytes[pos..end_pos].to_vec()
} else {
raw_bytes[pos..].to_vec()
}
} else {
raw_bytes[pos..].to_vec()
}
} else {
return Err((StatusCode::BAD_REQUEST, "Invalid package format: file is not a valid .grr (ZIP) archive".to_string()));
};
let installed = state.plugins.install_grr(&zip_bytes, &username, is_admin)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Plugin installation failed: {}", e)))?;
Ok(Json(installed))
}
#[derive(Deserialize)]
struct TogglePluginRequest {
enabled: bool,
}
async fn handle_toggle_plugin(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<String>,
Json(payload): Json<TogglePluginRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if !state.config.server.standalone && state.config.server.enable_auth {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only administrators can enable or disable plugins globally".to_string()));
}
} else {
return Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()));
}
}
let result = state.plugins.toggle_plugin(&id, payload.enabled)
.map_err(|e| (StatusCode::NOT_FOUND, e))?;
Ok(Json(serde_json::json!({ "success": true, "enabled": result })))
}
async fn handle_delete_plugin(
State(state): State<AppState>,
headers: HeaderMap,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut is_admin = false;
let username;
if state.config.server.standalone || !state.config.server.enable_auth {
is_admin = true;
username = "admin".to_string();
} else {
let auth_header = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
if let Some(token_str) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
let claims = state.auth.verify_token(token_str).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
username = claims.sub.clone();
if claims.role == "admin" {
is_admin = true;
}
} else {
return Err((StatusCode::UNAUTHORIZED, "Missing authorization token".to_string()));
}
}
state.plugins.uninstall_plugin(&id, &username, is_admin)
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
Ok(Json(serde_json::json!({ "success": true, "uninstalled": id })))
}
async fn handle_plugin_asset(
State(state): State<AppState>,
AxumPath((id, subpath)): AxumPath<(String, String)>,
) -> Result<Response, (StatusCode, String)> {
let (bytes, mime) = state.plugins.get_asset(&id, &subpath)
.map_err(|e| (StatusCode::NOT_FOUND, e))?;
let response = Response::builder()
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate, max-age=0")
.header(header::PRAGMA, "no-cache")
.header(header::EXPIRES, "0")
.header(header::CONTENT_SECURITY_POLICY, "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:;")
.body(Body::from(bytes))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_normalize_path_resolution() {
assert_eq!(normalize_path(Path::new("/home/user/../user/docs")), Path::new("/home/user/docs"));
assert_eq!(normalize_path(Path::new("/var/log/../../etc/passwd")), Path::new("/etc/passwd"));
assert_eq!(normalize_path(Path::new("/")), Path::new("/"));
#[cfg(windows)]
{
assert_eq!(
normalize_path(Path::new(r"\\?\C:\Users\Bolt\Documents")),
Path::new(r"C:\Users\Bolt\Documents")
);
assert_eq!(
normalize_path(Path::new(r"\\?\UNC\server\share\data")),
Path::new(r"\\server\share\data")
);
}
#[cfg(not(windows))]
{
assert_eq!(
normalize_path(Path::new(r"\\?\C:\Users\Bolt\Documents")),
Path::new("C:/Users/Bolt/Documents")
);
assert_eq!(
normalize_path(Path::new(r"\\?\UNC\server\share\data")),
Path::new("//server/share/data")
);
}
}
#[test]
fn test_path_starts_with_case_insensitive() {
assert!(path_starts_with_case_insensitive(
Path::new("C:/Users/Bolt/Documents/file.txt"),
Path::new("c:/users/bolt")
));
assert!(path_starts_with_case_insensitive(
Path::new(r"\\?\C:\Users\Bolt\Documents"),
Path::new(r"c:\users\bolt")
));
assert!(path_starts_with_case_insensitive(
Path::new("/home/bolt/projects"),
Path::new("/home/bolt")
));
assert!(!path_starts_with_case_insensitive(
Path::new("/home/other/projects"),
Path::new("/home/bolt")
));
}
#[tokio::test]
async fn test_handle_static_asset_etags() {
let headers = HeaderMap::new();
let uri: axum::http::Uri = "/index.html".parse().unwrap();
let res = handle_static_asset(headers, uri).await;
assert_eq!(res.status(), StatusCode::OK);
assert!(res.headers().get(header::ETAG).is_some());
let etag = res.headers().get(header::ETAG).unwrap().to_str().unwrap().to_string();
let mut conditional_headers = HeaderMap::new();
conditional_headers.insert(header::IF_NONE_MATCH, etag.parse().unwrap());
let cond_uri: axum::http::Uri = "/index.html".parse().unwrap();
let cond_res = handle_static_asset(conditional_headers, cond_uri).await;
assert_eq!(cond_res.status(), StatusCode::NOT_MODIFIED);
}
#[test]
fn test_http_range_parsing() {
let total = 10000;
let r1 = HttpRange::parse("bytes=0-499", total).unwrap().unwrap();
assert_eq!(r1, HttpRange { start: 0, end: 499 });
let r2 = HttpRange::parse("bytes=1000-", total).unwrap().unwrap();
assert_eq!(r2, HttpRange { start: 1000, end: 9999 });
let r3 = HttpRange::parse("bytes=-500", total).unwrap().unwrap();
assert_eq!(r3, HttpRange { start: 9500, end: 9999 });
let r4 = HttpRange::parse("bytes=9000-20000", total).unwrap().unwrap();
assert_eq!(r4, HttpRange { start: 9000, end: 9999 });
let r5 = HttpRange::parse("bytes=10000-", total).unwrap();
assert!(r5.is_err());
let r6 = HttpRange::parse("bytes=500-200", total).unwrap();
assert!(r6.is_err());
assert!(HttpRange::parse("gzip, deflate", total).is_none());
}
#[tokio::test]
async fn test_handle_health_endpoint() {
use crate::config::AppConfig;
use std::sync::Arc;
let mut config = AppConfig::default();
let temp = tempfile::tempdir().unwrap();
let db_path = temp.path().join("health_test.db");
config.server.database_path = db_path.to_string_lossy().to_string();
let auth = crate::auth::AuthManager::new(
&config.server.database_path,
&config.server.jwt_secret,
config.server.session_duration_hours,
&config.auth.mode,
&config.auth.pam_service,
&config.auth.default_admin_user,
&config.auth.default_admin_pass,
).unwrap();
let db = auth.db();
let auth_arc = Arc::new(auth);
let task_mgr = Arc::new(crate::tools::tasks::TaskManager::new());
let tag_mgr = Arc::new(crate::tools::tags::TagManager::new(db.clone()).unwrap());
let vault_mgr = Arc::new(crate::vfs::vault::VaultManager::new());
let backup_mgr = Arc::new(crate::tools::sync::BackupManager::new(db).unwrap());
let plugin_mgr = Arc::new(crate::plugins::PluginManager::new(
std::path::PathBuf::from("/tmp/system_plugins"),
std::path::PathBuf::from("/tmp/user_plugins"),
false,
"allow_all".to_string(),
vec!["*".to_string()],
vec![],
));
let oidc_mgr = Arc::new(crate::auth::oidc::OidcManager::new(config.auth.oidc.clone(), auth_arc.clone()));
let state = AppState {
config: Arc::new(config),
auth: auth_arc,
oidc: oidc_mgr,
tasks: task_mgr,
tags: tag_mgr,
vaults: vault_mgr,
backup: backup_mgr,
plugins: plugin_mgr,
};
let res = handle_health(State(state)).await;
let val = res.0;
assert_eq!(val["status"], "ok");
assert_eq!(val["version"], env!("CARGO_PKG_VERSION"));
assert!(val["hostname"].is_string());
assert!(val["os"].is_string());
assert!(val["arch"].is_string());
assert!(val["time"].is_string());
}
#[tokio::test]
async fn test_handle_install_and_list_plugins() {
use crate::config::AppConfig;
use std::sync::Arc;
let temp = tempfile::tempdir().unwrap();
let db_path = temp.path().join("test_auth.db");
let mut config = AppConfig::default();
config.server.database_path = db_path.to_str().unwrap().to_string();
config.server.standalone = true;
let auth = crate::auth::AuthManager::new(
&config.server.database_path,
&config.server.jwt_secret,
config.server.session_duration_hours,
&config.auth.mode,
&config.auth.pam_service,
&config.auth.default_admin_user,
&config.auth.default_admin_pass,
).unwrap();
let db = auth.db();
let auth_arc = Arc::new(auth);
let task_mgr = Arc::new(crate::tools::tasks::TaskManager::new());
let tag_mgr = Arc::new(crate::tools::tags::TagManager::new(db.clone()).unwrap());
let vault_mgr = Arc::new(crate::vfs::vault::VaultManager::new());
let backup_mgr = Arc::new(crate::tools::sync::BackupManager::new(db).unwrap());
let plugin_mgr = Arc::new(crate::plugins::PluginManager::new(
temp.path().join("system_plugins"),
temp.path().join("user_plugins"),
true,
"allow_all".to_string(),
vec!["*".to_string()],
vec![],
));
let oidc_mgr = Arc::new(crate::auth::oidc::OidcManager::new(config.auth.oidc.clone(), auth_arc.clone()));
let state = AppState {
config: Arc::new(config),
auth: auth_arc,
oidc: oidc_mgr,
tasks: task_mgr,
tags: tag_mgr,
vaults: vault_mgr,
backup: backup_mgr,
plugins: plugin_mgr,
};
let src_dir = temp.path().join("test_src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(src_dir.join("plugin.toml"), r#"
[plugin]
id = "test-chewtoy"
name = "Test ChewToy"
version = "1.0.0"
description = "Test chewtoy package"
"#).unwrap();
std::fs::write(src_dir.join("index.html"), "<h1>Test ChewToy</h1>").unwrap();
let grr_path = temp.path().join("test-chewtoy.grr");
crate::plugins::PluginManager::pack_grr(&src_dir, &grr_path).unwrap();
let grr_bytes = std::fs::read(&grr_path).unwrap();
let headers = HeaderMap::new();
let install_res = handle_install_plugin(
State(state.clone()),
headers.clone(),
axum::body::Bytes::from(grr_bytes),
).await.unwrap();
assert_eq!(install_res.0.id, "test-chewtoy");
assert_eq!(install_res.0.name, "Test ChewToy");
let list_res = handle_list_plugins(
State(state.clone()),
headers.clone(),
).await.unwrap();
assert_eq!(list_res.0.plugins.len(), 1);
assert_eq!(list_res.0.plugins[0].id, "test-chewtoy");
assert!(list_res.0.can_install);
let asset_res = handle_plugin_asset(
State(state.clone()),
AxumPath(("test-chewtoy".to_string(), "index.html".to_string())),
).await.unwrap();
assert_eq!(asset_res.status(), StatusCode::OK);
}
}