feat(admin): 跳转规范化、侧栏子分类与归类管理
- 条目保存时规范化 href(裸域名补 https,拒绝 javascript:/data: 等协议),详情按钮按解析结果置灰/跳转
- settings.sidebar 增加 hidden_groups / shown_groups / group_order,侧栏 Tab 支持分组显隐与排序
- 新增 PATCH /api/categories/{id} 与「归类」Tab:编辑类型/标题/图标/色条/条数;改类型自动迁移条目与首页卡片
- 测试 54 passed(href 规范化 3 例、分类更新 3 例)
This commit is contained in:
+110
-1
@@ -62,6 +62,81 @@ fn now() -> i64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 规范化跳转地址:补协议、拒绝危险协议、空值统一成空串。
|
||||
///
|
||||
/// 用户经常直接填 `www.zhihu.com`。原样存进 `href` 会被浏览器当成
|
||||
/// 相对地址(点「跳转到」会跳到 `/c/.../www.zhihu.com`),所以这里统一补
|
||||
/// `https://`。`href` 会原样渲染进 `<a>`,`javascript:` / `data:` 这类
|
||||
/// 协议必须直接拒绝,不能只靠前端过滤。
|
||||
fn normalize_href(raw: &str) -> Result<String, StashError> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() || value == "#" {
|
||||
return Ok(String::new());
|
||||
}
|
||||
if value.chars().any(char::is_whitespace) {
|
||||
return Err(StashError::InvalidDirectory("跳转地址不能包含空格".into()));
|
||||
}
|
||||
|
||||
// 站内路径(单个斜杠开头);`//host` 是协议相对地址,按外链处理
|
||||
if value.starts_with('/') && !value.starts_with("//") {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
if value.starts_with("//") {
|
||||
return Ok(format!("https:{value}"));
|
||||
}
|
||||
|
||||
// 带「其他协议」的地址一律拒绝(javascript: / data: / vbscript: / file: / mailto: …);
|
||||
// 只放行上面已经处理过的 http(s) 与站内路径。
|
||||
if let Some((scheme, _)) = lower.split_once(':') {
|
||||
let looks_like_scheme = !scheme.is_empty()
|
||||
&& scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
|
||||
&& scheme
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.');
|
||||
if looks_like_scheme && !looks_like_domain(value) {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"跳转地址只支持 http(s)、站内路径或域名".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if looks_like_domain(value) {
|
||||
return Ok(format!("https://{value}"));
|
||||
}
|
||||
|
||||
Err(StashError::InvalidDirectory(
|
||||
"跳转地址需以 http(s):// 或 / 开头".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 裸域名判断:`www.zhihu.com`、`example.com:8080/path` 这类没有协议头的地址。
|
||||
fn looks_like_domain(value: &str) -> bool {
|
||||
let host_port = value.split('/').next().unwrap_or("");
|
||||
let host = host_port.split(':').next().unwrap_or("");
|
||||
if !host.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut parts = host.split('.');
|
||||
let Some(tld) = parts.next_back() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
tld.len() >= 2
|
||||
&& tld.chars().all(|ch| ch.is_ascii_alphabetic())
|
||||
&& parts.all(|part| {
|
||||
!part.is_empty()
|
||||
&& part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
|
||||
if patch.name.trim().is_empty() {
|
||||
return Err(StashError::InvalidDirectory("标题不能为空".into()));
|
||||
@@ -143,6 +218,8 @@ pub fn update(
|
||||
expected_updated_at: i64,
|
||||
) -> Result<EntryRecord, StashError> {
|
||||
validate(&patch)?;
|
||||
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
|
||||
let href = normalize_href(&patch.href)?;
|
||||
|
||||
let tags: Vec<String> = patch
|
||||
.tags
|
||||
@@ -165,7 +242,7 @@ pub fn update(
|
||||
patch.name.trim(),
|
||||
patch.description,
|
||||
patch.icon,
|
||||
patch.href,
|
||||
href,
|
||||
patch.cover,
|
||||
patch.banner,
|
||||
patch.badge,
|
||||
@@ -331,4 +408,36 @@ mod tests {
|
||||
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
|
||||
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_links_so_jump_works() {
|
||||
// 裸域名补 https,避免被浏览器当成相对地址
|
||||
assert_eq!(normalize_href("www.zhihu.com").unwrap(), "https://www.zhihu.com");
|
||||
assert_eq!(
|
||||
normalize_href("example.com:8080/path").unwrap(),
|
||||
"https://example.com:8080/path"
|
||||
);
|
||||
// 已带协议的保持原样;站内路径保持站内
|
||||
assert_eq!(normalize_href("https://example.com/a").unwrap(), "https://example.com/a");
|
||||
assert_eq!(normalize_href("/git_helper/").unwrap(), "/git_helper/");
|
||||
// 空值和占位符统一成空串
|
||||
assert_eq!(normalize_href(" ").unwrap(), "");
|
||||
assert_eq!(normalize_href("#").unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dangerous_or_unknown_links() {
|
||||
for bad in ["javascript:alert(1)", "data:text/html,hi", "mailto:[email protected]", "not a url"] {
|
||||
assert!(normalize_href(bad).is_err(), "{bad} 应被拒绝");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_stores_normalized_href() {
|
||||
let mut conn = db();
|
||||
let mut input = patch("知乎");
|
||||
input.href = "www.zhihu.com".into();
|
||||
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
|
||||
assert_eq!(saved.href, "https://www.zhihu.com");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user