Stop search auto-pagination from looping on failure; accept CSRF in JSON body; make replication pause/resume idempotent

This commit is contained in:
2026-04-25 14:06:39 +08:00
parent 7e32ac2a46
commit dd1e6d0409
6 changed files with 169 additions and 61 deletions

View File

@@ -1213,6 +1213,8 @@ pub struct SearchObjectsQuery {
pub prefix: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub start_after: Option<String>,
}
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<Value> = Vec::new();
let mut truncated = false;
let mut last_match_key: Option<String> = None;
let mut token: Option<String> = 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, &params).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()
}

View File

@@ -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", &quota.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;

View File

@@ -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<String> {
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<String> {
let text = std::str::from_utf8(body).ok()?;
let prefix = format!("{}=", CSRF_FIELD_NAME);

View File

@@ -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 = `
<tr>
<td colspan="4" class="py-5">
@@ -572,8 +583,8 @@
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
</svg>
</div>
<h6 class="mb-2">Empty folder</h6>
<p class="text-muted small mb-0">This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}</p>
<h6 class="mb-2">${title}</h6>
<p class="text-muted small mb-0">${body}</p>
</div>
</td>
</tr>
@@ -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 = `
<td colspan="4" class="py-5">
@@ -1240,8 +1274,8 @@
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
</svg>
</div>
<h6 class="mb-2">Empty folder</h6>
<p class="text-muted small mb-0">This folder contains no objects.</p>
<h6 class="mb-2">${title}</h6>
<p class="text-muted small mb-0">${body}</p>
</div>
</td>
`;
@@ -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();

View File

@@ -19,11 +19,11 @@
<div>
<h1 class="h3 fw-bold mb-1">{{ bucket_name }}</h1>
<div class="d-flex align-items-center gap-2">
<span class="badge {% if versioning_enabled %}text-bg-success{% else %}text-bg-secondary{% endif %} rounded-pill">
<span class="badge {% if versioning_enabled %}text-bg-success{% elif versioning_suspended %}text-bg-warning{% else %}text-bg-secondary{% endif %} rounded-pill">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zm.995-14.901a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z"/>
</svg>
{% if versioning_enabled %}Versioning On{% else %}Versioning Off{% endif %}
{% if versioning_enabled %}Versioning On{% elif versioning_suspended %}Versioning Suspended{% else %}Versioning Off{% endif %}
</span>
<span class="text-muted small" id="object-count-badge">
<span class="spinner-border spinner-border-sm" role="status" style="width: 0.75rem; height: 0.75rem;"></span>
@@ -626,6 +626,16 @@
<p class="mb-0 small">All previous versions of objects are preserved. You can roll back accidental changes or deletions at any time.</p>
</div>
</div>
{% elif versioning_suspended %}
<div class="alert alert-warning d-flex align-items-start mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
</svg>
<div>
<strong>Versioning is suspended</strong>
<p class="mb-0 small">New uploads overwrite existing objects, but previously archived versions are still retained. Re-enable versioning to start preserving new versions again.</p>
</div>
</div>
{% else %}
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
@@ -633,8 +643,8 @@
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
<div>
<strong>Versioning is suspended</strong>
<p class="mb-0 small">New object uploads overwrite existing objects. Enable versioning to preserve previous versions.</p>
<strong>Versioning is disabled</strong>
<p class="mb-0 small">This bucket has never had versioning enabled. Enable it to preserve previous versions of every object.</p>
</div>
</div>
{% endif %}

View File

@@ -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::<Value>::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::<Value>::new());