修复客户端预览根入口 404
允许项目根布局的 index.html 和网页资源通过预览服务访问 保留 game/ 与 assets/ 旧布局路径支持 增加根入口、资源和控制目录隔离回归测试
This commit is contained in:
@@ -1455,6 +1455,42 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, Stri
|
||||
let canonical_file = file_path
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("预览文件不可用:{}: {error}", file_path.display()))?;
|
||||
|
||||
// New DirectProject layouts may use the project root itself as the web
|
||||
// root. The old allow-list below only considered `game/` and `assets/`,
|
||||
// which made a valid root `index.html` resolve to a 404 even though
|
||||
// `project_game_root` had selected it. Permit web files below the root
|
||||
// while keeping control/data directories out of the preview surface.
|
||||
if root.join("index.html").is_file() && canonical_file.starts_with(&canonical_root) {
|
||||
let relative = canonical_file
|
||||
.strip_prefix(&canonical_root)
|
||||
.map_err(|_| "预览路径越过项目目录".to_string())?;
|
||||
let first_component = relative
|
||||
.components()
|
||||
.next()
|
||||
.and_then(|component| match component {
|
||||
std::path::Component::Normal(value) => value.to_str(),
|
||||
_ => None,
|
||||
});
|
||||
let protected_root_component = first_component.is_some_and(|component| {
|
||||
[
|
||||
".agent",
|
||||
".git",
|
||||
".codex",
|
||||
".hermes",
|
||||
"memory",
|
||||
"exports",
|
||||
"node_modules",
|
||||
"target",
|
||||
]
|
||||
.iter()
|
||||
.any(|protected| component.eq_ignore_ascii_case(protected))
|
||||
});
|
||||
if !protected_root_component && content_type(&canonical_file) != "application/octet-stream" {
|
||||
return Ok(canonical_file);
|
||||
}
|
||||
}
|
||||
|
||||
for segment in ["game", "assets"] {
|
||||
let allowed_dir = root.join(segment);
|
||||
let metadata = match fs::symlink_metadata(&allowed_dir) {
|
||||
@@ -1543,3 +1579,83 @@ fn http_response(status: &str, content_type: &str, body: &[u8], content_length:
|
||||
response.extend_from_slice(body);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() {
|
||||
let root = tempfile::tempdir().expect("create preview root");
|
||||
fs::create_dir_all(root.path().join("game")).expect("create game directory");
|
||||
fs::create_dir_all(root.path().join("assets")).expect("create assets directory");
|
||||
fs::write(
|
||||
root.path().join("index.html"),
|
||||
"<!doctype html><html lang=\"zh-CN\"><body>根入口</body></html>",
|
||||
)
|
||||
.expect("write root entry");
|
||||
fs::write(
|
||||
root.path().join("game/index.html"),
|
||||
"<!doctype html><html lang=\"zh-CN\"><body>游戏入口</body></html>",
|
||||
)
|
||||
.expect("write game entry");
|
||||
fs::write(root.path().join("style.css"), "body { color: red; }")
|
||||
.expect("write root stylesheet");
|
||||
fs::write(
|
||||
root.path().join("assets/icon.png"),
|
||||
[0x89, 0x50, 0x4e, 0x47],
|
||||
)
|
||||
.expect("write asset");
|
||||
|
||||
let canonical_root_entry = root
|
||||
.path()
|
||||
.join("index.html")
|
||||
.canonicalize()
|
||||
.expect("canonical root entry");
|
||||
let canonical_game_entry = root
|
||||
.path()
|
||||
.join("game/index.html")
|
||||
.canonicalize()
|
||||
.expect("canonical game entry");
|
||||
assert_eq!(project_game_root(root.path()), root.path());
|
||||
assert_eq!(
|
||||
resolve_preview_path(root.path(), "/").unwrap(),
|
||||
canonical_root_entry
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_preview_path(root.path(), "/index.html").unwrap(),
|
||||
canonical_root_entry
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_preview_path(root.path(), "/style.css").unwrap(),
|
||||
root.path().join("style.css").canonicalize().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_preview_path(root.path(), "/game/index.html").unwrap(),
|
||||
canonical_game_entry
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_preview_path(root.path(), "/assets/icon.png").unwrap(),
|
||||
root.path().join("assets/icon.png").canonicalize().unwrap()
|
||||
);
|
||||
|
||||
let response = build_preview_response(root.path(), "GET", "/");
|
||||
let response_text = String::from_utf8_lossy(&response);
|
||||
assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n"));
|
||||
assert!(response_text.contains("根入口"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_layout_does_not_expose_control_or_data_directories() {
|
||||
let root = tempfile::tempdir().expect("create preview root");
|
||||
fs::create_dir_all(root.path().join(".agent")).expect("create agent directory");
|
||||
fs::create_dir_all(root.path().join("memory")).expect("create memory directory");
|
||||
fs::write(root.path().join("index.html"), "<!doctype html>").expect("write root entry");
|
||||
fs::write(root.path().join(".agent/secret.json"), "{}").expect("write secret");
|
||||
fs::write(root.path().join("memory/private.md"), "private").expect("write private data");
|
||||
|
||||
assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err());
|
||||
assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user