fix: resolve image/upload 500 errors and refactor rate limiting

- Separate /uploads and /api/upload routes from Dioxus app to avoid
  IncrementalRenderer intercepting non-page requests
- Remove broken SmartIpKeyExtractor-based general_limit() that failed
  under Dioxus dev server proxy (Unable To Extract Key)
- Move rate limiting into handlers using governor::RateLimiter directly
- Add IMAGE_LIMITER for /uploads/* serving
- Make all rate limits configurable via environment variables
- Add rate limit config to .env.example with sensible defaults
This commit is contained in:
xfy 2026-06-09 09:48:51 +08:00
parent 4f368e6fb8
commit 3974856f3d
4 changed files with 64 additions and 40 deletions

View File

@ -1,2 +1,12 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil
RUST_LOG=info
# Rate Limit — 严格限流(登录、注册)
RATE_LIMIT_STRICT_PER_SEC=1
RATE_LIMIT_STRICT_BURST=5
# Rate Limit — 上传限流(图片上传)
RATE_LIMIT_UPLOAD_PER_SEC=2
RATE_LIMIT_UPLOAD_BURST=15
# Rate Limit — 图片访问限流(/uploads/*
RATE_LIMIT_IMAGE_PER_SEC=10
RATE_LIMIT_IMAGE_BURST=50

View File

@ -224,8 +224,19 @@ fn is_path_safe(path: &str) -> bool {
}
#[cfg(feature = "server")]
pub async fn serve_image(Path(path): Path<String>, Query(params): Query<ImageParams>) -> Response {
// Path traversal protection
use axum::http::HeaderMap;
#[cfg(feature = "server")]
pub async fn serve_image(
Path(path): Path<String>,
Query(params): Query<ImageParams>,
headers: HeaderMap,
) -> Response {
let ip = crate::api::rate_limit::get_client_ip(&headers);
if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) {
return status.into_response();
}
if !is_path_safe(&path) {
return StatusCode::FORBIDDEN.into_response();
}

View File

@ -1,58 +1,57 @@
#![allow(clippy::unused_unit)]
#[cfg(feature = "server")]
use std::sync::Arc;
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
use std::num::NonZeroU32;
#[cfg(feature = "server")]
use tower_governor::governor::GovernorConfigBuilder;
#[cfg(feature = "server")]
use tower_governor::GovernorLayer;
#[cfg(feature = "server")]
use tower_governor::key_extractor::SmartIpKeyExtractor;
#[cfg(feature = "server")]
use governor::middleware::NoOpMiddleware;
#[cfg(feature = "server")]
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
/// 通用限流配置:每秒 1 请求,突发 30
#[cfg(feature = "server")]
pub fn general_limit() -> GovernorLayer<SmartIpKeyExtractor, NoOpMiddleware> {
let config = GovernorConfigBuilder::default()
.per_second(1)
.burst_size(30)
.key_extractor(SmartIpKeyExtractor)
.finish()
.unwrap();
GovernorLayer {
config: Arc::new(config),
}
use axum::http::StatusCode;
#[cfg(feature = "server")]
fn env_or(key: &str, default: u32) -> NonZeroU32 {
let val = std::env::var(key)
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(default);
NonZeroU32::new(val.max(1)).unwrap()
}
// 严格限流:每秒 1突发 5用于登录、注册等敏感操作
#[cfg(feature = "server")]
static STRICT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(NonZeroU32::new(1).unwrap())
.allow_burst(NonZeroU32::new(5).unwrap())
Quota::per_second(env_or("RATE_LIMIT_STRICT_PER_SEC", 1))
.allow_burst(env_or("RATE_LIMIT_STRICT_BURST", 5)),
)
});
// 上传限流:每秒 1突发 10
#[cfg(feature = "server")]
static UPLOAD_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(NonZeroU32::new(1).unwrap())
.allow_burst(NonZeroU32::new(10).unwrap())
Quota::per_second(env_or("RATE_LIMIT_UPLOAD_PER_SEC", 2))
.allow_burst(env_or("RATE_LIMIT_UPLOAD_BURST", 15)),
)
});
/// 从请求 headers 中提取客户端 IP
#[cfg(feature = "server")]
static IMAGE_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_IMAGE_PER_SEC", 10))
.allow_burst(env_or("RATE_LIMIT_IMAGE_BURST", 50)),
)
});
#[cfg(feature = "server")]
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
IMAGE_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| StatusCode::TOO_MANY_REQUESTS)
}
#[cfg(feature = "server")]
pub fn get_client_ip(headers: &http::HeaderMap) -> String {
// 1. X-Forwarded-For
if let Some(ip) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
@ -60,7 +59,6 @@ pub fn get_client_ip(headers: &http::HeaderMap) -> String {
{
return ip.trim().to_string();
}
// 2. X-Real-Ip
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
@ -70,7 +68,6 @@ pub fn get_client_ip(headers: &http::HeaderMap) -> String {
"unknown".to_string()
}
/// 检查严格限流(用于登录、注册等敏感操作)
#[cfg(feature = "server")]
pub fn check_strict_limit(ip: &str) -> Result<(), String> {
STRICT_LIMITER
@ -79,7 +76,6 @@ pub fn check_strict_limit(ip: &str) -> Result<(), String> {
.map_err(|_| "请求过于频繁,请稍后再试".to_string())
}
/// 检查上传限流
#[cfg(feature = "server")]
pub fn check_upload_limit(ip: &str) -> Result<(), String> {
UPLOAD_LIMITER

View File

@ -44,15 +44,22 @@ fn main() {
dioxus::server::IncrementalRendererConfig::default()
.invalidate_after(std::time::Duration::from_secs(300)),
);
let router = axum::Router::new()
let api_routes = axum::Router::new()
.route(
"/api/upload",
axum::routing::post(crate::api::upload::upload_image)
.layer(axum::extract::DefaultBodyLimit::disable()),
)
.route("/uploads/{*path}", axum::routing::get(crate::api::image::serve_image))
.layer(crate::api::rate_limit::general_limit())
.serve_dioxus_application(config, router::AppRouter)
);
let static_routes = axum::Router::new()
.route("/uploads/{*path}", axum::routing::get(crate::api::image::serve_image));
let dioxus_app = axum::Router::new()
.serve_dioxus_application(config, router::AppRouter);
let router = api_routes
.merge(static_routes)
.merge(dioxus_app)
.layer(
TraceLayer::new_for_http()
.make_span_with(