feat: ratelimit, ghcr package support, media path sensitization, LID to CN migration (#6)
* feat: ratelimit, ghcr package and proper project refactor from askarzh * fix(bridge): validate media path * update: dependencies * fix: added LID to contact number migration
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// loginLimiter holds per-IP token buckets for /auth/login.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
limit rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// parseRate parses values like "5/1m", "10/30s", "100/1h".
|
||||
// Returns (rate.Limit, burst, error).
|
||||
func parseRate(spec string) (rate.Limit, int, error) {
|
||||
if spec == "" {
|
||||
return rate.Every(12 * time.Second), 5, nil // default: 5/1m
|
||||
}
|
||||
parts := strings.SplitN(spec, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE %q: want <count>/<window>", spec)
|
||||
}
|
||||
count, err := strconv.Atoi(parts[0])
|
||||
if err != nil || count <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE count in %q", spec)
|
||||
}
|
||||
window, err := time.ParseDuration(parts[1])
|
||||
if err != nil || window <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE window in %q", spec)
|
||||
}
|
||||
return rate.Every(window / time.Duration(count)), count, nil
|
||||
}
|
||||
|
||||
func newLoginLimiter(spec string) (*loginLimiter, error) {
|
||||
limit, burst, err := parseRate(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := &loginLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
limit: limit,
|
||||
burst: burst,
|
||||
}
|
||||
go l.evictLoop()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *loginLimiter) get(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
v, ok := l.visitors[ip]
|
||||
if !ok {
|
||||
v = &visitor{limiter: rate.NewLimiter(l.limit, l.burst)}
|
||||
l.visitors[ip] = v
|
||||
}
|
||||
v.lastSeen = time.Now()
|
||||
return v.limiter
|
||||
}
|
||||
|
||||
func (l *loginLimiter) evictLoop() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
l.mu.Lock()
|
||||
for ip, v := range l.visitors {
|
||||
if v.lastSeen.Before(cutoff) {
|
||||
delete(l.visitors, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfterSeconds rounds up to the next whole second the limiter expects to refill.
|
||||
func retryAfterSeconds(lim *rate.Limiter) int {
|
||||
r := lim.Reserve()
|
||||
defer r.Cancel()
|
||||
d := r.Delay()
|
||||
if d <= 0 {
|
||||
return 1
|
||||
}
|
||||
secs := int(d / time.Second)
|
||||
if d%time.Second != 0 {
|
||||
secs++
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
// clientIP extracts the bare IP (no port) from r.RemoteAddr.
|
||||
// X-Forwarded-For is intentionally NOT consulted (see spec section 2,
|
||||
// "Known limitation"). A future PR adds trusted-proxy parsing.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in New Issue
Block a user