mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 14:44:04 +08:00
emails_remaining was clamped to what the email bucket holds right now, so a fresh Supporter saw 10 of 50. It is the daily limit minus today's emails again, as in v2.34.0 and like messages_remaining; the bucket still refuses a burst past it. Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
504 lines
16 KiB
Go
504 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"net/netip"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"heckel.io/ntfy/v2/limit"
|
|
"heckel.io/ntfy/v2/log"
|
|
"heckel.io/ntfy/v2/message"
|
|
"heckel.io/ntfy/v2/user"
|
|
"heckel.io/ntfy/v2/util"
|
|
vis "heckel.io/ntfy/v2/visitor" // Aliased: the server has its own visitor type
|
|
)
|
|
|
|
const (
|
|
// oneDay is an approximation of a day as a time.Duration
|
|
oneDay = 24 * time.Hour
|
|
|
|
// visitorExpungeAfter is how long a limiter key may go unused before its buckets and counts are
|
|
// forgotten (see Server.pruneVisitors), and how long a topic keeps a rate visitor whose subscriber
|
|
// is gone. This number has to be very high to prevent e-mail abuse, but it doesn't really affect
|
|
// the other limits anyway, since they are replenished faster (typically).
|
|
visitorExpungeAfter = oneDay
|
|
)
|
|
|
|
// visitor represents one request's API user: its identity (the IP, or the user) and the limits that
|
|
// apply to it. It is built per request and never changed afterwards, so concurrent requests from the
|
|
// same IP cannot see each other's user (#1051). The counting happens in the limit service, keyed by
|
|
// key, so two visitors with the same key share one set of buckets and counts.
|
|
type visitor struct {
|
|
env *visitorEnv
|
|
key string // The limiter key: "ip:<prefix>" or "user:<id>", see vis.Key
|
|
ip netip.Addr // Visitor IP address
|
|
user *user.User // Only set if authenticated user, otherwise nil
|
|
limits *vis.Limits // Resolved from the config or the user's tier
|
|
seen atomic.Int64 // Unix nanos of the last keepalive: the liveness of a subscriber kept as a topic's rate visitor (see Stale)
|
|
}
|
|
|
|
// visitorEnv is what every visitor of a server shares; it is built once, so building a visitor per
|
|
// request costs little
|
|
type visitorEnv struct {
|
|
config *Config
|
|
limitsConfig *vis.Config // The parts of config that keying and limit resolution need (see visitorConfigFrom)
|
|
ipLimits *vis.Limits // The config-based limits of visitors without a tier; never modified
|
|
limiter *limit.Service // Decides on and counts usage, keyed by key
|
|
subscriptions *subscriptionGauge
|
|
messageCache *message.Cache
|
|
userManager *user.Manager // May be nil
|
|
}
|
|
|
|
type visitorInfo struct {
|
|
Limits *vis.Limits
|
|
Stats *visitorStats
|
|
}
|
|
|
|
type visitorStats struct {
|
|
Messages int64
|
|
MessagesRemaining int64
|
|
Emails int64
|
|
EmailsRemaining int64
|
|
Calls int64
|
|
CallsRemaining int64
|
|
Reservations int64
|
|
ReservationsRemaining int64
|
|
AttachmentTotalSize int64
|
|
AttachmentTotalSizeRemaining int64
|
|
}
|
|
|
|
func newVisitorEnv(conf *Config, limiter *limit.Service, messageCache *message.Cache, userManager *user.Manager) *visitorEnv {
|
|
limitsConfig := visitorConfigFrom(conf)
|
|
return &visitorEnv{
|
|
config: conf,
|
|
limitsConfig: limitsConfig,
|
|
ipLimits: vis.LimitsFor(limitsConfig, nil),
|
|
limiter: limiter,
|
|
subscriptions: newSubscriptionGauge(),
|
|
messageCache: messageCache,
|
|
userManager: userManager, // May be nil
|
|
}
|
|
}
|
|
|
|
func newVisitor(env *visitorEnv, ip netip.Addr, u *user.User) *visitor {
|
|
limits := env.ipLimits
|
|
var tier *user.Tier
|
|
if u != nil && u.Tier != nil {
|
|
tier = u.Tier
|
|
limits = vis.LimitsFor(env.limitsConfig, tier) // Resolved per request: the tier's fields may change at any time
|
|
}
|
|
v := &visitor{
|
|
env: env,
|
|
key: vis.Key(ip, u, env.limitsConfig),
|
|
ip: ip,
|
|
user: u,
|
|
limits: limits,
|
|
}
|
|
if tier != nil {
|
|
// Carry the persisted stats into the key's daily counts, if the key is new to the limiter
|
|
var usage limit.Usage
|
|
usage.Set(limit.KindMessages, u.Stats.Messages)
|
|
usage.Set(limit.KindEmails, u.Stats.Emails)
|
|
usage.Set(limit.KindCalls, u.Stats.Calls)
|
|
env.limiter.Seed(v.key, usage)
|
|
}
|
|
env.limiter.Touch(v.key) // The key is seen now, as the visitor's request is (after Seed, which only fills unknown keys)
|
|
v.seen.Store(time.Now().UnixNano())
|
|
return v
|
|
}
|
|
|
|
func (v *visitor) Context() log.Context {
|
|
info := v.infoLight()
|
|
fields := log.Context{
|
|
"visitor_id": v.key,
|
|
"visitor_ip": v.ip.String(),
|
|
"visitor_seen": util.FormatTime(time.Unix(0, v.seen.Load())),
|
|
"visitor_messages": info.Stats.Messages,
|
|
"visitor_messages_limit": info.Limits.MessageLimit,
|
|
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
|
"visitor_requests_remaining": v.env.limiter.Tokens(v.key, v.requestRule()),
|
|
}
|
|
if v.env.config.SMTPSenderFrom != "" {
|
|
fields["visitor_emails"] = info.Stats.Emails
|
|
fields["visitor_emails_limit"] = info.Limits.EmailLimit
|
|
fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining
|
|
}
|
|
if v.env.config.TwilioAccount != "" {
|
|
fields["visitor_calls"] = info.Stats.Calls
|
|
fields["visitor_calls_limit"] = info.Limits.CallLimit
|
|
fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
|
|
}
|
|
if v.user == nil {
|
|
fields["visitor_auth_attempts_remaining"] = v.env.limiter.Tokens(v.key, v.authRule())
|
|
}
|
|
if v.topicCreationLimited() {
|
|
fields["visitor_topic_creations_remaining"] = v.env.limiter.Tokens(v.key, v.topicCreationRule())
|
|
}
|
|
if v.user != nil {
|
|
fields["user_id"] = v.user.ID
|
|
fields["user_name"] = v.user.Name
|
|
if v.user.Tier != nil {
|
|
for field, value := range v.user.Tier.Context() {
|
|
fields[field] = value
|
|
}
|
|
}
|
|
if v.user.Billing.StripeCustomerID != "" {
|
|
fields["stripe_customer_id"] = v.user.Billing.StripeCustomerID
|
|
}
|
|
if v.user.Billing.StripeSubscriptionID != "" {
|
|
fields["stripe_subscription_id"] = v.user.Billing.StripeSubscriptionID
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func visitorExtendedInfoContext(info *visitorInfo) log.Context {
|
|
return log.Context{
|
|
"visitor_reservations": info.Stats.Reservations,
|
|
"visitor_reservations_limit": info.Limits.ReservationsLimit,
|
|
"visitor_reservations_remaining": info.Stats.ReservationsRemaining,
|
|
"visitor_attachment_total_size": info.Stats.AttachmentTotalSize,
|
|
"visitor_attachment_total_size_limit": info.Limits.AttachmentTotalSizeLimit,
|
|
"visitor_attachment_total_size_remaining": info.Stats.AttachmentTotalSizeRemaining,
|
|
}
|
|
|
|
}
|
|
|
|
func (v *visitor) RequestAllowed() bool {
|
|
if v.limits.RequestLimitBurst <= 0 {
|
|
return false // A bucket of nothing admits nothing, as before
|
|
}
|
|
return v.env.limiter.Allow(v.key, v.requestRule(), 1).OK
|
|
}
|
|
|
|
// FirebaseAllowed returns false while Firebase sends are paused for the key (see FirebaseTemporarilyDeny)
|
|
func (v *visitor) FirebaseAllowed() bool {
|
|
rule := v.firebaseRule()
|
|
if rule.Refill <= 0 {
|
|
return true
|
|
}
|
|
return v.env.limiter.Tokens(v.key, rule) >= 1
|
|
}
|
|
|
|
// FirebaseTemporarilyDeny pauses Firebase sends for the key for the configured penalty duration
|
|
func (v *visitor) FirebaseTemporarilyDeny() {
|
|
rule := v.firebaseRule()
|
|
if rule.Refill <= 0 {
|
|
return
|
|
}
|
|
v.env.limiter.Allow(v.key, rule, 1)
|
|
}
|
|
|
|
func (v *visitor) MessageAllowed() bool {
|
|
if v.limits.MessageLimit <= 0 {
|
|
return false // A daily limit of zero admits nothing; the limit service reads it as "no cap"
|
|
}
|
|
allowed := v.env.limiter.Allow(v.key, v.messageRule(), 1).OK
|
|
if allowed {
|
|
v.enqueueUserStats(&user.Stats{
|
|
Messages: 1,
|
|
})
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
func (v *visitor) EmailAllowed() bool {
|
|
if v.limits.EmailLimitBurst <= 0 || v.limits.EmailLimit <= 0 {
|
|
return false // No email bucket, or no emails on the tier: the limit service reads a daily limit of zero as "no cap"
|
|
}
|
|
allowed := v.env.limiter.Allow(v.key, v.emailRule(), 1).OK
|
|
if allowed {
|
|
v.enqueueUserStats(&user.Stats{
|
|
Emails: 1,
|
|
})
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
func (v *visitor) CallAllowed() bool {
|
|
if v.limits.CallLimit <= 0 {
|
|
return false // A daily limit of zero admits nothing (e.g. a tier without calls, verified number or not)
|
|
}
|
|
allowed := v.env.limiter.Allow(v.key, v.callRule(), 1).OK
|
|
if allowed {
|
|
v.enqueueUserStats(&user.Stats{
|
|
Calls: 1,
|
|
})
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
// enqueueUserStats hands what a tier user's request admitted to the user manager's write queue, which
|
|
// adds it to the persisted stats (deltas add up across requests and cluster nodes)
|
|
func (v *visitor) enqueueUserStats(delta *user.Stats) {
|
|
if v.env.userManager == nil || v.user == nil || v.user.Tier == nil {
|
|
return
|
|
}
|
|
v.env.userManager.EnqueueUserStats(v.user.ID, delta)
|
|
}
|
|
|
|
// SubscriptionAllowed takes one of the visitor key's subscription slots on this node, if one is free.
|
|
// Every successful call must be paired with RemoveSubscription.
|
|
func (v *visitor) SubscriptionAllowed() bool {
|
|
return v.env.subscriptions.Acquire(v.key, int64(v.env.config.VisitorSubscriptionLimit))
|
|
}
|
|
|
|
// TopicCreationAllowed returns true if the visitor is allowed to cause a new topic to be
|
|
// inserted into the server's in-memory topic map. Returns true if no limiter is configured.
|
|
func (v *visitor) TopicCreationAllowed() bool {
|
|
if !v.topicCreationLimited() {
|
|
return true
|
|
}
|
|
return v.env.limiter.Allow(v.key, v.topicCreationRule(), 1).OK
|
|
}
|
|
|
|
// AuthAllowed returns true if an auth request can be attempted (a token is available)
|
|
func (v *visitor) AuthAllowed() bool {
|
|
return v.env.limiter.Tokens(v.key, v.authRule()) >= 1
|
|
}
|
|
|
|
// AuthFailed records an auth failure
|
|
func (v *visitor) AuthFailed() {
|
|
v.env.limiter.Allow(v.key, v.authRule(), 1)
|
|
}
|
|
|
|
// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset
|
|
// request) is currently allowed for this visitor
|
|
func (v *visitor) AccountActionAllowed() bool {
|
|
return v.env.limiter.Tokens(v.key, v.accountRule()) >= 1
|
|
}
|
|
|
|
// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited
|
|
// account action (signup or password-reset request).
|
|
func (v *visitor) AccountActionPerformed() {
|
|
v.env.limiter.Allow(v.key, v.accountRule(), 1)
|
|
}
|
|
|
|
func (v *visitor) BandwidthAllowed(bytes int64) bool {
|
|
if v.limits.AttachmentBandwidthLimit <= 0 {
|
|
return false // A bandwidth limit of zero admits nothing, as before
|
|
}
|
|
return v.env.limiter.Allow(v.key, v.bandwidthRule(), bytes).OK
|
|
}
|
|
|
|
// RemoveSubscription gives back a slot taken by SubscriptionAllowed
|
|
func (v *visitor) RemoveSubscription() {
|
|
v.env.subscriptions.Release(v.key)
|
|
}
|
|
|
|
// Keepalive marks the visitor and its key as seen. The subscribe handlers call it on the visitor a
|
|
// topic keeps as its rate visitor; nothing else does, so Stale is the subscriber's liveness.
|
|
func (v *visitor) Keepalive() {
|
|
v.seen.Store(time.Now().UnixNano())
|
|
v.env.limiter.Touch(v.key)
|
|
}
|
|
|
|
// BandwidthLimiter returns the visitor's bandwidth bucket as a util.Limiter, for the readers and
|
|
// writers that meter bytes. A revert on the limiter (a negative AllowN) refunds the bytes.
|
|
func (v *visitor) BandwidthLimiter() util.Limiter {
|
|
if v.limits.AttachmentBandwidthLimit <= 0 {
|
|
return util.NewFixedLimiter(0) // A bandwidth limit of zero admits nothing, as BandwidthAllowed
|
|
}
|
|
return v.env.limiter.Limiter(v.key, v.bandwidthRule())
|
|
}
|
|
|
|
// Stale is true when the visitor has not been kept alive for visitorExpungeAfter. It reads the
|
|
// visitor's own keepalive, not the limiter key's last use: publishers charging a topic's rate
|
|
// visitor must not keep a vanished subscriber's limits on the topic.
|
|
func (v *visitor) Stale() bool {
|
|
return time.Since(time.Unix(0, v.seen.Load())) > visitorExpungeAfter
|
|
}
|
|
|
|
func (v *visitor) Stats() *user.Stats {
|
|
usage := v.env.limiter.Usage(v.key)
|
|
return &user.Stats{
|
|
Messages: usage.Count(limit.KindMessages),
|
|
Emails: usage.Count(limit.KindEmails),
|
|
Calls: usage.Count(limit.KindCalls),
|
|
}
|
|
}
|
|
|
|
// User returns the visitor user, or nil if there is none
|
|
func (v *visitor) User() *user.User {
|
|
return v.user // May be nil
|
|
}
|
|
|
|
// IP returns the visitor IP address
|
|
func (v *visitor) IP() netip.Addr {
|
|
return v.ip
|
|
}
|
|
|
|
// Authenticated returns true if a user successfully authenticated
|
|
func (v *visitor) Authenticated() bool {
|
|
return v.user != nil
|
|
}
|
|
|
|
// MaybeUserID returns the user ID of the visitor (if any). If this is an anonymous visitor,
|
|
// an empty string is returned.
|
|
func (v *visitor) MaybeUserID() string {
|
|
if v.user != nil {
|
|
return v.user.ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (v *visitor) Limits() *vis.Limits {
|
|
return v.limits
|
|
}
|
|
|
|
// The rules the visitor's limits translate to, built per call (they are small values); the
|
|
// limiter keeps the state.
|
|
|
|
func (v *visitor) requestRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindRequests,
|
|
Burst: int64(v.limits.RequestLimitBurst),
|
|
Refill: v.limits.RequestLimitReplenish,
|
|
}
|
|
}
|
|
|
|
func (v *visitor) messageRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindMessages,
|
|
Daily: v.limits.MessageLimit,
|
|
}
|
|
}
|
|
|
|
func (v *visitor) emailRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindEmails,
|
|
Burst: int64(v.limits.EmailLimitBurst),
|
|
Refill: v.limits.EmailLimitReplenish,
|
|
Daily: v.limits.EmailLimit,
|
|
}
|
|
}
|
|
|
|
func (v *visitor) callRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindCalls,
|
|
Daily: v.limits.CallLimit,
|
|
}
|
|
}
|
|
|
|
// bandwidthRule is a bucket of the daily bandwidth bytes that refills over a day
|
|
func (v *visitor) bandwidthRule() limit.Rule {
|
|
rule := limit.Rule{
|
|
Kind: limit.KindBandwidth,
|
|
Burst: v.limits.AttachmentBandwidthLimit,
|
|
}
|
|
if rule.Burst > 0 {
|
|
rule.Refill = oneDay / time.Duration(rule.Burst)
|
|
}
|
|
return rule
|
|
}
|
|
|
|
func (v *visitor) topicCreationRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindTopics,
|
|
Burst: int64(v.env.config.VisitorTopicCreationLimitBurst),
|
|
Refill: v.env.config.VisitorTopicCreationLimitReplenish,
|
|
}
|
|
}
|
|
|
|
func (v *visitor) topicCreationLimited() bool {
|
|
return v.env.config.VisitorTopicCreationLimitBurst > 0 && v.env.config.VisitorTopicCreationLimitReplenish > 0
|
|
}
|
|
|
|
func (v *visitor) accountRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindAccount,
|
|
Burst: int64(v.env.config.VisitorAccountCreationLimitBurst),
|
|
Refill: v.env.config.VisitorAccountCreationLimitReplenish,
|
|
}
|
|
}
|
|
|
|
func (v *visitor) authRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindAuth,
|
|
Burst: int64(v.env.config.VisitorAuthFailureLimitBurst),
|
|
Refill: v.env.config.VisitorAuthFailureLimitReplenish,
|
|
}
|
|
}
|
|
|
|
// firebaseRule is a single token that takes the penalty duration to come back: taking it pauses sends
|
|
func (v *visitor) firebaseRule() limit.Rule {
|
|
return limit.Rule{
|
|
Kind: limit.KindFirebase,
|
|
Burst: 1,
|
|
Refill: v.env.config.FirebaseQuotaExceededPenaltyDuration,
|
|
}
|
|
}
|
|
|
|
func (v *visitor) Info() (*visitorInfo, error) {
|
|
info := v.infoLight()
|
|
|
|
// Attachment stats from database
|
|
var attachmentsBytesUsed int64
|
|
var err error
|
|
u := v.User()
|
|
if u != nil {
|
|
attachmentsBytesUsed, err = v.env.messageCache.AttachmentBytesUsedByUser(u.ID)
|
|
} else {
|
|
attachmentsBytesUsed, err = v.env.messageCache.AttachmentBytesUsedBySender(v.IP().String())
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info.Stats.AttachmentTotalSize = attachmentsBytesUsed
|
|
info.Stats.AttachmentTotalSizeRemaining = zeroIfNegative(info.Limits.AttachmentTotalSizeLimit - attachmentsBytesUsed)
|
|
|
|
// Reservation stats from database
|
|
var reservations int64
|
|
if v.env.userManager != nil && u != nil {
|
|
reservations, err = v.env.userManager.ReservationsCount(u.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
info.Stats.Reservations = reservations
|
|
info.Stats.ReservationsRemaining = zeroIfNegative(info.Limits.ReservationsLimit - reservations)
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func (v *visitor) infoLight() *visitorInfo {
|
|
stats := v.Stats()
|
|
limits := v.limits
|
|
return &visitorInfo{
|
|
Limits: limits,
|
|
Stats: &visitorStats{
|
|
Messages: stats.Messages,
|
|
MessagesRemaining: zeroIfNegative(limits.MessageLimit - stats.Messages),
|
|
Emails: stats.Emails,
|
|
EmailsRemaining: zeroIfNegative(limits.EmailLimit - stats.Emails),
|
|
Calls: stats.Calls,
|
|
CallsRemaining: zeroIfNegative(limits.CallLimit - stats.Calls),
|
|
},
|
|
}
|
|
}
|
|
|
|
func zeroIfNegative(value int64) int64 {
|
|
if value < 0 {
|
|
return 0
|
|
}
|
|
return value
|
|
}
|
|
|
|
// visitorConfigFrom assembles the keying and limit-resolution config from the server config
|
|
func visitorConfigFrom(conf *Config) *vis.Config {
|
|
return &vis.Config{
|
|
PrefixBitsIPv4: conf.VisitorPrefixBitsIPv4,
|
|
PrefixBitsIPv6: conf.VisitorPrefixBitsIPv6,
|
|
RequestLimitBurst: conf.VisitorRequestLimitBurst,
|
|
RequestLimitReplenish: conf.VisitorRequestLimitReplenish,
|
|
MessageDailyLimit: conf.VisitorMessageDailyLimit,
|
|
EmailLimitBurst: conf.VisitorEmailLimitBurst,
|
|
EmailLimitReplenish: conf.VisitorEmailLimitReplenish,
|
|
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
|
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
|
AttachmentExpiryDuration: conf.AttachmentExpiryDuration,
|
|
AttachmentDailyBandwidthLimit: conf.VisitorAttachmentDailyBandwidthLimit,
|
|
MessageExpiryDuration: conf.CacheDuration,
|
|
}
|
|
}
|