yggdrasil/src/api/slug.rs
xfy 449a545886
Some checks failed
CI / build (push) Has been cancelled
CI / check (push) Has been cancelled
security: fix critical issues from repository review
P0 blockers:
- Fix migration numbering conflict and duplicate indexes
- Change comments.post_id FK to ON DELETE CASCADE
- Restrict public post detail endpoint to published posts only
- Fix rate-limiting IP extraction and fallback to ConnectInfo
- Harden HTML sanitizer: deny unknown URL schemes, restrict data URIs
- Remove session token from login response body
- Enforce image pixel/dimension limits on upload and serving

P1 high-risk:
- Validate uploads by magic bytes and decode GIF/WebP
- Add pagination/rate-limiting to search, tag posts, and comments
- Make first-admin registration and slug uniqueness check atomic
- HTML-escape comment author fields
- Improve HTML minify cache key and skip admin/error responses
- Add mobile navigation menu

P2 accessibility/quality:
- Associate form labels with inputs
- Key PostDetail article by slug to re-init scripts on navigation
- Improve image viewer keyboard accessibility
- Make theme toggle SSR-friendly and add aria-label
- Invalidate slug 404 cache on create and pending count on new comment
- Deduplicate tags case-insensitively

P3 cleanup:
- Remove unused tower-http dependency, expand make clean
- Configure DB pool timeouts and verified recycling
- Run background cleanup tasks immediately on startup
- Use SHA-256 for stable disk cache keys
- Log DB errors with Display instead of Debug
- Update README migration instructions

All tests pass (321), clippy clean, dx check clean.
2026-06-17 10:34:14 +08:00

211 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章 slug 生成与唯一性校验。
//!
//! 将标题转换为小写、仅含字母数字与连字符/下划线的 URL 友好形式,
//! 并检测数据库中是否已存在,必要时追加数字后缀。
//! 仅在 `feature = "server"` 时访问数据库。
#![allow(clippy::unused_unit, deprecated)]
#[cfg(feature = "server")]
use dioxus::prelude::*;
#[cfg(feature = "server")]
/// 将标题转换为 URL 友好的 slug。
///
/// 非字母数字字符替换为 `-` 并合并连续 `-`,结果截断至 100 字符;
/// 若全部字符被过滤,则返回当前时间戳作为 slug。
pub fn slugify(title: &str) -> String {
let slug: String = title
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
// 合并连续的连字符,并去除首尾空段。
let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect();
let slug = parts.join("-");
if slug.is_empty() {
return format!("{}", chrono::Utc::now().timestamp());
}
slug.chars().take(100).collect()
}
#[cfg(feature = "server")]
/// 校验 slug 是否为空且仅含合法字符、长度不超过 200。
pub fn is_valid_slug(slug: &str) -> bool {
if slug.is_empty() || slug.len() > 200 {
return false;
}
slug.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
#[cfg(feature = "server")]
/// 确保生成的 slug 在数据库中唯一。
///
/// 若 `exclude_id` 不为空,则排除该文章自身;
/// 当冲突时依次尝试 `base-2`、`base-3` …… 直到生成唯一值。
///
/// 该函数应在事务内调用,确保与后续 INSERT/UPDATE 的 slug 唯一性检查
/// 在同一个事务中完成,避免并发竞态。
pub async fn ensure_unique_slug(
tx: &deadpool_postgres::Transaction<'_>,
base: &str,
exclude_id: Option<i32>,
) -> Result<String, ServerFnError> {
use crate::api::error::AppError;
let mut candidate = base.to_string();
let mut suffix = 2;
loop {
// 查询当前候选 slug 是否已存在(排除指定文章 ID
let exists = if let Some(exclude) = exclude_id {
tx.query_opt(
"SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2",
&[&candidate, &exclude],
)
.await
.map_err(AppError::query)?
.is_some()
} else {
tx.query_opt(
"SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL",
&[&candidate],
)
.await
.map_err(AppError::query)?
.is_some()
};
if !exists {
return Ok(candidate);
}
candidate = format!("{}-{}", base, suffix);
suffix += 1;
// 防止无限循环slug 总长度超过 200 时直接报错。
if candidate.len() > 200 {
return Err(AppError::Internal("无法生成唯一 slug").into());
}
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn slugify_ascii_title() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn slugify_special_characters() {
assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
}
#[test]
fn slugify_chinese_characters() {
let slug = slugify("你好世界 hello");
assert!(slug.contains("hello"));
}
#[test]
fn slugify_collapses_dashes() {
assert_eq!(slugify("a---b"), "a-b");
}
#[test]
fn slugify_empty_returns_timestamp() {
let slug = slugify("");
let _: i64 = slug.parse().expect("should be a valid timestamp");
}
#[test]
fn slugify_truncates_at_100_chars() {
let long_title = "a".repeat(200);
assert!(slugify(&long_title).len() <= 100);
}
#[test]
fn slugify_preserves_underscores() {
assert_eq!(slugify("hello_world"), "hello_world");
}
#[test]
fn is_valid_slug_normal() {
assert!(is_valid_slug("hello-world_123"));
}
#[test]
fn is_valid_slug_rejects_empty() {
assert!(!is_valid_slug(""));
}
#[test]
fn is_valid_slug_rejects_too_long() {
let long_slug = "a".repeat(201);
assert!(!is_valid_slug(&long_slug));
}
#[test]
fn is_valid_slug_accepts_max_length() {
let slug = "a".repeat(200);
assert!(is_valid_slug(&slug));
}
#[test]
fn is_valid_slug_rejects_special_chars() {
assert!(!is_valid_slug("hello world"));
assert!(!is_valid_slug("hello.world"));
assert!(!is_valid_slug("hello!world"));
}
#[test]
fn is_valid_slug_accepts_chinese() {
assert!(is_valid_slug("你好-world"));
}
#[test]
fn slugify_all_special_characters_returns_timestamp() {
let slug = slugify("!@#$%^&*()+=[]{}|\\;:'\",.<>/?`~");
let _: i64 = slug.parse().expect("should be a valid timestamp");
}
#[test]
fn slugify_only_whitespace_returns_timestamp() {
let slug = slugify(" \t\n ");
let _: i64 = slug.parse().expect("should be a valid timestamp");
}
#[test]
fn slugify_leading_and_trailing_dashes() {
assert_eq!(slugify("-hello-world-"), "hello-world");
assert_eq!(slugify("---hello---world---"), "hello-world");
}
#[test]
fn is_valid_slug_mixed_chinese_and_digits() {
assert!(is_valid_slug("你好123"));
assert!(is_valid_slug("123你好456"));
}
#[test]
fn is_valid_slug_exact_200_char_boundary() {
let slug = "a".repeat(200);
assert!(is_valid_slug(&slug));
let slug = "a".repeat(201);
assert!(!is_valid_slug(&slug));
}
}