style: apply cargo fmt to workspace
This commit is contained in:
parent
5e1becf116
commit
373498870a
@ -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);
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -51,4 +51,3 @@ pub async fn get_comments(post_id: i32) -> Result<CommentTreeResponse, ServerFnE
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
|
||||
@ -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 sniff(M2)。
|
||||
(
|
||||
@ -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"),
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)。
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/// 读取回收站配置。
|
||||
///
|
||||
|
||||
@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(";")
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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: {:?}",
|
||||
|
||||
@ -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 传递 GUC;tokio-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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
40
src/main.rs
40
src/main.rs
@ -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]
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
pub mod comment;
|
||||
/// 文章模型、文章状态、标签与统计信息。
|
||||
pub mod post;
|
||||
/// 用户模型、用户角色与可公开用户信息。
|
||||
pub mod user;
|
||||
/// 回收站与站点配置模型。
|
||||
pub mod settings;
|
||||
/// 用户模型、用户角色与可公开用户信息。
|
||||
pub mod user;
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -122,4 +122,3 @@ fn HomeInfo() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/// 返回当前全局世代号。
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user