Yggdrasil:用 Rust 同时编译浏览器与服务器

一份代码,两个运行时。一个 Rust 全栈博客引擎的深度源码剖析。

Yggdrasil 是一个用 Rust 写的全栈博客 / CMS。它没有前后端分离,没有 Node 中间层,没有两个仓库的同步地狱。它只用一份 Rust 源码,通过 Cargo 的 feature gate,编译出两个完全不同的产物:一个编译成 WebAssembly 跑进浏览器当交互前端,一个编译成原生 ELF 当 Axum HTTP 服务器。它还是个 MCP 服务器,让 AI 客户端把它当知识库查询。它甚至能在 Docker 沙箱里执行读者提交的代码。

这不是一篇功能介绍。这是一份逐行剖析源码的工程文档——每个子系统为什么这么设计、付出了什么代价、留下了什么技术债。所有代码均取自仓库当前实现。


项目规模

维度数值
Rust 源码187 个文件,约 43,660 行
Dioxus server function分布在 29 个文件,合计约 60–80 个
数据库迁移17 个(编译期 include_str! 内联)
单元测试23 个测试模块,637 个 #[test]
前端 JS 包7 个(pnpm workspace,Vite IIFE bundle)
生产镜像一个静态 ELF + public/ + uploads/FROM scratch
flowchart TB
    subgraph 客户端
        B[浏览器 WASM 前端]
        AI["AI 客户端<br/>Claude / Cursor / Cline"]
    end
    subgraph Axum 服务端
        AG["admin_guard<br/>302 快路径"]
        MW["CSRF → Cache-Control → 压缩 → 30s Timeout"]
        IR{IncrementalRenderer<br/>static/ 磁盘缓存}
        MK["moka 内存缓存<br/>9 个独立 TTL"]
        POOL["deadpool 连接池<br/>Fast 回收 + statement_timeout"]
        MCP["/mcp<br/>rmcp + bearer 鉴权"]
        SBX["Docker 沙箱<br/>bollard 代码执行"]
    end
    subgraph 数据层
        PG[(PostgreSQL)]
        FS["uploads/<br/>原图 + .cache/ WebP 派生"]
    end
    B -->|HTTP / SSE| AG
    AG --> MW --> IR
    IR -->|MISS| MK --> POOL --> PG
    AI -->|MCP| MCP --> MK
    B -->|runnable 代码块| SBX
    B -->|图片上传/访问| FS

一、双目标编译:同一份源码的两个命运

这是整个项目的架构基石。理解了它,就理解了后续所有设计决策的约束。

1.1 两个正交的 feature

Cargo.toml 定义了两个 feature,几乎所有行为都围绕它们展开:

[features]
default = ["web", "server"]
web = ["dioxus/web"]
server = [
    "dioxus/server",
    "dep:tokio", "dep:tokio-postgres", "dep:deadpool-postgres",
    "dep:argon2", "dep:sha2", "dep:hex",
    "dep:regex", "dep:fancy-regex", "dep:pulldown-cmark",
    "dep:axum", "dep:tower-http", "dep:lol_html",
    "dep:syntect", "dep:moka", "dep:governor",
    "dep:image", "dep:zenwebp", "dep:mimalloc",
    "dep:bollard", "dep:tokio-stream",
    "dep:katex-rs", "dep:pinyin", "dep:sysinfo",
    "dep:rmcp", "dep:aes-gcm", "dep:base64",
    # ... 合计约 30 个 optional 依赖
]

注意 server feature 用的是现代 dep:name 语法——这些依赖全是 optional = true,只在 server feature 激活时才拉入依赖图。

WASM 前端构建用 --no-default-features --features web。这意味着 tokio、deadpool、axum、moka、syntect、bollard、rmcp、mimalloc……这些重依赖一个都不会进前端的依赖图。前端只拉入 dioxus/webweb-sys / wasm-bindgen 系列。

[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", ...] }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde-wasm-bindgen = "0.6"

这是前端体积和编译时间的根本保障。

1.2 三类编译桩

但有个硬约束:#[server(Fn, "/api")] 宏注册的 server function 签名,两端必须一致编译。WASM 端虽然不执行 server fn 的函数体(宏会剥离),但它必须看到函数签名,才能生成调用桩。于是项目里出现了三类编译桩,让 WASM 端通过类型检查却不引入重依赖。

第一类:整层模块消失。 cache.rs 是最极端的例子——几乎每一行都独立挂 #[cfg(feature = "server")]

#[cfg(feature = "server")] use moka::future::Cache;
#[cfg(feature = "server")] use std::sync::LazyLock;
#[cfg(feature = "server")] use std::time::Duration;

#[cfg(feature = "server")] const TTL_POST_LIST: Duration = Duration::from_secs(60);

#[cfg(feature = "server")]
static POST_LIST_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
    Cache::builder().max_capacity(100).time_to_live(TTL_POST_LIST).build()
});

WASM 构建时,整个 moka 缓存层——9 个缓存实例、9 个统计结构、所有 get/set/invalidate 函数、所有 TTL 常量——全部凭空消失。零编译产物,零前端体积。

第二类:保持接口形状的桩。 数据库连接池在 WASM 端用一个 DummyPool 维持与真实 pool 完全相同的公开接口形状:

/// **请勿删除此 stub**,否则非 server 构建将无法通过编译。
#[cfg(not(feature = "server"))]
#[allow(dead_code)]
pub mod pool {
    pub struct DummyPool;
    impl DummyPool {
        pub async fn get(&self) -> Result<(), ()> { Err(()) }
    }
    pub static DB_POOL: DummyPool = DummyPool;
    pub async fn get_conn() -> Result<(), ()> { Err(()) }
}

模块文档解释了为什么这能工作:

这种 stub 模式是 Dioxus fullstack 项目的常见做法:服务端函数体在 WASM 构建时会被剥离,但模块结构必须保持一致,因此需要一个占位实现来满足编译器的符号解析。

第三类:unreachable!() 桩。 纯后台管理路径(如 MCP token 管理)前端永远不会调用。这些 server fn 的 WASM 端直接返回 unreachable!()——在真正的 server 构建里这些分支会被编译剔除,它只用于满足 WASM 的类型签名。AGENTS.md 明确规定 unreachable!() 只能用在 #[cfg(not(feature = "server"))] 分支里。

1.3 静态自包含二进制

这三个 feature gate 的配合(server / not(server) / wasm32)让项目可以把所有运行时资源都编译期内联,产出一个完全自包含的静态二进制:

// src/db/migrate.rs — 17 个迁移 SQL 编译期内联
const MIGRATIONS: &[(&str, &str)] = &[
    ("001", include_str!("../../migrations/001_init.sql")),
    ("002", include_str!("../../migrations/002_posts.sql")),
    // ...
    ("017", include_str!("../../migrations/017_mcp_tokens.sql")),
];

// src/highlight.rs — 7 个自定义语法高亮定义编译期内联
pub(crate) const CUSTOM_SYNTAXES: &[(&str, &str)] = &[
    ("JSX", include_str!("../syntaxes/JSX.sublime-syntax")),
    ("Kotlin", include_str!("../syntaxes/Kotlin.sublime-syntax")),
    ("Swift", include_str!("../syntaxes/Swift.sublime-syntax")),
    ("TSX", include_str!("../syntaxes/TSX.sublime-syntax")),
    ("TypeScript", include_str!("../syntaxes/TypeScript.sublime-syntax")),
    ("Vue", include_str!("../syntaxes/Vue.sublime-syntax")),
    ("Zig", include_str!("../syntaxes/Zig.sublime-syntax")),
];

// src/api/changelog.rs — CHANGELOG.md 编译期内联
const CHANGELOG_MD: &str = include_str!("../../CHANGELOG.md");

结果是:运行时的 FROM scratch 镜像只需三样东西——二进制本身、public/ 目录(CSS/JS/WASM 资源)、uploads/(用户图片)。磁盘上没有散落的 .sql 文件,没有 syntaxes/ 目录,没有 CHANGELOG.md

这些内联列表与磁盘目录的一致性由编译期测试守护。例如 custom_syntax_list_matches_directory 测试会检查 CUSTOM_SYNTAXES 数组是否与 syntaxes/ 目录下的文件完全对应——如果有人加了一个语法文件却忘了注册到数组,测试立即失败。

一个值得注意的例外:highlight.css 不是 include 的。它由构建工具 src/bin/generate_highlight_css.rs 用 syntect 在构建期生成到 public/highlight.css,运行时作为普通静态资源服务。这个二进制用 required-features = ["server"] 门控——WASM 构建不编译 syntect,这个工具也不会参与编译。

这篇文章本身的代码块就是上面这套渲染管线的产物——下面这段 Rust 代码可以直接在页面上运行,体验 Docker 沙箱的真实执行:

rust

二、启动流程:从零到就绪

理解了编译架构,来看运行时。main.rs 是整个服务端的启动入口,它的流程设计体现了“让用户可修复的错误走友好路径“的原则。

2.1 校验先行,panic 后行

启动的第一件事不是连接数据库,而是校验配置。validate_database_url() 在连接池被触碰之前运行,把 URL 格式错误、池大小非法这类用户可修复的问题,转成统一的 exit(1) 路径:

fn main() {
    #[cfg(feature = "server")]
    {
        dotenvy::dotenv().ok();
        tracing_subscriber::fmt()
            .with_env_filter(...)
            .init();
        build_info::log_build_info();

        // 校验 DATABASE_URL 存在
        if std::env::var("DATABASE_URL").is_err() {
            tracing::error!("DATABASE_URL environment variable not set...");
            eprintln!("HINT: create a .env file with DATABASE_URL=postgres://...");
            std::process::exit(1);
        }

        // 前置校验 URL 格式 + DB_POOL_SIZE
        if let Err(e) = db::pool::validate_database_url() {
            tracing::error!("{e}");
            eprintln!("ERROR: {e}");
            std::process::exit(1);
        }

        api::csrf::warn_if_app_base_url_unset();
        // ...
    }
}

这个顺序至关重要。DB_POOL 是一个 LazyLock<Pool>,它的初始化闭包里有一个 .expect()

pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
    let pg_cfg = build_pg_config()
        .expect("DATABASE_URL should have been validated at startup; \
                 validate_database_url() was not called");
    // ...
});

注释解释了这个设计:

不可达的防御性 panicmain.rs 启动时已通过 validate_database_url() 前置校验。因此在真实运行路径上本闭包里的 .expect() 永远不会触发。保留 .expect() 只是为了满足 LazyLock 必须返回 T(而非 Result)的类型约束——若这里真的 panic,说明 validate_database_url 与本闭包逻辑不一致,属于代码 bug 而非用户错误。

这是 panic = "abort" 项目处理初始化的标准模式:把可修复的错误提前拦截走 exit(1),让 LazyLock 闭包退化成“只在代码有 bug 时才会触发的防御性代码“。

2.2 一次性 runtime 驱动迁移

迁移逻辑是异步的,但 main() 是同步函数。项目用一个独立的 throwaway runtime 驱动迁移,完成后显式 drop,再交接给 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 {
    // 1. 确保目标数据库存在(连 postgres 维护库 CREATE DATABASE IF NOT EXISTS)
    if let Err(e) = db::pool::ensure_database().await {
        std::process::exit(1);
    }

    // 2. 启动期长重试窗口拿连接
    let mut conn = match db::pool::get_conn_for_startup().await {
        Ok(conn) => conn,
        Err(e) => {
            eprintln!("ERROR: could not connect to database within {secs}s: {e}");
            eprintln!("HINT: is PostgreSQL running? raise MIGRATE_STARTUP_TIMEOUT_SECS.");
            std::process::exit(1);
        }
    };

    // 3. 执行迁移(咨询锁串行化多实例)
    if let Err(e) = db::migrate::run_on_conn(&mut conn).await {
        std::process::exit(1);
    }

    // 4. 端口预探测
    let addr = dioxus::cli_config::fullstack_address_or_localhost();
    if let Err(e) = tokio::net::TcpListener::bind(addr).await {
        eprintln!("ERROR: 无法绑定监听地址 {addr}: {e}");
        std::process::exit(1);
    }
});

drop(migrate_rt); // 显式释放线程资源

端口预探测这段值得单独说。dioxus::server::serve() 内部对 TcpListener::bind(addr).await...unwrap() 失败会直接 panic(SIGABRT)。项目在交接给 serve() 之前先探测同一地址,把 panic unwrap 转成可操作的 exit(1) + 友好提示(“端口可能已被占用,用 lsof -i :PORT 查看占用进程”)。探测用的 listener 立即 drop,由 serve() 重新绑定——同进程内快速 rebind 不经过 TIME_WAIT,无窗口问题。

2.3 路由组装与中间件分层

dioxus::server::serve() 返回一个闭包,里面组装最终的 Axum Router。路由合并和中间件顺序是精心设计的:

dioxus::server::serve(|| async move {
    // 后台任务
    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; });
    tokio::spawn(async { tasks::ip_purge::run_purge().await; });
    sysinfo_sampler::spawn_sampler();

    // 各路由独立配置超时
    let upload_route = Router::new()
        .route("/api/upload", post(upload_image))
        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))  // 10 MiB
        .layer(TimeoutLayer::with_status_code(REQUEST_TIMEOUT, Duration::from_secs(300)))
        .layer(from_fn(csrf_middleware));

    let sse_route = Router::new()
        .route("/api/exec/stream", get(exec_stream))
        // 不挂 TimeoutLayer!SSE 是长连接,30s timeout 会杀掉流。
        .layer(from_fn(csrf_middleware));

    // Dioxus 应用路由
    let dioxus_app = Router::new().serve_dioxus_application(config, AppRouter);

    // 中间件层叠(后加的最外层先执行)
    let app_routes = dioxus_app
        .layer(from_fn(ssr_generation_middleware))
        .layer(from_fn(add_cache_control))
        .layer(from_fn(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(
        REQUEST_TIMEOUT, Duration::from_secs(30),
    ));
    // admin_guard 置于最外层(最后添加 = 最先执行)
    let app_routes = app_routes.layer(from_fn(admin_guard));

    // 静态资源(healthz / readyz / uploads)无中间件
    let static_routes = Router::new()
        .route("/healthz", get(healthz))
        .route("/readyz", get(readyz))
        .route("/uploads/{*path}", get(serve_image));

    let router = upload_route
        .merge(export_route)
        .merge(sse_route)
        .merge(app_routes)
        .merge(static_routes)
        .merge(mcp_route());

    Ok(router)
});

几个值得注意的决策:

  • 上传路由 300 秒超时——图片可能涉及 WebP 转码(CPU 密集),需要比默认 30 秒长得多的时间。
  • SSE 路由不挂 TimeoutLayer——它是长连接,30 秒超时会直接杀掉流。CSRF 对 GET 放行(is_write_method 返回 false)。
  • admin_guard 最外层——未登录的 /admin* 请求在 CSRF / cache / SSR 渲染之前就被 302 短路,零渲染开销。
  • 静态路由无中间件——healthz / readyz / 图片服务不需要 CSRF、不需要缓存头、不需要超时。

三、数据库策略:两种连接哲学

数据库层展示了一种成熟的系统思维:同一个连接池,根据使用场景采用完全不同的获取策略。

3.1 运行期:反雪崩的快速失败

运行期的 get_conn() 是反雪崩导向的。它只对“数据库不可达“这类错误退避重试,对“连接池已满“的 Timeout 直接返回,不重试:

pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
    use rand::Rng;
    let mut last_err = None;
    for attempt in 0..=crate::db::retry::MAX_RETRIES {
        match DB_POOL.get().await {
            Ok(conn) => return Ok(conn),
            Err(e) => {
                // Timeout(池满)不重试:快速失败让上层限流兜底,避免雪崩。
                // Backend/Postgres(DB 不可达)才退避重试。
                let is_timeout = matches!(e, deadpool_postgres::PoolError::Timeout(_));
                last_err = Some(e);
                if is_timeout { break; }
                // 指数退避 + jitter
                // ...
            }
        }
    }
    Err(last_err.unwrap())
}

这个区分的逻辑是:如果连接池满了,说明系统已经过载。此刻重试只会让等待队列更长、每个请求的延迟更高、最终触发更多超时和重试,形成雪崩。不如立即失败,让上层的 HTTP 限流器把新请求挡在门外,给系统喘息空间。

3.2 启动期:宽容的长重试窗口

启动期的 get_conn_for_startup() 采用了完全相反的哲学——固定 500ms 间隔轮询,以总时长为终止条件:

pub async fn get_conn_for_startup() -> Result<deadpool_postgres::Object, ...> {
    let timeout_secs = parse_migrate_startup_timeout();  // 默认 30
    let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
    let retry_interval = Duration::from_millis(500);

    let mut attempt = 0u32;
    loop {
        attempt += 1;
        match DB_POOL.get().await {
            Ok(conn) => {
                if attempt > 1 {
                    tracing::info!("connected to database after {} attempt(s)", attempt);
                }
                return Ok(conn);
            }
            Err(e) => {
                let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
                if remaining.is_zero() { return Err(e); }
                tracing::warn!(
                    "startup DB connection attempt {} failed, ~{}s remaining: {:?}",
                    attempt, remaining.as_secs(), e
                );
                let sleep = std::cmp::min(retry_interval, remaining);
                tokio::time::sleep(sleep).await;
            }
        }
    }
}

注释解释了为什么启动期不需要反雪崩:

与运行期的 get_conn 区别:

  • 没有反雪崩约束:启动时只有这一个进程在连,不会雪崩,可以放心长重试。
  • 固定间隔重试(而非指数退避):启动场景下 DB 要么起来要么没起来,固定 500ms 轮询比指数退避更可预测,也贴合 pg_isready 式的等待语义。
  • 以总时长为终止条件(而非次数):对运维更直观——“给 DB 30 秒起来”。

一个细节:sleepmin(retry_interval, remaining) 确保不会睡过 deadline——即便最后一次重试只剩下 200ms,也不会浪费。

3.3 statement_timeout:全局注入

防慢查询不是在每个查询上加超时,而是在建立连接时通过 libpq options 一次性注入 PostgreSQL 的 GUC:

fn build_pg_config() -> Result<tokio_postgres::Config, String> {
    let mut pg_cfg = std::env::var("DATABASE_URL")?.parse::<tokio_postgres::Config>()?;
    let statement_timeout_secs = std::env::var("STATEMENT_TIMEOUT_SECS")
        .ok().and_then(|s| s.parse::<u32>().ok()).unwrap_or(30);
    pg_cfg.options(format!("-c statement_timeout={}", statement_timeout_secs * 1000));
    Ok(pg_cfg)
}

默认 30 秒。这意味着任何查询(包括管理员 SQL 控制台的手动查询)如果超过 30 秒,PostgreSQL 会主动杀掉它。这防止单条慢查询(如无意中的全表扫描)长时间占用连接、拖垮整个连接池。

3.4 Fast 回收策略

连接池配置了 RecyclingMethod::Fast——归还连接时不额外发 SELECT 1 验证,直接复用:

let mgr_cfg = ManagerConfig {
    recycling_method: RecyclingMethod::Fast,
};

Verified 模式在每次 get() 时多一次 SELECT 1 往返来确认连接活着。在高并发下这个额外往返是显著的。Fast 模式依赖 tokio-postgres 在实际使用时自然报错(如果连接已断),由 get_conn 的重试层兜底。

3.5 自举:零手动首次部署

ensure_database() 在连接池首次被触碰之前运行,连接 postgres 维护库检查目标数据库是否存在:

pub async fn ensure_database() -> Result<(), String> {
    let pg_cfg = build_pg_config()?;
    let db_name = pg_cfg.get_dbname().or_else(|| pg_cfg.get_user()).map(|s| s.to_string());

    // 标识符安全校验:CREATE DATABASE 后面只能跟裸标识符(无法用 $1 参数化)
    if !is_simple_ident(&db_name) {
        tracing::warn!("skipping auto-create: db name is not a simple identifier");
        return Ok(());
    }
    // ... 连 postgres 维护库,CREATE DATABASE IF NOT EXISTS 等价逻辑
}

注释特别强调了 SQL 注入防护:CREATE DATABASE 不支持参数化($1),库名直接拼到 SQL 后面。所以 is_simple_ident 检查库名只含字母数字下划线——含 -、引号等特殊字符的库名直接跳过自动创建,把“库不存在“的错误留给后续正常连接路径去报告。

3.6 咨询锁:多实例串行化迁移

多实例滚动发布时,pg_advisory_lock 保证只有一个进程执行迁移:

const ADVISORY_LOCK_KEY: i64 = 0x5947_4752_4153_494C;
// "YGGRASIL" 的十六进制——项目专属的大整数,避免与同库其它应用冲突。

咨询锁是数据库级唯一的(不是行级锁、不随事务结束释放)。多个实例同时启动时,只有一个能拿到锁执行迁移,其余阻塞等待。拿到锁的实例执行完所有迁移后释放锁,等待的实例拿到锁后发现 schema_migrations 表已有全部版本,跳过执行。每个迁移在独立事务里运行,失败自动回滚,版本行不会写入。


四、认证体系:密码、会话与首用户原子 admin

4.1 Argon2:memory-hard 哈希

密码用 Argon2id(Argon2 的混合模式,同时抗 GPU 和侧信道攻击)。配置用的是 Argon2::default(),对应 OWASP 推荐的参数(m=19456 即 19 MB 内存、t=2 迭代、p=1 并行度):

pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    let password_hash = argon2.hash_password(password.as_bytes(), &salt)?;
    Ok(password_hash.to_string())
}

pub fn verify_password(password: &str, hash: &str) -> Result<bool, ...> {
    let parsed_hash = PasswordHash::new(hash)?;
    let argon2 = Argon2::default();
    match argon2.verify_password(password.as_bytes(), &parsed_hash) {
        Ok(()) => Ok(true),
        Err(argon2::password_hash::Error::Password) => Ok(false),
        Err(e) => Err(e),
    }
}

验证函数有个值得注意的错误处理:密码不匹配返回 Ok(false) 而非 Err——只有哈希格式无效等系统错误才返回 Err。这让调用方可以区分“密码错“和“系统错“。

Argon2 是 memory-hard 计算——它会分配并填充约 19 MB 内存。这意味着绝不能在 Tokio worker 线程上执行,否则阻塞整个线程。所以所有哈希和验证都包在 spawn_blocking 里:

let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw_for_hash))
    .await
    .map_err(|_| AppError::Internal("密码处理任务失败"))?
    .map_err(|_| AppError::Internal("密码处理失败"))?;

4.2 首 admin 的原子竞争

注册接口设计了一个精巧的“首用户自动成为 admin“机制。核心是一条 INSERT ... ON CONFLICT DO NOTHING RETURNING id

#[server(Register, "/api")]
pub async fn register(username: String, email: String, password: String) -> Result<...> {
    // 限流 → 校验用户名/邮箱/密码 → spawn_blocking Argon2 哈希

    let result = client.query_opt(
        "INSERT INTO users (username, email, password_hash, role)
         VALUES ($1, $2, $3, 'admin')
         ON CONFLICT DO NOTHING
         RETURNING id",
        &[&username, &email, &password_hash],
    ).await?;

    if result.is_some() {
        return Ok(AuthResponse { success: true, message: "注册成功".to_string(), token: None });
    }

    // 插入失败:区分是已有 admin 还是用户名/邮箱冲突
    let admin_exists: bool = client
        .query_one("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')", &[])
        .await?.get(0);

    let message = if admin_exists {
        "Registration is closed".to_string()
    } else {
        "用户名或邮箱已存在".to_string()
    };
    // ...
}

巧妙之处在于:第一次插入始终用 role = 'admin'。这意味着第一个注册的用户直接拿到 admin 权限。如果已有 admin,这条插入不会改变任何已有行的 role——ON CONFLICT DO NOTHING 在用户名或邮箱冲突时返回空。然后用一次 EXISTS 查询区分到底是“已有 admin 所以关闭注册“还是“用户名/邮箱已存在“。

整个竞争是原子的,无需 LOCK TABLE,无需应用层的先查后插(后者有 TOCTOU 竞态窗口)。

4.3 Timing Attack 防护

登录时如果用户不存在,也执行一次 Argon2 verify——用固定合法哈希(必然失败),抹平“用户不存在“与“密码错误“的响应时序差:

Ok(None) => {
    // 用户不存在时也执行一次 Argon2 verify,抹平「用户不存在」与
    // 「密码错误」的响应时序差,防止通过响应时间枚举账号。
    const DUMMY_HASH: &str =
        "$argon2id$v=19$m=19456,t=2,p=1$j3rNaAXzdExYaL94WBWtfg$...";
    let dummy_pw = password.clone();
    let _ = tokio::task::spawn_blocking(move || {
        crate::auth::password::verify_password(&dummy_pw, DUMMY_HASH)
    }).await;
    return Ok(AuthResponse {
        success: false,
        message: "Invalid credentials".to_string(),
        token: None,
    });
}

如果没有这个防护,攻击者可以通过响应时间差异判断哪些用户名存在:用户存在时会跑 Argon2 verify(约 100ms),用户不存在时直接返回(约 1ms)。Argon2 是 memory-hard,耗时显著,时序差异明显。

注意 DUMMY_HASH 用的是合法的 Argon2 PHC 格式——verify_password 不会因为格式错误提前返回,而是真跑完整个 Argon2 计算再返回 Ok(false)

4.4 Session LRU + 并发安全

同一用户的活跃 session 超过 MAX_SESSIONS_PER_USER(默认 5)时,删除最早的 session。关键是用事务 + FOR UPDATE 锁住用户行:

let tx = client.transaction().await?;
// 锁住该用户行,并发登录在此排队。
tx.execute("SELECT 1 FROM users WHERE id = $1 FOR UPDATE", &[&user_id]).await?;

let session_count: i64 = tx.query_one(
    "SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
    &[&user_id],
).await?.get(0);

if session_count >= max_sessions {
    tx.execute(
        "DELETE FROM sessions WHERE id IN (
            SELECT id FROM sessions WHERE user_id = $1 AND expires_at > NOW()
            ORDER BY created_at ASC LIMIT 1
        )",
        &[&user_id],
    ).await?;
}

tx.execute(
    "INSERT INTO sessions (user_id, token_hash, user_agent, expires_at) VALUES ($1, $2, $3, $4)",
    &[&user_id, &token_hash, &None::<String>, &expires_at],
).await?;
tx.commit().await?;

注释标注了这个问题编号(M1)。如果没有 FOR UPDATE 锁,两个并发登录请求可能同时读到 count = 4(认为未超上限),各自插入一条 session,最终 6 条超过上限。行锁把同一用户的并发登录排队成串行执行。

Session token 用 UUID v4 生成,存储时用 SHA-256 哈希——数据库里不存原始 token,即使数据库泄露也无法直接使用 session:

pub fn generate_token() -> String { Uuid::new_v4().to_string() }
pub fn hash_token(token: &str) -> String { crate::utils::server::sha256_hex(token) }

Cookie 设置 HttpOnly(JS 不可读)、SameSite=Lax(跨站 POST 不带 cookie)、可选 Secure(仅 HTTPS 传输)。

4.5 session_generation:版本化失效(只接了一半)

设计意图是:用户被降级或封禁后,通过 bump 一个版本号让所有已签发 session 立即失效。读侧完全接线了——get_user_by_token 命中 moka 缓存后仍回查数据库主键:

pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ServerFnError> {
    let token_hash = session::hash_token(token);

    if let Some(cached) = crate::cache::get_session_user(&token_hash).await {
        // 缓存命中后校验世代号:bump 后该用户所有 session 应失效。
        // 查询走主键,亚毫秒级,代价可接受。
        let current_gen: Option<i32> = get_conn().await?
            .query_opt(
                "SELECT session_generation FROM users WHERE id = $1",
                &[&cached.id],
            ).await?
            .map(|r| r.get::<_, i32>(0));

        match current_gen {
            Some(gen) if gen == cached.session_generation => return Ok(Some(cached)),
            _ => {
                // 世代不匹配或用户已删:逐出缓存,落入下方重新查询。
                crate::cache::invalidate_session_user(&token_hash).await;
            }
        }
    }
    // ... 缓存未命中时的正常查询路径
}

每次请求都查一次主键看起来开销大,但注释指出这是“亚毫秒级“——主键查询走索引,几乎是常数时间。缓存的意义不在省这次查询,而在省 JOIN sessions + users 的完整用户加载。

但写侧没有接线。 migration 012 只加了列 session_generation INT DEFAULT 0,没有 PostgreSQL TRIGGER 自动 +1。源码全局搜索也没有 UPDATE users SET session_generation 的语句。这意味着当前要触发这个失效机制,只能直接改库。这是一个诚实的半成品——读侧正确(永远 0==0 通过),但“封禁用户立即踢下线“的功能事实上还没落地。


五、错误处理体系:日志记全链,响应不泄露

这是 panic = "abort" 项目最关键的安全网。

5.1 AppError:脱敏映射

错误分两类处理。DB 类错误(连接失败、查询失败、事务失败)对外只暴露通用中文提示;业务校验错误原样透传:

pub enum AppError {
    Unauthorized(&'static str),
    Forbidden(&'static str),
    NotFound(&'static str),
    BadRequest(String),       // 业务规则拒绝,消息原样透传
    DbConn(String),
    Query(String),
    Transaction(String),
    Internal(&'static str),
}

impl AppError {
    pub fn db_conn(e: impl std::error::Error) -> Self {
        tracing::error!("DB connection failed: {}", crate::db::format_with_sources(&e));
        AppError::DbConn("connection error".to_string())
    }
    pub fn query(e: impl std::error::Error) -> Self {
        tracing::error!("Query failed: {}", crate::db::format_with_sources(&e));
        AppError::Query("query error".to_string())
    }
}

impl From<AppError> for ServerFnError {
    fn from(err: AppError) -> ServerFnError {
        let msg = match &err {
            AppError::Unauthorized(m) => m.to_string(),
            AppError::Forbidden(m) => m.to_string(),
            AppError::NotFound(m) => m.to_string(),
            AppError::BadRequest(m) => m.to_string(),      // 透传
            AppError::DbConn(_) => "服务暂时不可用".to_string(),  // 脱敏
            AppError::Query(_) => "操作失败".to_string(),
            AppError::Transaction(_) => "操作失败".to_string(),
            AppError::Internal(m) => m.to_string(),
        };
        ServerFnError::new(msg)
    }
}

区分原则很清晰:DB 错误可能含敏感信息(SQLSTATE、约束名、表结构),必须脱敏;业务错误是人类可读的操作指引(“用户名长度必须在 3-50 字符之间”),应该透传给用户。

5.2 format_with_sources:展开错误链

format_with_sources 的存在本身就是一个教训。tokio_postgres::ErrorDisplay 实现对 DB 侧错误只打印无信息量的占位串 db error,真正的错误消息藏在 source() 链里的 DbError

/// 存在的原因:tokio_postgres::Error 的 Display 对 DB 侧错误只会打印
/// 无信息量的占位串 `db error`,真正的消息文本(如
/// `column "x" of relation "y" already exists`、SQLSTATE、约束名)藏在
/// source() 链里的 postgres::error::DbError。不主动遍历链,日志和错误
/// 字符串就会全部折叠成 `db error`,无法定位失败原因。
pub fn format_with_sources(e: &dyn std::error::Error) -> String {
    use std::fmt::Write;
    let mut s = e.to_string();
    let mut cur: &dyn std::error::Error = e;
    while let Some(next) = cur.source() {
        // 跳过与外层 Display 完全相同的占位层(如 tokio_postgres 的 `db error`),
        // 避免输出 `db error: db error` 这种重复。
        let next_disp = next.to_string();
        if !next_disp.is_empty() && next_disp != s {
            let _ = write!(s, ": {next_disp}");
        }
        cur = next;
    }
    s
}

跳过重复占位层是个容易忽略的细节:如果外层和内层的 Display 输出完全相同(比如都是 db error),就不重复追加,避免 db error: db error 的冗余输出。

5.3 业务拒绝 vs 真正的错误

项目有一个贯穿全局的约定:业务校验失败返回 Ok(Response{success:false, message:"..."}) 而非 Err(ServerFnError)。例如注册时用户名不合法:

if let Err(e) = validate_username(&username) {
    return Ok(AuthResponse {
        success: false,
        message: e,   // "用户名长度必须在 3-50 字符之间"
        token: None,
    });
}

这让前端可以区分“操作被拒“(success: false,需提示用户修正输入)和“系统出错“(Err,需重试)。如果把业务拒绝也走 Err,前端的错误处理逻辑会混为一团。


六、Markdown 渲染管线:保存时算一次

6.1 render-once-at-save 架构

这是整个项目最关键的架构决策之一。文章的 Markdown 源码(content_md)和渲染后的 HTML(content_html都存在数据库里

// create.rs
let fields = render_post_fields(&content_md, &status, cover_image.as_deref()).await?;

let row = tx.query_one(
    "INSERT INTO posts (..., content_md, content_html, toc_html, status, ...)
     VALUES (..., $5, $6, $7, $8, ...)",
    &[..., &content_md, &fields.content_html, &fields.toc_html, ...],
).await?;

读路径(占流量 99%+)只需一次 SELECT content_html FROM posts 直接返回渲染好的 HTML,不需要在请求时跑 pulldown-cmark + syntect + sanitizer + KaTeX。CPU 密集的渲染只在写时发生一次。

代价是:修改语法高亮规则不会自动刷新已有文章。需要管理员点 /admin/posts 的“rebuild all“按钮(rebuild_content_html,批量 500),全量重新渲染。

6.2 两遍遍历,一次解析

render_markdown_enhanced 有个精心设计的优化——解析一次,收集成 Vec<Event> 后两遍遍历复用:

pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
    let mut opts = Options::all();
    opts.remove(Options::ENABLE_OLD_FOOTNOTES);
    opts.insert(Options::ENABLE_FOOTNOTES);

    // pulldown-cmark 只解析一次,collect 成 Vec<Event> 后两遍遍历复用。
    // 旧实现调用两次 Parser::new_ext,等于两倍的 tokenize + 解析 CPU。
    // Event 内含 CowStr(借用 md 切片),collect 后仍可重复借用。
    let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();

    // 第一遍:收集标题(level, text, id)→ 生成 TOC
    let mut headings: Vec<(u8, String, String)> = Vec::new();
    for event in &events { /* 收集标题 */ }

    // 第二遍:渲染正文 HTML,代码块走 syntect 高亮
    for event in &events { /* 渲染 */ }
}

CowStr 是 pulldown-cmark 的写时复制字符串——当内容是输入 Markdown 的子串时,它借用切片(零拷贝);只有需要修改时才分配。collectVec 后,这些借用仍然有效,两遍遍历都不会拷贝。

6.3 GFM 脚注的位掩码陷阱

pulldown-cmark 的脚注模式配置展示了对库内部的深度理解:

// ENABLE_OLD_FOOTNOTES = (1<<9)|(1<<2),它把 ENABLE_FOOTNOTES 的 bit 也打进了
// OLD 的位掩码里。Options::all() 同时置两者,使 has_gfm_footnotes()
//(=ENABLE_FOOTNOTES && !ENABLE_OLD_FOOTNOTES)返回 false,走 OLD 模式
//(续行宽松、label 可含换行)。我们想要 GFM 模式(与 GitHub 一致),所以不能
// 简单地 remove(OLD)——那会连 ENABLE_FOOTNOTES 一起清掉。
// 正确做法:先 remove(OLD)(清掉 bit 9 + bit 2),再 insert(ENABLE_FOOTNOTES)
// 单独把 bit 2 加回,使 has_gfm_footnotes() = true。
let mut opts = Options::all();
opts.remove(Options::ENABLE_OLD_FOOTNOTES);
opts.insert(Options::ENABLE_FOOTNOTES);

OLD 模式和 GFM 模式的行为差异:OLD 模式续行宽松、footnote label 可以包含换行符;GFM 模式与 GitHub 一致、解析更可控。项目要 GFM 模式,但因为位掩码的重叠设计,不能简单地“关掉旧的、打开新的“——关掉旧的会连新的也一起关掉。

6.4 HTML 转义的上下文陷阱

标题文本用于 TOC 的 aria-label 属性时,原先用 clean_html 处理。但 clean_html 面向元素内容上下文,在属性值上下文中会漏掉 "

/// 原先用 clean_html 处理属性上下文会漏掉 ",标题形如
/// " onmouseover="alert(1) 会越出属性边界。
fn escape_heading_text(s: &str) -> String {
    crate::utils::html::escape_html(s)  // 转义 & < > " '
}

这是“HTML 转义不是上下文无关的“的经典案例。clean_html 对元素内容足够(元素内容里 " 无特殊含义),但属性值里 " 会闭合属性边界。修复是复用 utils::html::escape_html(同时转义 & < > " '),避免在仓库里维护第二份转义实现。

6.5 HTML 清洗:两套白名单

sanitizer.rs 基于 lol_html 提供两套清洗策略。文章正文(clean_html)允许较完整的标签集;评论(clean_comment_html)更严格——移除 img / details / summary,禁用 data URI,外链额外加 nofollow(防 SEO 垃圾注入)。文章外链只加 noopener noreferrer(防 tabnabbing)。

一个值得注意的纵深防御设计:is_safe_data_uri 在请求路径上是死代码(两处配置都硬编码 allow_data_uri: false),但保留并配测试锁定安全不变量:

D7:生产死代码——两处 SanitizerConfig 均硬编码 allow_data_uri: false,本函数在请求路径上不可达。保留作为防御纵深:测试锁定“即便 flag=true 也只放行图片类型、拒绝 data:text/html“的安全不变量。若未来需要内联 data URI,flag + 本函数已就绪。


七、代码高亮:CSS class 方案与五层查找

7.1 ClassedHTMLGenerator 而非内联 style

高亮用 syntect 的 ClassedHTMLGenerator——它不输出内联 style="color: #...",而是输出 CSS class(<span class="k">fn</span>),配合 public/highlight.css 的主题规则。好处是:亮/暗主题切换只需换 CSS 文件,不需要重新渲染 HTML。

7.2 include_str! 内联自定义语法

syntect 内置没有 JSX/TSX/Vue/Zig/Swift/Kotlin/TypeScript 的语法定义。原本可以用运行时 add_from_folder 加载,但在 FROM scratch 的生产镜像里这条路走不通:

/// 生产镜像是 FROM scratch 的静态 musl 二进制,容器内不存在 syntaxes/
/// 目录;而 CARGO_MANIFEST_DIR 烘焙的是构建机路径(Docker 里是 /build),
/// 运行时 add_from_folder 注定失败、这些语言静默回退为纯文本。
/// 因此改为 include_str! 编译期嵌入,彻底消除运行时文件依赖。
pub(crate) const CUSTOM_SYNTAXES: &[(&str, &str)] = &[
    ("JSX", include_str!("../syntaxes/JSX.sublime-syntax")),
    ("Swift", include_str!("../syntaxes/Swift.sublime-syntax")),
    ("TSX", include_str!("../syntaxes/TSX.sublime-syntax")),
    // ...
];

注释描述了一个隐蔽的 bug:CARGO_MANIFEST_DIR 在 Docker 构建时是 /build,运行时这个路径不存在。add_from_folder 会静默失败(不报错),这些语言的高亮静默回退为纯文本——一个“不报错但不工作“的问题。

7.3 find_syntax 的五层查找

用户在代码块写 rust` 或 Rust 或 ````golang 都应正确匹配。find_syntax 依次尝试五条路径,每条都是不同的匹配策略:

fn find_syntax(lang: Option<&str>) -> &'static SyntaxReference {
    let ss = &*SYNTAX_SET;
    if let Some(lang) = lang {
        if !lang.is_empty() {
            // 1. 按扩展名匹配:rust → .rs 语法
            if let Some(s) = ss.find_syntax_by_extension(lang) { return s; }
            // 2. 按语法名称匹配:Rust
            if let Some(s) = ss.find_syntax_by_name(lang) { return s; }
            // 3. 小写扩展名再匹配一次(部分语言习惯小写)
            let lower = lang.to_lowercase();
            if lower != lang {
                if let Some(s) = ss.find_syntax_by_extension(&lower) { return s; }
            }
            // 4. 大小写不敏感的语法名称匹配(syntect 的语法名通常首字母大写)
            if let Some(s) = ss.syntaxes().iter()
                .find(|s| s.name.eq_ignore_ascii_case(lang)) { return s; }
            // 5. 常用语言别名映射表
            let aliases: &[(&str, &str)] = &[
                ("rust", "rs"), ("javascript", "js"), ("typescript", "ts"),
                ("bun", "ts"),  // bun 运行器跑的是 TypeScript
                ("python", "py"), ("golang", "go"), ("bash", "sh"),
                ("kotlin", "kt"), ("yaml", "yml"),
                // ...
            ];
            for &(from, to) in aliases {
                if lang.eq_ignore_ascii_case(from) {
                    if let Some(s) = ss.find_syntax_by_extension(to) { return s; }
                }
            }
        }
    }
    // 全部失败 → 纯文本
    ss.find_syntax_by_extension("txt")
        .or_else(|| ss.find_syntax_by_name("Plain Text"))
        .expect("no plain text syntax")
}

注意 bun → ts 的映射——bun 运行器实际跑的是 TypeScript 代码,高亮也用 TS 语法。这是代码执行与高亮的一致性考虑:如果运行器把 bun 代码当 TypeScript 执行,高亮也应该用 TypeScript 语法。


八、KaTeX:纯 Rust 数学渲染 + 物理宏表

数学公式用纯 Rust 的 katex-rs crate 渲染(无 JavaScript 运行时)。两个关键设计决策:

// katex.rs
// - OutputFormat::Html:只产出视觉层 <span class="katex">…</span>,不含 MathML
//   语义层(<math> 等)。这样 sanitizer 无需为 MathML 标签开白名单,XSS 面最小。
// - throw_on_error = false:坏公式渲染成红色错误 span 而非中断整篇文章。

OutputFormat::Html 而非 OutputFormat::HtmlAndMathml 是个安全决策——MathML 会引入大量 <math> / <mrow> / <mi> 等标签,sanitizer 要么为它们全开白名单(扩大 XSS 攻击面),要么砍掉它们(破坏公式渲染)。选择只输出视觉层 span 是最干净的方案。

物理宏表

katex-rs 默认没有 physics 宏包的宏定义,导致 \vu(单位向量)、\dv(导数)、\pdv(偏导)、\divg(散度)、\curl(旋度)、\grad(梯度)等物理常用宏渲染为红字错误。项目手动注册了完整的物理宏表:

fn physics_macros() -> &'static [(&'static str, MacroDefinition)] {
    &[
        // 数集
        (r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")),
        (r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")),
        // 微积分
        (r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}#1")),
        (r"\dv", MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}")),
        (r"\pdv", MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}")),
        // 场算子
        (r"\grad", MacroDefinition::StaticStr(r"\nabla")),
        (r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")),
        (r"\curl", MacroDefinition::StaticStr(r"\nabla \times")),
        // 量子力学 Dirac 记号
        (r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")),
        (r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")),
        (r"\braket", MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle")),
        // ...
    ]
}

注释记录了两个刻意差异:

  1. \divg(散度)覆写内置 \div(除号 ÷)——文档明确两者并存。如果覆写,写 \div 想要除号时会得到散度。
  2. \braket 虽是 katex 内置宏,但内置版本只接受 1 个参数(\langle{#1}\rangle),物理语义需要 2 个参数(\langle #1 | #2 \rangle),所以覆写为物理版本。

这些是领域知识进入代码的例证——不是泛泛的“支持 LaTeX“,而是精确到“物理学 physics 宏包的每个宏如何映射“。


九、评论系统:递归 approve 祖先链

评论的嵌套审核有个微妙问题。如果一条子评论先被管理员 approve,但它的父评论还是 pending 状态,那子评论在页面上就“悬空“了——父评论不可见导致整条嵌套链断裂,读者看到的是一条回复了“空气“的评论。

解法是 approve 一条评论时,递归 approve 所有 pending 的祖先。用 PostgreSQL 的 WITH RECURSIVE CTE 在一条 SQL 里完成:

#[server(ApproveComment, "/api")]
pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
    let _admin = get_current_admin_user().await?;
    let client = get_conn().await.map_err(AppError::db_conn)?;

    // 直接通过目标评论
    client.execute(
        "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1",
        &[&id],
    ).await?;

    // 递归向上查找所有 pending 父评论并同步通过
    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?;

    cache::invalidate_comments_by_post(post_id).await;
    cache::invalidate_pending_count().await;
    Ok(CommentResponse::ok("已通过".to_string()))
}

WITH RECURSIVEancestors CTE 从目标评论的 parent_id 开始,沿 parent_id 链逐级向上走。UNION ALL 把每一级的 parent 都收集进来。WHERE a.parent_id IS NOT NULL 在到达根评论(parent_id IS NULL,即顶级评论)时终止递归。最终的 UPDATE 把这些祖先中所有 status = 'pending' 的批量改为 approved

这比在应用层逐层查询+更新高效得多——一条 SQL 就完成了任意深度的祖先遍历和批量更新。

评论读取按 id ASC 排序返回扁平列表(不是按树结构),让前端自行构建嵌套树。查询包含一个 EXISTS 子查询确保文章已发布且未删除:

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
  AND EXISTS (
      SELECT 1 FROM posts p
      WHERE p.id = $1 AND p.status = 'published' AND p.deleted_at IS NULL
  )
ORDER BY id ASC LIMIT 200

这个 EXISTS 防止已删除或未发布的文章的评论被读到——即使评论本身是 approved 状态。


十、Slug 生成:单遍状态机与分配优化

slugify 把标题转成 URL 友好的 slug。汉字转无声调拼音(你好ni-hao),ASCII 字母数字保留,其余替换为 -。注释记录了一次完整的性能重构:

/// 单遍状态机实现:旧实现 to_lowercase 分配一次 String +
/// split.collect::<Vec> + join 再分配 + take.collect 第三次分配,
/// 共 4 次堆分配、3 遍扫描。
/// 现用 prev_dash 状态在单遍内合并连续 - 并按需截断,1 次分配、1 遍扫描。
pub fn slugify(title: &str) -> String {
    let mut out = String::with_capacity(title.len());
    // prev_dash = true 表示当前不应再输出 -(开头或上一个字符已是 -)
    let mut prev_dash = true;  // 初值 true 挡住开头的 -
    let mut len = 0usize;

    fn push_char(out: &mut String, len: &mut usize, c: char) -> bool {
        if *len >= 100 { return false; }  // 截断到 100 字符
        out.push(c);
        *len += 1;
        true
    }

    for c in title.chars() {
        if let Some(py) = c.to_pinyin() {
            // 汉字 → 拼音字母(无声调)
            for pc in py.plain().chars() {
                if !push_char(&mut out, &mut len, pc) { break; }
            }
            // 汉字成词,后接 -
            if !push_char(&mut out, &mut len, '-') { break; }
            prev_dash = true;
        } else {
            // 逐字符小写,避免全量 to_lowercase 分配
            let lc = c.to_ascii_lowercase();
            if lc.is_ascii_alphanumeric() {
                if !push_char(&mut out, &mut len, lc) { prev_dash = false; }
            } else if lc == '_' {
                if !push_char(&mut out, &mut len, '_') { prev_dash = false; }
            } else {
                // 连续分隔符合并为一个
                if !prev_dash {
                    if !push_char(&mut out, &mut len, '-') { break; }
                    prev_dash = true;
                }
            }
        }
    }
    while out.ends_with('-') { out.pop(); }  // 去尾部 -
    if out.is_empty() { return chrono::Utc::now().timestamp().to_string(); }
    out
}

几个值得注意的优化细节:

  • to_ascii_lowercase() 逐字符而非 to_lowercase() 整串——后者对 Unicode 有特殊大小写映射(如德语 ßSS)会分配新 String;前者是纯查表,不分配。
  • prev_dash 状态机——用一个布尔变量在单遍内合并连续 -,等价于旧实现的 split('-').filter(!empty).join('-'),但零分配。
  • with_capacity 预分配——按标题长度预分配,避免 push 过程中的增量扩容。
  • 空标题回退时间戳——保证 slug 永不为空(空 slug 会导致 URL 路由问题)。

唯一性校验 ensure_unique_slug事务内调用,冲突时追加 base-2 / base-3

/// 该函数应在事务内调用,确保与后续 INSERT/UPDATE 的 slug 唯一性检查
/// 在同一个事务中完成,避免并发竞态。
pub async fn ensure_unique_slug(
    tx: &deadpool_postgres::Transaction<'_>,
    base: &str,
    exclude_id: Option<i32>,
) -> Result<String, ServerFnError> { /* ... */ }

如果不放在事务内,两个并发请求可能拿到相同的 base slug,各自检查“不冲突“后同时插入,最终 slug 冲突。


十一、全文搜索:诚实的全表扫描

搜索接口的模块文档异常诚实——它直接在第一行承认了性能局限:

//! 通过 ILIKE 对 search_text 做子串模糊匹配(两侧 %),无法利用 trgm GIN
//! 索引(仅前缀模式命中),走全表扫描,靠 LIMIT 50 与搜索限流兜底。

PostgreSQL 的 trigram GIN 索引(pg_trgm 扩展)可以加速 ILIKE 查询,但只对前缀模式 query% 有效。双侧通配符 '%query%' 使索引失效,退化为全表扫描。

注释坦承这是技术债:

// 后续可升级为 tsvector 全文检索(独立大改动)。

当前的工程兜底是三道:

#[server(SearchPosts, "/api")]
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
    // 1. 严格限流(走 strict 桶)
    let ip = rate_limit::get_client_ip(&parts.headers);
    if let Err(_msg) = rate_limit::check_strict_limit(&ip) {
        return Ok(PostListResponse { posts: Vec::new(), total: 0 });
    }

    let q = query.trim();
    if q.is_empty() || q.chars().count() > 200 {  // 按 char 而非 byte 限长
        return Ok(PostListResponse { posts: Vec::new(), total: 0 });
    }

    // 2. 短 TTL 搜索结果缓存
    let cache_key = cache::normalize_search_key(q);
    if let Some((posts, total)) = cache::get_search_results(&cache_key).await {
        return Ok(PostListResponse { posts, total });
    }

    // 3. 转义 SQL LIKE 通配符,避免用户输入 % / _ 导致全表扫描放大
    let escaped = crate::utils::server::escape_like_pattern(q);

    let rows = client.query(
        "SELECT ..., word_similarity(p.search_text, $2) AS sml
         FROM posts p
         LEFT JOIN post_tags pt ON p.id = pt.post_id
         LEFT JOIN tags t ON pt.tag_id = t.id
         WHERE p.status = 'published' AND p.deleted_at IS NULL
           AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\'
         GROUP BY p.id, p.search_text
         ORDER BY sml DESC, p.published_at DESC
         LIMIT 50",
        &[&escaped, &q],
    ).await?;
}

结果按 pg_trgmword_similarity 函数计算相似度排序——这不是精确的相关度排序(那需要 tsvector + ts_rank),但对于博客规模的数据量是一个合理的近似。查询长度限制 200 字符按 chars().count() 而非 len()(bytes)——后者对多字节中文会截断到半个字符。


十二、HTTP 中间件栈的分层设计

12.1 Cache-Control 三级分级

cache_control_for_path 按路径前缀决定缓存策略,三档分明:

pub(crate) 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.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 小时 stale-while-revalidate
    Some(HeaderValue::from_static(
        "public, max-age=300, stale-while-revalidate=3600",
    ))
}

immutable 只给带内容哈希的静态资源——这些文件内容变了文件名就变(Dioxus 的 WASM bundle 带 hash 后缀),浏览器可以永不重新验证。公开页用 stale-while-revalidate 而非纯 max-age:允许浏览器在缓存过期后先用旧内容渲染、后台异步拉新,减少白屏时间。

add_cache_controlentry().or_insert() 而非直接 insert()——仅在响应尚未设置 Cache-Control 时才添加,不覆盖下游已经设置的策略。

12.2 admin_guard:fail-open 的 302 快路径

这个中间件解决了一个真实的体验问题。文档注释描述了改进前的痛:

// 此前后台鉴权完全在客户端 WASM 完成(SSR 渲染骨架屏 → WASM
// 下载/编译 → hydrate → 异步 get_current_user() → 客户端 navigator.push),
// 整条链串行,未登录用户首屏要"空白好久"才跳登录。

改进后,SSR 层直接拦截:未登录访问 /admin* 返回 302 跳 /login,根本不进入 Dioxus 渲染器。关键权衡是 fail-open

pub(crate) async fn admin_guard(
    req: axum::extract::Request, next: axum::middleware::Next,
) -> axum::response::Response {
    let path = req.uri().path().to_string();
    if !path.starts_with("/admin") {
        return next.run(req).await;  // 非 admin 路径直接放行
    }

    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,
        },
        None => false,  // 无 token:明确未登录,拦截
    };

    if is_admin {
        next.run(req).await
    } else {
        Response::builder()
            .status(StatusCode::FOUND)
            .header(header::LOCATION, "/login")
            .body(Body::empty())
            .expect("静态 302 重定向响应必然构造成功")
    }
}

DB 抖动时放行——因为把已登录的管理员踢到登录页的代价高于让客户端 AdminLayout 兜底校验。注意它只拦明确未登录(无 token),不是无差别放行。

这个中间件的安全定位是快速路径优化,不是安全决策的权威来源。真正的安全校验在每个 admin server fn 开头的 get_current_admin_user().await? 里。即便中间件 fail-open 放行了一个非法请求,server fn 仍会拒绝它。

12.3 压缩层:默认关闭

压缩默认关闭(COMPRESSION_ALGORITHMS 默认 "off"),显式设为 "all" 或算法列表才启用。tower-httpDefaultPredicate 自动跳过已是压缩格式的响应:

/// CompressionLayer 使用 tower-http 的 DefaultPredicate,开箱即用即:
/// - 跳过 image/* content-type(WebP/PNG/JPEG/GIF 等已是压缩格式,再压浪费 CPU)
/// - 跳过 gRPC 与 text/event-stream(SSE)
/// - 跳过小于 32 字节的响应

图片实际挂在 static_routes(无中间件),根本不经压缩层。


十三、三层缓存与统一失效

13.1 三层缓存

读路径用三层缓存,每层职责不同:

磁盘层——Dioxus IncrementalRenderer 把渲染好的完整 HTML 持久化到 static/<route>/。这是最外层缓存,命中时直接返回 HTML,连 Dioxus 渲染器都不进入。TTL 由 SSR_CACHE_SECS(默认 3600 秒)控制。

内存层——moka 维护 9 个独立 TTL 的缓存,用 CacheKey 枚举统一键空间:

#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub enum CacheKey {
    PublishedPosts { page: i32, per_page: i32 },
    TotalPublishedPosts,
    AllTags,
    PostBySlug(String),
    PostsByTag(String),
    PostStats,
    CommentsByPost { post_id: i32 },
    PendingCommentCount,
}

每个缓存有独立的 TTL 和容量,按数据特性差异化配置:

缓存TTL容量理由
文章列表60s100列表变动频繁,短 TTL 保证新文章快速出现
标签列表300s50标签变动少
单篇文章600s正文不变(只有编辑才变),长 TTL
文章统计60s发布/删除后需快速刷新
评论列表60s新评论需快速出现
待审核数10s管理后台需要较实时数据
会话用户300s短于 DB 会话过期时间(30 天)
搜索结果10s搜索词多样,长缓存无意义

观测层——全局世代号 GLOBAL_GENERATION,经 X-SSR-Generation 响应头暴露。它是纯观测用途的——看响应头的世代号判断 SSR 缓存是否新鲜。注释明确说明它不会实际失效 SSR 缓存

13.2 物理删除目录绕过框架限制

Dioxus 0.7 只暴露 invalidate_after(ttl) 这种粗粒度兜底,没有按路由失效的公开 API。项目用一个不算优雅但可靠的绕法——物理删除目录

// ssr_cache.rs: invalidate_ssr_route
// 物理删除 static/<route>/ 目录,绕过 Dioxus 无公开失效 API 的限制。
// route_cache_dir 过滤 ".." 防路径穿越。

13.3 统一失效序列

所有写操作的缓存失效收敛到一个函数,保证不会漏掉某一层:

pub async fn invalidate_for_post_write(slugs: &[String], tags: &[String]) {
    // 1. 失效元数据(列表 / 标签云 / 统计 / 搜索)
    invalidate_post_metadata();
    // 2. 按 slug 定向失效单篇
    for slug in slugs {
        invalidate_post_by_slug(slug).await;
    }
    // 3. 按标签失效标签下文章列表
    invalidate_tag_posts_for(tags).await;
    // 4. 逐路由 SSR 定向清理
    for slug in slugs {
        crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
    }
    // 5. 全量 SSR 刷新(兜底)
    crate::ssr_cache::invalidate_ssr_all_public();
    // 6. bump 世代号
    crate::ssr_cache::bump_global_generation();
}

这里有个微妙设计:第 5 步 invalidate_ssr_all_public 删全部公开页 SSR 缓存,所以第 4 步逐 slug 的 invalidate_ssr_route 只是先行的定向清理,不改变最终失效结果。文档注释解释了这两步并存的原因:

注意:invalidate_ssr_all_public 会删除全部公开页 SSR 缓存,因此批量场景下逐 slug 的 invalidate_ssr_route 仅是先行的定向清理,不会改变最终失效结果。

定向失效是“让该路由更快可见“的优化(删掉旧文件后,下次请求会 MISS 并重新渲染),全量刷新才是正确性兜底(保证所有可能受影响的页面都失效)。两者并存,既保正确又缩短局部可见延迟。

MCP 写工具复用的也是这条完全一致的失效序列——AI 客户端发文章和人在后台发文章走的是同一条正确性路径。

用 Python 模拟一下上面描述的缓存失效序列——观察“定向失效只是优化、全量刷新才是兜底“的行为:

python

十四、安全纵深:CSRF、限流、XFF

14.1 CSRF:SameSite 不够,但要保守放行

基础防线是 SameSite=Lax cookie。但项目文档指出它有两个盲区:(1) 登录 CSRF(攻击者诱导受害者登录攻击者账号,Lax 不阻止“设置“ cookie);(2) 未来若出现 GET 化写接口,Lax 会在顶级 GET 导航时带 cookie。因此对所有写请求叠加 Origin 校验(回退 Referer)。

最有意思的是“拿不到本站 origin 时怎么办“的权衡。trusted_origin 的注释直白:

// 返回 None 表示无法确定本站 origin(此时放行,避免误杀——CSRF 漏判
// 是请求被拒,但拿不到本站 origin 时误杀合法请求代价更高,故保守放行)。

清醒的风险计算:CSRF 漏判的最坏情况是一次非法写请求被放行(可被后续权限校验兜底),误杀的最坏情况是合法用户在配置不当时彻底无法使用站点(无解)。

配套的 warn_if_app_base_url_unset 在启动时打一条 WARN——生产环境未设 APP_BASE_URL 时,CSRF 会回退到 Host 头推导,反代后有被绕过的风险。但这个 WARN 不污染每请求路径——一次性 WARN,与每请求校验完全解耦。

14.2 限流:七个桶

7 个独立 DefaultKeyedRateLimiter:strict / upload / image / comment / code_exec / code_exec_daily / unknown。全部 env 可调。

code_exec_daily 用一个巧妙的技巧模拟日限额——governor 0.8 没有 Quota::per_day

/// governor 0.8 无 Quota::per_day,用 with_period(24h) + allow_burst(daily)
/// 模拟:每 24h 补充 1 token、突发上限即日额度,等效于「每日最多 daily 次」。
static CODE_EXEC_DAILY_LIMITER: LazyLock<...> = LazyLock::new(|| {
    RateLimiter::keyed(
        Quota::with_period(Duration::from_secs(86400))
            .unwrap()
            .allow_burst(env_or("RATE_LIMIT_CODE_EXEC_DAILY_BURST", 50))
    )
});

容器执行成本高(CPU/内存/启动延迟),需要硬性日上限防资源耗尽,与每秒突发限流构成双层。

unknown 的存在动机是务实妥协。TRUSTED_PROXY_COUNT=0(默认)时,Dioxus server function 拿不到 TCP 对端地址,get_client_ip 返回 "unknown",所有匿名请求共享同一个严格桶(1 req/s, burst 5),正常用户的高频请求被误杀。于是备一个阈值更高的宽松桶兜底:

/// 当无法识别真实客户端 IP("unknown")时使用的宽松限流桶。
/// TRUSTED_PROXY_COUNT=0(默认)时,Dioxus server function 拿不到 TCP 对端地址,
/// get_client_ip 会返回 "unknown",导致所有匿名请求共享同一个严格桶
/// (1 req/s, burst 5),正常用户的高频请求被误杀。此桶阈值更高。
static UNKNOWN_BUCKET_LIMITER: LazyLock<...> = LazyLock::new(|| { /* ... */ });

14.3 IP 轮换攻击与 retain_recent GC

攻击者不断换 IP 制造新限流桶键,导致键空间无限膨胀。ensure_limiter_gc 惰性派生一个常驻 tokio 任务周期回收:

/// 七个 IP 键控限流器均为独立的 DefaultKeyedRateLimiter,没有集中状态表,故采用
/// 惰性启动:首个请求到达任一 check_* 时,经 Once 派生一个常驻 tokio 任务,按
/// limiter_gc_interval 周期对全部限流器调用 retain_recent,回收长时间未命中的键。
/// retain_recent 只丢弃与「新桶」不可区分的键(即限流窗口早已冷却),
/// 因此即便间隔较长,内存占用也只反映「最近活跃过且仍在限流窗口内」的 IP 集合。

14.4 XFF 解析风险

ip_from_x_forwarded_for 的文档注释像一份安全告示:

/// XFF 头由客户端**可写**,本函数以 parts[len-1-trusted_proxy_count] 选取
/// 「可信代理链最左侧之外」的地址。该取值完全依赖 trusted_proxy_count 与真实
/// 代理跳数精确相等,一旦不符即可被滥用:
/// - 配得偏大:选中客户端伪造的地址——攻击者塞入伪 XFF 段绕限流。
/// - 配得偏小:选中中间代理 IP,所有用户共享一桶。

十五、SQL 控制台:四道护栏 + Token 级匹配

管理员后台有一个 SQL 控制台(全读写),这是最高风险的功能。四道护栏:

  1. 高危语句闸门DROP DATABASE/DROP SCHEMA/CREATE DATABASE 绝禁;DROP/TRUNCATE/ALTERconfirm_dangerous
  2. 无 WHERE 拦截UPDATE/DELETEselection 拒绝。
  3. 查询超时:复用 STATEMENT_TIMEOUT_SECS(pool 层已注入 GUC)。
  4. 前端二次确认

护栏 1 的实现有个关键技术细节——用 token 序列匹配而非 contains() 子串:

/// 注意:这里存的是「关键字序列」,由 is_absolutely_forbidden 做 token 级
/// 匹配——而非原始 contains() 子串。这样 DROP   DATABASE(多空格)、
/// DROP\tDATABASE、DROP\nDATABASE 等绕过单空格子串的写法都能命中。
/// 关键背景:sqlparser 的 PostgreSqlDialect 无法解析 DROP/CREATE DATABASE,
/// 这类语句【没有 AST 兜底】,字符串预检是唯一防线,故必须 token 级鲁棒。
const ABSOLUTELY_FORBIDDEN: &[&[&str]] = &[
    &["drop", "database"],
    &["drop", "schema"],
    &["create", "database"],
];

fn is_absolutely_forbidden(sql: &str) -> Option<&'static str> {
    // 规范化:小写 + 按任意空白拆分 + 去掉 token 尾部的 , ; ( )
    let lowered = sql.to_lowercase();
    let tokens: Vec<&str> = lowered
        .split_whitespace()
        .map(|t| t.trim_end_matches([',', ';', '(', ')']))
        .collect();
    // 在 token 流里滑窗查连续序列
    for forbidden in ABSOLUTELY_FORBIDDEN {
        for window in tokens.windows(forbidden.len()) {
            if window == *forbidden {
                return Some(match forbidden {
                    ["drop", "database"] => "DROP DATABASE",
                    ["drop", "schema"] => "DROP SCHEMA",
                    ["create", "database"] => "CREATE DATABASE",
                    _ => "未知高危语句",
                });
            }
        }
    }
    None
}

为什么不用 sql.contains("drop database")?因为 drop database(多空格)、drop\tdatabase(制表符)、drop\ndatabase(换行)在子串匹配下会漏过,在 token 序列下全部命中。

为什么不用 sqlparser AST 解析?注释点明:sqlparser 的 PostgreSqlDialect 无法解析 DROP DATABASE / CREATE DATABASE——这类语句没有 AST 表示,字符串预检是唯一防线。


十六、备份恢复:签名校验与幂等恢复

16.1 备份

备份优先用 pg_dump--clean --if-exists 使恢复幂等),不可用则回退纯 SQL:

const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";  // 文件名白名单防路径穿越

--clean --if-exists 让 pg_dump 生成的脚本自带 DROP ... IF EXISTS,使恢复操作幂等——可以安全地反复执行。

模块文档有个诚实的兼容性提示:

兼容性提示--clean --if-exists 是后加的备份参数。本修复之前生成的备份文件不含 DROP,对其执行恢复会在第一条「relation already exists」处中止并报失败(行为正确,但无法恢复数据)——需重新创建备份才能恢复。

16.2 恢复

恢复时三道防线:(1) 签名校验(拒绝非本系统文件);(2) 路径穿越防护(Component 校验规范化路径,拒绝 ../);(3) 二次确认。psql -v ON_ERROR_STOP=1 确保任何 SQL 错误立即中止并报失败。

长耗时操作走后台任务 + 进度轮询:create_backuprestore_backup 立即返回 task_id,实际工作在 tokio::spawn 里执行,前端通过 get_task_progress 轮询进度。


十七、代码沙箱:Docker 隔离执行

17.1 沙箱配置

文章里的 lang runnable 代码块和 /admin/runner 在 Docker 容器里执行。build_host_config 是一份保守的安全清单:

pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
    let mut tmpfs = HashMap::new();
    // /code 用 mode=1777(sticky + all-rwx)让容器内 1000:1000 用户可写。
    // 不用 uid=1000,gid=1000:那是 Docker 的 tmpfs 扩展选项,Podman 报错。
    // mode=1777 是 POSIX 标准 tmpfs 选项,Docker 与 Podman 都支持。
    tmpfs.insert("/code".to_string(), "size=16m,mode=1777".to_string());
    // /tmp 必须 exec:编译型语言把编译产物落在 /tmp 后再 exec,
    // Docker tmpfs 默认 noexec 会让执行时报 EACCES。
    tmpfs.insert("/tmp".to_string(), "size=64m,mode=1777,exec".to_string());
    tmpfs.insert("/run".to_string(), "size=16m,mode=1777".to_string());

    let memory = (limits.memory_mb * 1024 * 1024) as i64;

    HostConfig {
        cpu_quota: Some((limits.cpu_cores * 100_000.0) as i64),
        cpu_period: Some(100_000),
        memory: Some(memory),
        memory_swap: Some(memory),   // = memory, disable swap
        network_mode: Some(if limits.allow_network { "bridge" } else { "none" }),
        readonly_rootfs: Some(true),
        tmpfs: Some(tmpfs),
        pids_limit: Some(64),
        // 不设 nproc:RLIMIT_NPROC 在 setrlimit 时按 UID 计数,配合 non-root 用户
        // 会让容器初始 exec /bin/sh 直接 EAGAIN。pids_limit 已在 cgroup 层兜底。
        ulimits: Some(vec![ResourcesUlimits {
            name: Some("nofile".to_string()),
            soft: Some(64),
            hard: Some(64),
        }]),
        cap_drop: Some(vec!["ALL".to_string()]),
        security_opt: Some(vec!["no-new-privileges".to_string()]),
        auto_remove: Some(false),  // must be false to avoid premature removal before getting logs
        ..Default::default()
    }
}

每一项配置都有踩坑背景:

  • mode=1777 而非 uid=1000,gid=1000——后者是 Docker 的 tmpfs 扩展选项,Podman 不支持(报 unknown mount option)。mode=1777 是 POSIX 标准,两个运行时都支持。
  • /tmp 必须 exec——Docker tmpfs 默认 noexec,编译型语言(go/rust)把编译产物落在 /tmp 后再 exec 会报 EACCES
  • memory == memory_swap——设为相等值禁用 swap,防止容器把内存换到磁盘拖慢整个宿主机。
  • 刻意不设 nproc——RLIMIT_NPROC 按 UID 计数,配合 non-root 用户会让容器初始 exec /bin/sh 直接 EAGAINpids_limit 已在 cgroup 层兜底限制进程数,nproc 是冗余且有害的双重约束。
  • auto_remove: false——必须 false,否则容器退出时立即删除,来不及获取日志输出。

17.2 ContainerGuard:RAII 清理

容器清理用 RAII 模式。ContainerGuard 在创建容器时构造,Drop 时 fire-and-forget 删除容器:

impl Drop for ContainerGuard {
    fn drop(&mut self) {
        let docker = self.docker.clone();
        let container_id = self.container_id.clone();
        // fire-and-forget:调用方已返回,无法把错误回传给业务层。
        // 重试几次抵抗瞬时故障,仍失败则记录 container_id 供运维 docker rm -f。
        tokio::spawn(async move {
            let max_attempts = 3u8;
            let mut backoff = Duration::from_millis(200);
            for attempt in 1..=max_attempts {
                match docker.remove_container(&container_id, Some(RemoveContainerOptions {
                    force: true, ..Default::default()
                })).await {
                    Ok(()) => return,
                    Err(e) if attempt < max_attempts => {
                        tracing::warn!(attempt, "remove_container 失败,稍后重试: {:?}", e);
                        tokio::time::sleep(backoff).await;
                        backoff *= 2;
                    }
                    Err(e) => {
                        tracing::error!(
                            container_id = %container_id,
                            "重试 {} 次后仍无法删除容器,请手动执行 docker rm -f {}: {:?}",
                            max_attempts, container_id, e
                        );
                        return;
                    }
                }
            }
        });
    }
}

3 次指数退避重试(200ms → 400ms → 800ms)抵抗 Docker daemon 瞬时故障。最终失败则把 container_id 记进 error 日志,便于运维手动 docker rm -f 清理。

17.3 流式输出:select! 并发陷阱

容器执行必须用 tokio::select! 并发等待容器结束和读取日志:

// 若先 await wait_container 再读日志流,wait 会阻塞到程序结束,
// 届时 attach stream 里已缓冲全部输出,stream.next() 一次性读完——
// 表现为"等完再一次性输出",流式名存实亡。
// 用 select! 让两条分支并发:
// - log_reader:持续读 attach stream,每块 chunk 立即 tx.send 推流
// - wait_with_timeout:等容器退出(带超时),退出后日志流自然结束

如果没有并发,wait_container 会阻塞到程序自然结束,届时 attach stream 里已经缓冲了全部输出,stream.next() 会一次性快速读完——用户看到的是“等很久然后一次性蹦出所有输出“,而非边执行边显示。

17.4 执行流程:校验 → 入队 → 后台执行

代码执行有两条路径——轮询(start_exec)和流式(start_exec_stream),共用同一套校验逻辑:

// 公共校验:速率限制 + 语言白名单 + 源码大小
fn validate_exec_request(req: &ExecRequest) -> Result<(), ServerFnError> {
    if !is_supported_lang(&req.language) {
        return Err(ServerFnError::new("不支持该执行语言"));
    }
    if req.source.len() > RUNNER_CONFIG.max_source_bytes as usize {
        return Err(ServerFnError::new("源代码过大"));
    }
    Ok(())
}

// admin 跳过速率限制
async fn check_rate_limit_for_user() -> Result<(), ServerFnError> {
    let is_admin = get_current_admin_user().await.is_ok();
    if !is_admin {
        let ip = client_ip();
        if let Err(msg) = check_code_exec_limit(&ip) {
            return Err(ServerFnError::new(msg));
        }
    }
    Ok(())
}

后台执行用信号量限制并发容器数:

pub static RUNNER_SEMAPHORE: LazyLock<Arc<Semaphore>> =
    LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent)));

fn spawn_exec_task(task_id: String, req: ExecRequest, stream_tx: Option<...>) {
    tokio::spawn(async move {
        // 排队等待可用容器槽
        let ticket = match tokio::time::timeout(
            Duration::from_secs(RUNNER_CONFIG.queue_timeout_secs),
            sem.acquire(),
        ).await {
            Ok(Ok(t)) => t,
            _ => {
                update_task_stage(&task_id, ExecStatus::Failed, "系统繁忙,排队超时");
                return;
            }
        };

        // 资源限制合并与钳制
        let final_limits = clamp_limits(base_limits, lang_def.allow_network);

        // 执行(流式 vs 非流式分叉)
        let res = match stream_tx {
            Some(tx) => run_in_container_stream(...).await,
            None => run_in_container(...).await,
        };

        drop(ticket);  // 显式释放信号量
        // ... 状态映射
    });
}

错误脱敏的设计也值得说——匿名可见的错误返回中文消息(“不支持该执行语言”、“源代码过大”),系统内部异常(容器拉起失败等)只记服务端日志,对前端返回统一的“系统暂时不可用“。

17.5 SSE 流式端点

GET /api/exec/stream?task_id=X 是 SSE 端点。它从 EXEC_STREAMS 取走 receiver(取即删,防重复连接),映射成三类事件:

pub async fn exec_stream(
    Query(q): Query<StreamQuery>,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, (StatusCode, String)> {
    // 取出 rx(取出即移除):同一 task_id 只能连一次 SSE
    let (_, entry) = EXEC_STREAMS
        .remove(&q.task_id)
        .ok_or((StatusCode::NOT_FOUND, "任务不存在或已结束".to_string()))?;

    let stream = ReceiverStream::new(entry.rx).map(|chunk| {
        Ok::<_, Infallible>(match chunk {
            OutputChunk::Stdout(s) => Event::default().event("stdout").data(s),
            OutputChunk::Stderr(s) => Event::default().event("stderr").data(s),
            OutputChunk::Done { exit_code, oom_killed, timed_out, duration_ms } => {
                Event::default().event("done").json_data(DonePayload {
                    exit_code, oom_killed, timed_out, duration_ms,
                }).unwrap_or_else(|_| Event::default().event("done").data("{}"))
            }
        })
    });

    Ok(Sse::new(stream).keep_alive(
        KeepAlive::new()
            .interval(Duration::from_secs(15))
            .text("keep-alive")
    ))
}

remove 而非 get——同一 task_id 只能连一次 SSE,防止多客户端或重连导致 receiver 被多次消费。15 秒 keep-alive 防止反向代理因空闲超时关闭连接。

17.6 亲手试一试:沙箱里跑代码

上面剖析的所有安全围栏——只读 rootfs、cap_drop ALL、pids_limit 64、非 root 用户——此刻都在为你服务。下面每一个代码块都标记了 runnable,点击运行按钮即可在 Docker 容器里实时执行,输出经 SSE 流式推送回页面。

Python —— 试试触发 pids_limit:

python

Node.js —— 验证网络隔离:

node

Go —— 编译型语言在 tmpfs 上的执行路径:

go

Rust —— 感受 select! 并发流式(与 17.3 节呼应):

rust

十八、MCP 服务器:博客即 AI 知识库

18.1 rmcp 版本锁定

rmcp 锁在 =3.0.0-beta.3 而非稳定的 0.2.1

rmcp = { version = "=3.0.0-beta.3", optional = true,
         features = ["server", "macros",
                     "transport-streamable-http-server", "transport-worker"] }

稳定版缺少几项 spec 强制项:Origin 校验、MCP-Protocol-Version 头校验、请求体积上限(4 MiB)、无状态默认(SEP-2567)。用稳定版等于裸奔。transport-worker 也必须显式加——LocalSessionManager 无条件引用 transport::worker,但 transport-streamable-http-server feature 没有自动拉入它。

18.2 双白名单与开发期容错

挂载时同时配置 Origin 白名单与 Host 白名单,两者都源自 APP_BASE_URL。有个贴心的开发期容错:

// 开发期容错:APP_BASE_URL 未设或仍是 localhost 系(开发态)时,额外放行 0.0.0.0。
// 原因:dx serve --addr 0.0.0.0 转发到后端原生 server 时会把 Host 头改写成
// 0.0.0.0:<随机端口>(原生 server 端口每次重编译变化),rmcp 据此判 rebinding 攻击
// 一律 403——导致 MCP 在 dx 开发代理(:8080)下完全不可用。0.0.0.0 仅在本地开发
// 无攻击价值,故仅在未配置生产域名时放行;生产域名态保持严格。
let is_dev = base_url.as_deref()
    .and_then(|b| http::Uri::try_from(b).ok())
    .and_then(|u| u.authority().map(|a| a.host().to_lowercase()))
    .map(|h| matches!(h.as_str(), "localhost" | "127.0.0.1" | "::1"))
    .unwrap_or(true); // 未设 APP_BASE_URL 视为开发态
if is_dev && !allowed_hosts.iter().any(|h| h == "0.0.0.0") {
    allowed_hosts.push("0.0.0.0".into());
}

18.3 鉴权中间件

bearer → principal 的 axum from_fn 中间件:解析 Authorization: Bearer ygg_... → SHA-256 哈希 → 一次常量 SQL 查 → 注入 McpPrincipal。限流按 token_id 分桶(超限 429),last_used_at 用 60 秒节流避免每次请求都写库。

Token 不裸存明文,AES-GCM-256 加密,每次生成独立的 12 字节 OsRng nonce(nonce‖ct‖tag 一同 hex 存储)。

18.4 工具组合

7 组工具(read / posts / comments / tags / media / settings / runner)在同一个 YggMcpServer 上 impl 出公开 ToolRouterserver.rs+ 合并成单个 ServerHandler。作用域校验收敛到一个 require_scope,按 read < write < admin 三级偏序授权。


十九、一行配置治好 405

这是整个项目最具戏剧性的故障故事。

现象: 交叉编译部署后,登录请求返回 405 Method Not Allowed,响应头 allow: GET,HEAD

根因: Dioxus 0.7.9 的 #[server(Fn, "/api")] 宏在 URL 末尾追加去冲突数字后缀:

xxh64( <key> + ":" + module_path!(), 0 )

默认 <key> = CARGO_MANIFEST_DIR。生产部署里 server 和 WASM 在不同目录编译 → 后缀不同 → 浏览器请求 /api/login<容器hash>,server 只注册了 /api/login<宿主hash> → Dioxus SSR 兜底 GET handler 接管 → POST 撞 GET-only 路由 → 405。

诊断: 比对容器启动日志里 dioxus_server "Registering: POST /api/login<数字>"(server 端后缀)和浏览器 Network 的 POST /api/login<数字>(wasm 端后缀)。不一致即此问题。可进一步用 xxh64(候选路径 + ":" + "crate::module::path", 0) 精确验证哪个编译目录。

修复: 宏官方逃生舱(dioxus-fullstack-macro src/lib.rs:392)——编译时若设了 SERVER_FN_OVERRIDE_KEY,用它替代 CARGO_MANIFEST_DIR

# .cargo/config.toml
[env]
SERVER_FN_OVERRIDE_KEY = "yggdrasil"

两个 Dockerfile.cross builder stage 都继承这个配置,/api 路径两端匹配。


二十、零 QEMU 交叉编译

Apple Silicon 上为 x86_64 服务器构建,QEMU 让 rustc SIGSEGV,Rosetta 在 Docker VMM 下不可用。Dockerfile.cross 用两个 native-arm64 builder:

# Stage A: 前端(glibc Trixie,native arm64)
# dx CLI 需 GLIBC_2.39;Bookworm 只有 2.36(dx --version 报 GLIBC_2.39 not found)
FROM --platform=$BUILDPLATFORM rust:1.96-trixie AS frontend

# Stage B: server(Alpine musl,native arm64)
FROM --platform=$BUILDPLATFORM rust:1.96-alpine3.22 AS server
RUN apk add --no-cache zig build-base musl-dev pkgconfig
RUN cargo install cargo-zigbuild --version 0.23.0 --locked
RUN cargo zigbuild --release --target x86_64-unknown-linux-musl \
        --no-default-features --features server

# Stage C: linux/amd64 FROM scratch,拷贝两个产物
FROM scratch
COPY --from=frontend /build/dist/public /app/public/
COPY --from=server /build/server /app/yggdrasil

不能合并为一个 stage:dx CLI 是 glibc 二进制,zig(apk 装的)动态链接 musl + LLVM 无法跨环境拷贝。

zig 的来源本身就是故事——容器内只能从 Alpine apk(TUNA 镜像)装:ziglang.org 中国不可达(~22KB/s + SSL reset),GitHub Releases 没传 zig Linux 二进制,中文镜像全返回 HTTP 200 的 HTML 错误页。


二十一、图片处理:WebP 管线

image crate 的 webp feature 被刻意排除——所有 WebP 编解码走 zenwebp

// webp.rs 模块文档
// - image crate:通用格式(JPEG/PNG/GIF)解码、缩放、旋转、像素格式转换。
//   本项目特意禁用了 image 的 webp feature——它不支持编码,且解码能力有限。
// - zenwebp:专门负责 WebP 的有损编码与解码。

上传原图存 uploads/,访问时按需转码 WebP 派生缓存写 uploads/.cache/。后台任务每小时清理,溢出防护值得品味——panic = "abort"SystemTime 下溢不是“单次清理失败“而是整个进程崩溃

// checked_sub 防 SystemTime 下溢 panic(极端配置/时钟回拨时 max_age 可能
// 超过 now 距 UNIX_EPOCH 的时长)。release panic="abort" 会直接崩溃整个进程。
const MAX_AGE_HOURS_CAP: u64 = 87_600; // 10 年
let cutoff = now.checked_sub(max_age).unwrap_or(SystemTime::UNIX_EPOCH);

二十二、后台自治任务

5 个后台循环各自独立、永不中断:

任务频率职责
session_cleanup每小时删过期 session + invalidate_all 内存缓存
post_purge每天按保留天数物理删除回收站过期文章
image_cache_cleanup每小时超龄删除 + 超量 LRU 淘汰 uploads/.cache/
ip_purge周期清理限流器冷却 IP 键
sysinfo0.5s/admin/system 指标采样

错误处理模式一致——记录后继续 ticker.tick().await,绝不 break。post_purge 每次执行前重新读 settings 表——管理员调了保留天数不需要重启进程,下一轮就生效。


二十三、panic=abort 与工程纪律

panic = "abort" 下任何 panic 杀死整个进程。项目立下规矩:非测试代码禁止裸 unwrap()。所有 .expect() 必须带不变性推理——消息是写给未来读代码的人的不变式契约:

// val.max(1) 保证 ≥ 1,NonZeroU32::new 必然 Some
NonZeroU32::new(val.max(1)).expect("val.max(1) 保证非零,NonZeroU32::new 不可能失败")

// etag 仅含 ASCII hex 与双引号,必然是合法 HeaderValue
HeaderValue::from_str(&etag)
    .expect("etag 仅含 ASCII hex 与双引号,必然是合法的 HeaderValue")

// 静态 302 响应必然构造成功(合法 status + 固定 header + 空 body)
.body(Body::empty()).expect("静态 302 重定向响应必然构造成功")

// 启动期已校验
build_pg_config().expect("DATABASE_URL should have been validated at startup ...")

豁免规则明确记录在 AGENTS.md 里:

  • LazyLock / OnceLock 初始化编译期常量可用 .expect()——运行一次,失败意味着源常量本身有错。
  • WASM 浏览器上下文(web_sys::window())可用——缺失 window 证明运行在浏览器外,是部署 bug。
  • unreachable!() 只允许在 #[cfg(not(feature = "server"))] 桩里。
  • 测试代码和构建工具二进制豁免。

在沙箱里体验一下 Result + ? 的正确错误处理——与 unwrap() 的对比:

rust

二十四、前端架构:use_server_future 的 route-subscription gotcha

Dioxus 0.7 的 use_server_future(内部即 use_resource)只在闭包内读取的 signal 变化时才会重跑 future。它通过 ReactiveContext 追踪闭包执行期间的订阅。

但路由参数 slug 是一个普通 String prop,被 move 进闭包后成了冻结快照——读取它不会建立订阅。这导致一个隐蔽 bug:上/下一篇导航(同一路由变体间的 slug 变化)会复用组件实例、更新 props,却无法触发 future 重跑——URL 变了但内容不变,刷新才生效

#[component]
pub fn PostDetail(slug: String) -> Element {
    let router = dioxus::router::router();

    let post = use_server_future(move || {
        // 在闭包内读取当前 slug:current() 内部会 subscribe_to_current_context(),
        // 把订阅注册到 use_server_future 的 ReactiveContext,路由变化即重跑。
        // slug prop 本身是冻结的 String 快照,不能作为依赖。
        let current_slug = match router.current::<Route>() {
            Route::PostDetail { slug } => slug,
            // 组件卸载/路由切走的瞬间可能命中其它变体,退回用 prop 值兜底。
            _ => slug.clone(),
        };
        get_post_by_slug(current_slug)
    })?;
    // ...
}

修复:在闭包内通过 router().current::<Route>() 读取当前 slug。current() 内部调用 subscribe_to_current_context(),在 use_server_futureReactiveContext 中注册订阅;路由变化时订阅触发,future 自动重跑。

这段模块文档是整个项目最值得读的注释之一——它精确描述了 Dioxus 0.7 反应式系统的语义、一个常见的 gotcha、以及修复方案。AGENTS.md 强调“编辑 pages 前先读 post_detail.rs 头文档“。


二十五、再跑几个:完整语言矩阵

如果你还没点够运行按钮,这里还有一些跨语言的示例——每个都跑在前文剖析的 Docker 沙箱里:

Bun (TypeScript) —— MCP 鉴权流程模拟:

bun

Python —— 模拟 SQL 控制台的 token 级匹配(与第十五章呼应):

python

尾声:诚实的技术债

Yggdrasil 是一个认真对待工程的项目。每个决策都留下了为什么这么做的注释。它有未完成的地方,但它从不假装它们不存在。

session_generation 失效只接了一半。 读侧完全接线(缓存命中后回查主键世代号逐出),但写侧无自动 bump。“封禁用户立即踢下线“事实上还没落地。

搜索走全表扫描。 ILIKE '%query%' 无法利用 trigram GIN 索引。模块文档第一行就写着“走全表扫描“,注释坦承“后续可升级为 tsvector 全文检索“。

.cargo/config.toml:5 注释漂移。 仍写 “server: host 上 cross build”,但 cross 工具早已废弃(现为 Dockerfile.cross 全容器内构建)。

这些债务被诚实地记录在代码注释里,而非假装不存在。搜索模块的文档第一行就写着“走全表扫描“;session_generation 的文档写着“bump 后该用户所有 session 应失效“但找不到 bump 的代码。读代码的人一眼就能看到差距在哪里。