style: apply cargo fmt to workspace
Some checks failed
CI / check (push) Failing after 5m35s
CI / build (push) Has been skipped

This commit is contained in:
xfy 2026-06-29 13:47:40 +08:00
parent 5e1becf116
commit 373498870a
34 changed files with 280 additions and 228 deletions

View File

@ -19,9 +19,9 @@ use crate::auth::session::get_session_from_ctx;
use crate::auth::{password, session}; use crate::auth::{password, session};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::models::user::PublicUser;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::user::{SessionUser, UserRole}; use crate::models::user::{SessionUser, UserRole};
use crate::models::user::PublicUser;
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn validate_username(username: &str) -> Result<(), String> { fn validate_username(username: &str) -> Result<(), String> {
@ -119,12 +119,10 @@ pub async fn register(
// Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行,避免阻塞 Tokio worker。 // Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行,避免阻塞 Tokio worker。
let pw_for_hash = password.clone(); let pw_for_hash = password.clone();
let password_hash = tokio::task::spawn_blocking(move || { let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw_for_hash))
password::hash_password(&pw_for_hash) .await
}) .map_err(|_| AppError::Internal("密码处理任务失败"))?
.await .map_err(|_| AppError::Internal("密码处理失败"))?;
.map_err(|_| AppError::Internal("密码处理任务失败"))?
.map_err(|_| AppError::Internal("密码处理失败"))?;
// 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。 // 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。
// 若已有 admin 或用户名/邮箱冲突RETURNING 将返回空。 // 若已有 admin 或用户名/邮箱冲突RETURNING 将返回空。
@ -149,7 +147,10 @@ pub async fn register(
// 插入失败:区分是已有 admin 还是用户名/邮箱冲突。 // 插入失败:区分是已有 admin 还是用户名/邮箱冲突。
let admin_exists: bool = client let admin_exists: bool = client
.query_one("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')", &[]) .query_one(
"SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')",
&[],
)
.await .await
.map_err(AppError::query)? .map_err(AppError::query)?
.get(0); .get(0);

View File

@ -131,7 +131,9 @@ pub fn validate_comment_url(url: &str) -> Result<(), String> {
if trimmed.len() > 200 { if trimmed.len() > 200 {
return Err("网址长度不能超过 200 个字符".to_string()); return Err("网址长度不能超过 200 个字符".to_string());
} }
if trimmed.chars().any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r')) if trimmed
.chars()
.any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
{ {
return Err("网址包含非法字符".to_string()); return Err("网址包含非法字符".to_string());
} }

View File

@ -51,4 +51,3 @@ pub async fn get_comments(post_id: i32) -> Result<CommentTreeResponse, ServerFnE
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]
unreachable!() unreachable!()
} }

View File

@ -12,7 +12,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
#[cfg(feature = "server")] #[cfg(feature = "server")]
use std::net::SocketAddr; use bytes::Bytes;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use moka::future::Cache; use moka::future::Cache;
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -20,9 +20,9 @@ use moka::sync::Cache as SyncCache;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use serde::Deserialize; use serde::Deserialize;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use std::sync::LazyLock; use std::net::SocketAddr;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use bytes::Bytes; use std::sync::LazyLock;
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn etag_for(data: &[u8]) -> String { fn etag_for(data: &[u8]) -> String {
@ -55,13 +55,7 @@ pub static MAX_IMAGE_DIMENSION: LazyLock<u32> = LazyLock::new(|| {
let (val, clamped) = std::env::var("MAX_IMAGE_DIMENSION") let (val, clamped) = std::env::var("MAX_IMAGE_DIMENSION")
.ok() .ok()
.and_then(|s| s.parse::<u32>().ok()) .and_then(|s| s.parse::<u32>().ok())
.map(|v| { .map(|v| if v < MIN { (MIN, true) } else { (v, false) })
if v < MIN {
(MIN, true)
} else {
(v, false)
}
})
.unwrap_or((DEFAULT, false)); .unwrap_or((DEFAULT, false));
if clamped { if clamped {
tracing::warn!( tracing::warn!(
@ -89,13 +83,7 @@ pub static MAX_IMAGE_PIXELS: LazyLock<u32> = LazyLock::new(|| {
let (val, clamped) = std::env::var("MAX_IMAGE_PIXELS") let (val, clamped) = std::env::var("MAX_IMAGE_PIXELS")
.ok() .ok()
.and_then(|s| s.parse::<u32>().ok()) .and_then(|s| s.parse::<u32>().ok())
.map(|v| { .map(|v| if v < MIN { (MIN, true) } else { (v, false) })
if v < MIN {
(MIN, true)
} else {
(v, false)
}
})
.unwrap_or((DEFAULT, false)); .unwrap_or((DEFAULT, false));
if clamped { if clamped {
tracing::warn!( tracing::warn!(
@ -264,7 +252,10 @@ fn image_response(
StatusCode::NOT_MODIFIED, StatusCode::NOT_MODIFIED,
[ [
(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 sniffM2 // nosniff 防止浏览器对 content-type 错配的图片字节做 MIME sniffM2
( (
@ -281,7 +272,10 @@ fn image_response(
StatusCode::OK, StatusCode::OK,
[ [
(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, header::X_CONTENT_TYPE_OPTIONS,
@ -347,15 +341,12 @@ pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'sta
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 按 MIME 只读 header 拿 (width, height)。失败返回损坏错误。 /// 按 MIME 只读 header 拿 (width, height)。失败返回损坏错误。
fn read_dimensions_by_mime( fn read_dimensions_by_mime(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
data: &[u8],
mime_type: &str,
) -> Result<(u32, u32), &'static str> {
match mime_type { match mime_type {
"image/webp" => { "image/webp" => {
// zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素(与 webp::decode 同源)。 // zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素(与 webp::decode 同源)。
let decoder = zenwebp::WebPDecoder::build(data) let decoder =
.map_err(|_| "图片文件损坏或格式不正确")?; zenwebp::WebPDecoder::build(data).map_err(|_| "图片文件损坏或格式不正确")?;
let info = decoder.info(); let info = decoder.info();
Ok((info.width, info.height)) Ok((info.width, info.height))
} }
@ -624,7 +615,12 @@ pub async fn serve_image(
Ok(_) => match tokio::fs::read(&file_path).await { 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(),
}, },
@ -658,19 +654,18 @@ pub async fn serve_image(
// runtime stays responsive to other requests. // runtime stays responsive to other requests.
let path_for_blocking = path.clone(); let path_for_blocking = path.clone();
let params_for_blocking = params.clone(); let params_for_blocking = params.clone();
let (processed, content_type) = let (processed, content_type) = match tokio::task::spawn_blocking(move || {
match tokio::task::spawn_blocking(move || { process_image_blocking(data, params_for_blocking, path_for_blocking)
process_image_blocking(data, params_for_blocking, path_for_blocking) })
}) .await
.await {
{ Ok(Ok(r)) => r,
Ok(Ok(r)) => r, Ok(Err(status)) => return status.into_response(),
Ok(Err(status)) => return status.into_response(), Err(_) => {
Err(_) => { tracing::error!("Image processing task panicked");
tracing::error!("Image processing task panicked"); return StatusCode::INTERNAL_SERVER_ERROR.into_response();
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }
} };
};
let processed = Bytes::from(processed); let processed = Bytes::from(processed);
let cached = CachedImage { let cached = CachedImage {
@ -1086,15 +1081,17 @@ mod tests {
); );
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let headers = resp.headers(); let headers = resp.headers();
assert_eq!( assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
headers.get(header::CONTENT_TYPE).unwrap(),
"image/webp"
);
assert_eq!( assert_eq!(
headers.get(header::CACHE_CONTROL).unwrap(), headers.get(header::CACHE_CONTROL).unwrap(),
"public, max-age=86400" "public, max-age=86400"
); );
assert!(headers.get(header::ETAG).unwrap().to_str().unwrap().starts_with('"')); assert!(headers
.get(header::ETAG)
.unwrap()
.to_str()
.unwrap()
.starts_with('"'));
} }
#[test] #[test]
@ -1102,10 +1099,7 @@ mod tests {
let data = Bytes::from(vec![1, 2, 3]); let data = Bytes::from(vec![1, 2, 3]);
let etag = etag_for(&data); let etag = etag_for(&data);
let mut req_headers = HeaderMap::new(); let mut req_headers = HeaderMap::new();
req_headers.insert( req_headers.insert(header::IF_NONE_MATCH, HeaderValue::from_str(&etag).unwrap());
header::IF_NONE_MATCH,
HeaderValue::from_str(&etag).unwrap(),
);
let resp = image_response( let resp = image_response(
data, data,
HeaderValue::from_static("image/webp"), HeaderValue::from_static("image/webp"),

View File

@ -349,7 +349,10 @@ mod tests {
// 用一个不含 dimensions 的路径验证 --ar 缺省时的结构正确性。 // 用一个不含 dimensions 的路径验证 --ar 缺省时的结构正确性。
let html = r#"<p><img src="/uploads/nonexistent/test.webp" alt="test"></p>"#; let html = r#"<p><img src="/uploads/nonexistent/test.webp" alt="test"></p>"#;
let result = wrap_images_with_blur(html); let result = wrap_images_with_blur(html);
assert!(result.contains("blur-img-placeholder"), "should have placeholder"); assert!(
result.contains("blur-img-placeholder"),
"should have placeholder"
);
assert!(result.contains("blur-img-full"), "should have full layer"); assert!(result.contains("blur-img-full"), "should have full layer");
assert!(result.contains("?w=20"), "placeholder should use ?w=20"); assert!(result.contains("?w=20"), "placeholder should use ?w=20");
assert!(result.contains("?w=800"), "full should use ?w=800"); assert!(result.contains("?w=800"), "full should use ?w=800");
@ -361,7 +364,10 @@ mod tests {
let html = r#"<img src="https://example.com/img.png" alt="ext">"#; let html = r#"<img src="https://example.com/img.png" alt="ext">"#;
let result = wrap_images_with_blur(html); let result = wrap_images_with_blur(html);
// 外链图不处理,保持原样 // 外链图不处理,保持原样
assert!(!result.contains("blur-img"), "external image should not be wrapped"); assert!(
!result.contains("blur-img"),
"external image should not be wrapped"
);
} }
#[test] #[test]
@ -372,7 +378,11 @@ mod tests {
println!("ACTUAL OUTPUT: {}", result); println!("ACTUAL OUTPUT: {}", result);
// aspect-ratio 必须用斜杠分隔,如 "--ar:800 / 600;" // aspect-ratio 必须用斜杠分隔,如 "--ar:800 / 600;"
assert!(result.contains("--ar:"), "should have --ar"); assert!(result.contains("--ar:"), "should have --ar");
assert!(result.contains(" / "), "aspect-ratio must use slash separator, got: {}", result); assert!(
result.contains(" / "),
"aspect-ratio must use slash separator, got: {}",
result
);
} }
#[test] #[test]
@ -384,7 +394,11 @@ mod tests {
let cleaned = clean_html(&wrapped); let cleaned = clean_html(&wrapped);
println!("WRAPPED: {}", wrapped); println!("WRAPPED: {}", wrapped);
println!("CLEANED: {}", cleaned); println!("CLEANED: {}", cleaned);
assert!(cleaned.contains(" / "), "clean_html must preserve slash in --ar, got: {}", cleaned); assert!(
cleaned.contains(" / "),
"clean_html must preserve slash in --ar, got: {}",
cleaned
);
} }
#[test] #[test]

View File

@ -6,10 +6,10 @@
/// 认证相关的 Dioxus server function。 /// 认证相关的 Dioxus server function。
pub mod auth; pub mod auth;
/// CSRF 防护中间件。
pub mod csrf;
/// 评论相关接口。 /// 评论相关接口。
pub mod comments; pub mod comments;
/// CSRF 防护中间件。
pub mod csrf;
/// 应用错误类型与转换。 /// 应用错误类型与转换。
pub mod error; pub mod error;
/// 健康检查端点liveness / readiness /// 健康检查端点liveness / readiness

View File

@ -190,7 +190,10 @@ pub async fn list_deleted_posts(
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
let count_row = client let count_row = client
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL", &[]) .query_one(
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL",
&[],
)
.await .await
.map_err(AppError::query)?; .map_err(AppError::query)?;
let total: i64 = count_row.get(0); let total: i64 = count_row.get(0);

View File

@ -14,21 +14,21 @@ mod rebuild;
mod search; mod search;
mod stats; mod stats;
mod tags; mod tags;
mod trash;
mod types; mod types;
mod update; mod update;
mod trash;
/// 创建新文章。 /// 创建新文章。
#[allow(unused_imports)] #[allow(unused_imports)]
pub use create::create_post; pub use create::create_post;
/// 删除指定文章。 /// 删除指定文章。
pub use delete::delete_post; pub use delete::delete_post;
/// 获取管理员视角的全部文章分页列表。
#[allow(unused_imports)]
pub use list::list_posts;
/// 获取回收站中已软删除的文章列表。 /// 获取回收站中已软删除的文章列表。
#[allow(unused_imports)] #[allow(unused_imports)]
pub use list::list_deleted_posts; pub use list::list_deleted_posts;
/// 获取管理员视角的全部文章分页列表。
#[allow(unused_imports)]
pub use list::list_posts;
/// 获取已发布文章分页列表。 /// 获取已发布文章分页列表。
pub use list::{get_posts_by_tag, list_published_posts}; pub use list::{get_posts_by_tag, list_published_posts};
/// 根据 id 获取文章详情。 /// 根据 id 获取文章详情。
@ -43,14 +43,14 @@ pub use search::search_posts;
pub use stats::get_post_stats; pub use stats::get_post_stats;
/// 获取全部标签及其文章数量。 /// 获取全部标签及其文章数量。
pub use tags::list_tags; pub use tags::list_tags;
/// 恢复已删除文章。
#[allow(unused_imports)]
pub use trash::{batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post};
/// 文章 API 的请求与响应数据结构。 /// 文章 API 的请求与响应数据结构。
pub use types::*; pub use types::*;
/// 更新指定文章。 /// 更新指定文章。
#[allow(unused_imports)] #[allow(unused_imports)]
pub use update::update_post; pub use update::update_post;
/// 恢复已删除文章。
#[allow(unused_imports)]
pub use trash::{batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post};
/// 将 Markdown 渲染为增强 HTML含目录 /// 将 Markdown 渲染为增强 HTML含目录
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -232,8 +232,7 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
// 逐条恢复slug 冲突时自动加后缀;同时收集受影响的 slug 与标签。 // 逐条恢复slug 冲突时自动加后缀;同时收集受影响的 slug 与标签。
let mut restored = 0u64; let mut restored = 0u64;
let mut affected_slugs: Vec<String> = Vec::with_capacity(post_ids.len() * 2); let mut affected_slugs: Vec<String> = Vec::with_capacity(post_ids.len() * 2);
let mut affected_tags: std::collections::HashSet<String> = let mut affected_tags: std::collections::HashSet<String> = std::collections::HashSet::new();
std::collections::HashSet::new();
for id in &post_ids { for id in &post_ids {
let row = tx let row = tx
@ -289,7 +288,8 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
for slug in &unique_slugs { for slug in &unique_slugs {
crate::cache::invalidate_post_by_slug(slug).await; crate::cache::invalidate_post_by_slug(slug).await;
} }
crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::<Vec<_>>()).await; crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::<Vec<_>>())
.await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
@ -344,8 +344,7 @@ pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse,
let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT; let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
let (slugs, tags) = if use_precise { let (slugs, tags) = if use_precise {
let mut slugs = Vec::with_capacity(post_ids.len()); let mut slugs = Vec::with_capacity(post_ids.len());
let mut tags_set: std::collections::HashSet<String> = let mut tags_set: std::collections::HashSet<String> = std::collections::HashSet::new();
std::collections::HashSet::new();
for id in &post_ids { for id in &post_ids {
let slug_row = tx let slug_row = tx
@ -445,8 +444,8 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
) )
.await .await
.map_err(AppError::query)?; .map_err(AppError::query)?;
let use_precise = !deleted_rows.is_empty() let use_precise =
&& deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT; !deleted_rows.is_empty() && deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT;
let (slugs, tags) = if use_precise { let (slugs, tags) = if use_precise {
let slugs: Vec<String> = deleted_rows.iter().map(|r| r.get("slug")).collect(); let slugs: Vec<String> = deleted_rows.iter().map(|r| r.get("slug")).collect();

View File

@ -426,7 +426,10 @@ mod tests {
break; break;
} }
} }
assert!(blocked, "strict bucket should eventually block real IP burst"); assert!(
blocked,
"strict bucket should eventually block real IP burst"
);
assert!( assert!(
allowed <= 6, allowed <= 6,
"strict burst is 5, allowed should be <= 6, got {allowed}" "strict burst is 5, allowed should be <= 6, got {allowed}"

View File

@ -395,7 +395,10 @@ mod tests {
let input = r#"<span class="blur-img" style="--ar:16/9"><img class="blur-img-placeholder" src="/uploads/x.webp?w=20" alt="t"><img class="blur-img-full" data-src="/uploads/x.webp?w=800" alt="t"></span>"#; let input = r#"<span class="blur-img" style="--ar:16/9"><img class="blur-img-placeholder" src="/uploads/x.webp?w=20" alt="t"><img class="blur-img-full" data-src="/uploads/x.webp?w=800" alt="t"></span>"#;
let result = clean_html(input); let result = clean_html(input);
assert!(result.contains("data-src"), "data-src should be allowed"); assert!(result.contains("data-src"), "data-src should be allowed");
assert!(result.contains("blur-img-placeholder"), "class should be allowed"); assert!(
result.contains("blur-img-placeholder"),
"class should be allowed"
);
assert!(result.contains("--ar"), "style should be allowed"); assert!(result.contains("--ar"), "style should be allowed");
} }
@ -492,12 +495,24 @@ mod tests {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 仅在显式允许且 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
));
// 禁用 data URI 时拒绝 // 禁用 data URI 时拒绝
assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false)); assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
// 非图片 data URI 拒绝 // 非图片 data URI 拒绝
assert!(!is_safe_url("data:text/html,<script>alert(1)</script>", &schemes, true)); assert!(!is_safe_url(
assert!(!is_safe_url("data:application/javascript,alert(1)", &schemes, true)); "data:text/html,<script>alert(1)</script>",
&schemes,
true
));
assert!(!is_safe_url(
"data:application/javascript,alert(1)",
&schemes,
true
));
} }
#[test] #[test]
@ -538,7 +553,11 @@ mod tests {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 未知 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
));
assert!(!is_safe_url("about:blank", &schemes, false)); assert!(!is_safe_url("about:blank", &schemes, false));
assert!(!is_safe_url("custom-app://open", &schemes, false)); assert!(!is_safe_url("custom-app://open", &schemes, false));
} }

View File

@ -8,13 +8,13 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::models::settings::TrashSettings;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::models::settings::TrashSettings;
/// 读取回收站配置。 /// 读取回收站配置。
/// ///

View File

@ -12,9 +12,9 @@ use axum::{
response::Json, response::Json,
}; };
#[cfg(feature = "server")] #[cfg(feature = "server")]
use std::net::SocketAddr;
#[cfg(feature = "server")]
use serde_json::{json, Value}; use serde_json::{json, Value};
#[cfg(feature = "server")]
use std::net::SocketAddr;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::auth::session::parse_session_token; use crate::auth::session::parse_session_token;
@ -47,9 +47,7 @@ fn validate_image_magic_bytes(data: &[u8], mime_type: &str) -> bool {
"image/gif" => data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a"), "image/gif" => data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a"),
"image/webp" => { "image/webp" => {
// RIFF....WEBP // RIFF....WEBP
data.len() >= 12 data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP"
&& &data[0..4] == b"RIFF"
&& &data[8..12] == b"WEBP"
} }
_ => false, _ => false,
} }
@ -448,8 +446,14 @@ mod tests {
#[test] #[test]
fn validate_jpeg_magic_bytes() { fn validate_jpeg_magic_bytes() {
assert!(super::validate_image_magic_bytes(&[0xFF, 0xD8, 0xFF], "image/jpeg")); assert!(super::validate_image_magic_bytes(
assert!(!super::validate_image_magic_bytes(&[0x89, 0x50], "image/jpeg")); &[0xFF, 0xD8, 0xFF],
"image/jpeg"
));
assert!(!super::validate_image_magic_bytes(
&[0x89, 0x50],
"image/jpeg"
));
} }
#[test] #[test]
@ -458,7 +462,10 @@ mod tests {
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
"image/png" "image/png"
)); ));
assert!(!super::validate_image_magic_bytes(&[0xFF, 0xD8], "image/png")); assert!(!super::validate_image_magic_bytes(
&[0xFF, 0xD8],
"image/png"
));
} }
#[test] #[test]
@ -472,6 +479,9 @@ mod tests {
fn validate_webp_magic_bytes() { fn validate_webp_magic_bytes() {
let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 "; let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ";
assert!(super::validate_image_magic_bytes(&webp[..12], "image/webp")); assert!(super::validate_image_magic_bytes(&webp[..12], "image/webp"));
assert!(!super::validate_image_magic_bytes(&[0xFF, 0xD8], "image/webp")); assert!(!super::validate_image_magic_bytes(
&[0xFF, 0xD8],
"image/webp"
));
} }
} }

View File

@ -113,10 +113,7 @@ fn strip_background_color(body: &str) -> String {
body.split(';') body.split(';')
.filter(|decl| { .filter(|decl| {
let trimmed = decl.trim(); let trimmed = decl.trim();
!trimmed.is_empty() !trimmed.is_empty() && !trimmed.to_ascii_lowercase().starts_with("background-color")
&& !trimmed
.to_ascii_lowercase()
.starts_with("background-color")
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(";") .join(";")

View File

@ -77,7 +77,11 @@ pub enum CacheKey {
/// 按标签查询的文章列表(不分页,返回全部)。 /// 按标签查询的文章列表(不分页,返回全部)。
PostsByTag(String), PostsByTag(String),
/// 按标签查询的分页文章列表。 /// 按标签查询的分页文章列表。
PostsByTagPage { tag: String, page: i32, per_page: i32 }, PostsByTagPage {
tag: String,
page: i32,
per_page: i32,
},
/// 文章统计信息。 /// 文章统计信息。
PostStats, PostStats,
/// 某篇文章下的评论列表。 /// 某篇文章下的评论列表。
@ -760,5 +764,4 @@ mod tests {
invalidate_search_results(); invalidate_search_results();
assert!(get_search_results("tokio").await.is_none()); assert!(get_search_results("tokio").await.is_none());
} }
} }

View File

@ -27,11 +27,7 @@ const COMMENT_SUBMIT_CLASS: &str = "py-2.5 px-6 bg-paper-accent text-white font-
/// - 提交时校验必填项与蜜罐字段 /// - 提交时校验必填项与蜜罐字段
/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新 /// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
#[component] #[component]
pub fn CommentForm( pub fn CommentForm(post_id: i32, parent_id: Option<i64>, parent_indent: Option<i32>) -> Element {
post_id: i32,
parent_id: Option<i64>,
parent_indent: Option<i32>,
) -> Element {
let ctx: CommentContext = use_context(); let ctx: CommentContext = use_context();
let mut active_reply = ctx.active_reply; let mut active_reply = ctx.active_reply;
let mut refresh_trigger = ctx.refresh_trigger; let mut refresh_trigger = ctx.refresh_trigger;

View File

@ -24,7 +24,7 @@ pub fn PostContent(content_html: String) -> Element {
// 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。 // 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。
let _ = js_sys::eval( let _ = js_sys::eval(
"window.__lightboxSelectors = ['.post-content', '.entry-cover']; \ "window.__lightboxSelectors = ['.post-content', '.entry-cover']; \
if (window.__initLightbox) window.__initLightbox(window.__lightboxSelectors);" if (window.__initLightbox) window.__initLightbox(window.__lightboxSelectors);",
); );
}); });

View File

@ -16,8 +16,7 @@ use crate::router::Route;
// =========================================================================== // ===========================================================================
/// Admin 卡片容器:白底圆角描边,亮/暗双模式。用于 stat 卡片、面板等。 /// Admin 卡片容器:白底圆角描边,亮/暗双模式。用于 stat 卡片、面板等。
pub const ADMIN_CARD_CLASS: &str = pub const ADMIN_CARD_CLASS: &str = "bg-paper-entry rounded-xl border border-paper-border";
"bg-paper-entry rounded-xl border border-paper-border";
/// Admin 表格容器:在卡片基础上加 `overflow-hidden`,圆角裁剪表格。 /// Admin 表格容器:在卡片基础上加 `overflow-hidden`,圆角裁剪表格。
pub const ADMIN_TABLE_CLASS: &str = pub const ADMIN_TABLE_CLASS: &str =
@ -56,7 +55,8 @@ pub const BTN_TEXT_AMBER: &str = "text-xs text-amber-600 hover:text-amber-800 da
pub const BTN_TEXT_RED: &str = pub const BTN_TEXT_RED: &str =
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"; "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
/// 主题绿(鼠尾草)文字小按钮(行内恢复)。 /// 主题绿(鼠尾草)文字小按钮(行内恢复)。
pub const BTN_TEXT_ACCENT: &str = "text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer"; pub const BTN_TEXT_ACCENT: &str =
"text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
// --- 次要按钮冷调玫瑰第二色ghost 描边风格,从属于主色鼠尾草绿) --- // --- 次要按钮冷调玫瑰第二色ghost 描边风格,从属于主色鼠尾草绿) ---
@ -192,7 +192,7 @@ static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicU
/// ///
/// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。 /// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。
/// 具备高级的平滑滑动底部指示器动画。 /// 具备高级的平滑滑动底部指示器动画。
/// ///
/// Props /// Props
/// - `items`:选项卡列表,每一项为 `(value, label)` /// - `items`:选项卡列表,每一项为 `(value, label)`
/// - `active_value`:当前选中的值 /// - `active_value`:当前选中的值
@ -212,15 +212,16 @@ pub fn FilterTabs(
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
// 等待 DOM 节点更新 // 等待 DOM 节点更新
let promise = js_sys::Promise::new(&mut |resolve, _| { let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() { if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 50); let _ = window
.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 50);
} }
}); });
let _ = wasm_bindgen_futures::JsFuture::from(promise).await; let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
if let Some(window) = web_sys::window() { if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() { if let Some(doc) = window.document() {
let element_id = format!("tab-{}-{}", id_prefix, active); let element_id = format!("tab-{}-{}", id_prefix, active);
@ -253,10 +254,10 @@ pub fn FilterTabs(
button { button {
id: "tab-{id_prefix}-{value}", id: "tab-{id_prefix}-{value}",
key: "{value}", key: "{value}",
class: if active_value == *value { class: if active_value == *value {
"cursor-pointer px-4 py-2 text-sm font-medium text-paper-primary transition-colors" "cursor-pointer px-4 py-2 text-sm font-medium text-paper-primary transition-colors"
} else { } else {
"cursor-pointer px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors" "cursor-pointer px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors"
}, },
onclick: { onclick: {
let v = value.to_string(); let v = value.to_string();

View File

@ -35,7 +35,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
("005", include_str!("../../migrations/005_comments.sql")), ("005", include_str!("../../migrations/005_comments.sql")),
("006", include_str!("../../migrations/006_add_toc_html.sql")), ("006", include_str!("../../migrations/006_add_toc_html.sql")),
("007", include_str!("../../migrations/007_settings.sql")), ("007", include_str!("../../migrations/007_settings.sql")),
("008", include_str!("../../migrations/008_comments_cascade.sql")), (
"008",
include_str!("../../migrations/008_comments_cascade.sql"),
),
( (
"009", "009",
include_str!("../../migrations/009_cleanup_duplicate_indexes.sql"), include_str!("../../migrations/009_cleanup_duplicate_indexes.sql"),
@ -185,7 +188,9 @@ async fn ensure_versions_table(conn: &deadpool_postgres::Object) -> Result<(), M
async fn applied_versions( async fn applied_versions(
conn: &deadpool_postgres::Object, conn: &deadpool_postgres::Object,
) -> Result<HashSet<String>, MigrateError> { ) -> Result<HashSet<String>, MigrateError> {
let rows = conn.query("SELECT version FROM schema_migrations", &[]).await?; let rows = conn
.query("SELECT version FROM schema_migrations", &[])
.await?;
let mut set = HashSet::with_capacity(rows.len()); let mut set = HashSet::with_capacity(rows.len());
for row in rows { for row in rows {
set.insert(row.get::<_, String>(0)); set.insert(row.get::<_, String>(0));
@ -243,7 +248,10 @@ mod tests {
let mut sorted = MIGRATIONS.iter().map(|(v, _)| *v).collect::<Vec<_>>(); let mut sorted = MIGRATIONS.iter().map(|(v, _)| *v).collect::<Vec<_>>();
sorted.sort_unstable(); sorted.sort_unstable();
let original: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect(); let original: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
assert_eq!(original, sorted, "MIGRATIONS must be in ascending version order"); assert_eq!(
original, sorted,
"MIGRATIONS must be in ascending version order"
);
} }
#[test] #[test]
@ -252,7 +260,11 @@ mod tests {
let total = versions.len(); let total = versions.len();
versions.sort_unstable(); versions.sort_unstable();
versions.dedup(); versions.dedup();
assert_eq!(versions.len(), total, "MIGRATIONS has duplicate version strings"); assert_eq!(
versions.len(),
total,
"MIGRATIONS has duplicate version strings"
);
} }
#[test] #[test]
@ -296,8 +308,7 @@ mod tests {
MIGRATIONS.iter().map(|(v, _)| v.to_string()).collect(); MIGRATIONS.iter().map(|(v, _)| v.to_string()).collect();
// 磁盘上有但数组里没有 → 忘记加行(会静默不执行该迁移)。 // 磁盘上有但数组里没有 → 忘记加行(会静默不执行该迁移)。
let missing_in_array: Vec<&String> = let missing_in_array: Vec<&String> = files_on_disk.difference(&versions_in_array).collect();
files_on_disk.difference(&versions_in_array).collect();
assert!( assert!(
missing_in_array.is_empty(), missing_in_array.is_empty(),
"migrations/*.sql files not registered in MIGRATIONS: {:?}. \ "migrations/*.sql files not registered in MIGRATIONS: {:?}. \
@ -306,8 +317,7 @@ mod tests {
); );
// 数组里有但磁盘上没有 → include_str! 本就会编译失败,这里只是双保险。 // 数组里有但磁盘上没有 → include_str! 本就会编译失败,这里只是双保险。
let missing_on_disk: Vec<&String> = let missing_on_disk: Vec<&String> = versions_in_array.difference(&files_on_disk).collect();
versions_in_array.difference(&files_on_disk).collect();
assert!( assert!(
missing_on_disk.is_empty(), missing_on_disk.is_empty(),
"MIGRATIONS rows without a corresponding .sql file: {:?}", "MIGRATIONS rows without a corresponding .sql file: {:?}",

View File

@ -22,9 +22,8 @@ use tokio_postgres::{Config, NoTls};
/// ///
/// 返回 `Err(String)` 而非 panic调用方决定如何向用户报告错误。 /// 返回 `Err(String)` 而非 panic调用方决定如何向用户报告错误。
fn build_pg_config() -> Result<tokio_postgres::Config, String> { fn build_pg_config() -> Result<tokio_postgres::Config, String> {
let db_url = std::env::var("DATABASE_URL").map_err(|_| { let db_url = std::env::var("DATABASE_URL")
"DATABASE_URL environment variable not set".to_string() .map_err(|_| "DATABASE_URL environment variable not set".to_string())?;
})?;
let mut pg_cfg = db_url let mut pg_cfg = db_url
.parse::<tokio_postgres::Config>() .parse::<tokio_postgres::Config>()
.map_err(|e| format!("Invalid DATABASE_URL format: {e}"))?; .map_err(|e| format!("Invalid DATABASE_URL format: {e}"))?;
@ -36,7 +35,10 @@ fn build_pg_config() -> Result<tokio_postgres::Config, String> {
.and_then(|s| s.parse::<u32>().ok()) .and_then(|s| s.parse::<u32>().ok())
.unwrap_or(30); .unwrap_or(30);
// 通过 libpq options 传递 GUCtokio-postgres 在建连时执行。 // 通过 libpq options 传递 GUCtokio-postgres 在建连时执行。
pg_cfg.options(format!("-c statement_timeout={}", statement_timeout_secs * 1000)); pg_cfg.options(format!(
"-c statement_timeout={}",
statement_timeout_secs * 1000
));
Ok(pg_cfg) Ok(pg_cfg)
} }
@ -166,10 +168,7 @@ pub async fn get_conn_for_startup(
match DB_POOL.get().await { match DB_POOL.get().await {
Ok(conn) => { Ok(conn) => {
if attempt > 1 { if attempt > 1 {
tracing::info!( tracing::info!("connected to database after {} attempt(s)", attempt);
"connected to database after {} attempt(s)",
attempt
);
} }
return Ok(conn); return Ok(conn);
} }

View File

@ -25,9 +25,7 @@ pub mod server {
tracing::info!( tracing::info!(
"SyntaxSet built: {} syntaxes, swift={:?}", "SyntaxSet built: {} syntaxes, swift={:?}",
built.syntaxes().len(), built.syntaxes().len(),
built built.find_syntax_by_extension("swift").map(|s| &s.name)
.find_syntax_by_extension("swift")
.map(|s| &s.name)
); );
built built
}); });
@ -257,7 +255,8 @@ mod tests {
#[test] #[test]
fn highlight_code_swift_keyword_and_func() { fn highlight_code_swift_keyword_and_func() {
// Swift 关键字 func/import/let 应生成 declaration/keyword span而不是纯文本。 // Swift 关键字 func/import/let 应生成 declaration/keyword span而不是纯文本。
let code = "import Foundation\nfunc greet(person: String) -> String {\n return \"Hi\"\n}"; let code =
"import Foundation\nfunc greet(person: String) -> String {\n return \"Hi\"\n}";
let result = highlight_code(code, Some("swift")); let result = highlight_code(code, Some("swift"));
assert!( assert!(
result.contains("keyword"), result.contains("keyword"),
@ -338,11 +337,7 @@ mod tests {
// Zig 关键字 const/fn/pub 与内建函数 @import 都应被高亮。 // Zig 关键字 const/fn/pub 与内建函数 @import 都应被高亮。
let code = "const std = @import(\"std\");\npub fn main() void {}"; let code = "const std = @import(\"std\");\npub fn main() void {}";
let result = highlight_code(code, Some("zig")); let result = highlight_code(code, Some("zig"));
assert!( assert!(result.contains("keyword"), "Zig 关键字未被识别: {}", result);
result.contains("keyword"),
"Zig 关键字未被识别: {}",
result
);
assert!( assert!(
result.contains("name function"), result.contains("name function"),
"Zig 函数名未被识别: {}", "Zig 函数名未被识别: {}",
@ -365,15 +360,7 @@ mod tests {
"Zig u32 类型未被识别: {}", "Zig u32 类型未被识别: {}",
result result
); );
assert!( assert!(result.contains("string"), "Zig 字符串未被识别: {}", result);
result.contains("string"), assert!(result.contains("numeric"), "Zig 数字未被识别: {}", result);
"Zig 字符串未被识别: {}",
result
);
assert!(
result.contains("numeric"),
"Zig 数字未被识别: {}",
result
);
} }
} }

View File

@ -272,7 +272,11 @@ pub fn relative_label_from_millis(delta_millis: i64, created_iso: &str) -> (Stri
.map(|dt| dt.format("%Y-%m-%d").to_string()) .map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default(); .unwrap_or_default();
let label = if label.is_empty() { absolute.clone() } else { label }; let label = if label.is_empty() {
absolute.clone()
} else {
label
};
(label, absolute) (label, absolute)
} }

View File

@ -165,7 +165,9 @@ fn cache_control_for_path(
|| path == "/style.css" || path == "/style.css"
|| path == "/highlight.css" || path == "/highlight.css"
{ {
return Some(HeaderValue::from_static("public, max-age=31536000, immutable")); return Some(HeaderValue::from_static(
"public, max-age=31536000, immutable",
));
} }
// 公开页面5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证 // 公开页面5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证
@ -190,7 +192,10 @@ async fn add_cache_control(
if let Some(value) = cache_value { if let Some(value) = cache_value {
// 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略 // 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
response.headers_mut().entry(header::CACHE_CONTROL).or_insert(value); response
.headers_mut()
.entry(header::CACHE_CONTROL)
.or_insert(value);
} }
response response
@ -215,7 +220,9 @@ fn main() {
if std::env::var("DATABASE_URL").is_err() { if std::env::var("DATABASE_URL").is_err() {
tracing::error!("DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported."); tracing::error!("DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported.");
eprintln!("ERROR: DATABASE_URL environment variable not set"); eprintln!("ERROR: DATABASE_URL environment variable not set");
eprintln!("HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"); eprintln!(
"HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
);
std::process::exit(1); std::process::exit(1);
} }
@ -290,10 +297,10 @@ fn main() {
// 启动 Dioxus 服务端,返回构建好的 Axum Router // 启动 Dioxus 服务端,返回构建好的 Axum Router
dioxus::server::serve(|| async move { dioxus::server::serve(|| async move {
use axum::http::StatusCode;
use dioxus::server::{axum, DioxusRouterExt, ServeConfig}; use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
use std::time::Duration; use std::time::Duration;
use tower_http::timeout::TimeoutLayer; use tower_http::timeout::TimeoutLayer;
use axum::http::StatusCode;
// 启动后台定时任务IP 信息清理 // 启动后台定时任务IP 信息清理
tokio::spawn(async { tokio::spawn(async {
@ -340,7 +347,9 @@ fn main() {
let generation = crate::ssr_cache::current_global_generation(); let generation = crate::ssr_cache::current_global_generation();
let is_get = req.method() == axum::http::Method::GET; let is_get = req.method() == axum::http::Method::GET;
let (mut parts, body) = req.into_parts(); let (mut parts, body) = req.into_parts();
parts.extensions.insert(crate::ssr_cache::SsrGeneration(generation)); parts
.extensions
.insert(crate::ssr_cache::SsrGeneration(generation));
let mut response = next.run(axum::http::Request::from_parts(parts, body)).await; let mut response = next.run(axum::http::Request::from_parts(parts, body)).await;
if is_get { if is_get {
response.headers_mut().insert( response.headers_mut().insert(
@ -364,9 +373,7 @@ fn main() {
StatusCode::REQUEST_TIMEOUT, StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(300), Duration::from_secs(300),
)) ))
.layer(axum::middleware::from_fn( .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
crate::api::csrf::csrf_middleware,
));
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件 // Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
let dioxus_app = let dioxus_app =
@ -377,9 +384,7 @@ fn main() {
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( .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
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);
} }
@ -396,14 +401,8 @@ fn main() {
// 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT // 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT
// 使限流能拿到真实客户端 IP。 // 使限流能拿到真实客户端 IP。
let static_routes = axum::Router::new() let static_routes = axum::Router::new()
.route( .route("/healthz", axum::routing::get(crate::api::health::healthz))
"/healthz", .route("/readyz", axum::routing::get(crate::api::health::readyz))
axum::routing::get(crate::api::health::healthz),
)
.route(
"/readyz",
axum::routing::get(crate::api::health::readyz),
)
.route( .route(
"/uploads/{*path}", "/uploads/{*path}",
axum::routing::get(crate::api::image::serve_image), axum::routing::get(crate::api::image::serve_image),
@ -434,8 +433,7 @@ mod tests {
use axum::http::Method; use axum::http::Method;
fn cache_value(path: &str, method: Method) -> Option<String> { fn cache_value(path: &str, method: Method) -> Option<String> {
cache_control_for_path(path, &method) cache_control_for_path(path, &method).map(|v| v.to_str().unwrap().to_string())
.map(|v| v.to_str().unwrap().to_string())
} }
#[test] #[test]

View File

@ -7,7 +7,7 @@
pub mod comment; pub mod comment;
/// 文章模型、文章状态、标签与统计信息。 /// 文章模型、文章状态、标签与统计信息。
pub mod post; pub mod post;
/// 用户模型、用户角色与可公开用户信息。
pub mod user;
/// 回收站与站点配置模型。 /// 回收站与站点配置模型。
pub mod settings; pub mod settings;
/// 用户模型、用户角色与可公开用户信息。
pub mod user;

View File

@ -72,7 +72,13 @@ mod tests {
#[test] #[test]
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn clamp_retention_boundary() { fn clamp_retention_boundary() {
assert_eq!(TrashSettings::clamp_retention(MIN_RETENTION_DAYS), MIN_RETENTION_DAYS); assert_eq!(
assert_eq!(TrashSettings::clamp_retention(MAX_RETENTION_DAYS), MAX_RETENTION_DAYS); TrashSettings::clamp_retention(MIN_RETENTION_DAYS),
MIN_RETENTION_DAYS
);
assert_eq!(
TrashSettings::clamp_retention(MAX_RETENTION_DAYS),
MAX_RETENTION_DAYS
);
} }
} }

View File

@ -14,12 +14,12 @@ use crate::api::comments::trash_comment;
use crate::api::comments::{approve_comment, batch_update_comment_status, spam_comment}; use crate::api::comments::{approve_comment, batch_update_comment_status, spam_comment};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::comments::{get_all_comments, AllCommentsResponse}; use crate::api::comments::{get_all_comments, AllCommentsResponse};
use crate::components::empty_state::EmptyState;
use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::empty_state::EmptyState;
use crate::components::ui::{ use crate::components::ui::{
FilterTabs, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, FilterTabs, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
BTN_TEXT_AMBER, BTN_TEXT_GREEN, BTN_TEXT_RED, BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_AMBER, BTN_TEXT_GREEN, BTN_TEXT_RED,
CHECKBOX_CLASS, CHECKBOX_CLASS,
}; };
use crate::models::comment::{AdminComment, CommentStatus}; use crate::models::comment::{AdminComment, CommentStatus};
@ -470,4 +470,3 @@ fn CommentRow(
} }
} }
} }

View File

@ -13,7 +13,7 @@ use crate::api::posts::{get_post_stats, list_posts};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::posts::{PostListResponse, PostStatsResponse}; use crate::api::posts::{PostListResponse, PostStatsResponse};
use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::ui::{BTN_SECONDARY, ADMIN_CARD_CLASS}; use crate::components::ui::{ADMIN_CARD_CLASS, BTN_SECONDARY};
use crate::models::post::{PostListItem, PostStats}; use crate::models::post::{PostListItem, PostStats};
use crate::router::Route; use crate::router::Route;

View File

@ -12,10 +12,12 @@ use crate::api::posts::list_posts;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::posts::PostListResponse; use crate::api::posts::PostListResponse;
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult}; use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
use crate::components::empty_state::{EmptyState, EmptyStateAction};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton; use crate::components::skeletons::posts_skeleton::PostsSkeleton;
use crate::components::empty_state::{EmptyState, EmptyStateAction}; use crate::components::ui::{
use crate::components::ui::{Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED}; Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED,
};
use crate::models::post::PostListItem; use crate::models::post::PostListItem;
use crate::router::Route; use crate::router::Route;
@ -188,7 +190,11 @@ fn RebuildCacheBar() -> Element {
rebuild_result.set(None); rebuild_result.set(None);
spawn(async move { spawn(async move {
match rebuild_content_html(rebuild_all).await { match rebuild_content_html(rebuild_all).await {
Ok(RebuildResult { rebuilt, failed, errors }) => { Ok(RebuildResult {
rebuilt,
failed,
errors,
}) => {
if failed > 0 { if failed > 0 {
let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed}"); let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed}");
if let Some(first) = errors.first() { if let Some(first) = errors.first() {

View File

@ -122,4 +122,3 @@ fn HomeInfo() -> Element {
} }
} }
} }

View File

@ -42,7 +42,9 @@ pub struct SsrGeneration(pub u64);
/// 原子递增并返回新的全局世代号。 /// 原子递增并返回新的全局世代号。
pub fn bump_global_generation() -> u64 { pub fn bump_global_generation() -> u64 {
GLOBAL_GENERATION.fetch_add(1, Ordering::SeqCst).wrapping_add(1) GLOBAL_GENERATION
.fetch_add(1, Ordering::SeqCst)
.wrapping_add(1)
} }
/// 返回当前全局世代号。 /// 返回当前全局世代号。

View File

@ -151,7 +151,11 @@ mod tests {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_nanos(); .as_nanos();
std::env::temp_dir().join(format!("yggdrasil_image_cache_test_{}_{}", nanos, std::process::id())) std::env::temp_dir().join(format!(
"yggdrasil_image_cache_test_{}_{}",
nanos,
std::process::id()
))
} }
#[tokio::test] #[tokio::test]

View File

@ -2,15 +2,15 @@
//! //!
//! 所有任务仅在 `server` feature 启用时编译,运行在服务端独立的 tokio 任务中。 //! 所有任务仅在 `server` feature 启用时编译,运行在服务端独立的 tokio 任务中。
/// 定时清理评论过期的 IP 与用户代理信息,满足隐私保护要求。
#[cfg(feature = "server")]
pub mod ip_purge;
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
#[cfg(feature = "server")]
pub mod session_cleanup;
/// 定时清理回收站中超过保留期的已删除文章。
#[cfg(feature = "server")]
pub mod post_purge;
/// 定时清理图片磁盘缓存,避免缓存目录无限增长。 /// 定时清理图片磁盘缓存,避免缓存目录无限增长。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod image_cache_cleanup; pub mod image_cache_cleanup;
/// 定时清理评论过期的 IP 与用户代理信息,满足隐私保护要求。
#[cfg(feature = "server")]
pub mod ip_purge;
/// 定时清理回收站中超过保留期的已删除文章。
#[cfg(feature = "server")]
pub mod post_purge;
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
#[cfg(feature = "server")]
pub mod session_cleanup;

View File

@ -19,16 +19,14 @@ pub async fn run_purge() {
let mut ticker = interval(Duration::from_secs(86400)); let mut ticker = interval(Duration::from_secs(86400));
loop { loop {
match get_conn().await { match get_conn().await {
Ok(client) => { Ok(client) => match purge_expired(&client).await {
match purge_expired(&client).await { Ok(n) => {
Ok(n) => { if n > 0 {
if n > 0 { tracing::info!("Post auto-purge: removed {} expired trashed posts", n);
tracing::info!("Post auto-purge: removed {} expired trashed posts", n);
}
} }
Err(e) => tracing::error!("Post auto-purge error: {:?}", e),
} }
} Err(e) => tracing::error!("Post auto-purge error: {:?}", e),
},
Err(e) => tracing::error!("Failed to get DB connection for post purge: {:?}", e), Err(e) => tracing::error!("Failed to get DB connection for post purge: {:?}", e),
} }
ticker.tick().await; ticker.tick().await;

View File

@ -64,7 +64,8 @@ pub mod wasm {
/// unchecked_into 只做编译期类型标注不做运行时校验Reflect.get 已保证拿到的是目标对象)。 /// unchecked_into 只做编译期类型标注不做运行时校验Reflect.get 已保证拿到的是目标对象)。
pub fn get_module() -> TiptapEditorModule { pub fn get_module() -> TiptapEditorModule {
let window = web_sys::window().expect("no window"); let window = web_sys::window().expect("no window");
let val = js_sys::Reflect::get(&window, &"TiptapEditor".into()).expect("window.TiptapEditor missing"); let val = js_sys::Reflect::get(&window, &"TiptapEditor".into())
.expect("window.TiptapEditor missing");
val.unchecked_into::<TiptapEditorModule>() val.unchecked_into::<TiptapEditorModule>()
} }
@ -106,25 +107,25 @@ pub mod wasm {
#[wasm_bindgen(method, setter, js_name = placeholder)] #[wasm_bindgen(method, setter, js_name = placeholder)]
pub fn set_placeholder(this: &EditorOptions, v: &str); pub fn set_placeholder(this: &EditorOptions, v: &str);
/// 文档变更回调ProseMirror transaction 提交时触发,参数为最新 Markdown /// 文档变更回调ProseMirror transaction 提交时触发,参数为最新 Markdown
#[wasm_bindgen(method, setter, js_name = onUpdate)] #[wasm_bindgen(method, setter, js_name = onUpdate)]
pub fn set_on_update(this: &EditorOptions, cb: &Closure<dyn FnMut(String)>); pub fn set_on_update(this: &EditorOptions, cb: &Closure<dyn FnMut(String)>);
/// JS 侧 onImageUpload: (file: File) => Promise<string>。 /// JS 侧 onImageUpload: (file: File) => Promise<string>。
/// Rust closure 返回 js_sys::Promise由 future_to_promise 包装。 /// Rust closure 返回 js_sys::Promise由 future_to_promise 包装。
#[wasm_bindgen(method, setter, js_name = onImageUpload)] #[wasm_bindgen(method, setter, js_name = onImageUpload)]
pub fn set_on_image_upload( pub fn set_on_image_upload(
this: &EditorOptions, this: &EditorOptions,
cb: &Closure<dyn Fn(web_sys::File) -> js_sys::Promise>, cb: &Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
); );
/// 编辑器就绪回调init 末尾同步触发一次)。 /// 编辑器就绪回调init 末尾同步触发一次)。
#[wasm_bindgen(method, setter, js_name = onReady)] #[wasm_bindgen(method, setter, js_name = onReady)]
pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>); pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
/// 上传事件回调coordinator.emit 时触发,携带 counts /// 上传事件回调coordinator.emit 时触发,携带 counts
#[wasm_bindgen(method, setter, js_name = onUploadEvent)] #[wasm_bindgen(method, setter, js_name = onUploadEvent)]
pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn FnMut(UploadEventJs)>); pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn FnMut(UploadEventJs)>);
} }
// —— 上传事件JS UploadEvent 的 Rust 映射)—— // —— 上传事件JS UploadEvent 的 Rust 映射)——
@ -307,9 +308,7 @@ pub mod wasm {
.map_err(|_| "上传响应类型异常".to_string())?; .map_err(|_| "上传响应类型异常".to_string())?;
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON // 读响应体文本(无论 2xx 与否,服务端都返回 JSON
let text_promise = resp let text_promise = resp.text().map_err(|e| format!("读取响应失败: {:?}", e))?;
.text()
.map_err(|e| format!("读取响应失败: {:?}", e))?;
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise) let text_val = wasm_bindgen_futures::JsFuture::from(text_promise)
.await .await
.map_err(|e| format!("读取响应失败: {:?}", e))?; .map_err(|e| format!("读取响应失败: {:?}", e))?;
@ -355,6 +354,6 @@ pub mod wasm {
/// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。 /// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub use wasm::{ pub use wasm::{
consume_upload_event, make_upload_closure, upload_image_file, EditorHandle, EditorOptions, consume_upload_event, get_module, make_upload_closure, upload_image_file, EditorHandle,
UploadEventJs, get_module, EditorOptions, UploadEventJs,
}; };