use std::{
collections::{BTreeSet, HashMap},
io::{Cursor, Write},
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
};
use ::actix_web::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, HeaderValue};
use ::actix_web::{
HttpResponse, Scope,
http::{StatusCode, header::CONTENT_TYPE},
web::{self, Data, Form, Json, Path as ActixPath, Query},
};
use serde::{Deserialize, Serialize};
use crate::{
FileServerConfig, FileService, FileServiceError, PreviewHandler, PreviewRegistry,
PreviewRequest,
templates::{
BrowseTemplate, DownloadProgressTemplate, HtmxJs, IndexTemplate, MountUnavailableTemplate,
MoveProgressTemplate, PanelTemplate, PreviewTemplate, SearchTemplate, StylesCss,
},
};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
#[derive(Clone)]
struct AppState {
config: Arc<FileServerConfig>,
preview_registry: Arc<PreviewRegistry>,
archive_jobs: Arc<Mutex<HashMap<String, ArchiveJobState>>>,
move_jobs: Arc<Mutex<HashMap<String, MoveJobState>>>,
next_archive_job_id: Arc<AtomicU64>,
next_move_job_id: Arc<AtomicU64>,
}
#[derive(Clone)]
pub struct FileServer {
state: AppState,
}
impl FileServer {
pub fn new(config: FileServerConfig) -> Self {
Self {
state: AppState {
config: Arc::new(config),
preview_registry: Arc::new(PreviewRegistry::new()),
archive_jobs: Arc::new(Mutex::new(HashMap::new())),
move_jobs: Arc::new(Mutex::new(HashMap::new())),
next_archive_job_id: Arc::new(AtomicU64::new(1)),
next_move_job_id: Arc::new(AtomicU64::new(1)),
},
}
}
pub fn with_preview_handler(mut self, handler: impl PreviewHandler + 'static) -> Self {
self.state.preview_registry = Arc::new(
self.state
.preview_registry
.as_ref()
.clone()
.with_handler(handler),
);
self
}
pub fn config(&self) -> &FileServerConfig {
self.state.config.as_ref()
}
pub fn scope(&self) -> Scope {
let scope_path = if self.config().mount_path() == "/" {
""
} else {
self.config().mount_path()
};
web::scope(scope_path)
.app_data(Data::new(self.state.clone()))
.route("", web::get().to(index))
.route("/", web::get().to(index))
.route("/api/mounts", web::get().to(api_mounts))
.route("/api/entries", web::get().to(api_entries))
.route("/api/search", web::get().to(api_search))
.route("/api/storage", web::get().to(api_storage))
.route("/api/delete-selected", web::post().to(api_delete_selected))
.route(
"/api/download-selected",
web::post().to(api_download_selected),
)
.route("/api/move-selected", web::post().to(api_move_selected))
.route("/browse", web::get().to(browse))
.route("/delete-selected", web::post().to(delete_selected))
.route("/download-selected", web::get().to(download_selected))
.route(
"/download-selected/jobs/{job_id}/status",
web::get().to(download_job_status),
)
.route(
"/download-selected/jobs/{job_id}/file",
web::get().to(download_job_file),
)
.route("/move-selected", web::get().to(move_selected))
.route(
"/move-selected/jobs/{job_id}/status",
web::get().to(move_job_status),
)
.route("/search", web::get().to(search))
.route("/preview", web::get().to(preview))
.route("/raw/{path:.*}", web::get().to(raw_file))
.route("/static/htmx.min.js", web::get().to(htmx_js))
.route("/static/styles.css", web::get().to(styles_css))
}
}
#[derive(Debug, Default, Deserialize)]
struct IndexParams {
mount: Option<String>,
path: Option<String>,
q: Option<String>,
sort: Option<String>,
desc: Option<String>,
}
async fn index(state: Data<AppState>, Query(params): Query<IndexParams>) -> HttpResponse {
let routes = state.config.route_paths();
let table_sort =
parse_table_sort(params.sort.as_deref(), params.desc.as_deref()).unwrap_or(TableSort {
key: None,
desc: false,
});
let initial_content_url = table_state_url(
&routes,
params.mount.as_deref().unwrap_or_default(),
params.path.as_deref().unwrap_or_default(),
params.q.as_deref().unwrap_or_default(),
"",
&[],
table_sort,
&[],
);
html_response(
IndexTemplate {
branding: state.config.branding(),
theme: state.config.theme(),
routes: &routes,
initial_content_url: &initial_content_url,
}
.to_string(),
)
}
async fn api_mounts(state: Data<AppState>) -> HttpResponse {
let routes = state.config.route_paths();
HttpResponse::Ok().json(ApiMountListPayload {
mounts: build_api_mounts(state.config.as_ref(), &routes),
})
}
async fn api_entries(
state: Data<AppState>,
Query(params): Query<ApiEntriesParams>,
) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
let service = file_service_response(mount)?;
let table_sort = parse_table_sort(params.sort.as_deref(), params.desc.as_deref())?;
let mut listing = service.list_dir(params.path.as_deref().unwrap_or_default())?;
sort_entries(&mut listing.entries, table_sort);
let storage_usage = service.storage_usage()?;
Ok(ApiDirectoryListingPayload {
mount_id: mount.id.clone(),
mount_name: mount.name.clone(),
current_path: listing.current_path.clone(),
current_path_display: listing.current_path_display.clone(),
breadcrumbs: listing
.breadcrumbs
.iter()
.map(|crumb| ApiBreadcrumbPayload {
name: crumb.name.clone(),
path: crumb.path.clone(),
})
.collect(),
total_entries: listing.total_entries,
entries: build_api_file_entries(&listing.entries, &routes, &mount.id),
storage_usage: api_storage_usage(&storage_usage),
})
}) {
Ok(response) => HttpResponse::Ok().json(response),
Err(error) => api_error_response(&error),
}
}
async fn api_search(state: Data<AppState>, Query(params): Query<ApiSearchParams>) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
let service = file_service_response(mount)?;
let table_sort = parse_table_sort(params.sort.as_deref(), params.desc.as_deref())?;
let mut results = service.search(params.q.as_deref().unwrap_or_default())?;
sort_entries(&mut results.entries, table_sort);
let storage_usage = service.storage_usage()?;
Ok(ApiSearchPayload {
mount_id: mount.id.clone(),
mount_name: mount.name.clone(),
query: results.query.clone(),
total_matches: results.total_matches,
is_truncated: results.is_truncated,
entries: build_api_file_entries(&results.entries, &routes, &mount.id),
storage_usage: api_storage_usage(&storage_usage),
})
}) {
Ok(response) => HttpResponse::Ok().json(response),
Err(error) => api_error_response(&error),
}
}
async fn api_storage(
state: Data<AppState>,
Query(params): Query<ApiStorageParams>,
) -> HttpResponse {
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
let service = file_service_response(mount)?;
Ok(api_storage_usage(&service.storage_usage()?))
}) {
Ok(response) => HttpResponse::Ok().json(response),
Err(error) => api_error_response(&error),
}
}
async fn api_delete_selected(
state: Data<AppState>,
Json(request): Json<ApiBatchRequest>,
) -> HttpResponse {
match selected_mount(state.config.as_ref(), Some(request.mount.as_str())).and_then(|mount| {
let service = file_service_response(mount)?;
Ok(ApiDeletePayload {
deleted_count: service.delete_entries(&request.paths)?.deleted_count,
})
}) {
Ok(response) => HttpResponse::Ok().json(response),
Err(error) => api_error_response(&error),
}
}
async fn api_download_selected(
state: Data<AppState>,
Json(request): Json<ApiDownloadRequest>,
) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), Some(request.mount.as_str())).and_then(|mount| {
let service = file_service_response(mount)?;
let assets = service.collect_download_assets(&request.paths)?;
let download_mode =
parse_download_mode(request.mode.as_deref(), request.structure.as_deref())?;
match download_mode {
DownloadMode::Individual => Ok(ApiDownloadPayload {
mode: "individual",
total_files: assets.len(),
files: assets
.iter()
.map(|asset| ApiDownloadFilePayload {
relative_path: asset.relative_path.clone(),
download_url: routes
.raw_file_url_for_mount(&mount.id, &asset.relative_path),
})
.collect(),
job_id: None,
status_url: None,
file_url: None,
}),
DownloadMode::ArchivePreserve | DownloadMode::ArchiveFlat => {
let layout = match download_mode {
DownloadMode::ArchivePreserve => ArchiveLayout::PreservePaths,
DownloadMode::ArchiveFlat => ArchiveLayout::Flatten,
DownloadMode::Individual => unreachable!(),
};
let job_id = start_archive_job(&state, assets, layout);
Ok(ApiDownloadPayload {
mode: match layout {
ArchiveLayout::PreservePaths => "archive-preserve",
ArchiveLayout::Flatten => "archive-flat",
},
total_files: archive_job_total_files(&state, &job_id),
files: Vec::new(),
status_url: Some(routes.download_job_status_url(&job_id)),
file_url: Some(routes.download_job_file_url(&job_id)),
job_id: Some(job_id),
})
}
}
}) {
Ok(response) => HttpResponse::Ok().json(response),
Err(error) => api_error_response(&error),
}
}
async fn api_move_selected(
state: Data<AppState>,
Json(request): Json<ApiMoveRequest>,
) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), Some(request.mount.as_str())).and_then(
|source_mount| {
let target_mount = selected_target_mount(
state.config.as_ref(),
source_mount,
Some(request.target_mount.as_str()),
)?;
let source_service = file_service_response(source_mount)?;
let target_service = file_service_response(target_mount)?;
let total_items =
source_service.move_operation_count_to(&request.paths, &target_service)?;
let job_id = start_move_job(
&state,
source_service,
target_service,
request.paths,
total_items,
);
Ok(ApiMovePayload {
job_id: job_id.clone(),
total_items,
status_url: routes.move_job_status_url(&job_id),
source_mount_id: source_mount.id.clone(),
target_mount_id: target_mount.id.clone(),
})
},
) {
Ok(response) => HttpResponse::Ok().json(response),
Err(error) => api_error_response(&error),
}
}
#[derive(Debug, Default, Deserialize)]
struct BrowseParams {
mount: Option<String>,
path: Option<String>,
selected: Option<String>,
batch: Option<String>,
batch_add: Option<String>,
batch_add_many: Option<String>,
batch_remove: Option<String>,
sort: Option<String>,
desc: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct SearchParams {
mount: Option<String>,
q: Option<String>,
path: Option<String>,
selected: Option<String>,
batch: Option<String>,
batch_add: Option<String>,
batch_add_many: Option<String>,
batch_remove: Option<String>,
sort: Option<String>,
desc: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct DeleteSelectedForm {
mount: Option<String>,
path: Option<String>,
q: Option<String>,
batch: Option<String>,
sort: Option<String>,
desc: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct FilePathParams {
mount: Option<String>,
path: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct DownloadSelectedParams {
mount: Option<String>,
batch: Option<String>,
mode: Option<String>,
structure: Option<String>,
delete_after: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct MoveSelectedParams {
mount: Option<String>,
target_mount: Option<String>,
batch: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ApiEntriesParams {
mount: Option<String>,
path: Option<String>,
sort: Option<String>,
desc: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ApiSearchParams {
mount: Option<String>,
q: Option<String>,
sort: Option<String>,
desc: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ApiStorageParams {
mount: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiBatchRequest {
mount: String,
paths: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ApiDownloadRequest {
mount: String,
paths: Vec<String>,
mode: Option<String>,
structure: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiMoveRequest {
mount: String,
target_mount: String,
paths: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DownloadMode {
Individual,
ArchivePreserve,
ArchiveFlat,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TableSortKey {
Name,
Size,
Modified,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct TableSort {
key: Option<TableSortKey>,
desc: bool,
}
#[derive(Clone, Debug)]
struct ArchiveJobState {
phase: ArchiveJobPhase,
total_files: usize,
completed_files: usize,
current_file: Option<String>,
archive_file_name: String,
archive_bytes: Option<Vec<u8>>,
error: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ArchiveJobPhase {
Queued,
Archiving,
Ready,
Failed,
}
#[derive(Debug, Serialize)]
struct ArchiveJobStatusPayload {
phase: &'static str,
total_files: usize,
completed_files: usize,
current_label: String,
current_percent: u8,
status_line: String,
error: Option<String>,
}
#[derive(Clone, Debug)]
struct MoveJobState {
phase: MoveJobPhase,
total_items: usize,
completed_items: usize,
current_path: Option<String>,
error: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MoveJobPhase {
Queued,
Moving,
Ready,
Failed,
}
#[derive(Debug, Serialize)]
struct MoveJobStatusPayload {
phase: &'static str,
total_items: usize,
completed_items: usize,
current_label: String,
current_percent: u8,
status_line: String,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct ApiErrorPayload {
error: String,
}
#[derive(Debug, Serialize)]
struct ApiMountPayload {
id: String,
name: String,
available: bool,
error_message: Option<String>,
browse_url: String,
raw_url: String,
}
#[derive(Debug, Serialize)]
struct ApiMountListPayload {
mounts: Vec<ApiMountPayload>,
}
#[derive(Debug, Serialize)]
struct ApiBreadcrumbPayload {
name: String,
path: String,
}
#[derive(Debug, Serialize)]
struct ApiStorageUsagePayload {
used_bytes: u64,
available_bytes: u64,
total_bytes: u64,
used_percent: u8,
used_label: String,
available_label: String,
total_label: String,
}
#[derive(Debug, Serialize)]
struct ApiFileEntryPayload {
name: String,
relative_path: String,
parent_relative_path: String,
kind: &'static str,
is_directory: bool,
size_bytes: u64,
modified_unix_seconds: Option<u64>,
size_label: String,
modified_label: String,
permissions_label: String,
download_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct ApiDirectoryListingPayload {
mount_id: String,
mount_name: String,
current_path: String,
current_path_display: String,
breadcrumbs: Vec<ApiBreadcrumbPayload>,
total_entries: usize,
entries: Vec<ApiFileEntryPayload>,
storage_usage: ApiStorageUsagePayload,
}
#[derive(Debug, Serialize)]
struct ApiSearchPayload {
mount_id: String,
mount_name: String,
query: String,
total_matches: usize,
is_truncated: bool,
entries: Vec<ApiFileEntryPayload>,
storage_usage: ApiStorageUsagePayload,
}
#[derive(Debug, Serialize)]
struct ApiDeletePayload {
deleted_count: usize,
}
#[derive(Debug, Serialize)]
struct ApiDownloadFilePayload {
relative_path: String,
download_url: String,
}
#[derive(Debug, Serialize)]
struct ApiDownloadPayload {
mode: &'static str,
total_files: usize,
files: Vec<ApiDownloadFilePayload>,
job_id: Option<String>,
status_url: Option<String>,
file_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct ApiMovePayload {
job_id: String,
total_items: usize,
status_url: String,
source_mount_id: String,
target_mount_id: String,
}
async fn browse(state: Data<AppState>, Query(params): Query<BrowseParams>) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
let service = match file_service_response(mount) {
Ok(service) => service,
Err(FileServiceError::InvalidRoot(_)) => {
return render_mount_unavailable_view(
state.config.as_ref(),
&routes,
mount,
"",
params.path.as_deref().unwrap_or_default(),
parse_table_sort(params.sort.as_deref(), params.desc.as_deref()).unwrap_or(
TableSort {
key: None,
desc: false,
},
),
);
}
Err(error) => return Err(error),
};
{
let table_sort = parse_table_sort(params.sort.as_deref(), params.desc.as_deref())?;
let batch_paths = apply_batch_mutation(
params.batch.as_deref(),
params.batch_add.as_deref(),
params.batch_add_many.as_deref(),
params.batch_remove.as_deref(),
)?;
render_browse_view(
&service,
&routes,
state.preview_registry.as_ref(),
state.config.as_ref(),
mount,
state.config.features(),
params.path.as_deref().unwrap_or_default(),
"",
params.selected.as_deref().unwrap_or_default(),
table_sort,
&batch_paths,
None,
)
}
}) {
Ok(response) => response,
Err(error) => error_response("Browse", "Unable to load directory", &error),
}
}
async fn search(state: Data<AppState>, Query(params): Query<SearchParams>) -> HttpResponse {
let routes = state.config.route_paths();
let current_path = params.path.as_deref().unwrap_or_default();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
let query = params.q.as_deref().unwrap_or_default();
let table_sort = parse_table_sort(params.sort.as_deref(), params.desc.as_deref())
.unwrap_or(TableSort {
key: None,
desc: false,
});
let service = match file_service_response(mount) {
Ok(service) => service,
Err(FileServiceError::InvalidRoot(_)) => {
return render_mount_unavailable_view(
state.config.as_ref(),
&routes,
mount,
query,
current_path,
table_sort,
);
}
Err(error) => return Err(error),
};
{
let table_sort = parse_table_sort(params.sort.as_deref(), params.desc.as_deref())?;
let batch_paths = apply_batch_mutation(
params.batch.as_deref(),
params.batch_add.as_deref(),
params.batch_add_many.as_deref(),
params.batch_remove.as_deref(),
)?;
if query.trim().is_empty() {
return render_browse_view(
&service,
&routes,
state.preview_registry.as_ref(),
state.config.as_ref(),
mount,
state.config.features(),
current_path,
"",
params.selected.as_deref().unwrap_or_default(),
table_sort,
&batch_paths,
None,
);
}
render_search_view(
&service,
&routes,
state.preview_registry.as_ref(),
state.config.as_ref(),
mount,
state.config.features(),
current_path,
query,
params.selected.as_deref().unwrap_or_default(),
table_sort,
&batch_paths,
None,
)
}
}) {
Ok(response) => response,
Err(error) => error_response("Search", "Unable to search files", &error),
}
}
async fn delete_selected(
state: Data<AppState>,
Form(form): Form<DeleteSelectedForm>,
) -> HttpResponse {
let routes = state.config.route_paths();
let current_path = form.path.as_deref().unwrap_or_default();
match selected_mount(state.config.as_ref(), form.mount.as_deref()).and_then(|mount| {
file_service_response(mount).and_then(|service| {
let batch_paths = parse_batch_paths(form.batch.as_deref())?;
let table_sort = parse_table_sort(form.sort.as_deref(), form.desc.as_deref())?;
let summary = service.delete_entries(&batch_paths)?;
let notice = if summary.deleted_count == 0 {
"No items selected for deletion.".to_string()
} else if summary.deleted_count == 1 {
"Deleted 1 item.".to_string()
} else {
format!("Deleted {} items.", summary.deleted_count)
};
let query = form.q.as_deref().unwrap_or_default();
if query.trim().is_empty() {
return render_browse_view(
&service,
&routes,
state.preview_registry.as_ref(),
state.config.as_ref(),
mount,
state.config.features(),
current_path,
"",
"",
table_sort,
&[],
Some(notice.as_str()),
);
}
render_search_view(
&service,
&routes,
state.preview_registry.as_ref(),
state.config.as_ref(),
mount,
state.config.features(),
current_path,
query,
"",
table_sort,
&[],
Some(notice.as_str()),
)
})
}) {
Ok(response) => response,
Err(error) => error_response("Delete", "Unable to delete selection", &error),
}
}
async fn preview(state: Data<AppState>, Query(params): Query<FilePathParams>) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
file_service_response(mount).and_then(|service| {
let requested_path = params.path.as_deref().unwrap_or_default();
let asset = service.file_asset(requested_path)?;
let request = PreviewRequest {
relative_path: asset.relative_path.clone(),
file_name: asset.file_name.clone(),
extension: asset.extension.clone(),
mime_type: asset.mime_type.clone(),
raw_url: routes.raw_file_url_for_mount(&mount.id, &asset.relative_path),
text_excerpt: if is_textish(&asset.mime_type, asset.extension.as_deref()) {
Some(service.read_text_excerpt(&asset, 64 * 1024)?)
} else {
None
},
};
let document = state.preview_registry.render(&request);
let html = PreviewTemplate {
preview: &document,
close_url: &format!(
"{}?mount={}",
routes.mount_path,
urlencoding::encode(&mount.id)
),
}
.to_string();
Ok(html_response(html))
})
}) {
Ok(response) => response,
Err(error) => error_response("Preview", "Unable to render preview", &error),
}
}
async fn download_selected(
state: Data<AppState>,
Query(params): Query<DownloadSelectedParams>,
) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
file_service_response(mount).and_then(|service| {
let batch_paths = parse_batch_paths(params.batch.as_deref())?;
let assets = service.collect_download_assets(&batch_paths)?;
let batch_serialized = serialize_batch_paths(&batch_paths);
let delete_after_download = parse_bool_flag(params.delete_after.as_deref());
let download_mode =
parse_download_mode(params.mode.as_deref(), params.structure.as_deref())?;
match download_mode {
DownloadMode::Individual => {
let entries = assets
.iter()
.map(|asset| crate::DownloadLauncherEntry {
relative_path: format!("/{}", asset.relative_path),
download_url: routes
.raw_file_url_for_mount(&mount.id, &asset.relative_path),
})
.collect::<Vec<_>>();
let html = DownloadProgressTemplate {
branding: state.config.branding(),
routes: &routes,
entries: &entries,
current_mount_id: &mount.id,
batch_serialized: &batch_serialized,
mode_label: "Downloading files individually",
total_files: entries.len(),
delete_after_download,
archive_job_status_url: None,
archive_job_file_url: None,
}
.to_string();
Ok(html_response(html))
}
DownloadMode::ArchivePreserve | DownloadMode::ArchiveFlat => {
let layout = match download_mode {
DownloadMode::ArchivePreserve => ArchiveLayout::PreservePaths,
DownloadMode::ArchiveFlat => ArchiveLayout::Flatten,
DownloadMode::Individual => unreachable!(),
};
let job_id = start_archive_job(&state, assets, layout);
let status_url = routes.download_job_status_url(&job_id);
let file_url = routes.download_job_file_url(&job_id);
let html = DownloadProgressTemplate {
branding: state.config.branding(),
routes: &routes,
entries: &[],
current_mount_id: &mount.id,
batch_serialized: &batch_serialized,
mode_label: match layout {
ArchiveLayout::PreservePaths => "Preparing structured archive",
ArchiveLayout::Flatten => "Preparing flat archive",
},
total_files: archive_job_total_files(&state, &job_id),
delete_after_download,
archive_job_status_url: Some(&status_url),
archive_job_file_url: Some(&file_url),
}
.to_string();
Ok(html_response(html))
}
}
})
}) {
Ok(response) => response,
Err(error) => error_response("Download", "Unable to prepare batch download", &error),
}
}
async fn move_selected(
state: Data<AppState>,
Query(params): Query<MoveSelectedParams>,
) -> HttpResponse {
let routes = state.config.route_paths();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|source_mount| {
let target_mount = selected_target_mount(
state.config.as_ref(),
source_mount,
params.target_mount.as_deref(),
)?;
let source_service = file_service_response(source_mount)?;
let target_service = file_service_response(target_mount)?;
let batch_paths = parse_batch_paths(params.batch.as_deref())?;
let batch_entries = build_batch_entries(&source_service, &batch_paths);
let total_items = source_service.move_operation_count_to(&batch_paths, &target_service)?;
let job_id = start_move_job(
&state,
source_service,
target_service,
batch_paths,
total_items,
);
let status_url = routes.move_job_status_url(&job_id);
let target_mount_url = shell_state_url(
&routes,
&target_mount.id,
&common_parent_path(&batch_entries),
"",
TableSort {
key: None,
desc: false,
},
);
let html = MoveProgressTemplate {
branding: state.config.branding(),
routes: &routes,
entries: &batch_entries,
source_mount_name: &source_mount.name,
target_mount_name: &target_mount.name,
total_items,
move_job_status_url: &status_url,
target_mount_url: &target_mount_url,
}
.to_string();
Ok(html_response(html))
}) {
Ok(response) => response,
Err(error) => error_response("Move", "Unable to prepare batch move", &error),
}
}
async fn download_job_status(state: Data<AppState>, job_id: ActixPath<String>) -> HttpResponse {
let job_id = job_id.into_inner();
let Some(payload) = archive_job_payload(&state, &job_id) else {
return HttpResponse::NotFound().finish();
};
HttpResponse::Ok().json(payload)
}
async fn download_job_file(state: Data<AppState>, job_id: ActixPath<String>) -> HttpResponse {
let job_id = job_id.into_inner();
let Some((file_name, bytes)) = archive_job_file(&state, &job_id) else {
return HttpResponse::NotFound().finish();
};
HttpResponse::Ok()
.insert_header((CONTENT_TYPE, "application/zip"))
.insert_header((CONTENT_DISPOSITION, content_disposition_header(&file_name)))
.insert_header((
CONTENT_LENGTH,
HeaderValue::from_str(&archive_job_size(&state, &job_id).to_string())
.unwrap_or_else(|_| HeaderValue::from_static("0")),
))
.body(bytes)
}
async fn move_job_status(state: Data<AppState>, job_id: ActixPath<String>) -> HttpResponse {
let job_id = job_id.into_inner();
let Some(payload) = move_job_payload(&state, &job_id) else {
return HttpResponse::NotFound().finish();
};
HttpResponse::Ok().json(payload)
}
async fn raw_file(
state: Data<AppState>,
path: ActixPath<String>,
Query(params): Query<FilePathParams>,
) -> HttpResponse {
let path = path.into_inner();
match selected_mount(state.config.as_ref(), params.mount.as_deref()).and_then(|mount| {
file_service_response(mount).and_then(|service| {
let asset = service.file_asset(&path)?;
let bytes = std::fs::read(&asset.absolute_path)?;
let content_length = bytes.len();
Ok(HttpResponse::Ok()
.insert_header((
CONTENT_TYPE,
HeaderValue::from_str(&asset.mime_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
))
.insert_header((
CONTENT_DISPOSITION,
content_disposition_header(&asset.file_name),
))
.insert_header((
CONTENT_LENGTH,
HeaderValue::from_str(&content_length.to_string())
.unwrap_or_else(|_| HeaderValue::from_static("0")),
))
.body(bytes))
})
}) {
Ok(response) => response,
Err(error) => error_response("Preview", "Unable to load file", &error),
}
}
async fn htmx_js() -> HttpResponse {
HttpResponse::Ok()
.insert_header((CONTENT_TYPE, "application/javascript"))
.body(HtmxJs.to_string())
}
async fn styles_css(state: Data<AppState>) -> HttpResponse {
HttpResponse::Ok()
.insert_header((CONTENT_TYPE, "text/css; charset=utf-8"))
.body(
StylesCss {
theme: state.config.theme(),
}
.to_string(),
)
}
fn selected_mount<'a>(
config: &'a FileServerConfig,
mount_id: Option<&str>,
) -> Result<&'a crate::FileMount, FileServiceError> {
match mount_id.filter(|id| !id.is_empty()) {
Some(id) => config
.mount(id)
.ok_or_else(|| FileServiceError::invalid_path(format!("Unknown mount '{id}'."))),
None => Ok(config.default_mount()),
}
}
fn selected_target_mount<'a>(
config: &'a FileServerConfig,
source_mount: &crate::FileMount,
target_mount_id: Option<&str>,
) -> Result<&'a crate::FileMount, FileServiceError> {
let Some(target_id) = target_mount_id.filter(|id| !id.is_empty()) else {
return Err(FileServiceError::invalid_path(
"Select a destination mount for the move.",
));
};
let target_mount = config
.mount(target_id)
.ok_or_else(|| FileServiceError::invalid_path(format!("Unknown mount '{target_id}'.")))?;
if target_mount.id == source_mount.id {
return Err(FileServiceError::invalid_path(
"Select a different destination mount.",
));
}
if !mount_is_available(target_mount) {
return Err(FileServiceError::invalid_path(format!(
"Mount '{}' is unavailable.",
target_mount.name
)));
}
Ok(target_mount)
}
fn file_service_response(mount: &crate::FileMount) -> Result<FileService, FileServiceError> {
FileService::new(mount.root_dir.clone())
}
fn html_response(body: String) -> HttpResponse {
HttpResponse::Ok()
.insert_header((CONTENT_TYPE, "text/html; charset=utf-8"))
.body(body)
}
fn error_response(eyebrow: &str, title: &str, error: &FileServiceError) -> HttpResponse {
let status = match error {
FileServiceError::InvalidRoot(_) | FileServiceError::Io(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
FileServiceError::InvalidPath(_)
| FileServiceError::AlreadyExists(_)
| FileServiceError::NotADirectory(_)
| FileServiceError::NotAFile(_)
| FileServiceError::OutsideRoot(_) => StatusCode::BAD_REQUEST,
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
};
HttpResponse::build(status)
.insert_header((CONTENT_TYPE, "text/html; charset=utf-8"))
.body(
PanelTemplate {
eyebrow,
title,
message: &error.to_string(),
}
.to_string(),
)
}
fn api_error_response(error: &FileServiceError) -> HttpResponse {
let status = match error {
FileServiceError::InvalidRoot(_) => StatusCode::SERVICE_UNAVAILABLE,
FileServiceError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
FileServiceError::InvalidPath(_)
| FileServiceError::AlreadyExists(_)
| FileServiceError::NotADirectory(_)
| FileServiceError::NotAFile(_)
| FileServiceError::OutsideRoot(_) => StatusCode::BAD_REQUEST,
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
};
HttpResponse::build(status).json(ApiErrorPayload {
error: error.to_string(),
})
}
fn render_mount_unavailable_view(
config: &FileServerConfig,
routes: &crate::RoutePaths,
mount: &crate::FileMount,
current_query: &str,
current_path: &str,
table_sort: TableSort,
) -> Result<HttpResponse, FileServiceError> {
let mount_options = build_mount_options(config, routes, current_query, table_sort, &mount.id);
let refresh_url = table_state_url(
routes,
&mount.id,
current_path,
current_query,
"",
&[],
table_sort,
&[],
);
let back_url = build_available_back_url(config, routes, &mount.id, current_query, table_sort);
let error_message = FileServiceError::InvalidRoot(mount.root_dir.clone()).to_string();
let html = MountUnavailableTemplate {
mount_options: &mount_options,
active_mount_name: &mount.name,
error_message: &error_message,
refresh_url: &refresh_url,
back_url: back_url.as_deref(),
}
.to_string();
Ok(html_response(html))
}
fn build_api_mounts(config: &FileServerConfig, routes: &crate::RoutePaths) -> Vec<ApiMountPayload> {
config
.mounts()
.iter()
.map(|mount| {
let error_message = match FileService::new(mount.root_dir.clone()) {
Ok(_) => None,
Err(error) => Some(error.to_string()),
};
ApiMountPayload {
id: mount.id.clone(),
name: mount.name.clone(),
available: error_message.is_none(),
error_message,
browse_url: shell_state_url(
routes,
&mount.id,
"",
"",
TableSort {
key: None,
desc: false,
},
),
raw_url: routes.raw_file_url_for_mount(&mount.id, ""),
}
})
.collect()
}
fn api_storage_usage(storage: &crate::StorageUsage) -> ApiStorageUsagePayload {
ApiStorageUsagePayload {
used_bytes: storage.used_bytes,
available_bytes: storage.available_bytes,
total_bytes: storage.total_bytes,
used_percent: storage.used_percent,
used_label: storage.used_label.clone(),
available_label: storage.available_label.clone(),
total_label: storage.total_label.clone(),
}
}
fn build_api_file_entries(
entries: &[crate::FileEntry],
routes: &crate::RoutePaths,
mount_id: &str,
) -> Vec<ApiFileEntryPayload> {
entries
.iter()
.map(|entry| ApiFileEntryPayload {
name: entry.name.clone(),
relative_path: entry.relative_path.clone(),
parent_relative_path: entry.parent_relative_path.clone(),
kind: match entry.kind {
crate::FileKind::File => "file",
crate::FileKind::Directory => "directory",
crate::FileKind::Symlink => "symlink",
crate::FileKind::Other => "other",
},
is_directory: entry.is_directory,
size_bytes: entry.size_bytes,
modified_unix_seconds: entry.modified_unix_seconds,
size_label: entry.size_label.clone(),
modified_label: entry.modified_label.clone(),
permissions_label: entry.permissions_label.clone(),
download_url: (!entry.is_directory)
.then(|| routes.raw_file_url_for_mount(mount_id, &entry.relative_path)),
})
.collect()
}
fn render_browse_view(
service: &FileService,
routes: &crate::RoutePaths,
preview_registry: &PreviewRegistry,
config: &FileServerConfig,
mount: &crate::FileMount,
features: &crate::FeatureFlags,
current_path: &str,
current_query: &str,
selected_relative_path: &str,
table_sort: TableSort,
batch_paths: &[String],
action_notice: Option<&str>,
) -> Result<HttpResponse, FileServiceError> {
let mut listing = service.list_dir(current_path)?;
sort_entries(&mut listing.entries, table_sort);
mark_batch_membership(&mut listing.entries, batch_paths);
let storage_usage = service.storage_usage()?;
let batch_serialized = serialize_batch_paths(batch_paths);
let batch_entries = build_batch_entries(service, batch_paths);
let mount_options = build_mount_options(config, routes, current_query, table_sort, &mount.id);
let move_target_options = build_move_target_options(config, &mount.id);
let sort_key = table_sort.key.map(table_sort_key_name).unwrap_or_default();
let batch_add_all_url = table_state_url(
routes,
&mount.id,
&listing.current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
&[("batch_add_many", serialize_entry_paths(&listing.entries))],
);
let sort_name_url = sort_toggle_url(
routes,
&mount.id,
&listing.current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
TableSortKey::Name,
);
let sort_size_url = sort_toggle_url(
routes,
&mount.id,
&listing.current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
TableSortKey::Size,
);
let sort_modified_url = sort_toggle_url(
routes,
&mount.id,
&listing.current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
TableSortKey::Modified,
);
let preview_html = render_selected_preview(
service,
preview_registry,
routes,
&mount.id,
&listing.entries,
selected_relative_path,
&table_state_url(
routes,
&mount.id,
&listing.current_path,
current_query,
"",
batch_paths,
table_sort,
&[],
),
)?;
let html = BrowseTemplate {
listing: &listing,
entries: &listing.entries,
batch_entries: &batch_entries,
batch_serialized: &batch_serialized,
mount_options: &mount_options,
move_target_options: &move_target_options,
active_mount_name: &mount.name,
current_mount_id: &mount.id,
batch_add_all_url: &batch_add_all_url,
current_path: &listing.current_path,
current_query,
current_sort_key: sort_key,
current_sort_desc: table_sort.desc,
sort_name_url: &sort_name_url,
sort_size_url: &sort_size_url,
sort_modified_url: &sort_modified_url,
storage_usage: &storage_usage,
selected_relative_path,
action_notice,
preview_html: preview_html.as_deref(),
routes,
features,
}
.to_string();
Ok(html_response(html))
}
fn render_search_view(
service: &FileService,
routes: &crate::RoutePaths,
preview_registry: &PreviewRegistry,
config: &FileServerConfig,
mount: &crate::FileMount,
features: &crate::FeatureFlags,
current_path: &str,
current_query: &str,
selected_relative_path: &str,
table_sort: TableSort,
batch_paths: &[String],
action_notice: Option<&str>,
) -> Result<HttpResponse, FileServiceError> {
let mut results = service.search(current_query)?;
sort_entries(&mut results.entries, table_sort);
mark_batch_membership(&mut results.entries, batch_paths);
let storage_usage = service.storage_usage()?;
let batch_serialized = serialize_batch_paths(batch_paths);
let batch_entries = build_batch_entries(service, batch_paths);
let mount_options = build_mount_options(config, routes, current_query, table_sort, &mount.id);
let move_target_options = build_move_target_options(config, &mount.id);
let sort_key = table_sort.key.map(table_sort_key_name).unwrap_or_default();
let batch_add_all_url = table_state_url(
routes,
&mount.id,
current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
&[("batch_add_many", serialize_entry_paths(&results.entries))],
);
let sort_name_url = sort_toggle_url(
routes,
&mount.id,
current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
TableSortKey::Name,
);
let sort_size_url = sort_toggle_url(
routes,
&mount.id,
current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
TableSortKey::Size,
);
let sort_modified_url = sort_toggle_url(
routes,
&mount.id,
current_path,
current_query,
selected_relative_path,
batch_paths,
table_sort,
TableSortKey::Modified,
);
let preview_html = render_selected_preview(
service,
preview_registry,
routes,
&mount.id,
&results.entries,
selected_relative_path,
&table_state_url(
routes,
&mount.id,
current_path,
current_query,
"",
batch_paths,
table_sort,
&[],
),
)?;
let html = SearchTemplate {
results: &results,
entries: &results.entries,
batch_entries: &batch_entries,
batch_serialized: &batch_serialized,
mount_options: &mount_options,
move_target_options: &move_target_options,
active_mount_name: &mount.name,
current_mount_id: &mount.id,
current_query,
current_path,
batch_add_all_url: &batch_add_all_url,
current_sort_key: sort_key,
current_sort_desc: table_sort.desc,
sort_name_url: &sort_name_url,
sort_size_url: &sort_size_url,
sort_modified_url: &sort_modified_url,
storage_usage: &storage_usage,
selected_relative_path,
action_notice,
preview_html: preview_html.as_deref(),
routes,
features,
}
.to_string();
Ok(html_response(html))
}
fn render_selected_preview(
service: &FileService,
preview_registry: &PreviewRegistry,
routes: &crate::RoutePaths,
current_mount_id: &str,
entries: &[crate::FileEntry],
selected_relative_path: &str,
close_url: &str,
) -> Result<Option<String>, FileServiceError> {
if selected_relative_path.is_empty() {
return Ok(None);
}
let Some(selected_entry) = entries
.iter()
.find(|entry| entry.relative_path == selected_relative_path && !entry.is_directory)
else {
return Ok(None);
};
let asset = service.file_asset(&selected_entry.relative_path)?;
let request = PreviewRequest {
relative_path: asset.relative_path.clone(),
file_name: asset.file_name.clone(),
extension: asset.extension.clone(),
mime_type: asset.mime_type.clone(),
raw_url: routes.raw_file_url_for_mount(current_mount_id, &asset.relative_path),
text_excerpt: if is_textish(&asset.mime_type, asset.extension.as_deref()) {
Some(service.read_text_excerpt(&asset, 64 * 1024)?)
} else {
None
},
};
let document = preview_registry.render(&request);
Ok(Some(
PreviewTemplate {
preview: &document,
close_url,
}
.to_string(),
))
}
fn content_disposition_header(file_name: &str) -> HeaderValue {
let escaped = file_name.replace('\\', "\\\\").replace('"', "\\\"");
HeaderValue::from_str(&format!("attachment; filename=\"{escaped}\""))
.unwrap_or_else(|_| HeaderValue::from_static("attachment"))
}
fn parse_bool_flag(value: Option<&str>) -> bool {
matches!(value.map(str::trim), Some("1" | "true" | "on" | "yes"))
}
fn parse_table_sort(sort: Option<&str>, desc: Option<&str>) -> Result<TableSort, FileServiceError> {
let key = match sort.unwrap_or_default().trim() {
"" => None,
"name" => Some(TableSortKey::Name),
"size" => Some(TableSortKey::Size),
"modified" => Some(TableSortKey::Modified),
invalid => {
return Err(FileServiceError::invalid_path(format!(
"Unsupported sort '{invalid}'."
)));
}
};
Ok(TableSort {
key,
desc: parse_bool_flag(desc),
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ArchiveLayout {
PreservePaths,
Flatten,
}
fn parse_download_mode(
mode: Option<&str>,
structure: Option<&str>,
) -> Result<DownloadMode, FileServiceError> {
match mode.unwrap_or("individual").trim() {
"" | "individual" => Ok(DownloadMode::Individual),
"archive" => match structure.unwrap_or("preserve").trim() {
"" | "preserve" => Ok(DownloadMode::ArchivePreserve),
"flat" => Ok(DownloadMode::ArchiveFlat),
invalid => Err(FileServiceError::invalid_path(format!(
"Unsupported archive structure '{invalid}'."
))),
},
invalid => Err(FileServiceError::invalid_path(format!(
"Unsupported download mode '{invalid}'."
))),
}
}
fn build_archive_bytes(
assets: &[crate::FileAsset],
layout: ArchiveLayout,
mut on_file_archived: impl FnMut(usize, &crate::FileAsset),
) -> Result<Vec<u8>, FileServiceError> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
let mut used_flat_names = BTreeSet::new();
for (index, asset) in assets.iter().enumerate() {
let archive_path = match layout {
ArchiveLayout::PreservePaths => asset.relative_path.clone(),
ArchiveLayout::Flatten => unique_flat_file_name(&asset.file_name, &mut used_flat_names),
};
zip.start_file(archive_path, options)
.map_err(zip_error_to_file_service)?;
zip.write_all(&std::fs::read(&asset.absolute_path)?)?;
on_file_archived(index + 1, asset);
}
let cursor = zip.finish().map_err(zip_error_to_file_service)?;
Ok(cursor.into_inner())
}
fn unique_flat_file_name(file_name: &str, used_names: &mut BTreeSet<String>) -> String {
if used_names.insert(file_name.to_string()) {
return file_name.to_string();
}
let path = Path::new(file_name);
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.unwrap_or(file_name);
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(|value| format!(".{value}"))
.unwrap_or_default();
let mut index = 2;
loop {
let candidate = format!("{stem} ({index}){extension}");
if used_names.insert(candidate.clone()) {
return candidate;
}
index += 1;
}
}
fn zip_error_to_file_service(error: zip::result::ZipError) -> FileServiceError {
FileServiceError::Io(std::io::Error::other(error))
}
fn start_archive_job(
state: &AppState,
assets: Vec<crate::FileAsset>,
layout: ArchiveLayout,
) -> String {
let job_id = format!(
"job-{}",
state.next_archive_job_id.fetch_add(1, Ordering::Relaxed)
);
let archive_file_name = match layout {
ArchiveLayout::PreservePaths => "slash-files-batch.zip".to_string(),
ArchiveLayout::Flatten => "slash-files-batch-flat.zip".to_string(),
};
let total_files = assets.len();
{
let mut jobs = state.archive_jobs.lock().expect("archive jobs poisoned");
jobs.insert(
job_id.clone(),
ArchiveJobState {
phase: ArchiveJobPhase::Queued,
total_files,
completed_files: 0,
current_file: None,
archive_file_name,
archive_bytes: None,
error: None,
},
);
}
let jobs = Arc::clone(&state.archive_jobs);
let task_job_id = job_id.clone();
::actix_web::rt::spawn(async move {
update_archive_job(&jobs, &task_job_id, |job| {
job.phase = ArchiveJobPhase::Archiving;
job.current_file = None;
});
let result = build_archive_bytes(&assets, layout, |completed_files, asset| {
update_archive_job(&jobs, &task_job_id, |job| {
job.phase = ArchiveJobPhase::Archiving;
job.completed_files = completed_files;
job.current_file = Some(asset.relative_path.clone());
});
});
match result {
Ok(bytes) => update_archive_job(&jobs, &task_job_id, |job| {
job.phase = ArchiveJobPhase::Ready;
job.completed_files = job.total_files;
job.current_file = None;
job.archive_bytes = Some(bytes);
job.error = None;
}),
Err(error) => update_archive_job(&jobs, &task_job_id, |job| {
job.phase = ArchiveJobPhase::Failed;
job.current_file = None;
job.error = Some(error.to_string());
}),
}
});
job_id
}
fn update_archive_job(
jobs: &Arc<Mutex<HashMap<String, ArchiveJobState>>>,
job_id: &str,
update: impl FnOnce(&mut ArchiveJobState),
) {
if let Some(job) = jobs.lock().expect("archive jobs poisoned").get_mut(job_id) {
update(job);
}
}
fn archive_job_total_files(state: &AppState, job_id: &str) -> usize {
state
.archive_jobs
.lock()
.expect("archive jobs poisoned")
.get(job_id)
.map(|job| job.total_files)
.unwrap_or(0)
}
fn archive_job_payload(state: &AppState, job_id: &str) -> Option<ArchiveJobStatusPayload> {
let jobs = state.archive_jobs.lock().expect("archive jobs poisoned");
let job = jobs.get(job_id)?;
let total_files = job.total_files;
let completed_files = job.completed_files.min(total_files);
let progress_percent = if total_files == 0 {
100
} else {
((completed_files * 100) / total_files) as u8
};
let (phase, current_label, status_line, current_percent) = match job.phase {
ArchiveJobPhase::Queued => (
"queued",
"Queued for archive build".to_string(),
"Waiting for archive worker…".to_string(),
0,
),
ArchiveJobPhase::Archiving => {
let current = job
.current_file
.as_deref()
.map(|path| format!("Archiving file: /{path}"))
.unwrap_or_else(|| "Archiving selected files".to_string());
(
"archiving",
current.clone(),
format!("{current} {completed_files}/{total_files}"),
progress_percent,
)
}
ArchiveJobPhase::Ready => (
"ready",
"Archive ready".to_string(),
"Archive is ready and will start downloading now.".to_string(),
100,
),
ArchiveJobPhase::Failed => (
"failed",
"Archive failed".to_string(),
job.error
.clone()
.unwrap_or_else(|| "Archive build failed.".to_string()),
progress_percent,
),
};
Some(ArchiveJobStatusPayload {
phase,
total_files,
completed_files,
current_label,
current_percent,
status_line,
error: job.error.clone(),
})
}
fn archive_job_file(state: &AppState, job_id: &str) -> Option<(String, Vec<u8>)> {
let jobs = state.archive_jobs.lock().expect("archive jobs poisoned");
let job = jobs.get(job_id)?;
if job.phase != ArchiveJobPhase::Ready {
return None;
}
Some((
job.archive_file_name.clone(),
job.archive_bytes.clone().unwrap_or_default(),
))
}
fn archive_job_size(state: &AppState, job_id: &str) -> usize {
state
.archive_jobs
.lock()
.expect("archive jobs poisoned")
.get(job_id)
.and_then(|job| job.archive_bytes.as_ref().map(Vec::len))
.unwrap_or(0)
}
fn start_move_job(
state: &AppState,
source_service: FileService,
target_service: FileService,
batch_paths: Vec<String>,
total_items: usize,
) -> String {
let job_id = format!(
"move-{}",
state.next_move_job_id.fetch_add(1, Ordering::Relaxed)
);
{
let mut jobs = state.move_jobs.lock().expect("move jobs poisoned");
jobs.insert(
job_id.clone(),
MoveJobState {
phase: MoveJobPhase::Queued,
total_items,
completed_items: 0,
current_path: None,
error: None,
},
);
}
let jobs = Arc::clone(&state.move_jobs);
let task_job_id = job_id.clone();
::actix_web::rt::spawn(async move {
update_move_job(&jobs, &task_job_id, |job| {
job.phase = MoveJobPhase::Moving;
job.current_path = None;
});
let result = source_service.move_entries_to(
&batch_paths,
&target_service,
|completed_items, total_items, relative_path| {
update_move_job(&jobs, &task_job_id, |job| {
job.phase = MoveJobPhase::Moving;
job.completed_items = completed_items;
job.total_items = total_items;
job.current_path = Some(relative_path.to_string());
});
},
);
match result {
Ok(_) => update_move_job(&jobs, &task_job_id, |job| {
job.phase = MoveJobPhase::Ready;
job.completed_items = job.total_items;
job.current_path = None;
job.error = None;
}),
Err(error) => update_move_job(&jobs, &task_job_id, |job| {
job.phase = MoveJobPhase::Failed;
job.current_path = None;
job.error = Some(error.to_string());
}),
}
});
job_id
}
fn update_move_job(
jobs: &Arc<Mutex<HashMap<String, MoveJobState>>>,
job_id: &str,
update: impl FnOnce(&mut MoveJobState),
) {
if let Some(job) = jobs.lock().expect("move jobs poisoned").get_mut(job_id) {
update(job);
}
}
fn move_job_payload(state: &AppState, job_id: &str) -> Option<MoveJobStatusPayload> {
let jobs = state.move_jobs.lock().expect("move jobs poisoned");
let job = jobs.get(job_id)?;
let total_items = job.total_items;
let completed_items = job.completed_items.min(total_items);
let progress_percent = if total_items == 0 {
100
} else {
((completed_items * 100) / total_items) as u8
};
let (phase, current_label, status_line, current_percent) = match job.phase {
MoveJobPhase::Queued => (
"queued",
"Queued for move".to_string(),
"Waiting for move worker…".to_string(),
0,
),
MoveJobPhase::Moving => {
let current = job
.current_path
.as_deref()
.map(|path| format!("Moving item: /{path}"))
.unwrap_or_else(|| "Moving selected items".to_string());
(
"moving",
current.clone(),
format!("{current} {completed_items}/{total_items}"),
progress_percent,
)
}
MoveJobPhase::Ready => (
"ready",
"Move complete".to_string(),
"All selected items were moved successfully.".to_string(),
100,
),
MoveJobPhase::Failed => (
"failed",
"Move failed".to_string(),
job.error
.clone()
.unwrap_or_else(|| "Move job failed.".to_string()),
progress_percent,
),
};
Some(MoveJobStatusPayload {
phase,
total_items,
completed_items,
current_label,
current_percent,
status_line,
error: job.error.clone(),
})
}
fn parse_batch_paths(batch: Option<&str>) -> Result<Vec<String>, FileServiceError> {
let mut batch_paths = Vec::new();
if let Some(batch) = batch {
for line in batch.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let normalized = normalize_relative_path(trimmed)?;
if !batch_paths.contains(&normalized) {
batch_paths.push(normalized);
}
}
}
Ok(normalize_batch_paths(batch_paths))
}
fn apply_batch_mutation(
batch: Option<&str>,
batch_add: Option<&str>,
batch_add_many: Option<&str>,
batch_remove: Option<&str>,
) -> Result<Vec<String>, FileServiceError> {
let mut batch_paths = parse_batch_paths(batch)?;
if let Some(batch_add) = batch_add.filter(|value| !value.is_empty()) {
let normalized = normalize_relative_path(batch_add)?;
batch_paths.push(normalized);
}
if let Some(batch_add_many) = batch_add_many.filter(|value| !value.is_empty()) {
batch_paths.extend(parse_batch_paths(Some(batch_add_many))?);
}
if let Some(batch_remove) = batch_remove.filter(|value| !value.is_empty()) {
let normalized = normalize_relative_path(batch_remove)?;
batch_paths.retain(|path| path != &normalized);
}
Ok(normalize_batch_paths(batch_paths))
}
fn normalize_relative_path(path: &str) -> Result<String, FileServiceError> {
let trimmed = path.trim().trim_matches('/');
if trimmed.is_empty() {
return Err(FileServiceError::invalid_path(path));
}
let mut parts = Vec::new();
for component in std::path::Path::new(trimmed).components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => {
return Err(FileServiceError::invalid_path(path));
}
}
}
Ok(parts.join("/"))
}
fn normalize_batch_paths(mut batch_paths: Vec<String>) -> Vec<String> {
batch_paths.sort();
batch_paths.dedup();
let mut normalized = Vec::new();
for path in batch_paths {
let covered = normalized.iter().any(|existing: &String| {
path == *existing
|| path
.strip_prefix(existing)
.is_some_and(|suffix| suffix.starts_with('/'))
});
if !covered {
normalized.push(path);
}
}
normalized
}
fn serialize_batch_paths(batch_paths: &[String]) -> String {
batch_paths.join("\n")
}
fn mark_batch_membership(entries: &mut [crate::FileEntry], batch_paths: &[String]) {
for entry in entries {
entry.in_batch = batch_paths.iter().any(|path| path == &entry.relative_path);
}
}
fn build_batch_entries(service: &FileService, batch_paths: &[String]) -> Vec<crate::BatchEntry> {
batch_paths
.iter()
.map(|path| {
let display_path = match service.entry_kind(path) {
Ok(crate::FileKind::Directory) => format!("/{path}/*"),
_ => format!("/{path}"),
};
crate::BatchEntry {
relative_path: path.clone(),
display_path,
}
})
.collect()
}
fn serialize_entry_paths(entries: &[crate::FileEntry]) -> String {
entries
.iter()
.map(|entry| entry.relative_path.as_str())
.collect::<Vec<_>>()
.join("\n")
}
fn build_mount_options(
config: &FileServerConfig,
routes: &crate::RoutePaths,
current_query: &str,
table_sort: TableSort,
current_mount_id: &str,
) -> Vec<crate::MountOption> {
config
.mounts()
.iter()
.map(|mount| {
let error_message = match FileService::new(mount.root_dir.clone()) {
Ok(_) => None,
Err(error) => Some(error.to_string()),
};
crate::MountOption {
id: mount.id.clone(),
name: mount.name.clone(),
is_active: mount.id == current_mount_id,
is_available: error_message.is_none(),
error_message,
switch_url: table_state_url(
routes,
&mount.id,
"",
current_query,
"",
&[],
table_sort,
&[],
),
}
})
.collect()
}
fn build_move_target_options(
config: &FileServerConfig,
current_mount_id: &str,
) -> Vec<crate::MoveTargetOption> {
config
.mounts()
.iter()
.filter(|mount| mount.id != current_mount_id && mount_is_available(mount))
.map(|mount| crate::MoveTargetOption {
id: mount.id.clone(),
name: mount.name.clone(),
})
.collect()
}
fn build_available_back_url(
config: &FileServerConfig,
routes: &crate::RoutePaths,
current_mount_id: &str,
current_query: &str,
table_sort: TableSort,
) -> Option<String> {
config
.mounts()
.iter()
.find(|mount| mount.id != current_mount_id && mount_is_available(mount))
.map(|mount| shell_state_url(routes, &mount.id, "", current_query, table_sort))
}
fn mount_is_available(mount: &crate::FileMount) -> bool {
FileService::new(mount.root_dir.clone()).is_ok()
}
fn common_parent_path(batch_entries: &[crate::BatchEntry]) -> String {
let mut paths = batch_entries
.iter()
.map(|entry| entry.relative_path.split('/').collect::<Vec<_>>())
.collect::<Vec<_>>();
if paths.is_empty() {
return String::new();
}
let first = paths.remove(0);
let mut prefix_len = first.len();
for path in paths {
prefix_len = prefix_len.min(path.len());
for index in 0..prefix_len {
if first[index] != path[index] {
prefix_len = index;
break;
}
}
}
first[..prefix_len.saturating_sub(1)].join("/")
}
fn shell_state_url(
routes: &crate::RoutePaths,
current_mount_id: &str,
current_path: &str,
current_query: &str,
table_sort: TableSort,
) -> String {
let inner_url = table_state_url(
routes,
current_mount_id,
current_path,
current_query,
"",
&[],
table_sort,
&[],
);
match inner_url.split_once('?') {
Some((_, query)) => format!("{}?{query}", routes.mount_path),
None => routes.mount_path.clone(),
}
}
fn table_state_url(
routes: &crate::RoutePaths,
current_mount_id: &str,
current_path: &str,
current_query: &str,
selected_relative_path: &str,
batch_paths: &[String],
table_sort: TableSort,
extra_params: &[(&str, String)],
) -> String {
let base_route = if current_query.is_empty() {
&routes.browse
} else {
&routes.search
};
let batch = serialize_batch_paths(batch_paths);
let mut params = vec![
("mount", current_mount_id.to_string()),
("path", current_path.to_string()),
("batch", batch),
];
if !current_query.is_empty() {
params.push(("q", current_query.to_string()));
}
if !selected_relative_path.is_empty() {
params.push(("selected", selected_relative_path.to_string()));
}
if let Some(sort_key) = table_sort.key {
params.push(("sort", table_sort_key_name(sort_key).to_string()));
if table_sort.desc {
params.push(("desc", "1".to_string()));
}
}
params.extend(
extra_params
.iter()
.map(|(key, value)| (*key, value.clone())),
);
let query = params
.into_iter()
.map(|(key, value)| format!("{key}={}", urlencoding::encode(&value)))
.collect::<Vec<_>>()
.join("&");
if query.is_empty() {
base_route.to_string()
} else {
format!("{base_route}?{query}")
}
}
fn sort_toggle_url(
routes: &crate::RoutePaths,
current_mount_id: &str,
current_path: &str,
current_query: &str,
selected_relative_path: &str,
batch_paths: &[String],
current_sort: TableSort,
requested_key: TableSortKey,
) -> String {
let next_sort = TableSort {
key: Some(requested_key),
desc: current_sort.key == Some(requested_key) && !current_sort.desc,
};
table_state_url(
routes,
current_mount_id,
current_path,
current_query,
selected_relative_path,
batch_paths,
next_sort,
&[],
)
}
fn table_sort_key_name(key: TableSortKey) -> &'static str {
match key {
TableSortKey::Name => "name",
TableSortKey::Size => "size",
TableSortKey::Modified => "modified",
}
}
fn sort_entries(entries: &mut [crate::FileEntry], table_sort: TableSort) {
let Some(sort_key) = table_sort.key else {
return;
};
entries.sort_by(|left, right| {
let ordering = match sort_key {
TableSortKey::Name => left
.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.relative_path.cmp(&right.relative_path)),
TableSortKey::Size => left
.size_bytes
.cmp(&right.size_bytes)
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())),
TableSortKey::Modified => left
.modified_unix_seconds
.cmp(&right.modified_unix_seconds)
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())),
};
if table_sort.desc {
ordering.reverse()
} else {
ordering
}
});
}
fn is_textish(mime_type: &str, extension: Option<&str>) -> bool {
mime_type.starts_with("text/")
|| matches!(
mime_type,
"application/json"
| "application/javascript"
| "application/toml"
| "application/xml"
| "application/x-yaml"
)
|| matches!(
extension,
Some(
"rs" | "toml"
| "json"
| "md"
| "txt"
| "yml"
| "yaml"
| "csv"
| "xml"
| "css"
| "html"
| "js"
| "ts"
| "tsx"
| "sql"
| "log"
)
)
}
#[cfg(test)]
mod tests {
use std::{fs, io::Cursor, time::Duration};
use ::actix_web::{App, http::StatusCode, test};
use tempfile::tempdir;
use zip::ZipArchive;
use super::FileServer;
use crate::{FileMount, FileServerConfig};
#[actix_web::test]
async fn browse_works_through_actix_scope() {
let temp_dir = tempdir().unwrap();
fs::write(temp_dir.path().join("demo.txt"), "hello").unwrap();
let app = test::init_service(
App::new().service(
FileServer::new(FileServerConfig::new(temp_dir.path()).with_mount_path("/files"))
.scope(),
),
)
.await;
let response = test::call_service(
&app,
test::TestRequest::get().uri("/files/browse").to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
assert!(body.contains("demo.txt"));
assert!(body.contains("storage-meter"));
}
#[actix_web::test]
async fn json_api_and_raw_download_work_through_actix_scope() {
let temp_dir = tempdir().unwrap();
fs::create_dir_all(temp_dir.path().join("nested")).unwrap();
fs::write(temp_dir.path().join("nested/demo.txt"), "hello").unwrap();
let app = test::init_service(
App::new().service(
FileServer::new(FileServerConfig::new(temp_dir.path()).with_mounts(vec![
FileMount::new("data", "Data", temp_dir.path()),
FileMount::new("archive", "Archive", temp_dir.path()),
]))
.scope(),
),
)
.await;
let response = test::call_service(
&app,
test::TestRequest::get()
.uri("/files/api/entries?mount=data&path=nested")
.to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
assert!(body.contains("\"mount_id\":\"data\""));
assert!(body.contains("\"relative_path\":\"nested/demo.txt\""));
assert!(body.contains("/files/raw/nested/demo.txt?mount=data"));
let response = test::call_service(
&app,
test::TestRequest::get()
.uri("/files/raw/nested/demo.txt?mount=data")
.to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain"
);
assert_eq!(
response.headers().get("content-disposition").unwrap(),
"attachment; filename=\"demo.txt\""
);
let body = test::read_body(response).await;
assert_eq!(body.as_ref(), b"hello");
}
#[actix_web::test]
async fn archive_job_endpoints_work_through_actix_scope() {
let temp_dir = tempdir().unwrap();
fs::create_dir_all(temp_dir.path().join("nested/deeper")).unwrap();
fs::write(temp_dir.path().join("nested/demo.txt"), "demo").unwrap();
fs::write(temp_dir.path().join("nested/deeper/hero.png"), "png").unwrap();
let app = test::init_service(
App::new().service(FileServer::new(FileServerConfig::new(temp_dir.path())).scope()),
)
.await;
let response = test::call_service(
&app,
test::TestRequest::get()
.uri("/files/download-selected?mode=archive&structure=preserve&batch=nested")
.to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
let status_url = extract_attr(&body, "data-archive-status-url=\"");
let file_url = extract_attr(&body, "data-archive-file-url=\"");
for _ in 0..20 {
let response =
test::call_service(&app, test::TestRequest::get().uri(&status_url).to_request())
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
if body.contains("\"phase\":\"ready\"") {
break;
}
::actix_web::rt::time::sleep(Duration::from_millis(25)).await;
}
let response =
test::call_service(&app, test::TestRequest::get().uri(&file_url).to_request()).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-disposition").unwrap(),
"attachment; filename=\"slash-files-batch.zip\""
);
let body = test::read_body(response).await;
let mut archive = ZipArchive::new(Cursor::new(body.to_vec())).unwrap();
let mut names = (0..archive.len())
.map(|index| archive.by_index(index).unwrap().name().to_string())
.collect::<Vec<_>>();
names.sort();
assert_eq!(names, vec!["nested/deeper/hero.png", "nested/demo.txt"]);
}
#[actix_web::test]
async fn move_job_endpoints_work_through_actix_scope() {
let data_dir = tempdir().unwrap();
let archive_dir = tempdir().unwrap();
fs::create_dir_all(data_dir.path().join("lib/src")).unwrap();
fs::write(data_dir.path().join("lib/src/main.rs"), "fn main() {}\n").unwrap();
let app = test::init_service(
App::new().service(
FileServer::new(FileServerConfig::new(data_dir.path()).with_mounts(vec![
FileMount::new("data", "Data", data_dir.path()),
FileMount::new("archive", "Archive", archive_dir.path()),
]))
.scope(),
),
)
.await;
let response = test::call_service(
&app,
test::TestRequest::get()
.uri("/files/move-selected?mount=data&target_mount=archive&batch=lib%2Fsrc%2Fmain.rs")
.to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
let status_url = extract_attr(&body, "data-move-status-url=\"");
for _ in 0..20 {
let response =
test::call_service(&app, test::TestRequest::get().uri(&status_url).to_request())
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
if body.contains("\"phase\":\"ready\"") {
break;
}
::actix_web::rt::time::sleep(Duration::from_millis(25)).await;
}
assert!(!data_dir.path().join("lib/src/main.rs").exists());
assert_eq!(
fs::read_to_string(archive_dir.path().join("lib/src/main.rs")).unwrap(),
"fn main() {}\n"
);
}
fn extract_attr(body: &str, marker: &str) -> String {
let start = body.find(marker).unwrap() + marker.len();
let rest = &body[start..];
let end = rest.find('"').unwrap();
rest[..end].to_string()
}
}