Compare commits
No commits in common. "1a56c0cd3f494588841dd36700b54fbd0997b069" and "b7afd12538ed7f5318690c4616b38332c44c7b01" have entirely different histories.
1a56c0cd3f
...
b7afd12538
@ -71,7 +71,7 @@ Dioxus 0.7 fullstack project with **two independent gates** — the most common
|
||||
|
||||
**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when `server` feature is disabled — do not remove.
|
||||
|
||||
**Server-only helpers**: `src/auth/password.rs`, `src/auth/session.rs`, `src/api/auth.rs`, `src/api/comments/helpers.rs`, and several model helper methods are gated with `#[cfg(feature = "server")]` because they are only called from server function bodies, which are stripped in WASM builds.
|
||||
**Dead code allowances**: `src/auth/password.rs` and `src/auth/session.rs` carry `#[allow(dead_code)]` because the compiler sees them as unused in WASM builds where server function bodies are stripped.
|
||||
|
||||
## Dual API Architecture
|
||||
|
||||
@ -165,6 +165,6 @@ Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when th
|
||||
|
||||
## Notes
|
||||
|
||||
- `rand` is optional and only enabled by the `server` feature; it is not compiled into the WASM frontend.
|
||||
- `rand` + `getrandom` with `js` feature are required for Argon2 salt generation in WASM builds.
|
||||
- `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — `mut` signals are used in `#[cfg(target_arch = "wasm32")]` blocks stripped in server builds.
|
||||
- Server uses incremental rendering with 300s cache (`IncrementalRendererConfig` in `src/main.rs`).
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5309,6 +5309,7 @@ dependencies = [
|
||||
"deadpool-postgres",
|
||||
"dioxus",
|
||||
"dotenvy",
|
||||
"getrandom 0.2.17",
|
||||
"governor",
|
||||
"hex",
|
||||
"http",
|
||||
|
||||
25
Cargo.toml
25
Cargo.toml
@ -9,21 +9,22 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "time", "sync"], optional = true }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"], optional = true }
|
||||
deadpool-postgres = { version = "0.14", optional = true }
|
||||
argon2 = { version = "0.5", optional = true }
|
||||
uuid = { version = "1", features = ["v4"], optional = true }
|
||||
argon2 = "0.5"
|
||||
uuid = { version = "1", features = ["v4", "js"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
regex = { version = "1.12", optional = true }
|
||||
pulldown-cmark = { version = "0.13", optional = true }
|
||||
regex = "1.12"
|
||||
pulldown-cmark = "0.13"
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] }
|
||||
tracing-subscriber = { version = "0.3", optional = true }
|
||||
tower-http = { version = "0.6", optional = true }
|
||||
rand = { version = "0.8", features = ["getrandom"], optional = true }
|
||||
http = { version = "1", optional = true }
|
||||
rand = { version = "0.8", features = ["getrandom"] }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
http = "1"
|
||||
axum = { version = "0.8", optional = true, features = ["multipart"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
hex = { version = "0.4", optional = true }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
lol_html = { version = "2", optional = true }
|
||||
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "default-fancy", "html", "parsing", "dump-load", "yaml-load"], optional = true }
|
||||
# NOTE: WebP decoder is intentionally excluded from the image crate.
|
||||
@ -59,14 +60,6 @@ server = [
|
||||
"dep:tokio",
|
||||
"dep:tokio-postgres",
|
||||
"dep:deadpool-postgres",
|
||||
"dep:argon2",
|
||||
"dep:uuid",
|
||||
"dep:regex",
|
||||
"dep:pulldown-cmark",
|
||||
"dep:rand",
|
||||
"dep:http",
|
||||
"dep:sha2",
|
||||
"dep:hex",
|
||||
"dep:dotenvy",
|
||||
"dep:tracing",
|
||||
"dep:tracing-subscriber",
|
||||
|
||||
@ -23,7 +23,7 @@ use crate::db::pool::get_conn;
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::models::user::PublicUser;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
fn validate_username(username: &str) -> Result<(), String> {
|
||||
if username.len() < 3 || username.len() > 50 {
|
||||
return Err("用户名长度必须在 3-50 字符之间".to_string());
|
||||
@ -34,7 +34,7 @@ fn validate_username(username: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
fn validate_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) {
|
||||
@ -43,7 +43,7 @@ fn validate_email(email: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
fn validate_password(password: &str) -> Result<(), String> {
|
||||
if password.len() < 8 {
|
||||
return Err("密码长度至少 8 位".to_string());
|
||||
@ -387,7 +387,7 @@ pub async fn get_current_admin_user() -> Result<User, AppError> {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! 评论模块的辅助函数:数据转换、校验、哈希与头像生成。
|
||||
//!
|
||||
//! 所有工具函数仅在 `feature = "server"` 启用的服务端构建中使用。
|
||||
//! 大部分工具函数仅在 `feature = "server"` 启用的服务端构建中使用;
|
||||
//! 校验函数同时在前端构建中保留签名,避免编译器提示未使用。
|
||||
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
@ -86,7 +87,7 @@ pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||
}
|
||||
|
||||
/// 校验评论作者昵称:非空且不超过 50 字符。
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_name(name: &str) -> Result<(), String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@ -99,7 +100,7 @@ pub fn validate_comment_name(name: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// 校验评论作者邮箱格式。
|
||||
#[cfg(feature = "server")]
|
||||
#[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()) {
|
||||
@ -109,7 +110,7 @@ pub fn validate_comment_email(email: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头且不超过 200 字符。
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_url(url: &str) -> Result<(), String> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
@ -126,7 +127,7 @@ pub fn validate_comment_url(url: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// 校验评论内容:非空且不超过 10000 字符。
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_content(content: &str) -> Result<(), String> {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
||||
@ -3,6 +3,24 @@
|
||||
use crate::models::comment::{AdminComment, PublicComment};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 创建评论请求体(客户端使用)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CreateCommentRequest {
|
||||
/// 目标文章 id。
|
||||
pub post_id: i32,
|
||||
/// 父评论 id,顶层评论为 None。
|
||||
pub parent_id: Option<i64>,
|
||||
/// 评论者昵称。
|
||||
pub author_name: String,
|
||||
/// 评论者邮箱。
|
||||
pub author_email: String,
|
||||
/// 评论者个人网址。
|
||||
pub author_url: Option<String>,
|
||||
/// 评论 Markdown 原文。
|
||||
pub content_md: String,
|
||||
}
|
||||
|
||||
/// 创建/审核/删除评论的统一响应结构。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommentResponse {
|
||||
@ -33,9 +51,6 @@ pub struct CommentTreeResponse {
|
||||
}
|
||||
|
||||
/// 评论计数响应。
|
||||
///
|
||||
/// 此类型仅在服务端函数体中构造;保留 `#[allow(dead_code)]` 以避免 WASM 构建中
|
||||
/// 因函数体被剥离而产生的未使用警告。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CommentCountResponse {
|
||||
@ -44,9 +59,6 @@ pub struct CommentCountResponse {
|
||||
}
|
||||
|
||||
/// 待审核评论列表响应。
|
||||
///
|
||||
/// 当前前端未直接调用 `get_pending_comments`,此类型仅在服务端函数体中构造;
|
||||
/// 保留 `#[allow(dead_code)]` 以避免 WASM 构建中因函数体被剥离而产生的未使用警告。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PendingCommentsResponse {
|
||||
|
||||
@ -61,7 +61,7 @@ impl From<AppError> for ServerFnError {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@ -2,6 +2,26 @@
|
||||
|
||||
use crate::models::post::{Post, PostStats, Tag};
|
||||
|
||||
/// 创建/更新文章请求体(客户端使用)。
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CreatePostRequest {
|
||||
/// 文章标题。
|
||||
pub title: String,
|
||||
/// 自定义 slug,为空时由标题自动生成。
|
||||
pub slug: Option<String>,
|
||||
/// 文章摘要,为空时自动从正文提取。
|
||||
pub summary: Option<String>,
|
||||
/// Markdown 格式正文。
|
||||
pub content_md: String,
|
||||
/// 文章状态(如 draft / published)。
|
||||
pub status: String,
|
||||
/// 标签列表。
|
||||
pub tags: Vec<String>,
|
||||
/// 封面图 URL。
|
||||
pub cover_image: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建/更新/删除文章的统一响应结构。
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CreatePostResponse {
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
//! 密码哈希与校验(Argon2)。
|
||||
//!
|
||||
//! 使用随机 salt 生成 PHC 字符串格式哈希,并通过 Argon2 验证。
|
||||
//! 仅在 `feature = "server"` 启用的服务端构建中使用。
|
||||
//! `#[allow(dead_code)]` 用于避免 WASM 构建中服务端函数体被剥离后的未使用警告。
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
#[cfg(feature = "server")]
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
/// 使用 Argon2 对明文密码进行哈希。
|
||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
@ -20,7 +18,7 @@ pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Er
|
||||
Ok(password_hash.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
/// 校验明文密码是否与已存储的哈希匹配。
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
|
||||
let parsed_hash = PasswordHash::new(hash)?;
|
||||
@ -32,7 +30,7 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::passw
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@ -4,20 +4,17 @@
|
||||
//! Cookie 包含 HttpOnly、SameSite=Lax 与可选 Secure 标志。
|
||||
//! 服务端上下文解析函数仅在 `feature = "server"` 时可用。
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
#[cfg(feature = "server")]
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(feature = "server")]
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
/// 生成新的随机会话 token(UUID 格式)。
|
||||
pub fn generate_token() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
/// 使用 SHA-256 对 token 进行哈希,用于数据库存储。
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
@ -25,7 +22,7 @@ pub fn hash_token(token: &str) -> String {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
/// 返回默认会话过期时间(当前时间 + 30 天)。
|
||||
pub fn default_expiry() -> DateTime<Utc> {
|
||||
Utc::now() + Duration::days(30)
|
||||
|
||||
@ -33,7 +33,7 @@ impl CommentStatus {
|
||||
}
|
||||
|
||||
/// 将 CommentStatus 序列化为小写字符串。
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
@ -145,7 +145,6 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "server")]
|
||||
fn comment_status_from_str() {
|
||||
assert_eq!(CommentStatus::from_str("pending"), CommentStatus::Pending);
|
||||
assert_eq!(CommentStatus::from_str("approved"), CommentStatus::Approved);
|
||||
@ -154,7 +153,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "server")]
|
||||
fn comment_status_from_str_unknown_defaults_to_pending() {
|
||||
assert_eq!(CommentStatus::from_str("unknown"), CommentStatus::Pending);
|
||||
assert_eq!(CommentStatus::from_str(""), CommentStatus::Pending);
|
||||
|
||||
@ -17,6 +17,7 @@ pub enum PostStatus {
|
||||
|
||||
impl PostStatus {
|
||||
/// 将状态序列化为数据库或 API 使用的小写字符串。
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PostStatus::Draft => "draft",
|
||||
@ -25,7 +26,7 @@ impl PostStatus {
|
||||
}
|
||||
|
||||
/// 将字符串解析为 PostStatus,无法识别时返回 None。
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"draft" => Some(PostStatus::Draft),
|
||||
@ -174,7 +175,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "server")]
|
||||
fn post_status_from_str() {
|
||||
assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
|
||||
assert_eq!(
|
||||
@ -192,7 +192,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "server")]
|
||||
fn post_status_roundtrip() {
|
||||
for status in [PostStatus::Draft, PostStatus::Published] {
|
||||
assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
|
||||
|
||||
@ -17,7 +17,7 @@ pub enum UserRole {
|
||||
|
||||
impl UserRole {
|
||||
/// 将数据库中的角色字符串解析为 UserRole,无法识别时返回 None。
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(UserRole::Admin),
|
||||
@ -89,7 +89,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "server")]
|
||||
fn user_role_from_str() {
|
||||
assert_eq!(UserRole::from_str("admin"), Some(UserRole::Admin));
|
||||
assert_eq!(UserRole::from_str("blocked"), Some(UserRole::Blocked));
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// localStorage 中存储主题值的键名。
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
#[allow(dead_code)]
|
||||
const THEME_KEY: &str = "yggdrasil-theme";
|
||||
|
||||
/// 应用主题枚举。
|
||||
|
||||
@ -78,7 +78,7 @@ pub fn auto_summary(md: &str) -> String {
|
||||
plain.chars().take(200).collect()
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user