release gate / check (push) Failing after 6m43s
High severity: - wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup - bucket public rate limits on effective client IP, add per-bookmark budget, and forward the signed browser identity from the share page - disable first-run bootstrap in server deployments and document seeding - serialize last-admin demotion in one advisory-locked transaction Medium/low: - cap and batch share recipient inserts - add a shared outbound budget for preview/CardDAV/IMAP/signal work - pin CardDAV dials to 443 and bound IMAP logout - fix rightward kanban column drags and normalize cards on column delete - classify backend outages in auth guards, add request timeouts and an error boundary, remove the dead login route, surface throttling - enforce pinned/unpinned page reorder grouping server-side - avoid preview work for stale bookmark versions; drop duplicate dialog fetch - sequence-guard collection/tag/calendar/quick-find loads - validate property config and fix MULTI_SELECT/DATE filter inputs - document index maintenance; apply same-origin check to sign-out - trim signup names, unique shared dates and kanban keys (migration 010), reset reference cache on identity changes, hide OIDC issuer, return 413 for oversized direct bodies, escape Markdown link destinations - remove dead template files and duplicate test helper Verified with go build/vet, full PostgreSQL race suite, db convergence, tsc, both ESLint configs and a Next production build.
76 lines
1.9 KiB
Go
76 lines
1.9 KiB
Go
// Package outbound bounds server-initiated network work — URL previews,
|
|
// CardDAV syncs, IMAP test/check passes and manual signal checks — so an
|
|
// authenticated user cannot open unbounded outbound connections, exhaust
|
|
// file descriptors or saturate egress (review M-2).
|
|
//
|
|
// Two layers are shared by every caller:
|
|
// - a global in-flight cap (defaultConcurrent), and
|
|
// - a per-identity fixed-window rate (the caller supplies the limit/window,
|
|
// since a preview is cheaper than an IMAP search).
|
|
//
|
|
// Exhaustion is immediate: previews degrade to no metadata and interactive
|
|
// endpoints answer 429 with Retry-After. Nothing queues behind the budget.
|
|
package outbound
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const defaultConcurrent = 8
|
|
|
|
type window struct {
|
|
count int
|
|
reset time.Time
|
|
}
|
|
|
|
type budget struct {
|
|
mu sync.Mutex
|
|
inFlight int
|
|
max int
|
|
keys map[string]*window
|
|
}
|
|
|
|
var shared = &budget{max: defaultConcurrent, keys: map[string]*window{}}
|
|
|
|
// Reserve claims one outbound slot for key. The returned release MUST be
|
|
// called when the work finishes. When ok is false, retry is a conservative
|
|
// Retry-After duration.
|
|
func Reserve(key string, limit int, windowDur time.Duration) (release func(), retry time.Duration, ok bool) {
|
|
if key == "" {
|
|
key = "anonymous"
|
|
}
|
|
shared.mu.Lock()
|
|
defer shared.mu.Unlock()
|
|
if shared.inFlight >= shared.max {
|
|
return nil, time.Second, false
|
|
}
|
|
now := time.Now()
|
|
if len(shared.keys) > 4096 {
|
|
for k, w := range shared.keys {
|
|
if now.After(w.reset) {
|
|
delete(shared.keys, k)
|
|
}
|
|
}
|
|
}
|
|
w := shared.keys[key]
|
|
if w == nil || now.After(w.reset) {
|
|
w = &window{reset: now.Add(windowDur)}
|
|
shared.keys[key] = w
|
|
}
|
|
if limit > 0 && w.count >= limit {
|
|
left := w.reset.Sub(now)
|
|
if left < 0 {
|
|
left = 0
|
|
}
|
|
return nil, left.Truncate(time.Second) + time.Second, false
|
|
}
|
|
w.count++
|
|
shared.inFlight++
|
|
return func() {
|
|
shared.mu.Lock()
|
|
shared.inFlight--
|
|
shared.mu.Unlock()
|
|
}, 0, true
|
|
}
|