mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Adds plugins/waitlist/ — a Base Go plugin that exposes a viral waitlist
under /v1/waitlist on any Base process. Replaces the standalone Next.js
+ Upstash Redis surface that lived in github.com/hanzoai/waitlist.
Surface
- POST /v1/waitlist/join register, atomically credit referrer
- GET /v1/waitlist/status look up rank + share URL
- GET /v1/waitlist/export admin-only CSV (superuser or shared secret)
Storage
- Two collections auto-created on bootstrap: waitlists, waitlist_entries
- Indexes: UNIQUE(waitlist,email), UNIQUE(waitlist,refCode),
(waitlist, referralCount DESC, createdAt ASC) for rank queries
- All mutations inside RunInTransaction — idempotent join, atomic
referral counts, collision-safe refCode allocation
Anti-abuse
- crypto/rand 8-char refCode from 36-char alphabet (no 0/O/1/l/I/etc)
- Optional Cloudflare Turnstile verify (no-op when secret unset)
- In-process sliding-window IP rate limit (default 5/IP/hour, tested)
- Disposable email domain blocklist (overridable via Config)
Registration
- waitlist.MustRegister(app, waitlist.Config{Enabled: true, ...})
- TURNSTILE_SECRET_KEY and WAITLIST_ADMIN_SECRET resolved from env
Tests
- plugin_test.go: refCode uniqueness (5000 codes), email validation,
disposable-domain check, sliding limiter (window expiry + disable),
config defaults, prefix validation
65 lines
1.4 KiB
Go
65 lines
1.4 KiB
Go
// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package waitlist
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// slidingLimiter is a tiny in-process sliding-window limiter keyed by
|
|
// arbitrary string (IP, email, etc.). It's intentionally minimal: O(N)
|
|
// in the count per key, GC on read, no background goroutine. For Base
|
|
// instances handling >100 req/s the host should put a real proxy in
|
|
// front; this limiter exists to keep a single dev / small SaaS box safe.
|
|
type slidingLimiter struct {
|
|
mu sync.Mutex
|
|
limit int
|
|
window time.Duration
|
|
hits map[string][]time.Time
|
|
now func() time.Time // injectable for tests
|
|
}
|
|
|
|
func newSlidingLimiter(limit int, window time.Duration) *slidingLimiter {
|
|
return &slidingLimiter{
|
|
limit: limit,
|
|
window: window,
|
|
hits: make(map[string][]time.Time),
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// allow returns true if the key is under the limit and records the hit.
|
|
// It also opportunistically GCs the key's slice.
|
|
func (l *slidingLimiter) allow(key string) bool {
|
|
if l == nil || l.limit <= 0 {
|
|
return true
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
now := l.now()
|
|
cutoff := now.Add(-l.window)
|
|
hits := l.hits[key]
|
|
|
|
// Drop entries older than the window.
|
|
i := 0
|
|
for ; i < len(hits); i++ {
|
|
if hits[i].After(cutoff) {
|
|
break
|
|
}
|
|
}
|
|
if i > 0 {
|
|
hits = hits[i:]
|
|
}
|
|
|
|
if len(hits) >= l.limit {
|
|
l.hits[key] = hits
|
|
return false
|
|
}
|
|
|
|
l.hits[key] = append(hits, now)
|
|
return true
|
|
}
|