From dd1e6d0409d09f5f5c9beacf02ea5cb9e71f15d6 Mon Sep 17 00:00:00 2001 From: kqjy Date: Sat, 25 Apr 2026 14:06:39 +0800 Subject: [PATCH] Stop search auto-pagination from looping on failure; accept CSRF in JSON body; make replication pause/resume idempotent --- crates/myfsio-server/src/handlers/ui_api.rs | 13 +- crates/myfsio-server/src/handlers/ui_pages.rs | 33 ++-- .../myfsio-server/src/middleware/session.rs | 12 +- .../static/js/bucket-detail-main.js | 152 +++++++++++++----- .../templates/bucket_detail.html | 18 ++- crates/myfsio-server/tests/template_render.rs | 2 + 6 files changed, 169 insertions(+), 61 deletions(-) diff --git a/crates/myfsio-server/src/handlers/ui_api.rs b/crates/myfsio-server/src/handlers/ui_api.rs index 7e8f84f..8c681b3 100644 --- a/crates/myfsio-server/src/handlers/ui_api.rs +++ b/crates/myfsio-server/src/handlers/ui_api.rs @@ -1213,6 +1213,8 @@ pub struct SearchObjectsQuery { pub prefix: Option, #[serde(default)] pub limit: Option, + #[serde(default)] + pub start_after: Option, } pub async fn search_bucket_objects( @@ -1228,14 +1230,18 @@ pub async fn search_bucket_objects( let term = q.q.unwrap_or_default().to_lowercase(); let limit = q.limit.unwrap_or(500).clamp(1, 1000); let prefix = q.prefix.clone().unwrap_or_default(); + let start_after = q.start_after.clone().filter(|s| !s.is_empty()); if term.is_empty() { - return Json(json!({ "results": [], "truncated": false })).into_response(); + return Json(json!({ "results": [], "truncated": false, "next_token": Value::Null })) + .into_response(); } let mut results: Vec = Vec::new(); let mut truncated = false; + let mut last_match_key: Option = None; let mut token: Option = None; + let mut start_after_arg = start_after; loop { let params = ListParams { max_keys: 1000, @@ -1245,7 +1251,7 @@ pub async fn search_bucket_objects( } else { Some(prefix.clone()) }, - start_after: None, + start_after: start_after_arg.take(), }; match state.storage.list_objects(&bucket_name, ¶ms).await { Ok(res) => { @@ -1255,6 +1261,7 @@ pub async fn search_bucket_objects( truncated = true; break; } + last_match_key = Some(o.key.clone()); results.push(object_json(&bucket_name, o)); } } @@ -1270,9 +1277,11 @@ pub async fn search_bucket_objects( } } + let next_token = if truncated { last_match_key } else { None }; Json(json!({ "results": results, "truncated": truncated, + "next_token": next_token, })) .into_response() } diff --git a/crates/myfsio-server/src/handlers/ui_pages.rs b/crates/myfsio-server/src/handlers/ui_pages.rs index 5f13a7b..2214609 100644 --- a/crates/myfsio-server/src/handlers/ui_pages.rs +++ b/crates/myfsio-server/src/handlers/ui_pages.rs @@ -427,11 +427,17 @@ pub async fn bucket_detail( let target_conn = replication_rule .as_ref() .and_then(|rule| state.connections.get(&rule.target_connection_id)); - let versioning_enabled = state + let versioning_status_enum = state .storage - .is_versioning_enabled(&bucket_name) + .get_versioning_status(&bucket_name) .await - .unwrap_or(false); + .unwrap_or(myfsio_common::types::VersioningStatus::Disabled); + let versioning_enabled = + matches!(versioning_status_enum, myfsio_common::types::VersioningStatus::Enabled); + let versioning_suspended = matches!( + versioning_status_enum, + myfsio_common::types::VersioningStatus::Suspended + ); let encryption_config = config_encryption_to_ui(bucket_config.encryption.as_ref()); let website_config = config_website_to_ui(bucket_config.website.as_ref()); let quota = bucket_config.quota.clone(); @@ -491,12 +497,13 @@ pub async fn bucket_detail( ); ctx.insert("has_quota", "a.is_some()); ctx.insert("versioning_enabled", &versioning_enabled); + ctx.insert("versioning_suspended", &versioning_suspended); ctx.insert( "versioning_status", - &(if versioning_enabled { - "Enabled" - } else { - "Disabled" + &(match versioning_status_enum { + myfsio_common::types::VersioningStatus::Enabled => "Enabled", + myfsio_common::types::VersioningStatus::Suspended => "Suspended", + myfsio_common::types::VersioningStatus::Disabled => "Disabled", }), ); ctx.insert("encryption_config", &encryption_config); @@ -2361,10 +2368,10 @@ pub async fn update_bucket_replication( "pause" => { let Some(mut rule) = state.replication.get_rule(&bucket_name) else { return respond( - false, - StatusCode::NOT_FOUND, + true, + StatusCode::OK, "No replication configuration to pause.".to_string(), - json!({ "error": "No replication configuration to pause" }), + json!({ "action": "pause", "enabled": false, "no_op": true }), ); }; rule.enabled = false; @@ -2379,10 +2386,10 @@ pub async fn update_bucket_replication( "resume" => { let Some(mut rule) = state.replication.get_rule(&bucket_name) else { return respond( - false, - StatusCode::NOT_FOUND, + true, + StatusCode::OK, "No replication configuration to resume.".to_string(), - json!({ "error": "No replication configuration to resume" }), + json!({ "action": "resume", "enabled": false, "no_op": true }), ); }; rule.enabled = true; diff --git a/crates/myfsio-server/src/middleware/session.rs b/crates/myfsio-server/src/middleware/session.rs index ee2bcc4..3b36417 100644 --- a/crates/myfsio-server/src/middleware/session.rs +++ b/crates/myfsio-server/src/middleware/session.rs @@ -155,6 +155,8 @@ pub async fn csrf_layer( extract_form_token(&bytes) } else if content_type.starts_with("multipart/form-data") { extract_multipart_token(&content_type, &bytes) + } else if content_type.starts_with("application/json") { + extract_json_token(&bytes) } else { None }; @@ -194,7 +196,7 @@ pub async fn csrf_layer( let mut resp = ( StatusCode::FORBIDDEN, [(header::CONTENT_TYPE, "application/json")], - r#"{"error":"Invalid CSRF token"}"#, + r#"{"error":"Invalid CSRF token. Send it via the X-CSRF-Token header or a csrf_token field in the form/JSON body."}"#, ) .into_response(); *resp.status_mut() = StatusCode::FORBIDDEN; @@ -238,6 +240,14 @@ fn build_session_cookie(id: &str, secure: bool) -> Cookie<'static> { cookie } +fn extract_json_token(body: &[u8]) -> Option { + let value: serde_json::Value = serde_json::from_slice(body).ok()?; + value + .get(CSRF_FIELD_NAME) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + fn extract_form_token(body: &[u8]) -> Option { let text = std::str::from_utf8(body).ok()?; let prefix = format!("{}=", CSRF_FIELD_NAME); diff --git a/crates/myfsio-server/static/js/bucket-detail-main.js b/crates/myfsio-server/static/js/bucket-detail-main.js index 63e2672..7a7ff79 100644 --- a/crates/myfsio-server/static/js/bucket-detail-main.js +++ b/crates/myfsio-server/static/js/bucket-detail-main.js @@ -552,7 +552,13 @@ let scrollTimeout = null; const handleVirtualScroll = () => { if (scrollTimeout) cancelAnimationFrame(scrollTimeout); - scrollTimeout = requestAnimationFrame(renderVirtualRows); + scrollTimeout = requestAnimationFrame(() => { + renderVirtualRows(); + const c = document.querySelector('.objects-table-container'); + if (c && c.scrollHeight - c.scrollTop - c.clientHeight < 500) { + if (typeof loadMoreOnSentinel === 'function') loadMoreOnSentinel(); + } + }); }; const refreshVirtualList = () => { @@ -563,6 +569,11 @@ if (allObjects.length === 0 && streamFolders.length === 0 && !hasMoreObjects) { showEmptyState(); } else { + const isFiltering = currentFilterTerm && currentFilterTerm.length > 0; + const title = isFiltering ? 'No matches' : 'Empty folder'; + const body = isFiltering + ? `No objects match "${escapeHtml(currentFilterTerm)}".` + : `This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}`; objectsTableBody.innerHTML = ` @@ -572,8 +583,8 @@ -
Empty folder
-

This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}

+
${title}
+

${body}

@@ -977,12 +988,32 @@ scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true }); } + const isSentinelVisible = () => { + if (!scrollSentinel) return false; + const rect = scrollSentinel.getBoundingClientRect(); + if (scrollContainer) { + const cr = scrollContainer.getBoundingClientRect(); + return rect.top <= cr.bottom + 500 && rect.bottom >= cr.top - 500; + } + return rect.top <= window.innerHeight + 500 && rect.bottom >= -500; + }; + + const loadMoreOnSentinel = () => { + if (searchResults !== null) { + if (searchNextToken && !searchLoading) { + performServerSearch(currentFilterTerm, true); + } + return; + } + if (hasMoreObjects && !isLoadingObjects) { + loadObjects(true); + } + }; + if (scrollSentinel && scrollContainer) { const containerObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { - if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) { - loadObjects(true); - } + if (entry.isIntersecting) loadMoreOnSentinel(); }); }, { root: scrollContainer, @@ -993,9 +1024,7 @@ const viewportObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { - if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) { - loadObjects(true); - } + if (entry.isIntersecting) loadMoreOnSentinel(); }); }, { root: null, @@ -1231,6 +1260,11 @@ }); if (folders.length === 0 && files.length === 0) { + const isFiltering = currentFilterTerm && currentFilterTerm.length > 0; + const title = isFiltering ? 'No matches' : 'Empty folder'; + const body = isFiltering + ? `No objects match "${escapeHtml(currentFilterTerm)}".` + : 'This folder contains no objects.'; const emptyRow = document.createElement('tr'); emptyRow.innerHTML = ` @@ -1240,8 +1274,8 @@ -
Empty folder
-

This folder contains no objects.

+
${title}
+

${body}

`; @@ -1421,6 +1455,11 @@ }); const bulkActionsWrapper = document.getElementById('bulk-actions-wrapper'); + const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]'); + const updateBulkDownloadState = () => { + if (!bulkDownloadButton) return; + bulkDownloadButton.disabled = selectedRows.size === 0; + }; const updateBulkDeleteState = () => { const selectedCount = selectedRows.size; if (bulkDeleteButton) { @@ -1447,6 +1486,7 @@ selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0; selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total; } + updateBulkDownloadState(); }; function toggleRowSelection(row, shouldSelect) { @@ -2274,47 +2314,69 @@ const filterWarningText = document.getElementById('filter-warning-text'); const folderViewStatus = document.getElementById('folder-view-status'); - const updateFilterWarning = () => { - if (!filterWarning) return; - const isFiltering = currentFilterTerm.length > 0; - if (isFiltering && hasMoreObjects) { - filterWarning.classList.remove('d-none'); - } else { - filterWarning.classList.add('d-none'); - } - }; - let searchDebounceTimer = null; let searchAbortController = null; let searchResults = null; + let searchNextToken = null; + let searchLoading = false; + const SEARCH_PAGE_SIZE = 500; - const performServerSearch = async (term) => { - if (searchAbortController) searchAbortController.abort(); - searchAbortController = new AbortController(); + const updateFilterWarning = () => { + if (!filterWarning) return; + filterWarning.classList.add('d-none'); + }; + const performServerSearch = async (term, append = false) => { + if (!append && searchAbortController) searchAbortController.abort(); + if (append && (searchLoading || !searchNextToken)) return; + if (!append) { + searchAbortController = new AbortController(); + } + searchLoading = true; + if (append && loadMoreSpinner) loadMoreSpinner.classList.remove('d-none'); + + let succeeded = false; try { - const params = new URLSearchParams({ q: term, limit: '500' }); + const params = new URLSearchParams({ q: term, limit: String(SEARCH_PAGE_SIZE) }); if (currentPrefix) params.set('prefix', currentPrefix); + if (append && searchNextToken) params.set('start_after', searchNextToken); const searchUrl = objectsStreamUrl.replace('/stream', '/search'); const response = await fetch(`${searchUrl}?${params}`, { - signal: searchAbortController.signal + signal: searchAbortController?.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); - searchResults = (data.results || []).map(obj => processStreamObject(obj)); + const newResults = (data.results || []).map(obj => processStreamObject(obj)); + if (append && Array.isArray(searchResults)) { + searchResults = searchResults.concat(newResults); + } else { + searchResults = newResults; + } + searchNextToken = data.truncated ? (data.next_token || null) : null; memoizedVisibleItems = null; memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null }; refreshVirtualList(); if (loadMoreStatus) { const countText = searchResults.length.toLocaleString(); - const truncated = data.truncated ? '+' : ''; - loadMoreStatus.textContent = `${countText}${truncated} result${searchResults.length !== 1 ? 's' : ''}`; + const more = searchNextToken ? '+' : ''; + const noun = searchResults.length === 1 ? 'result' : 'results'; + loadMoreStatus.textContent = searchNextToken + ? `${countText}${more} ${noun} (scroll to load more)` + : `${countText} ${noun}`; } + succeeded = true; } catch (e) { if (e.name === 'AbortError') return; if (loadMoreStatus) { - loadMoreStatus.textContent = 'Search failed'; + loadMoreStatus.textContent = 'Search failed (scroll to retry)'; } + } finally { + searchLoading = false; + if (loadMoreSpinner) loadMoreSpinner.classList.add('d-none'); + } + + if (succeeded && searchNextToken && !searchLoading && isSentinelVisible()) { + performServerSearch(currentFilterTerm, true); } }; @@ -2334,6 +2396,7 @@ if (!isFiltering && wasFiltering) { if (searchAbortController) searchAbortController.abort(); searchResults = null; + searchNextToken = null; memoizedVisibleItems = null; memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null }; if (loadMoreStatus) { @@ -3311,15 +3374,8 @@ } } - const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]'); const bulkDownloadEndpoint = document.getElementById('objects-drop-zone')?.dataset.bulkDownloadEndpoint; - const updateBulkDownloadState = () => { - if (!bulkDownloadButton) return; - const selectedCount = document.querySelectorAll('[data-object-select]:checked').length; - bulkDownloadButton.disabled = selectedCount === 0; - }; - selectAllCheckbox?.addEventListener('change', (event) => { const shouldSelect = Boolean(event.target?.checked); @@ -3354,7 +3410,6 @@ }); updateBulkDeleteState(); - setTimeout(updateBulkDownloadState, 0); }); bulkDownloadButton?.addEventListener('click', async () => { @@ -4402,10 +4457,25 @@ }); if (lifecycleHistoryCard) { - loadLifecycleHistory(); - if (window.pollingManager) { - window.pollingManager.start('lifecycle', loadLifecycleHistory); + const lifecycleTab = document.getElementById('lifecycle-tab'); + const lifecyclePane = document.getElementById('lifecycle-pane'); + const startLifecyclePolling = () => { + if (window.pollingManager) { + window.pollingManager.start('lifecycle', loadLifecycleHistory); + } else { + loadLifecycleHistory(); + } + }; + const stopLifecyclePolling = () => { + if (window.pollingManager) { + window.pollingManager.stop('lifecycle'); + } + }; + if (lifecyclePane && lifecyclePane.classList.contains('show') && lifecyclePane.classList.contains('active')) { + startLifecyclePolling(); } + lifecycleTab?.addEventListener('shown.bs.tab', startLifecyclePolling); + lifecycleTab?.addEventListener('hidden.bs.tab', stopLifecyclePolling); } if (corsCard) loadCorsRules(); diff --git a/crates/myfsio-server/templates/bucket_detail.html b/crates/myfsio-server/templates/bucket_detail.html index f968b6a..bec6dca 100644 --- a/crates/myfsio-server/templates/bucket_detail.html +++ b/crates/myfsio-server/templates/bucket_detail.html @@ -19,11 +19,11 @@

{{ bucket_name }}

- + - {% if versioning_enabled %}Versioning On{% else %}Versioning Off{% endif %} + {% if versioning_enabled %}Versioning On{% elif versioning_suspended %}Versioning Suspended{% else %}Versioning Off{% endif %} @@ -626,6 +626,16 @@

All previous versions of objects are preserved. You can roll back accidental changes or deletions at any time.

+ {% elif versioning_suspended %} + {% else %} {% endif %} diff --git a/crates/myfsio-server/tests/template_render.rs b/crates/myfsio-server/tests/template_render.rs index cd4bd55..c8edbe3 100644 --- a/crates/myfsio-server/tests/template_render.rs +++ b/crates/myfsio-server/tests/template_render.rs @@ -288,6 +288,7 @@ fn render_bucket_detail() { ctx.insert("bytes_pct", &0); ctx.insert("has_quota", &false); ctx.insert("versioning_enabled", &false); + ctx.insert("versioning_suspended", &false); ctx.insert("versioning_status", &"Disabled"); ctx.insert("encryption_config", &json!({"Rules": []})); ctx.insert("enc_rules", &Vec::::new()); @@ -369,6 +370,7 @@ fn render_bucket_detail_without_error_document() { ctx.insert("bytes_pct", &0); ctx.insert("has_quota", &false); ctx.insert("versioning_enabled", &false); + ctx.insert("versioning_suspended", &false); ctx.insert("versioning_status", &"Disabled"); ctx.insert("encryption_config", &json!({"Rules": []})); ctx.insert("enc_rules", &Vec::::new());