diff --git a/Cargo.lock b/Cargo.lock index 5eedc83..d02d44a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2690,6 +2690,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "md-5" version = "0.11.0" @@ -3137,7 +3147,7 @@ dependencies = [ "bytes", "fallible-iterator", "hmac", - "md-5", + "md-5 0.11.0", "memchr", "rand 0.10.1", "sha2 0.11.0", @@ -5358,6 +5368,7 @@ dependencies = [ "http", "image", "js-sys", + "md-5 0.10.6", "moka", "pulldown-cmark", "rand 0.8.6", diff --git a/Cargo.toml b/Cargo.toml index 59d81cd..42f2570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ image = { version = "0.25", optional = true, default-features = false, features zenwebp = { version = "0.3", optional = true } moka = { version = "0.12", features = ["future"], optional = true } governor = { version = "0.8", optional = true } +md-5 = { version = "0.10", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] } @@ -69,4 +70,5 @@ server = [ "dep:zenwebp", "dep:moka", "dep:governor", + "dep:md-5", ] diff --git a/migrations/004_comments.sql b/migrations/004_comments.sql new file mode 100644 index 0000000..d58ef13 --- /dev/null +++ b/migrations/004_comments.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS comments ( + id BIGSERIAL PRIMARY KEY, + post_id INT NOT NULL REFERENCES posts(id) ON DELETE RESTRICT, + parent_id BIGINT REFERENCES comments(id) ON DELETE SET NULL, + depth INT NOT NULL DEFAULT 0, + author_name VARCHAR(50) NOT NULL, + author_email VARCHAR(255) NOT NULL, + author_url VARCHAR(500), + content_md TEXT NOT NULL, + content_html TEXT, + content_hash VARCHAR(64), + status TEXT NOT NULL DEFAULT 'pending', + ip_address VARCHAR(45), + user_agent VARCHAR(500), + consented_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + CONSTRAINT comments_status_check + CHECK (status IN ('pending', 'approved', 'spam', 'trash')), + CONSTRAINT comments_depth_check + CHECK (depth >= 0 AND depth <= 20), + CONSTRAINT comments_content_not_empty + CHECK (length(trim(content_md)) >= 1), + CONSTRAINT comments_name_not_empty + CHECK (length(trim(author_name)) >= 1) +); + +CREATE INDEX IF NOT EXISTS idx_comments_post_approved + ON comments(post_id, created_at) WHERE status = 'approved' AND deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_comments_top_level + ON comments(post_id, created_at) + WHERE parent_id IS NULL AND status = 'approved' AND deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_comments_pending + ON comments(created_at DESC) WHERE status = 'pending' AND deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_comments_admin_list + ON comments(status, created_at DESC) WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_comments_parent + ON comments(parent_id) WHERE parent_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION update_comments_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_comments_updated_at + BEFORE UPDATE ON comments + FOR EACH ROW + EXECUTE FUNCTION update_comments_updated_at(); diff --git a/src/api/auth.rs b/src/api/auth.rs index 782eeae..a914eef 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -326,6 +326,22 @@ pub async fn get_current_user() -> Result { Ok(CurrentUserResponse { user }) } +#[cfg(feature = "server")] +pub async fn get_current_admin_user() -> Result { + let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; + + let user = get_user_by_token(&token) + .await + .map_err(AppError::query)? + .ok_or(AppError::Unauthorized("会话已过期"))?; + + if user.role != UserRole::Admin { + return Err(AppError::Forbidden("权限不足")); + } + + Ok(user) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/comments/create.rs b/src/api/comments/create.rs new file mode 100644 index 0000000..7cb5db2 --- /dev/null +++ b/src/api/comments/create.rs @@ -0,0 +1,234 @@ +use dioxus::prelude::*; +use crate::api::comments::types::*; + +#[server(CreateComment, "/api")] +pub async fn create_comment( + post_id: i32, + parent_id: Option, + author_name: String, + author_email: String, + author_url: Option, + content_md: String, + consented: bool, +) -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + 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() { + let parts = ctx.parts_mut(); + let ip = crate::api::rate_limit::get_client_ip(&parts.headers); + if let Err(msg) = crate::api::rate_limit::check_comment_limit(&ip) { + return Ok(CommentResponse { + success: false, + message: msg, + error_code: Some("rate_limited".into()), + }); + } + } + + if !consented { + return Ok(CommentResponse { + success: false, + message: "请同意隐私政策".to_string(), + error_code: Some("invalid_input".into()), + }); + } + + if let Err(e) = validate_comment_name(&author_name) { + return Ok(CommentResponse { + success: false, + message: e, + error_code: Some("invalid_input".into()), + }); + } + if let Err(e) = validate_comment_email(&author_email) { + return Ok(CommentResponse { + success: false, + message: e, + error_code: Some("invalid_input".into()), + }); + } + if let Some(ref url) = author_url { + if let Err(e) = validate_comment_url(url) { + return Ok(CommentResponse { + success: false, + message: e, + error_code: Some("invalid_input".into()), + }); + } + } + if let Err(e) = validate_comment_content(&content_md) { + return Ok(CommentResponse { + success: false, + message: e, + error_code: Some("invalid_input".into()), + }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let post_row = client + .query_opt( + "SELECT status, deleted_at FROM posts WHERE id = $1", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + match post_row { + None => { + return Ok(CommentResponse { + success: false, + message: "文章不存在".to_string(), + error_code: Some("post_not_found".into()), + }); + } + Some(row) => { + let status: String = row.get("status"); + let deleted_at: Option> = row.get("deleted_at"); + if status != "published" || deleted_at.is_some() { + return Ok(CommentResponse { + success: false, + message: "文章不存在".to_string(), + error_code: Some("post_not_found".into()), + }); + } + } + } + + let mut depth: i32 = 0; + if let Some(pid) = parent_id { + let parent_row = client + .query_opt( + "SELECT post_id, status, depth FROM comments WHERE id = $1 AND deleted_at IS NULL", + &[&pid], + ) + .await + .map_err(AppError::query)?; + + match parent_row { + None => { + return Ok(CommentResponse { + success: false, + message: "父评论不存在".to_string(), + error_code: Some("parent_not_found".into()), + }); + } + Some(row) => { + let parent_post_id: i32 = row.get("post_id"); + let parent_status: String = row.get("status"); + let parent_depth: i32 = row.get("depth"); + + if parent_post_id != post_id { + return Ok(CommentResponse { + success: false, + message: "父评论不存在".to_string(), + error_code: Some("parent_not_found".into()), + }); + } + if parent_status != "approved" { + return Ok(CommentResponse { + success: false, + message: "父评论未通过审核".to_string(), + error_code: Some("parent_not_approved".into()), + }); + } + + depth = parent_depth + 1; + if depth > 20 { + return Ok(CommentResponse { + success: false, + message: "评论嵌套层级过深".to_string(), + error_code: Some("too_deep".into()), + }); + } + } + } + } + + let content_hash = compute_content_hash( + post_id, + parent_id, + &author_name, + &content_md, + ); + + let dup: Option = client + .query_opt( + "SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'", + &[&post_id, &content_hash], + ) + .await + .map_err(AppError::query)? + .map(|r| r.get(0)); + + if dup.is_some() { + return Ok(CommentResponse { + success: false, + message: "请勿重复提交".to_string(), + error_code: Some("duplicate".into()), + }); + } + + let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md); + + let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { + let parts = ctx.parts_mut(); + Some(crate::api::rate_limit::get_client_ip(&parts.headers)) + } else { + None + }; + + let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { + let parts = ctx.parts_mut(); + parts.headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + } else { + None + }; + + client + .query_one( + "INSERT INTO comments \ + (post_id, parent_id, depth, author_name, author_email, author_url, \ + content_md, content_html, content_hash, status, ip_address, user_agent, consented_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, NOW()) \ + RETURNING id", + &[ + &post_id, + &parent_id, + &depth, + &author_name.trim(), + &author_email.trim(), + &author_url.as_ref().map(|u| u.trim()).filter(|u| !u.is_empty()), + &content_md, + &content_html, + &content_hash, + &ip_address, + &user_agent, + ], + ) + .await + .map_err(AppError::query)?; + + cache::invalidate_comments_by_post(post_id).await; + cache::invalidate_comment_count(post_id).await; + + Ok(CommentResponse { + success: true, + message: "评论已提交,等待审核".to_string(), + error_code: None, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} diff --git a/src/api/comments/helpers.rs b/src/api/comments/helpers.rs new file mode 100644 index 0000000..8ba54ca --- /dev/null +++ b/src/api/comments/helpers.rs @@ -0,0 +1,316 @@ +#![allow(clippy::unused_unit, deprecated, unused_imports)] + +#[cfg(feature = "server")] +use crate::models::comment::{AdminComment, CommentStatus, PublicComment}; + +#[cfg(feature = "server")] +pub fn md5_hash(input: &str) -> String { + use md5::Digest; + let hash = md5::Md5::digest(input.as_bytes()); + hex::encode(hash) +} + +#[cfg(feature = "server")] +pub fn gravatar_url(email: &str) -> String { + let hash = md5_hash(&email.trim().to_lowercase()); + format!("https://cravatar.cn/avatar/{}?d=mp&s=80", hash) +} + +#[cfg(feature = "server")] +pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment { + let email: String = row.get("author_email"); + let created_at_dt: chrono::DateTime = row.get("created_at"); + let created_at_iso = created_at_dt.to_rfc3339(); + let created_at_relative = format_relative_time(created_at_dt); + + PublicComment { + id: row.get("id"), + parent_id: row.get("parent_id"), + depth: row.get("depth"), + author_name: row.get("author_name"), + author_url: row.get("author_url"), + avatar_url: gravatar_url(&email), + content_html: row.get("content_html"), + created_at: created_at_relative, + created_at_iso, + } +} + +#[cfg(feature = "server")] +pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment { + let status_str: String = row.get("status"); + + AdminComment { + id: row.get("id"), + post_id: row.get("post_id"), + post_title: row.get("post_title"), + post_slug: row.get("post_slug"), + parent_id: row.get("parent_id"), + depth: row.get("depth"), + author_name: row.get("author_name"), + author_email: row.get("author_email"), + author_url: row.get("author_url"), + content_md: row.get("content_md"), + status: CommentStatus::from_str(&status_str), + created_at: row.get("created_at"), + } +} + +pub fn format_relative_time(dt: chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(dt); + + if diff.num_seconds() < 60 { + "刚刚".to_string() + } else if diff.num_minutes() < 60 { + format!("{} 分钟前", diff.num_minutes()) + } else if diff.num_hours() < 24 { + format!("{} 小时前", diff.num_hours()) + } else if diff.num_days() < 30 { + format!("{} 天前", diff.num_days()) + } else { + dt.format("%Y-%m-%d").to_string() + } +} + +#[allow(dead_code)] +pub fn validate_comment_name(name: &str) -> Result<(), String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("请输入昵称".to_string()); + } + if trimmed.len() > 50 { + return Err("昵称长度不能超过 50 个字符".to_string()); + } + Ok(()) +} + +#[allow(dead_code)] +pub fn validate_comment_email(email: &str) -> Result<(), String> { + let re = + regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); + if !re.is_match(email.trim()) { + return Err("邮箱格式不正确".to_string()); + } + Ok(()) +} + +#[allow(dead_code)] +pub fn validate_comment_url(url: &str) -> Result<(), String> { + if url.trim().is_empty() { + return Ok(()); + } + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err("网址必须以 http:// 或 https:// 开头".to_string()); + } + if url.len() > 200 { + return Err("网址长度不能超过 200 个字符".to_string()); + } + Ok(()) +} + +#[allow(dead_code)] +pub fn validate_comment_content(content: &str) -> Result<(), String> { + let trimmed = content.trim(); + if trimmed.is_empty() { + return Err("请输入评论内容".to_string()); + } + if trimmed.len() > 10000 { + return Err("评论内容不能超过 10000 个字符".to_string()); + } + Ok(()) +} + +pub fn compute_content_hash( + post_id: i32, + parent_id: Option, + name: &str, + content: &str, +) -> String { + use sha2::Digest; + let input = format!( + "{}:{}:{}:{}", + post_id, + parent_id.map(|id| id.to_string()).unwrap_or_default(), + name.trim(), + content.trim() + ); + let hash = sha2::Sha256::digest(input.as_bytes()); + hex::encode(hash) +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + + #[test] + fn md5_hash_known_value() { + assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592"); + } + + #[test] + fn md5_hash_empty() { + assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e"); + } + + #[test] + fn gravatar_url_format() { + let url = gravatar_url("test@example.com"); + assert!(url.starts_with("https://cravatar.cn/avatar/")); + assert!(url.contains("?d=mp&s=80")); + } + + #[test] + fn gravatar_url_normalizes_email() { + let url1 = gravatar_url("Test@Example.com"); + let url2 = gravatar_url("test@example.com"); + assert_eq!(url1, url2); + } + + #[test] + fn gravatar_url_trims_whitespace() { + let url1 = gravatar_url(" test@example.com "); + let url2 = gravatar_url("test@example.com"); + assert_eq!(url1, url2); + } + + #[test] + fn format_relative_time_just_now() { + let now = chrono::Utc::now(); + assert_eq!(format_relative_time(now), "刚刚"); + } + + #[test] + fn format_relative_time_minutes() { + let dt = chrono::Utc::now() - chrono::Duration::minutes(5); + assert_eq!(format_relative_time(dt), "5 分钟前"); + } + + #[test] + fn format_relative_time_hours() { + let dt = chrono::Utc::now() - chrono::Duration::hours(3); + assert_eq!(format_relative_time(dt), "3 小时前"); + } + + #[test] + fn format_relative_time_days() { + let dt = chrono::Utc::now() - chrono::Duration::days(7); + assert_eq!(format_relative_time(dt), "7 天前"); + } + + #[test] + fn format_relative_time_old_date() { + let dt = chrono::Utc::now() - chrono::Duration::days(60); + let result = format_relative_time(dt); + assert!(result.contains('-')); + assert_eq!(result.len(), 10); + } + + #[test] + fn validate_comment_name_valid() { + assert!(validate_comment_name("Alice").is_ok()); + assert!(validate_comment_name("张三").is_ok()); + } + + #[test] + fn validate_comment_name_empty() { + assert!(validate_comment_name("").is_err()); + assert!(validate_comment_name(" ").is_err()); + } + + #[test] + fn validate_comment_name_too_long() { + assert!(validate_comment_name(&"a".repeat(51)).is_err()); + } + + #[test] + fn validate_comment_name_max_length() { + assert!(validate_comment_name(&"a".repeat(50)).is_ok()); + } + + #[test] + fn validate_comment_email_valid() { + assert!(validate_comment_email("user@example.com").is_ok()); + assert!(validate_comment_email("a.b+c@domain.co").is_ok()); + } + + #[test] + fn validate_comment_email_invalid() { + assert!(validate_comment_email("notanemail").is_err()); + assert!(validate_comment_email("@domain.com").is_err()); + assert!(validate_comment_email("user@").is_err()); + } + + #[test] + fn validate_comment_url_valid() { + assert!(validate_comment_url("http://example.com").is_ok()); + assert!(validate_comment_url("https://example.com/path").is_ok()); + } + + #[test] + fn validate_comment_url_empty_is_ok() { + assert!(validate_comment_url("").is_ok()); + assert!(validate_comment_url(" ").is_ok()); + } + + #[test] + fn validate_comment_url_invalid_scheme() { + assert!(validate_comment_url("ftp://example.com").is_err()); + assert!(validate_comment_url("javascript:alert(1)").is_err()); + } + + #[test] + fn validate_comment_url_too_long() { + let long_url = format!("https://example.com/{}", "a".repeat(200)); + assert!(validate_comment_url(&long_url).is_err()); + } + + #[test] + fn validate_comment_content_valid() { + assert!(validate_comment_content("Hello world").is_ok()); + } + + #[test] + fn validate_comment_content_empty() { + assert!(validate_comment_content("").is_err()); + assert!(validate_comment_content(" ").is_err()); + } + + #[test] + fn validate_comment_content_too_long() { + assert!(validate_comment_content(&"a".repeat(10001)).is_err()); + } + + #[test] + fn validate_comment_content_max_length() { + assert!(validate_comment_content(&"a".repeat(10000)).is_ok()); + } + + #[test] + fn compute_content_hash_deterministic() { + let h1 = compute_content_hash(1, None, "Alice", "Hello"); + let h2 = compute_content_hash(1, None, "Alice", "Hello"); + assert_eq!(h1, h2); + } + + #[test] + fn compute_content_hash_different_inputs() { + let h1 = compute_content_hash(1, None, "Alice", "Hello"); + let h2 = compute_content_hash(2, None, "Alice", "Hello"); + assert_ne!(h1, h2); + } + + #[test] + fn compute_content_hash_trims_whitespace() { + let h1 = compute_content_hash(1, None, "Alice", "Hello"); + let h2 = compute_content_hash(1, None, " Alice ", " Hello "); + assert_eq!(h1, h2); + } + + #[test] + fn compute_content_hash_64_hex_chars() { + let h = compute_content_hash(1, None, "Alice", "Hello"); + assert_eq!(h.len(), 64); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/src/api/comments/list.rs b/src/api/comments/list.rs new file mode 100644 index 0000000..9be6337 --- /dev/null +++ b/src/api/comments/list.rs @@ -0,0 +1,166 @@ +use dioxus::prelude::*; +use crate::api::comments::types::*; + +#[server(GetPendingComments, "/api")] +pub async fn get_pending_comments( + page: i32, +) -> Result { + #[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; + + let _admin = get_current_admin_user().await?; + + let page = page.max(1); + let per_page: i64 = 20; + let offset: i64 = (page as i64 - 1) * per_page; + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let total: i64 = client + .query_one( + "SELECT COUNT(*) FROM comments WHERE status = 'pending' AND deleted_at IS NULL", + &[], + ) + .await + .map_err(AppError::query)? + .get(0); + + let rows = client + .query( + "SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \ + c.author_url, c.content_md, c.status, c.created_at, \ + p.title as post_title, p.slug as post_slug \ + FROM comments c JOIN posts p ON c.post_id = p.id \ + WHERE c.status = 'pending' AND c.deleted_at IS NULL \ + ORDER BY c.created_at DESC LIMIT $1 OFFSET $2", + &[&per_page, &offset], + ) + .await + .map_err(AppError::query)?; + + let comments = rows.iter().map(row_to_admin_comment).collect(); + + Ok(PendingCommentsResponse { comments, total }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +#[server(GetPendingCount, "/api")] +pub async fn get_pending_count() -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + 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?; + + if let Some(cached) = cache::get_pending_count().await { + return Ok(PendingCountResponse { count: cached }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let count: i64 = client + .query_one( + "SELECT COUNT(*) FROM comments WHERE status = 'pending' AND deleted_at IS NULL", + &[], + ) + .await + .map_err(AppError::query)? + .get(0); + + cache::set_pending_count(count).await; + + Ok(PendingCountResponse { count }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +#[server(GetAllComments, "/api")] +pub async fn get_all_comments( + page: i32, + status: Option, +) -> Result { + #[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; + + let _admin = get_current_admin_user().await?; + + let page = page.max(1); + let per_page: i64 = 20; + let offset: i64 = (page as i64 - 1) * per_page; + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let (total, rows) = match status.as_deref() { + Some(s) if !s.is_empty() => { + let total: i64 = client + .query_one( + "SELECT COUNT(*) FROM comments WHERE status = $1 AND deleted_at IS NULL", + &[&s], + ) + .await + .map_err(AppError::query)? + .get(0); + + let rows = client + .query( + "SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \ + c.author_url, c.content_md, c.status, c.created_at, \ + p.title as post_title, p.slug as post_slug \ + FROM comments c JOIN posts p ON c.post_id = p.id \ + WHERE c.status = $1 AND c.deleted_at IS NULL \ + ORDER BY c.created_at DESC LIMIT $2 OFFSET $3", + &[&s, &per_page, &offset], + ) + .await + .map_err(AppError::query)?; + + (total, rows) + } + _ => { + let total: i64 = client + .query_one( + "SELECT COUNT(*) FROM comments WHERE deleted_at IS NULL", + &[], + ) + .await + .map_err(AppError::query)? + .get(0); + + let rows = client + .query( + "SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \ + c.author_url, c.content_md, c.status, c.created_at, \ + p.title as post_title, p.slug as post_slug \ + FROM comments c JOIN posts p ON c.post_id = p.id \ + WHERE c.deleted_at IS NULL \ + ORDER BY c.created_at DESC LIMIT $1 OFFSET $2", + &[&per_page, &offset], + ) + .await + .map_err(AppError::query)?; + + (total, rows) + } + }; + + let comments = rows.iter().map(row_to_admin_comment).collect(); + + Ok(AllCommentsResponse { comments, total }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} diff --git a/src/api/comments/markdown.rs b/src/api/comments/markdown.rs new file mode 100644 index 0000000..aa531d8 --- /dev/null +++ b/src/api/comments/markdown.rs @@ -0,0 +1,206 @@ +#![allow(clippy::unused_unit, deprecated, unused_imports)] + +#[cfg(feature = "server")] +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(feature = "server")] +pub fn clean_comment_html(input: &str) -> String { + let mut builder = ammonia::Builder::default(); + builder + .rm_tags(["img", "details", "summary"]) + .add_generic_attributes(&[ + "class", + "title", + "aria-hidden", + "aria-label", + "role", + "accesskey", + ]) + .url_relative(ammonia::UrlRelative::PassThrough) + .add_tag_attributes("a", &["class", "aria-hidden", "aria-label"]) + .add_tag_attributes("span", &["class"]) + .link_rel(Some("nofollow noopener")); + + builder.clean(input).to_string() +} + +#[cfg(feature = "server")] +pub fn render_comment_markdown(md: &str) -> String { + use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd}; + + let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; + let parser = pulldown_cmark::Parser::new_ext(md, opts); + + let mut events: Vec = Vec::new(); + let mut in_codeblock = false; + let mut code_lang: Option = None; + let mut code_buffer = String::new(); + + for event in parser { + match event { + Event::Start(Tag::Heading { .. }) => { + events.push(Event::Start(Tag::Strong)); + } + Event::End(TagEnd::Heading(_)) => { + events.push(Event::End(TagEnd::Strong)); + } + Event::Start(Tag::CodeBlock(kind)) => { + in_codeblock = true; + code_lang = match kind { + CodeBlockKind::Fenced(lang) if !lang.is_empty() => { + Some(lang.to_string()) + } + _ => None, + }; + code_buffer.clear(); + } + Event::Text(text) if in_codeblock => { + code_buffer.push_str(&text); + } + Event::End(TagEnd::CodeBlock) => { + let html = if let Some(ref lang) = code_lang { + let highlighted = + crate::highlight::server::highlight_code(&code_buffer, Some(lang)); + format!("
{}
", highlighted) + } else { + format!("
{}
", html_escape(&code_buffer)) + }; + events.push(Event::Html(html.into())); + in_codeblock = false; + } + _ if !in_codeblock => { + events.push(event); + } + _ => {} + } + } + + let mut html = String::new(); + pulldown_cmark::html::push_html(&mut html, events.into_iter()); + clean_comment_html(&html) +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + + #[test] + fn render_comment_heading_converted_to_strong() { + let result = render_comment_markdown("## Hello World"); + assert!(result.contains("Hello World")); + assert!(!result.contains("

")); + } + + #[test] + fn render_comment_heading_all_levels() { + for md in &[ + "# H1", "## H2", "### H3", "#### H4", "##### H5", "###### H6", + ] { + let result = render_comment_markdown(md); + assert!(result.contains(""), "heading not converted for: {}", md); + } + } + + #[test] + fn render_comment_paragraph() { + let result = render_comment_markdown("Hello **world**"); + assert!(result.contains("world")); + } + + #[test] + fn render_comment_code_block_with_language() { + let result = render_comment_markdown("```rust\nfn main() {}\n```"); + assert!(result.contains("
"));
+        assert!(result.contains("main"));
+    }
+
+    #[test]
+    fn render_comment_code_block_without_language() {
+        let result = render_comment_markdown("```\nplain text\n```");
+        assert!(result.contains("
"));
+        assert!(result.contains("plain text"));
+    }
+
+    #[test]
+    fn render_comment_code_block_without_language_escapes_html() {
+        let result = render_comment_markdown("```\n
alert('xss')
\n```"); + assert!(result.contains("<div>")); + assert!(!result.contains("
")); + } + + #[test] + fn render_comment_strips_script() { + let result = render_comment_markdown(""); + assert!(!result.contains("script")); + } + + #[test] + fn render_comment_no_img_tags() { + let result = render_comment_markdown("![alt](https://example.com/img.png)"); + assert!(!result.contains("text
"); + assert!(!result.contains("id=")); + } + + #[test] + fn render_comment_table() { + let result = render_comment_markdown("| a | b |\n|---|---|\n| 1 | 2 |"); + assert!(result.contains("")); + } + + #[test] + fn render_comment_strikethrough() { + let result = render_comment_markdown("~~deleted~~"); + assert!(result.contains("deleted")); + } + + #[test] + fn render_comment_inline_code() { + let result = render_comment_markdown("Use `println!` to print"); + assert!(result.contains("println!")); + } + + #[test] + fn clean_comment_html_removes_details_summary() { + let result = clean_comment_html("
Click

Content

"); + assert!(!result.contains("details")); + assert!(!result.contains("summary")); + } + + #[test] + fn clean_comment_html_removes_data_uri() { + let result = + clean_comment_html("alert(1)\">click"); + assert!(!result.contains("data:")); + } + + #[test] + fn render_comment_empty() { + let result = render_comment_markdown(""); + assert!(result.is_empty()); + } + + #[test] + fn render_comment_heading_with_inline_code() { + let result = render_comment_markdown("## Using `foo()`"); + assert!(result.contains("")); + assert!(result.contains("foo()")); + assert!(!result.contains("

")); + } +} diff --git a/src/api/comments/mod.rs b/src/api/comments/mod.rs new file mode 100644 index 0000000..6a91202 --- /dev/null +++ b/src/api/comments/mod.rs @@ -0,0 +1,18 @@ +#![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; + +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}; + +#[cfg(feature = "server")] +pub use markdown::render_comment_markdown; diff --git a/src/api/comments/read.rs b/src/api/comments/read.rs new file mode 100644 index 0000000..baefeb4 --- /dev/null +++ b/src/api/comments/read.rs @@ -0,0 +1,78 @@ +use dioxus::prelude::*; +use crate::api::comments::types::*; + +#[server(GetComments, "/api")] +pub async fn get_comments( + post_id: i32, +) -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + use crate::db::pool::get_conn; + use crate::api::comments::helpers::row_to_public_comment; + use crate::api::error::AppError; + + if let Some(cached) = cache::get_comments_by_post(post_id).await { + let count = cached.len() as i64; + return Ok(CommentTreeResponse { + comments: cached, + count, + }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let rows = client + .query( + "SELECT id, parent_id, depth, author_name, author_email, author_url, content_html, created_at \ + FROM comments \ + WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL \ + ORDER BY id ASC", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + let comments: Vec<_> = rows.iter().map(row_to_public_comment).collect(); + let count = comments.len() as i64; + + cache::set_comments_by_post(post_id, comments.clone()).await; + + Ok(CommentTreeResponse { comments, count }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +#[server(GetCommentCount, "/api")] +pub async fn get_comment_count( + post_id: i32, +) -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + use crate::db::pool::get_conn; + use crate::api::error::AppError; + + if let Some(cached) = cache::get_comment_count(post_id).await { + return Ok(CommentCountResponse { count: cached }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let count: i64 = client + .query_one( + "SELECT COUNT(*) FROM comments WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL", + &[&post_id], + ) + .await + .map_err(AppError::query)? + .get(0); + + cache::set_comment_count(post_id, count).await; + + Ok(CommentCountResponse { count }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} diff --git a/src/api/comments/types.rs b/src/api/comments/types.rs new file mode 100644 index 0000000..5dbc072 --- /dev/null +++ b/src/api/comments/types.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use crate::models::comment::{AdminComment, PublicComment}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct CreateCommentRequest { + pub post_id: i32, + pub parent_id: Option, + pub author_name: String, + pub author_email: String, + pub author_url: Option, + pub content_md: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentResponse { + pub success: bool, + pub message: String, + pub error_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentTreeResponse { + pub comments: Vec, + pub count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentCountResponse { + pub count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingCommentsResponse { + pub comments: Vec, + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AllCommentsResponse { + pub comments: Vec, + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingCountResponse { + pub count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchStatusResponse { + pub success: bool, + pub updated_count: i64, + pub message: String, +} diff --git a/src/api/comments/update.rs b/src/api/comments/update.rs new file mode 100644 index 0000000..fbb0962 --- /dev/null +++ b/src/api/comments/update.rs @@ -0,0 +1,244 @@ +use dioxus::prelude::*; +use crate::api::comments::types::*; + +#[server(ApproveComment, "/api")] +pub async fn approve_comment(id: i64) -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + 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 client = get_conn().await.map_err(AppError::db_conn)?; + + let row = client + .query_opt( + "SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL", + &[&id], + ) + .await + .map_err(AppError::query)?; + + let post_id: i32 = match row { + Some(r) => r.get("post_id"), + None => { + return Ok(CommentResponse { + success: false, + message: "评论不存在".to_string(), + error_code: Some("not_found".into()), + }); + } + }; + + client + .execute( + "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1", + &[&id], + ) + .await + .map_err(AppError::query)?; + + client + .execute( + "WITH RECURSIVE ancestors AS ( \ + SELECT parent_id FROM comments WHERE id = $1 \ + UNION ALL \ + SELECT c.parent_id FROM comments c JOIN ancestors a ON c.id = a.parent_id WHERE a.parent_id IS NOT NULL \ + ) \ + UPDATE comments SET status = 'approved', approved_at = NOW() \ + WHERE id IN (SELECT parent_id FROM ancestors WHERE parent_id IS NOT NULL) AND status = 'pending'", + &[&id], + ) + .await + .map_err(AppError::query)?; + + cache::invalidate_comments_by_post(post_id).await; + cache::invalidate_comment_count(post_id).await; + cache::invalidate_pending_count().await; + + Ok(CommentResponse { + success: true, + message: "已通过".to_string(), + error_code: None, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +#[server(SpamComment, "/api")] +pub async fn spam_comment(id: i64) -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + 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 client = get_conn().await.map_err(AppError::db_conn)?; + + let row = client + .query_opt( + "SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL", + &[&id], + ) + .await + .map_err(AppError::query)?; + + if let Some(r) = row { + let post_id: i32 = r.get("post_id"); + let old_status: String = r.get("status"); + + client + .execute( + "UPDATE comments SET status = 'spam' WHERE id = $1 AND deleted_at IS NULL", + &[&id], + ) + .await + .map_err(AppError::query)?; + + if old_status == "approved" { + cache::invalidate_comments_by_post(post_id).await; + cache::invalidate_comment_count(post_id).await; + } + cache::invalidate_pending_count().await; + } + + Ok(CommentResponse { + success: true, + message: "已标记为垃圾".to_string(), + error_code: None, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +#[server(TrashComment, "/api")] +pub async fn trash_comment(id: i64) -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + 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 client = get_conn().await.map_err(AppError::db_conn)?; + + let row = client + .query_opt( + "SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL", + &[&id], + ) + .await + .map_err(AppError::query)?; + + if let Some(r) = row { + let post_id: i32 = r.get("post_id"); + + client + .execute( + "UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1", + &[&id], + ) + .await + .map_err(AppError::query)?; + + cache::invalidate_comments_by_post(post_id).await; + cache::invalidate_comment_count(post_id).await; + cache::invalidate_pending_count().await; + } + + Ok(CommentResponse { + success: true, + message: "已删除".to_string(), + error_code: None, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +#[server(BatchUpdateCommentStatus, "/api")] +pub async fn batch_update_comment_status( + ids: Vec, + status: String, +) -> Result { + #[cfg(feature = "server")] + { + use crate::cache; + 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?; + + if !matches!(status.as_str(), "approved" | "spam" | "trash") { + return Ok(BatchStatusResponse { + success: false, + updated_count: 0, + message: "无效的状态".to_string(), + }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let post_ids: Vec = client + .query( + "SELECT DISTINCT post_id FROM comments WHERE id = ANY($1)", + &[&ids], + ) + .await + .map_err(AppError::query)? + .iter() + .map(|r| r.get("post_id")) + .collect(); + + let result = if status == "trash" { + client + .execute( + "UPDATE comments SET status = $1, deleted_at = NOW() WHERE id = ANY($2)", + &[&status, &ids], + ) + .await + .map_err(AppError::query)? + } else if status == "approved" { + client + .execute( + "UPDATE comments SET status = $1, approved_at = NOW() WHERE id = ANY($2)", + &[&status, &ids], + ) + .await + .map_err(AppError::query)? + } else { + client + .execute( + "UPDATE comments SET status = $1 WHERE id = ANY($2)", + &[&status, &ids], + ) + .await + .map_err(AppError::query)? + }; + + cache::invalidate_pending_count().await; + for pid in post_ids { + cache::invalidate_comments_by_post(pid).await; + cache::invalidate_comment_count(pid).await; + } + + Ok(BatchStatusResponse { + success: true, + updated_count: result as i64, + message: format!("已更新 {} 条评论", result), + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 8de4e81..010e789 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod comments; pub mod error; pub mod image; pub mod markdown; diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index 3d05cb5..a572cfb 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -3,25 +3,10 @@ use crate::api::error::AppError; #[cfg(feature = "server")] use crate::models::post::{Post, PostStatus}; #[cfg(feature = "server")] -use crate::models::user::{User, UserRole}; -#[cfg(feature = "server")] use crate::utils::text::count_words; #[cfg(feature = "server")] -pub(super) async fn get_current_admin_user() -> Result { - let token = crate::auth::session::get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; - - let user = crate::api::auth::get_user_by_token(&token) - .await - .map_err(AppError::query)? - .ok_or(AppError::Unauthorized("会话已过期"))?; - - if user.role != UserRole::Admin { - return Err(AppError::Forbidden("权限不足")); - } - - Ok(user) -} +pub(super) use crate::api::auth::get_current_admin_user; #[cfg(feature = "server")] pub(super) async fn row_to_post_list( diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs index f579e10..7fd4fd0 100644 --- a/src/api/rate_limit.rs +++ b/src/api/rate_limit.rs @@ -42,6 +42,22 @@ static IMAGE_LIMITER: LazyLock> = LazyLock::new( ) }); +#[cfg(feature = "server")] +static COMMENT_LIMITER: LazyLock> = LazyLock::new(|| { + RateLimiter::keyed( + Quota::per_second(env_or("RATE_LIMIT_COMMENT_PER_SEC", 1)) + .allow_burst(env_or("RATE_LIMIT_COMMENT_BURST", 5)), + ) +}); + +#[cfg(feature = "server")] +pub fn check_comment_limit(ip: &str) -> Result<(), String> { + COMMENT_LIMITER + .check_key(&ip.to_string()) + .map(|_| ()) + .map_err(|_| "评论过于频繁,请稍后再试".to_string()) +} + #[cfg(feature = "server")] pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> { IMAGE_LIMITER diff --git a/src/cache.rs b/src/cache.rs index e17f243..3a0f288 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -5,6 +5,8 @@ use std::sync::LazyLock; #[cfg(feature = "server")] use std::time::Duration; +#[cfg(feature = "server")] +use crate::models::comment::PublicComment; #[cfg(feature = "server")] use crate::models::post::{Post, PostStats, Tag}; @@ -22,6 +24,12 @@ const TTL_SINGLE_POST: Duration = Duration::from_secs(600); const TTL_POST_STATS: Duration = Duration::from_secs(60); #[cfg(feature = "server")] const TTL_TAG_POSTS: Duration = Duration::from_secs(120); +#[cfg(feature = "server")] +const TTL_COMMENTS: Duration = Duration::from_secs(60); +#[cfg(feature = "server")] +const TTL_COMMENT_COUNT: Duration = Duration::from_secs(60); +#[cfg(feature = "server")] +const TTL_PENDING_COUNT: Duration = Duration::from_secs(10); // ============================================================================ // Cache Key Types @@ -36,6 +44,9 @@ pub enum CacheKey { PostBySlug(String), PostsByTag(String), PostStats, + CommentsByPost { post_id: i32 }, + CommentCount { post_id: i32 }, + PendingCommentCount, } @@ -96,6 +107,36 @@ static TAG_POSTS_CACHE: LazyLock = LazyLock::new(|| { .build() }); +#[cfg(feature = "server")] +pub type CommentListCache = Cache>; + +#[cfg(feature = "server")] +pub type CommentCountCache = Cache; + +#[cfg(feature = "server")] +static COMMENT_CACHE: LazyLock = LazyLock::new(|| { + Cache::builder() + .max_capacity(200) + .time_to_live(TTL_COMMENTS) + .build() +}); + +#[cfg(feature = "server")] +static COMMENT_COUNT_CACHE: LazyLock = LazyLock::new(|| { + Cache::builder() + .max_capacity(200) + .time_to_live(TTL_COMMENT_COUNT) + .build() +}); + +#[cfg(feature = "server")] +static PENDING_COUNT_CACHE: LazyLock = LazyLock::new(|| { + Cache::builder() + .max_capacity(10) + .time_to_live(TTL_PENDING_COUNT) + .build() +}); + // ============================================================================ // Public Cache API // ============================================================================ @@ -210,9 +251,81 @@ pub fn invalidate_all_post_caches() { TAG_POSTS_CACHE.invalidate_all(); } +#[cfg(feature = "server")] +pub async fn get_comments_by_post(post_id: i32) -> Option> { + COMMENT_CACHE + .get(&CacheKey::CommentsByPost { post_id }) + .await +} + +#[cfg(feature = "server")] +pub async fn set_comments_by_post(post_id: i32, comments: Vec) { + let _ = COMMENT_CACHE + .insert(CacheKey::CommentsByPost { post_id }, comments) + .await; +} + +#[cfg(feature = "server")] +pub async fn get_comment_count(post_id: i32) -> Option { + COMMENT_COUNT_CACHE + .get(&CacheKey::CommentCount { post_id }) + .await +} + +#[cfg(feature = "server")] +pub async fn set_comment_count(post_id: i32, count: i64) { + let _ = COMMENT_COUNT_CACHE + .insert(CacheKey::CommentCount { post_id }, count) + .await; +} + +#[cfg(feature = "server")] +pub async fn get_pending_count() -> Option { + PENDING_COUNT_CACHE + .get(&CacheKey::PendingCommentCount) + .await +} + +#[cfg(feature = "server")] +pub async fn set_pending_count(count: i64) { + let _ = PENDING_COUNT_CACHE + .insert(CacheKey::PendingCommentCount, count) + .await; +} + +#[cfg(feature = "server")] +pub async fn invalidate_comments_by_post(post_id: i32) { + COMMENT_CACHE + .invalidate(&CacheKey::CommentsByPost { post_id }) + .await; +} + +#[cfg(feature = "server")] +pub async fn invalidate_comment_count(post_id: i32) { + COMMENT_COUNT_CACHE + .invalidate(&CacheKey::CommentCount { post_id }) + .await; +} + +#[cfg(feature = "server")] +pub async fn invalidate_pending_count() { + PENDING_COUNT_CACHE + .invalidate(&CacheKey::PendingCommentCount) + .await; +} + +#[cfg(feature = "server")] +#[allow(dead_code)] +pub async fn invalidate_all_comment_caches() { + COMMENT_CACHE.invalidate_all(); + COMMENT_COUNT_CACHE.invalidate_all(); + PENDING_COUNT_CACHE.invalidate_all(); +} + #[cfg(all(test, feature = "server"))] mod tests { use super::*; + use crate::models::comment::PublicComment; use crate::models::post::PostStatus; #[test] @@ -322,4 +435,83 @@ mod tests { let cached_after = get_post_by_slug("invalidation-test").await; assert!(cached_after.is_none()); } + + #[tokio::test] + async fn comment_cache_roundtrip() { + let comments = vec![PublicComment { + id: 1, + parent_id: None, + depth: 0, + author_name: "Alice".to_string(), + author_url: None, + avatar_url: "https://example.com/avatar".to_string(), + content_html: Some("

Hello

".to_string()), + created_at: "刚刚".to_string(), + created_at_iso: "2026-01-01T00:00:00Z".to_string(), + }]; + + set_comments_by_post(42, comments.clone()).await; + let cached = get_comments_by_post(42).await; + + assert!(cached.is_some()); + assert_eq!(cached.unwrap().len(), 1); + } + + #[tokio::test] + async fn comment_count_cache_roundtrip() { + set_comment_count(42, 15).await; + let cached = get_comment_count(42).await; + + assert!(cached.is_some()); + assert_eq!(cached.unwrap(), 15); + } + + #[tokio::test] + async fn pending_count_cache_roundtrip() { + set_pending_count(7).await; + let cached = get_pending_count().await; + + assert!(cached.is_some()); + assert_eq!(cached.unwrap(), 7); + } + + #[tokio::test] + async fn comment_cache_invalidation() { + set_comments_by_post(99, vec![]).await; + assert!(get_comments_by_post(99).await.is_some()); + + invalidate_comments_by_post(99).await; + assert!(get_comments_by_post(99).await.is_none()); + } + + #[tokio::test] + async fn comment_count_invalidation() { + set_comment_count(99, 5).await; + assert!(get_comment_count(99).await.is_some()); + + invalidate_comment_count(99).await; + assert!(get_comment_count(99).await.is_none()); + } + + #[tokio::test] + async fn pending_count_invalidation() { + set_pending_count(3).await; + assert!(get_pending_count().await.is_some()); + + invalidate_pending_count().await; + assert!(get_pending_count().await.is_none()); + } + + #[tokio::test] + async fn invalidate_all_comment_caches_clears_everything() { + set_comments_by_post(1, vec![]).await; + set_comment_count(1, 10).await; + set_pending_count(5).await; + + invalidate_all_comment_caches().await; + + assert!(get_comments_by_post(1).await.is_none()); + assert!(get_comment_count(1).await.is_none()); + assert!(get_pending_count().await.is_none()); + } } diff --git a/src/components/comments/actions.rs b/src/components/comments/actions.rs new file mode 100644 index 0000000..93529d9 --- /dev/null +++ b/src/components/comments/actions.rs @@ -0,0 +1,60 @@ +use dioxus::prelude::*; + +use crate::api::comments::{approve_comment, spam_comment, trash_comment}; +use crate::components::comments::section::CommentContext; + +#[component] +pub fn CommentActions(comment_id: i64, post_id: i32) -> Element { + let ctx: CommentContext = use_context(); + let refresh_trigger = ctx.refresh_trigger; + let mut busy = use_signal(|| false); + + let _ = post_id; + + rsx! { + div { class: "flex items-center gap-1.5", + button { + class: "text-xs px-2 py-0.5 rounded-full text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors cursor-pointer", + disabled: busy(), + onclick: move |_| { + busy.set(true); + let mut refresh_trigger = refresh_trigger; + spawn(async move { + let _ = approve_comment(comment_id).await; + refresh_trigger.set(!refresh_trigger()); + busy.set(false); + }); + }, + "通过" + } + button { + class: "text-xs px-2 py-0.5 rounded-full text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors cursor-pointer", + disabled: busy(), + onclick: move |_| { + busy.set(true); + let mut refresh_trigger = refresh_trigger; + spawn(async move { + let _ = spam_comment(comment_id).await; + refresh_trigger.set(!refresh_trigger()); + busy.set(false); + }); + }, + "垃圾" + } + button { + class: "text-xs px-2 py-0.5 rounded-full text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors cursor-pointer", + disabled: busy(), + onclick: move |_| { + busy.set(true); + let mut refresh_trigger = refresh_trigger; + spawn(async move { + let _ = trash_comment(comment_id).await; + refresh_trigger.set(!refresh_trigger()); + busy.set(false); + }); + }, + "删除" + } + } + } +} diff --git a/src/components/comments/form.rs b/src/components/comments/form.rs new file mode 100644 index 0000000..159368e --- /dev/null +++ b/src/components/comments/form.rs @@ -0,0 +1,197 @@ +use dioxus::prelude::*; + +use crate::api::comments::create_comment; +use crate::components::comments::section::CommentContext; +use crate::components::forms::{INPUT_CLASS, BUTTON_PRIMARY_CLASS, AlertBox}; + +#[component] +pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { + let ctx: CommentContext = use_context(); + let mut active_reply = ctx.active_reply; + let mut refresh_trigger = ctx.refresh_trigger; + let mut author_name = use_signal(String::new); + let mut author_email = use_signal(String::new); + let mut author_url = use_signal(String::new); + let mut content_md = use_signal(String::new); + let mut honeypot = use_signal(String::new); + let mut consented = use_signal(|| false); + let mut submitting = use_signal(|| false); + let mut message = use_signal(|| Option::<(String, &'static str)>::None); + + if let Some(pid) = parent_id { + if active_reply() != Some(pid) { + return rsx! {}; + } + } + + let is_reply = parent_id.is_some(); + + rsx! { + div { + class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" }, + role: "form", + aria_label: if is_reply { "回复评论" } else { "发表评论" }, + + if let Some((msg, variant)) = message() { + div { aria_live: "polite", + AlertBox { message: msg, variant } + } + } + + div { class: "space-y-3", + if !is_reply { + 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", + "昵称 *" + } + input { + class: INPUT_CLASS, + r#type: "text", + placeholder: "你的昵称", + value: "{author_name}", + disabled: submitting(), + oninput: move |e| author_name.set(e.value()), + } + } + div { + label { class: "block text-sm font-medium text-paper-secondary mb-1", + "邮箱 *" + } + input { + class: INPUT_CLASS, + r#type: "email", + placeholder: "your@email.com", + value: "{author_email}", + disabled: submitting(), + oninput: move |e| author_email.set(e.value()), + } + } + } + div { + label { class: "block text-sm font-medium text-paper-secondary mb-1", + "网站" + } + input { + class: INPUT_CLASS, + r#type: "url", + placeholder: "https://example.com(可选)", + value: "{author_url}", + disabled: submitting(), + oninput: move |e| author_url.set(e.value()), + } + } + } + + textarea { + class: "{INPUT_CLASS} min-h-[100px] resize-y", + placeholder: "写下你的评论…", + value: "{content_md}", + disabled: submitting(), + oninput: move |e| content_md.set(e.value()), + } + + p { class: "text-xs text-paper-tertiary", + "支持 Markdown 语法" + } + + textarea { + class: "hidden", + aria_hidden: "true", + tabindex: "-1", + value: "{honeypot}", + oninput: move |e| honeypot.set(e.value()), + } + + div { class: "flex items-start gap-2", + input { + r#type: "checkbox", + id: "consent-{post_id}-{parent_id.unwrap_or(0)}", + checked: consented(), + disabled: submitting(), + class: "mt-1 rounded border-gray-300 text-paper-accent focus:ring-paper-accent/30", + onchange: move |e| consented.set(e.checked()), + } + label { + r#for: "consent-{post_id}-{parent_id.unwrap_or(0)}", + class: "text-sm text-paper-secondary select-none cursor-pointer", + "同意隐私政策" + } + } + + button { + class: BUTTON_PRIMARY_CLASS, + disabled: submitting(), + onclick: move |_| { + 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 consent = consented(); + + 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 !consent { + message.set(Some(("请同意隐私政策".to_string(), "error"))); + return; + } + + submitting.set(true); + message.set(None); + + spawn(async move { + let result = create_comment( + post_id, + parent_id, + name, + email, + if url_val.trim().is_empty() { None } else { Some(url_val) }, + content, + consent, + ).await; + + submitting.set(false); + + match result { + Ok(resp) => { + if resp.success { + content_md.set(String::new()); + consented.set(false); + 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"))); + } + } + }); + }, + + if submitting() { + "提交中…" + } else if is_reply { + "回复" + } else { + "发表评论" + } + } + } + } + } +} diff --git a/src/components/comments/item.rs b/src/components/comments/item.rs new file mode 100644 index 0000000..daae7ce --- /dev/null +++ b/src/components/comments/item.rs @@ -0,0 +1,105 @@ +use dioxus::prelude::*; + +use crate::context::UserContext; +use crate::models::comment::PublicComment; +use crate::components::comments::section::CommentContext; +use crate::components::comments::form::CommentForm; +use crate::components::comments::actions::CommentActions; + +#[component] +pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element { + let ctx: CommentContext = use_context(); + let mut active_reply = ctx.active_reply; + let refresh_trigger = ctx.refresh_trigger; + let user_ctx: UserContext = use_context(); + + let depth = if comment.parent_id.is_none() && comment.depth > 0 { + 0 + } else { + comment.depth + }; + + let indent = depth.min(6) * 24; + + let is_admin = user_ctx.user.read().as_ref().is_some(); + let is_replying = active_reply() == Some(comment.id); + let show_reply = depth < 20; + + let _ = refresh_trigger; + + let author_element = match &comment.author_url { + Some(url) if !url.is_empty() => rsx! { + a { + href: "{url}", + rel: "nofollow noopener", + target: "_blank", + class: "font-medium text-paper-primary hover:text-paper-accent transition-colors", + "{comment.author_name}" + } + }, + _ => rsx! { + span { class: "font-medium text-paper-primary", + "{comment.author_name}" + } + }, + }; + + rsx! { + div { + class: "py-4", + style: "margin-left: {indent}px", + + div { class: "flex gap-3", + img { + src: "{comment.avatar_url}", + alt: "{comment.author_name} 的头像", + loading: "lazy", + decoding: "async", + class: "w-8 h-8 rounded-full shrink-0 mt-0.5 bg-gray-200 dark:bg-[#2a2a2a]", + } + + div { class: "flex-1 min-w-0", + div { class: "flex items-center gap-1.5 text-sm mb-1.5 flex-wrap", + {author_element} + span { class: "text-paper-tertiary", "·" } + span { + class: "text-paper-tertiary", + title: "{comment.created_at_iso}", + "{comment.created_at}" + } + } + + div { + class: "prose prose-sm dark:prose-invert max-w-none text-paper-secondary", + dangerous_inner_html: comment.content_html.as_deref().unwrap_or(""), + } + + div { class: "flex items-center gap-3 mt-2", + if show_reply { + button { + class: "text-xs text-paper-tertiary hover:text-paper-accent transition-colors cursor-pointer", + aria_label: "回复 {comment.author_name} 的评论", + onclick: move |_| { + if is_replying { + active_reply.set(None); + } else { + active_reply.set(Some(comment.id)); + } + }, + if is_replying { "取消回复" } else { "回复" } + } + } + + if is_admin { + CommentActions { comment_id: comment.id, post_id } + } + } + + if is_replying { + CommentForm { post_id, parent_id: Some(comment.id) } + } + } + } + } + } +} diff --git a/src/components/comments/list.rs b/src/components/comments/list.rs new file mode 100644 index 0000000..85563ed --- /dev/null +++ b/src/components/comments/list.rs @@ -0,0 +1,15 @@ +use dioxus::prelude::*; + +use crate::models::comment::PublicComment; +use crate::components::comments::item::CommentItem; + +#[component] +pub fn CommentList(comments: Vec, post_id: i32) -> Element { + rsx! { + div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]", + for comment in comments { + CommentItem { comment, post_id } + } + } + } +} diff --git a/src/components/comments/mod.rs b/src/components/comments/mod.rs new file mode 100644 index 0000000..0411343 --- /dev/null +++ b/src/components/comments/mod.rs @@ -0,0 +1,5 @@ +pub mod section; +pub mod form; +pub mod list; +pub mod item; +pub mod actions; diff --git a/src/components/comments/section.rs b/src/components/comments/section.rs new file mode 100644 index 0000000..8cfd77b --- /dev/null +++ b/src/components/comments/section.rs @@ -0,0 +1,58 @@ +use dioxus::prelude::*; + +use crate::api::comments::{get_comments, CommentTreeResponse}; +use crate::components::comments::form::CommentForm; +use crate::components::comments::list::CommentList; +use crate::components::skeletons::comment_skeleton::CommentListSkeleton; + +#[derive(Clone, Copy)] +pub struct CommentContext { + pub active_reply: Signal>, + pub refresh_trigger: Signal, +} + +#[component] +pub fn CommentSection(post_id: i32) -> Element { + let ctx = use_context_provider(|| CommentContext { + active_reply: Signal::new(None), + refresh_trigger: Signal::new(false), + }); + + let comments_resource = use_server_future(move || { + let _ = ctx.refresh_trigger; + get_comments(post_id) + })?; + + let data = comments_resource.read(); + + match data.as_ref().map(|r| r.as_ref()) { + Some(Ok(CommentTreeResponse { comments, count })) => { + let count = *count; + rsx! { + div { class: "space-y-8", + h2 { class: "text-xl font-bold text-paper-primary", + "评论区 ({count})" + } + + CommentForm { post_id, parent_id: None } + + if comments.is_empty() { + p { class: "text-paper-tertiary text-center py-8", + "暂无评论,成为第一个评论的人吧!" + } + } else { + CommentList { comments: comments.clone(), post_id } + } + } + } + } + Some(Err(_)) => { + rsx! { + div { class: "text-center text-red-500 dark:text-red-400 py-8", + "评论加载失败" + } + } + } + None => rsx! { CommentListSkeleton {} }, + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 54d1196..c7b7cc2 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,5 +1,6 @@ pub mod admin_layout; pub mod admin_skeleton; +pub mod comments; pub mod footer; pub mod forms; pub mod frontend_layout; diff --git a/src/components/skeletons/comment_skeleton.rs b/src/components/skeletons/comment_skeleton.rs new file mode 100644 index 0000000..0a8135d --- /dev/null +++ b/src/components/skeletons/comment_skeleton.rs @@ -0,0 +1,38 @@ +use dioxus::prelude::*; +use crate::components::skeletons::atoms::*; + +#[component] +pub fn CommentListSkeleton() -> Element { + rsx! { + div { class: "animate-pulse space-y-6", + div { class: "h-8 w-32 bg-paper-tertiary/30 rounded mb-6" } + div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4", + div { class: "flex gap-3", + div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" } + div { class: "flex-1 space-y-2", + SkeletonBox { class: "h-4 w-1/4 rounded" } + SkeletonBox { class: "h-3 w-3/4 rounded" } + } + } + } + div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4 ml-6", + div { class: "flex gap-3", + div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" } + div { class: "flex-1 space-y-2", + SkeletonBox { class: "h-4 w-1/4 rounded" } + SkeletonBox { class: "h-3 w-3/4 rounded" } + } + } + } + div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4", + div { class: "flex gap-3", + div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" } + div { class: "flex-1 space-y-2", + SkeletonBox { class: "h-4 w-1/4 rounded" } + SkeletonBox { class: "h-3 w-3/4 rounded" } + } + } + } + } + } +} diff --git a/src/components/skeletons/mod.rs b/src/components/skeletons/mod.rs index f24ec85..7518ebf 100644 --- a/src/components/skeletons/mod.rs +++ b/src/components/skeletons/mod.rs @@ -1,5 +1,6 @@ pub mod atoms; pub mod archive_skeleton; +pub mod comment_skeleton; pub mod delayed_skeleton; pub mod home_skeleton; pub mod post_card_skeleton; diff --git a/src/main.rs b/src/main.rs index 1a56f6f..4244821 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,10 @@ fn main() { use dioxus::server::{axum, DioxusRouterExt, ServeConfig}; use tower_http::trace::TraceLayer; + tokio::spawn(async { + tasks::ip_purge::run_purge().await; + }); + tokio::spawn(async { tasks::session_cleanup::run_cleanup().await; }); diff --git a/src/models/comment.rs b/src/models/comment.rs new file mode 100644 index 0000000..f3d1125 --- /dev/null +++ b/src/models/comment.rs @@ -0,0 +1,136 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CommentStatus { + Pending, + Approved, + Spam, + Trash, +} + +impl CommentStatus { + pub fn from_str(s: &str) -> Self { + match s { + "approved" => Self::Approved, + "spam" => Self::Spam, + "trash" => Self::Trash, + _ => Self::Pending, + } + } + + #[allow(dead_code)] + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Spam => "spam", + Self::Trash => "trash", + } + } +} + +#[cfg(feature = "server")] +#[allow(dead_code)] +pub struct Comment { + pub id: i64, + pub post_id: i32, + pub parent_id: Option, + pub depth: i32, + pub author_name: String, + pub author_email: String, + pub author_url: Option, + pub content_md: String, + pub content_html: Option, + pub content_hash: Option, + pub status: CommentStatus, + pub ip_address: Option, + pub user_agent: Option, + pub consented_at: Option>, + pub approved_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PublicComment { + pub id: i64, + pub parent_id: Option, + pub depth: i32, + pub author_name: String, + pub author_url: Option, + pub avatar_url: String, + pub content_html: Option, + pub created_at: String, + pub created_at_iso: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AdminComment { + pub id: i64, + pub post_id: i32, + pub post_title: String, + pub post_slug: String, + pub parent_id: Option, + pub depth: i32, + pub author_name: String, + pub author_email: String, + pub author_url: Option, + pub content_md: String, + pub status: CommentStatus, + pub created_at: DateTime, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comment_status_from_str() { + assert_eq!(CommentStatus::from_str("pending"), CommentStatus::Pending); + assert_eq!(CommentStatus::from_str("approved"), CommentStatus::Approved); + assert_eq!(CommentStatus::from_str("spam"), CommentStatus::Spam); + assert_eq!(CommentStatus::from_str("trash"), CommentStatus::Trash); + } + + #[test] + fn comment_status_from_str_unknown_defaults_to_pending() { + assert_eq!(CommentStatus::from_str("unknown"), CommentStatus::Pending); + assert_eq!(CommentStatus::from_str(""), CommentStatus::Pending); + } + + #[test] + fn comment_status_as_str() { + assert_eq!(CommentStatus::Pending.as_str(), "pending"); + assert_eq!(CommentStatus::Approved.as_str(), "approved"); + assert_eq!(CommentStatus::Spam.as_str(), "spam"); + assert_eq!(CommentStatus::Trash.as_str(), "trash"); + } + + #[test] + fn comment_status_serde_roundtrip() { + let statuses = vec![ + CommentStatus::Pending, + CommentStatus::Approved, + CommentStatus::Spam, + CommentStatus::Trash, + ]; + for status in statuses { + let json = serde_json::to_string(&status).unwrap(); + let expected = format!("\"{}\"", status.as_str()); + assert_eq!(json, expected); + let deserialized: CommentStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, status); + } + } + + #[test] + fn comment_status_deserialize_from_lowercase() { + let pending: CommentStatus = serde_json::from_str("\"pending\"").unwrap(); + assert_eq!(pending, CommentStatus::Pending); + let approved: CommentStatus = serde_json::from_str("\"approved\"").unwrap(); + assert_eq!(approved, CommentStatus::Approved); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 6f1b986..2a5d7a6 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,2 +1,3 @@ +pub mod comment; pub mod post; pub mod user; diff --git a/src/pages/admin/comments.rs b/src/pages/admin/comments.rs new file mode 100644 index 0000000..4010ae7 --- /dev/null +++ b/src/pages/admin/comments.rs @@ -0,0 +1,400 @@ +use std::collections::HashSet; + +use dioxus::prelude::*; +use dioxus::router::components::Link; + +use crate::api::comments::{ + approve_comment, batch_update_comment_status, get_all_comments, spam_comment, + AllCommentsResponse, +}; +#[cfg(target_arch = "wasm32")] +use crate::api::comments::trash_comment; +use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; +use crate::models::comment::CommentStatus; +use crate::router::Route; + +const COMMENTS_PER_PAGE: i32 = 20; + +#[component] +pub fn AdminComments() -> Element { + rsx! { AdminCommentsPage { page: 1 } } +} + +#[component] +pub fn AdminCommentsPage(page: i32) -> Element { + let current_page = page.max(1); + let mut active_filter = use_signal(|| { + #[cfg(target_arch = "wasm32")] + { + web_sys::window() + .and_then(|w| w.location().search().ok()) + .and_then(|s| { + let params = s.trim_start_matches('?'); + for pair in params.split('&') { + if let Some(val) = pair.strip_prefix("status=") { + return Some(val.to_string()); + } + } + None + }) + .unwrap_or_default() + } + #[cfg(not(target_arch = "wasm32"))] + String::new() + }); + let mut selected_ids: Signal> = use_signal(HashSet::new); + let filter_status = move || { + let f = active_filter(); + if f.is_empty() { None } else { Some(f) } + }; + let mut comments_res = + use_server_future(move || get_all_comments(current_page, filter_status()))?; + + rsx! { + div { class: "space-y-6", + h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]", + "评论管理" + } + + div { class: "flex gap-1 border-b border-gray-200 dark:border-[#333]", + for (status, label) in [("", "全部"), ("pending", "待审核"), ("approved", "已通过"), ("spam", "垃圾箱")] { + button { + class: if active_filter() == status { + "px-4 py-2 text-sm font-medium border-b-2 border-gray-900 dark:border-[#dadadb] text-gray-900 dark:text-[#dadadb]" + } else { + "px-4 py-2 text-sm font-medium text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors" + }, + onclick: move |_| active_filter.set(status.to_string()), + "{label}" + } + } + } + + if !selected_ids().is_empty() { + { rsx! { + div { class: "flex items-center gap-3 p-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg", + span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]", + "已选择 {selected_ids().len()} 条" + } + button { + class: "px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors", + onclick: move |_| { + let ids: Vec = selected_ids().iter().copied().collect(); + spawn(async move { + let _ = batch_update_comment_status(ids, "approved".to_string()).await; + }); + selected_ids.set(HashSet::new()); + comments_res.restart(); + }, + "批量通过" + } + button { + class: "px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors", + onclick: move |_| { + let ids: Vec = selected_ids().iter().copied().collect(); + spawn(async move { + let _ = batch_update_comment_status(ids, "spam".to_string()).await; + }); + selected_ids.set(HashSet::new()); + comments_res.restart(); + }, + "批量垃圾" + } + button { + class: "px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors", + onclick: move |_| { + #[cfg(target_arch = "wasm32")] + { + if web_sys::window() + .and_then(|w| w.confirm_with_message("确定要删除这些评论吗?")) + .unwrap_or(false) + { + let ids: Vec = selected_ids().iter().copied().collect(); + spawn(async move { + let _ = batch_update_comment_status(ids, "trash".to_string()).await; + }); + selected_ids.set(HashSet::new()); + comments_res.restart(); + } + } + }, + "批量删除" + } + } + } } + } + + { + let data = comments_res.read().as_ref().map(|r| match r { + Ok(AllCommentsResponse { comments, total }) => Ok((comments.clone(), *total)), + Err(e) => Err(e.to_string()), + }); + match data { + Some(Ok((comments, total))) => { + if comments.is_empty() { + rsx! { + div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]", + "暂无评论" + } + } + } else { + let all_selected = comments.iter().all(|c| selected_ids().contains(&c.id)); + let all_ids: Vec = comments.iter().map(|c| c.id).collect(); + rsx! { + div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden", + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]", + th { class: "px-4 py-3 font-medium w-10", + input { + r#type: "checkbox", + class: "rounded border-gray-300 dark:border-[#555]", + checked: all_selected, + onchange: { + move |_| { + let mut s = selected_ids(); + if all_selected { + for id in &all_ids { s.remove(id); } + } else { + for id in &all_ids { s.insert(*id); } + } + selected_ids.set(s); + } + } + } + } + th { class: "px-4 py-3 font-medium", "作者" } + th { class: "px-4 py-3 font-medium", "内容" } + th { class: "px-4 py-3 font-medium", "文章" } + th { class: "px-4 py-3 font-medium w-20 text-center", "状态" } + th { class: "px-4 py-3 font-medium w-28", "日期" } + th { class: "px-4 py-3 font-medium w-32 text-right", "操作" } + } + } + tbody { + for comment in comments.iter() { + CommentRow { + key: "{comment.id}", + comment: comment.clone(), + selected: selected_ids().contains(&comment.id), + on_select: { + let id = comment.id; + move |checked: bool| { + let mut s = selected_ids(); + if checked { s.insert(id); } else { s.remove(&id); } + selected_ids.set(s); + } + }, + on_approve: { + let id = comment.id; + move |_| { + spawn(async move { + let _ = approve_comment(id).await; + }); + comments_res.restart(); + } + }, + on_spam: { + let id = comment.id; + move |_| { + spawn(async move { + let _ = spam_comment(id).await; + }); + comments_res.restart(); + } + }, + on_trash: { + #[allow(unused_variables)] + let id = comment.id; + move |_| { + #[cfg(target_arch = "wasm32")] + { + if web_sys::window() + .and_then(|w| w.confirm_with_message("确定要删除这条评论吗?")) + .unwrap_or(false) + { + let id = id; + spawn(async move { + let _ = trash_comment(id).await; + }); + comments_res.restart(); + } + } + } + }, + } + } + } + } + } + } + CommentsPagination { current_page, total } + } + } + } + Some(Err(_e)) => { + rsx! { + div { class: "text-center text-red-500 dark:text-red-400 py-20", + "加载失败" + } + } + } + None => { + rsx! { + DelayedSkeleton { + div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-6 space-y-4", + for _ in 0..5 { + div { class: "flex items-center gap-4", + div { class: "h-4 w-4 bg-gray-200 dark:bg-[#2a2a2a] rounded" } + div { class: "h-8 w-8 bg-gray-200 dark:bg-[#2a2a2a] rounded-full" } + div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded" } + div { class: "h-4 flex-1 bg-gray-200 dark:bg-[#2a2a2a] rounded" } + } + } + } + } + } + } + } + } + } + } +} + +#[component] +fn CommentRow( + comment: crate::models::comment::AdminComment, + selected: bool, + on_select: EventHandler, + on_approve: EventHandler, + on_spam: EventHandler, + on_trash: EventHandler, +) -> Element { + 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::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 preview = if comment.content_md.len() > 100 { + format!("{}...", &comment.content_md[..comment.content_md.ceil_char_boundary(100)]) + } else { + comment.content_md.clone() + }; + + rsx! { + tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors", + td { class: "px-4 py-3", + input { + r#type: "checkbox", + class: "rounded border-gray-300 dark:border-[#555]", + checked: selected, + onchange: move |e| on_select.call(e.checked()), + } + } + td { class: "px-4 py-3", + div { class: "flex items-center gap-2", + div { class: "w-8 h-8 rounded-full bg-gray-200 dark:bg-[#444] flex-shrink-0" } + div { class: "min-w-0", + div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate", + "{comment.author_name}" + } + div { class: "text-xs text-gray-400 dark:text-[#666] truncate", + "{comment.author_email}" + } + } + } + } + td { class: "px-4 py-3 max-w-xs", + p { class: "text-sm text-gray-600 dark:text-[#9b9c9d] truncate", + "{preview}" + } + } + td { class: "px-4 py-3", + Link { + class: "text-sm text-gray-700 dark:text-[#dadadb] hover:opacity-80 transition-opacity", + to: Route::PostDetail { slug: comment.post_slug.clone() }, + "{comment.post_title}" + } + } + td { class: "px-4 py-3 text-center", + span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {badge_class}", + "{status_label}" + } + } + td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]", + "{date_str}" + } + td { class: "px-4 py-3 text-right", + div { class: "flex justify-end gap-2", + button { + class: "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors", + onclick: move |_| on_approve.call(()), + "通过" + } + button { + class: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors", + onclick: move |_| on_spam.call(()), + "垃圾" + } + button { + class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors", + onclick: move |_| on_trash.call(()), + "删除" + } + } + } + } + } +} + +#[component] +fn CommentsPagination(current_page: i32, total: i64) -> Element { + let has_prev = current_page > 1; + let total_pages = + ((total + COMMENTS_PER_PAGE as i64 - 1) / COMMENTS_PER_PAGE as i64).max(1) as i32; + let has_next = current_page < total_pages; + + let prev_route = if current_page - 1 <= 1 { + Route::AdminComments {} + } else { + Route::AdminCommentsPage { page: current_page - 1 } + }; + let next_route = Route::AdminCommentsPage { page: current_page + 1 }; + + rsx! { + nav { class: "flex mt-6 justify-between", + if has_prev { + Link { + class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer", + to: prev_route, + span { class: "mr-1", "«" } + "上一页" + } + } else { + span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed", + span { class: "mr-1", "«" } + "上一页" + } + } + span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center", + "{current_page} / {total_pages} 页 (共 {total} 条)" + } + if has_next { + Link { + class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer", + to: next_route, + "下一页" + span { class: "ml-1", "»" } + } + } else { + span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed", + "下一页" + span { class: "ml-1", "»" } + } + } + } + } +} diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index 6ee0ada..0c7d4ec 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; use dioxus::router::components::Link; +use crate::api::comments::get_pending_count; use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse}; use crate::hooks::delayed_loading::use_delayed_loading; use crate::models::post::Post; @@ -10,6 +11,7 @@ use crate::router::Route; pub fn Admin() -> Element { let stats_res = use_resource(get_post_stats); let posts_res = use_resource(|| list_posts(1, 5)); + let pending_res = use_resource(get_pending_count); let show_stats_skeleton = use_delayed_loading(move || stats_res.read().is_none()); let show_posts_skeleton = use_delayed_loading(move || posts_res.read().is_none()); @@ -37,6 +39,29 @@ pub fn Admin() -> Element { } } + Link { + class: "block rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center hover:border-gray-300 dark:hover:border-[#555] transition-colors", + to: Route::AdminComments {}, + match &*pending_res.read() { + Some(Ok(resp)) => { + rsx! { + div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400", + "{resp.count}" + } + div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2", + "待审核评论" + } + } + } + _ => { + rsx! { + div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded mt-3 animate-pulse" } + } + } + } + } + div { class: "grid grid-cols-1 md:grid-cols-2 gap-4", Link { class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer", diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index 64c8ae3..4ea2a15 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -1,7 +1,9 @@ +pub mod comments; pub mod dashboard; pub mod posts; pub mod write; +pub use comments::{AdminComments, AdminCommentsPage}; pub use dashboard::Admin; pub use posts::{Posts, PostsPage}; pub use write::{Write, WriteEdit}; diff --git a/src/pages/post_detail.rs b/src/pages/post_detail.rs index 1632fa6..3a9587c 100644 --- a/src/pages/post_detail.rs +++ b/src/pages/post_detail.rs @@ -48,6 +48,17 @@ pub fn PostDetail(slug: String) -> Element { } PostFooter { post: post.clone() } + + if post.status == crate::models::post::PostStatus::Published { + div { class: "mt-12 border-t border-gray-200 dark:border-[#333] pt-8", + SuspenseBoundary { + fallback: move |_| rsx! { + crate::components::skeletons::comment_skeleton::CommentListSkeleton {} + }, + crate::components::comments::section::CommentSection { post_id: post.id } + } + } + } } } } diff --git a/src/router.rs b/src/router.rs index 5e96e78..f3e1815 100644 --- a/src/router.rs +++ b/src/router.rs @@ -5,7 +5,7 @@ use crate::components::admin_layout::AdminLayout; use crate::components::frontend_layout::FrontendLayout; use crate::context::UserContext; use crate::pages::about::About; -use crate::pages::admin::{Admin, Posts, PostsPage, Write, WriteEdit}; +use crate::pages::admin::{Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit}; use crate::pages::archives::Archives; use crate::pages::home::{Home, HomePage}; use crate::pages::login::Login; @@ -52,6 +52,10 @@ pub enum Route { Posts {}, #[route("/posts/:page")] PostsPage { page: i32 }, + #[route("/comments")] + AdminComments {}, + #[route("/comments/:page")] + AdminCommentsPage { page: i32 }, #[end_layout] #[end_nest] diff --git a/src/tasks/ip_purge.rs b/src/tasks/ip_purge.rs new file mode 100644 index 0000000..74c90d4 --- /dev/null +++ b/src/tasks/ip_purge.rs @@ -0,0 +1,25 @@ +use std::time::Duration; + +use tokio::time::interval; + +use crate::db::pool::get_conn; + +pub async fn run_purge() { + let mut ticker = interval(Duration::from_secs(86400)); + loop { + ticker.tick().await; + match get_conn().await { + Ok(client) => { + if let Err(e) = client + .execute("UPDATE comments SET ip_address = NULL, user_agent = NULL WHERE created_at < NOW() - INTERVAL '90 days' AND ip_address IS NOT NULL", &[]) + .await + { + tracing::error!("IP purge error: {:?}", e); + } + } + Err(e) => { + tracing::error!("Failed to get DB connection for IP purge: {:?}", e); + } + } + } +} diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index 5b016c6..789dfa5 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -1,2 +1,4 @@ #[cfg(feature = "server")] +pub mod ip_purge; +#[cfg(feature = "server")] pub mod session_cleanup;