docs(api, auth): 补充中文注释
This commit is contained in:
parent
4fe26f7eb3
commit
26b012c40c
@ -1,3 +1,10 @@
|
|||||||
|
//! 认证相关的 Dioxus server function 与辅助函数。
|
||||||
|
//!
|
||||||
|
//! 提供注册、登录、登出、获取当前用户等接口,
|
||||||
|
//! 通过 HttpOnly Cookie 维护会话,首个注册用户自动成为 admin。
|
||||||
|
//! 所有 server function 均在 `#[server(Name, "/api")]` 下注册,供客户端与服务端调用。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与 Cookie 写入。
|
||||||
|
|
||||||
#![allow(clippy::unused_unit, deprecated)]
|
#![allow(clippy::unused_unit, deprecated)]
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
@ -41,18 +48,28 @@ fn validate_password(password: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
/// 认证接口统一响应结构。
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
|
/// 操作是否成功。
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
|
/// 提示信息。
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// 登录成功后的会话 token(已废弃,实际通过 Cookie 传递)。
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户注册。
|
||||||
|
///
|
||||||
|
/// 校验用户名、邮箱、密码,首个注册用户自动设为 admin;
|
||||||
|
/// 已有 admin 时返回 "Registration is closed"。
|
||||||
|
/// Dioxus server function,注册在 `/api` 路径下。
|
||||||
#[server(Register, "/api")]
|
#[server(Register, "/api")]
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<AuthResponse, ServerFnError> {
|
) -> Result<AuthResponse, ServerFnError> {
|
||||||
|
// 服务端构建时先进行严格限流检查。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
@ -92,6 +109,7 @@ pub async fn register(
|
|||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 仅允许第一个注册用户注册为 admin,其余拒绝。
|
||||||
let admin_count: i64 = client
|
let admin_count: i64 = client
|
||||||
.query_one("SELECT COUNT(*) FROM users WHERE role = 'admin'", &[])
|
.query_one("SELECT COUNT(*) FROM users WHERE role = 'admin'", &[])
|
||||||
.await
|
.await
|
||||||
@ -137,8 +155,14 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户登录。
|
||||||
|
///
|
||||||
|
/// 验证用户名/邮箱与密码,生成会话并写入 HttpOnly Cookie;
|
||||||
|
/// 同一用户活跃会话数超过 `MAX_SESSIONS_PER_USER` 时删除最早会话。
|
||||||
|
/// Dioxus server function,注册在 `/api` 路径下。
|
||||||
#[server(Login, "/api")]
|
#[server(Login, "/api")]
|
||||||
pub async fn login(username: String, password: String) -> Result<AuthResponse, ServerFnError> {
|
pub async fn login(username: String, password: String) -> Result<AuthResponse, ServerFnError> {
|
||||||
|
// 服务端构建时先进行严格限流检查。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
@ -199,6 +223,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
.unwrap_or(5)
|
.unwrap_or(5)
|
||||||
.max(1);
|
.max(1);
|
||||||
|
|
||||||
|
// 查询当前活跃会话数,超出限制时删除最早的一条。
|
||||||
let session_count: i64 = client
|
let session_count: i64 = client
|
||||||
.query_one(
|
.query_one(
|
||||||
"SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
|
"SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
|
||||||
@ -232,6 +257,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure());
|
let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure());
|
||||||
|
// 通过 Dioxus FullstackContext 设置 HttpOnly Cookie 响应头。
|
||||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
|
if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
|
||||||
ctx.add_response_header(SET_COOKIE, value);
|
ctx.add_response_header(SET_COOKIE, value);
|
||||||
@ -245,12 +271,17 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户登出。
|
||||||
|
///
|
||||||
|
/// 清空客户端 session Cookie,并删除数据库中对应会话记录。
|
||||||
|
/// Dioxus server function,注册在 `/api` 路径下。
|
||||||
#[server(Logout, "/api")]
|
#[server(Logout, "/api")]
|
||||||
pub async fn logout() -> Result<AuthResponse, ServerFnError> {
|
pub async fn logout() -> Result<AuthResponse, ServerFnError> {
|
||||||
let token = get_session_from_ctx();
|
let token = get_session_from_ctx();
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 设置过期时间为 0 的 Cookie,通知浏览器清除会话。
|
||||||
let cookie = session::session_cookie("", 0, session::cookie_secure());
|
let cookie = session::session_cookie("", 0, session::cookie_secure());
|
||||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
|
if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
|
||||||
@ -274,11 +305,16 @@ pub async fn logout() -> Result<AuthResponse, ServerFnError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
/// 当前用户查询响应。
|
||||||
pub struct CurrentUserResponse {
|
pub struct CurrentUserResponse {
|
||||||
|
/// 当前已登录用户的公开信息;未登录时为 `None`。
|
||||||
pub user: Option<PublicUser>,
|
pub user: Option<PublicUser>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。
|
||||||
|
///
|
||||||
|
/// 仅服务端内部使用,不会暴露给前端。
|
||||||
pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnError> {
|
pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnError> {
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
@ -313,6 +349,9 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
|
|||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取当前登录用户的公开信息。
|
||||||
|
///
|
||||||
|
/// Dioxus server function,注册在 `/api` 路径下。
|
||||||
#[server(GetCurrentUser, "/api")]
|
#[server(GetCurrentUser, "/api")]
|
||||||
pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
|
pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
|
||||||
let token = match get_session_from_ctx() {
|
let token = match get_session_from_ctx() {
|
||||||
@ -326,6 +365,9 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 获取当前登录用户并要求其为 admin,否则返回 401/403。
|
||||||
|
///
|
||||||
|
/// 供其它服务端接口内部调用。
|
||||||
pub async fn get_current_admin_user() -> Result<User, AppError> {
|
pub async fn get_current_admin_user() -> Result<User, AppError> {
|
||||||
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
|
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +1,51 @@
|
|||||||
|
//! 应用错误类型与 `ServerFnError` 转换。
|
||||||
|
//!
|
||||||
|
//! `AppError` 封装认证、权限、数据库、内部错误等场景,
|
||||||
|
//! 并转换为对外友好的 `ServerFnError` 消息,避免泄露 SQL 细节。
|
||||||
|
|
||||||
use dioxus::prelude::ServerFnError;
|
use dioxus::prelude::ServerFnError;
|
||||||
|
|
||||||
|
/// 应用层统一错误类型。
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
|
/// 未认证(401)。
|
||||||
Unauthorized(&'static str),
|
Unauthorized(&'static str),
|
||||||
|
/// 无权限(403)。
|
||||||
Forbidden(&'static str),
|
Forbidden(&'static str),
|
||||||
|
/// 资源不存在(404)。
|
||||||
NotFound(&'static str),
|
NotFound(&'static str),
|
||||||
|
/// 数据库连接失败。
|
||||||
DbConn(String),
|
DbConn(String),
|
||||||
|
/// SQL 查询执行失败。
|
||||||
Query(String),
|
Query(String),
|
||||||
|
/// 数据库事务失败。
|
||||||
Transaction(String),
|
Transaction(String),
|
||||||
|
/// 内部通用错误。
|
||||||
Internal(&'static str),
|
Internal(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
impl AppError {
|
impl AppError {
|
||||||
|
/// 记录并包装数据库连接错误。
|
||||||
pub fn db_conn(e: impl std::fmt::Debug) -> Self {
|
pub fn db_conn(e: impl std::fmt::Debug) -> Self {
|
||||||
tracing::error!("DB connection failed: {e:?}");
|
tracing::error!("DB connection failed: {e:?}");
|
||||||
AppError::DbConn(format!("{e:?}"))
|
AppError::DbConn(format!("{e:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 记录并包装 SQL 查询错误。
|
||||||
pub fn query(e: impl std::fmt::Debug) -> Self {
|
pub fn query(e: impl std::fmt::Debug) -> Self {
|
||||||
tracing::error!("Query failed: {e:?}");
|
tracing::error!("Query failed: {e:?}");
|
||||||
AppError::Query(format!("{e:?}"))
|
AppError::Query(format!("{e:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 记录并包装数据库事务错误。
|
||||||
pub fn tx(e: impl std::fmt::Debug) -> Self {
|
pub fn tx(e: impl std::fmt::Debug) -> Self {
|
||||||
tracing::error!("Transaction failed: {e:?}");
|
tracing::error!("Transaction failed: {e:?}");
|
||||||
AppError::Transaction(format!("{e:?}"))
|
AppError::Transaction(format!("{e:?}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 转换为 `ServerFnError`,对数据库类错误返回通用中文提示。
|
||||||
impl From<AppError> for ServerFnError {
|
impl From<AppError> for ServerFnError {
|
||||||
fn from(err: AppError) -> ServerFnError {
|
fn from(err: AppError) -> ServerFnError {
|
||||||
let msg = match &err {
|
let msg = match &err {
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 图片服务的 Axum 处理器与处理流水线。
|
||||||
|
//!
|
||||||
|
//! 支持按宽度/高度、缩略图、旋转角度、输出格式/质量动态处理图片,
|
||||||
|
//! 使用内存(moka)+ 磁盘两级缓存加速响应。
|
||||||
|
//! WebP 编解码走 `zenwebp`(`image` crate 未启用 WebP feature)。
|
||||||
|
//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query},
|
extract::{Path, Query},
|
||||||
@ -16,10 +23,12 @@ const MAX_IMAGE_DIMENSION: u32 = 4096;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
const DEFAULT_JPEG_QUALITY: u8 = 85;
|
const DEFAULT_JPEG_QUALITY: u8 = 85;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 允许处理的最大图片像素数(约 10k x 10k)。
|
||||||
pub const MAX_IMAGE_PIXELS: u32 = 100_000_000; // ~10k x 10k
|
pub const MAX_IMAGE_PIXELS: u32 = 100_000_000; // ~10k x 10k
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
/// 缓存条目,保存处理后的图片字节与 Content-Type。
|
||||||
struct CachedImage {
|
struct CachedImage {
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
content_type: HeaderValue,
|
content_type: HeaderValue,
|
||||||
@ -35,12 +44,19 @@ static IMAGE_CACHE: LazyLock<Cache<String, CachedImage>> = LazyLock::new(|| {
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
|
#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
|
||||||
|
/// 图片处理查询参数。
|
||||||
pub struct ImageParams {
|
pub struct ImageParams {
|
||||||
|
/// 限制最大宽度。
|
||||||
pub w: Option<u32>,
|
pub w: Option<u32>,
|
||||||
|
/// 限制最大高度。
|
||||||
pub h: Option<u32>,
|
pub h: Option<u32>,
|
||||||
|
/// 缩略图尺寸,格式 `WxH`。
|
||||||
pub thumb: Option<String>,
|
pub thumb: Option<String>,
|
||||||
|
/// 旋转角度,仅允许 0/90/180/270。
|
||||||
pub rotate: Option<u16>,
|
pub rotate: Option<u16>,
|
||||||
|
/// 输出格式:`jpeg`/`jpg`、`png`、`webp`。
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
|
/// 输出质量,范围 1-100。
|
||||||
pub quality: Option<u8>,
|
pub quality: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,6 +94,7 @@ impl ImageParams {
|
|||||||
parts.join("|")
|
parts.join("|")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验参数合法性,返回 HTTP 400 状态码表示非法。
|
||||||
fn validate(&self) -> Result<(), StatusCode> {
|
fn validate(&self) -> Result<(), StatusCode> {
|
||||||
if let Some(dim) = self.w {
|
if let Some(dim) = self.w {
|
||||||
if dim == 0 || dim > MAX_IMAGE_DIMENSION {
|
if dim == 0 || dim > MAX_IMAGE_DIMENSION {
|
||||||
@ -279,6 +296,10 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 图片访问与动态处理的 Axum handler。
|
||||||
|
///
|
||||||
|
/// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 →
|
||||||
|
/// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。
|
||||||
pub async fn serve_image(
|
pub async fn serve_image(
|
||||||
Path(path): Path<String>,
|
Path(path): Path<String>,
|
||||||
Query(params): Query<ImageParams>,
|
Query(params): Query<ImageParams>,
|
||||||
@ -344,6 +365,7 @@ pub async fn serve_image(
|
|||||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// WebP 解码使用 zenwebp,其它格式使用 image crate。
|
||||||
let original_format = detect_format(&path);
|
let original_format = detect_format(&path);
|
||||||
let img = if original_format == image::ImageFormat::WebP {
|
let img = if original_format == image::ImageFormat::WebP {
|
||||||
match crate::webp::decode(&data) {
|
match crate::webp::decode(&data) {
|
||||||
|
|||||||
@ -1,24 +1,36 @@
|
|||||||
|
//! Markdown 渲染与目录生成。
|
||||||
|
//!
|
||||||
|
//! 使用 pulldown-cmark 解析 Markdown,为标题生成锚点与目录(TOC),
|
||||||
|
//! 代码块调用 `highlight` 模块进行语法高亮,最终通过 sanitizer 清理 HTML。
|
||||||
|
//! 仅在 `feature = "server"` 时执行实际渲染。
|
||||||
|
|
||||||
#![allow(clippy::unused_unit, deprecated)]
|
#![allow(clippy::unused_unit, deprecated)]
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 对外暴露的 HTML 清理函数,委托给 sanitizer 模块。
|
||||||
pub fn clean_html(input: &str) -> String {
|
pub fn clean_html(input: &str) -> String {
|
||||||
crate::api::sanitizer::clean_html(input)
|
crate::api::sanitizer::clean_html(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// Markdown 渲染结果。
|
||||||
pub struct RenderedContent {
|
pub struct RenderedContent {
|
||||||
|
/// 清理后的正文 HTML。
|
||||||
pub html: String,
|
pub html: String,
|
||||||
|
/// 目录 HTML(无标题时为空字符串)。
|
||||||
pub toc_html: String,
|
pub toc_html: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。
|
||||||
pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
||||||
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
|
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
|
||||||
|
|
||||||
// 1. Parse markdown and collect headings for TOC
|
// 1. Parse markdown and collect headings for TOC
|
||||||
let parser = pulldown_cmark::Parser::new_ext(md, Options::all());
|
let parser = pulldown_cmark::Parser::new_ext(md, Options::all());
|
||||||
let mut headings: Vec<(u8, String, String)> = Vec::new(); // (level, text, id)
|
// (level, text, id)
|
||||||
|
let mut headings: Vec<(u8, String, String)> = Vec::new();
|
||||||
let mut current_heading: Option<(u8, String)> = None;
|
let mut current_heading: Option<(u8, String)> = None;
|
||||||
|
|
||||||
for event in parser {
|
for event in parser {
|
||||||
@ -70,6 +82,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
for event in parser {
|
for event in parser {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(Tag::Heading { level, .. }) => {
|
Event::Start(Tag::Heading { level, .. }) => {
|
||||||
|
// 先把累积的普通事件刷入 HTML,再开始新标题。
|
||||||
if !non_heading_events.is_empty() {
|
if !non_heading_events.is_empty() {
|
||||||
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
|
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
|
||||||
non_heading_events = Vec::new();
|
non_heading_events = Vec::new();
|
||||||
@ -108,6 +121,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
in_heading = false;
|
in_heading = false;
|
||||||
}
|
}
|
||||||
Event::Start(Tag::CodeBlock(kind)) => {
|
Event::Start(Tag::CodeBlock(kind)) => {
|
||||||
|
// 代码块开始前同样先刷入未处理的普通事件。
|
||||||
if !non_heading_events.is_empty() {
|
if !non_heading_events.is_empty() {
|
||||||
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
|
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
|
||||||
non_heading_events = Vec::new();
|
non_heading_events = Vec::new();
|
||||||
@ -129,6 +143,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
code_buffer.push_str(&text);
|
code_buffer.push_str(&text);
|
||||||
}
|
}
|
||||||
Event::End(TagEnd::CodeBlock) => {
|
Event::End(TagEnd::CodeBlock) => {
|
||||||
|
// 使用 syntect 对代码块进行服务端语法高亮。
|
||||||
let highlighted =
|
let highlighted =
|
||||||
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
|
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
|
||||||
html.push_str("<pre><code");
|
html.push_str("<pre><code");
|
||||||
@ -142,6 +157,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if in_heading {
|
if in_heading {
|
||||||
|
// 标题内部只保留文本与行内代码,避免嵌套块级元素。
|
||||||
match event {
|
match event {
|
||||||
Event::Text(text) => html.push_str(&clean_html(&text)),
|
Event::Text(text) => html.push_str(&clean_html(&text)),
|
||||||
Event::Code(code) => {
|
Event::Code(code) => {
|
||||||
@ -170,6 +186,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 根据标题层级生成嵌套目录 HTML。
|
||||||
fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
||||||
if headings.is_empty() {
|
if headings.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
@ -184,13 +201,13 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
let prev_level = headings[i - 1].0;
|
let prev_level = headings[i - 1].0;
|
||||||
if level > prev_level {
|
if level > prev_level {
|
||||||
// Open new nested lists
|
// 标题层级升高:打开新的嵌套列表。
|
||||||
for _ in prev_level..level {
|
for _ in prev_level..level {
|
||||||
html.push_str("<ul>");
|
html.push_str("<ul>");
|
||||||
stack.push(level);
|
stack.push(level);
|
||||||
}
|
}
|
||||||
} else if level < prev_level {
|
} else if level < prev_level {
|
||||||
// Close nested lists
|
// 标题层级降低:关闭多余的嵌套列表。
|
||||||
while let Some(top) = stack.last() {
|
while let Some(top) = stack.last() {
|
||||||
if *top > level {
|
if *top > level {
|
||||||
html.push_str("</li></ul>");
|
html.push_str("</li></ul>");
|
||||||
@ -213,6 +230,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close remaining lists
|
// Close remaining lists
|
||||||
|
// 闭合所有残留的嵌套列表。
|
||||||
while stack.len() > 1 {
|
while stack.len() > 1 {
|
||||||
html.push_str("</li></ul>");
|
html.push_str("</li></ul>");
|
||||||
stack.pop();
|
stack.pop();
|
||||||
@ -223,6 +241,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 将标题文本转换为可用于锚点的 slug。
|
||||||
fn slugify_heading(text: &str) -> String {
|
fn slugify_heading(text: &str) -> String {
|
||||||
let mut slug = String::new();
|
let mut slug = String::new();
|
||||||
let mut prev_dash = true;
|
let mut prev_dash = true;
|
||||||
|
|||||||
@ -1,10 +1,26 @@
|
|||||||
|
//! API 层根模块。
|
||||||
|
//!
|
||||||
|
//! 按职责划分子模块,包含两类接口:
|
||||||
|
//! - Dioxus server function(`#[server(Name, "/api")]`),如 `auth`、`posts`;
|
||||||
|
//! - Axum 手动路由处理器,如 `upload`、`image`。
|
||||||
|
|
||||||
|
/// 认证相关的 Dioxus server function。
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
/// 评论相关接口。
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
|
/// 应用错误类型与转换。
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
/// 图片服务的 Axum 处理器。
|
||||||
pub mod image;
|
pub mod image;
|
||||||
|
/// Markdown 渲染与 HTML 清理。
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
/// 文章 CRUD 相关接口。
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
|
/// 限流工具。
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
|
/// HTML 消毒器。
|
||||||
pub mod sanitizer;
|
pub mod sanitizer;
|
||||||
|
/// URL slug 生成与校验。
|
||||||
pub mod slug;
|
pub mod slug;
|
||||||
|
/// 图片上传的 Axum 处理器。
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 基于 governor 的多级限流。
|
||||||
|
//!
|
||||||
|
//! 提供 strict、upload、image、comment 四个限流器,
|
||||||
|
//! 支持从 `X-Forwarded-For` / `X-Real-IP` 中提取客户端 IP,
|
||||||
|
//! 并可通过 `TRUSTED_PROXY_COUNT` 配置信任代理层数。
|
||||||
|
//! 仅在 `feature = "server"` 时生效。
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -49,6 +56,7 @@ static COMMENT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::ne
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 检查评论请求是否超出限流阈值。
|
||||||
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
|
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
|
||||||
COMMENT_LIMITER
|
COMMENT_LIMITER
|
||||||
.check_key(&ip.to_string())
|
.check_key(&ip.to_string())
|
||||||
@ -57,6 +65,7 @@ pub fn check_comment_limit(ip: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 检查图片访问请求是否超出限流阈值,返回 HTTP 状态码。
|
||||||
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
|
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
|
||||||
IMAGE_LIMITER
|
IMAGE_LIMITER
|
||||||
.check_key(&ip.to_string())
|
.check_key(&ip.to_string())
|
||||||
@ -74,6 +83,7 @@ fn trusted_proxy_count() -> usize {
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<String> {
|
fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<String> {
|
||||||
|
// 按逗号拆分并过滤空项,列表末尾是离服务端最近的代理。
|
||||||
let parts: Vec<&str> = value
|
let parts: Vec<&str> = value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@ -82,14 +92,17 @@ fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<St
|
|||||||
if parts.is_empty() || trusted_proxy_count == 0 {
|
if parts.is_empty() || trusted_proxy_count == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// 可信任代理数量不足时无法确定真实客户端 IP。
|
||||||
if parts.len() <= trusted_proxy_count {
|
if parts.len() <= trusted_proxy_count {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// 从列表末尾倒数 `trusted_proxy_count + 1` 位即为真实客户端 IP。
|
||||||
let idx = parts.len() - 1 - trusted_proxy_count;
|
let idx = parts.len() - 1 - trusted_proxy_count;
|
||||||
parts.get(idx).map(|s| s.to_string())
|
parts.get(idx).map(|s| s.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 根据信任代理层数从请求头中提取客户端 IP。
|
||||||
pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count: usize) -> String {
|
pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count: usize) -> String {
|
||||||
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||||
if let Some(ip) = ip_from_x_forwarded_for(value, trusted_proxy_count) {
|
if let Some(ip) = ip_from_x_forwarded_for(value, trusted_proxy_count) {
|
||||||
@ -97,6 +110,7 @@ pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 配置了信任代理时,回退到 X-Real-IP。
|
||||||
if trusted_proxy_count > 0 {
|
if trusted_proxy_count > 0 {
|
||||||
if let Some(ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
if let Some(ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
||||||
return ip.trim().to_string();
|
return ip.trim().to_string();
|
||||||
@ -107,11 +121,13 @@ pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 使用环境变量配置的代理层数提取客户端 IP。
|
||||||
pub fn get_client_ip(headers: &http::HeaderMap) -> String {
|
pub fn get_client_ip(headers: &http::HeaderMap) -> String {
|
||||||
get_client_ip_with_trusted(headers, trusted_proxy_count())
|
get_client_ip_with_trusted(headers, trusted_proxy_count())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 检查严格限流(注册、登录等敏感接口)。
|
||||||
pub fn check_strict_limit(ip: &str) -> Result<(), String> {
|
pub fn check_strict_limit(ip: &str) -> Result<(), String> {
|
||||||
STRICT_LIMITER
|
STRICT_LIMITER
|
||||||
.check_key(&ip.to_string())
|
.check_key(&ip.to_string())
|
||||||
@ -120,6 +136,7 @@ pub fn check_strict_limit(ip: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 检查上传请求是否超出限流阈值。
|
||||||
pub fn check_upload_limit(ip: &str) -> Result<(), String> {
|
pub fn check_upload_limit(ip: &str) -> Result<(), String> {
|
||||||
UPLOAD_LIMITER
|
UPLOAD_LIMITER
|
||||||
.check_key(&ip.to_string())
|
.check_key(&ip.to_string())
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
//! HTML 消毒器。
|
||||||
|
//!
|
||||||
|
//! 基于 lol_html 清理不受信任的 HTML,限制允许的 tag/attribute/URL scheme,
|
||||||
|
//! 分别提供文章正文(`clean_html`)与评论(`clean_comment_html`)两套白名单策略。
|
||||||
|
//! 仅在 `feature = "server"` 时执行。
|
||||||
|
|
||||||
#![allow(clippy::unused_unit, deprecated)]
|
#![allow(clippy::unused_unit, deprecated)]
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -137,6 +143,7 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// 解析 scheme 并与白名单对比;data URI 与 javascript/vbscript 单独处理。
|
||||||
if let Some(colon_pos) = trimmed.find(':') {
|
if let Some(colon_pos) = trimmed.find(':') {
|
||||||
let scheme = &trimmed[..colon_pos];
|
let scheme = &trimmed[..colon_pos];
|
||||||
let scheme_lower = scheme.to_lowercase();
|
let scheme_lower = scheme.to_lowercase();
|
||||||
@ -160,6 +167,7 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
|
||||||
struct SanitizerConfig {
|
struct SanitizerConfig {
|
||||||
allowed_tags: HashSet<&'static str>,
|
allowed_tags: HashSet<&'static str>,
|
||||||
extra_generic_attrs: Vec<&'static str>,
|
extra_generic_attrs: Vec<&'static str>,
|
||||||
@ -251,6 +259,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
|||||||
.filter_map(|attr| {
|
.filter_map(|attr| {
|
||||||
let name = attr.name();
|
let name = attr.name();
|
||||||
let name_lower = name.to_lowercase();
|
let name_lower = name.to_lowercase();
|
||||||
|
// 仅保留白名单属性;对 href/src/cite 额外校验 URL 安全性。
|
||||||
if allowed_for_tag.contains(name_lower.as_str()) {
|
if allowed_for_tag.contains(name_lower.as_str()) {
|
||||||
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
|
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
|
||||||
let val = attr.value();
|
let val = attr.value();
|
||||||
@ -296,6 +305,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。
|
||||||
pub fn clean_html(input: &str) -> String {
|
pub fn clean_html(input: &str) -> String {
|
||||||
let config = SanitizerConfig {
|
let config = SanitizerConfig {
|
||||||
allowed_tags: default_allowed_tags(),
|
allowed_tags: default_allowed_tags(),
|
||||||
@ -327,6 +337,7 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。
|
||||||
pub fn clean_comment_html(input: &str) -> String {
|
pub fn clean_comment_html(input: &str) -> String {
|
||||||
let mut tags = default_allowed_tags();
|
let mut tags = default_allowed_tags();
|
||||||
tags.remove("img");
|
tags.remove("img");
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
|
//! 文章 slug 生成与唯一性校验。
|
||||||
|
//!
|
||||||
|
//! 将标题转换为小写、仅含字母数字与连字符/下划线的 URL 友好形式,
|
||||||
|
//! 并检测数据库中是否已存在,必要时追加数字后缀。
|
||||||
|
//! 仅在 `feature = "server"` 时访问数据库。
|
||||||
|
|
||||||
#![allow(clippy::unused_unit, deprecated)]
|
#![allow(clippy::unused_unit, deprecated)]
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 将标题转换为 URL 友好的 slug。
|
||||||
|
///
|
||||||
|
/// 非字母数字字符替换为 `-` 并合并连续 `-`,结果截断至 100 字符;
|
||||||
|
/// 若全部字符被过滤,则返回当前时间戳作为 slug。
|
||||||
pub fn slugify(title: &str) -> String {
|
pub fn slugify(title: &str) -> String {
|
||||||
let slug: String = title
|
let slug: String = title
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
@ -16,6 +26,7 @@ pub fn slugify(title: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// 合并连续的连字符,并去除首尾空段。
|
||||||
let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect();
|
let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect();
|
||||||
let slug = parts.join("-");
|
let slug = parts.join("-");
|
||||||
|
|
||||||
@ -27,6 +38,7 @@ pub fn slugify(title: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 校验 slug 是否为空且仅含合法字符、长度不超过 200。
|
||||||
pub fn is_valid_slug(slug: &str) -> bool {
|
pub fn is_valid_slug(slug: &str) -> bool {
|
||||||
if slug.is_empty() || slug.len() > 200 {
|
if slug.is_empty() || slug.len() > 200 {
|
||||||
return false;
|
return false;
|
||||||
@ -36,6 +48,10 @@ pub fn is_valid_slug(slug: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 确保生成的 slug 在数据库中唯一。
|
||||||
|
///
|
||||||
|
/// 若 `exclude_id` 不为空,则排除该文章自身;
|
||||||
|
/// 当冲突时依次尝试 `base-2`、`base-3` …… 直到生成唯一值。
|
||||||
pub async fn ensure_unique_slug(
|
pub async fn ensure_unique_slug(
|
||||||
client: &tokio_postgres::Client,
|
client: &tokio_postgres::Client,
|
||||||
base: &str,
|
base: &str,
|
||||||
@ -47,6 +63,7 @@ pub async fn ensure_unique_slug(
|
|||||||
let mut suffix = 2;
|
let mut suffix = 2;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// 查询当前候选 slug 是否已存在(排除指定文章 ID)。
|
||||||
let exists = if let Some(exclude) = exclude_id {
|
let exists = if let Some(exclude) = exclude_id {
|
||||||
client
|
client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
@ -74,6 +91,7 @@ pub async fn ensure_unique_slug(
|
|||||||
candidate = format!("{}-{}", base, suffix);
|
candidate = format!("{}-{}", base, suffix);
|
||||||
suffix += 1;
|
suffix += 1;
|
||||||
|
|
||||||
|
// 防止无限循环:slug 总长度超过 200 时直接报错。
|
||||||
if candidate.len() > 200 {
|
if candidate.len() > 200 {
|
||||||
return Err(AppError::Internal("无法生成唯一 slug").into());
|
return Err(AppError::Internal("无法生成唯一 slug").into());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 图片上传的 Axum 处理器。
|
||||||
|
//!
|
||||||
|
//! 处理 multipart 上传,校验 MIME 类型、文件大小与 admin 权限,
|
||||||
|
//! JPEG/PNG 自动转 WebP(若体积更小则保留原格式),GIF/WebP 保持原样。
|
||||||
|
//! 文件按日期分目录存放于 `uploads/`。
|
||||||
|
//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Multipart,
|
extract::Multipart,
|
||||||
@ -27,6 +34,10 @@ fn mime_to_ext(mime: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 处理图片上传的 Axum handler。
|
||||||
|
///
|
||||||
|
/// 流程:限流 → 解析 session → 校验 admin → 读取 multipart → 校验类型/大小 →
|
||||||
|
/// 转码(如适用)→ 按日期落盘 → 返回相对 URL。
|
||||||
pub async fn upload_image(
|
pub async fn upload_image(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
@ -150,6 +161,7 @@ pub async fn upload_image(
|
|||||||
let is_gif = mime_type.as_str() == "image/gif";
|
let is_gif = mime_type.as_str() == "image/gif";
|
||||||
let is_webp = mime_type.as_str() == "image/webp";
|
let is_webp = mime_type.as_str() == "image/webp";
|
||||||
|
|
||||||
|
// GIF 与 WebP 保持原格式;其余格式尝试转 WebP。
|
||||||
let (final_data, final_ext) = if is_gif {
|
let (final_data, final_ext) = if is_gif {
|
||||||
(data.to_vec(), "gif".to_string())
|
(data.to_vec(), "gif".to_string())
|
||||||
} else if is_webp {
|
} else if is_webp {
|
||||||
@ -158,6 +170,7 @@ pub async fn upload_image(
|
|||||||
let original_data = data.to_vec();
|
let original_data = data.to_vec();
|
||||||
let mime = mime_type.clone();
|
let mime = mime_type.clone();
|
||||||
let config = crate::webp::WEBP_CONFIG.clone();
|
let config = crate::webp::WEBP_CONFIG.clone();
|
||||||
|
// 在阻塞线程中执行图片解码与 WebP 编码,避免阻塞异步运行时。
|
||||||
let result = tokio::task::spawn_blocking(move || -> (Vec<u8>, String, bool) {
|
let result = tokio::task::spawn_blocking(move || -> (Vec<u8>, String, bool) {
|
||||||
let total_start = std::time::Instant::now();
|
let total_start = std::time::Instant::now();
|
||||||
match image::load_from_memory(&original_data) {
|
match image::load_from_memory(&original_data) {
|
||||||
@ -168,6 +181,7 @@ pub async fn upload_image(
|
|||||||
Ok(webp_data) => {
|
Ok(webp_data) => {
|
||||||
let enc_time = enc_start.elapsed();
|
let enc_time = enc_start.elapsed();
|
||||||
let total_time = total_start.elapsed();
|
let total_time = total_start.elapsed();
|
||||||
|
// WebP 更小才采用,否则回退原格式以节省带宽。
|
||||||
if webp_data.len() < original_data.len() {
|
if webp_data.len() < original_data.len() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"WebP conversion: decode={:?} encode={:?} total={:?} {}x{} {} bytes -> {} bytes",
|
"WebP conversion: decode={:?} encode={:?} total={:?} {}x{} {} bytes -> {} bytes",
|
||||||
@ -210,6 +224,7 @@ pub async fn upload_image(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 按上传时间组织目录:uploads/YYYY/MM/DD。
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let year = now.format("%Y").to_string();
|
let year = now.format("%Y").to_string();
|
||||||
let month = now.format("%m").to_string();
|
let month = now.format("%m").to_string();
|
||||||
|
|||||||
@ -1,2 +1,9 @@
|
|||||||
|
//! 认证辅助模块。
|
||||||
|
//!
|
||||||
|
//! 包含密码哈希(Argon2)与会话 token 管理两个子模块。
|
||||||
|
|
||||||
|
/// 密码哈希(Argon2)子模块。
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
|
||||||
|
/// 会话 token 管理子模块。
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
//! 密码哈希与校验(Argon2)。
|
||||||
|
//!
|
||||||
|
//! 使用随机 salt 生成 PHC 字符串格式哈希,并通过 Argon2 验证。
|
||||||
|
//! `#[allow(dead_code)]` 用于避免 WASM 构建中服务端函数体被剥离后的未使用警告。
|
||||||
|
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
@ -5,6 +10,7 @@ use argon2::{
|
|||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
/// 使用 Argon2 对明文密码进行哈希。
|
||||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
let argon2 = Argon2::default();
|
let argon2 = Argon2::default();
|
||||||
@ -13,6 +19,7 @@ pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Er
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
/// 校验明文密码是否与已存储的哈希匹配。
|
||||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
|
||||||
let parsed_hash = PasswordHash::new(hash)?;
|
let parsed_hash = PasswordHash::new(hash)?;
|
||||||
let argon2 = Argon2::default();
|
let argon2 = Argon2::default();
|
||||||
|
|||||||
@ -1,13 +1,21 @@
|
|||||||
|
//! 会话 token 生成、哈希与 Cookie 处理。
|
||||||
|
//!
|
||||||
|
//! token 使用 UUID,存储时使用 SHA-256 哈希,
|
||||||
|
//! Cookie 包含 HttpOnly、SameSite=Lax 与可选 Secure 标志。
|
||||||
|
//! 服务端上下文解析函数仅在 `feature = "server"` 时可用。
|
||||||
|
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
/// 生成新的随机会话 token(UUID 格式)。
|
||||||
pub fn generate_token() -> String {
|
pub fn generate_token() -> String {
|
||||||
Uuid::new_v4().to_string()
|
Uuid::new_v4().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
/// 使用 SHA-256 对 token 进行哈希,用于数据库存储。
|
||||||
pub fn hash_token(token: &str) -> String {
|
pub fn hash_token(token: &str) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(token.as_bytes());
|
hasher.update(token.as_bytes());
|
||||||
@ -15,11 +23,13 @@ pub fn hash_token(token: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
/// 返回默认会话过期时间(当前时间 + 30 天)。
|
||||||
pub fn default_expiry() -> DateTime<Utc> {
|
pub fn default_expiry() -> DateTime<Utc> {
|
||||||
Utc::now() + Duration::days(30)
|
Utc::now() + Duration::days(30)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 读取环境变量 `COOKIE_SECURE`,决定是否给 Cookie 添加 Secure 标志。
|
||||||
pub fn cookie_secure() -> bool {
|
pub fn cookie_secure() -> bool {
|
||||||
std::env::var("COOKIE_SECURE")
|
std::env::var("COOKIE_SECURE")
|
||||||
.ok()
|
.ok()
|
||||||
@ -28,6 +38,7 @@ pub fn cookie_secure() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 构造会话 Cookie 字符串,包含 HttpOnly、Path、Max-Age 与 SameSite。
|
||||||
pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String {
|
pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String {
|
||||||
let secure_flag = if secure { "; Secure" } else { "" };
|
let secure_flag = if secure { "; Secure" } else { "" };
|
||||||
format!(
|
format!(
|
||||||
@ -36,6 +47,7 @@ pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 从 `Cookie` 头中解析名为 `session` 的 token 值。
|
||||||
pub fn parse_session_token(cookie_header: &str) -> Option<&str> {
|
pub fn parse_session_token(cookie_header: &str) -> Option<&str> {
|
||||||
cookie_header.split(';').map(|s| s.trim()).find_map(|pair| {
|
cookie_header.split(';').map(|s| s.trim()).find_map(|pair| {
|
||||||
let mut parts = pair.splitn(2, '=');
|
let mut parts = pair.splitn(2, '=');
|
||||||
@ -50,6 +62,7 @@ pub fn parse_session_token(cookie_header: &str) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
/// 从 Dioxus `FullstackContext` 中读取 Cookie 并返回会话 token。
|
||||||
pub fn get_session_from_ctx() -> Option<String> {
|
pub fn get_session_from_ctx() -> Option<String> {
|
||||||
use dioxus::fullstack::FullstackContext;
|
use dioxus::fullstack::FullstackContext;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user