fix(comment): harden comment form with server honeypot, a11y labels, and reply layout
四项评论区输入框修复: 1. 蜜罐服务端二次校验 - helpers.rs 新增 validate_comment_honeypot 校验器 + 单元测试 - create_comment 加 honeypot 参数,在限流后做服务端校验 - 即便机器人禁用 JS 绕过前端拦截,服务端仍返回 spam_detected 错误 - 与 rate limit 共同构成纵深防御 2. label/input 关联 (可访问性) - 四个字段补 id + r#for 属性,用 id_suffix(顶层 root/回复用 parent_id)保证页面唯一 - textarea 原本无 label,补上"内容 *"label 3. 提交按钮右对齐 + 宽度自适应 - 新常量 COMMENT_SUBMIT_CLASS 去掉 w-full、px-4 改为 px-6 - 外层包 flex justify-end,按钮跟随文字宽度 - login/register 的全宽按钮保持不变(模态卡片场景) 4. 深层回复表单回到内容区左边缘 - CommentForm 新增 parent_indent prop,回复时用负 margin 抵消父评论缩进 - 避免 depth 越深表单被越挤越右 验证: cargo test (376 passed) / cargo clippy --all-targets (无警告) / dx check (无问题)
This commit is contained in:
parent
694e198331
commit
10ef52bede
@ -21,12 +21,13 @@ pub async fn create_comment(
|
|||||||
author_email: String,
|
author_email: String,
|
||||||
author_url: Option<String>,
|
author_url: Option<String>,
|
||||||
content_md: String,
|
content_md: String,
|
||||||
|
honeypot: String,
|
||||||
) -> Result<CommentResponse, ServerFnError> {
|
) -> Result<CommentResponse, ServerFnError> {
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
use crate::api::comments::helpers::{
|
use crate::api::comments::helpers::{
|
||||||
compute_content_hash, validate_comment_content, validate_comment_email,
|
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::api::error::AppError;
|
||||||
use crate::cache;
|
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) {
|
if let Err(e) = validate_comment_name(&author_name) {
|
||||||
return Ok(CommentResponse {
|
return Ok(CommentResponse {
|
||||||
|
|||||||
@ -158,6 +158,19 @@ pub fn validate_comment_content(content: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
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")]
|
#[cfg(feature = "server")]
|
||||||
pub fn compute_content_hash(
|
pub fn compute_content_hash(
|
||||||
@ -400,4 +413,15 @@ mod tests {
|
|||||||
assert_eq!(h.len(), 64);
|
assert_eq!(h.len(), 64);
|
||||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,21 +6,32 @@ 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::{AlertBox, BUTTON_PRIMARY_CLASS, INPUT_CLASS};
|
use crate::components::forms::{AlertBox, INPUT_CLASS};
|
||||||
use crate::hooks::comment_storage::{self, PendingComment};
|
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:
|
/// Props:
|
||||||
/// - `post_id`:所属文章 ID
|
/// - `post_id`:所属文章 ID
|
||||||
/// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论
|
/// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论
|
||||||
|
/// - `parent_indent`:回复时父评论的缩进像素值,用于用负 margin 把表单拉回内容区左边缘
|
||||||
///
|
///
|
||||||
/// 关键事件:
|
/// 关键事件:
|
||||||
/// - 挂载时从本地存储恢复上次填写的作者信息
|
/// - 挂载时从本地存储恢复上次填写的作者信息
|
||||||
/// - 提交时校验必填项与蜜罐字段
|
/// - 提交时校验必填项与蜜罐字段
|
||||||
/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
|
/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> 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;
|
||||||
@ -57,9 +68,22 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
|||||||
|
|
||||||
let is_reply = parent_id.is_some();
|
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! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" },
|
class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" },
|
||||||
|
style: "{negative_margin}",
|
||||||
role: "form",
|
role: "form",
|
||||||
aria_label: if is_reply { "回复评论" } else { "发表评论" },
|
aria_label: if is_reply { "回复评论" } else { "发表评论" },
|
||||||
|
|
||||||
@ -72,10 +96,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
|||||||
div { class: "space-y-3",
|
div { class: "space-y-3",
|
||||||
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
||||||
div {
|
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 {
|
input {
|
||||||
|
id: "comment-name-{id_suffix}",
|
||||||
class: INPUT_CLASS,
|
class: INPUT_CLASS,
|
||||||
r#type: "text",
|
r#type: "text",
|
||||||
placeholder: "你的昵称",
|
placeholder: "你的昵称",
|
||||||
@ -85,10 +112,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
div {
|
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 {
|
input {
|
||||||
|
id: "comment-email-{id_suffix}",
|
||||||
class: INPUT_CLASS,
|
class: INPUT_CLASS,
|
||||||
r#type: "email",
|
r#type: "email",
|
||||||
placeholder: "your@email.com",
|
placeholder: "your@email.com",
|
||||||
@ -99,10 +129,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
div {
|
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 {
|
input {
|
||||||
|
id: "comment-url-{id_suffix}",
|
||||||
class: INPUT_CLASS,
|
class: INPUT_CLASS,
|
||||||
r#type: "url",
|
r#type: "url",
|
||||||
placeholder: "https://example.com(可选)",
|
placeholder: "https://example.com(可选)",
|
||||||
@ -112,11 +145,19 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
div {
|
||||||
class: "{INPUT_CLASS} min-h-[100px] resize-y",
|
label {
|
||||||
value: "{content_md}",
|
r#for: "comment-content-{id_suffix}",
|
||||||
disabled: submitting(),
|
class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||||
oninput: move |e| content_md.set(e.value()),
|
"内容 *"
|
||||||
|
}
|
||||||
|
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",
|
p { class: "text-xs text-paper-tertiary",
|
||||||
@ -132,100 +173,103 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
|||||||
oninput: move |e| honeypot.set(e.value()),
|
oninput: move |e| honeypot.set(e.value()),
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
div { class: "flex justify-end",
|
||||||
class: BUTTON_PRIMARY_CLASS,
|
button {
|
||||||
disabled: submitting(),
|
class: COMMENT_SUBMIT_CLASS,
|
||||||
onclick: move |_| {
|
disabled: submitting(),
|
||||||
if submitting() {
|
onclick: move |_| {
|
||||||
return;
|
if submitting() {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let post_id = post_id;
|
let post_id = post_id;
|
||||||
let parent_id = parent_id;
|
let parent_id = parent_id;
|
||||||
let name = author_name();
|
let name = author_name();
|
||||||
let email = author_email();
|
let email = author_email();
|
||||||
let url_val = author_url();
|
let url_val = author_url();
|
||||||
let content = content_md();
|
let content = content_md();
|
||||||
let hp = honeypot();
|
let hp = honeypot();
|
||||||
|
|
||||||
// 蜜罐被填充则直接丢弃
|
// 蜜罐被填充则直接丢弃
|
||||||
if !hp.is_empty() {
|
if !hp.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() {
|
if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() {
|
||||||
message.set(Some(("请填写所有必填项".to_string(), "error")));
|
message.set(Some(("请填写所有必填项".to_string(), "error")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
submitting.set(true);
|
submitting.set(true);
|
||||||
message.set(None);
|
message.set(None);
|
||||||
|
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
let result = create_comment(
|
let result = create_comment(
|
||||||
post_id,
|
post_id,
|
||||||
parent_id,
|
parent_id,
|
||||||
name.clone(),
|
name.clone(),
|
||||||
email.clone(),
|
email.clone(),
|
||||||
if url_val.trim().is_empty() { None } else { Some(url_val.clone()) },
|
if url_val.trim().is_empty() { None } else { Some(url_val.clone()) },
|
||||||
content.clone(),
|
content.clone(),
|
||||||
).await;
|
hp.clone(),
|
||||||
|
).await;
|
||||||
|
|
||||||
submitting.set(false);
|
submitting.set(false);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if resp.success {
|
if resp.success {
|
||||||
comment_storage::save_author(
|
comment_storage::save_author(
|
||||||
&name,
|
&name,
|
||||||
&email,
|
&email,
|
||||||
&url_val,
|
&url_val,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(comment_id) = resp.comment_id {
|
if let Some(comment_id) = resp.comment_id {
|
||||||
let avatar_url = resp.avatar_url.unwrap_or_default();
|
let avatar_url = resp.avatar_url.unwrap_or_default();
|
||||||
let depth = resp.depth.unwrap_or(0);
|
let depth = resp.depth.unwrap_or(0);
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let pending = PendingComment {
|
let pending = PendingComment {
|
||||||
id: comment_id,
|
id: comment_id,
|
||||||
parent_id,
|
parent_id,
|
||||||
depth,
|
depth,
|
||||||
author_name: name.clone(),
|
author_name: name.clone(),
|
||||||
author_url: if url_val.trim().is_empty() { None } else { Some(url_val) },
|
author_url: if url_val.trim().is_empty() { None } else { Some(url_val) },
|
||||||
avatar_url,
|
avatar_url,
|
||||||
content_md: content,
|
content_md: content,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
stored_at: now,
|
stored_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
comment_storage::save_pending_comment(post_id, pending.clone());
|
comment_storage::save_pending_comment(post_id, pending.clone());
|
||||||
pending_comments.write().push(pending);
|
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());
|
Err(_) => {
|
||||||
message.set(Some((resp.message, "success")));
|
message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
|
||||||
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")));
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
if submitting() {
|
if submitting() {
|
||||||
"提交中…"
|
"提交中…"
|
||||||
} else if is_reply {
|
} else if is_reply {
|
||||||
"回复"
|
"回复"
|
||||||
} else {
|
} else {
|
||||||
"发表评论"
|
"发表评论"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,7 +104,7 @@ pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if is_replying {
|
if is_replying {
|
||||||
CommentForm { post_id, parent_id: Some(comment.id) }
|
CommentForm { post_id, parent_id: Some(comment.id), parent_indent: Some(indent) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ pub fn CommentSection(post_id: i32) -> Element {
|
|||||||
"评论区 ({total_count})"
|
"评论区 ({total_count})"
|
||||||
}
|
}
|
||||||
|
|
||||||
CommentForm { post_id, parent_id: None }
|
CommentForm { post_id, parent_id: None, parent_indent: None }
|
||||||
|
|
||||||
if !has_any {
|
if !has_any {
|
||||||
p { class: "text-paper-tertiary text-center py-8",
|
p { class: "text-paper-tertiary text-center py-8",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user