新增 version_headers_middleware,为所有响应附加三个头:
- Server: yggdrasil/<version>(遵循 product/version 习惯)
- X-Yggdrasil-Version: Cargo 版本号
- X-Yggdrasil-Git: git describe(版本+提交数+短hash+脏标记)
数据源复用 build_info::BUILD_INFO,与启动日志 log_build_info() 同源,
零新依赖。
关键设计:中间件挂在最终合并 router 的最外层(Ok(router) 前),因此
/healthz、/uploads/*、被 CSRF 拒(403)/超时/admin_guard 重定向的响应
都会带头,探测价值最大。此前 app_routes 的中间件栈只覆盖 Dioxus 路由,
无法覆盖这些端点。
受 EXPOSE_VERSION_HEADERS 控制,默认 true;设 0/false/no 关闭。
bool 解析沿用 COOKIE_SECURE 的 matches!("1"/"true"/"yes") 约定,
此处取反为"非 false 值即开"使默认行为对应「暴露」。启动时记录一条
info 日志便于确认生效值。
757 lines
31 KiB
Rust
757 lines
31 KiB
Rust
//! 服务端入口与启动配置
|
||
//!
|
||
//! 本文件是 Dioxus fullstack 应用的启动入口。
|
||
//! 当启用 `server` feature 时,启动 Axum 服务器并挂载:
|
||
//! - Dioxus server function(由 `serve_dioxus_application` 自动注册);
|
||
//! - 自定义 Axum 路由:图片上传 `/api/upload`、图片服务 `/uploads/{*path}`;
|
||
//! - 增量渲染(Incremental Rendering)缓存配置。
|
||
//!
|
||
//! 当未启用 `server` feature(例如编译为 WASM 前端)时,
|
||
//! 仅调用 `dioxus::launch` 启动客户端应用。
|
||
|
||
// 业务模块
|
||
mod api;
|
||
mod auth;
|
||
// build_info:编译期注入的 git/rustc/构建时间信息。模块内部 gate 在 server feature,
|
||
// 模块声明本身不需要再加 cfg(空模块在 WASM 端也能编译)。
|
||
mod build_info;
|
||
mod cache;
|
||
mod components;
|
||
mod context;
|
||
mod db;
|
||
pub mod infra;
|
||
// highlight 模块仅在服务端构建时编译
|
||
#[cfg(feature = "server")]
|
||
mod highlight;
|
||
mod hooks;
|
||
mod models;
|
||
mod pages;
|
||
mod router;
|
||
// sysinfo_sampler:主机指标快照。
|
||
// SystemSnapshot 结构体两端都编译(被 system_status 的 ServerStatus 字段引用);
|
||
// 真正的采样任务 / RwLock / read_snapshot 实现在本模块内部自行 #[cfg(feature = "server")] gate。
|
||
mod sysinfo_sampler;
|
||
// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
|
||
#[cfg(feature = "server")]
|
||
mod ssr_cache;
|
||
mod tasks;
|
||
mod theme;
|
||
// tiptap_bridge:共享类型(UploadsInFlight/UploadErrorEntry)两端都编译;
|
||
// wasm-bindgen extern 与 EditorHandle 在内部的 #[cfg(wasm32)] 子模块里。
|
||
mod tiptap_bridge;
|
||
// codemirror_bridge:SQL 编辑器的 wasm-bindgen 绑定,结构镜像 tiptap_bridge。
|
||
// 共享类型(SqlSchema/SqlTable)两端都编译;extern 与 EditorHandle 在 #[cfg(wasm32)] 子模块里。
|
||
mod codemirror_bridge;
|
||
mod utils;
|
||
mod webp;
|
||
mod xterm_bridge;
|
||
|
||
/// 压缩算法配置。
|
||
#[cfg(feature = "server")]
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
struct CompressionAlgorithms {
|
||
gzip: bool,
|
||
brotli: bool,
|
||
deflate: bool,
|
||
zstd: bool,
|
||
}
|
||
|
||
#[cfg(feature = "server")]
|
||
impl CompressionAlgorithms {
|
||
fn all_enabled() -> Self {
|
||
Self {
|
||
gzip: true,
|
||
brotli: true,
|
||
deflate: true,
|
||
zstd: true,
|
||
}
|
||
}
|
||
|
||
fn is_empty(&self) -> bool {
|
||
!self.gzip && !self.brotli && !self.deflate && !self.zstd
|
||
}
|
||
}
|
||
|
||
/// 解析 COMPRESSION_ALGORITHMS 环境变量值。
|
||
/// ""、"none"、"off" 返回 None;"all" 或未识别到任何算法时启用全部。
|
||
#[cfg(feature = "server")]
|
||
fn parse_compression_algorithms(env: &str) -> Option<CompressionAlgorithms> {
|
||
let env = env.trim();
|
||
if env.is_empty() || env.eq_ignore_ascii_case("none") || env.eq_ignore_ascii_case("off") {
|
||
return None;
|
||
}
|
||
|
||
let mut all = false;
|
||
let mut gzip = false;
|
||
let mut brotli = false;
|
||
let mut deflate = false;
|
||
let mut zstd = false;
|
||
|
||
for part in env.split(',') {
|
||
match part.trim().to_lowercase().as_str() {
|
||
"all" => all = true,
|
||
"gzip" => gzip = true,
|
||
"brotli" | "br" => brotli = true,
|
||
"deflate" => deflate = true,
|
||
"zstd" => zstd = true,
|
||
other => tracing::warn!(
|
||
"Unknown compression algorithm in COMPRESSION_ALGORITHMS: '{}'",
|
||
other
|
||
),
|
||
}
|
||
}
|
||
|
||
if all {
|
||
return Some(CompressionAlgorithms::all_enabled());
|
||
}
|
||
|
||
let algorithms = CompressionAlgorithms {
|
||
gzip,
|
||
brotli,
|
||
deflate,
|
||
zstd,
|
||
};
|
||
if algorithms.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(algorithms)
|
||
}
|
||
|
||
/// 根据 COMPRESSION_ALGORITHMS 环境变量构造 CompressionLayer。
|
||
/// 未设置或设置为 "all" 时启用全部算法;设置为 ""、"none" 或 "off" 时禁用。
|
||
///
|
||
/// CompressionLayer 使用 tower-http 的 `DefaultPredicate`,开箱即用即:
|
||
/// - 跳过 `image/*` content-type(WebP/PNG/JPEG/GIF 等已是压缩格式,再压浪费 CPU,
|
||
/// 唯一例外是 `image/svg+xml`,作为 XML 文本可被压缩);
|
||
/// - 跳过 gRPC 与 `text/event-stream`(SSE);
|
||
/// - 跳过小于 32 字节的响应。
|
||
///
|
||
/// 因此无需在此处对图片响应做额外的 content-type 过滤。另:图片实际挂在
|
||
/// `static_routes`(无中间件),根本不经此层,详见下方路由 merge 处。
|
||
#[cfg(feature = "server")]
|
||
fn compression_layer_from_env() -> Option<tower_http::compression::CompressionLayer> {
|
||
use tower_http::compression::CompressionLayer;
|
||
|
||
let env = std::env::var("COMPRESSION_ALGORITHMS").unwrap_or_else(|_| "all".to_string());
|
||
let algorithms = parse_compression_algorithms(&env)?;
|
||
|
||
Some(
|
||
CompressionLayer::new()
|
||
.gzip(algorithms.gzip)
|
||
.br(algorithms.brotli)
|
||
.deflate(algorithms.deflate)
|
||
.zstd(algorithms.zstd),
|
||
)
|
||
}
|
||
|
||
/// 根据请求路径和方法决定公开页面的 Cache-Control 头。
|
||
/// 返回 None 表示不添加缓存头(保留现有行为或避免覆盖)。
|
||
#[cfg(feature = "server")]
|
||
fn cache_control_for_path(
|
||
path: &str,
|
||
method: &axum::http::Method,
|
||
) -> Option<axum::http::HeaderValue> {
|
||
use axum::http::{HeaderValue, Method};
|
||
|
||
// 只对 GET/HEAD 请求添加缓存头
|
||
if *method != Method::GET && *method != Method::HEAD {
|
||
return None;
|
||
}
|
||
|
||
// API 接口:不缓存(可能涉及认证、写操作)
|
||
if path.starts_with("/api") {
|
||
return None;
|
||
}
|
||
|
||
// 管理后台和认证页面:不缓存
|
||
if path.starts_with("/admin") || path == "/login" || path == "/register" {
|
||
return None;
|
||
}
|
||
|
||
// 静态资源:长期缓存(Dioxus/WASM 资源通常带内容哈希)
|
||
if path.starts_with("/_dioxus/")
|
||
|| path.starts_with("/wasm/")
|
||
|| path.ends_with(".wasm")
|
||
|| path.ends_with(".js")
|
||
|| path == "/style.css"
|
||
|| path == "/highlight.css"
|
||
{
|
||
return Some(HeaderValue::from_static(
|
||
"public, max-age=31536000, immutable",
|
||
));
|
||
}
|
||
|
||
// 公开页面:5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证
|
||
Some(HeaderValue::from_static(
|
||
"public, max-age=300, stale-while-revalidate=3600",
|
||
))
|
||
}
|
||
|
||
/// Axum 中间件:为公开页面和静态资源附加 Cache-Control 头。
|
||
#[cfg(feature = "server")]
|
||
async fn add_cache_control(
|
||
req: axum::extract::Request,
|
||
next: axum::middleware::Next,
|
||
) -> axum::response::Response {
|
||
use axum::http::header;
|
||
|
||
let path = req.uri().path().to_string();
|
||
let method = req.method().clone();
|
||
let cache_value = cache_control_for_path(&path, &method);
|
||
|
||
let mut response = next.run(req).await;
|
||
|
||
if let Some(value) = cache_value {
|
||
// 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
|
||
response
|
||
.headers_mut()
|
||
.entry(header::CACHE_CONTROL)
|
||
.or_insert(value);
|
||
}
|
||
|
||
response
|
||
}
|
||
|
||
/// Axum 中间件:`/admin*` 的 SSR 层认证守卫。
|
||
///
|
||
/// 未登录访问后台时,服务端**直接 302 跳转 `/login`**,根本不进入 Dioxus
|
||
/// SSR 渲染器。此前后台鉴权完全在客户端 WASM 完成(SSR 渲染骨架屏 → WASM
|
||
/// 下载/编译 → hydrate → 异步 `get_current_user()` → 客户端 `navigator.push`),
|
||
/// 整条链串行,未登录用户首屏要"空白好久"才跳登录。
|
||
///
|
||
/// - 只匹配 `/admin*`,其它路径(`/login`、公开页、`/api/*`)直接放行。
|
||
/// - 复用 `get_user_by_token`:命中内存缓存 + 校验 `session_generation`
|
||
/// (封禁/降级后旧 session 立即失效),与客户端鉴权同一套语义。
|
||
/// - DB 错误时 **fail-open**(放行进入 SSR):避免数据库抖动把已登录的
|
||
/// 管理员也踢到登录页;客户端 `AdminLayout` 仍有兜底校验。
|
||
/// - `/admin` 与 `/login` 本就不进 `cache_control_for_path` 缓存,302 不会被缓存。
|
||
#[cfg(feature = "server")]
|
||
async fn admin_guard(
|
||
req: axum::extract::Request,
|
||
next: axum::middleware::Next,
|
||
) -> axum::response::Response {
|
||
use crate::models::user::UserRole;
|
||
use axum::body::Body;
|
||
use axum::http::{header, StatusCode};
|
||
use axum::response::Response;
|
||
|
||
let path = req.uri().path().to_string();
|
||
if !path.starts_with("/admin") {
|
||
return next.run(req).await;
|
||
}
|
||
|
||
// 从 Cookie 头读 session token(与 export.rs / upload.rs 同款手法)。
|
||
let cookie = req
|
||
.headers()
|
||
.get("cookie")
|
||
.and_then(|h| h.to_str().ok())
|
||
.unwrap_or("");
|
||
let token = crate::auth::session::parse_session_token(cookie);
|
||
|
||
let is_admin = match token {
|
||
Some(t) => match crate::api::auth::get_user_by_token(t).await {
|
||
Ok(Some(user)) => user.role == UserRole::Admin,
|
||
// Err(DB 抖动)/ Ok(None)(token 无效):fail-open,交给客户端兜底。
|
||
_ => true,
|
||
},
|
||
// 无 token:明确未登录,拦截。
|
||
None => false,
|
||
};
|
||
|
||
if is_admin {
|
||
next.run(req).await
|
||
} else {
|
||
Response::builder()
|
||
.status(StatusCode::FOUND)
|
||
.header(header::LOCATION, "/login")
|
||
.body(Body::empty())
|
||
.unwrap()
|
||
}
|
||
}
|
||
|
||
/// 程序入口
|
||
fn main() {
|
||
// server feature:启动服务端
|
||
#[cfg(feature = "server")]
|
||
{
|
||
// 加载 .env 环境变量
|
||
dotenvy::dotenv().ok();
|
||
// 初始化 tracing 日志,默认级别为 info
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||
)
|
||
.init();
|
||
|
||
// 打印构建元信息(版本 / git / 提交时间 / rustc / 编译时刻)。
|
||
// 必须在 tracing 初始化之后,否则日志被丢弃。
|
||
build_info::log_build_info();
|
||
|
||
// 校验数据库连接串,未设置则直接退出
|
||
if std::env::var("DATABASE_URL").is_err() {
|
||
tracing::error!("DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported.");
|
||
eprintln!("ERROR: DATABASE_URL environment variable not set");
|
||
eprintln!(
|
||
"HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
|
||
);
|
||
std::process::exit(1);
|
||
}
|
||
|
||
// 前置校验 DATABASE_URL 格式 + DB_POOL_SIZE,避免触发 DB_POOL LazyLock 闭包里
|
||
// 不可达的 .expect() panic——让用户可修复的配置错误走统一友好的 exit(1) 路径。
|
||
// 此处必须在任何 DB_POOL.get() 调用之前执行(即迁移之前)。
|
||
if let Err(e) = db::pool::validate_database_url() {
|
||
tracing::error!("{e}");
|
||
eprintln!("ERROR: {e}");
|
||
if e.starts_with("DB_POOL_SIZE") {
|
||
eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20).");
|
||
} else {
|
||
eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname");
|
||
}
|
||
std::process::exit(1);
|
||
}
|
||
|
||
// 提醒部署者显式设置 APP_BASE_URL:未设置时 CSRF 会回退到 Host 头,
|
||
// 反向代理后存在绕过风险。本地开发未设置时也会打一条 WARN(代价可接受)。
|
||
api::csrf::warn_if_app_base_url_unset();
|
||
|
||
// 启动前执行数据库迁移。阻塞:完成前不监听端口。
|
||
// 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。
|
||
// 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。
|
||
//
|
||
// main() 是同步函数,这里用一个独立的多线程 runtime 驱动迁移的异步逻辑,
|
||
// 完成后再交给 dioxus::server::serve() 启动它自己的 runtime。
|
||
// 两个 runtime 不重叠,避免与 Dioxus 内部 runtime 产生交互。
|
||
let migrate_rt = tokio::runtime::Builder::new_multi_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("failed to build migration runtime");
|
||
migrate_rt.block_on(async {
|
||
tracing::info!("running database migrations");
|
||
|
||
// 连接池指向目标库,但目标库可能尚不存在(全新部署)。
|
||
// 先连 postgres 维护库确保目标库存在,复用启动超时窗口应对 DB 起得慢。
|
||
if let Err(e) = db::pool::ensure_database().await {
|
||
tracing::error!("failed to ensure target database exists: {e}");
|
||
eprintln!("ERROR: failed to ensure target database exists: {e}");
|
||
eprintln!("HINT: verify DATABASE_URL; the role needs CREATEDB (or CREATE privilege on the 'postgres' DB) to auto-create the target database.");
|
||
std::process::exit(1);
|
||
}
|
||
|
||
// 启动期用长重试窗口拿连接:DB 可能还在初始化(docker-compose 无 healthcheck、
|
||
// 本机忘启 Postgres 等)。窗口由 MIGRATE_STARTUP_TIMEOUT_SECS 控制,默认 30s。
|
||
let mut conn = match db::pool::get_conn_for_startup().await {
|
||
Ok(conn) => conn,
|
||
Err(e) => {
|
||
let secs = std::env::var("MIGRATE_STARTUP_TIMEOUT_SECS")
|
||
.ok()
|
||
.and_then(|s| s.parse::<u64>().ok())
|
||
.unwrap_or(30);
|
||
tracing::error!("could not connect to database within {secs}s startup window: {e}");
|
||
eprintln!("ERROR: could not connect to database within {secs}s startup window: {e}");
|
||
eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?");
|
||
eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start.");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
// 连接拿到后再执行迁移主体(咨询锁 + 建表 + 应用迁移)。
|
||
if let Err(e) = db::migrate::run_on_conn(&mut conn).await {
|
||
tracing::error!("database migration failed: {e}");
|
||
eprintln!("ERROR: database migration failed: {e}");
|
||
eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy.");
|
||
std::process::exit(1);
|
||
}
|
||
});
|
||
// 迁移 runtime 用完即弃,显式 drop 以在 serve() 前释放其线程资源。
|
||
drop(migrate_rt);
|
||
|
||
// 启动 Dioxus 服务端,返回构建好的 Axum Router
|
||
dioxus::server::serve(|| async move {
|
||
use axum::http::StatusCode;
|
||
use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
|
||
use std::time::Duration;
|
||
use tower_http::timeout::TimeoutLayer;
|
||
|
||
// 启动后台定时任务:IP 信息清理
|
||
tokio::spawn(async {
|
||
tasks::ip_purge::run_purge().await;
|
||
});
|
||
|
||
// 启动后台定时任务:过期 session 清理
|
||
tokio::spawn(async {
|
||
tasks::session_cleanup::run_cleanup().await;
|
||
});
|
||
|
||
// 启动后台定时任务:回收站自动清理
|
||
tokio::spawn(async {
|
||
tasks::post_purge::run_purge().await;
|
||
});
|
||
|
||
// 启动后台定时任务:图片磁盘缓存清理
|
||
tokio::spawn(async {
|
||
tasks::image_cache_cleanup::run_cleanup().await;
|
||
});
|
||
|
||
// 启动后台采样任务:sysinfo 主机指标(CPU/内存/磁盘),server function 只读快照。
|
||
sysinfo_sampler::spawn_sampler();
|
||
|
||
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。
|
||
// 注意:src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使
|
||
// Dioxus 0.7 的 SSR 缓存实际失效(Dioxus 未暴露相应 API)。在 API 可用
|
||
// 之前,SSR_CACHE_SECS 仍是唯一有效的兜底 TTL——它就是内容写入后
|
||
// SSR 页面可见滞后的上界。
|
||
let ssr_cache_secs = std::env::var("SSR_CACHE_SECS")
|
||
.ok()
|
||
.and_then(|s| s.parse().ok())
|
||
.unwrap_or(3600);
|
||
tracing::info!(
|
||
ssr_cache_secs,
|
||
"增量渲染缓存生效(写入后内容可见滞后的上界);\
|
||
调小可缩短滞后,代价是 SSR 重渲染更频繁"
|
||
);
|
||
let config = ServeConfig::builder().incremental(
|
||
dioxus::server::IncrementalRendererConfig::default()
|
||
.invalidate_after(std::time::Duration::from_secs(ssr_cache_secs)),
|
||
);
|
||
|
||
// 版本响应头开关:默认开启。设 0/false/no 关闭(注重安全、不想对外暴露版本/commit 时)。
|
||
// bool 解析约定与 COOKIE_SECURE 一致(matches "1"/"true"/"yes");这里取反为
|
||
// "非 false 值即开",使默认行为(unwrap_or(true))对应「暴露」。
|
||
let expose_version_headers = std::env::var("EXPOSE_VERSION_HEADERS")
|
||
.ok()
|
||
.map(|v| !matches!(v.as_str(), "0" | "false" | "no"))
|
||
.unwrap_or(true);
|
||
tracing::info!(
|
||
expose_version_headers,
|
||
"版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git)"
|
||
);
|
||
|
||
// SSR 世代号中间件:把当前全局世代号注入请求扩展,并对 GET 请求的
|
||
// 响应附加 `X-SSR-Generation` 头。这是为未来 Dioxus 支持自定义 SSR 缓存键
|
||
// 预留的钩子;目前主要提供可观测性,不会实际失效 SSR 缓存。
|
||
async fn ssr_generation_middleware(
|
||
req: axum::http::Request<axum::body::Body>,
|
||
next: axum::middleware::Next,
|
||
) -> axum::response::Response {
|
||
let generation = crate::ssr_cache::current_global_generation();
|
||
let is_get = req.method() == axum::http::Method::GET;
|
||
let (mut parts, body) = req.into_parts();
|
||
parts
|
||
.extensions
|
||
.insert(crate::ssr_cache::SsrGeneration(generation));
|
||
let mut response = next.run(axum::http::Request::from_parts(parts, body)).await;
|
||
if is_get {
|
||
response.headers_mut().insert(
|
||
axum::http::header::HeaderName::from_static("x-ssr-generation"),
|
||
axum::http::HeaderValue::from_str(&generation.to_string())
|
||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")),
|
||
);
|
||
}
|
||
response
|
||
}
|
||
|
||
// 版本头中间件:为所有响应附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git,
|
||
// 数据源与启动日志 log_build_info() 同源(crate::build_info::BUILD_INFO)。
|
||
// 挂在最终合并 router 的最外层(见下方 Ok(router) 前),因此连 /healthz、
|
||
// /uploads/*、被 CSRF 拒(403)/超时/admin_guard 重定向的响应都会带头,
|
||
// 探测价值最大。受 EXPOSE_VERSION_HEADERS 控制(默认 true)。
|
||
async fn version_headers_middleware(
|
||
req: axum::http::Request<axum::body::Body>,
|
||
next: axum::middleware::Next,
|
||
) -> axum::response::Response {
|
||
let mut response = next.run(req).await;
|
||
let h = response.headers_mut();
|
||
// Server 头:产品名/版本,遵循 "Server: product/version" 习惯。
|
||
h.insert(
|
||
axum::http::header::SERVER,
|
||
axum::http::HeaderValue::from_str(&format!(
|
||
"yggdrasil/{}",
|
||
crate::build_info::BUILD_INFO.version
|
||
))
|
||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("yggdrasil")),
|
||
);
|
||
// X-Yggdrasil-Version:Cargo.toml 版本号。
|
||
h.insert(
|
||
axum::http::header::HeaderName::from_static("x-yggdrasil-version"),
|
||
axum::http::HeaderValue::from_static(crate::build_info::BUILD_INFO.version),
|
||
);
|
||
// X-Yggdrasil-Git:git describe(版本+提交数+短hash+脏标记)。
|
||
h.insert(
|
||
axum::http::header::HeaderName::from_static("x-yggdrasil-git"),
|
||
axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_describe)
|
||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")),
|
||
);
|
||
response
|
||
}
|
||
|
||
// 自定义 API 路由:图片上传(大文件,需要更长超时)
|
||
// CSRF 校验置于最外层,先拦截非法来源再做超时/限体。
|
||
let upload_route = axum::Router::new()
|
||
.route(
|
||
"/api/upload",
|
||
axum::routing::post(crate::api::upload::upload_image),
|
||
)
|
||
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
|
||
.layer(TimeoutLayer::with_status_code(
|
||
StatusCode::REQUEST_TIMEOUT,
|
||
Duration::from_secs(300),
|
||
))
|
||
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
|
||
|
||
// 数据导出:流式响应,走 GET + query(参数较短)。
|
||
// 鉴权在 handler 内部从 cookie 校验 admin;CSRF 最外层拦截非法来源。
|
||
let export_route = axum::Router::new()
|
||
.route(
|
||
"/api/database/export",
|
||
axum::routing::get(crate::api::database::export::export_data),
|
||
)
|
||
// 备份下载:admin 鉴权 + 路径白名单(backups/ 不直接暴露静态目录)
|
||
.route(
|
||
"/api/database/backups/{filename}",
|
||
axum::routing::get(crate::api::database::backup::download_backup),
|
||
)
|
||
.layer(TimeoutLayer::with_status_code(
|
||
StatusCode::REQUEST_TIMEOUT,
|
||
Duration::from_secs(120),
|
||
))
|
||
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
|
||
|
||
// SSE 流式输出端点:GET /api/exec/stream?task_id=X
|
||
// 不挂 TimeoutLayer!SSE 是长连接,30s timeout 会杀掉流。
|
||
// 鉴权 + 限流已在 start_exec_stream server function 完成(校验链),
|
||
// 此处只校验 task_id 存在;CSRF 对 GET 放行(is_write_method 返回 false)。
|
||
// CompressionLayer 跳过 text/event-stream(见 compression_layer_from_env 注释),
|
||
// 但 sse_route 本身不挂 compression,更安全。
|
||
let sse_route = axum::Router::new()
|
||
.route(
|
||
"/api/exec/stream",
|
||
axum::routing::get(crate::api::code_runner::sse::exec_stream),
|
||
)
|
||
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
|
||
|
||
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
|
||
let dioxus_app =
|
||
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
||
|
||
// 合并 Dioxus + CSRF/世代号/缓存头/可选压缩/30s 超时中间件
|
||
// layer 顺序:后加的最外层先执行。CSRF 最外层先拦截非法来源。
|
||
let mut app_routes = dioxus_app
|
||
.layer(axum::middleware::from_fn(ssr_generation_middleware))
|
||
.layer(axum::middleware::from_fn(add_cache_control))
|
||
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
|
||
if let Some(layer) = compression_layer_from_env() {
|
||
app_routes = app_routes.layer(layer);
|
||
}
|
||
let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
|
||
StatusCode::REQUEST_TIMEOUT,
|
||
Duration::from_secs(30),
|
||
));
|
||
// admin_guard 置于最外层(最后添加 = 最先执行):未登录的 /admin* 请求
|
||
// 在 CSRF / cache / SSR 渲染之前就被 302 短路,零渲染开销。
|
||
let app_routes = app_routes.layer(axum::middleware::from_fn(admin_guard));
|
||
|
||
// 静态资源路由:图片文件服务。
|
||
// 注意:`dioxus::server::serve()` 接管了 listener 与 `into_make_service`
|
||
// 调用,没有机会换成 `into_make_service_with_connect_info::<SocketAddr>()`,
|
||
// 所以手动 merge 进来的路由(含 static_routes)拿不到 `ConnectInfo` 扩展。
|
||
// serve_image / upload_image 因此都用 `Option<Extension<ConnectInfo<SocketAddr>>>`
|
||
// 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT,
|
||
// 使限流能拿到真实客户端 IP。
|
||
let static_routes = axum::Router::new()
|
||
.route("/healthz", axum::routing::get(crate::api::health::healthz))
|
||
.route("/readyz", axum::routing::get(crate::api::health::readyz))
|
||
.route(
|
||
"/uploads/{*path}",
|
||
axum::routing::get(crate::api::image::serve_image),
|
||
)
|
||
.route(
|
||
"/uploads",
|
||
axum::routing::get(|| async { StatusCode::NOT_FOUND }),
|
||
);
|
||
|
||
// 合并:upload 路由 300s 超时;export 路由 120s;sse 路由无超时(长连接);app routes 加可选压缩/30s;static routes 无任何中间件
|
||
let router = upload_route
|
||
.merge(export_route)
|
||
.merge(sse_route)
|
||
.merge(app_routes)
|
||
.merge(static_routes);
|
||
|
||
// 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、
|
||
// 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。
|
||
let router = if expose_version_headers {
|
||
router.layer(axum::middleware::from_fn(version_headers_middleware))
|
||
} else {
|
||
router
|
||
};
|
||
|
||
Ok(router)
|
||
});
|
||
}
|
||
|
||
// 非 server feature(通常为 WASM 前端):启动客户端应用
|
||
#[cfg(not(feature = "server"))]
|
||
{
|
||
use router::AppRouter;
|
||
dioxus::launch(AppRouter);
|
||
}
|
||
}
|
||
|
||
#[cfg(all(test, feature = "server"))]
|
||
mod tests {
|
||
use super::{cache_control_for_path, parse_compression_algorithms, CompressionAlgorithms};
|
||
use axum::http::Method;
|
||
|
||
fn cache_value(path: &str, method: Method) -> Option<String> {
|
||
cache_control_for_path(path, &method).map(|v| v.to_str().unwrap().to_string())
|
||
}
|
||
|
||
#[test]
|
||
fn public_page_is_cached() {
|
||
assert_eq!(
|
||
cache_value("/", Method::GET),
|
||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||
);
|
||
assert_eq!(
|
||
cache_value("/post/hello-world", Method::GET),
|
||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||
);
|
||
assert_eq!(
|
||
cache_value("/tags/rust", Method::GET),
|
||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn static_assets_are_cached_long_term() {
|
||
assert_eq!(
|
||
cache_value("/style.css", Method::GET),
|
||
Some("public, max-age=31536000, immutable".to_string())
|
||
);
|
||
assert_eq!(
|
||
cache_value("/highlight.css", Method::GET),
|
||
Some("public, max-age=31536000, immutable".to_string())
|
||
);
|
||
assert_eq!(
|
||
cache_value("/wasm/app.wasm", Method::GET),
|
||
Some("public, max-age=31536000, immutable".to_string())
|
||
);
|
||
assert_eq!(
|
||
cache_value("/_dioxus/assets/main.js", Method::GET),
|
||
Some("public, max-age=31536000, immutable".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn api_and_admin_and_auth_are_not_cached() {
|
||
assert_eq!(cache_value("/api/posts", Method::GET), None);
|
||
assert_eq!(cache_value("/admin", Method::GET), None);
|
||
assert_eq!(cache_value("/admin/posts", Method::GET), None);
|
||
assert_eq!(cache_value("/login", Method::GET), None);
|
||
assert_eq!(cache_value("/register", Method::GET), None);
|
||
}
|
||
|
||
#[test]
|
||
fn non_get_requests_are_not_cached() {
|
||
assert_eq!(cache_value("/", Method::POST), None);
|
||
assert_eq!(cache_value("/post/hello-world", Method::POST), None);
|
||
assert_eq!(cache_value("/style.css", Method::POST), None);
|
||
}
|
||
|
||
#[test]
|
||
fn head_requests_are_cached_like_get() {
|
||
assert_eq!(
|
||
cache_value("/", Method::HEAD),
|
||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compression_all_enables_everything() {
|
||
assert_eq!(
|
||
parse_compression_algorithms("all"),
|
||
Some(CompressionAlgorithms::all_enabled())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compression_default_env_is_all() {
|
||
// 模拟未设置环境变量时的默认值
|
||
assert_eq!(
|
||
parse_compression_algorithms("all"),
|
||
Some(CompressionAlgorithms::all_enabled())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compression_empty_none_off_disable() {
|
||
assert_eq!(parse_compression_algorithms(""), None);
|
||
assert_eq!(parse_compression_algorithms("none"), None);
|
||
assert_eq!(parse_compression_algorithms("NONE"), None);
|
||
assert_eq!(parse_compression_algorithms("off"), None);
|
||
assert_eq!(parse_compression_algorithms("OFF"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn compression_single_algorithm() {
|
||
assert_eq!(
|
||
parse_compression_algorithms("gzip"),
|
||
Some(CompressionAlgorithms {
|
||
gzip: true,
|
||
brotli: false,
|
||
deflate: false,
|
||
zstd: false,
|
||
})
|
||
);
|
||
assert_eq!(
|
||
parse_compression_algorithms("br"),
|
||
Some(CompressionAlgorithms {
|
||
gzip: false,
|
||
brotli: true,
|
||
deflate: false,
|
||
zstd: false,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compression_multiple_algorithms() {
|
||
assert_eq!(
|
||
parse_compression_algorithms("gzip, zstd"),
|
||
Some(CompressionAlgorithms {
|
||
gzip: true,
|
||
brotli: false,
|
||
deflate: false,
|
||
zstd: true,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compression_case_insensitive_and_whitespace_tolerant() {
|
||
assert_eq!(
|
||
parse_compression_algorithms("GZIP, Brotli, Deflate, Zstd"),
|
||
Some(CompressionAlgorithms::all_enabled())
|
||
);
|
||
assert_eq!(
|
||
parse_compression_algorithms(" gzip , br , deflate , zstd "),
|
||
Some(CompressionAlgorithms::all_enabled())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compression_unknown_algorithms_are_ignored() {
|
||
assert_eq!(
|
||
parse_compression_algorithms("gzip, unknown, lz4"),
|
||
Some(CompressionAlgorithms {
|
||
gzip: true,
|
||
brotli: false,
|
||
deflate: false,
|
||
zstd: false,
|
||
})
|
||
);
|
||
}
|
||
}
|