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:
2026-10-30 12:33:52 +08:00
parent 00e0360c85
commit 94e16b783c
13 changed files with 891 additions and 47 deletions
+110 -1
View File
@@ -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");
}
}