20260726 修复rpki-explorer体验问题32项(#130):RocksDB max_open_files+compose ulimits、objects列表流式早停+scope前置(每页2.85s->10ms级)、未知/过期run统一404、runs倒序、导出文件名Content-Disposition、路径段percent_decode、nginx /api/v1代理与400回SPA;前端serialNumber schema、30s请求超时、reason筛选、组合VRP查询、导出反馈与repo/PP入口、键盘可达、cursor进URL、分页/对比度/skip-link等体验项

This commit is contained in:
2026-07-27 13:05:56 +08:00
parent 001f13209f
commit cf68abf642
36 changed files with 1082 additions and 125 deletions

View File

@@ -542,7 +542,18 @@ impl ApiError {
impl From<QueryDbError> for ApiError {
fn from(value: QueryDbError) -> Self {
Self::new(500, value.to_string())
match value {
// The run is still indexed but its report.json was removed by
// retention cleanup: this is a client-visible "gone", not a
// server fault.
QueryDbError::RunDataGone(run_id) => Self::new(
404,
format!(
"report data for run {run_id} has expired and was cleaned up by retention"
),
),
other => Self::new(500, other.to_string()),
}
}
}
@@ -555,11 +566,10 @@ fn route_request(
let (path, query) = split_target(target);
let query = parse_query(query);
let path = path.trim_end_matches('/');
let segments = path
.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
let decoded_segments = decode_path_segments(path);
let segments = decoded_segments
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
if segments.is_empty() {
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
@@ -1002,11 +1012,10 @@ fn route_post_request(
) -> Result<Value, ApiError> {
let (path, _) = split_target(target);
let path = path.trim_end_matches('/');
let segments = path
.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
let decoded_segments = decode_path_segments(path);
let segments = decoded_segments
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
match segments.as_slice() {
["runs", raw_run_id, "exports"] => {
@@ -1159,11 +1168,10 @@ fn route_raw_request(
) -> Option<Result<Vec<u8>, ApiError>> {
let (path, _) = split_target(target);
let path = path.trim_end_matches('/');
let segments = path
.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
let decoded_segments = decode_path_segments(path);
let segments = decoded_segments
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
match segments.as_slice() {
["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
@@ -1224,7 +1232,20 @@ fn export_download_response(
.as_ref()
.ok_or_else(|| ApiError::new(404, "export output path not found"))?;
let bytes = fs::read(path).map_err(|err| ApiError::new(404, err.to_string()))?;
Ok(binary_response(200, "application/x-tar", bytes))
let filename = format!(
"rpki-export-{}-{}.tar",
sanitize_filename_token(&run_id),
sanitize_filename_token(job_id)
);
Ok(binary_response_with_headers(
200,
"application/x-tar",
&[(
"Content-Disposition".to_string(),
format!("attachment; filename=\"{filename}\""),
)],
bytes,
))
}
fn export_job_store_key(run_id: &str, job_id: &str) -> String {
@@ -2098,6 +2119,18 @@ fn hex_value(byte: u8) -> Option<u8> {
}
}
/// Split an API path into percent-decoded segments. Real-world ids are plain
/// hex, so decoding is a no-op for them; it only matters for hand-built URLs
/// (e.g. an id containing a literal `%2F`).
fn decode_path_segments(path: &str) -> Vec<String> {
path.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|segment| percent_decode(segment).unwrap_or_else(|| segment.to_string()))
.collect()
}
fn limit(query: &BTreeMap<String, String>) -> usize {
query
.get("limit")
@@ -2248,13 +2281,26 @@ fn json_response(status: u16, value: &Value) -> Vec<u8> {
}
fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
binary_response_with_headers(status, content_type, &[], body)
}
fn binary_response_with_headers(
status: u16,
content_type: &str,
extra_headers: &[(String, String)],
body: Vec<u8>,
) -> Vec<u8> {
let reason = match status {
200 => "OK",
404 => "Not Found",
_ => "Internal Server Error",
};
let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n");
for (name, value) in extra_headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
let mut response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
"{head}Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
@@ -2262,6 +2308,15 @@ fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
response
}
/// Header-safe token for use inside a `Content-Disposition` filename: strips
/// anything that could smuggle CRLF or quotes into the response head.
fn sanitize_filename_token(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
.collect()
}
fn is_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
@@ -2626,6 +2681,94 @@ mod tests {
);
}
fn index_sample_run(temp: &tempfile::TempDir) -> QueryDb {
let run_dir = temp.path().join("runs/run_0001");
fs::create_dir_all(&run_dir).expect("run dir");
let object_bytes =
fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
.expect("fixture roa");
let object_sha = hex::encode(Sha256::digest(&object_bytes));
write_sample_run(&run_dir, &object_sha);
let query_db_path = temp.path().join("query-db");
index_artifacts(&ArtifactIndexerConfig {
query_db_path: query_db_path.clone(),
run_root: Some(temp.path().to_path_buf()),
run_dir: None,
repo_bytes_db_path: None,
projection_entry_limit: 5,
min_run_seq: None,
retain_indexed_runs: None,
})
.expect("index artifacts");
QueryDb::open(&query_db_path).expect("query db")
}
#[test]
fn unknown_run_id_returns_404_instead_of_empty_payload() {
let temp = tempfile::tempdir().expect("tempdir");
let db = index_sample_run(&temp);
for target in [
"/api/v1/runs/run_9999",
"/api/v1/runs/run_9999/summary",
"/api/v1/runs/run_9999/objects",
"/api/v1/runs/run_9999/issues",
"/api/v1/runs/run_9999/stats/overview",
"/api/v1/runs/run_9999/stats/validation-events?name=manifest",
] {
let err = match test_route_request(&db, None, target) {
Ok(_) => panic!("expected 404 for {target}"),
Err(err) => err,
};
assert_eq!(err.status, 404, "{target}");
}
// Existing runs and "latest" keep working.
assert!(
test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
);
assert!(test_route_request(&db, None, "/api/v1/runs/latest/objects").is_ok());
}
#[test]
fn expired_run_report_returns_404_run_data_gone() {
let temp = tempfile::tempdir().expect("tempdir");
let db = index_sample_run(&temp);
assert!(test_route_request(&db, None, "/api/v1/runs/run_0001/objects").is_ok());
// Retention cleanup removes the run directory while the index record
// survives: object routes must report a distinguishable 404.
fs::remove_dir_all(temp.path().join("runs/run_0001")).expect("remove run dir");
let err = test_route_request(&db, None, "/api/v1/runs/run_0001/objects")
.expect_err("run data gone");
assert_eq!(err.status, 404);
assert!(
err.message.contains("expired"),
"unexpected message: {}",
err.message
);
let err = test_route_request(&db, None, "/api/v1/runs/run_0001/issues")
.expect_err("run data gone");
assert_eq!(err.status, 404);
// Stats served from the index keep working.
assert!(
test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
);
}
#[test]
fn decode_path_segments_percent_decodes_ids() {
let segments = decode_path_segments("/api/v1/runs/run_0001/objects/abc%2Fdef");
assert_eq!(segments, vec!["runs", "run_0001", "objects", "abc/def"]);
assert!(decode_path_segments("/api/v1").is_empty());
assert!(decode_path_segments("/api/v1/").is_empty());
}
#[test]
fn sanitize_filename_token_strips_crlf_and_quotes() {
assert_eq!(sanitize_filename_token("run_0001"), "run_0001");
assert_eq!(sanitize_filename_token("a\r\nb\"c d/e"), "abcde");
}
#[test]
fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -2813,6 +2956,31 @@ mod tests {
assert_eq!(job.status, "complete");
assert_eq!(job.object_count, 1);
assert!(job.bytes_written > 0);
let download = test_route_raw_request(
&db,
None,
&format!("/api/v1/runs/latest/exports/{job_id}/download"),
)
.expect("download route")
.expect("download response");
let head_end = download
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("header separator");
let head = String::from_utf8_lossy(&download[..head_end]);
assert!(head.starts_with("HTTP/1.1 200 OK"), "head: {head}");
assert!(
head.contains("Content-Type: application/x-tar\r\n"),
"head: {head}"
);
let expected_disposition = format!(
"Content-Disposition: attachment; filename=\"rpki-export-run_0001-{job_id}.tar\""
);
assert!(
head.contains(&expected_disposition),
"head: {head}"
);
}
#[test]