Files
beardrive/internal/webapp/ratelimit.go
T
Snow LeeandClaude Fable 5 baad9e6dc2 feat(hub): signup gating, admin lifecycle, and onboarding UX
Makes a self-hosted hub safe to expose on a public URL and operable
without hand-editing JSON — addressing the blocker/major findings from
the persona usability evaluations.

Signup gating (config auth block, all optional):
- allowed_domains: signup email must match (e.g. only @runbear.io)
- require_verification: email-link activation before sign-in (reuses mailer)
- require_approval: hub admins approve new accounts (admins list)
- brand shown on the sign-in page; allow_signup:false already hid Sign up
Accounts carry a Status (active/unverified/pending); non-active accounts
cannot authenticate.

Admin lifecycle (endpoints + web UI):
- org: rename, member role change, member remove (last-owner guarded),
  invite list + revoke
- project: create (web), rename, delete (from the org panel)
- hub admins: approve/deny pending signups (sidebar bell + panel)
- org-wide public-share audit with revoke

UX: onboarding empty-state (explains invites, paste-invite + create-project)
instead of a blank sidebar; visible "Search ⌘K" button; toasts replace
blocking alert(); responsive layout with an off-canvas sidebar; joining
via #join now survives a logged-out click (token carried through login).
Web uploads are attributed to the signed-in account, not the server.
Login/signup are rate-limited per IP.

Tests: domain/verification/approval gates, auth rate limit, org+project
lifecycle, owner-only guards, invite→join→role→remove over HTTP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 22:23:27 -07:00

121 lines
3.1 KiB
Go

package webapp
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// Share links are the one unauthenticated surface of a hub, so /s/* gets a
// per-IP token bucket — generous enough that no human reader ever sees it,
// tight enough that a scraper can't turn the server into a free CDN.
// DefaultShareRPM is the per-IP sustained rate on /s/* when the config
// doesn't say otherwise.
const DefaultShareRPM = 120
type rateLimiter struct {
rate float64 // tokens per second
burst float64
now func() time.Time // injectable for tests
mu sync.Mutex
buckets map[string]*tokenBucket
}
type tokenBucket struct {
tokens float64
last time.Time
}
// newRateLimiter allows rpm sustained requests per key with a burst of
// rpm/4 (min 10) on top.
func newRateLimiter(rpm int) *rateLimiter {
if rpm <= 0 {
rpm = DefaultShareRPM
}
return &rateLimiter{
rate: float64(rpm) / 60,
burst: max(float64(rpm)/4, 10),
now: time.Now,
buckets: make(map[string]*tokenBucket),
}
}
func (l *rateLimiter) allow(key string) bool {
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
// Keep the map bounded: when it grows past 10k keys, drop buckets idle
// long enough to be full again anyway.
if len(l.buckets) > 10000 {
idle := time.Duration(l.burst/l.rate) * time.Second
for k, b := range l.buckets {
if now.Sub(b.last) > idle {
delete(l.buckets, k)
}
}
}
b, ok := l.buckets[key]
if !ok {
b = &tokenBucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
b.tokens = min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.rate)
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// clientIP is the rate-limit key: the first X-Forwarded-For hop when a
// proxy fronts the server, else the connection's address.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, ok := strings.Cut(xff, ","); ok || first != "" {
return strings.TrimSpace(first)
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// shareLimiter lazily builds the /s/* limiter from ShareRPM.
func (s *Server) shareLimiter() *rateLimiter {
s.shareLimOnce.Do(func() {
s.shareLim = newRateLimiter(s.ShareRPM)
})
return s.shareLim
}
// authLimiter throttles credential endpoints (login, signup) per IP to blunt
// password brute-force and signup floods. Deliberately tight (10/min).
func (s *Server) authLimiter() *rateLimiter {
s.authLimOnce.Do(func() {
s.authLim = newRateLimiter(10)
})
return s.authLim
}
// rateLimitAuth wraps the auth mux so POSTs to /auth/login and /auth/signup
// are throttled per IP; GETs (rendering the forms) pass freely.
func (s *Server) rateLimitAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if r.Method == http.MethodPost && (p == "/auth/login" || p == "/auth/signup") {
if !s.authLimiter().allow(clientIP(r)) {
http.Error(w, "too many attempts — wait a minute and try again", http.StatusTooManyRequests)
return
}
}
next.ServeHTTP(w, r)
})
}