diff --git a/src/api/comments/create.rs b/src/api/comments/create.rs index bee1e11..6f9300d 100644 --- a/src/api/comments/create.rs +++ b/src/api/comments/create.rs @@ -21,12 +21,13 @@ pub async fn create_comment( author_email: String, author_url: Option, content_md: String, + honeypot: String, ) -> Result { #[cfg(feature = "server")] { use crate::api::comments::helpers::{ compute_content_hash, validate_comment_content, validate_comment_email, - validate_comment_name, validate_comment_url, + validate_comment_honeypot, validate_comment_name, validate_comment_url, }; use crate::api::error::AppError; use crate::cache; @@ -48,6 +49,18 @@ pub async fn create_comment( } } + // 蜜罐字段二次校验:禁用 JS 的机器人可能绕过前端拦截,这里作为服务端防线。 + if let Err(e) = validate_comment_honeypot(&honeypot) { + return Ok(CommentResponse { + success: false, + message: e, + error_code: Some("spam_detected".into()), + comment_id: None, + avatar_url: None, + depth: None, + }); + } + // 依次校验昵称、邮箱、网址与评论内容。 if let Err(e) = validate_comment_name(&author_name) { return Ok(CommentResponse { diff --git a/src/api/comments/helpers.rs b/src/api/comments/helpers.rs index fc3e548..a484a6a 100644 --- a/src/api/comments/helpers.rs +++ b/src/api/comments/helpers.rs @@ -158,6 +158,19 @@ pub fn validate_comment_content(content: &str) -> Result<(), String> { Ok(()) } +/// 校验蜜罐字段:正常用户蜜罐为空,机器人可能填入任意内容。 +/// +/// 为空视为通过;一旦被填入内容即判定为机器人提交并拒绝。 +/// 这是禁用 JS / 无视前端校验的机器人也能在服务端被拦下的防线。 +#[cfg(feature = "server")] +pub fn validate_comment_honeypot(value: &str) -> Result<(), String> { + if value.is_empty() { + Ok(()) + } else { + Err("评论提交异常".to_string()) + } +} + /// 计算评论内容哈希,用于检测短时间内的重复提交。 #[cfg(feature = "server")] pub fn compute_content_hash( @@ -400,4 +413,15 @@ mod tests { assert_eq!(h.len(), 64); assert!(h.chars().all(|c| c.is_ascii_hexdigit())); } + + #[test] + fn validate_comment_honeypot_empty_is_ok() { + assert!(validate_comment_honeypot("").is_ok()); + } + + #[test] + fn validate_comment_honeypot_filled_is_err() { + assert!(validate_comment_honeypot("anything").is_err()); + assert!(validate_comment_honeypot(" ").is_err()); + } } diff --git a/src/components/comments/form.rs b/src/components/comments/form.rs index 2011a90..ef5a2a8 100644 --- a/src/components/comments/form.rs +++ b/src/components/comments/form.rs @@ -6,21 +6,32 @@ use dioxus::prelude::*; use crate::api::comments::create_comment; use crate::components::comments::section::CommentContext; -use crate::components::forms::{AlertBox, BUTTON_PRIMARY_CLASS, INPUT_CLASS}; +use crate::components::forms::{AlertBox, INPUT_CLASS}; use crate::hooks::comment_storage::{self, PendingComment}; +/// 评论提交按钮样式:去掉全宽,改为内联宽度并右对齐。 +/// +/// 与 `BUTTON_PRIMARY_CLASS` 视觉一致,但不含 `w-full`,并把 `px-4` 加宽为 `px-6`, +/// 使按钮宽度跟随文字、更适合文章页内联场景。 +const COMMENT_SUBMIT_CLASS: &str = "py-2.5 px-6 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer"; + /// 评论表单组件,用于顶层评论或回复评论。 /// /// Props: /// - `post_id`:所属文章 ID /// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论 +/// - `parent_indent`:回复时父评论的缩进像素值,用于用负 margin 把表单拉回内容区左边缘 /// /// 关键事件: /// - 挂载时从本地存储恢复上次填写的作者信息 /// - 提交时校验必填项与蜜罐字段 /// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新 #[component] -pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { +pub fn CommentForm( + post_id: i32, + parent_id: Option, + parent_indent: Option, +) -> Element { let ctx: CommentContext = use_context(); let mut active_reply = ctx.active_reply; let mut refresh_trigger = ctx.refresh_trigger; @@ -57,9 +68,22 @@ pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { let is_reply = parent_id.is_some(); + // 用于区分顶层表单与多个回复表单的 id 后缀,保证页面内 label/for 关联唯一。 + let id_suffix = match parent_id { + Some(pid) => pid.to_string(), + None => "root".to_string(), + }; + + // 回复表单抵消父评论缩进,让表单回到内容区左边缘,避免深层回复时被越挤越右。 + let negative_margin = match (is_reply, parent_indent) { + (true, Some(px)) if px > 0 => format!("margin-left: -{px}px;"), + _ => String::new(), + }; + rsx! { div { class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" }, + style: "{negative_margin}", role: "form", aria_label: if is_reply { "回复评论" } else { "发表评论" }, @@ -72,10 +96,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { div { class: "space-y-3", div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3", div { - label { class: "block text-sm font-medium text-paper-secondary mb-1", + label { + r#for: "comment-name-{id_suffix}", + class: "block text-sm font-medium text-paper-secondary mb-1", "昵称 *" } input { + id: "comment-name-{id_suffix}", class: INPUT_CLASS, r#type: "text", placeholder: "你的昵称", @@ -85,10 +112,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { } } div { - label { class: "block text-sm font-medium text-paper-secondary mb-1", + label { + r#for: "comment-email-{id_suffix}", + class: "block text-sm font-medium text-paper-secondary mb-1", "邮箱 *" } input { + id: "comment-email-{id_suffix}", class: INPUT_CLASS, r#type: "email", placeholder: "your@email.com", @@ -99,10 +129,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { } } div { - label { class: "block text-sm font-medium text-paper-secondary mb-1", + label { + r#for: "comment-url-{id_suffix}", + class: "block text-sm font-medium text-paper-secondary mb-1", "网站" } input { + id: "comment-url-{id_suffix}", class: INPUT_CLASS, r#type: "url", placeholder: "https://example.com(可选)", @@ -112,11 +145,19 @@ pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { } } - textarea { - class: "{INPUT_CLASS} min-h-[100px] resize-y", - value: "{content_md}", - disabled: submitting(), - oninput: move |e| content_md.set(e.value()), + div { + label { + r#for: "comment-content-{id_suffix}", + class: "block text-sm font-medium text-paper-secondary mb-1", + "内容 *" + } + textarea { + id: "comment-content-{id_suffix}", + class: "{INPUT_CLASS} min-h-[100px] resize-y", + value: "{content_md}", + disabled: submitting(), + oninput: move |e| content_md.set(e.value()), + } } p { class: "text-xs text-paper-tertiary", @@ -132,100 +173,103 @@ pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { oninput: move |e| honeypot.set(e.value()), } - button { - class: BUTTON_PRIMARY_CLASS, - disabled: submitting(), - onclick: move |_| { - if submitting() { - return; - } + div { class: "flex justify-end", + button { + class: COMMENT_SUBMIT_CLASS, + disabled: submitting(), + onclick: move |_| { + if submitting() { + return; + } - let post_id = post_id; - let parent_id = parent_id; - let name = author_name(); - let email = author_email(); - let url_val = author_url(); - let content = content_md(); - let hp = honeypot(); + let post_id = post_id; + let parent_id = parent_id; + let name = author_name(); + let email = author_email(); + let url_val = author_url(); + let content = content_md(); + let hp = honeypot(); - // 蜜罐被填充则直接丢弃 - if !hp.is_empty() { - return; - } + // 蜜罐被填充则直接丢弃 + if !hp.is_empty() { + return; + } - if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() { - message.set(Some(("请填写所有必填项".to_string(), "error"))); - return; - } + if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() { + message.set(Some(("请填写所有必填项".to_string(), "error"))); + return; + } - submitting.set(true); - message.set(None); + submitting.set(true); + message.set(None); - spawn(async move { - let result = create_comment( - post_id, - parent_id, - name.clone(), - email.clone(), - if url_val.trim().is_empty() { None } else { Some(url_val.clone()) }, - content.clone(), - ).await; + spawn(async move { + let result = create_comment( + post_id, + parent_id, + name.clone(), + email.clone(), + if url_val.trim().is_empty() { None } else { Some(url_val.clone()) }, + content.clone(), + hp.clone(), + ).await; - submitting.set(false); + submitting.set(false); - match result { - Ok(resp) => { - if resp.success { - comment_storage::save_author( - &name, - &email, - &url_val, - ); + match result { + Ok(resp) => { + if resp.success { + comment_storage::save_author( + &name, + &email, + &url_val, + ); - if let Some(comment_id) = resp.comment_id { - let avatar_url = resp.avatar_url.unwrap_or_default(); - let depth = resp.depth.unwrap_or(0); + if let Some(comment_id) = resp.comment_id { + let avatar_url = resp.avatar_url.unwrap_or_default(); + let depth = resp.depth.unwrap_or(0); - let now = chrono::Utc::now().to_rfc3339(); - let pending = PendingComment { - id: comment_id, - parent_id, - depth, - author_name: name.clone(), - author_url: if url_val.trim().is_empty() { None } else { Some(url_val) }, - avatar_url, - content_md: content, - created_at: now.clone(), - stored_at: now, - }; + let now = chrono::Utc::now().to_rfc3339(); + let pending = PendingComment { + id: comment_id, + parent_id, + depth, + author_name: name.clone(), + author_url: if url_val.trim().is_empty() { None } else { Some(url_val) }, + avatar_url, + content_md: content, + created_at: now.clone(), + stored_at: now, + }; - comment_storage::save_pending_comment(post_id, pending.clone()); - pending_comments.write().push(pending); + comment_storage::save_pending_comment(post_id, pending.clone()); + pending_comments.write().push(pending); + } + + content_md.set(String::new()); + message.set(Some((resp.message, "success"))); + if parent_id.is_some() { + active_reply.set(None); + } + refresh_trigger.set(!refresh_trigger()); + } else { + message.set(Some((resp.message, "error"))); } - - content_md.set(String::new()); - message.set(Some((resp.message, "success"))); - if parent_id.is_some() { - active_reply.set(None); - } - refresh_trigger.set(!refresh_trigger()); - } else { - message.set(Some((resp.message, "error"))); + } + Err(_) => { + message.set(Some(("提交失败,请稍后重试".to_string(), "error"))); } } - Err(_) => { - message.set(Some(("提交失败,请稍后重试".to_string(), "error"))); - } - } - }); - }, + }); + }, - if submitting() { - "提交中…" - } else if is_reply { - "回复" - } else { - "发表评论" + if submitting() { + "提交中…" + } else if is_reply { + "回复" + } else { + "发表评论" + } } } } diff --git a/src/components/comments/item.rs b/src/components/comments/item.rs index a2ea1aa..29a38ef 100644 --- a/src/components/comments/item.rs +++ b/src/components/comments/item.rs @@ -104,7 +104,7 @@ pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element { } if is_replying { - CommentForm { post_id, parent_id: Some(comment.id) } + CommentForm { post_id, parent_id: Some(comment.id), parent_indent: Some(indent) } } } } diff --git a/src/components/comments/section.rs b/src/components/comments/section.rs index a300047..87491ba 100644 --- a/src/components/comments/section.rs +++ b/src/components/comments/section.rs @@ -98,7 +98,7 @@ pub fn CommentSection(post_id: i32) -> Element { "评论区 ({total_count})" } - CommentForm { post_id, parent_id: None } + CommentForm { post_id, parent_id: None, parent_indent: None } if !has_any { p { class: "text-paper-tertiary text-center py-8",