style: format rust code
Some checks failed
CI / build (push) Has been cancelled
CI / check (push) Has been cancelled

This commit is contained in:
xfy 2026-06-12 17:14:31 +08:00
parent 71ac08c373
commit 294d60afab
55 changed files with 582 additions and 296 deletions

View File

@ -4,11 +4,11 @@ use dioxus::prelude::*;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use http::header::{HeaderValue, SET_COOKIE}; use http::header::{HeaderValue, SET_COOKIE};
use crate::auth::{password, session};
#[cfg(feature = "server")]
use crate::auth::session::get_session_from_ctx;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::auth::session::get_session_from_ctx;
use crate::auth::{password, session};
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::models::user::{PublicUser, User, UserRole}; use crate::models::user::{PublicUser, User, UserRole};
@ -106,7 +106,8 @@ pub async fn register(
}); });
} }
let password_hash = password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?; let password_hash =
password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?;
let result = client let result = client
.query_one( .query_one(
@ -176,7 +177,8 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
}; };
let password_hash: String = row.get("password_hash"); let password_hash: String = row.get("password_hash");
let valid = password::verify_password(&password, &password_hash).map_err(|_| AppError::Internal("密码处理失败"))?; let valid = password::verify_password(&password, &password_hash)
.map_err(|_| AppError::Internal("密码处理失败"))?;
if !valid { if !valid {
return Ok(AuthResponse { return Ok(AuthResponse {

View File

@ -10,8 +10,8 @@ pub struct PendingStatusItem {
pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem>, ServerFnError> { pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem>, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::db::pool::get_conn;
use crate::api::error::AppError; use crate::api::error::AppError;
use crate::db::pool::get_conn;
if ids.is_empty() { if ids.is_empty() {
return Ok(vec![]); return Ok(vec![]);
@ -35,7 +35,10 @@ pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem
let result: Vec<PendingStatusItem> = ids let result: Vec<PendingStatusItem> = ids
.into_iter() .into_iter()
.map(|id| { .map(|id| {
let status = found.get(&id).cloned().unwrap_or_else(|| "gone".to_string()); let status = found
.get(&id)
.cloned()
.unwrap_or_else(|| "gone".to_string());
PendingStatusItem { id, status } PendingStatusItem { id, status }
}) })
.collect(); .collect();

View File

@ -1,5 +1,5 @@
use dioxus::prelude::*;
use crate::api::comments::types::*; use crate::api::comments::types::*;
use dioxus::prelude::*;
#[server(CreateComment, "/api")] #[server(CreateComment, "/api")]
pub async fn create_comment( pub async fn create_comment(
@ -12,13 +12,13 @@ pub async fn create_comment(
) -> Result<CommentResponse, ServerFnError> { ) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::api::comments::helpers::{
compute_content_hash, validate_comment_content, validate_comment_email,
validate_comment_name, validate_comment_url,
};
use crate::api::error::AppError;
use crate::cache; use crate::cache;
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::comments::helpers::{
validate_comment_name, validate_comment_email, validate_comment_url,
validate_comment_content, compute_content_hash,
};
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut(); let parts = ctx.parts_mut();
@ -177,12 +177,7 @@ pub async fn create_comment(
} }
} }
let content_hash = compute_content_hash( let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md);
post_id,
parent_id,
&author_name,
&content_md,
);
let dup: Option<i64> = client let dup: Option<i64> = client
.query_opt( .query_opt(
@ -215,7 +210,8 @@ pub async fn create_comment(
let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut(); let parts = ctx.parts_mut();
parts.headers parts
.headers
.get("user-agent") .get("user-agent")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(|s| s.to_string()) .map(|s| s.to_string())
@ -236,7 +232,10 @@ pub async fn create_comment(
&depth, &depth,
&author_name.trim(), &author_name.trim(),
&author_email.trim(), &author_email.trim(),
&author_url.as_ref().map(|u| u.trim()).filter(|u| !u.is_empty()), &author_url
.as_ref()
.map(|u| u.trim())
.filter(|u| !u.is_empty()),
&content_md, &content_md,
&content_html, &content_html,
&content_hash, &content_hash,

View File

@ -89,8 +89,7 @@ pub fn validate_comment_name(name: &str) -> Result<(), String> {
#[allow(dead_code)] #[allow(dead_code)]
pub fn validate_comment_email(email: &str) -> Result<(), String> { pub fn validate_comment_email(email: &str) -> Result<(), String> {
let re = let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
if !re.is_match(email.trim()) { if !re.is_match(email.trim()) {
return Err("邮箱格式不正确".to_string()); return Err("邮箱格式不正确".to_string());
} }

View File

@ -1,16 +1,14 @@
use dioxus::prelude::*;
use crate::api::comments::types::*; use crate::api::comments::types::*;
use dioxus::prelude::*;
#[server(GetPendingComments, "/api")] #[server(GetPendingComments, "/api")]
pub async fn get_pending_comments( pub async fn get_pending_comments(page: i32) -> Result<PendingCommentsResponse, ServerFnError> {
page: i32,
) -> Result<PendingCommentsResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::comments::helpers::row_to_admin_comment;
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
use crate::api::comments::helpers::row_to_admin_comment;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
let _admin = get_current_admin_user().await?; let _admin = get_current_admin_user().await?;
@ -54,10 +52,10 @@ pub async fn get_pending_comments(
pub async fn get_pending_count() -> Result<PendingCountResponse, ServerFnError> { pub async fn get_pending_count() -> Result<PendingCountResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::cache; use crate::cache;
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?; let _admin = get_current_admin_user().await?;
@ -91,10 +89,10 @@ pub async fn get_all_comments(
) -> Result<AllCommentsResponse, ServerFnError> { ) -> Result<AllCommentsResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::comments::helpers::row_to_admin_comment;
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
use crate::api::comments::helpers::row_to_admin_comment;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
let _admin = get_current_admin_user().await?; let _admin = get_current_admin_user().await?;

View File

@ -36,9 +36,7 @@ pub fn render_comment_markdown(md: &str) -> String {
Event::Start(Tag::CodeBlock(kind)) => { Event::Start(Tag::CodeBlock(kind)) => {
in_codeblock = true; in_codeblock = true;
code_lang = match kind { code_lang = match kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => { CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
Some(lang.to_string())
}
_ => None, _ => None,
}; };
code_buffer.clear(); code_buffer.clear();
@ -83,10 +81,19 @@ mod tests {
#[test] #[test]
fn render_comment_heading_all_levels() { fn render_comment_heading_all_levels() {
for md in &[ for md in &[
"# H1", "## H2", "### H3", "#### H4", "##### H5", "###### H6", "# H1",
"## H2",
"### H3",
"#### H4",
"##### H5",
"###### H6",
] { ] {
let result = render_comment_markdown(md); let result = render_comment_markdown(md);
assert!(result.contains("<strong>"), "heading not converted for: {}", md); assert!(
result.contains("<strong>"),
"heading not converted for: {}",
md
);
} }
} }
@ -162,7 +169,8 @@ mod tests {
#[test] #[test]
fn clean_comment_html_removes_details_summary() { fn clean_comment_html_removes_details_summary() {
let result = clean_comment_html("<details><summary>Click</summary><p>Content</p></details>"); let result =
clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
assert!(!result.contains("details")); assert!(!result.contains("details"));
assert!(!result.contains("summary")); assert!(!result.contains("summary"));
} }

View File

@ -1,20 +1,25 @@
#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)] #![allow(
clippy::unused_unit,
deprecated,
unused_imports,
clippy::too_many_arguments
)]
mod types;
mod helpers;
mod markdown;
mod create;
mod read;
mod update;
mod list;
mod check; mod check;
mod create;
mod helpers;
mod list;
mod markdown;
mod read;
mod types;
mod update;
pub use types::*;
pub use create::create_comment;
pub use read::{get_comments, get_comment_count};
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
pub use list::{get_pending_comments, get_pending_count, get_all_comments};
pub use check::check_pending_status; pub use check::check_pending_status;
pub use create::create_comment;
pub use list::{get_all_comments, get_pending_comments, get_pending_count};
pub use read::{get_comment_count, get_comments};
pub use types::*;
pub use update::{approve_comment, batch_update_comment_status, spam_comment, trash_comment};
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub use markdown::render_comment_markdown; pub use markdown::render_comment_markdown;

View File

@ -1,16 +1,14 @@
use dioxus::prelude::*;
use crate::api::comments::types::*; use crate::api::comments::types::*;
use dioxus::prelude::*;
#[server(GetComments, "/api")] #[server(GetComments, "/api")]
pub async fn get_comments( pub async fn get_comments(post_id: i32) -> Result<CommentTreeResponse, ServerFnError> {
post_id: i32,
) -> Result<CommentTreeResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::comments::helpers::row_to_public_comment; use crate::api::comments::helpers::row_to_public_comment;
use crate::api::error::AppError; use crate::api::error::AppError;
use crate::cache;
use crate::db::pool::get_conn;
if let Some(cached) = cache::get_comments_by_post(post_id).await { if let Some(cached) = cache::get_comments_by_post(post_id).await {
let count = cached.len() as i64; let count = cached.len() as i64;
@ -45,14 +43,12 @@ pub async fn get_comments(
} }
#[server(GetCommentCount, "/api")] #[server(GetCommentCount, "/api")]
pub async fn get_comment_count( pub async fn get_comment_count(post_id: i32) -> Result<CommentCountResponse, ServerFnError> {
post_id: i32,
) -> Result<CommentCountResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::api::error::AppError;
use crate::cache; use crate::cache;
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::api::error::AppError;
if let Some(cached) = cache::get_comment_count(post_id).await { if let Some(cached) = cache::get_comment_count(post_id).await {
return Ok(CommentCountResponse { count: cached }); return Ok(CommentCountResponse { count: cached });

View File

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::models::comment::{AdminComment, PublicComment}; use crate::models::comment::{AdminComment, PublicComment};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]

View File

@ -1,14 +1,14 @@
use dioxus::prelude::*;
use crate::api::comments::types::*; use crate::api::comments::types::*;
use dioxus::prelude::*;
#[server(ApproveComment, "/api")] #[server(ApproveComment, "/api")]
pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError> { pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::cache; use crate::cache;
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?; let _admin = get_current_admin_user().await?;
@ -79,10 +79,10 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> { pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::cache; use crate::cache;
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?; let _admin = get_current_admin_user().await?;
@ -132,10 +132,10 @@ pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> { pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::cache; use crate::cache;
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?; let _admin = get_current_admin_user().await?;
@ -185,10 +185,10 @@ pub async fn batch_update_comment_status(
) -> Result<BatchStatusResponse, ServerFnError> { ) -> Result<BatchStatusResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::cache; use crate::cache;
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?; let _admin = get_current_admin_user().await?;

View File

@ -63,7 +63,10 @@ mod tests {
!msg.contains("5432"), !msg.contains("5432"),
"should not leak internal details: {msg}" "should not leak internal details: {msg}"
); );
assert!(msg.contains("服务暂时不可用"), "expected generic message: {msg}"); assert!(
msg.contains("服务暂时不可用"),
"expected generic message: {msg}"
);
} }
#[test] #[test]

View File

@ -266,7 +266,10 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
tracing::warn!("Failed to create cache dir: {:?}", e); tracing::warn!("Failed to create cache dir: {:?}", e);
return; return;
} }
let ct_str = cached.content_type.to_str().unwrap_or("application/octet-stream"); let ct_str = cached
.content_type
.to_str()
.unwrap_or("application/octet-stream");
if let Err(e) = tokio::fs::write(format!("{}.dat", base), &cached.data).await { if let Err(e) = tokio::fs::write(format!("{}.dat", base), &cached.data).await {
tracing::warn!("Failed to write disk cache data: {:?}", e); tracing::warn!("Failed to write disk cache data: {:?}", e);
} }
@ -319,10 +322,15 @@ pub async fn serve_image(
} }
if let Some(cached) = read_disk_cache(&cache_key).await { if let Some(cached) = read_disk_cache(&cache_key).await {
let _ = IMAGE_CACHE.insert(cache_key.clone(), CachedImage { let _ = IMAGE_CACHE
data: cached.data.clone(), .insert(
content_type: cached.content_type.clone(), cache_key.clone(),
}).await; CachedImage {
data: cached.data.clone(),
content_type: cached.content_type.clone(),
},
)
.await;
return ( return (
StatusCode::OK, StatusCode::OK,
[(header::CONTENT_TYPE, cached.content_type)], [(header::CONTENT_TYPE, cached.content_type)],
@ -388,77 +396,113 @@ mod tests {
#[test] #[test]
fn image_params_validate_valid_width() { fn image_params_validate_valid_width() {
let params = ImageParams { w: Some(100), ..Default::default() }; let params = ImageParams {
w: Some(100),
..Default::default()
};
assert!(params.validate().is_ok()); assert!(params.validate().is_ok());
} }
#[test] #[test]
fn image_params_validate_zero_width_rejected() { fn image_params_validate_zero_width_rejected() {
let params = ImageParams { w: Some(0), ..Default::default() }; let params = ImageParams {
w: Some(0),
..Default::default()
};
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
#[test] #[test]
fn image_params_validate_oversized_width_rejected() { fn image_params_validate_oversized_width_rejected() {
let params = ImageParams { w: Some(5000), ..Default::default() }; let params = ImageParams {
w: Some(5000),
..Default::default()
};
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
#[test] #[test]
fn image_params_validate_valid_rotation() { fn image_params_validate_valid_rotation() {
for angle in [0, 90, 180, 270] { for angle in [0, 90, 180, 270] {
let params = ImageParams { rotate: Some(angle), ..Default::default() }; let params = ImageParams {
rotate: Some(angle),
..Default::default()
};
assert!(params.validate().is_ok(), "angle {} should be valid", angle); assert!(params.validate().is_ok(), "angle {} should be valid", angle);
} }
} }
#[test] #[test]
fn image_params_validate_invalid_rotation_rejected() { fn image_params_validate_invalid_rotation_rejected() {
let params = ImageParams { rotate: Some(45), ..Default::default() }; let params = ImageParams {
rotate: Some(45),
..Default::default()
};
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
#[test] #[test]
fn image_params_validate_valid_format() { fn image_params_validate_valid_format() {
for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] { for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] {
let params = ImageParams { format: Some(fmt.to_string()), ..Default::default() }; let params = ImageParams {
format: Some(fmt.to_string()),
..Default::default()
};
assert!(params.validate().is_ok(), "format {} should be valid", fmt); assert!(params.validate().is_ok(), "format {} should be valid", fmt);
} }
} }
#[test] #[test]
fn image_params_validate_invalid_format_rejected() { fn image_params_validate_invalid_format_rejected() {
let params = ImageParams { format: Some("gif".to_string()), ..Default::default() }; let params = ImageParams {
format: Some("gif".to_string()),
..Default::default()
};
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
#[test] #[test]
fn image_params_validate_valid_thumbnail() { fn image_params_validate_valid_thumbnail() {
let params = ImageParams { thumb: Some("200x150".to_string()), ..Default::default() }; let params = ImageParams {
thumb: Some("200x150".to_string()),
..Default::default()
};
assert!(params.validate().is_ok()); assert!(params.validate().is_ok());
} }
#[test] #[test]
fn image_params_validate_invalid_thumbnail_rejected() { fn image_params_validate_invalid_thumbnail_rejected() {
let params = ImageParams { thumb: Some("200".to_string()), ..Default::default() }; let params = ImageParams {
thumb: Some("200".to_string()),
..Default::default()
};
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
#[test] #[test]
fn image_params_validate_valid_quality() { fn image_params_validate_valid_quality() {
let params = ImageParams { quality: Some(85), ..Default::default() }; let params = ImageParams {
quality: Some(85),
..Default::default()
};
assert!(params.validate().is_ok()); assert!(params.validate().is_ok());
} }
#[test] #[test]
fn image_params_validate_zero_quality_rejected() { fn image_params_validate_zero_quality_rejected() {
let params = ImageParams { quality: Some(0), ..Default::default() }; let params = ImageParams {
quality: Some(0),
..Default::default()
};
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
#[test] #[test]
fn image_params_validate_over_100_quality_rejected() { fn image_params_validate_over_100_quality_rejected() {
let params = ImageParams { quality: Some(101), ..Default::default() }; let params = ImageParams {
quality: Some(101),
..Default::default()
};
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
@ -486,9 +530,18 @@ mod tests {
#[test] #[test]
fn detect_format_jpeg() { fn detect_format_jpeg() {
assert!(matches!(detect_format("photo.jpg"), image::ImageFormat::Jpeg)); assert!(matches!(
assert!(matches!(detect_format("photo.jpeg"), image::ImageFormat::Jpeg)); detect_format("photo.jpg"),
assert!(matches!(detect_format("PHOTO.JPG"), image::ImageFormat::Jpeg)); image::ImageFormat::Jpeg
));
assert!(matches!(
detect_format("photo.jpeg"),
image::ImageFormat::Jpeg
));
assert!(matches!(
detect_format("PHOTO.JPG"),
image::ImageFormat::Jpeg
));
} }
#[test] #[test]
@ -498,18 +551,30 @@ mod tests {
#[test] #[test]
fn detect_format_webp() { fn detect_format_webp() {
assert!(matches!(detect_format("anim.webp"), image::ImageFormat::WebP)); assert!(matches!(
detect_format("anim.webp"),
image::ImageFormat::WebP
));
} }
#[test] #[test]
fn detect_format_defaults_to_jpeg() { fn detect_format_defaults_to_jpeg() {
assert!(matches!(detect_format("file.xyz"), image::ImageFormat::Jpeg)); assert!(matches!(
detect_format("file.xyz"),
image::ImageFormat::Jpeg
));
} }
#[test] #[test]
fn cache_key_differs_for_different_params() { fn cache_key_differs_for_different_params() {
let p1 = ImageParams { w: Some(100), ..Default::default() }; let p1 = ImageParams {
let p2 = ImageParams { w: Some(200), ..Default::default() }; w: Some(100),
..Default::default()
};
let p2 = ImageParams {
w: Some(200),
..Default::default()
};
assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg")); assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg"));
} }
@ -521,7 +586,10 @@ mod tests {
#[test] #[test]
fn is_empty_false_when_any_set() { fn is_empty_false_when_any_set() {
let params = ImageParams { w: Some(100), ..Default::default() }; let params = ImageParams {
w: Some(100),
..Default::default()
};
assert!(!params.is_empty()); assert!(!params.is_empty());
} }

View File

@ -204,9 +204,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
let clean_text = clean_html(text); let clean_text = clean_html(text);
html.push_str(&format!( html.push_str(&format!(
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>", "<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
id, id, clean_text, clean_text
clean_text,
clean_text
)); ));
} }
@ -367,7 +365,11 @@ mod tests {
#[test] #[test]
fn render_markdown_data_uri_image() { fn render_markdown_data_uri_image() {
let result = render_markdown_enhanced("![alt](data:image/svg+xml,%3csvg%3e%3c/svg%3e)"); let result = render_markdown_enhanced("![alt](data:image/svg+xml,%3csvg%3e%3c/svg%3e)");
assert!(result.html.contains("data:image/svg+xml"), "data URI should be preserved in img src, got: {}", result.html); assert!(
result.html.contains("data:image/svg+xml"),
"data URI should be preserved in img src, got: {}",
result.html
);
assert!(result.html.contains("alt=\"alt\"")); assert!(result.html.contains("alt=\"alt\""));
} }
} }

View File

@ -16,7 +16,10 @@ pub async fn list_published_posts(
{ {
let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page }; let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page };
if let Some((cached_posts, cached_total)) = crate::cache::get_post_list(&cache_key).await { if let Some((cached_posts, cached_total)) = crate::cache::get_post_list(&cache_key).await {
return Ok(PostListResponse { posts: cached_posts, total: cached_total }); return Ok(PostListResponse {
posts: cached_posts,
total: cached_total,
});
} }
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
@ -68,15 +71,15 @@ pub async fn list_published_posts(
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]
{ {
Ok(PostListResponse { posts: Vec::new(), total: 0 }) Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
} }
} }
#[server(ListPosts, "/api")] #[server(ListPosts, "/api")]
pub async fn list_posts( pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
page: i32,
per_page: i32,
) -> Result<PostListResponse, ServerFnError> {
let _user = get_current_admin_user().await?; let _user = get_current_admin_user().await?;
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -84,10 +87,7 @@ pub async fn list_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 NULL", &[])
"SELECT COUNT(*) FROM posts WHERE deleted_at IS 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);
@ -122,7 +122,10 @@ pub async fn list_posts(
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]
{ {
Ok(PostListResponse { posts: Vec::new(), total: 0 }) Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
} }
} }
@ -130,8 +133,12 @@ pub async fn list_posts(
pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> { pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await { if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await
return Ok(PostListResponse { posts: cached_posts, total: cached_total }); {
return Ok(PostListResponse {
posts: cached_posts,
total: cached_total,
});
} }
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
@ -170,6 +177,9 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]
{ {
Ok(PostListResponse { posts: Vec::new(), total: 0 }) Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
} }
} }

View File

@ -1,27 +1,32 @@
#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)] #![allow(
clippy::unused_unit,
deprecated,
unused_imports,
clippy::too_many_arguments
)]
mod types;
mod helpers;
mod create; mod create;
mod update;
mod delete; mod delete;
mod read; mod helpers;
mod list; mod list;
mod search; mod read;
mod tags;
mod stats;
mod rebuild; mod rebuild;
mod search;
mod stats;
mod tags;
mod types;
mod update;
pub use types::*;
pub use create::create_post; pub use create::create_post;
pub use update::update_post;
pub use delete::delete_post; pub use delete::delete_post;
pub use list::{get_posts_by_tag, list_posts, list_published_posts};
pub use read::{get_post_by_id, get_post_by_slug}; pub use read::{get_post_by_id, get_post_by_slug};
pub use list::{list_published_posts, list_posts, get_posts_by_tag};
pub use search::search_posts;
pub use tags::list_tags;
pub use stats::get_post_stats;
pub use rebuild::rebuild_content_html; pub use rebuild::rebuild_content_html;
pub use search::search_posts;
pub use stats::get_post_stats;
pub use tags::list_tags;
pub use types::*;
pub use update::update_post;
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub use crate::api::markdown::render_markdown_enhanced; pub use crate::api::markdown::render_markdown_enhanced;

View File

@ -15,7 +15,10 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
let q = query.trim(); let q = query.trim();
if q.is_empty() { if q.is_empty() {
return Ok(PostListResponse { posts: Vec::new(), total: 0 }); return Ok(PostListResponse {
posts: Vec::new(),
total: 0,
});
} }
let rows = client let rows = client
@ -49,6 +52,9 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]
{ {
Ok(PostListResponse { posts: Vec::new(), total: 0 }) Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
} }
} }

View File

@ -1,10 +1,10 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use super::types::TagListResponse; use super::types::TagListResponse;
use crate::db::pool::get_conn;
use crate::models::post::Tag;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
use crate::db::pool::get_conn;
use crate::models::post::Tag;
#[server(ListTags, "/api")] #[server(ListTags, "/api")]
pub async fn list_tags() -> Result<TagListResponse, ServerFnError> { pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {

View File

@ -65,7 +65,8 @@ pub async fn update_post(
_ => crate::api::slug::slugify(&title), _ => crate::api::slug::slugify(&title),
}; };
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?; let final_slug =
crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?;
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
let content_html = rendered.html; let content_html = rendered.html;
let toc_html = if rendered.toc_html.is_empty() { let toc_html = if rendered.toc_html.is_empty() {

View File

@ -1,11 +1,11 @@
#[cfg(feature = "server")] #[cfg(feature = "server")]
use std::sync::LazyLock; use axum::http::StatusCode;
#[cfg(feature = "server")]
use std::num::NonZeroU32;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use axum::http::StatusCode; use std::num::NonZeroU32;
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn env_or(key: &str, default: u32) -> NonZeroU32 { fn env_or(key: &str, default: u32) -> NonZeroU32 {
@ -90,10 +90,7 @@ fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<St
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn get_client_ip_with_trusted( pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count: usize) -> String {
headers: &http::HeaderMap,
trusted_proxy_count: usize,
) -> String {
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(ip) = ip_from_x_forwarded_for(value, trusted_proxy_count) { if let Some(ip) = ip_from_x_forwarded_for(value, trusted_proxy_count) {
return ip; return ip;
@ -204,7 +201,10 @@ mod tests {
#[test] #[test]
fn get_client_ip_ignores_empty_x_forwarded_for_entries() { fn get_client_ip_ignores_empty_x_forwarded_for_entries() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", " , 1.2.3.4 , 5.6.7.8 , ".parse().unwrap()); headers.insert(
"x-forwarded-for",
" , 1.2.3.4 , 5.6.7.8 , ".parse().unwrap(),
);
assert_eq!(get_client_ip_with_trusted(&headers, 1), "1.2.3.4"); assert_eq!(get_client_ip_with_trusted(&headers, 1), "1.2.3.4");
} }
} }

View File

@ -7,15 +7,81 @@ use std::collections::HashSet;
fn default_allowed_tags() -> HashSet<&'static str> { fn default_allowed_tags() -> HashSet<&'static str> {
let mut set = HashSet::new(); let mut set = HashSet::new();
for tag in [ for tag in [
"a", "abbr", "acronym", "area", "article", "aside", "b", "bdi", "a",
"bdo", "blockquote", "br", "caption", "center", "cite", "code", "abbr",
"col", "colgroup", "data", "dd", "del", "details", "dfn", "div", "acronym",
"dl", "dt", "em", "figcaption", "figure", "footer", "h1", "h2", "area",
"h3", "h4", "h5", "h6", "header", "hgroup", "hr", "i", "img", "article",
"ins", "kbd", "li", "map", "mark", "nav", "ol", "p", "pre", "aside",
"q", "rp", "rt", "rtc", "ruby", "s", "samp", "small", "span", "b",
"strike", "strong", "sub", "summary", "sup", "table", "tbody", "bdi",
"td", "th", "thead", "time", "tr", "tt", "u", "ul", "var", "wbr", "bdo",
"blockquote",
"br",
"caption",
"center",
"cite",
"code",
"col",
"colgroup",
"data",
"dd",
"del",
"details",
"dfn",
"div",
"dl",
"dt",
"em",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hgroup",
"hr",
"i",
"img",
"ins",
"kbd",
"li",
"map",
"mark",
"nav",
"ol",
"p",
"pre",
"q",
"rp",
"rt",
"rtc",
"ruby",
"s",
"samp",
"small",
"span",
"strike",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"th",
"thead",
"time",
"tr",
"tt",
"u",
"ul",
"var",
"wbr",
] { ] {
set.insert(tag); set.insert(tag);
} }
@ -34,10 +100,31 @@ fn clean_content_tags() -> HashSet<&'static str> {
fn default_allowed_schemes() -> HashSet<&'static str> { fn default_allowed_schemes() -> HashSet<&'static str> {
let mut set = HashSet::new(); let mut set = HashSet::new();
for scheme in [ for scheme in [
"bitcoin", "ftp", "ftps", "geo", "http", "https", "im", "irc", "bitcoin",
"ircs", "magnet", "mailto", "mms", "mx", "news", "nntp", "ftp",
"openpgp4fpr", "sip", "sms", "smsto", "ssh", "tel", "url", "ftps",
"webcal", "wtai", "xmpp", "geo",
"http",
"https",
"im",
"irc",
"ircs",
"magnet",
"mailto",
"mms",
"mx",
"news",
"nntp",
"openpgp4fpr",
"sip",
"sms",
"smsto",
"ssh",
"tel",
"url",
"webcal",
"wtai",
"xmpp",
] { ] {
set.insert(scheme); set.insert(scheme);
} }
@ -109,9 +196,17 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
("q", vec!["cite"]), ("q", vec!["cite"]),
("table", vec!["align", "char", "charoff", "summary"]), ("table", vec!["align", "char", "charoff", "summary"]),
("tbody", vec!["align", "char", "charoff"]), ("tbody", vec!["align", "char", "charoff"]),
("td", vec!["align", "char", "charoff", "colspan", "headers", "rowspan"]), (
"td",
vec!["align", "char", "charoff", "colspan", "headers", "rowspan"],
),
("tfoot", vec!["align", "char", "charoff"]), ("tfoot", vec!["align", "char", "charoff"]),
("th", vec!["align", "char", "charoff", "colspan", "headers", "rowspan", "scope"]), (
"th",
vec![
"align", "char", "charoff", "colspan", "headers", "rowspan", "scope",
],
),
("thead", vec!["align", "char", "charoff"]), ("thead", vec!["align", "char", "charoff"]),
("tr", vec!["align", "char", "charoff"]), ("tr", vec!["align", "char", "charoff"]),
]; ];

View File

@ -261,7 +261,11 @@ mod tests {
let uuid = "abc-123"; let uuid = "abc-123";
let ext = "jpg"; let ext = "jpg";
let file_name = format!("{}.{}.{}", now_str, uuid, ext); let file_name = format!("{}.{}.{}", now_str, uuid, ext);
assert!(!file_name.contains(' '), "filename should not contain spaces: got '{}'", file_name); assert!(
!file_name.contains(' '),
"filename should not contain spaces: got '{}'",
file_name
);
} }
#[test] #[test]
@ -297,5 +301,3 @@ mod tests {
assert!(loaded.is_ok()); assert!(loaded.is_ok());
} }
} }

View File

@ -36,8 +36,7 @@ fn main() {
output.push_str(&mocha_rewritten); output.push_str(&mocha_rewritten);
std::fs::create_dir_all("public").expect("Failed to create public/"); std::fs::create_dir_all("public").expect("Failed to create public/");
std::fs::write("public/highlight.css", output) std::fs::write("public/highlight.css", output).expect("Failed to write public/highlight.css");
.expect("Failed to write public/highlight.css");
println!("Generated public/highlight.css"); println!("Generated public/highlight.css");
} }

View File

@ -49,8 +49,6 @@ pub enum CacheKey {
PendingCommentCount, PendingCommentCount,
} }
// ============================================================================ // ============================================================================
// Cache Instances // Cache Instances
// ============================================================================ // ============================================================================
@ -153,12 +151,17 @@ pub async fn set_post_list(key: &CacheKey, posts: Vec<Post>, total: i64) {
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn get_total_published_posts() -> Option<i64> { pub async fn get_total_published_posts() -> Option<i64> {
POST_LIST_CACHE.get(&CacheKey::TotalPublishedPosts).await.map(|(_, total)| total) POST_LIST_CACHE
.get(&CacheKey::TotalPublishedPosts)
.await
.map(|(_, total)| total)
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn set_total_published_posts(total: i64) { pub async fn set_total_published_posts(total: i64) {
let _ = POST_LIST_CACHE.insert(CacheKey::TotalPublishedPosts, (vec![], total)).await; let _ = POST_LIST_CACHE
.insert(CacheKey::TotalPublishedPosts, (vec![], total))
.await;
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -330,16 +333,28 @@ mod tests {
#[test] #[test]
fn cache_key_equality() { fn cache_key_equality() {
let k1 = CacheKey::PublishedPosts { page: 1, per_page: 10 }; let k1 = CacheKey::PublishedPosts {
let k2 = CacheKey::PublishedPosts { page: 1, per_page: 10 }; page: 1,
let k3 = CacheKey::PublishedPosts { page: 2, per_page: 10 }; per_page: 10,
};
let k2 = CacheKey::PublishedPosts {
page: 1,
per_page: 10,
};
let k3 = CacheKey::PublishedPosts {
page: 2,
per_page: 10,
};
assert_eq!(k1, k2); assert_eq!(k1, k2);
assert_ne!(k1, k3); assert_ne!(k1, k3);
} }
#[tokio::test] #[tokio::test]
async fn post_list_cache_roundtrip() { async fn post_list_cache_roundtrip() {
let key = CacheKey::PublishedPosts { page: 999, per_page: 99 }; let key = CacheKey::PublishedPosts {
page: 999,
per_page: 99,
};
let posts = vec![]; let posts = vec![];
set_post_list(&key, posts.clone(), 0).await; set_post_list(&key, posts.clone(), 0).await;
@ -353,7 +368,11 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn tag_list_cache_roundtrip() { async fn tag_list_cache_roundtrip() {
let tags = vec![Tag { id: 1, name: "rust".to_string(), post_count: 5 }]; let tags = vec![Tag {
id: 1,
name: "rust".to_string(),
post_count: 5,
}];
set_tag_list(tags.clone()).await; set_tag_list(tags.clone()).await;
let cached = get_tag_list().await; let cached = get_tag_list().await;
@ -394,7 +413,11 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn post_stats_cache_roundtrip() { async fn post_stats_cache_roundtrip() {
let stats = PostStats { total: 10, drafts: 3, published: 7 }; let stats = PostStats {
total: 10,
drafts: 3,
published: 7,
};
set_post_stats(stats.clone()).await; set_post_stats(stats.clone()).await;
let cached = get_post_stats().await; let cached = get_post_stats().await;

View File

@ -73,7 +73,8 @@ pub fn AdminLayout() -> Element {
} }
}; };
let is_write_route = matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. }); let is_write_route =
matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
let main_class = if is_write_route { let main_class = if is_write_route {
"flex-1 w-full max-w-5xl mx-auto px-6 flex flex-col overflow-hidden" "flex-1 w-full max-w-5xl mx-auto px-6 flex flex-col overflow-hidden"
} else { } else {

View File

@ -38,5 +38,3 @@ pub fn AdminDashboardSkeleton() -> Element {
} }
} }
} }

View File

@ -2,7 +2,7 @@ use dioxus::prelude::*;
use crate::api::comments::create_comment; use crate::api::comments::create_comment;
use crate::components::comments::section::CommentContext; use crate::components::comments::section::CommentContext;
use crate::components::forms::{INPUT_CLASS, BUTTON_PRIMARY_CLASS, AlertBox}; use crate::components::forms::{AlertBox, BUTTON_PRIMARY_CLASS, INPUT_CLASS};
use crate::hooks::comment_storage::{self, PendingComment}; use crate::hooks::comment_storage::{self, PendingComment};
#[component] #[component]

View File

@ -1,8 +1,8 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::models::comment::PublicComment;
use crate::components::comments::section::CommentContext;
use crate::components::comments::form::CommentForm; use crate::components::comments::form::CommentForm;
use crate::components::comments::section::CommentContext;
use crate::models::comment::PublicComment;
#[component] #[component]
pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element { pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element {

View File

@ -1,9 +1,9 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::models::comment::PublicComment;
use crate::hooks::comment_storage::PendingComment;
use crate::components::comments::item::CommentItem; use crate::components::comments::item::CommentItem;
use crate::components::comments::pending_item::PendingCommentItem; use crate::components::comments::pending_item::PendingCommentItem;
use crate::hooks::comment_storage::PendingComment;
use crate::models::comment::PublicComment;
#[derive(Clone)] #[derive(Clone)]
enum MergedComment { enum MergedComment {
@ -23,10 +23,13 @@ fn merge_and_treeify(
.chain(pending.into_iter().map(MergedComment::Pending)) .chain(pending.into_iter().map(MergedComment::Pending))
.collect(); .collect();
let all_ids: HashSet<i64> = all.iter().map(|c| match c { let all_ids: HashSet<i64> = all
MergedComment::Approved(c) => c.id, .iter()
MergedComment::Pending(c) => c.id, .map(|c| match c {
}).collect(); MergedComment::Approved(c) => c.id,
MergedComment::Pending(c) => c.id,
})
.collect();
let mut children_map: HashMap<Option<i64>, Vec<MergedComment>> = HashMap::new(); let mut children_map: HashMap<Option<i64>, Vec<MergedComment>> = HashMap::new();
for comment in all { for comment in all {
@ -38,7 +41,10 @@ fn merge_and_treeify(
Some(pid) if !all_ids.contains(&pid) => None, Some(pid) if !all_ids.contains(&pid) => None,
_ => parent_id, _ => parent_id,
}; };
children_map.entry(effective_parent).or_default().push(comment); children_map
.entry(effective_parent)
.or_default()
.push(comment);
} }
for children in children_map.values_mut() { for children in children_map.values_mut() {

View File

@ -1,6 +1,6 @@
pub mod section;
pub mod form;
pub mod list;
pub mod item;
pub mod pending_item;
pub mod actions; pub mod actions;
pub mod form;
pub mod item;
pub mod list;
pub mod pending_item;
pub mod section;

View File

@ -1,11 +1,10 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::hooks::comment_storage::{PendingComment, render_pending_content}; use crate::hooks::comment_storage::{render_pending_content, PendingComment};
#[component] #[component]
#[allow(unused_variables)] #[allow(unused_variables)]
pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element { pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element {
let depth = if comment.parent_id.is_none() && comment.depth > 0 { let depth = if comment.parent_id.is_none() && comment.depth > 0 {
0 0
} else { } else {

View File

@ -13,7 +13,11 @@ pub fn FormInput(
oninput: EventHandler<String>, oninput: EventHandler<String>,
onkeydown: Option<EventHandler<KeyboardEvent>>, onkeydown: Option<EventHandler<KeyboardEvent>>,
) -> Element { ) -> Element {
let disabled_class = if disabled { "opacity-60 cursor-not-allowed" } else { "" }; let disabled_class = if disabled {
"opacity-60 cursor-not-allowed"
} else {
""
};
rsx! { rsx! {
input { input {
class: "{INPUT_CLASS} {disabled_class}", class: "{INPUT_CLASS} {disabled_class}",
@ -43,8 +47,14 @@ pub fn FormLabel(label: &'static str) -> Element {
#[component] #[component]
pub fn AlertBox(message: String, variant: &'static str) -> Element { pub fn AlertBox(message: String, variant: &'static str) -> Element {
let (bg_class, text_class) = match variant { let (bg_class, text_class) = match variant {
"error" => ("bg-red-100 dark:bg-red-900/30", "text-red-700 dark:text-red-300"), "error" => (
"success" => ("bg-green-100 dark:bg-green-900/30", "text-green-700 dark:text-green-300"), "bg-red-100 dark:bg-red-900/30",
"text-red-700 dark:text-red-300",
),
"success" => (
"bg-green-100 dark:bg-green-900/30",
"text-green-700 dark:text-green-300",
),
_ => ("bg-paper-code-bg", "text-paper-secondary"), _ => ("bg-paper-code-bg", "text-paper-secondary"),
}; };
rsx! { rsx! {

View File

@ -3,17 +3,18 @@ use dioxus::prelude::*;
#[component] #[component]
pub fn ImageViewer( pub fn ImageViewer(
src: String, src: String,
#[props(default = "?w=800".to_string())] #[props(default = "?w=800".to_string())] thumb_params: String,
thumb_params: String, #[props(default = "图片".to_string())] alt: String,
#[props(default = "图片".to_string())] #[props(default = false)] lazy_load: bool,
alt: String,
#[props(default = false)]
lazy_load: bool,
) -> Element { ) -> Element {
let mut is_open = use_signal(|| false); let mut is_open = use_signal(|| false);
let thumb_src = if src.contains('?') { let thumb_src = if src.contains('?') {
format!("{}&{}", src.split('?').next().unwrap_or(&src), thumb_params.trim_start_matches('?')) format!(
"{}&{}",
src.split('?').next().unwrap_or(&src),
thumb_params.trim_start_matches('?')
)
} else { } else {
format!("{}{}", src, thumb_params) format!("{}{}", src, thumb_params)
}; };

View File

@ -1,5 +1,5 @@
use dioxus::prelude::*;
use crate::components::skeletons::atoms::*; use crate::components::skeletons::atoms::*;
use dioxus::prelude::*;
/// 归档页骨架屏 /// 归档页骨架屏
/// 结构:统计行("共 N 篇文章") + 年份标题 + 月份标题 + 文章条目列表 /// 结构:统计行("共 N 篇文章") + 年份标题 + 月份标题 + 文章条目列表

View File

@ -1,5 +1,5 @@
use dioxus::prelude::*;
use crate::components::skeletons::atoms::*; use crate::components::skeletons::atoms::*;
use dioxus::prelude::*;
#[component] #[component]
pub fn CommentListSkeleton() -> Element { pub fn CommentListSkeleton() -> Element {

View File

@ -1,5 +1,5 @@
use dioxus::prelude::*;
use crate::utils::time::sleep_ms; use crate::utils::time::sleep_ms;
use dioxus::prelude::*;
/// 骨架屏 pulse 动画延迟(毫秒) /// 骨架屏 pulse 动画延迟(毫秒)
/// 加载时间低于此值时骨架屏只显示静态灰色块,避免 pulse 动画一闪而过 /// 加载时间低于此值时骨架屏只显示静态灰色块,避免 pulse 动画一闪而过

View File

@ -1,5 +1,5 @@
pub mod atoms;
pub mod archive_skeleton; pub mod archive_skeleton;
pub mod atoms;
pub mod comment_skeleton; pub mod comment_skeleton;
pub mod delayed_skeleton; pub mod delayed_skeleton;
pub mod home_skeleton; pub mod home_skeleton;

View File

@ -1,5 +1,5 @@
use dioxus::prelude::*;
use crate::components::skeletons::atoms::*; use crate::components::skeletons::atoms::*;
use dioxus::prelude::*;
/// 文章详情页骨架屏 /// 文章详情页骨架屏
/// 结构:面包屑 + 标题(h1) + 摘要 + 元信息 + 封面图 + 正文(多段) + Footer /// 结构:面包屑 + 标题(h1) + 摘要 + 元信息 + 封面图 + 正文(多段) + Footer

View File

@ -1,5 +1,5 @@
use dioxus::prelude::*;
use crate::components::skeletons::atoms::*; use crate::components::skeletons::atoms::*;
use dioxus::prelude::*;
#[component] #[component]
pub fn WriteSkeleton() -> Element { pub fn WriteSkeleton() -> Element {

View File

@ -16,10 +16,12 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg); let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg);
Pool::builder(mgr) Pool::builder(mgr)
.max_size(std::env::var("DB_POOL_SIZE") .max_size(
.ok() std::env::var("DB_POOL_SIZE")
.and_then(|s| s.parse().ok()) .ok()
.unwrap_or(20)) .and_then(|s| s.parse().ok())
.unwrap_or(20),
)
.build() .build()
.expect("Failed to create database connection pool") .expect("Failed to create database connection pool")
}); });
@ -34,7 +36,12 @@ pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::
Ok(conn) => return Ok(conn), Ok(conn) => return Ok(conn),
Err(e) => { Err(e) => {
if attempt < MAX_RETRIES { if attempt < MAX_RETRIES {
tracing::warn!("DB connection attempt {} failed, retrying in {:?}: {:?}", attempt + 1, RETRY_DELAY, e); tracing::warn!(
"DB connection attempt {} failed, retrying in {:?}: {:?}",
attempt + 1,
RETRY_DELAY,
e
);
tokio::time::sleep(RETRY_DELAY).await; tokio::time::sleep(RETRY_DELAY).await;
} }
last_err = Some(e); last_err = Some(e);

View File

@ -2,7 +2,7 @@
pub mod server { pub mod server {
use std::sync::LazyLock; use std::sync::LazyLock;
use syntect::html::{ClassedHTMLGenerator, ClassStyle}; use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::SyntaxSet; use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings; use syntect::util::LinesWithEndings;

View File

@ -1,5 +1,5 @@
use dioxus::prelude::*;
use crate::utils::time::sleep_ms; use crate::utils::time::sleep_ms;
use dioxus::prelude::*;
/// 骨架屏最小显示延迟(毫秒)。加载时间低于此值时不会显示骨架屏,避免闪烁。 /// 骨架屏最小显示延迟(毫秒)。加载时间低于此值时不会显示骨架屏,避免闪烁。
pub const MIN_SKELETON_DELAY_MS: u32 = 200; pub const MIN_SKELETON_DELAY_MS: u32 = 200;

View File

@ -1,2 +1,2 @@
pub mod delayed_loading;
pub mod comment_storage; pub mod comment_storage;
pub mod delayed_loading;

View File

@ -1,6 +1,6 @@
mod api; mod api;
mod cache;
mod auth; mod auth;
mod cache;
mod components; mod components;
mod context; mod context;
mod db; mod db;
@ -44,13 +44,14 @@ fn main() {
}); });
let config = ServeConfig::builder().incremental( let config = ServeConfig::builder().incremental(
dioxus::server::IncrementalRendererConfig::default() dioxus::server::IncrementalRendererConfig::default().invalidate_after(
.invalidate_after(std::time::Duration::from_secs( std::time::Duration::from_secs(
std::env::var("SSR_CACHE_SECS") std::env::var("SSR_CACHE_SECS")
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(3600), .unwrap_or(3600),
)), ),
),
); );
let api_routes = axum::Router::new().route( let api_routes = axum::Router::new().route(
"/api/upload", "/api/upload",
@ -66,9 +67,7 @@ fn main() {
let dioxus_app = let dioxus_app =
axum::Router::new().serve_dioxus_application(config, router::AppRouter); axum::Router::new().serve_dioxus_application(config, router::AppRouter);
let router = api_routes let router = api_routes.merge(static_routes).merge(dioxus_app);
.merge(static_routes)
.merge(dioxus_app);
Ok(router) Ok(router)
}); });

View File

@ -71,7 +71,9 @@ impl Post {
pub fn status_badge_class(&self) -> &'static str { pub fn status_badge_class(&self) -> &'static str {
match self.status { match self.status {
PostStatus::Published => "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300", PostStatus::Published => {
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
}
PostStatus::Draft => "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]", PostStatus::Draft => "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]",
} }
} }
@ -128,7 +130,10 @@ mod tests {
#[test] #[test]
fn post_status_from_str() { fn post_status_from_str() {
assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft)); assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
assert_eq!(PostStatus::from_str("published"), Some(PostStatus::Published)); assert_eq!(
PostStatus::from_str("published"),
Some(PostStatus::Published)
);
assert_eq!(PostStatus::from_str("unknown"), None); assert_eq!(PostStatus::from_str("unknown"), None);
assert_eq!(PostStatus::from_str(""), None); assert_eq!(PostStatus::from_str(""), None);
} }
@ -186,6 +191,9 @@ mod tests {
#[test] #[test]
fn post_status_serde_roundtrip() { fn post_status_serde_roundtrip() {
let json = serde_json::to_string(&PostStatus::Draft).unwrap(); let json = serde_json::to_string(&PostStatus::Draft).unwrap();
assert_eq!(serde_json::from_str::<PostStatus>(&json).unwrap(), PostStatus::Draft); assert_eq!(
serde_json::from_str::<PostStatus>(&json).unwrap(),
PostStatus::Draft
);
} }
} }

View File

@ -95,6 +95,9 @@ mod tests {
#[test] #[test]
fn user_role_serde_roundtrip() { fn user_role_serde_roundtrip() {
let json = serde_json::to_string(&UserRole::Admin).unwrap(); let json = serde_json::to_string(&UserRole::Admin).unwrap();
assert_eq!(serde_json::from_str::<UserRole>(&json).unwrap(), UserRole::Admin); assert_eq!(
serde_json::from_str::<UserRole>(&json).unwrap(),
UserRole::Admin
);
} }
} }

View File

@ -3,13 +3,11 @@ use std::collections::HashSet;
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
use crate::api::comments::{
approve_comment, batch_update_comment_status, spam_comment,
};
#[cfg(target_arch = "wasm32")]
use crate::api::comments::{get_all_comments, AllCommentsResponse};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::comments::trash_comment; 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::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::models::comment::{AdminComment, CommentStatus}; use crate::models::comment::{AdminComment, CommentStatus};
use crate::router::Route; use crate::router::Route;
@ -54,7 +52,11 @@ pub fn AdminCommentsPage(page: i32) -> Element {
#[allow(unused_variables)] #[allow(unused_variables)]
let filter_status = move || { let filter_status = move || {
let f = active_filter(); let f = active_filter();
if f.is_empty() { None } else { Some(f) } if f.is_empty() {
None
} else {
Some(f)
}
}; };
// 客户端CSR加载数据 // 客户端CSR加载数据
@ -313,14 +315,29 @@ fn CommentRow(
on_trash: EventHandler, on_trash: EventHandler,
) -> Element { ) -> Element {
let (badge_class, status_label) = match &comment.status { let (badge_class, status_label) = match &comment.status {
CommentStatus::Pending => ("bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", "待审核"), CommentStatus::Pending => (
CommentStatus::Approved => ("bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", "已通过"), "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
CommentStatus::Spam => ("bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", "垃圾"), "待审核",
CommentStatus::Trash => ("bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400", "已删除"), ),
CommentStatus::Approved => (
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
"已通过",
),
CommentStatus::Spam => (
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
"垃圾",
),
CommentStatus::Trash => (
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
"已删除",
),
}; };
let date_str = comment.created_at.format("%Y-%m-%d").to_string(); let date_str = comment.created_at.format("%Y-%m-%d").to_string();
let preview = if comment.content_md.len() > 100 { let preview = if comment.content_md.len() > 100 {
format!("{}...", &comment.content_md[..comment.content_md.ceil_char_boundary(100)]) format!(
"{}...",
&comment.content_md[..comment.content_md.ceil_char_boundary(100)]
)
} else { } else {
comment.content_md.clone() comment.content_md.clone()
}; };
@ -411,9 +428,13 @@ fn CommentsPagination(current_page: i32, total: i64) -> Element {
let prev_route = if current_page - 1 <= 1 { let prev_route = if current_page - 1 <= 1 {
Route::AdminComments {} Route::AdminComments {}
} else { } else {
Route::AdminCommentsPage { page: current_page - 1 } Route::AdminCommentsPage {
page: current_page - 1,
}
};
let next_route = Route::AdminCommentsPage {
page: current_page + 1,
}; };
let next_route = Route::AdminCommentsPage { page: current_page + 1 };
rsx! { rsx! {
nav { class: "flex mt-6 justify-between", nav { class: "flex mt-6 justify-between",

View File

@ -37,7 +37,10 @@ pub fn PostsPage(page: i32) -> Element {
let p = current_page; let p = current_page;
spawn(async move { spawn(async move {
match list_posts(p, POSTS_PER_PAGE).await { match list_posts(p, POSTS_PER_PAGE).await {
Ok(PostListResponse { posts: list, total: t }) => { Ok(PostListResponse {
posts: list,
total: t,
}) => {
posts.set(list); posts.set(list);
total.set(t); total.set(t);
} }

View File

@ -4,7 +4,9 @@ use dioxus::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::posts::{create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse}; use crate::api::posts::{
create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
};
use crate::components::write_skeleton::WriteSkeleton; use crate::components::write_skeleton::WriteSkeleton;
use crate::models::post::Post; use crate::models::post::Post;
use crate::router::Route; use crate::router::Route;
@ -63,21 +65,21 @@ fn write_editor(post_id: Option<i32>) -> Element {
if is_edit { if is_edit {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
if let Some(id) = post_id { if let Some(id) = post_id {
spawn(async move { spawn(async move {
match get_post_by_id(id).await { match get_post_by_id(id).await {
Ok(SinglePostResponse { post: Some(post) }) => { Ok(SinglePostResponse { post: Some(post) }) => {
edit_post.set(Some(post)); edit_post.set(Some(post));
}
Ok(SinglePostResponse { post: None }) => {
load_error.set(Some("文章不存在".to_string()));
}
Err(e) => {
load_error.set(Some(format!("加载失败: {}", e)));
}
} }
Ok(SinglePostResponse { post: None }) => { });
load_error.set(Some("文章不存在".to_string())); }
}
Err(e) => {
load_error.set(Some(format!("加载失败: {}", e)));
}
}
});
} }
}
}); });
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]

View File

@ -2,8 +2,8 @@ use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
use crate::api::posts::{list_published_posts, PostListResponse}; use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::archive_skeleton::ArchiveSkeleton; use crate::components::skeletons::archive_skeleton::ArchiveSkeleton;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::models::post::Post; use crate::models::post::Post;
use crate::router::Route; use crate::router::Route;

View File

@ -4,7 +4,7 @@ use dioxus::router::components::Link;
use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse}; use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
use crate::components::post_card::PostCard; use crate::components::post_card::PostCard;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::tags_skeleton::{TagsSkeleton, TagDetailSkeleton}; use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton};
use crate::router::Route; use crate::router::Route;
#[component] #[component]

View File

@ -5,7 +5,9 @@ use crate::components::admin_layout::AdminLayout;
use crate::components::frontend_layout::FrontendLayout; use crate::components::frontend_layout::FrontendLayout;
use crate::context::UserContext; use crate::context::UserContext;
use crate::pages::about::About; use crate::pages::about::About;
use crate::pages::admin::{Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit}; use crate::pages::admin::{
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit,
};
use crate::pages::archives::Archives; use crate::pages::archives::Archives;
use crate::pages::home::{Home, HomePage}; use crate::pages::home::{Home, HomePage};
use crate::pages::login::Login; use crate::pages::login::Login;

View File

@ -1,3 +1,3 @@
pub mod time;
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod text; pub mod text;
pub mod time;

View File

@ -1,28 +1,21 @@
use std::sync::LazyLock; use std::sync::LazyLock;
static CODE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| { static CODE_BLOCK_RE: LazyLock<regex::Regex> =
regex::Regex::new(r"```[\s\S]*?```").unwrap() LazyLock::new(|| regex::Regex::new(r"```[\s\S]*?```").unwrap());
});
static INLINE_CODE_RE: LazyLock<regex::Regex> = LazyLock::new(|| { static INLINE_CODE_RE: LazyLock<regex::Regex> =
regex::Regex::new(r"`[^`]*`").unwrap() LazyLock::new(|| regex::Regex::new(r"`[^`]*`").unwrap());
});
static LINK_RE: LazyLock<regex::Regex> = LazyLock::new(|| { static LINK_RE: LazyLock<regex::Regex> =
regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").unwrap() LazyLock::new(|| regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").unwrap());
});
static HEADING_RE: LazyLock<regex::Regex> = LazyLock::new(|| { static HEADING_RE: LazyLock<regex::Regex> =
regex::Regex::new(r"^#{1,6}\s*").unwrap() LazyLock::new(|| regex::Regex::new(r"^#{1,6}\s*").unwrap());
});
static IMAGE_RE: LazyLock<regex::Regex> = LazyLock::new(|| { static IMAGE_RE: LazyLock<regex::Regex> =
regex::Regex::new(r"!\[([^\]]*)\]\([^)]*\)").unwrap() LazyLock::new(|| regex::Regex::new(r"!\[([^\]]*)\]\([^)]*\)").unwrap());
});
static WHITESPACE_RE: LazyLock<regex::Regex> = LazyLock::new(|| { static WHITESPACE_RE: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new(r"\s+").unwrap());
regex::Regex::new(r"\s+").unwrap()
});
pub fn strip_markdown(md: &str) -> String { pub fn strip_markdown(md: &str) -> String {
let mut plain = CODE_BLOCK_RE.replace_all(md, "").to_string(); let mut plain = CODE_BLOCK_RE.replace_all(md, "").to_string();
@ -88,7 +81,10 @@ mod tests {
#[test] #[test]
fn strip_markdown_keeps_link_text() { fn strip_markdown_keeps_link_text() {
assert_eq!(strip_markdown("[click me](https://example.com)"), "click me"); assert_eq!(
strip_markdown("[click me](https://example.com)"),
"click me"
);
} }
#[test] #[test]
@ -98,7 +94,10 @@ mod tests {
#[test] #[test]
fn strip_markdown_removes_bold_and_italic() { fn strip_markdown_removes_bold_and_italic() {
assert_eq!(strip_markdown("**bold** *italic* __bold__ _italic_"), "bold italic bold italic"); assert_eq!(
strip_markdown("**bold** *italic* __bold__ _italic_"),
"bold italic bold italic"
);
} }
#[test] #[test]

View File

@ -178,7 +178,10 @@ mod tests {
// Note: LazyLock initializes once, so we can't easily test // Note: LazyLock initializes once, so we can't easily test
// different env values in the same process. // different env values in the same process.
// Test that the default config is reasonable // Test that the default config is reasonable
let config = WebpConfig { quality: 85.0, method: 2 }; let config = WebpConfig {
quality: 85.0,
method: 2,
};
assert!(config.quality >= 0.0 && config.quality <= 100.0); assert!(config.quality >= 0.0 && config.quality <= 100.0);
assert!(config.method <= 6); assert!(config.method <= 6);
} }