mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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
121 lines
3.1 KiB
Go
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)
|
|
})
|
|
}
|