修复:调整游客取号限流策略
问题:移动网络共享出口 IP 会造成游客取号被误限流。 实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
This commit is contained in:
@@ -16,3 +16,6 @@ DB_CONN_MAX_IDLE_TIME=5m
|
||||
DB_CONN_MAX_LIFETIME=30m
|
||||
MAINTENANCE_INTERVAL=1h
|
||||
MAINTENANCE_BATCH_SIZE=500
|
||||
PUBLIC_TICKET_PROJECT_LIMIT=1000
|
||||
PUBLIC_TICKET_PHONE_LIMIT=5
|
||||
PUBLIC_TICKET_RATE_WINDOW=1m
|
||||
|
||||
@@ -51,7 +51,11 @@ self-service, including each project's allowed party-size range.
|
||||
`POST /api/public/projects/{id}/tickets` uses the same ticket
|
||||
validation, queue locking and idempotency rules as the staff ticket flow, but
|
||||
returns only the public ticket projection and a private status token. Public
|
||||
ticket creation is rate-limited and audited as `PUBLIC_TICKET_CREATED`.
|
||||
ticket creation is rate-limited per project and per normalized phone HMAC, and
|
||||
audited as `PUBLIC_TICKET_CREATED`. The default limits are 1000 project requests
|
||||
and 5 requests for the same project/phone in one minute; configure them with
|
||||
`PUBLIC_TICKET_PROJECT_LIMIT`, `PUBLIC_TICKET_PHONE_LIMIT` and
|
||||
`PUBLIC_TICKET_RATE_WINDOW`. Ticket creation does not reject by client IP.
|
||||
|
||||
`POST /api/public/status/search` is a temporary non-production operational-test
|
||||
endpoint. It accepts `{ "phone": "..." }` and returns all current active
|
||||
|
||||
@@ -12,22 +12,25 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
EncryptionKey []byte
|
||||
PhoneHMACKey []byte
|
||||
SessionCookieName string
|
||||
SessionSecure bool
|
||||
SessionTTL time.Duration
|
||||
MigrateOnStart bool
|
||||
ShutdownTimeout time.Duration
|
||||
DBMaxOpenConns int
|
||||
DBMaxIdleConns int
|
||||
DBConnMaxIdleTime time.Duration
|
||||
DBConnMaxLifetime time.Duration
|
||||
MaintenanceInterval time.Duration
|
||||
MaintenanceBatchSize int
|
||||
Environment string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
EncryptionKey []byte
|
||||
PhoneHMACKey []byte
|
||||
SessionCookieName string
|
||||
SessionSecure bool
|
||||
SessionTTL time.Duration
|
||||
MigrateOnStart bool
|
||||
ShutdownTimeout time.Duration
|
||||
DBMaxOpenConns int
|
||||
DBMaxIdleConns int
|
||||
DBConnMaxIdleTime time.Duration
|
||||
DBConnMaxLifetime time.Duration
|
||||
MaintenanceInterval time.Duration
|
||||
MaintenanceBatchSize int
|
||||
PublicTicketProjectLimit int
|
||||
PublicTicketPhoneLimit int
|
||||
PublicTicketRateWindow time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -100,6 +103,18 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
publicTicketProjectLimit, err := parseIntSetting("PUBLIC_TICKET_PROJECT_LIMIT", get("PUBLIC_TICKET_PROJECT_LIMIT", "1000"), 1, 1000000)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
publicTicketPhoneLimit, err := parseIntSetting("PUBLIC_TICKET_PHONE_LIMIT", get("PUBLIC_TICKET_PHONE_LIMIT", "5"), 1, 10000)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
publicTicketRateWindow, err := parseDurationSetting("PUBLIC_TICKET_RATE_WINDOW", get("PUBLIC_TICKET_RATE_WINDOW", "1m"), time.Second, time.Hour)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if environment == "production" && !sessionSecure {
|
||||
return Config{}, errors.New("SESSION_COOKIE_SECURE must be true in production")
|
||||
}
|
||||
@@ -119,22 +134,25 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
}
|
||||
|
||||
return Config{
|
||||
Environment: environment,
|
||||
HTTPAddr: get("HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: databaseURL,
|
||||
EncryptionKey: encryptionKey,
|
||||
PhoneHMACKey: phoneHMACKey,
|
||||
SessionCookieName: cookieName,
|
||||
SessionSecure: sessionSecure,
|
||||
SessionTTL: sessionTTL,
|
||||
MigrateOnStart: migrate,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
DBMaxOpenConns: maxOpen,
|
||||
DBMaxIdleConns: maxIdle,
|
||||
DBConnMaxIdleTime: connMaxIdleTime,
|
||||
DBConnMaxLifetime: connMaxLifetime,
|
||||
MaintenanceInterval: maintenanceInterval,
|
||||
MaintenanceBatchSize: maintenanceBatchSize,
|
||||
Environment: environment,
|
||||
HTTPAddr: get("HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: databaseURL,
|
||||
EncryptionKey: encryptionKey,
|
||||
PhoneHMACKey: phoneHMACKey,
|
||||
SessionCookieName: cookieName,
|
||||
SessionSecure: sessionSecure,
|
||||
SessionTTL: sessionTTL,
|
||||
MigrateOnStart: migrate,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
DBMaxOpenConns: maxOpen,
|
||||
DBMaxIdleConns: maxIdle,
|
||||
DBConnMaxIdleTime: connMaxIdleTime,
|
||||
DBConnMaxLifetime: connMaxLifetime,
|
||||
MaintenanceInterval: maintenanceInterval,
|
||||
MaintenanceBatchSize: maintenanceBatchSize,
|
||||
PublicTicketProjectLimit: publicTicketProjectLimit,
|
||||
PublicTicketPhoneLimit: publicTicketPhoneLimit,
|
||||
PublicTicketRateWindow: publicTicketRateWindow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadAcceptsExactly32ByteKeys(t *testing.T) {
|
||||
@@ -74,3 +75,66 @@ func TestLoadRequiresTLSAndReleaseMigrationsInProduction(t *testing.T) {
|
||||
t.Fatalf("expected production migration mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfiguresPublicTicketRateLimits(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", 32)))
|
||||
values := map[string]string{
|
||||
"DATABASE_URL": "postgres://localhost/test",
|
||||
"DATA_ENCRYPTION_KEY_BASE64": key,
|
||||
"PHONE_HMAC_KEY_BASE64": key,
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
}
|
||||
lookup := func(name string) (string, bool) { value, ok := values[name]; return value, ok }
|
||||
|
||||
cfg, err := load(lookup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.PublicTicketProjectLimit != 1000 || cfg.PublicTicketPhoneLimit != 5 || cfg.PublicTicketRateWindow != time.Minute {
|
||||
t.Fatalf("unexpected public ticket limits: project=%d phone=%d window=%s",
|
||||
cfg.PublicTicketProjectLimit, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow)
|
||||
}
|
||||
|
||||
values["PUBLIC_TICKET_PROJECT_LIMIT"] = "1200"
|
||||
values["PUBLIC_TICKET_PHONE_LIMIT"] = "8"
|
||||
values["PUBLIC_TICKET_RATE_WINDOW"] = "2m"
|
||||
cfg, err = load(lookup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.PublicTicketProjectLimit != 1200 || cfg.PublicTicketPhoneLimit != 8 || cfg.PublicTicketRateWindow != 2*time.Minute {
|
||||
t.Fatalf("configured public ticket limits not applied: project=%d phone=%d window=%s",
|
||||
cfg.PublicTicketProjectLimit, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidPublicTicketRateLimits(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", 32)))
|
||||
base := map[string]string{
|
||||
"DATABASE_URL": "postgres://localhost/test",
|
||||
"DATA_ENCRYPTION_KEY_BASE64": key,
|
||||
"PHONE_HMAC_KEY_BASE64": key,
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{name: "project limit", key: "PUBLIC_TICKET_PROJECT_LIMIT", value: "0"},
|
||||
{name: "phone limit", key: "PUBLIC_TICKET_PHONE_LIMIT", value: "0"},
|
||||
{name: "window", key: "PUBLIC_TICKET_RATE_WINDOW", value: "0s"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values := make(map[string]string, len(base)+1)
|
||||
for name, value := range base {
|
||||
values[name] = value
|
||||
}
|
||||
values[test.key] = test.value
|
||||
if _, err := load(func(name string) (string, bool) { value, ok := values[name]; return value, ok }); err == nil {
|
||||
t.Fatalf("%s=%q should be rejected", test.key, test.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,21 @@ type queryLimitEntry struct {
|
||||
count int
|
||||
}
|
||||
|
||||
// queryLimiter is deliberately small and in-memory for the temporary public
|
||||
// phone lookup. The production replacement will be an OTP or external
|
||||
// identity provider, so this limiter is only a safety net for the test flow.
|
||||
// queryLimiter is a per-process fixed-window safety limit. Expired entries are
|
||||
// removed opportunistically so unique keys do not accumulate indefinitely.
|
||||
type queryLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]queryLimitEntry
|
||||
now func() time.Time
|
||||
limit int
|
||||
window time.Duration
|
||||
mu sync.Mutex
|
||||
entries map[string]queryLimitEntry
|
||||
now func() time.Time
|
||||
limit int
|
||||
window time.Duration
|
||||
nextCleanup time.Time
|
||||
}
|
||||
|
||||
func newQueryLimiter(now func() time.Time, limit int, window time.Duration) *queryLimiter {
|
||||
if limit <= 0 || window <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &queryLimiter{
|
||||
entries: make(map[string]queryLimitEntry), now: now, limit: limit, window: window,
|
||||
}
|
||||
@@ -49,6 +52,14 @@ func (l *queryLimiter) allow(key string) (bool, time.Duration) {
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := l.now()
|
||||
if l.nextCleanup.IsZero() || !now.Before(l.nextCleanup) {
|
||||
for entryKey, existing := range l.entries {
|
||||
if now.Sub(existing.windowStart) >= l.window {
|
||||
delete(l.entries, entryKey)
|
||||
}
|
||||
}
|
||||
l.nextCleanup = now.Add(l.window)
|
||||
}
|
||||
entry, ok := l.entries[key]
|
||||
if !ok || now.Sub(entry.windowStart) >= l.window {
|
||||
entry = queryLimitEntry{windowStart: now}
|
||||
|
||||
@@ -25,6 +25,23 @@ func TestQueryLimiterResetsAfterWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryLimiterRemovesExpiredEntries(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
limiter := newQueryLimiter(func() time.Time { return now }, 2, time.Minute)
|
||||
limiter.allow("project:old-a")
|
||||
limiter.allow("project:old-b")
|
||||
|
||||
now = now.Add(2 * time.Minute)
|
||||
limiter.allow("project:current")
|
||||
|
||||
if len(limiter.entries) != 1 {
|
||||
t.Fatalf("entries=%d, want only the current window entry", len(limiter.entries))
|
||||
}
|
||||
if _, ok := limiter.entries["project:current"]; !ok {
|
||||
t.Fatal("current limiter entry was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterBlocksAfterTenFailures(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
limiter := newLoginLimiter(func() time.Time { return now })
|
||||
|
||||
@@ -60,12 +60,6 @@ func (s *Server) publicCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if s.publicTicketLimiter != nil {
|
||||
if allowed, retry := s.publicTicketLimiter.allow("ip:" + publicQueryClientKey(r)); !allowed {
|
||||
writePublicTicketRateLimit(w, retry)
|
||||
return
|
||||
}
|
||||
}
|
||||
var actor model.User
|
||||
if err := s.db.WithContext(r.Context()).Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil {
|
||||
writeError(w, err)
|
||||
@@ -74,6 +68,21 @@ func (s *Server) publicCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
s.createTicketForActor(w, r, actor.ID, true)
|
||||
}
|
||||
|
||||
func publicTicketProjectLimitKey(projectID string) string {
|
||||
return "project:" + projectID
|
||||
}
|
||||
|
||||
func publicTicketPhoneLimitKey(projectID, phoneDigest string) string {
|
||||
return "project:" + projectID + ":phone:" + phoneDigest
|
||||
}
|
||||
|
||||
func (s *Server) allowPublicTicket(projectID, phoneDigest string) (bool, time.Duration) {
|
||||
if allowed, retry := s.publicTicketProjectLimiter.allow(publicTicketProjectLimitKey(projectID)); !allowed {
|
||||
return false, retry
|
||||
}
|
||||
return s.publicTicketPhoneLimiter.allow(publicTicketPhoneLimitKey(projectID, phoneDigest))
|
||||
}
|
||||
|
||||
type publicPhoneQueryRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -154,6 +155,43 @@ func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) {
|
||||
if afterCallRecorder.Code != http.StatusCreated {
|
||||
t.Fatalf("after-call status = %d, want 201; body = %s", afterCallRecorder.Code, afterCallRecorder.Body.String())
|
||||
}
|
||||
|
||||
server.publicTicketProjectLimiter = newQueryLimiter(server.now, 10, time.Minute)
|
||||
server.publicTicketPhoneLimiter = newQueryLimiter(server.now, 1, time.Minute)
|
||||
var replayBody, replayKey string
|
||||
for index := 0; index < 6; index++ {
|
||||
body := fmt.Sprintf(`{"phone":"1390000%04d","party_size":1}`, index)
|
||||
key := "same-mobile-ip-" + uuid.NewString()
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(body))
|
||||
request.RemoteAddr = "198.51.100.27:42000"
|
||||
request.Header.Set("Idempotency-Key", key)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("same-IP distinct phone %d status = %d, want 201; body = %s", index, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if index == 0 {
|
||||
replayBody, replayKey = body, key
|
||||
}
|
||||
}
|
||||
|
||||
replayRecorder := httptest.NewRecorder()
|
||||
replayRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody))
|
||||
replayRequest.RemoteAddr = "198.51.100.27:42000"
|
||||
replayRequest.Header.Set("Idempotency-Key", replayKey)
|
||||
server.Handler().ServeHTTP(replayRecorder, replayRequest)
|
||||
if replayRecorder.Code != http.StatusCreated {
|
||||
t.Fatalf("idempotent replay status = %d, want 201; body = %s", replayRecorder.Code, replayRecorder.Body.String())
|
||||
}
|
||||
|
||||
limitedRecorder := httptest.NewRecorder()
|
||||
limitedRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody))
|
||||
limitedRequest.RemoteAddr = "203.0.113.44:42000"
|
||||
limitedRequest.Header.Set("Idempotency-Key", "new-intent-"+uuid.NewString())
|
||||
server.Handler().ServeHTTP(limitedRecorder, limitedRequest)
|
||||
if limitedRecorder.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("same project/phone new intent status = %d, want 429; body = %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(t *testing.T) {
|
||||
|
||||
@@ -101,3 +101,40 @@ func TestPublicPhoneLookupRejectsInvalidPhone(t *testing.T) {
|
||||
t.Fatalf("invalid phone lookup status = %d, want 422", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicTicketLimitsUseProjectAndPhoneDigest(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
server := &Server{
|
||||
publicTicketProjectLimiter: newQueryLimiter(func() time.Time { return now }, 10, time.Minute),
|
||||
publicTicketPhoneLimiter: newQueryLimiter(func() time.Time { return now }, 1, time.Minute),
|
||||
}
|
||||
projectA := "11111111-1111-4111-8111-111111111111"
|
||||
projectB := "22222222-2222-4222-8222-222222222222"
|
||||
phoneDigest := strings.Repeat("a", 64)
|
||||
|
||||
if allowed, _ := server.allowPublicTicket(projectA, phoneDigest); !allowed {
|
||||
t.Fatal("first project/phone request should be allowed")
|
||||
}
|
||||
if allowed, retry := server.allowPublicTicket(projectA, phoneDigest); allowed || retry <= 0 {
|
||||
t.Fatalf("second request should be phone-limited, got allowed=%v retry=%s", allowed, retry)
|
||||
}
|
||||
if allowed, _ := server.allowPublicTicket(projectB, phoneDigest); !allowed {
|
||||
t.Fatal("the same phone digest should have an independent limit in another project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicTicketLimitsEnforceProjectTotalAcrossPhones(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
server := &Server{
|
||||
publicTicketProjectLimiter: newQueryLimiter(func() time.Time { return now }, 1, time.Minute),
|
||||
publicTicketPhoneLimiter: newQueryLimiter(func() time.Time { return now }, 10, time.Minute),
|
||||
}
|
||||
projectID := "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
if allowed, _ := server.allowPublicTicket(projectID, strings.Repeat("a", 64)); !allowed {
|
||||
t.Fatal("first project request should be allowed")
|
||||
}
|
||||
if allowed, retry := server.allowPublicTicket(projectID, strings.Repeat("b", 64)); allowed || retry <= 0 {
|
||||
t.Fatalf("second phone should be limited by project total, got allowed=%v retry=%s", allowed, retry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,17 @@ const (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
db *gorm.DB
|
||||
config config.Config
|
||||
cipher *security.Cipher
|
||||
logger *slog.Logger
|
||||
hub eventPublisher
|
||||
loginLimiter *loginLimiter
|
||||
publicQueryLimiter *queryLimiter
|
||||
publicTicketLimiter *queryLimiter
|
||||
dummyPassword string
|
||||
now func() time.Time
|
||||
db *gorm.DB
|
||||
config config.Config
|
||||
cipher *security.Cipher
|
||||
logger *slog.Logger
|
||||
hub eventPublisher
|
||||
loginLimiter *loginLimiter
|
||||
publicQueryLimiter *queryLimiter
|
||||
publicTicketProjectLimiter *queryLimiter
|
||||
publicTicketPhoneLimiter *queryLimiter
|
||||
dummyPassword string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, cfg config.Config, logger *slog.Logger) (*Server, error) {
|
||||
@@ -57,16 +58,17 @@ func NewWithEventPublisher(db *gorm.DB, cfg config.Config, logger *slog.Logger,
|
||||
}
|
||||
now := func() time.Time { return time.Now().UTC() }
|
||||
return &Server{
|
||||
db: db,
|
||||
config: cfg,
|
||||
cipher: fieldCipher,
|
||||
logger: logger,
|
||||
hub: publisher,
|
||||
loginLimiter: newLoginLimiter(now),
|
||||
publicQueryLimiter: newQueryLimiter(now, 120, time.Minute),
|
||||
publicTicketLimiter: newQueryLimiter(now, 20, time.Minute),
|
||||
dummyPassword: dummy,
|
||||
now: now,
|
||||
db: db,
|
||||
config: cfg,
|
||||
cipher: fieldCipher,
|
||||
logger: logger,
|
||||
hub: publisher,
|
||||
loginLimiter: newLoginLimiter(now),
|
||||
publicQueryLimiter: newQueryLimiter(now, 120, time.Minute),
|
||||
publicTicketProjectLimiter: newQueryLimiter(now, cfg.PublicTicketProjectLimit, cfg.PublicTicketRateWindow),
|
||||
publicTicketPhoneLimiter: newQueryLimiter(now, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow),
|
||||
dummyPassword: dummy,
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,20 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
|
||||
auditAction = "PUBLIC_TICKET_CREATED"
|
||||
duplicateMessage = "该手机号已有活动号码,请确认后继续"
|
||||
}
|
||||
phoneDigest := s.cipher.Digest(phone)
|
||||
if publicView && (s.publicTicketProjectLimiter != nil || s.publicTicketPhoneLimiter != nil) {
|
||||
if stored, code, found, err := loadIdempotent(s.db.WithContext(r.Context()), projectID, actorID, scope, key, hash, s.now()); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
} else if found {
|
||||
writeRawJSON(w, code, stored)
|
||||
return
|
||||
}
|
||||
if allowed, retry := s.allowPublicTicket(projectID, phoneDigest); !allowed {
|
||||
writePublicTicketRateLimit(w, retry)
|
||||
return
|
||||
}
|
||||
}
|
||||
var responseBody []byte
|
||||
responseCode := http.StatusCreated
|
||||
var revision int64
|
||||
@@ -293,7 +307,6 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
|
||||
return nil
|
||||
}
|
||||
|
||||
phoneDigest := s.cipher.Digest(phone)
|
||||
var duplicates []model.QueueTicket
|
||||
if err := tx.Select("id", "display_number", "status").
|
||||
Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status = ?",
|
||||
|
||||
Reference in New Issue
Block a user