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};
#[cfg(feature = "server")]
use crate::db::pool::get_conn;
use crate::models::user::PublicUser;
#[cfg(feature = "server")]
use crate::models::user::{SessionUser, UserRole};
use crate::models::user::PublicUser;
#[cfg(feature = "server")]
fn validate_username(username: &str) -> Result<(), String> {
@ -119,12 +119,10 @@ pub async fn register(
// Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行,避免阻塞 Tokio worker。
let pw_for_hash = password.clone();
let password_hash = tokio::task::spawn_blocking(move || {
password::hash_password(&pw_for_hash)
})
.await
.map_err(|_| AppError::Internal("密码处理任务失败"))?
.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”的竞争。
// 若已有 admin 或用户名/邮箱冲突RETURNING 将返回空。
@ -149,7 +147,10 @@ pub async fn register(
// 插入失败:区分是已有 admin 还是用户名/邮箱冲突。
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
.map_err(AppError::query)?
.get(0);

View File

@ -131,7 +131,9 @@ pub fn validate_comment_url(url: &str) -> Result<(), String> {
if trimmed.len() > 200 {
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());
}

View File

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

View File

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

View File

@ -349,7 +349,10 @@ mod tests {
// 用一个不含 dimensions 的路径验证 --ar 缺省时的结构正确性。
let html = r#"<p><img src="/uploads/nonexistent/test.webp" alt="test"></p>"#;
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("?w=20"), "placeholder should use ?w=20");
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 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]
@ -372,7 +378,11 @@ mod tests {
println!("ACTUAL OUTPUT: {}", result);
// aspect-ratio 必须用斜杠分隔,如 "--ar:800 / 600;"
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]
@ -384,7 +394,11 @@ mod tests {
let cleaned = clean_html(&wrapped);
println!("WRAPPED: {}", wrapped);
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]

View File

@ -6,10 +6,10 @@
/// 认证相关的 Dioxus server function。
pub mod auth;
/// CSRF 防护中间件。
pub mod csrf;
/// 评论相关接口。
pub mod comments;
/// CSRF 防护中间件。
pub mod csrf;
/// 应用错误类型与转换。
pub mod error;
/// 健康检查端点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 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
.map_err(AppError::query)?;
let total: i64 = count_row.get(0);

View File

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

View File

@ -232,8 +232,7 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
// 逐条恢复slug 冲突时自动加后缀;同时收集受影响的 slug 与标签。
let mut restored = 0u64;
let mut affected_slugs: Vec<String> = Vec::with_capacity(post_ids.len() * 2);
let mut affected_tags: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut affected_tags: std::collections::HashSet<String> = std::collections::HashSet::new();
for id in &post_ids {
let row = tx
@ -289,7 +288,8 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
for slug in &unique_slugs {
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 缓存失效)。
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 (slugs, tags) = if use_precise {
let mut slugs = Vec::with_capacity(post_ids.len());
let mut tags_set: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut tags_set: std::collections::HashSet<String> = std::collections::HashSet::new();
for id in &post_ids {
let slug_row = tx
@ -445,8 +444,8 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
)
.await
.map_err(AppError::query)?;
let use_precise = !deleted_rows.is_empty()
&& deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT;
let use_precise =
!deleted_rows.is_empty() && deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT;
let (slugs, tags) = if use_precise {
let slugs: Vec<String> = deleted_rows.iter().map(|r| r.get("slug")).collect();

View File

@ -426,7 +426,10 @@ mod tests {
break;
}
}
assert!(blocked, "strict bucket should eventually block real IP burst");
assert!(
blocked,
"strict bucket should eventually block real IP burst"
);
assert!(
allowed <= 6,
"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 result = clean_html(input);
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");
}
@ -492,12 +495,24 @@ mod tests {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 仅在显式允许且 media type 为图片时通过
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 时拒绝
assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
// 非图片 data URI 拒绝
assert!(!is_safe_url("data:text/html,<script>alert(1)</script>", &schemes, true));
assert!(!is_safe_url("data:application/javascript,alert(1)", &schemes, true));
assert!(!is_safe_url(
"data:text/html,<script>alert(1)</script>",
&schemes,
true
));
assert!(!is_safe_url(
"data:application/javascript,alert(1)",
&schemes,
true
));
}
#[test]
@ -538,7 +553,11 @@ mod tests {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 未知 scheme 默认拒绝。
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("custom-app://open", &schemes, false));
}

View File

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

View File

@ -12,9 +12,9 @@ use axum::{
response::Json,
};
#[cfg(feature = "server")]
use std::net::SocketAddr;
#[cfg(feature = "server")]
use serde_json::{json, Value};
#[cfg(feature = "server")]
use std::net::SocketAddr;
#[cfg(feature = "server")]
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/webp" => {
// RIFF....WEBP
data.len() >= 12
&& &data[0..4] == b"RIFF"
&& &data[8..12] == b"WEBP"
data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP"
}
_ => false,
}
@ -448,8 +446,14 @@ mod tests {
#[test]
fn validate_jpeg_magic_bytes() {
assert!(super::validate_image_magic_bytes(&[0xFF, 0xD8, 0xFF], "image/jpeg"));
assert!(!super::validate_image_magic_bytes(&[0x89, 0x50], "image/jpeg"));
assert!(super::validate_image_magic_bytes(
&[0xFF, 0xD8, 0xFF],
"image/jpeg"
));
assert!(!super::validate_image_magic_bytes(
&[0x89, 0x50],
"image/jpeg"
));
}
#[test]
@ -458,7 +462,10 @@ mod tests {
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
"image/png"
));
assert!(!super::validate_image_magic_bytes(&[0xFF, 0xD8], "image/png"));
assert!(!super::validate_image_magic_bytes(
&[0xFF, 0xD8],
"image/png"
));
}
#[test]
@ -472,6 +479,9 @@ mod tests {
fn validate_webp_magic_bytes() {
let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ";
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(';')
.filter(|decl| {
let trimmed = decl.trim();
!trimmed.is_empty()
&& !trimmed
.to_ascii_lowercase()
.starts_with("background-color")
!trimmed.is_empty() && !trimmed.to_ascii_lowercase().starts_with("background-color")
})
.collect::<Vec<_>>()
.join(";")

View File

@ -77,7 +77,11 @@ pub enum CacheKey {
/// 按标签查询的文章列表(不分页,返回全部)。
PostsByTag(String),
/// 按标签查询的分页文章列表。
PostsByTagPage { tag: String, page: i32, per_page: i32 },
PostsByTagPage {
tag: String,
page: i32,
per_page: i32,
},
/// 文章统计信息。
PostStats,
/// 某篇文章下的评论列表。
@ -760,5 +764,4 @@ mod tests {
invalidate_search_results();
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]
pub fn CommentForm(
post_id: i32,
parent_id: Option<i64>,
parent_indent: Option<i32>,
) -> Element {
pub fn CommentForm(post_id: i32, parent_id: Option<i64>, parent_indent: Option<i32>) -> Element {
let ctx: CommentContext = use_context();
let mut active_reply = ctx.active_reply;
let mut refresh_trigger = ctx.refresh_trigger;

View File

@ -24,7 +24,7 @@ pub fn PostContent(content_html: String) -> Element {
// 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。
let _ = js_sys::eval(
"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 卡片、面板等。
pub const ADMIN_CARD_CLASS: &str =
"bg-paper-entry rounded-xl border border-paper-border";
pub const ADMIN_CARD_CLASS: &str = "bg-paper-entry rounded-xl border border-paper-border";
/// Admin 表格容器:在卡片基础上加 `overflow-hidden`,圆角裁剪表格。
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 =
"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 描边风格,从属于主色鼠尾草绿) ---
@ -192,7 +192,7 @@ static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicU
///
/// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。
/// 具备高级的平滑滑动底部指示器动画。
///
///
/// Props
/// - `items`:选项卡列表,每一项为 `(value, label)`
/// - `active_value`:当前选中的值
@ -212,15 +212,16 @@ pub fn FilterTabs(
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen::JsCast;
// 等待 DOM 节点更新
let promise = js_sys::Promise::new(&mut |resolve, _| {
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;
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
let element_id = format!("tab-{}-{}", id_prefix, active);
@ -253,10 +254,10 @@ pub fn FilterTabs(
button {
id: "tab-{id_prefix}-{value}",
key: "{value}",
class: if active_value == *value {
"cursor-pointer px-4 py-2 text-sm font-medium text-paper-primary transition-colors"
} else {
"cursor-pointer px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors"
class: if active_value == *value {
"cursor-pointer px-4 py-2 text-sm font-medium text-paper-primary transition-colors"
} else {
"cursor-pointer px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors"
},
onclick: {
let v = value.to_string();

View File

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

View File

@ -22,9 +22,8 @@ use tokio_postgres::{Config, NoTls};
///
/// 返回 `Err(String)` 而非 panic调用方决定如何向用户报告错误。
fn build_pg_config() -> Result<tokio_postgres::Config, String> {
let db_url = std::env::var("DATABASE_URL").map_err(|_| {
"DATABASE_URL environment variable not set".to_string()
})?;
let db_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL environment variable not set".to_string())?;
let mut pg_cfg = db_url
.parse::<tokio_postgres::Config>()
.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())
.unwrap_or(30);
// 通过 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)
}
@ -166,10 +168,7 @@ pub async fn get_conn_for_startup(
match DB_POOL.get().await {
Ok(conn) => {
if attempt > 1 {
tracing::info!(
"connected to database after {} attempt(s)",
attempt
);
tracing::info!("connected to database after {} attempt(s)", attempt);
}
return Ok(conn);
}

View File

@ -25,9 +25,7 @@ pub mod server {
tracing::info!(
"SyntaxSet built: {} syntaxes, swift={:?}",
built.syntaxes().len(),
built
.find_syntax_by_extension("swift")
.map(|s| &s.name)
built.find_syntax_by_extension("swift").map(|s| &s.name)
);
built
});
@ -257,7 +255,8 @@ mod tests {
#[test]
fn highlight_code_swift_keyword_and_func() {
// 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"));
assert!(
result.contains("keyword"),
@ -338,11 +337,7 @@ mod tests {
// Zig 关键字 const/fn/pub 与内建函数 @import 都应被高亮。
let code = "const std = @import(\"std\");\npub fn main() void {}";
let result = highlight_code(code, Some("zig"));
assert!(
result.contains("keyword"),
"Zig 关键字未被识别: {}",
result
);
assert!(result.contains("keyword"), "Zig 关键字未被识别: {}", result);
assert!(
result.contains("name function"),
"Zig 函数名未被识别: {}",
@ -365,15 +360,7 @@ mod tests {
"Zig u32 类型未被识别: {}",
result
);
assert!(
result.contains("string"),
"Zig 字符串未被识别: {}",
result
);
assert!(
result.contains("numeric"),
"Zig 数字未被识别: {}",
result
);
assert!(result.contains("string"), "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())
.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)
}

View File

@ -165,7 +165,9 @@ fn cache_control_for_path(
|| path == "/style.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 小时内可提供过期内容并后台重新验证
@ -190,7 +192,10 @@ async fn add_cache_control(
if let Some(value) = cache_value {
// 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
response.headers_mut().entry(header::CACHE_CONTROL).or_insert(value);
response
.headers_mut()
.entry(header::CACHE_CONTROL)
.or_insert(value);
}
response
@ -215,7 +220,9 @@ fn main() {
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.");
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);
}
@ -290,10 +297,10 @@ fn main() {
// 启动 Dioxus 服务端,返回构建好的 Axum Router
dioxus::server::serve(|| async move {
use axum::http::StatusCode;
use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
use std::time::Duration;
use tower_http::timeout::TimeoutLayer;
use axum::http::StatusCode;
// 启动后台定时任务IP 信息清理
tokio::spawn(async {
@ -340,7 +347,9 @@ fn main() {
let generation = crate::ssr_cache::current_global_generation();
let is_get = req.method() == axum::http::Method::GET;
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;
if is_get {
response.headers_mut().insert(
@ -364,9 +373,7 @@ fn main() {
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(300),
))
.layer(axum::middleware::from_fn(
crate::api::csrf::csrf_middleware,
));
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
let dioxus_app =
@ -377,9 +384,7 @@ fn main() {
let mut app_routes = dioxus_app
.layer(axum::middleware::from_fn(ssr_generation_middleware))
.layer(axum::middleware::from_fn(add_cache_control))
.layer(axum::middleware::from_fn(
crate::api::csrf::csrf_middleware,
));
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
if let Some(layer) = compression_layer_from_env() {
app_routes = app_routes.layer(layer);
}
@ -396,14 +401,8 @@ fn main() {
// 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT
// 使限流能拿到真实客户端 IP。
let static_routes = axum::Router::new()
.route(
"/healthz",
axum::routing::get(crate::api::health::healthz),
)
.route(
"/readyz",
axum::routing::get(crate::api::health::readyz),
)
.route("/healthz", axum::routing::get(crate::api::health::healthz))
.route("/readyz", axum::routing::get(crate::api::health::readyz))
.route(
"/uploads/{*path}",
axum::routing::get(crate::api::image::serve_image),
@ -434,8 +433,7 @@ mod tests {
use axum::http::Method;
fn cache_value(path: &str, method: Method) -> Option<String> {
cache_control_for_path(path, &method)
.map(|v| v.to_str().unwrap().to_string())
cache_control_for_path(path, &method).map(|v| v.to_str().unwrap().to_string())
}
#[test]

View File

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

View File

@ -72,7 +72,13 @@ mod tests {
#[test]
#[cfg(feature = "server")]
fn clamp_retention_boundary() {
assert_eq!(TrashSettings::clamp_retention(MIN_RETENTION_DAYS), MIN_RETENTION_DAYS);
assert_eq!(TrashSettings::clamp_retention(MAX_RETENTION_DAYS), MAX_RETENTION_DAYS);
assert_eq!(
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};
#[cfg(target_arch = "wasm32")]
use crate::api::comments::{get_all_comments, AllCommentsResponse};
use crate::components::empty_state::EmptyState;
use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::empty_state::EmptyState;
use crate::components::ui::{
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,
};
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")]
use crate::api::posts::{PostListResponse, PostStatsResponse};
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::router::Route;

View File

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

View File

@ -2,15 +2,15 @@
//!
//! 所有任务仅在 `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")]
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));
loop {
match get_conn().await {
Ok(client) => {
match purge_expired(&client).await {
Ok(n) => {
if n > 0 {
tracing::info!("Post auto-purge: removed {} expired trashed posts", n);
}
Ok(client) => match purge_expired(&client).await {
Ok(n) => {
if n > 0 {
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),
}
ticker.tick().await;

View File

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