Compare commits
No commits in common. "790002181a65f902a31a9ddae0380e0d6527bd78" and "306da3cf8336fb95f4f6be5151c4878683f8c1e5" have entirely different histories.
790002181a
...
306da3cf83
13
.env.example
13
.env.example
@ -11,19 +11,6 @@ RATE_LIMIT_UPLOAD_BURST=15
|
|||||||
RATE_LIMIT_IMAGE_PER_SEC=10
|
RATE_LIMIT_IMAGE_PER_SEC=10
|
||||||
RATE_LIMIT_IMAGE_BURST=50
|
RATE_LIMIT_IMAGE_BURST=50
|
||||||
|
|
||||||
# Security
|
|
||||||
# Trusted origin for CSRF checks on write requests (POST/PUT/PATCH/DELETE).
|
|
||||||
# Set to your production origin, e.g. https://your-domain.example.
|
|
||||||
# Unset: falls back to the request Host header + X-Forwarded-Proto (behind a reverse proxy).
|
|
||||||
APP_BASE_URL=
|
|
||||||
# Set true/1/yes to add the Secure flag to the session cookie (enable in HTTPS production).
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
# Number of reverse proxies in front of the app; used to extract the real client IP
|
|
||||||
# from X-Forwarded-For. 0 when serving directly; 1 behind one proxy (e.g. nginx/Caddy).
|
|
||||||
TRUSTED_PROXY_COUNT=0
|
|
||||||
# Per-query timeout in seconds; slow queries are canceled to protect the connection pool.
|
|
||||||
STATEMENT_TIMEOUT_SECS=30
|
|
||||||
|
|
||||||
# WebP encoding configuration
|
# WebP encoding configuration
|
||||||
# Quality: 0.0 (smallest) to 100.0 (best), default 85.0
|
# Quality: 0.0 (smallest) to 100.0 (best), default 85.0
|
||||||
WEBP_QUALITY=85.0
|
WEBP_QUALITY=85.0
|
||||||
|
|||||||
@ -42,7 +42,6 @@ RATE_LIMIT_UPLOAD_BURST=15
|
|||||||
RATE_LIMIT_IMAGE_PER_SEC=10
|
RATE_LIMIT_IMAGE_PER_SEC=10
|
||||||
RATE_LIMIT_IMAGE_BURST=50
|
RATE_LIMIT_IMAGE_BURST=50
|
||||||
DB_POOL_SIZE=20 # database connection pool size
|
DB_POOL_SIZE=20 # database connection pool size
|
||||||
STATEMENT_TIMEOUT_SECS=30 # per-query timeout; slow queries are canceled to protect the pool
|
|
||||||
SSR_CACHE_SECS=3600 # incremental SSR cache TTL
|
SSR_CACHE_SECS=3600 # incremental SSR cache TTL
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -51,7 +50,6 @@ Session / security tuning:
|
|||||||
```
|
```
|
||||||
COOKIE_SECURE=false # set true/1/yes to add Secure flag to session cookie
|
COOKIE_SECURE=false # set true/1/yes to add Secure flag to session cookie
|
||||||
TRUSTED_PROXY_COUNT=0 # number of reverse proxies in front of the app; used to extract real client IP from X-Forwarded-For
|
TRUSTED_PROXY_COUNT=0 # number of reverse proxies in front of the app; used to extract real client IP from X-Forwarded-For
|
||||||
APP_BASE_URL= # e.g. https://your-domain.example — trusted origin for CSRF checks on write requests; unset falls back to Host header + X-Forwarded-Proto
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run migrations before first dev server start:
|
Run migrations before first dev server start:
|
||||||
|
|||||||
14
migrate.sh
14
migrate.sh
@ -66,20 +66,10 @@ echo "$MIGRATION_FILES" | sort | while IFS= read -r file; do
|
|||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo -n "[$filename] ... "
|
echo -n "[$filename] ... "
|
||||||
|
|
||||||
# 区分「已应用」与「真出错」:迁移本身已幂等(IF NOT EXISTS),正常应返回 0。
|
if psql "$DATABASE_URL" -f "$file" > /dev/null 2>&1; then
|
||||||
# 非零退出码视为真错误,打印输出并中止,避免静默吞错(M6)。
|
|
||||||
err_output=$(psql "$DATABASE_URL" -f "$file" 2>&1 >/dev/null)
|
|
||||||
rc=$?
|
|
||||||
|
|
||||||
if [[ $rc -eq 0 ]]; then
|
|
||||||
echo "OK"
|
echo "OK"
|
||||||
elif echo "$err_output" | grep -qiE "already exists|duplicate|multiple primary keys"; then
|
|
||||||
echo "SKIPPED (already applied)"
|
|
||||||
else
|
else
|
||||||
echo "FAIL"
|
echo "SKIPPED (already applied or error)"
|
||||||
echo "$err_output" | head -5 | sed 's/^/ /'
|
|
||||||
echo "Migration aborted due to error in $filename"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@ -20,8 +20,8 @@ CREATE TABLE IF NOT EXISTS posts (
|
|||||||
CONSTRAINT posts_status_check CHECK (status IN ('draft', 'published'))
|
CONSTRAINT posts_status_check CHECK (status IN ('draft', 'published'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_status_published ON posts(status, published_at DESC) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_posts_status_published ON posts(status, published_at DESC) WHERE deleted_at IS NULL;
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL;
|
CREATE UNIQUE INDEX idx_posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tags (
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@ -34,8 +34,8 @@ CREATE TABLE IF NOT EXISTS post_tags (
|
|||||||
PRIMARY KEY (post_id, tag_id)
|
PRIMARY KEY (post_id, tag_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_tags_post ON post_tags(post_id);
|
CREATE INDEX idx_post_tags_post ON post_tags(post_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id);
|
CREATE INDEX idx_post_tags_tag ON post_tags(tag_id);
|
||||||
|
|
||||||
-- 为封面图添加索引
|
-- 为封面图添加索引
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_cover ON posts(cover_image) WHERE cover_image IS NOT NULL;
|
CREATE INDEX idx_posts_cover ON posts(cover_image) WHERE cover_image IS NOT NULL;
|
||||||
@ -1 +1 @@
|
|||||||
ALTER TABLE posts ADD COLUMN IF NOT EXISTS toc_html TEXT;
|
ALTER TABLE posts ADD COLUMN toc_html TEXT;
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
-- 性能审计补充索引。
|
|
||||||
-- 所有索引均为 CREATE INDEX IF NOT EXISTS,对已有数据库安全幂等。
|
|
||||||
|
|
||||||
-- 回收站查询:trash.rs 中大量 WHERE deleted_at IS NOT NULL ... ORDER BY deleted_at DESC。
|
|
||||||
-- 配合 list_deleted_posts 的 ORDER BY deleted_at DESC 分页。
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_deleted_at
|
|
||||||
ON posts(deleted_at DESC) WHERE deleted_at IS NOT NULL;
|
|
||||||
|
|
||||||
-- 管理后台列表 list_posts:WHERE deleted_at IS NULL ORDER BY created_at DESC。
|
|
||||||
-- 注意 002_posts.sql 已有 idx_posts_status_published(仅 published 分页),
|
|
||||||
-- 这里补充未按 status 过滤的管理后台全量列表路径。
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_created_at_admin
|
|
||||||
ON posts(created_at DESC) WHERE deleted_at IS NULL;
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
-- 会话世代号:用户角色/封禁状态变更时 bump 此列,使该用户所有已签发 session
|
|
||||||
-- 立即失效(get_user_by_token 校验世代不匹配则视为未登录)。
|
|
||||||
-- 默认 0,向后兼容。
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_generation INT NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN users.session_generation IS '会话世代号,变更时 +1 使旧 session 失效';
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- 评论内容哈希索引,加速 5 分钟窗口内的重复检测查询。
|
|
||||||
-- 注意不加 UNIQUE 约束:content_hash 基于 parent_id+author+content,
|
|
||||||
-- 不同作者发相同内容(如"顶"、"+1")是合法的,唯一约束会误杀。
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comments_content_hash
|
|
||||||
ON comments(content_hash);
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- 删除对 ILIKE '%...%'(双侧通配符)无效的 trgm GIN 索引(L1)。
|
|
||||||
-- pg_trgm 的 GIN 索引只在前缀模式('xxx%')命中,双侧模糊匹配无法利用它,
|
|
||||||
-- 索引建了等于白建且误导。搜索改由 LIMIT + 限流兜底的全表扫承担;
|
|
||||||
-- 后续可升级为 tsvector 全文检索(独立大改动)。
|
|
||||||
DROP INDEX IF EXISTS idx_posts_search_trgm;
|
|
||||||
107
src/api/auth.rs
107
src/api/auth.rs
@ -34,14 +34,10 @@ fn validate_username(username: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
static EMAIL_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
|
|
||||||
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn validate_email(email: &str) -> Result<(), String> {
|
fn validate_email(email: &str) -> Result<(), String> {
|
||||||
if !EMAIL_REGEX.is_match(email) {
|
let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
||||||
|
if !re.is_match(email) {
|
||||||
return Err("邮箱格式不正确".to_string());
|
return Err("邮箱格式不正确".to_string());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -117,14 +113,8 @@ pub async fn register(
|
|||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
// Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行,避免阻塞 Tokio worker。
|
let password_hash =
|
||||||
let pw_for_hash = password.clone();
|
password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?;
|
||||||
let password_hash = tokio::task::spawn_blocking(move || {
|
|
||||||
password::hash_password(&pw_for_hash)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::Internal("密码处理任务失败"))?
|
|
||||||
.map_err(|_| AppError::Internal("密码处理失败"))?;
|
|
||||||
|
|
||||||
// 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。
|
// 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。
|
||||||
// 若已有 admin 或用户名/邮箱冲突,RETURNING 将返回空。
|
// 若已有 admin 或用户名/邮箱冲突,RETURNING 将返回空。
|
||||||
@ -190,7 +180,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
let row = match client
|
let row = match client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
@ -201,16 +191,6 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
{
|
{
|
||||||
Ok(Some(row)) => row,
|
Ok(Some(row)) => row,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// 用户不存在时也执行一次 Argon2 verify,抹平「用户不存在」与
|
|
||||||
// 「密码错误」的响应时序差,防止通过响应时间枚举账号(L2)。
|
|
||||||
// 用固定合法哈希做 verify(必然失败),耗时与真实校验一致。
|
|
||||||
const DUMMY_HASH: &str =
|
|
||||||
"$argon2id$v=19$m=19456,t=2,p=1$j3rNaAXzdExYaL94WBWtfg$n1S75LUQKaYJwaRl5bkFF/f/N1tLfRYR/7TuQxKP94c";
|
|
||||||
let dummy_pw = password.clone();
|
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
|
||||||
crate::auth::password::verify_password(&dummy_pw, DUMMY_HASH)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
return Ok(AuthResponse {
|
return Ok(AuthResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Invalid credentials".to_string(),
|
message: "Invalid credentials".to_string(),
|
||||||
@ -223,14 +203,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
};
|
};
|
||||||
|
|
||||||
let password_hash: String = row.get("password_hash");
|
let password_hash: String = row.get("password_hash");
|
||||||
// Argon2 校验同样在 spawn_blocking 中执行。
|
let valid = password::verify_password(&password, &password_hash)
|
||||||
let pw_for_verify = password.clone();
|
|
||||||
let hash_for_verify = password_hash.clone();
|
|
||||||
let valid = tokio::task::spawn_blocking(move || {
|
|
||||||
password::verify_password(&pw_for_verify, &hash_for_verify)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::Internal("密码处理任务失败"))?
|
|
||||||
.map_err(|_| AppError::Internal("密码处理失败"))?;
|
.map_err(|_| AppError::Internal("密码处理失败"))?;
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
@ -252,15 +225,8 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
.unwrap_or(5)
|
.unwrap_or(5)
|
||||||
.max(1);
|
.max(1);
|
||||||
|
|
||||||
// 用事务 + 对 users 行加 FOR UPDATE 锁,串行化同一用户的并发登录,
|
// 查询当前活跃会话数,超出限制时删除最早的一条。
|
||||||
// 避免 COUNT→DELETE→INSERT 之间的竞态导致超出上限(M1)。
|
let session_count: i64 = client
|
||||||
let tx = client.transaction().await.map_err(AppError::query)?;
|
|
||||||
// 锁住该用户行,并发登录在此排队。
|
|
||||||
tx.execute("SELECT 1 FROM users WHERE id = $1 FOR UPDATE", &[&user_id])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let session_count: i64 = tx
|
|
||||||
.query_one(
|
.query_one(
|
||||||
"SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
|
"SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
|
||||||
&[&user_id],
|
&[&user_id],
|
||||||
@ -270,7 +236,8 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
.get(0);
|
.get(0);
|
||||||
|
|
||||||
if session_count >= max_sessions {
|
if session_count >= max_sessions {
|
||||||
tx.execute(
|
client
|
||||||
|
.execute(
|
||||||
"DELETE FROM sessions WHERE id IN (
|
"DELETE FROM sessions WHERE id IN (
|
||||||
SELECT id FROM sessions
|
SELECT id FROM sessions
|
||||||
WHERE user_id = $1 AND expires_at > NOW()
|
WHERE user_id = $1 AND expires_at > NOW()
|
||||||
@ -283,15 +250,14 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.execute(
|
client
|
||||||
|
.execute(
|
||||||
"INSERT INTO sessions (user_id, token_hash, user_agent, expires_at) VALUES ($1, $2, $3, $4)",
|
"INSERT INTO sessions (user_id, token_hash, user_agent, expires_at) VALUES ($1, $2, $3, $4)",
|
||||||
&[&user_id, &token_hash, &None::<String>, &expires_at],
|
&[&user_id, &token_hash, &None::<String>, &expires_at],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure());
|
let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure());
|
||||||
// 通过 Dioxus FullstackContext 设置 HttpOnly Cookie 响应头。
|
// 通过 Dioxus FullstackContext 设置 HttpOnly Cookie 响应头。
|
||||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
@ -352,39 +318,19 @@ pub struct CurrentUserResponse {
|
|||||||
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
|
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
|
||||||
///
|
///
|
||||||
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
||||||
/// 缓存命中后仍回查 `users.session_generation`:若用户已被降级/封禁(generation 被
|
/// 仅服务端内部使用,不会暴露给前端。
|
||||||
/// bump),缓存的旧 SessionUser.generation 不再匹配,此时逐出缓存并视为未登录,
|
|
||||||
/// 消除权限残留窗口(见 H2)。仅服务端内部使用,不会暴露给前端。
|
|
||||||
pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ServerFnError> {
|
pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ServerFnError> {
|
||||||
let token_hash = session::hash_token(token);
|
let token_hash = session::hash_token(token);
|
||||||
|
|
||||||
if let Some(cached) = crate::cache::get_session_user(&token_hash).await {
|
if let Some(user) = crate::cache::get_session_user(&token_hash).await {
|
||||||
// 缓存命中后校验世代号:bump 后该用户所有 session 应失效。
|
return Ok(Some(user));
|
||||||
// 查询走主键,亚毫秒级,代价可接受。
|
|
||||||
let current_gen: Option<i32> = get_conn()
|
|
||||||
.await
|
|
||||||
.map_err(AppError::db_conn)?
|
|
||||||
.query_opt(
|
|
||||||
"SELECT session_generation FROM users WHERE id = $1",
|
|
||||||
&[&cached.id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?
|
|
||||||
.map(|r| r.get::<_, i32>(0));
|
|
||||||
match current_gen {
|
|
||||||
Some(gen) if gen == cached.session_generation => return Ok(Some(cached)),
|
|
||||||
_ => {
|
|
||||||
// 世代不匹配或用户已删:逐出缓存,落入下方重新查询。
|
|
||||||
crate::cache::invalidate_session_user(&token_hash).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
let row = client
|
let row = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT u.id, u.username, u.email, u.role, u.created_at, u.session_generation
|
"SELECT u.id, u.username, u.email, u.role, u.created_at
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
||||||
@ -403,7 +349,6 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
|
|||||||
email: row.get("email"),
|
email: row.get("email"),
|
||||||
role,
|
role,
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
session_generation: row.get("session_generation"),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@ -416,26 +361,6 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
|
|||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
/// 使指定用户的所有 session 立即失效:bump `session_generation`。
|
|
||||||
///
|
|
||||||
/// 用于角色降级、封禁、密码修改等场景。bump 后该用户所有已签发 session 在下次
|
|
||||||
/// `get_user_by_token` 时因世代不匹配被逐出缓存并视为未登录。内存缓存无需主动清,
|
|
||||||
/// 惰性逐出即可。当前仓库无运行时角色变更入口,本函数是为未来「用户管理」功能
|
|
||||||
/// 预备的基础设施,一旦引入降级/封禁的 server function,必须在 UPDATE 后调用。
|
|
||||||
#[allow(dead_code)] // 预留给未来的用户管理功能(角色变更/封禁触发全量 session 失效)
|
|
||||||
pub async fn invalidate_user_sessions(user_id: i32) -> Result<(), ServerFnError> {
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
client
|
|
||||||
.execute(
|
|
||||||
"UPDATE users SET session_generation = session_generation + 1 WHERE id = $1",
|
|
||||||
&[&user_id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取当前登录用户的公开信息。
|
/// 获取当前登录用户的公开信息。
|
||||||
///
|
///
|
||||||
/// Dioxus server function,注册在 `/api` 路径下。
|
/// Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
|||||||
@ -31,17 +31,6 @@ pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限流防高速遍历枚举评论状态(L3)。本接口供访客轮询自己刚提交的评论
|
|
||||||
// 审核状态,故不加 admin 鉴权;但 strict 限流(对 unknown IP 降级宽松桶)
|
|
||||||
// 足以阻止批量枚举。
|
|
||||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
|
||||||
let parts = ctx.parts_mut();
|
|
||||||
let ip = crate::api::rate_limit::get_client_ip(&parts.headers);
|
|
||||||
if let Err(_msg) = crate::api::rate_limit::check_strict_limit(&ip) {
|
|
||||||
return Err(ServerFnError::new("请求过于频繁,请稍后再试"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
let rows = client
|
let rows = client
|
||||||
|
|||||||
@ -92,7 +92,7 @@ pub async fn create_comment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
// 确认目标文章存在且处于已发布状态。
|
// 确认目标文章存在且处于已发布状态。
|
||||||
let post_row = client
|
let post_row = client
|
||||||
@ -196,20 +196,44 @@ pub async fn create_comment(
|
|||||||
// 基于文章、父评论、作者与内容计算哈希,防止短时间重复提交。
|
// 基于文章、父评论、作者与内容计算哈希,防止短时间重复提交。
|
||||||
let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md);
|
let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md);
|
||||||
|
|
||||||
// 在开事务前完成纯计算(Markdown 渲染、字段转义、IP/UA 提取),避免
|
let dup: Option<i64> = client
|
||||||
// 在事务持锁期间做无谓工作,缩短关键排他锁窗口。
|
.query_opt(
|
||||||
|
"SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
|
||||||
|
&[&post_id, &content_hash],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?
|
||||||
|
.map(|r| r.get(0));
|
||||||
|
|
||||||
|
if dup.is_some() {
|
||||||
|
return Ok(CommentResponse {
|
||||||
|
success: false,
|
||||||
|
message: "请勿重复提交".to_string(),
|
||||||
|
error_code: Some("duplicate".into()),
|
||||||
|
comment_id: None,
|
||||||
|
avatar_url: None,
|
||||||
|
depth: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 Markdown 渲染为 HTML,并通过 sanitizer 过滤危险标签。
|
||||||
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
|
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
|
||||||
|
|
||||||
|
// 对作者展示字段做 HTML 转义,避免 XSS;URL 为空字符串时统一为 None。
|
||||||
let author_name_safe = crate::api::comments::helpers::escape_html(author_name.trim());
|
let author_name_safe = crate::api::comments::helpers::escape_html(author_name.trim());
|
||||||
let author_url_safe = author_url
|
let author_url_safe = author_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|u| crate::api::comments::helpers::escape_html(u.trim()))
|
.map(|u| crate::api::comments::helpers::escape_html(u.trim()))
|
||||||
.filter(|u| !u.is_empty());
|
.filter(|u| !u.is_empty());
|
||||||
|
|
||||||
|
// 获取客户端 IP 与 User-Agent,用于反垃圾与审计。
|
||||||
let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
let parts = ctx.parts_mut();
|
let parts = ctx.parts_mut();
|
||||||
Some(crate::api::rate_limit::get_client_ip(&parts.headers))
|
Some(crate::api::rate_limit::get_client_ip(&parts.headers))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
let parts = ctx.parts_mut();
|
let parts = ctx.parts_mut();
|
||||||
parts
|
parts
|
||||||
@ -221,43 +245,8 @@ pub async fn create_comment(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查重与插入在同一事务内,并用 advisory lock 串行化相同内容的并发提交(M4)。
|
|
||||||
// 仅靠普通 SELECT+事务在 Read Committed 下无法阻止并发重复(两个事务都看不到
|
|
||||||
// 对方未提交的 INSERT);pg_advisory_xact_lock 以内容哈希派生的 key 加事务级
|
|
||||||
// 排他锁,使相同内容的并发请求在锁上排队,第二个提交时查重必然命中前一个。
|
|
||||||
// key 取 content_hash 前 16 个 hex 字符(8 字节)解析为 i64。
|
|
||||||
let lock_key: i64 = i64::from_str_radix(&content_hash[..16], 16).unwrap_or(0);
|
|
||||||
|
|
||||||
let tx = client.transaction().await.map_err(AppError::query)?;
|
|
||||||
// 事务级 advisory 锁:随事务结束自动释放,无需显式 unlock。
|
|
||||||
tx.execute("SELECT pg_advisory_xact_lock($1)", &[&lock_key])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let dup: Option<i64> = tx
|
|
||||||
.query_opt(
|
|
||||||
"SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
|
|
||||||
&[&post_id, &content_hash],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?
|
|
||||||
.map(|r| r.get(0));
|
|
||||||
|
|
||||||
if dup.is_some() {
|
|
||||||
// 重复:回滚(释放 advisory 锁)后返回。
|
|
||||||
tx.rollback().await.ok();
|
|
||||||
return Ok(CommentResponse {
|
|
||||||
success: false,
|
|
||||||
message: "请勿重复提交".to_string(),
|
|
||||||
error_code: Some("duplicate".into()),
|
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 插入评论,默认状态为 pending,等待管理员审核。
|
// 插入评论,默认状态为 pending,等待管理员审核。
|
||||||
let row = tx
|
let row = client
|
||||||
.query_one(
|
.query_one(
|
||||||
"INSERT INTO comments \
|
"INSERT INTO comments \
|
||||||
(post_id, parent_id, depth, author_name, author_email, author_url, \
|
(post_id, parent_id, depth, author_name, author_email, author_url, \
|
||||||
@ -281,8 +270,6 @@ pub async fn create_comment(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let comment_id: i64 = row.get(0);
|
let comment_id: i64 = row.get(0);
|
||||||
|
|
||||||
// 根据邮箱生成 Gravatar 头像链接。
|
// 根据邮箱生成 Gravatar 头像链接。
|
||||||
|
|||||||
@ -109,15 +109,11 @@ pub fn validate_comment_name(name: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
static EMAIL_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
|
|
||||||
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 校验评论作者邮箱格式。
|
/// 校验评论作者邮箱格式。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn validate_comment_email(email: &str) -> Result<(), String> {
|
pub fn validate_comment_email(email: &str) -> Result<(), String> {
|
||||||
if !EMAIL_REGEX.is_match(email.trim()) {
|
let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
||||||
|
if !re.is_match(email.trim()) {
|
||||||
return Err("邮箱格式不正确".to_string());
|
return Err("邮箱格式不正确".to_string());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
200
src/api/csrf.rs
200
src/api/csrf.rs
@ -1,200 +0,0 @@
|
|||||||
//! CSRF 防护:对写请求校验 Origin(回退 Referer)必须等于本站。
|
|
||||||
//!
|
|
||||||
//! SameSite=Lax 只在顶级 GET 导航时自动带 cookie,对跨站 POST 不带 cookie,
|
|
||||||
//! 挡住了大部分经典 CSRF。但存在两个 Lax 覆盖不到的盲区:
|
|
||||||
//! 1. 登录 CSRF(攻击者诱导受害者登录攻击者账号,Lax 不阻止「设置」cookie);
|
|
||||||
//! 2. 未来若出现 GET 化写接口,Lax 会在顶级 GET 导航时带 cookie。
|
|
||||||
//! 因此对所有写请求叠加 Origin 校验作为纵深防御。
|
|
||||||
//! 仅在 `feature = "server"` 时编译。
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
use axum::http::{HeaderMap, Method, Request};
|
|
||||||
|
|
||||||
/// 判断请求是否需要 CSRF 校验:非简单方法(POST/PUT/PATCH/DELETE)需要。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
fn is_write_method(method: &Method) -> bool {
|
|
||||||
matches!(
|
|
||||||
method,
|
|
||||||
&Method::POST | &Method::PUT | &Method::PATCH | &Method::DELETE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 `scheme://host[:port][/path][?query]` 提取标准化的 `scheme://host[:port]`,
|
|
||||||
/// 端口为默认值(http=80, https=443)时省略。
|
|
||||||
///
|
|
||||||
/// 不引入 url crate:Origin 头本身就是 `scheme://host[:port]`(无路径),
|
|
||||||
/// Referer 需要剥离 path/query,用简单的 split 即可。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
fn normalize_origin(input: &str) -> String {
|
|
||||||
// 取 authority 之前的部分作为 scheme,以及第一个 '/' 之前的部分作为 authority。
|
|
||||||
let (scheme, rest) = match input.split_once("://") {
|
|
||||||
Some(pair) => pair,
|
|
||||||
None => return input.to_string(),
|
|
||||||
};
|
|
||||||
// rest 形如 host[:port]/path?query,去掉首个 '/' 及之后内容。
|
|
||||||
let authority = match rest.split_once('/') {
|
|
||||||
Some((auth, _)) => auth,
|
|
||||||
None => rest,
|
|
||||||
};
|
|
||||||
// 省略默认端口。
|
|
||||||
match authority.rsplit_once(':') {
|
|
||||||
Some((host, port)) if port == "80" || port == "443" => {
|
|
||||||
format!("{}://{}", scheme, host)
|
|
||||||
}
|
|
||||||
_ => format!("{}://{}", scheme, authority),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从请求头解析来源站点(Origin 优先,回退 Referer)。
|
|
||||||
///
|
|
||||||
/// 返回标准化的 `scheme://host[:port]`。两者都缺失时返回 None(视为不可信)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
fn extract_origin(headers: &HeaderMap) -> Option<String> {
|
|
||||||
if let Some(origin) = headers.get(axum::http::header::ORIGIN) {
|
|
||||||
return origin.to_str().ok().map(normalize_origin);
|
|
||||||
}
|
|
||||||
headers
|
|
||||||
.get(axum::http::header::REFERER)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(normalize_origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 计算本站可信 origin:优先 `APP_BASE_URL` 环境变量(生产域名),
|
|
||||||
/// 否则用请求 Host 头 + `X-Forwarded-Proto`(反代后)或 https 推导。
|
|
||||||
///
|
|
||||||
/// 返回 None 表示无法确定本站 origin(此时放行,避免误杀——CSRF 漏判
|
|
||||||
/// 是请求被拒,但拿不到本站 origin 时误杀合法请求代价更高,故保守放行)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
fn trusted_origin(headers: &HeaderMap) -> Option<String> {
|
|
||||||
if let Ok(base) = std::env::var("APP_BASE_URL") {
|
|
||||||
return Some(normalize_origin(&base));
|
|
||||||
}
|
|
||||||
let host = headers.get(axum::http::header::HOST)?.to_str().ok()?;
|
|
||||||
let proto = headers
|
|
||||||
.get("X-Forwarded-Proto")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("https");
|
|
||||||
Some(normalize_origin(&format!("{}://{}", proto, host)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// CSRF 校验中间件。
|
|
||||||
///
|
|
||||||
/// 对写方法校验请求来源等于本站;不匹配返回 403。GET/OPTIONS 等放行。
|
|
||||||
/// 拿不到本站 origin 或请求来源时放行(见 trusted_origin 注释)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub async fn csrf_middleware(
|
|
||||||
req: Request<axum::body::Body>,
|
|
||||||
next: axum::middleware::Next,
|
|
||||||
) -> axum::response::Response {
|
|
||||||
if is_write_method(req.method()) {
|
|
||||||
let headers = req.headers().clone();
|
|
||||||
let trusted = trusted_origin(&headers);
|
|
||||||
let incoming = extract_origin(&headers);
|
|
||||||
let ok = match (&trusted, &incoming) {
|
|
||||||
(Some(t), Some(o)) => t == o,
|
|
||||||
// 拿不到本站 origin 或请求来源时放行。
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
if !ok {
|
|
||||||
return axum::response::Response::builder()
|
|
||||||
.status(axum::http::StatusCode::FORBIDDEN)
|
|
||||||
.body(axum::body::Body::empty())
|
|
||||||
.expect("static forbidden response is always valid");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next.run(req).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "server"))]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use axum::http::{HeaderMap, HeaderValue, Method};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_write_method_recognizes_writes() {
|
|
||||||
assert!(is_write_method(&Method::POST));
|
|
||||||
assert!(is_write_method(&Method::PUT));
|
|
||||||
assert!(is_write_method(&Method::PATCH));
|
|
||||||
assert!(is_write_method(&Method::DELETE));
|
|
||||||
assert!(!is_write_method(&Method::GET));
|
|
||||||
assert!(!is_write_method(&Method::OPTIONS));
|
|
||||||
assert!(!is_write_method(&Method::HEAD));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_strips_path_and_query() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_origin("https://example.com/a/b?c=1"),
|
|
||||||
"https://example.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_preserves_nondefault_port() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_origin("http://localhost:3000/x"),
|
|
||||||
"http://localhost:3000"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_drops_default_ports() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_origin("https://example.com:443/path"),
|
|
||||||
"https://example.com"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_origin("http://example.com:80/path"),
|
|
||||||
"http://example.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_keeps_explicit_nondefault_https_port() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_origin("https://example.com:8443"),
|
|
||||||
"https://example.com:8443"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_plain_origin_no_path() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_origin("https://example.com"),
|
|
||||||
"https://example.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_origin_prefers_origin_header() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
axum::http::header::ORIGIN,
|
|
||||||
HeaderValue::from_static("https://example.com"),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
extract_origin(&headers),
|
|
||||||
Some("https://example.com".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_origin_falls_back_to_referer() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
axum::http::header::REFERER,
|
|
||||||
HeaderValue::from_static("https://example.com/posts/1"),
|
|
||||||
);
|
|
||||||
// Referer 的路径被剥离,只保留 scheme://host。
|
|
||||||
assert_eq!(
|
|
||||||
extract_origin(&headers),
|
|
||||||
Some("https://example.com".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_origin_returns_none_when_both_absent() {
|
|
||||||
let headers = HeaderMap::new();
|
|
||||||
assert_eq!(extract_origin(&headers), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
110
src/api/image.rs
110
src/api/image.rs
@ -206,11 +206,6 @@ fn image_response(
|
|||||||
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
|
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
|
||||||
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
|
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
|
||||||
(header::CONTENT_TYPE, content_type),
|
(header::CONTENT_TYPE, content_type),
|
||||||
// nosniff 防止浏览器对 content-type 错配的图片字节做 MIME sniff(M2)。
|
|
||||||
(
|
|
||||||
header::X_CONTENT_TYPE_OPTIONS,
|
|
||||||
HeaderValue::from_static("nosniff"),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
@ -223,10 +218,6 @@ fn image_response(
|
|||||||
(header::CONTENT_TYPE, content_type),
|
(header::CONTENT_TYPE, content_type),
|
||||||
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
|
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
|
||||||
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
|
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
|
||||||
(
|
|
||||||
header::X_CONTENT_TYPE_OPTIONS,
|
|
||||||
HeaderValue::from_static("nosniff"),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
@ -350,10 +341,9 @@ fn process_image_blocking(
|
|||||||
img
|
img
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// decode 失败不再降级返回原始字节(可能是构造的畸形文件,配合 nosniff
|
tracing::warn!("WebP decode failed ({}), returning raw bytes", e);
|
||||||
// 构成内容混淆面),直接报错让上层返回 422(M3)。
|
let ct = content_type(original_format);
|
||||||
tracing::warn!("WebP decode failed ({}), rejecting", e);
|
return Ok((data, ct));
|
||||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -363,8 +353,9 @@ fn process_image_blocking(
|
|||||||
match reader.decode() {
|
match reader.decode() {
|
||||||
Ok(img) => img,
|
Ok(img) => img,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Image decode failed ({}), rejecting", e);
|
tracing::warn!("Image decode failed ({}), returning raw bytes", e);
|
||||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
let ct = content_type(original_format);
|
||||||
|
return Ok((data, ct));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -373,24 +364,16 @@ fn process_image_blocking(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 校验请求路径不会逃出 uploads 目录。
|
fn is_path_safe(path: &str) -> bool {
|
||||||
///
|
// Reject paths with parent directory references or null bytes
|
||||||
/// 两层校验:① 子串级拒绝 `..`/`\0`/绝对路径前缀;② 对已存在文件用 canonicalize
|
if path.contains("..") || path.contains('\0') {
|
||||||
/// 确认解析后真实路径仍在 uploads 目录内(纵深防御,抵御符号链接等绕过)。
|
|
||||||
/// 文件不存在或 uploads 目录不存在时只做第一层校验(由后续 read 报 404)。
|
|
||||||
async fn is_path_safe(path: &str) -> bool {
|
|
||||||
if path.contains("..") || path.contains('\0') || path.starts_with('/') {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let candidate = std::path::Path::new("uploads").join(path);
|
// Reject absolute paths
|
||||||
let uploads_root = match tokio::fs::canonicalize("uploads").await {
|
if path.starts_with('/') {
|
||||||
Ok(p) => p,
|
return false;
|
||||||
Err(_) => return true, // uploads 目录不存在(测试环境),只靠第一层校验。
|
|
||||||
};
|
|
||||||
match tokio::fs::canonicalize(&candidate).await {
|
|
||||||
Ok(resolved) => resolved.starts_with(&uploads_root),
|
|
||||||
Err(_) => true, // 文件不存在,交由后续读取报 404。
|
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -435,31 +418,11 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
|
|||||||
.content_type
|
.content_type
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap_or("application/octet-stream");
|
.unwrap_or("application/octet-stream");
|
||||||
|
if let Err(e) = tokio::fs::write(format!("{}.dat", base), &cached.data).await {
|
||||||
// 原子写:先写 .tmp 再 rename,避免并发请求读到 .dat 与 .ct 错配的半成品(L5)。
|
tracing::warn!("Failed to write disk cache data: {:?}", e);
|
||||||
let dat_path = format!("{}.dat", base);
|
|
||||||
let ct_path = format!("{}.ct", base);
|
|
||||||
let dat_tmp = format!("{}.dat.tmp", base);
|
|
||||||
let ct_tmp = format!("{}.ct.tmp", base);
|
|
||||||
|
|
||||||
// 两个临时文件都写成功后才 rename;任一失败则清理半成品。
|
|
||||||
let writes_ok = tokio::fs::write(&dat_tmp, &cached.data).await.is_ok()
|
|
||||||
&& tokio::fs::write(&ct_tmp, ct_str).await.is_ok();
|
|
||||||
|
|
||||||
if !writes_ok {
|
|
||||||
let _ = tokio::fs::remove_file(&dat_tmp).await;
|
|
||||||
let _ = tokio::fs::remove_file(&ct_tmp).await;
|
|
||||||
tracing::warn!("Failed to write disk cache temp files at {}", base);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if let Err(e) = tokio::fs::write(format!("{}.ct", base), ct_str).await {
|
||||||
let rename_dat = tokio::fs::rename(&dat_tmp, &dat_path).await;
|
tracing::warn!("Failed to write disk cache content type: {:?}", e);
|
||||||
let rename_ct = tokio::fs::rename(&ct_tmp, &ct_path).await;
|
|
||||||
if rename_dat.is_err() || rename_ct.is_err() {
|
|
||||||
// rename 失败:清理可能残留的临时文件与目标,避免读到错配内容。
|
|
||||||
let _ = tokio::fs::remove_file(&dat_tmp).await;
|
|
||||||
let _ = tokio::fs::remove_file(&ct_tmp).await;
|
|
||||||
tracing::warn!("Failed to atomically rename disk cache at {}", base);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -480,7 +443,7 @@ pub async fn serve_image(
|
|||||||
return status.into_response();
|
return status.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !is_path_safe(&path).await {
|
if !is_path_safe(&path) {
|
||||||
return StatusCode::FORBIDDEN.into_response();
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,19 +456,12 @@ pub async fn serve_image(
|
|||||||
|
|
||||||
// No processing params: return raw file with long-lived cache headers.
|
// No processing params: return raw file with long-lived cache headers.
|
||||||
if params.is_empty() {
|
if params.is_empty() {
|
||||||
// 原始分支也限制大小,避免读取超大文件撑爆内存(M3)。上限 20MB
|
return match tokio::fs::read(&file_path).await {
|
||||||
// 覆盖正常上传图(上传侧 MAX_FILE_SIZE=5MB),拒绝异常大文件。
|
|
||||||
const MAX_RAW_BYTES: u64 = 20 * 1024 * 1024;
|
|
||||||
return match tokio::fs::metadata(&file_path).await {
|
|
||||||
Ok(meta) if meta.len() > MAX_RAW_BYTES => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
|
|
||||||
Ok(_) => match tokio::fs::read(&file_path).await {
|
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let ct = content_type(detect_format(&path));
|
let ct = content_type(detect_format(&path));
|
||||||
image_response(Bytes::from(data), ct, "public, max-age=31536000, immutable", &headers)
|
image_response(Bytes::from(data), ct, "public, max-age=31536000, immutable", &headers)
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
},
|
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -682,26 +638,26 @@ mod tests {
|
|||||||
assert!(params.validate().is_err());
|
assert!(params.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn is_path_safe_normal() {
|
fn is_path_safe_normal() {
|
||||||
assert!(is_path_safe("images/photo.jpg").await);
|
assert!(is_path_safe("images/photo.jpg"));
|
||||||
assert!(is_path_safe("2024/01/photo.png").await);
|
assert!(is_path_safe("2024/01/photo.png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn is_path_safe_rejects_parent_dir() {
|
fn is_path_safe_rejects_parent_dir() {
|
||||||
assert!(!is_path_safe("../etc/passwd").await);
|
assert!(!is_path_safe("../etc/passwd"));
|
||||||
assert!(!is_path_safe("foo/../../bar").await);
|
assert!(!is_path_safe("foo/../../bar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn is_path_safe_rejects_null_bytes() {
|
fn is_path_safe_rejects_null_bytes() {
|
||||||
assert!(!is_path_safe("foo\0bar").await);
|
assert!(!is_path_safe("foo\0bar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn is_path_safe_rejects_absolute_path() {
|
fn is_path_safe_rejects_absolute_path() {
|
||||||
assert!(!is_path_safe("/etc/passwd").await);
|
assert!(!is_path_safe("/etc/passwd"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -37,12 +37,8 @@ pub struct RenderedContent {
|
|||||||
pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
||||||
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
|
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
|
||||||
|
|
||||||
// 两遍解析使用相同的 Options,避免 TOC 收集与正文渲染对 Markdown 扩展语法
|
|
||||||
// (表格、删除线、脚注等)的处理不一致。
|
|
||||||
let opts = Options::all();
|
|
||||||
|
|
||||||
// 1. Parse markdown and collect headings for TOC
|
// 1. Parse markdown and collect headings for TOC
|
||||||
let parser = pulldown_cmark::Parser::new_ext(md, opts);
|
let parser = pulldown_cmark::Parser::new_ext(md, Options::all());
|
||||||
// (level, text, id)
|
// (level, text, id)
|
||||||
let mut headings: Vec<(u8, String, String)> = Vec::new();
|
let mut headings: Vec<(u8, String, String)> = Vec::new();
|
||||||
let mut current_heading: Option<(u8, String)> = None;
|
let mut current_heading: Option<(u8, String)> = None;
|
||||||
@ -84,7 +80,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
let toc_html = generate_toc_html(&headings);
|
let toc_html = generate_toc_html(&headings);
|
||||||
|
|
||||||
// 3. Generate HTML with heading anchors
|
// 3. Generate HTML with heading anchors
|
||||||
let parser = pulldown_cmark::Parser::new_ext(md, opts);
|
let parser = pulldown_cmark::Parser::new_ext(md, Options::ENABLE_TABLES);
|
||||||
let mut html = String::new();
|
let mut html = String::new();
|
||||||
let mut heading_idx = 0;
|
let mut heading_idx = 0;
|
||||||
let mut in_heading = false;
|
let mut in_heading = false;
|
||||||
|
|||||||
@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
/// 认证相关的 Dioxus server function。
|
/// 认证相关的 Dioxus server function。
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
/// CSRF 防护中间件。
|
|
||||||
pub mod csrf;
|
|
||||||
/// 评论相关接口。
|
/// 评论相关接口。
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
/// 应用错误类型与转换。
|
/// 应用错误类型与转换。
|
||||||
|
|||||||
@ -76,13 +76,8 @@ pub async fn create_post(
|
|||||||
{
|
{
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
// Markdown 渲染(含 syntect 高亮)是 CPU 密集任务,移到阻塞线程池执行。
|
// 渲染 Markdown 为 HTML,并提取目录。
|
||||||
let md_for_render = content_md.clone();
|
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||||
let rendered = tokio::task::spawn_blocking(move || {
|
|
||||||
crate::api::markdown::render_markdown_enhanced(&md_for_render)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
|
|
||||||
let content_html = rendered.html;
|
let content_html = rendered.html;
|
||||||
let toc_html = if rendered.toc_html.is_empty() {
|
let toc_html = if rendered.toc_html.is_empty() {
|
||||||
None::<String>
|
None::<String>
|
||||||
|
|||||||
@ -235,111 +235,22 @@ pub async fn list_deleted_posts(
|
|||||||
|
|
||||||
/// 获取指定标签下的已发布文章列表。
|
/// 获取指定标签下的已发布文章列表。
|
||||||
///
|
///
|
||||||
/// 分页参数为可选:
|
/// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。
|
||||||
/// - `page` 与 `per_page` 均为 `None` 时返回该标签下全部已发布文章(上限 200),
|
|
||||||
/// 用于无分页 UI 的标签详情页。
|
|
||||||
/// - 两者均提供时走标准分页(经 `clamp_pagination` 钳制)。
|
|
||||||
/// 结果缓存于按标签的分页键空间。
|
|
||||||
#[server(GetPostsByTag, "/api")]
|
#[server(GetPostsByTag, "/api")]
|
||||||
pub async fn get_posts_by_tag(
|
pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
|
||||||
tag_name: String,
|
|
||||||
page: Option<i32>,
|
|
||||||
per_page: Option<i32>,
|
|
||||||
) -> Result<PostListResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
// 仅当两个分页参数都提供时才走分页路径;任一为 None 视为不分页。
|
if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await
|
||||||
let (page, per_page) = match (page, per_page) {
|
{
|
||||||
(Some(p), Some(pp)) => (Some(p), Some(pp)),
|
return Ok(PostListResponse {
|
||||||
_ => (None, None),
|
posts: cached_posts,
|
||||||
};
|
total: cached_total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
if let (Some(page), Some(per_page)) = (page, per_page) {
|
// 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。
|
||||||
// 分页路径:钳制参数,走分页缓存键。
|
|
||||||
let (page, per_page) = clamp_pagination(page, per_page);
|
|
||||||
let cache_key = crate::cache::CacheKey::PostsByTagPage {
|
|
||||||
tag: tag_name.clone(),
|
|
||||||
page,
|
|
||||||
per_page,
|
|
||||||
};
|
|
||||||
if let Some((cached_posts, cached_total)) =
|
|
||||||
crate::cache::get_posts_by_tag_paged(&cache_key).await
|
|
||||||
{
|
|
||||||
return Ok(PostListResponse {
|
|
||||||
posts: cached_posts,
|
|
||||||
total: cached_total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标签下已发布文章总数。
|
|
||||||
let total: i64 = client
|
|
||||||
.query_one(
|
|
||||||
"SELECT COUNT(*) FROM posts p
|
|
||||||
JOIN post_tags pt ON p.id = pt.post_id
|
|
||||||
JOIN tags t ON pt.tag_id = t.id
|
|
||||||
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
|
|
||||||
&[&tag_name],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?
|
|
||||||
.get(0);
|
|
||||||
|
|
||||||
let offset = ((page - 1).max(0) as i64) * (per_page as i64);
|
|
||||||
let limit = per_page as i64;
|
|
||||||
let rows = client
|
|
||||||
.query(
|
|
||||||
"SELECT
|
|
||||||
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
|
||||||
p.published_at, p.created_at, p.updated_at, p.cover_image,
|
|
||||||
p.word_count, p.reading_time,
|
|
||||||
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
|
|
||||||
FROM posts p
|
|
||||||
JOIN post_tags pt ON p.id = pt.post_id
|
|
||||||
JOIN tags t ON pt.tag_id = t.id
|
|
||||||
LEFT JOIN post_tags pt2 ON p.id = pt2.post_id
|
|
||||||
LEFT JOIN tags t2 ON pt2.tag_id = t2.id
|
|
||||||
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL
|
|
||||||
GROUP BY p.id
|
|
||||||
ORDER BY p.published_at DESC
|
|
||||||
LIMIT $2 OFFSET $3",
|
|
||||||
&[&tag_name, &limit, &offset],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let mut posts = Vec::new();
|
|
||||||
for row in &rows {
|
|
||||||
posts.push(row_to_post_list_item(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::cache::set_posts_by_tag_paged(&cache_key, posts.clone(), total).await;
|
|
||||||
Ok(PostListResponse { posts, total })
|
|
||||||
} else {
|
|
||||||
// 不分页路径:返回全部(上限 200),用于无翻页 UI 的标签详情页。
|
|
||||||
if let Some((cached_posts, cached_total)) =
|
|
||||||
crate::cache::get_posts_by_tag(&tag_name).await
|
|
||||||
{
|
|
||||||
return Ok(PostListResponse {
|
|
||||||
posts: cached_posts,
|
|
||||||
total: cached_total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 真实总数(即使被 LIMIT 截断也返回完整计数)。
|
|
||||||
let total: i64 = client
|
|
||||||
.query_one(
|
|
||||||
"SELECT COUNT(*) FROM posts p
|
|
||||||
JOIN post_tags pt ON p.id = pt.post_id
|
|
||||||
JOIN tags t ON pt.tag_id = t.id
|
|
||||||
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
|
|
||||||
&[&tag_name],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?
|
|
||||||
.get(0);
|
|
||||||
|
|
||||||
let rows = client
|
let rows = client
|
||||||
.query(
|
.query(
|
||||||
"SELECT
|
"SELECT
|
||||||
@ -366,11 +277,12 @@ pub async fn get_posts_by_tag(
|
|||||||
posts.push(row_to_post_list_item(row));
|
posts.push(row_to_post_list_item(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
// total 为真实 COUNT(*),不再用 posts.len()。
|
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。
|
||||||
|
// 若后续增加分页,应改为 COUNT(*) 查询。
|
||||||
|
let total = posts.len() as i64;
|
||||||
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
|
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
|
||||||
Ok(PostListResponse { posts, total })
|
Ok(PostListResponse { posts, total })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@ -32,44 +32,33 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
// 根据参数构造 WHERE 条件,限制单次处理数量。
|
// 根据参数构造 WHERE 条件,限制单次处理数量。
|
||||||
// SELECT 在事务内并加 FOR UPDATE,锁住待处理行直到 UPDATE 完成,避免
|
|
||||||
// 并发编辑造成非可重复读(review 发现):事务外读到的 content_md 可能在
|
|
||||||
// UPDATE 前被并发请求修改,导致用旧内容覆盖新内容。
|
|
||||||
let query = if rebuild_all {
|
let query = if rebuild_all {
|
||||||
format!(
|
format!(
|
||||||
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT} FOR UPDATE"
|
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL AND content_html IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT} FOR UPDATE"
|
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL AND content_html IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let rows = client.query(&query, &[]).await.map_err(AppError::query)?;
|
||||||
|
|
||||||
let mut rebuilt: u64 = 0;
|
let mut rebuilt: u64 = 0;
|
||||||
let mut failed: u64 = 0;
|
let mut failed: u64 = 0;
|
||||||
let mut errors: Vec<String> = Vec::new();
|
let mut errors: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// 整批 SELECT + UPDATE 纳入单事务:中途断连或写入失败整批回滚,避免产生
|
|
||||||
// 「部分文章已重建」的中间态(M5);FOR UPDATE 锁住的行随事务结束释放。
|
|
||||||
let tx = client.transaction().await.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let rows = tx.query(&query, &[]).await.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let id: i32 = row.get(0);
|
let id: i32 = row.get(0);
|
||||||
let content_md: String = row.get(1);
|
let content_md: String = row.get(1);
|
||||||
|
|
||||||
// Markdown 渲染在阻塞线程池执行;spawn_blocking 的 JoinError 自动捕获 panic,
|
// 捕获 Markdown 渲染 panic,避免单条记录导致整批失败。
|
||||||
// 替代原先的 catch_unwind。
|
let rendered = match std::panic::catch_unwind(|| {
|
||||||
let md_for_render = content_md.clone();
|
crate::api::markdown::render_markdown_enhanced(&content_md)
|
||||||
let rendered = match tokio::task::spawn_blocking(move || {
|
}) {
|
||||||
crate::api::markdown::render_markdown_enhanced(&md_for_render)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
@ -89,7 +78,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
|||||||
let word_count = crate::utils::text::count_words(&content_md);
|
let word_count = crate::utils::text::count_words(&content_md);
|
||||||
let reading_time = crate::utils::text::reading_time(word_count);
|
let reading_time = crate::utils::text::reading_time(word_count);
|
||||||
|
|
||||||
match tx
|
match client
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
|
"UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
|
||||||
&[
|
&[
|
||||||
@ -105,25 +94,14 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
rebuilt += 1;
|
rebuilt += 1;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(_) => {
|
||||||
// 事务内任一写入失败会使事务进入 abort 状态,后续写入都会失败;
|
|
||||||
// 此时整批回滚,保证不产生中间态。
|
|
||||||
failed += 1;
|
failed += 1;
|
||||||
if errors.len() < MAX_DISPLAY_ERRORS {
|
if errors.len() < MAX_DISPLAY_ERRORS {
|
||||||
errors.push(format!("文章 #{id}: DB 写入失败(整批将回滚)"));
|
errors.push(format!("文章 #{id}: DB 写入失败"));
|
||||||
}
|
}
|
||||||
tracing::error!("rebuild UPDATE 失败,整批回滚: {:?}", e);
|
|
||||||
tx.rollback().await.ok();
|
|
||||||
return Ok(RebuildResult {
|
|
||||||
rebuilt: 0,
|
|
||||||
failed,
|
|
||||||
errors,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
// 重建会修改 word_count / reading_time 等列表项字段,批量影响列表、标签云、
|
// 重建会修改 word_count / reading_time 等列表项字段,批量影响列表、标签云、
|
||||||
// 标签文章及单篇缓存;这里使用全量失效作为务实的回退策略。
|
// 标签文章及单篇缓存;这里使用全量失效作为务实的回退策略。
|
||||||
|
|||||||
@ -61,9 +61,7 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
|
|||||||
.replace('%', "\\%")
|
.replace('%', "\\%")
|
||||||
.replace('_', "\\_");
|
.replace('_', "\\_");
|
||||||
|
|
||||||
// 使用 ILIKE 做子串模糊匹配(双侧 %)。注意:此查询无法利用 trgm GIN
|
// 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。
|
||||||
// 索引(仅前缀模式命中),走全表扫,靠 LIMIT 50 + search 限流兜底。
|
|
||||||
// 后续可升级为 tsvector 全文检索(独立大改动)。
|
|
||||||
let rows = client
|
let rows = client
|
||||||
.query(
|
.query(
|
||||||
"SELECT
|
"SELECT
|
||||||
|
|||||||
@ -40,13 +40,8 @@ pub async fn update_post(
|
|||||||
{
|
{
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
// Markdown 渲染移到阻塞线程池执行。
|
// 重新渲染 Markdown 与目录。
|
||||||
let md_for_render = content_md.clone();
|
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||||
let rendered = tokio::task::spawn_blocking(move || {
|
|
||||||
crate::api::markdown::render_markdown_enhanced(&md_for_render)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
|
|
||||||
let content_html = rendered.html;
|
let content_html = rendered.html;
|
||||||
let toc_html = if rendered.toc_html.is_empty() {
|
let toc_html = if rendered.toc_html.is_empty() {
|
||||||
None::<String>
|
None::<String>
|
||||||
|
|||||||
@ -61,20 +61,6 @@ static COMMENT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::ne
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
/// 当无法识别真实客户端 IP("unknown")时使用的宽松限流桶。
|
|
||||||
///
|
|
||||||
/// TRUSTED_PROXY_COUNT=0(默认)时,Dioxus server function 拿不到 TCP 对端地址,
|
|
||||||
/// get_client_ip 会返回 "unknown",导致所有匿名请求共享同一个严格桶
|
|
||||||
/// (1 req/s, burst 5),正常用户的高频请求被误杀。此桶阈值更高,
|
|
||||||
/// 通过 env RATE_LIMIT_UNKNOWN_PER_SEC / RATE_LIMIT_UNKNOWN_BURST 可调。
|
|
||||||
static UNKNOWN_BUCKET_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
|
|
||||||
RateLimiter::keyed(
|
|
||||||
Quota::per_second(env_or("RATE_LIMIT_UNKNOWN_PER_SEC", 30))
|
|
||||||
.allow_burst(env_or("RATE_LIMIT_UNKNOWN_BURST", 100)),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 检查评论请求是否超出限流阈值。
|
/// 检查评论请求是否超出限流阈值。
|
||||||
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
|
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
|
||||||
@ -198,23 +184,11 @@ pub fn get_client_ip(headers: &http::HeaderMap) -> String {
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 检查严格限流(注册、登录等敏感接口)。
|
/// 检查严格限流(注册、登录等敏感接口)。
|
||||||
///
|
|
||||||
/// 当 IP 为 "unknown"(无法识别真实客户端,通常是 TRUSTED_PROXY_COUNT=0
|
|
||||||
/// 且调用方为 Dioxus server function 时)改用宽松桶,避免所有匿名请求共享
|
|
||||||
/// 严格桶导致正常用户被误杀。生产环境配好 TRUSTED_PROXY_COUNT 后走真实 IP,
|
|
||||||
/// 始终命中严格桶。
|
|
||||||
pub fn check_strict_limit(ip: &str) -> Result<(), String> {
|
pub fn check_strict_limit(ip: &str) -> Result<(), String> {
|
||||||
if ip == "unknown" {
|
|
||||||
UNKNOWN_BUCKET_LIMITER
|
|
||||||
.check_key(&ip.to_string())
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|_| "服务繁忙,请稍后再试".to_string())
|
|
||||||
} else {
|
|
||||||
STRICT_LIMITER
|
STRICT_LIMITER
|
||||||
.check_key(&ip.to_string())
|
.check_key(&ip.to_string())
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|_| "请求过于频繁,请稍后再试".to_string())
|
.map_err(|_| "请求过于频繁,请稍后再试".to_string())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -396,43 +370,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn check_strict_unknown_ip_uses_lenient_bucket() {
|
|
||||||
// "unknown" 桶 burst 为 100,少量请求应全部放行,不被严格桶误杀。
|
|
||||||
// 用 serial 隔离,因为 UNKNOWN_BUCKET_LIMITER 是全局状态。
|
|
||||||
for _ in 0..20 {
|
|
||||||
assert!(
|
|
||||||
super::check_strict_limit("unknown").is_ok(),
|
|
||||||
"unknown bucket should allow small bursts, not hit strict 1 req/s limit"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn check_strict_real_ip_uses_strict_bucket() {
|
|
||||||
// 真实 IP 命中严格桶(1 req/s, burst 5)。连发超过 burst 应被限流。
|
|
||||||
// 用一个唯一的 IP 避免与其他测试状态冲突。
|
|
||||||
let unique_ip = "198.51.100.42";
|
|
||||||
let mut allowed = 0;
|
|
||||||
let mut blocked = false;
|
|
||||||
for _ in 0..50 {
|
|
||||||
match super::check_strict_limit(unique_ip) {
|
|
||||||
Ok(()) => allowed += 1,
|
|
||||||
Err(_) => blocked = true,
|
|
||||||
}
|
|
||||||
if blocked {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(blocked, "strict bucket should eventually block real IP burst");
|
|
||||||
assert!(
|
|
||||||
allowed <= 6,
|
|
||||||
"strict burst is 5, allowed should be <= 6, got {allowed}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试辅助函数:绕过环境变量读取,直接指定 trusted_proxy_count。
|
// 测试辅助函数:绕过环境变量读取,直接指定 trusted_proxy_count。
|
||||||
fn get_client_ip_with_trusted_and_peer(
|
fn get_client_ip_with_trusted_and_peer(
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
|
|||||||
@ -10,11 +10,9 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use std::sync::LazyLock;
|
fn default_allowed_tags() -> HashSet<&'static str> {
|
||||||
|
let mut set = HashSet::new();
|
||||||
#[cfg(feature = "server")]
|
for tag in [
|
||||||
static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
|
||||||
HashSet::from([
|
|
||||||
"a",
|
"a",
|
||||||
"abbr",
|
"abbr",
|
||||||
"acronym",
|
"acronym",
|
||||||
@ -90,16 +88,24 @@ static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(||
|
|||||||
"ul",
|
"ul",
|
||||||
"var",
|
"var",
|
||||||
"wbr",
|
"wbr",
|
||||||
])
|
] {
|
||||||
});
|
set.insert(tag);
|
||||||
|
}
|
||||||
|
set
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
static CLEAN_CONTENT_TAGS: LazyLock<HashSet<&'static str>> =
|
fn clean_content_tags() -> HashSet<&'static str> {
|
||||||
LazyLock::new(|| HashSet::from(["script", "style"]));
|
let mut set = HashSet::new();
|
||||||
|
set.insert("script");
|
||||||
|
set.insert("style");
|
||||||
|
set
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
static DEFAULT_ALLOWED_SCHEMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
fn default_allowed_schemes() -> HashSet<&'static str> {
|
||||||
HashSet::from([
|
let mut set = HashSet::new();
|
||||||
|
for scheme in [
|
||||||
"bitcoin",
|
"bitcoin",
|
||||||
"ftp",
|
"ftp",
|
||||||
"ftps",
|
"ftps",
|
||||||
@ -125,18 +131,11 @@ static DEFAULT_ALLOWED_SCHEMES: LazyLock<HashSet<&'static str>> = LazyLock::new(
|
|||||||
"webcal",
|
"webcal",
|
||||||
"wtai",
|
"wtai",
|
||||||
"xmpp",
|
"xmpp",
|
||||||
])
|
] {
|
||||||
});
|
set.insert(scheme);
|
||||||
|
}
|
||||||
#[cfg(feature = "server")]
|
|
||||||
/// 评论允许的标签:在默认集合基础上移除 img / details / summary。
|
|
||||||
static COMMENT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
|
||||||
let mut set = DEFAULT_ALLOWED_TAGS.clone();
|
|
||||||
set.remove("img");
|
|
||||||
set.remove("details");
|
|
||||||
set.remove("summary");
|
|
||||||
set
|
set
|
||||||
});
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn is_safe_data_uri(url: &str) -> bool {
|
fn is_safe_data_uri(url: &str) -> bool {
|
||||||
@ -193,19 +192,19 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
|
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
|
||||||
struct SanitizerConfig {
|
struct SanitizerConfig {
|
||||||
allowed_tags: &'static HashSet<&'static str>,
|
allowed_tags: HashSet<&'static str>,
|
||||||
extra_generic_attrs: Vec<&'static str>,
|
extra_generic_attrs: Vec<&'static str>,
|
||||||
extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
|
extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
|
||||||
allowed_schemes: &'static HashSet<&'static str>,
|
allowed_schemes: HashSet<&'static str>,
|
||||||
allow_data_uri: bool,
|
allow_data_uri: bool,
|
||||||
link_rel: Option<&'static str>,
|
link_rel: Option<&'static str>,
|
||||||
remove_tags: &'static HashSet<&'static str>,
|
remove_tags: HashSet<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
||||||
let allowed_tags = config.allowed_tags;
|
let allowed_tags = config.allowed_tags.clone();
|
||||||
let remove_tags = config.remove_tags;
|
let remove_tags = config.remove_tags.clone();
|
||||||
let generic_attrs: HashSet<&str> = config
|
let generic_attrs: HashSet<&str> = config
|
||||||
.extra_generic_attrs
|
.extra_generic_attrs
|
||||||
.iter()
|
.iter()
|
||||||
@ -252,7 +251,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
|||||||
}
|
}
|
||||||
m
|
m
|
||||||
};
|
};
|
||||||
let allowed_schemes = config.allowed_schemes;
|
let allowed_schemes = config.allowed_schemes.clone();
|
||||||
let allow_data_uri = config.allow_data_uri;
|
let allow_data_uri = config.allow_data_uri;
|
||||||
let link_rel = config.link_rel;
|
let link_rel = config.link_rel;
|
||||||
|
|
||||||
@ -287,7 +286,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
|||||||
if allowed_for_tag.contains(name_lower.as_str()) {
|
if allowed_for_tag.contains(name_lower.as_str()) {
|
||||||
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
|
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
|
||||||
let val = attr.value();
|
let val = attr.value();
|
||||||
if !is_safe_url(&val, allowed_schemes, allow_data_uri) {
|
if !is_safe_url(&val, &allowed_schemes, allow_data_uri) {
|
||||||
return Some(name);
|
return Some(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -332,7 +331,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
|||||||
/// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。
|
/// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。
|
||||||
pub fn clean_html(input: &str) -> String {
|
pub fn clean_html(input: &str) -> String {
|
||||||
let config = SanitizerConfig {
|
let config = SanitizerConfig {
|
||||||
allowed_tags: &DEFAULT_ALLOWED_TAGS,
|
allowed_tags: default_allowed_tags(),
|
||||||
extra_generic_attrs: vec![
|
extra_generic_attrs: vec![
|
||||||
"class",
|
"class",
|
||||||
"aria-hidden",
|
"aria-hidden",
|
||||||
@ -352,10 +351,10 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
("h5", vec!["id", "class"]),
|
("h5", vec!["id", "class"]),
|
||||||
("h6", vec!["id", "class"]),
|
("h6", vec!["id", "class"]),
|
||||||
],
|
],
|
||||||
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
|
allowed_schemes: default_allowed_schemes(),
|
||||||
allow_data_uri: false,
|
allow_data_uri: false,
|
||||||
link_rel: Some("noopener noreferrer"),
|
link_rel: Some("noopener noreferrer"),
|
||||||
remove_tags: &CLEAN_CONTENT_TAGS,
|
remove_tags: clean_content_tags(),
|
||||||
};
|
};
|
||||||
sanitize(input, &config)
|
sanitize(input, &config)
|
||||||
}
|
}
|
||||||
@ -363,8 +362,13 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。
|
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。
|
||||||
pub fn clean_comment_html(input: &str) -> String {
|
pub fn clean_comment_html(input: &str) -> String {
|
||||||
|
let mut tags = default_allowed_tags();
|
||||||
|
tags.remove("img");
|
||||||
|
tags.remove("details");
|
||||||
|
tags.remove("summary");
|
||||||
|
|
||||||
let config = SanitizerConfig {
|
let config = SanitizerConfig {
|
||||||
allowed_tags: &COMMENT_ALLOWED_TAGS,
|
allowed_tags: tags,
|
||||||
extra_generic_attrs: vec![
|
extra_generic_attrs: vec![
|
||||||
"class",
|
"class",
|
||||||
"title",
|
"title",
|
||||||
@ -377,10 +381,10 @@ pub fn clean_comment_html(input: &str) -> String {
|
|||||||
("a", vec!["class", "aria-hidden", "aria-label"]),
|
("a", vec!["class", "aria-hidden", "aria-label"]),
|
||||||
("span", vec!["class"]),
|
("span", vec!["class"]),
|
||||||
],
|
],
|
||||||
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
|
allowed_schemes: default_allowed_schemes(),
|
||||||
allow_data_uri: false,
|
allow_data_uri: false,
|
||||||
link_rel: Some("nofollow noopener"),
|
link_rel: Some("nofollow noopener"),
|
||||||
remove_tags: &CLEAN_CONTENT_TAGS,
|
remove_tags: clean_content_tags(),
|
||||||
};
|
};
|
||||||
sanitize(input, &config)
|
sanitize(input, &config)
|
||||||
}
|
}
|
||||||
@ -460,26 +464,26 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_allows_https() {
|
fn is_safe_url_allows_https() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
assert!(is_safe_url("https://example.com", &schemes, false));
|
assert!(is_safe_url("https://example.com", &schemes, false));
|
||||||
assert!(is_safe_url("http://example.com", &schemes, false));
|
assert!(is_safe_url("http://example.com", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_javascript() {
|
fn is_safe_url_rejects_javascript() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
|
assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_vbscript() {
|
fn is_safe_url_rejects_vbscript() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
|
assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_data_uri_respects_flag_and_media_type() {
|
fn is_safe_url_data_uri_respects_flag_and_media_type() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
// 仅在显式允许且 media type 为图片时通过
|
// 仅在显式允许且 media type 为图片时通过
|
||||||
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
|
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
|
||||||
assert!(is_safe_url("data:image/svg+xml;base64,PHN2Zz4=", &schemes, true));
|
assert!(is_safe_url("data:image/svg+xml;base64,PHN2Zz4=", &schemes, true));
|
||||||
@ -492,7 +496,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_allows_relative_and_fragment() {
|
fn is_safe_url_allows_relative_and_fragment() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
// 绝对路径
|
// 绝对路径
|
||||||
assert!(is_safe_url("/path/to/page", &schemes, false));
|
assert!(is_safe_url("/path/to/page", &schemes, false));
|
||||||
// 锚点
|
// 锚点
|
||||||
@ -501,7 +505,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_empty_is_safe() {
|
fn is_safe_url_empty_is_safe() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
// 空 URL(如 img 无 src)视为安全。
|
// 空 URL(如 img 无 src)视为安全。
|
||||||
assert!(is_safe_url("", &schemes, false));
|
assert!(is_safe_url("", &schemes, false));
|
||||||
assert!(is_safe_url(" ", &schemes, false));
|
assert!(is_safe_url(" ", &schemes, false));
|
||||||
@ -509,7 +513,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_allows_other_whitelisted_schemes() {
|
fn is_safe_url_allows_other_whitelisted_schemes() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
// mailto / tel / ftp 等均在默认白名单中。
|
// mailto / tel / ftp 等均在默认白名单中。
|
||||||
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
|
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
|
||||||
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
|
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
|
||||||
@ -518,14 +522,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_scheme_with_whitespace() {
|
fn is_safe_url_rejects_scheme_with_whitespace() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
|
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
|
||||||
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
|
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_unknown_schemes() {
|
fn is_safe_url_rejects_unknown_schemes() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
// 未知 scheme 默认拒绝。
|
// 未知 scheme 默认拒绝。
|
||||||
assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
|
assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
|
||||||
assert!(!is_safe_url("blob:https://example.com/abc", &schemes, false));
|
assert!(!is_safe_url("blob:https://example.com/abc", &schemes, false));
|
||||||
@ -535,7 +539,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_scheme_matching_is_case_insensitive() {
|
fn is_safe_url_scheme_matching_is_case_insensitive() {
|
||||||
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
let schemes = default_allowed_schemes();
|
||||||
// scheme 大小写不敏感:HTTPS 与 https 等价。
|
// scheme 大小写不敏感:HTTPS 与 https 等价。
|
||||||
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
|
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
|
||||||
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
|
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
|
||||||
|
|||||||
@ -205,25 +205,8 @@ pub async fn upload_image(
|
|||||||
let is_gif = mime_type.as_str() == "image/gif";
|
let is_gif = mime_type.as_str() == "image/gif";
|
||||||
let is_webp = mime_type.as_str() == "image/webp";
|
let is_webp = mime_type.as_str() == "image/webp";
|
||||||
|
|
||||||
// 对不经过重编码的格式做解码验证。GIF 走 image::load_from_memory 会完整解码,
|
// 对不经过重编码的格式做解码验证。
|
||||||
// 移到阻塞线程池避免拖住 async 运行时。
|
if (is_gif || is_webp) && !validate_raw_image(&data, mime_type.as_str()) {
|
||||||
if is_gif || is_webp {
|
|
||||||
let validate_data = data.to_vec();
|
|
||||||
let validate_mime = mime_type.clone();
|
|
||||||
let is_valid = tokio::task::spawn_blocking(move || {
|
|
||||||
validate_raw_image(&validate_data, validate_mime.as_str())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({
|
|
||||||
"success": false,
|
|
||||||
"error": "图片校验任务失败"
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !is_valid {
|
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@ -232,7 +215,6 @@ pub async fn upload_image(
|
|||||||
})),
|
})),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// GIF 与 WebP 保持原格式;其余格式尝试转 WebP。
|
// GIF 与 WebP 保持原格式;其余格式尝试转 WebP。
|
||||||
let (final_data, final_ext) = if is_gif {
|
let (final_data, final_ext) = if is_gif {
|
||||||
|
|||||||
17
src/cache.rs
17
src/cache.rs
@ -74,10 +74,8 @@ pub enum CacheKey {
|
|||||||
AllTags,
|
AllTags,
|
||||||
/// 按 slug 查询的单篇文章。
|
/// 按 slug 查询的单篇文章。
|
||||||
PostBySlug(String),
|
PostBySlug(String),
|
||||||
/// 按标签查询的文章列表(不分页,返回全部)。
|
/// 按标签查询的文章列表。
|
||||||
PostsByTag(String),
|
PostsByTag(String),
|
||||||
/// 按标签查询的分页文章列表。
|
|
||||||
PostsByTagPage { tag: String, page: i32, per_page: i32 },
|
|
||||||
/// 文章统计信息。
|
/// 文章统计信息。
|
||||||
PostStats,
|
PostStats,
|
||||||
/// 某篇文章下的评论列表。
|
/// 某篇文章下的评论列表。
|
||||||
@ -276,18 +274,6 @@ pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按标签+分页读取文章列表缓存。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
|
|
||||||
TAG_POSTS_CACHE.get(key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按标签+分页写入文章列表缓存。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
|
|
||||||
let _ = TAG_POSTS_CACHE.insert(key.clone(), (posts, total)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取文章统计缓存。
|
/// 读取文章统计缓存。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub async fn get_post_stats() -> Option<PostStats> {
|
pub async fn get_post_stats() -> Option<PostStats> {
|
||||||
@ -679,7 +665,6 @@ mod tests {
|
|||||||
email: "cached@example.com".to_string(),
|
email: "cached@example.com".to_string(),
|
||||||
role: UserRole::Admin,
|
role: UserRole::Admin,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
session_generation: 0,
|
|
||||||
};
|
};
|
||||||
let token_hash = "sha256_token_hash";
|
let token_hash = "sha256_token_hash";
|
||||||
|
|
||||||
|
|||||||
@ -12,10 +12,6 @@
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
|
|
||||||
/// 连接获取的指数退避重试策略,仅在启用 server feature 时编译。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub mod retry;
|
|
||||||
|
|
||||||
/// 占位连接池实现,仅在不启用 server feature 时编译。
|
/// 占位连接池实现,仅在不启用 server feature 时编译。
|
||||||
///
|
///
|
||||||
/// `DummyPool` 是一个最小 stub:它提供与真实连接池相同的公开接口形状
|
/// `DummyPool` 是一个最小 stub:它提供与真实连接池相同的公开接口形状
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 仅在启用 `server` feature 时编译,使用 deadpool-postgres 管理连接池,
|
//! 仅在启用 `server` feature 时编译,使用 deadpool-postgres 管理连接池,
|
||||||
//! 并通过 `std::sync::LazyLock` 在首次访问时延迟初始化全局连接池。
|
//! 并通过 `std::sync::LazyLock` 在首次访问时延迟初始化全局连接池。
|
||||||
//! `get_conn` 失败时按指数退避 + jitter 重试(见 `retry` 模块),以应对瞬时连接失败。
|
//! `get_conn` 失败时按固定 2 秒间隔进行简单重试,以应对瞬时连接失败。
|
||||||
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@ -15,24 +15,13 @@ use tokio_postgres::NoTls;
|
|||||||
/// 最大连接数可通过 `DB_POOL_SIZE` 环境变量调整,默认 20。
|
/// 最大连接数可通过 `DB_POOL_SIZE` 环境变量调整,默认 20。
|
||||||
pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
||||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable not set");
|
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable not set");
|
||||||
let mut pg_cfg = db_url
|
let pg_cfg = db_url
|
||||||
.parse::<tokio_postgres::Config>()
|
.parse::<tokio_postgres::Config>()
|
||||||
.expect("Invalid DATABASE_URL format");
|
.expect("Invalid DATABASE_URL format");
|
||||||
|
|
||||||
// statement_timeout:防止单条慢查询(如全表扫搜索)长时间占用连接拖垮池。
|
// 使用 Verified 回收策略,确保归还的连接仍然可用,避免 DB 重启后拿到死连接。
|
||||||
// 默认 30s,可由 STATEMENT_TIMEOUT_SECS 覆盖(L6)。
|
|
||||||
let statement_timeout_secs = std::env::var("STATEMENT_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.unwrap_or(30);
|
|
||||||
// 通过 libpq options 传递 GUC;tokio-postgres 在建连时执行。
|
|
||||||
pg_cfg.options(format!("-c statement_timeout={}", statement_timeout_secs * 1000));
|
|
||||||
|
|
||||||
// 使用 Fast 回收策略:归还连接时不额外发 SELECT 1 验证,直接复用。
|
|
||||||
// Verified 在高并发下会为每次 get() 增加一次往返;Fast 依赖 tokio-postgres
|
|
||||||
// 在使用时自然报错,由 get_conn 的重试层兜底。
|
|
||||||
let mgr_cfg = ManagerConfig {
|
let mgr_cfg = ManagerConfig {
|
||||||
recycling_method: RecyclingMethod::Fast,
|
recycling_method: RecyclingMethod::Verified,
|
||||||
};
|
};
|
||||||
let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg);
|
let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg);
|
||||||
|
|
||||||
@ -51,37 +40,31 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
|||||||
.expect("Failed to create database connection pool")
|
.expect("Failed to create database connection pool")
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 从全局连接池获取一个数据库连接,失败时按指数退避 + jitter 重试。
|
/// 最大重试次数。
|
||||||
///
|
const MAX_RETRIES: u32 = 3;
|
||||||
/// 退避策略见 `retry::backoff_for`。仅对 Backend/Postgres 错误(DB 不可达)重试;
|
|
||||||
/// Timeout(池满)直接返回,让上层限流兜底,避免雪崩(L6)。
|
|
||||||
/// 若所有重试均失败,返回最后一次的 PoolError。
|
|
||||||
pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
|
|
||||||
use rand::Rng;
|
|
||||||
|
|
||||||
|
/// 每次重试之间的固定等待时间。
|
||||||
|
const RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// 从全局连接池获取一个数据库连接,失败时按 `MAX_RETRIES` 进行重试。
|
||||||
|
///
|
||||||
|
/// 若所有尝试均失败,则返回最后一次遇到的 PoolError。
|
||||||
|
pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
|
||||||
let mut last_err = None;
|
let mut last_err = None;
|
||||||
for attempt in 0..=crate::db::retry::MAX_RETRIES {
|
for attempt in 0..=MAX_RETRIES {
|
||||||
match DB_POOL.get().await {
|
match DB_POOL.get().await {
|
||||||
Ok(conn) => return Ok(conn),
|
Ok(conn) => return Ok(conn),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Timeout(池满)不重试:快速失败让上层限流兜底,避免雪崩。
|
if attempt < MAX_RETRIES {
|
||||||
// Backend/Postgres(DB 不可达)才退避重试。
|
|
||||||
let is_timeout = matches!(e, deadpool_postgres::PoolError::Timeout(_));
|
|
||||||
last_err = Some(e);
|
|
||||||
if !is_timeout && attempt < crate::db::retry::MAX_RETRIES {
|
|
||||||
let jitter = rand::thread_rng().gen::<f64>();
|
|
||||||
let delay = crate::db::retry::backoff_for(attempt, jitter);
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"DB connection attempt {} failed (backend error), retrying in {:?}: {:?}",
|
"DB connection attempt {} failed, retrying in {:?}: {:?}",
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
delay,
|
RETRY_DELAY,
|
||||||
last_err.as_ref().unwrap(),
|
e
|
||||||
);
|
);
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(RETRY_DELAY).await;
|
||||||
} else if is_timeout {
|
|
||||||
// 池满:立即返回,不再 sleep。
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
last_err = Some(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
//! 数据库连接获取的指数退避重试策略。
|
|
||||||
//!
|
|
||||||
//! 取代 pool.rs 中固定 2s 间隔的重试:每次重试间隔 = base * 2^attempt,
|
|
||||||
//! 再叠加 [0, base) 的随机 jitter,避免多请求同步重试形成惊群。
|
|
||||||
//! 仅在 `feature = "server"` 时编译。
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// 退避基准间隔(首次重试前的等待约为 base,随后翻倍)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
const BASE_BACKOFF: Duration = Duration::from_millis(200);
|
|
||||||
|
|
||||||
/// 最大重试次数(不含首次尝试)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub const MAX_RETRIES: u32 = 3;
|
|
||||||
|
|
||||||
/// 计算第 `attempt` 次重试(attempt 从 0 开始)前的等待时长。
|
|
||||||
///
|
|
||||||
/// 公式:base * 2^attempt,再叠加 [0, base) 的 jitter。
|
|
||||||
/// jitter 由调用方传入的随机比例 [0.0, 1.0) 决定,便于测试时锁定为 0。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub fn backoff_for(attempt: u32, jitter_ratio: f64) -> Duration {
|
|
||||||
debug_assert!((0.0..=1.0).contains(&jitter_ratio));
|
|
||||||
let exp = u32::checked_shl(1, attempt).unwrap_or(1 << 30);
|
|
||||||
let base_ms = BASE_BACKOFF.as_millis() as u64;
|
|
||||||
let core = base_ms.saturating_mul(exp as u64);
|
|
||||||
let jitter = (base_ms as f64 * jitter_ratio) as u64;
|
|
||||||
Duration::from_millis(core.saturating_add(jitter))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "server"))]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backoff_grows_exponentially_without_jitter() {
|
|
||||||
// jitter=0 时序列应严格翻倍:200, 400, 800 ms。
|
|
||||||
assert_eq!(backoff_for(0, 0.0), Duration::from_millis(200));
|
|
||||||
assert_eq!(backoff_for(1, 0.0), Duration::from_millis(400));
|
|
||||||
assert_eq!(backoff_for(2, 0.0), Duration::from_millis(800));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backoff_includes_jitter_within_base_range() {
|
|
||||||
// jitter_ratio=0.5 时在 core 上叠加 base*0.5 = 100ms。
|
|
||||||
assert_eq!(backoff_for(0, 0.5), Duration::from_millis(300));
|
|
||||||
assert_eq!(backoff_for(1, 0.5), Duration::from_millis(500));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backoff_clamps_large_attempt() {
|
|
||||||
// 超大 attempt 不应 panic,应靠 saturating 保护返回一个大但有界的值。
|
|
||||||
let d = backoff_for(40, 0.0);
|
|
||||||
assert!(d.as_millis() > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
src/main.rs
12
src/main.rs
@ -271,7 +271,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自定义 API 路由:图片上传(大文件,需要更长超时)
|
// 自定义 API 路由:图片上传(大文件,需要更长超时)
|
||||||
// CSRF 校验置于最外层,先拦截非法来源再做超时/限体。
|
|
||||||
let upload_route = axum::Router::new()
|
let upload_route = axum::Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/api/upload",
|
"/api/upload",
|
||||||
@ -281,23 +280,16 @@ fn main() {
|
|||||||
.layer(TimeoutLayer::with_status_code(
|
.layer(TimeoutLayer::with_status_code(
|
||||||
StatusCode::REQUEST_TIMEOUT,
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
Duration::from_secs(300),
|
Duration::from_secs(300),
|
||||||
))
|
|
||||||
.layer(axum::middleware::from_fn(
|
|
||||||
crate::api::csrf::csrf_middleware,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
|
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
|
||||||
let dioxus_app =
|
let dioxus_app =
|
||||||
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
||||||
|
|
||||||
// 合并 Dioxus + CSRF/世代号/缓存头/可选压缩/30s 超时中间件
|
// 合并 Dioxus + 世代号/缓存头/可选压缩/30s 超时中间件
|
||||||
// layer 顺序:后加的最外层先执行。CSRF 最外层先拦截非法来源。
|
|
||||||
let mut app_routes = dioxus_app
|
let mut app_routes = dioxus_app
|
||||||
.layer(axum::middleware::from_fn(ssr_generation_middleware))
|
.layer(axum::middleware::from_fn(ssr_generation_middleware))
|
||||||
.layer(axum::middleware::from_fn(add_cache_control))
|
.layer(axum::middleware::from_fn(add_cache_control));
|
||||||
.layer(axum::middleware::from_fn(
|
|
||||||
crate::api::csrf::csrf_middleware,
|
|
||||||
));
|
|
||||||
if let Some(layer) = compression_layer_from_env() {
|
if let Some(layer) = compression_layer_from_env() {
|
||||||
app_routes = app_routes.layer(layer);
|
app_routes = app_routes.layer(layer);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,8 +42,6 @@ pub struct User {
|
|||||||
pub role: UserRole,
|
pub role: UserRole,
|
||||||
/// 账户创建时间。
|
/// 账户创建时间。
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
/// 会话世代号,角色/封禁变更时 +1 使旧 session 失效。
|
|
||||||
pub session_generation: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
|
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
|
||||||
@ -59,8 +57,6 @@ pub struct SessionUser {
|
|||||||
pub role: UserRole,
|
pub role: UserRole,
|
||||||
/// 账户创建时间。
|
/// 账户创建时间。
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
/// 会话世代号,签发 session 时记录;与 users 表当前值不一致则 session 失效。
|
|
||||||
pub session_generation: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
|
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
|
||||||
@ -87,7 +83,6 @@ impl From<User> for SessionUser {
|
|||||||
email: u.email,
|
email: u.email,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
session_generation: u.session_generation,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -131,7 +126,6 @@ mod tests {
|
|||||||
password_hash: "hash".to_string(),
|
password_hash: "hash".to_string(),
|
||||||
role: UserRole::Admin,
|
role: UserRole::Admin,
|
||||||
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
||||||
session_generation: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 数据获取:
|
//! 数据获取:
|
||||||
//! - 标签云通过 `use_server_future(list_tags)` 获取全部标签信息。
|
//! - 标签云通过 `use_server_future(list_tags)` 获取全部标签信息。
|
||||||
//! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag, None, None)`
|
//! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag)` 获取该标签下的文章列表。
|
||||||
//! 获取该标签下的全部已发布文章(不分页)。
|
|
||||||
//! 在 `wasm32` 目标下,这些 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
|
//! 在 `wasm32` 目标下,这些 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
|
||||||
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
|
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
|
||||||
|
|
||||||
@ -110,7 +109,7 @@ pub fn TagDetail(tag: String) -> Element {
|
|||||||
/// 成功时渲染文章总数与文章卡片。
|
/// 成功时渲染文章总数与文章卡片。
|
||||||
#[component]
|
#[component]
|
||||||
fn TagDetailContent(tag: String) -> Element {
|
fn TagDetailContent(tag: String) -> Element {
|
||||||
let posts_res = use_server_future(move || get_posts_by_tag(tag.clone(), None, None))?;
|
let posts_res = use_server_future(move || get_posts_by_tag(tag.clone()))?;
|
||||||
|
|
||||||
// 将结果映射为 (posts, total) 形式以便渲染。
|
// 将结果映射为 (posts, total) 形式以便渲染。
|
||||||
let posts_data = posts_res.read().as_ref().map(|r| match r {
|
let posts_data = posts_res.read().as_ref().map(|r| match r {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user