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
78 lines
1.8 KiB
Go
78 lines
1.8 KiB
Go
// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package waitlist
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const turnstileEndpoint = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
|
|
type turnstileVerifyResp struct {
|
|
Success bool `json:"success"`
|
|
ErrorCodes []string `json:"error-codes,omitempty"`
|
|
}
|
|
|
|
// turnstileVerifier verifies a Cloudflare Turnstile token. If secret is
|
|
// empty, verify is a no-op (used in dev).
|
|
type turnstileVerifier struct {
|
|
secret string
|
|
client *http.Client
|
|
url string // injectable for tests
|
|
}
|
|
|
|
func newTurnstileVerifier(secret string) *turnstileVerifier {
|
|
return &turnstileVerifier{
|
|
secret: strings.TrimSpace(secret),
|
|
client: &http.Client{Timeout: 5 * time.Second},
|
|
url: turnstileEndpoint,
|
|
}
|
|
}
|
|
|
|
// verify returns nil on success, a non-nil error otherwise. Empty secret
|
|
// is treated as "verification disabled" and always succeeds.
|
|
func (v *turnstileVerifier) verify(ctx context.Context, token, remoteIP string) error {
|
|
if v == nil || v.secret == "" {
|
|
return nil
|
|
}
|
|
if token == "" {
|
|
return errors.New("MISSING_TOKEN")
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"secret": v.secret,
|
|
"response": token,
|
|
"remoteip": remoteIP,
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, v.url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := v.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var out turnstileVerifyResp
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return err
|
|
}
|
|
if !out.Success {
|
|
if len(out.ErrorCodes) > 0 {
|
|
return errors.New(strings.Join(out.ErrorCodes, ","))
|
|
}
|
|
return errors.New("VERIFICATION_FAILED")
|
|
}
|
|
return nil
|
|
}
|