Files
tamabox/internal/route/ratelimit.go
T
2026-09-08 10:10:44 +08:00

184 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package route
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/route/vote"
)
// rateLimiter provides a simple in-memory rate limiter using a sliding window.
type rateLimiter struct {
mu sync.Mutex
visitors map[string][]time.Time
limit int
window time.Duration
}
// ---------- 全局清理 ----------
//
// 所有 rateLimiter 实例注册到全局注册表,由单个常驻 goroutine 统一清理,
// 避免每个中间件实例各自启动一个永久 goroutine(旧实现会随路由数量线性泄漏 goroutine)。
var (
rateLimitersMu sync.Mutex
rateLimiters = make(map[*rateLimiter]struct{})
rateLimiterClean sync.Once
)
func registerRateLimiter(rl *rateLimiter) {
rateLimitersMu.Lock()
rateLimiters[rl] = struct{}{}
rateLimitersMu.Unlock()
// 仅启动一次全局清理循环
rateLimiterClean.Do(func() {
go func() {
for {
time.Sleep(5 * time.Minute)
rateLimitersMu.Lock()
for rl := range rateLimiters {
rl.cleanup()
}
rateLimitersMu.Unlock()
}
}()
})
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
visitors: make(map[string][]time.Time),
limit: limit,
window: window,
}
registerRateLimiter(rl)
return rl
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
// 恶意伪造 X-Forwarded-For 可撑爆 visitors map,超过阈值时快速清理
// 窗口内无活动的 key(其最新时间戳已过期)
if len(rl.visitors) > 10000 {
cutoff2 := time.Now().Add(-rl.window)
for ip, ts := range rl.visitors {
if len(ts) == 0 || ts[len(ts)-1].Before(cutoff2) {
delete(rl.visitors, ip)
}
}
}
now := time.Now()
cutoff := now.Add(-rl.window)
times := rl.visitors[ip]
// Remove expired entries.
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= rl.limit {
rl.visitors[ip] = valid
return false
}
valid = append(valid, now)
rl.visitors[ip] = valid
return true
}
func (rl *rateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
for ip, times := range rl.visitors {
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(rl.visitors, ip)
} else {
rl.visitors[ip] = valid
}
}
}
// rateLimitMiddleware returns a Flamego handler that applies rate limiting.
// limit: max requests per window; window: time window duration.
//
// 匿名 / 登录分级限流:登录用户(session 中存在 uid)使用 3 倍额度,
// 未登录访客使用基础额度。分级使用独立限流器统计,互不污染。
func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
anonymousRL := newRateLimiter(limit, window)
loggedRL := newRateLimiter(limit*3, window)
return func(ctx flamego.Context, s session.Session) {
// 统一通过 shared.ResolveClientIP 解析,trust_proxy=false 时忽略伪造的代理头。
// 再经 shared.LimitKey 归一化:IPv4 原样,IPv6 聚合到 /64,
// 防止在单个 /64 内轮换海量地址绕过阈值(详见 LimitKey 注释)。
ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))
rl := anonymousRL
if _, ok := s.Get("uid").(uint); ok {
rl = loggedRL
}
if !rl.allow(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
return
}
}
}
// voteSubmitRateLimitMiddleware 对投票提交同时执行两层限流:
// 匿名身份层按 token 计数,IP 层使用更高阈值作为批量换 token 的风控兜底。
// 这样共享公网出口的多个匿名浏览器不会共享低阈值,同时仍能限制异常突发流量。
func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
anonymousTokenRL := newRateLimiter(limit, window)
loggedUserRL := newRateLimiter(limit*3, window)
anonymousIPRL := newRateLimiter(limit*10, window)
tooMany := func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
}
return func(ctx flamego.Context, s session.Session) {
r := ctx.Request().Request
if uid, ok := s.Get("uid").(uint); ok && uid > 0 {
if !loggedUserRL.allow(fmt.Sprintf("user:%d", uid)) {
tooMany(ctx)
}
return
}
// 匿名请求始终经过高阈值 IP 兜底;共享出口下每个有效 token 仍独立计数。
// IP 键同样经 LimitKey 聚合,避免 IPv6 轮换绕过兜底阈值。
if !anonymousIPRL.allow("ip:" + shared.LimitKey(shared.ResolveClientIP(r))) {
tooMany(ctx)
return
}
key := vote.VoterTokenRateLimitKey(r)
if key != "" && !anonymousTokenRL.allow("token:"+key) {
tooMany(ctx)
}
}
}