Files
ONE/backend/internal/ratelimit/ratelimit.go
T
Sakurasan 6108aca34c 缩略图懒生成 + 评论登录限流:时间线不再拉原图
- 新增 /uploads/thumb/{key}?w=:首访从存储端读一次原图,标准库盒均值缩放后
  落盘 data/.thumbnail_cache(按内容哈希命名,天然失效);失败写 .failed 冷却
  一小时,非图片/超大一律 302 回原图,前端无感。原图路由改用 ServeContent,
  补上视频拖动进度条所需的 Range 支持
- 前端 thumbURL/thumbifyHtml 接入两套 UI 的时间线配图、长文封面与短文正文,
  并补 loading=lazy;详情页与灯箱仍用原图。/api/site 改为
  {settings, uploads_public_base},让前端识别哪些直链属于自家存储
- 后台登录的滑动窗口限速器抽成 internal/ratelimit 共享包(调用面不变),
  新增:读者登录失败按 IP 20 次/10 分钟、评论写入按读者 5 条/分钟
  (站主豁免,且只计成功写入)
2026-09-28 13:34:19 +08:00

74 lines
1.6 KiB
Go

// Package ratelimit 是一个极小的按来源 IP 滑动窗口限速器,
// 后台登录与读者登录 / 评论写入共用同一实现。
// 只计失败/写入这类「不该高频」的事件;键取连接层地址,
// X-Forwarded-For 可伪造,不作为限速键。
package ratelimit
import (
"net"
"net/http"
"sync"
"time"
)
type Window struct {
mu sync.Mutex
hits map[string][]time.Time
max int
window time.Duration
}
func New(max int, window time.Duration) *Window {
return &Window{hits: make(map[string][]time.Time), max: max, window: window}
}
// Blocked 报告 key 在窗口内是否已达上限。
func (l *Window) Blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.recent(key, time.Now())) >= l.max
}
// Add 记录一次事件。
func (l *Window) Add(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.hits[key] = append(l.recent(key, now), now)
}
// Reset 清空 key 的计数(如登录成功后)。
func (l *Window) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
// recent 返回窗口内的事件时间;调用方必须持有 l.mu。
func (l *Window) recent(key string, now time.Time) []time.Time {
hs := l.hits[key]
cut := now.Add(-l.window)
i := 0
for ; i < len(hs); i++ {
if hs[i].After(cut) {
break
}
}
if i > 0 {
hs = hs[i:]
l.hits[key] = hs
}
if len(hs) == 0 {
delete(l.hits, key)
}
return hs
}
// SourceKey 取连接层来源地址(IPv6 去掉端口)。
func SourceKey(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}