yggdrasil/src/pages/post_detail.rs
xfy 449a545886
Some checks failed
CI / build (push) Has been cancelled
CI / check (push) Has been cancelled
security: fix critical issues from repository review
P0 blockers:
- Fix migration numbering conflict and duplicate indexes
- Change comments.post_id FK to ON DELETE CASCADE
- Restrict public post detail endpoint to published posts only
- Fix rate-limiting IP extraction and fallback to ConnectInfo
- Harden HTML sanitizer: deny unknown URL schemes, restrict data URIs
- Remove session token from login response body
- Enforce image pixel/dimension limits on upload and serving

P1 high-risk:
- Validate uploads by magic bytes and decode GIF/WebP
- Add pagination/rate-limiting to search, tag posts, and comments
- Make first-admin registration and slug uniqueness check atomic
- HTML-escape comment author fields
- Improve HTML minify cache key and skip admin/error responses
- Add mobile navigation menu

P2 accessibility/quality:
- Associate form labels with inputs
- Key PostDetail article by slug to re-init scripts on navigation
- Improve image viewer keyboard accessibility
- Make theme toggle SSR-friendly and add aria-label
- Invalidate slug 404 cache on create and pending count on new comment
- Deduplicate tags case-insensitively

P3 cleanup:
- Remove unused tower-http dependency, expand make clean
- Configure DB pool timeouts and verified recycling
- Run background cleanup tasks immediately on startup
- Use SHA-256 for stable disk cache keys
- Log DB errors with Display instead of Debug
- Update README migration instructions

All tests pass (321), clippy clean, dx check clean.
2026-06-17 10:34:14 +08:00

117 lines
4.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章详情页面模块。
//!
//! 对应路由 `/post/:slug`。
//!
//! 数据获取:通过 `use_server_future` 调用 `get_post_by_slug` server function
//! 根据 URL 中的 slug 获取单篇文章详情(含正文 HTML、目录、封面及上下篇导航
//! 由于 Dioxus 组件参数在路由切换时可能复用同一组件实例,
//! 这里使用 `use_signal` 保存当前 slug并在参数变化时更新信号以触发重新取数。
//! 在 `wasm32` 目标下server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::api::posts::{get_post_by_slug, SinglePostResponse};
use crate::components::post::post_content::PostContent;
use crate::components::post::post_cover::PostCover;
use crate::components::post::post_footer::PostFooter;
use crate::components::post::post_header::PostHeader;
use crate::components::post::post_toc::PostToc;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
use crate::router::Route;
/// 文章详情页面组件,对应路由 `/post/:slug`。
///
/// 根据 slug 异步获取文章,渲染文章头部、封面、目录、正文、页脚及评论区;
/// 若文章不存在或加载失败,则展示对应的提示页面。
#[component]
pub fn PostDetail(slug: String) -> Element {
// 使用信号保存当前 slug以便在路由参数变化时重新触发 server future。
let mut slug_signal = use_signal(|| slug.clone());
if slug_signal() != slug {
slug_signal.set(slug.clone());
}
// 当 slug 信号变化时,自动重新调用 server function 获取文章详情。
let post = use_server_future(move || {
let s = slug_signal();
get_post_by_slug(s)
})?;
// 将结果映射为更直观的 Ok(post) / Err("not_found") / Err("error") 三种状态。
let post_data = post.read().as_ref().map(|r| match r {
Ok(SinglePostResponse { post: Some(post) }) => Ok(post.clone()),
Ok(SinglePostResponse { post: None }) => Err("not_found"),
Err(_) => Err("error"),
});
match post_data {
Some(Ok(post)) => {
rsx! {
article { class: "post-single", key: "{slug}",
PostHeader { post: post.clone() }
// 如果文章设置了封面图,则渲染封面组件。
if let Some(cover) = &post.cover_image {
PostCover { src: cover.clone() }
}
// 如果文章生成了目录 HTML则渲染目录组件。
if let Some(toc) = &post.toc_html {
PostToc { toc_html: toc.clone() }
}
PostContent {
content_html: post.content_html.clone().unwrap_or_default()
}
PostFooter { post: post.clone() }
// 仅对已发布文章展示评论区域,使用 SuspenseBoundary 处理加载状态。
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 }
}
}
}
}
}
}
Some(Err("not_found")) => {
rsx! {
div { class: "text-center py-20",
h2 { class: "text-2xl font-bold text-paper-primary mb-4",
"文章不存在"
}
p { class: "text-paper-secondary mb-6",
"这篇文章可能已被删除或移动。"
}
Link {
class: "px-6 py-2 bg-paper-primary text-paper-theme rounded-full font-medium hover:opacity-80 transition-opacity",
to: Route::Home {},
"返回首页"
}
}
}
}
Some(Err("error")) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
}
}
_ => {
rsx! {
DelayedSkeleton { PostDetailSkeleton {} }
}
}
}
}