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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user