This commit is contained in:
Ember Moth
2026-07-05 20:47:34 +08:00
parent 4f74e14fba
commit 25d190b90e
5 changed files with 147 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ pub mod adapter;
pub mod cache;
pub mod config;
pub mod db;
pub mod exchange_rate;
pub mod handler;
pub mod middleware;
pub mod migration;
+1
View File
@@ -4,5 +4,6 @@ pub mod device_middleware;
pub mod logger_middleware;
pub mod notify_middleware;
pub mod pan_domain_middleware;
pub mod rate_limit_middleware;
pub mod server_middleware;
pub mod trace_middleware;
+133
View File
@@ -0,0 +1,133 @@
//! Redis-based period rate-limit middleware.
//! Port of `server/pkg/limit/periodlimit.go`.
//!
//! Uses the same Lua script as the Go version for atomicity:
//! - INCRBY key 1
//! - Set TTL on first hit
//! - Return 1 (allowed), 2 (hit quota exactly), 0 (over quota)
use std::sync::Arc;
use axum::{extract::Request, middleware::Next, response::Response};
use redis::{aio::ConnectionManager, AsyncCommands, Script};
use result::code_error::CodeError;
use result::error_code;
use result::http_result::build_http_result;
/// Result codes returned by the Lua script (mirrors Go constants).
const ALLOWED: i64 = 1;
const HIT_QUOTA: i64 = 2;
// 0 = over quota
/// Shared rate-limiter state — cheap to clone (Arc inside).
#[derive(Clone)]
pub struct PeriodLimiter {
inner: Arc<PeriodLimiterInner>,
}
struct PeriodLimiterInner {
/// Window length in seconds.
period: usize,
/// Maximum requests per window.
quota: usize,
/// Redis connection.
redis: ConnectionManager,
/// Key prefix prepended to every cache key.
key_prefix: String,
/// Lua script (same as `periodscript.lua` in the Go version).
script: Script,
}
/// Lua script — identical to `pkg/limit/periodscript.lua`.
const PERIOD_LUA: &str = r#"
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCRBY", KEYS[1], 1)
if current == 1 then
redis.call("expire", KEYS[1], window)
end
if current < limit then
return 1
elseif current == limit then
return 2
else
return 0
end
"#;
impl PeriodLimiter {
/// Create a new `PeriodLimiter`.
///
/// - `period` — window size in seconds
/// - `quota` — max requests allowed in that window
/// - `redis` — shared Redis connection manager
/// - `key_prefix` — namespace prefix (e.g. `"rate:register:"`)
pub fn new(period: usize, quota: usize, redis: ConnectionManager, key_prefix: impl Into<String>) -> Self {
Self {
inner: Arc::new(PeriodLimiterInner {
period,
quota,
redis,
key_prefix: key_prefix.into(),
script: Script::new(PERIOD_LUA),
}),
}
}
/// Check and increment the counter for `key`.
///
/// Returns `Ok(true)` if the request is within quota, `Ok(false)` if over.
pub async fn allow(&self, key: &str) -> anyhow::Result<bool> {
let full_key = format!("{}{}", self.inner.key_prefix, key);
let mut conn = self.inner.redis.clone();
let result: i64 = self.inner.script
.key(&full_key)
.arg(self.inner.quota)
.arg(self.inner.period)
.invoke_async(&mut conn)
.await?;
Ok(result == ALLOWED || result == HIT_QUOTA)
}
}
/// Axum middleware that enforces a per-IP (or per-custom-key) rate limit.
///
/// The key is extracted from the `X-Original-Forwarded-For` / `X-Forwarded-For`
/// header, falling back to the socket address.
///
/// Inject via `axum::middleware::from_fn_with_state(limiter, rate_limit_layer)`.
pub async fn rate_limit_layer(
axum::extract::State(limiter): axum::extract::State<PeriodLimiter>,
req: Request,
next: Next,
) -> Response {
let ip = req
.headers()
.get("X-Original-Forwarded-For")
.or_else(|| req.headers().get("X-Forwarded-For"))
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
match limiter.allow(&ip).await {
Ok(true) => next.run(req).await,
Ok(false) => {
let err = anyhow::Error::new(
CodeError::new_err_code(error_code::ERROR)
);
// Build a 200 body with RATE_LIMIT business code.
build_http_result::<()>(None, Some(err)).into_response()
}
Err(e) => {
tracing::error!("rate limiter redis error: {e}");
// On Redis failure, allow the request (fail open).
next.run(req).await
}
}
}
// ─── trait helper ─────────────────────────────────────────────────────────
use axum::response::IntoResponse;