Compare commits
No commits in common. "373498870a160ee46b1cd84d7e985445704a0711" and "46e9a29f1e006b70d9b5556138260b7a088307d8" have entirely different histories.
373498870a
...
46e9a29f1e
@ -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,7 +119,9 @@ 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 || password::hash_password(&pw_for_hash))
|
let password_hash = tokio::task::spawn_blocking(move || {
|
||||||
|
password::hash_password(&pw_for_hash)
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::Internal("密码处理任务失败"))?
|
.map_err(|_| AppError::Internal("密码处理任务失败"))?
|
||||||
.map_err(|_| AppError::Internal("密码处理失败"))?;
|
.map_err(|_| AppError::Internal("密码处理失败"))?;
|
||||||
@ -147,10 +149,7 @@ pub async fn register(
|
|||||||
|
|
||||||
// 插入失败:区分是已有 admin 还是用户名/邮箱冲突。
|
// 插入失败:区分是已有 admin 还是用户名/邮箱冲突。
|
||||||
let admin_exists: bool = client
|
let admin_exists: bool = client
|
||||||
.query_one(
|
.query_one("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')", &[])
|
||||||
"SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')",
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?
|
.map_err(AppError::query)?
|
||||||
.get(0);
|
.get(0);
|
||||||
|
|||||||
@ -131,9 +131,7 @@ 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
|
if trimmed.chars().any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
|
||||||
.chars()
|
|
||||||
.any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
|
|
||||||
{
|
{
|
||||||
return Err("网址包含非法字符".to_string());
|
return Err("网址包含非法字符".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,3 +51,4 @@ pub async fn get_comments(post_id: i32) -> Result<CommentTreeResponse, ServerFnE
|
|||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use bytes::Bytes;
|
use std::net::SocketAddr;
|
||||||
#[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::net::SocketAddr;
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn etag_for(data: &[u8]) -> String {
|
fn etag_for(data: &[u8]) -> String {
|
||||||
@ -55,7 +55,13 @@ 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| if v < MIN { (MIN, true) } else { (v, false) })
|
.map(|v| {
|
||||||
|
if v < MIN {
|
||||||
|
(MIN, true)
|
||||||
|
} else {
|
||||||
|
(v, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
.unwrap_or((DEFAULT, false));
|
.unwrap_or((DEFAULT, false));
|
||||||
if clamped {
|
if clamped {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@ -83,7 +89,13 @@ 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| if v < MIN { (MIN, true) } else { (v, false) })
|
.map(|v| {
|
||||||
|
if v < MIN {
|
||||||
|
(MIN, true)
|
||||||
|
} else {
|
||||||
|
(v, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
.unwrap_or((DEFAULT, false));
|
.unwrap_or((DEFAULT, false));
|
||||||
if clamped {
|
if clamped {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@ -252,10 +264,7 @@ 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 sniff(M2)。
|
// nosniff 防止浏览器对 content-type 错配的图片字节做 MIME sniff(M2)。
|
||||||
(
|
(
|
||||||
@ -272,10 +281,7 @@ 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,
|
||||||
@ -341,12 +347,15 @@ 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(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 {
|
match mime_type {
|
||||||
"image/webp" => {
|
"image/webp" => {
|
||||||
// zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素(与 webp::decode 同源)。
|
// zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素(与 webp::decode 同源)。
|
||||||
let decoder =
|
let decoder = zenwebp::WebPDecoder::build(data)
|
||||||
zenwebp::WebPDecoder::build(data).map_err(|_| "图片文件损坏或格式不正确")?;
|
.map_err(|_| "图片文件损坏或格式不正确")?;
|
||||||
let info = decoder.info();
|
let info = decoder.info();
|
||||||
Ok((info.width, info.height))
|
Ok((info.width, info.height))
|
||||||
}
|
}
|
||||||
@ -615,12 +624,7 @@ 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(
|
image_response(Bytes::from(data), ct, "public, max-age=31536000, immutable", &headers)
|
||||||
Bytes::from(data),
|
|
||||||
ct,
|
|
||||||
"public, max-age=31536000, immutable",
|
|
||||||
&headers,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
},
|
},
|
||||||
@ -654,7 +658,8 @@ 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) = match tokio::task::spawn_blocking(move || {
|
let (processed, content_type) =
|
||||||
|
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
|
||||||
@ -1081,17 +1086,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
let headers = resp.headers();
|
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!(
|
assert_eq!(
|
||||||
headers.get(header::CACHE_CONTROL).unwrap(),
|
headers.get(header::CACHE_CONTROL).unwrap(),
|
||||||
"public, max-age=86400"
|
"public, max-age=86400"
|
||||||
);
|
);
|
||||||
assert!(headers
|
assert!(headers.get(header::ETAG).unwrap().to_str().unwrap().starts_with('"'));
|
||||||
.get(header::ETAG)
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with('"'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1099,7 +1102,10 @@ 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(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(
|
let resp = image_response(
|
||||||
data,
|
data,
|
||||||
HeaderValue::from_static("image/webp"),
|
HeaderValue::from_static("image/webp"),
|
||||||
|
|||||||
@ -349,10 +349,7 @@ 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!(
|
assert!(result.contains("blur-img-placeholder"), "should have placeholder");
|
||||||
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");
|
||||||
@ -364,10 +361,7 @@ 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!(
|
assert!(!result.contains("blur-img"), "external image should not be wrapped");
|
||||||
!result.contains("blur-img"),
|
|
||||||
"external image should not be wrapped"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -378,11 +372,7 @@ 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!(
|
assert!(result.contains(" / "), "aspect-ratio must use slash separator, got: {}", result);
|
||||||
result.contains(" / "),
|
|
||||||
"aspect-ratio must use slash separator, got: {}",
|
|
||||||
result
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -394,11 +384,7 @@ 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!(
|
assert!(cleaned.contains(" / "), "clean_html must preserve slash in --ar, got: {}", cleaned);
|
||||||
cleaned.contains(" / "),
|
|
||||||
"clean_html must preserve slash in --ar, got: {}",
|
|
||||||
cleaned
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
/// 认证相关的 Dioxus server function。
|
/// 认证相关的 Dioxus server function。
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
/// 评论相关接口。
|
|
||||||
pub mod comments;
|
|
||||||
/// CSRF 防护中间件。
|
/// CSRF 防护中间件。
|
||||||
pub mod csrf;
|
pub mod csrf;
|
||||||
|
/// 评论相关接口。
|
||||||
|
pub mod comments;
|
||||||
/// 应用错误类型与转换。
|
/// 应用错误类型与转换。
|
||||||
pub mod error;
|
pub mod error;
|
||||||
/// 健康检查端点(liveness / readiness)。
|
/// 健康检查端点(liveness / readiness)。
|
||||||
|
|||||||
@ -190,10 +190,7 @@ 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(
|
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL", &[])
|
||||||
"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);
|
||||||
|
|||||||
@ -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_deleted_posts;
|
|
||||||
/// 获取管理员视角的全部文章分页列表。
|
/// 获取管理员视角的全部文章分页列表。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use list::list_posts;
|
pub use list::list_posts;
|
||||||
|
/// 获取回收站中已软删除的文章列表。
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use list::list_deleted_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")]
|
||||||
|
|||||||
@ -232,7 +232,8 @@ 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> = std::collections::HashSet::new();
|
let mut affected_tags: std::collections::HashSet<String> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
|
||||||
for id in &post_ids {
|
for id in &post_ids {
|
||||||
let row = tx
|
let row = tx
|
||||||
@ -288,8 +289,7 @@ 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<_>>())
|
crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::<Vec<_>>()).await;
|
||||||
.await;
|
|
||||||
|
|
||||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
@ -344,7 +344,8 @@ 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> = std::collections::HashSet::new();
|
let mut tags_set: std::collections::HashSet<String> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
|
||||||
for id in &post_ids {
|
for id in &post_ids {
|
||||||
let slug_row = tx
|
let slug_row = tx
|
||||||
@ -444,8 +445,8 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
let use_precise =
|
let use_precise = !deleted_rows.is_empty()
|
||||||
!deleted_rows.is_empty() && deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT;
|
&& 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();
|
||||||
|
|||||||
@ -426,10 +426,7 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(blocked, "strict bucket should eventually block real IP burst");
|
||||||
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}"
|
||||||
|
|||||||
@ -395,10 +395,7 @@ 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!(
|
assert!(result.contains("blur-img-placeholder"), "class should be allowed");
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -495,24 +492,12 @@ 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(
|
assert!(is_safe_url("data:image/svg+xml;base64,PHN2Zz4=", &schemes, true));
|
||||||
"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(
|
assert!(!is_safe_url("data:text/html,<script>alert(1)</script>", &schemes, true));
|
||||||
"data:text/html,<script>alert(1)</script>",
|
assert!(!is_safe_url("data:application/javascript,alert(1)", &schemes, true));
|
||||||
&schemes,
|
|
||||||
true
|
|
||||||
));
|
|
||||||
assert!(!is_safe_url(
|
|
||||||
"data:application/javascript,alert(1)",
|
|
||||||
&schemes,
|
|
||||||
true
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -553,11 +538,7 @@ 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(
|
assert!(!is_safe_url("blob:https://example.com/abc", &schemes, false));
|
||||||
"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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
|
|
||||||
/// 读取回收站配置。
|
/// 读取回收站配置。
|
||||||
///
|
///
|
||||||
|
|||||||
@ -12,9 +12,9 @@ use axum::{
|
|||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use serde_json::{json, Value};
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::auth::session::parse_session_token;
|
use crate::auth::session::parse_session_token;
|
||||||
@ -47,7 +47,9 @@ 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[0..4] == b"RIFF" && &data[8..12] == b"WEBP"
|
data.len() >= 12
|
||||||
|
&& &data[0..4] == b"RIFF"
|
||||||
|
&& &data[8..12] == b"WEBP"
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@ -446,14 +448,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_jpeg_magic_bytes() {
|
fn validate_jpeg_magic_bytes() {
|
||||||
assert!(super::validate_image_magic_bytes(
|
assert!(super::validate_image_magic_bytes(&[0xFF, 0xD8, 0xFF], "image/jpeg"));
|
||||||
&[0xFF, 0xD8, 0xFF],
|
assert!(!super::validate_image_magic_bytes(&[0x89, 0x50], "image/jpeg"));
|
||||||
"image/jpeg"
|
|
||||||
));
|
|
||||||
assert!(!super::validate_image_magic_bytes(
|
|
||||||
&[0x89, 0x50],
|
|
||||||
"image/jpeg"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -462,10 +458,7 @@ 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(
|
assert!(!super::validate_image_magic_bytes(&[0xFF, 0xD8], "image/png"));
|
||||||
&[0xFF, 0xD8],
|
|
||||||
"image/png"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -479,9 +472,6 @@ 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(
|
assert!(!super::validate_image_magic_bytes(&[0xFF, 0xD8], "image/webp"));
|
||||||
&[0xFF, 0xD8],
|
|
||||||
"image/webp"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,7 +113,10 @@ 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.to_ascii_lowercase().starts_with("background-color")
|
!trimmed.is_empty()
|
||||||
|
&& !trimmed
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("background-color")
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(";")
|
.join(";")
|
||||||
|
|||||||
@ -77,11 +77,7 @@ pub enum CacheKey {
|
|||||||
/// 按标签查询的文章列表(不分页,返回全部)。
|
/// 按标签查询的文章列表(不分页,返回全部)。
|
||||||
PostsByTag(String),
|
PostsByTag(String),
|
||||||
/// 按标签查询的分页文章列表。
|
/// 按标签查询的分页文章列表。
|
||||||
PostsByTagPage {
|
PostsByTagPage { tag: String, page: i32, per_page: i32 },
|
||||||
tag: String,
|
|
||||||
page: i32,
|
|
||||||
per_page: i32,
|
|
||||||
},
|
|
||||||
/// 文章统计信息。
|
/// 文章统计信息。
|
||||||
PostStats,
|
PostStats,
|
||||||
/// 某篇文章下的评论列表。
|
/// 某篇文章下的评论列表。
|
||||||
@ -764,4 +760,5 @@ mod tests {
|
|||||||
invalidate_search_results();
|
invalidate_search_results();
|
||||||
assert!(get_search_results("tokio").await.is_none());
|
assert!(get_search_results("tokio").await.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,11 @@ const COMMENT_SUBMIT_CLASS: &str = "py-2.5 px-6 bg-paper-accent text-white font-
|
|||||||
/// - 提交时校验必填项与蜜罐字段
|
/// - 提交时校验必填项与蜜罐字段
|
||||||
/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
|
/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
|
||||||
#[component]
|
#[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 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;
|
||||||
|
|||||||
@ -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);"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,8 @@ use crate::router::Route;
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// Admin 卡片容器:白底圆角描边,亮/暗双模式。用于 stat 卡片、面板等。
|
/// 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`,圆角裁剪表格。
|
/// Admin 表格容器:在卡片基础上加 `overflow-hidden`,圆角裁剪表格。
|
||||||
pub const ADMIN_TABLE_CLASS: &str =
|
pub const ADMIN_TABLE_CLASS: &str =
|
||||||
@ -55,8 +56,7 @@ 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 =
|
pub const BTN_TEXT_ACCENT: &str = "text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
|
||||||
"text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
|
|
||||||
|
|
||||||
// --- 次要按钮(冷调玫瑰第二色,ghost 描边风格,从属于主色鼠尾草绿) ---
|
// --- 次要按钮(冷调玫瑰第二色,ghost 描边风格,从属于主色鼠尾草绿) ---
|
||||||
|
|
||||||
@ -185,95 +185,3 @@ pub fn EmptyState(message: &'static str, variant: &'static str) -> Element {
|
|||||||
div { class: "{class}", "{message}" }
|
div { class: "{class}", "{message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
||||||
|
|
||||||
/// 筛选选项卡组件。
|
|
||||||
///
|
|
||||||
/// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。
|
|
||||||
/// 具备高级的平滑滑动底部指示器动画。
|
|
||||||
///
|
|
||||||
/// Props:
|
|
||||||
/// - `items`:选项卡列表,每一项为 `(value, label)`
|
|
||||||
/// - `active_value`:当前选中的值
|
|
||||||
/// - `on_change`:选项卡切换时的回调
|
|
||||||
#[component]
|
|
||||||
pub fn FilterTabs(
|
|
||||||
items: Vec<(&'static str, &'static str)>,
|
|
||||||
active_value: String,
|
|
||||||
on_change: EventHandler<String>,
|
|
||||||
) -> Element {
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
|
|
||||||
let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
|
|
||||||
|
|
||||||
let update_indicator = move |active: String| {
|
|
||||||
spawn(async move {
|
|
||||||
#[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 _ = 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);
|
|
||||||
if let Some(el) = doc.get_element_by_id(&element_id) {
|
|
||||||
if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
|
|
||||||
let left = html_el.offset_left();
|
|
||||||
let width = html_el.offset_width();
|
|
||||||
indicator_style.set(format!(
|
|
||||||
"left: {}px; width: {}px; opacity: 1;",
|
|
||||||
left, width
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
use_effect({
|
|
||||||
let active_value = active_value.clone();
|
|
||||||
move || {
|
|
||||||
update_indicator(active_value.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { class: "relative flex gap-1 border-b border-paper-border",
|
|
||||||
for (value, label) in items {
|
|
||||||
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"
|
|
||||||
},
|
|
||||||
onclick: {
|
|
||||||
let v = value.to_string();
|
|
||||||
move |_| {
|
|
||||||
on_change.call(v.clone());
|
|
||||||
update_indicator(v.clone());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"{label}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 绝对定位的滑动颜色条
|
|
||||||
div {
|
|
||||||
class: "absolute bottom-[-1px] h-[2px] bg-paper-accent transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] pointer-events-none",
|
|
||||||
style: "{indicator_style}",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -35,10 +35,7 @@ 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"),
|
||||||
@ -188,9 +185,7 @@ 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
|
let rows = conn.query("SELECT version FROM schema_migrations", &[]).await?;
|
||||||
.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));
|
||||||
@ -248,10 +243,7 @@ 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!(
|
assert_eq!(original, sorted, "MIGRATIONS must be in ascending version order");
|
||||||
original, sorted,
|
|
||||||
"MIGRATIONS must be in ascending version order"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -260,11 +252,7 @@ mod tests {
|
|||||||
let total = versions.len();
|
let total = versions.len();
|
||||||
versions.sort_unstable();
|
versions.sort_unstable();
|
||||||
versions.dedup();
|
versions.dedup();
|
||||||
assert_eq!(
|
assert_eq!(versions.len(), total, "MIGRATIONS has duplicate version strings");
|
||||||
versions.len(),
|
|
||||||
total,
|
|
||||||
"MIGRATIONS has duplicate version strings"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -308,7 +296,8 @@ mod tests {
|
|||||||
MIGRATIONS.iter().map(|(v, _)| v.to_string()).collect();
|
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!(
|
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: {:?}. \
|
||||||
@ -317,7 +306,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 数组里有但磁盘上没有 → include_str! 本就会编译失败,这里只是双保险。
|
// 数组里有但磁盘上没有 → 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!(
|
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: {:?}",
|
||||||
|
|||||||
@ -22,8 +22,9 @@ 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")
|
let db_url = std::env::var("DATABASE_URL").map_err(|_| {
|
||||||
.map_err(|_| "DATABASE_URL environment variable not set".to_string())?;
|
"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}"))?;
|
||||||
@ -35,10 +36,7 @@ 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 传递 GUC;tokio-postgres 在建连时执行。
|
// 通过 libpq options 传递 GUC;tokio-postgres 在建连时执行。
|
||||||
pg_cfg.options(format!(
|
pg_cfg.options(format!("-c statement_timeout={}", statement_timeout_secs * 1000));
|
||||||
"-c statement_timeout={}",
|
|
||||||
statement_timeout_secs * 1000
|
|
||||||
));
|
|
||||||
|
|
||||||
Ok(pg_cfg)
|
Ok(pg_cfg)
|
||||||
}
|
}
|
||||||
@ -168,7 +166,10 @@ 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!("connected to database after {} attempt(s)", attempt);
|
tracing::info!(
|
||||||
|
"connected to database after {} attempt(s)",
|
||||||
|
attempt
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return Ok(conn);
|
return Ok(conn);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,9 @@ pub mod server {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
"SyntaxSet built: {} syntaxes, swift={:?}",
|
"SyntaxSet built: {} syntaxes, swift={:?}",
|
||||||
built.syntaxes().len(),
|
built.syntaxes().len(),
|
||||||
built.find_syntax_by_extension("swift").map(|s| &s.name)
|
built
|
||||||
|
.find_syntax_by_extension("swift")
|
||||||
|
.map(|s| &s.name)
|
||||||
);
|
);
|
||||||
built
|
built
|
||||||
});
|
});
|
||||||
@ -255,8 +257,7 @@ 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 =
|
let code = "import Foundation\nfunc greet(person: String) -> String {\n return \"Hi\"\n}";
|
||||||
"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"),
|
||||||
@ -337,7 +338,11 @@ 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!(result.contains("keyword"), "Zig 关键字未被识别: {}", result);
|
assert!(
|
||||||
|
result.contains("keyword"),
|
||||||
|
"Zig 关键字未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
result.contains("name function"),
|
result.contains("name function"),
|
||||||
"Zig 函数名未被识别: {}",
|
"Zig 函数名未被识别: {}",
|
||||||
@ -360,7 +365,15 @@ mod tests {
|
|||||||
"Zig u32 类型未被识别: {}",
|
"Zig u32 类型未被识别: {}",
|
||||||
result
|
result
|
||||||
);
|
);
|
||||||
assert!(result.contains("string"), "Zig 字符串未被识别: {}", result);
|
assert!(
|
||||||
assert!(result.contains("numeric"), "Zig 数字未被识别: {}", result);
|
result.contains("string"),
|
||||||
|
"Zig 字符串未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("numeric"),
|
||||||
|
"Zig 数字未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -272,11 +272,7 @@ 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() {
|
let label = if label.is_empty() { absolute.clone() } else { label };
|
||||||
absolute.clone()
|
|
||||||
} else {
|
|
||||||
label
|
|
||||||
};
|
|
||||||
(label, absolute)
|
(label, absolute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
src/main.rs
40
src/main.rs
@ -165,9 +165,7 @@ fn cache_control_for_path(
|
|||||||
|| path == "/style.css"
|
|| path == "/style.css"
|
||||||
|| path == "/highlight.css"
|
|| path == "/highlight.css"
|
||||||
{
|
{
|
||||||
return Some(HeaderValue::from_static(
|
return Some(HeaderValue::from_static("public, max-age=31536000, immutable"));
|
||||||
"public, max-age=31536000, immutable",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开页面:5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证
|
// 公开页面:5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证
|
||||||
@ -192,10 +190,7 @@ async fn add_cache_control(
|
|||||||
|
|
||||||
if let Some(value) = cache_value {
|
if let Some(value) = cache_value {
|
||||||
// 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
|
// 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
|
||||||
response
|
response.headers_mut().entry(header::CACHE_CONTROL).or_insert(value);
|
||||||
.headers_mut()
|
|
||||||
.entry(header::CACHE_CONTROL)
|
|
||||||
.or_insert(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response
|
response
|
||||||
@ -220,9 +215,7 @@ 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!(
|
eprintln!("HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname");
|
||||||
"HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,10 +290,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 {
|
||||||
@ -347,9 +340,7 @@ 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
|
parts.extensions.insert(crate::ssr_cache::SsrGeneration(generation));
|
||||||
.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(
|
||||||
@ -373,7 +364,9 @@ fn main() {
|
|||||||
StatusCode::REQUEST_TIMEOUT,
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
Duration::from_secs(300),
|
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 并渲染前端组件
|
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
|
||||||
let dioxus_app =
|
let dioxus_app =
|
||||||
@ -384,7 +377,9 @@ 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(crate::api::csrf::csrf_middleware));
|
.layer(axum::middleware::from_fn(
|
||||||
|
crate::api::csrf::csrf_middleware,
|
||||||
|
));
|
||||||
if let Some(layer) = compression_layer_from_env() {
|
if let Some(layer) = compression_layer_from_env() {
|
||||||
app_routes = app_routes.layer(layer);
|
app_routes = app_routes.layer(layer);
|
||||||
}
|
}
|
||||||
@ -401,8 +396,14 @@ fn main() {
|
|||||||
// 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT,
|
// 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT,
|
||||||
// 使限流能拿到真实客户端 IP。
|
// 使限流能拿到真实客户端 IP。
|
||||||
let static_routes = axum::Router::new()
|
let static_routes = axum::Router::new()
|
||||||
.route("/healthz", axum::routing::get(crate::api::health::healthz))
|
.route(
|
||||||
.route("/readyz", axum::routing::get(crate::api::health::readyz))
|
"/healthz",
|
||||||
|
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),
|
||||||
@ -433,7 +434,8 @@ 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).map(|v| v.to_str().unwrap().to_string())
|
cache_control_for_path(path, &method)
|
||||||
|
.map(|v| v.to_str().unwrap().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
pub mod comment;
|
pub mod comment;
|
||||||
/// 文章模型、文章状态、标签与统计信息。
|
/// 文章模型、文章状态、标签与统计信息。
|
||||||
pub mod post;
|
pub mod post;
|
||||||
/// 回收站与站点配置模型。
|
|
||||||
pub mod settings;
|
|
||||||
/// 用户模型、用户角色与可公开用户信息。
|
/// 用户模型、用户角色与可公开用户信息。
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
/// 回收站与站点配置模型。
|
||||||
|
pub mod settings;
|
||||||
|
|||||||
@ -72,13 +72,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn clamp_retention_boundary() {
|
fn clamp_retention_boundary() {
|
||||||
assert_eq!(
|
assert_eq!(TrashSettings::clamp_retention(MIN_RETENTION_DAYS), MIN_RETENTION_DAYS);
|
||||||
TrashSettings::clamp_retention(MIN_RETENTION_DAYS),
|
assert_eq!(TrashSettings::clamp_retention(MAX_RETENTION_DAYS), MAX_RETENTION_DAYS);
|
||||||
MIN_RETENTION_DAYS
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
TrashSettings::clamp_retention(MAX_RETENTION_DAYS),
|
|
||||||
MAX_RETENTION_DAYS
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,12 +14,11 @@ 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::ui::{
|
use crate::components::ui::{
|
||||||
FilterTabs, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
|
EmptyState, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
|
||||||
BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_AMBER, BTN_TEXT_GREEN, BTN_TEXT_RED,
|
BTN_TEXT_AMBER, BTN_TEXT_GREEN, BTN_TEXT_RED, BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED,
|
||||||
CHECKBOX_CLASS,
|
CHECKBOX_CLASS,
|
||||||
};
|
};
|
||||||
use crate::models::comment::{AdminComment, CommentStatus};
|
use crate::models::comment::{AdminComment, CommentStatus};
|
||||||
@ -129,15 +128,20 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
div { class: "space-y-6",
|
div { class: "space-y-6",
|
||||||
h1 { class: "text-2xl font-bold text-paper-primary", "评论管理" }
|
h1 { class: "text-2xl font-bold text-paper-primary", "评论管理" }
|
||||||
|
|
||||||
FilterTabs {
|
div { class: "flex gap-1 border-b border-paper-border",
|
||||||
items: vec![
|
for (status, label) in [
|
||||||
("", "全部"),
|
("", "全部"),
|
||||||
("pending", "待审核"),
|
("pending", "待审核"),
|
||||||
("approved", "已通过"),
|
("approved", "已通过"),
|
||||||
("spam", "垃圾箱"),
|
("spam", "垃圾箱"),
|
||||||
],
|
]
|
||||||
active_value: active_filter(),
|
{
|
||||||
on_change: move |v| active_filter.set(v),
|
button {
|
||||||
|
class: if active_filter() == status { "px-4 py-2 text-sm font-medium border-b-2 border-paper-accent text-paper-primary" } else { "px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors" },
|
||||||
|
onclick: move |_| active_filter.set(status.to_string()),
|
||||||
|
"{label}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !selected_ids().is_empty() {
|
if !selected_ids().is_empty() {
|
||||||
@ -210,10 +214,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
{
|
{
|
||||||
if error().is_some() {
|
if error().is_some() {
|
||||||
rsx! {
|
rsx! {
|
||||||
EmptyState {
|
EmptyState { message: "加载失败", variant: "error" }
|
||||||
title: "加载失败",
|
|
||||||
description: "获取评论列表时发生错误,请稍后重试。",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if loading() && comments().is_empty() {
|
} else if loading() && comments().is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
@ -232,10 +233,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
} else if comments().is_empty() {
|
} else if comments().is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
EmptyState {
|
EmptyState { message: "暂无评论", variant: "default" }
|
||||||
title: "暂无评论",
|
|
||||||
description: "当前分类下还没有任何评论。",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let list = comments();
|
let list = comments();
|
||||||
@ -470,3 +468,4 @@ fn CommentRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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::{ADMIN_CARD_CLASS, BTN_SECONDARY};
|
use crate::components::ui::{BTN_SECONDARY, ADMIN_CARD_CLASS};
|
||||||
use crate::models::post::{PostListItem, PostStats};
|
use crate::models::post::{PostListItem, PostStats};
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,9 @@ 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::ui::{
|
use crate::components::ui::{EmptyState, 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;
|
||||||
|
|
||||||
@ -93,14 +90,7 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
if loading() && posts().is_empty() {
|
if loading() && posts().is_empty() {
|
||||||
DelayedSkeleton { PostsSkeleton {} }
|
DelayedSkeleton { PostsSkeleton {} }
|
||||||
} else if posts().is_empty() {
|
} else if posts().is_empty() {
|
||||||
EmptyState {
|
EmptyState { message: "暂无文章", variant: "default" }
|
||||||
title: "暂无文章",
|
|
||||||
description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
|
|
||||||
action: EmptyStateAction {
|
|
||||||
label: "写文章".to_string(),
|
|
||||||
to: Route::Write {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
div { class: "{ADMIN_TABLE_CLASS}",
|
div { class: "{ADMIN_TABLE_CLASS}",
|
||||||
table { class: "w-full text-sm",
|
table { class: "w-full text-sm",
|
||||||
@ -172,10 +162,6 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
/// 结果消息(`rebuild_result`)、以及 `do_rebuild` 异步闭包。完全自洽,与父组件
|
/// 结果消息(`rebuild_result`)、以及 `do_rebuild` 异步闭包。完全自洽,与父组件
|
||||||
/// 无任何状态共享。
|
/// 无任何状态共享。
|
||||||
///
|
///
|
||||||
/// 布局要点:根容器 `relative`,结果消息 `absolute` 悬挂于按钮行下方,**不进入文档流**。
|
|
||||||
/// 这样无论消息是否出现,本组件的盒高始终等于按钮行高度,避免外层 header 的
|
|
||||||
/// `items-center` 因消息撑高而把标题与「+ 写文章」按钮重新居中、与重建按钮错位。
|
|
||||||
///
|
|
||||||
/// 从 `PostsPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
|
/// 从 `PostsPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
|
||||||
#[component]
|
#[component]
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||||
@ -190,11 +176,7 @@ 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 {
|
Ok(RebuildResult { rebuilt, failed, errors }) => {
|
||||||
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() {
|
||||||
@ -214,11 +196,8 @@ fn RebuildCacheBar() -> Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
// 根容器仅占「按钮行」高度:结果消息用绝对定位悬挂于按钮下方,
|
// 垂直容器:上方按钮行(与父级 + 写文章 同级水平排列),下方重建结果消息。
|
||||||
// 不进入文档流。否则消息出现时会撑高本组件,导致外层 header 的
|
div { class: "flex flex-col gap-2",
|
||||||
// items-center 把「文章管理」标题与「+ 写文章」按钮重新居中、
|
|
||||||
// 与停在容器顶部的重建按钮产生纵向错位。
|
|
||||||
div { class: "relative",
|
|
||||||
div { class: "flex items-center gap-3",
|
div { class: "flex items-center gap-3",
|
||||||
div { class: "group relative",
|
div { class: "group relative",
|
||||||
button {
|
button {
|
||||||
@ -252,9 +231,7 @@ fn RebuildCacheBar() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(msg) = rebuild_result() {
|
if let Some(msg) = rebuild_result() {
|
||||||
div { class: "absolute top-full left-0 mt-2 text-sm text-paper-secondary whitespace-nowrap",
|
div { class: "text-sm text-paper-secondary px-1", "{msg}" }
|
||||||
"{msg}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,12 +18,11 @@ use crate::api::posts::{
|
|||||||
use crate::api::posts::{list_deleted_posts, PostListResponse};
|
use crate::api::posts::{list_deleted_posts, PostListResponse};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::api::settings::{get_trash_settings, update_trash_settings};
|
use crate::api::settings::{get_trash_settings, update_trash_settings};
|
||||||
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::ui::{
|
use crate::components::ui::{
|
||||||
Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
|
EmptyState, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
|
||||||
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
|
BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
|
||||||
};
|
};
|
||||||
use crate::models::post::PostListItem;
|
use crate::models::post::PostListItem;
|
||||||
use crate::models::settings::TrashSettings;
|
use crate::models::settings::TrashSettings;
|
||||||
@ -68,10 +67,7 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
let page = current_page;
|
let page = current_page;
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match list_deleted_posts(page, TRASH_PER_PAGE).await {
|
match list_deleted_posts(page, TRASH_PER_PAGE).await {
|
||||||
Ok(PostListResponse {
|
Ok(PostListResponse { posts: list, total: t }) => {
|
||||||
posts: list,
|
|
||||||
total: t,
|
|
||||||
}) => {
|
|
||||||
posts.set(list);
|
posts.set(list);
|
||||||
total.set(t);
|
total.set(t);
|
||||||
}
|
}
|
||||||
@ -159,10 +155,7 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
{
|
{
|
||||||
if error().is_some() {
|
if error().is_some() {
|
||||||
rsx! {
|
rsx! {
|
||||||
EmptyState {
|
EmptyState { message: "加载失败", variant: "error" }
|
||||||
title: "加载失败",
|
|
||||||
description: "获取回收站列表时发生错误,请稍后重试。",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if loading() && posts().is_empty() {
|
} else if loading() && posts().is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
@ -176,10 +169,7 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
} else if posts().is_empty() {
|
} else if posts().is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
EmptyState {
|
EmptyState { message: "回收站为空", variant: "default" }
|
||||||
title: "回收站为空",
|
|
||||||
description: "当前没有被软删除的文章。",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let list = posts();
|
let list = posts();
|
||||||
@ -422,15 +412,8 @@ fn AutoPurgeSettings(settings: Signal<TrashSettings>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置面板(可折叠带平滑动画)
|
// 设置面板(可折叠)
|
||||||
div {
|
if settings_panel_open() {
|
||||||
class: "grid transition-all duration-300 ease-in-out",
|
|
||||||
style: if settings_panel_open() {
|
|
||||||
"grid-template-rows: 1fr; opacity: 1; pointer-events: auto;"
|
|
||||||
} else {
|
|
||||||
"grid-template-rows: 0fr; opacity: 0; pointer-events: none;"
|
|
||||||
},
|
|
||||||
div { class: "overflow-hidden min-h-0",
|
|
||||||
div { class: "border-t border-paper-border p-5 space-y-6",
|
div { class: "border-t border-paper-border p-5 space-y-6",
|
||||||
// 开关行:启用自动清理
|
// 开关行:启用自动清理
|
||||||
div { class: "flex items-center justify-between gap-4",
|
div { class: "flex items-center justify-between gap-4",
|
||||||
@ -565,7 +548,6 @@ fn AutoPurgeSettings(settings: Signal<TrashSettings>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 计算剩余天数(保留期 - 已删除天数)。
|
/// 计算剩余天数(保留期 - 已删除天数)。
|
||||||
@ -610,11 +592,7 @@ fn TrashRow(
|
|||||||
} else {
|
} else {
|
||||||
"bg-paper-tertiary text-paper-secondary"
|
"bg-paper-tertiary text-paper-secondary"
|
||||||
};
|
};
|
||||||
let badge_text = if expired {
|
let badge_text = if expired { "待清理".to_string() } else { format!("{remaining}天") };
|
||||||
"待清理".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{remaining}天")
|
|
||||||
};
|
|
||||||
let deleted_str = post
|
let deleted_str = post
|
||||||
.deleted_at
|
.deleted_at
|
||||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||||
@ -663,16 +641,3 @@ fn TrashRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_auto_purge_settings_has_transition_class() {
|
|
||||||
let full_code = include_str!("trash.rs");
|
|
||||||
let code = full_code.split("#[cfg(test)]").next().unwrap_or(full_code);
|
|
||||||
assert!(code.contains("grid transition-all duration-300 ease-in-out"));
|
|
||||||
assert!(code.contains("grid-template-rows: 1fr; opacity: 1;"));
|
|
||||||
assert!(code.contains("grid-template-rows: 0fr; opacity: 0;"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -122,3 +122,4 @@ fn HomeInfo() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,9 +42,7 @@ pub struct SsrGeneration(pub u64);
|
|||||||
|
|
||||||
/// 原子递增并返回新的全局世代号。
|
/// 原子递增并返回新的全局世代号。
|
||||||
pub fn bump_global_generation() -> u64 {
|
pub fn bump_global_generation() -> u64 {
|
||||||
GLOBAL_GENERATION
|
GLOBAL_GENERATION.fetch_add(1, Ordering::SeqCst).wrapping_add(1)
|
||||||
.fetch_add(1, Ordering::SeqCst)
|
|
||||||
.wrapping_add(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回当前全局世代号。
|
/// 返回当前全局世代号。
|
||||||
|
|||||||
@ -151,11 +151,7 @@ mod tests {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
std::env::temp_dir().join(format!(
|
std::env::temp_dir().join(format!("yggdrasil_image_cache_test_{}_{}", nanos, std::process::id()))
|
||||||
"yggdrasil_image_cache_test_{}_{}",
|
|
||||||
nanos,
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -2,15 +2,15 @@
|
|||||||
//!
|
//!
|
||||||
//! 所有任务仅在 `server` feature 启用时编译,运行在服务端独立的 tokio 任务中。
|
//! 所有任务仅在 `server` feature 启用时编译,运行在服务端独立的 tokio 任务中。
|
||||||
|
|
||||||
/// 定时清理图片磁盘缓存,避免缓存目录无限增长。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub mod image_cache_cleanup;
|
|
||||||
/// 定时清理评论过期的 IP 与用户代理信息,满足隐私保护要求。
|
/// 定时清理评论过期的 IP 与用户代理信息,满足隐私保护要求。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod ip_purge;
|
pub mod ip_purge;
|
||||||
/// 定时清理回收站中超过保留期的已删除文章。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub mod post_purge;
|
|
||||||
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
|
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod session_cleanup;
|
pub mod session_cleanup;
|
||||||
|
/// 定时清理回收站中超过保留期的已删除文章。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub mod post_purge;
|
||||||
|
/// 定时清理图片磁盘缓存,避免缓存目录无限增长。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub mod image_cache_cleanup;
|
||||||
|
|||||||
@ -19,14 +19,16 @@ 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) => match purge_expired(&client).await {
|
Ok(client) => {
|
||||||
|
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;
|
||||||
|
|||||||
@ -64,8 +64,7 @@ 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())
|
let val = js_sys::Reflect::get(&window, &"TiptapEditor".into()).expect("window.TiptapEditor missing");
|
||||||
.expect("window.TiptapEditor missing");
|
|
||||||
val.unchecked_into::<TiptapEditorModule>()
|
val.unchecked_into::<TiptapEditorModule>()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,7 +307,9 @@ pub mod wasm {
|
|||||||
.map_err(|_| "上传响应类型异常".to_string())?;
|
.map_err(|_| "上传响应类型异常".to_string())?;
|
||||||
|
|
||||||
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON)
|
// 读响应体文本(无论 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)
|
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("读取响应失败: {:?}", e))?;
|
.map_err(|e| format!("读取响应失败: {:?}", e))?;
|
||||||
@ -354,6 +355,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, get_module, make_upload_closure, upload_image_file, EditorHandle,
|
consume_upload_event, make_upload_closure, upload_image_file, EditorHandle, EditorOptions,
|
||||||
EditorOptions, UploadEventJs,
|
UploadEventJs, get_module,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user