diff --git a/cmd/serve.go b/cmd/serve.go index 60010fe0..4b5ee745 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -6,7 +6,12 @@ import ( "errors" "fmt" "github.com/stripe/stripe-go/v74" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" "io/fs" "math" "net" @@ -16,13 +21,6 @@ import ( "strings" "syscall" "time" - - "heckel.io/ntfy/v2/log" - - "github.com/urfave/cli/v2" - "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/v2/server" - "heckel.io/ntfy/v2/util" ) func init() { @@ -35,7 +33,7 @@ const ( var flagsServe = append( append([]cli.Flag{}, flagsDefault...), - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}), @@ -45,19 +43,19 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}), @@ -76,16 +74,18 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), @@ -99,7 +99,6 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "message-limit", Aliases: []string{"message_limit"}, EnvVars: []string{"NTFY_MESSAGE_LIMIT"}, Value: server.DefaultMessageLengthLimit, Usage: "size limit for the message in bytes"}), ) var cmdServe = &cli.Command{ @@ -141,19 +140,19 @@ func execServe(c *cli.Context) error { webPushEmailAddress := c.String("web-push-email-address") webPushStartupQueries := c.String("web-push-startup-queries") cacheFile := c.String("cache-file") - cacheDuration := c.Duration("cache-duration") + cacheDurationStr := c.String("cache-duration") cacheStartupQueries := c.String("cache-startup-queries") cacheBatchSize := c.Int("cache-batch-size") - cacheBatchTimeout := c.Duration("cache-batch-timeout") + cacheBatchTimeoutStr := c.String("cache-batch-timeout") authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") - attachmentExpiryDuration := c.Duration("attachment-expiry-duration") - keepaliveInterval := c.Duration("keepalive-interval") - managerInterval := c.Duration("manager-interval") + attachmentExpiryDurationStr := c.String("attachment-expiry-duration") + keepaliveIntervalStr := c.String("keepalive-interval") + managerIntervalStr := c.String("manager-interval") disallowedTopics := c.StringSlice("disallowed-topics") webRoot := c.String("web-root") enableSignup := c.Bool("enable-signup") @@ -172,17 +171,19 @@ func execServe(c *cli.Context) error { twilioAuthToken := c.String("twilio-auth-token") twilioPhoneNumber := c.String("twilio-phone-number") twilioVerifyService := c.String("twilio-verify-service") + messageSizeLimitStr := c.String("message-size-limit") + messageDelayLimitStr := c.String("message-delay-limit") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") - visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") + visitorRequestLimitReplenishStr := c.String("visitor-request-limit-replenish") visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",") visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") - visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") + visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish") behindProxy := c.Bool("behind-proxy") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") @@ -190,7 +191,64 @@ func execServe(c *cli.Context) error { metricsListenHTTP := c.String("metrics-listen-http") enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" profileListenHTTP := c.String("profile-listen-http") - messageLimit := c.Int("message-limit") + + // Convert durations + cacheDuration, err := util.ParseDuration(cacheDurationStr) + if err != nil { + return err + } + cacheBatchTimeout, err := util.ParseDuration(cacheBatchTimeoutStr) + if err != nil { + return err + } + attachmentExpiryDuration, err := util.ParseDuration(attachmentExpiryDurationStr) + if err != nil { + return err + } + keepaliveInterval, err := util.ParseDuration(keepaliveIntervalStr) + if err != nil { + return err + } + managerInterval, err := util.ParseDuration(managerIntervalStr) + if err != nil { + return err + } + messageDelayLimit, err := util.ParseDuration(messageDelayLimitStr) + if err != nil { + return err + } + visitorRequestLimitReplenish, err := util.ParseDuration(visitorRequestLimitReplenishStr) + if err != nil { + return err + } + visitorEmailLimitReplenish, err := util.ParseDuration(visitorEmailLimitReplenishStr) + if err != nil { + return err + } + + // Convert sizes to bytes + messageSizeLimit, err := parseSize(messageSizeLimitStr, server.DefaultMessageSizeLimit) + if err != nil { + return err + } + attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit) + if err != nil { + return err + } + attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit) + if err != nil { + return err + } + visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit) + if err != nil { + return err + } + visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit) + if err != nil { + return err + } else if visitorAttachmentDailyBandwidthLimit > math.MaxInt { + return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt) + } // Check values if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { @@ -235,6 +293,11 @@ func execServe(c *cli.Context) error { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set") + } else if messageSizeLimit > 4096 { + log.Warn("message-size-limit is >4K, this is not recommended and largely untested, and may lead to issues with some clients") + if messageSizeLimit > 5*1024*1024 { + return errors.New("message-size-limit cannot be higher than 5M") + } } // Backwards compatibility @@ -259,26 +322,6 @@ func execServe(c *cli.Context) error { listenHTTP = "" } - // Convert sizes to bytes - attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit) - if err != nil { - return err - } - attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit) - if err != nil { - return err - } - visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit) - if err != nil { - return err - } - visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit) - if err != nil { - return err - } else if visitorAttachmentDailyBandwidthLimit > math.MaxInt { - return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt) - } - // Resolve hosts visitorRequestLimitExemptIPs := make([]netip.Prefix, 0) for _, host := range visitorRequestLimitExemptHosts { @@ -339,6 +382,8 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService + conf.MessageSizeLimit = int(messageSizeLimit) + conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit @@ -366,7 +411,6 @@ func execServe(c *cli.Context) error { conf.WebPushFile = webPushFile conf.WebPushEmailAddress = webPushEmailAddress conf.WebPushStartupQueries = webPushStartupQueries - conf.MessageLimit = messageLimit // Set up hot-reloading of config go sigHandlerConfigReload(config) diff --git a/cmd/tier.go b/cmd/tier.go index 63b023f9..3b45eaa7 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -366,9 +366,9 @@ func printTier(c *cli.Context, tier *user.Tier) { fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) - fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit)) fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) - fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit)) fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices) } diff --git a/server/config.go b/server/config.go index a0cfdcd5..d2c3bf06 100644 --- a/server/config.go +++ b/server/config.go @@ -15,8 +15,8 @@ const ( DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) DefaultManagerInterval = time.Minute DefaultDelayedSenderInterval = 10 * time.Second - DefaultMinDelay = 10 * time.Second - DefaultMaxDelay = 3 * 24 * time.Hour + DefaultMessageDelayMin = 10 * time.Second + DefaultMessageDelayMax = 3 * 24 * time.Hour DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs) DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded" @@ -34,7 +34,7 @@ const ( // - total topic limit: max number of topics overall // - various attachment limits const ( - DefaultMessageLengthLimit = 4096 // Bytes + DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message DefaultTotalTopicLimit = 15000 DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB @@ -122,9 +122,9 @@ type Config struct { MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string - MessageLimit int - MinDelay time.Duration - MaxDelay time.Duration + MessageDelayMin time.Duration + MessageDelayMax time.Duration + MessageSizeLimit int TotalTopicLimit int TotalAttachmentSizeLimit int64 VisitorSubscriptionLimit int @@ -211,9 +211,9 @@ func NewConfig() *Config { TwilioPhoneNumber: "", TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyService: "", - MessageLimit: DefaultMessageLengthLimit, - MinDelay: DefaultMinDelay, - MaxDelay: DefaultMaxDelay, + MessageSizeLimit: DefaultMessageSizeLimit, + MessageDelayMin: DefaultMessageDelayMin, + MessageDelayMax: DefaultMessageDelayMax, TotalTopicLimit: DefaultTotalTopicLimit, TotalAttachmentSizeLimit: 0, VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, diff --git a/server/server.go b/server/server.go index aad452ed..f6e39be3 100644 --- a/server/server.go +++ b/server/server.go @@ -733,7 +733,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if err != nil { return nil, err } - body, err := util.Peek(r.Body, s.config.MessageLimit) + body, err := util.Peek(r.Body, s.config.MessageSizeLimit) if err != nil { return nil, err } @@ -996,9 +996,9 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { return false, false, "", "", false, errHTTPBadRequestDelayCannotParse - } else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() { + } else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { return false, false, "", "", false, errHTTPBadRequestDelayTooSmall - } else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() { + } else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() { return false, false, "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() @@ -1754,7 +1754,7 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error { // before passing it on to the next handler. This is meant to be used in combination with handlePublish. func (s *Server) transformBodyJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead + m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageSizeLimit*2, false) // 2x to account for JSON format overhead if err != nil { return err } @@ -1812,7 +1812,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit) + newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageSizeLimit) if err != nil { logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request") if e, ok := err.(*errMatrixPushkeyRejected); ok { diff --git a/server/server.yml b/server/server.yml index b55b6844..7329d37e 100644 --- a/server/server.yml +++ b/server/server.yml @@ -236,6 +236,16 @@ # upstream-base-url: # upstream-access-token: +# Configures message-specific limits +# +# - message-size-limit defines the max size of a message body. Please note message sizes >4K are NOT RECOMMENDED, +# and largely untested. If FCM and/or APNS is used, the limit should stay 4K, because their limits are around that size. +# If you increase this size limit regardless, FCM and APNS will NOT work for large messages. +# - message-delay-limit defines the max delay of a message when using the "Delay" header. +# +# message-size-limit: "4k" +# message-delay-limit: "3d" + # Rate limiting: Total number of topics before the server rejects new topics. # # global-topic-limit: 15000 @@ -360,9 +370,3 @@ # log-level-overrides: # log-format: text # log-file: - -# Defines the size limit (in bytes) for a ntfy message. -# NOTE: FCM has size limit at 4000 bytes. APNS has size limit at 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. -# The default value is 4096 bytes. -# -# message-limit: diff --git a/server/server_account_test.go b/server/server_account_test.go index 4c269c2f..72ba55c9 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -718,11 +718,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.AddTier(&user.Tier{ Code: "starter", - MessageLimit: 10, + MessageSizeLimit: 10, })) require.Nil(t, s.userManager.AddTier(&user.Tier{ Code: "pro", - MessageLimit: 20, + MessageSizeLimit: 20, })) require.Nil(t, s.userManager.ChangeTier("phil", "starter")) diff --git a/server/smtp_server.go b/server/smtp_server.go index 467b8ca4..a687f1da 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -150,8 +150,8 @@ func (s *smtpSession) Data(r io.Reader) error { return err } body = strings.TrimSpace(body) - if len(body) > conf.MessageLimit { - body = body[:conf.MessageLimit] + if len(body) > conf.MessageSizeLimit { + body = body[:conf.MessageSizeLimit] } m := newDefaultMessage(s.topic, body) subject := strings.TrimSpace(msg.Header.Get("Subject")) diff --git a/server/visitor.go b/server/visitor.go index f8dc416a..d542e773 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -30,10 +30,10 @@ const ( visitorDefaultCallsLimit = int64(0) ) -// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter +// Constants used to convert a tier-user's MessageSizeLimit (see user.Tier) into adequate request limiter // values (token bucket). This is only used to increase the values in server.yml, never decrease them. // -// Example: Assuming a user.Tier's MessageLimit is 10,000: +// Example: Assuming a user.Tier's MessageSizeLimit is 10,000: // - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max) // - the replenish rate is 2 * 10,000 / 24 hours const ( diff --git a/util/time.go b/util/time.go index 14aa3936..0d4ed378 100644 --- a/util/time.go +++ b/util/time.go @@ -83,6 +83,22 @@ func ParseDuration(s string) (time.Duration, error) { return 0, errUnparsableTime } +func FormatDuration(d time.Duration) string { + if d >= 24*time.Hour { + return strconv.Itoa(int(d/(24*time.Hour))) + "d" + } + if d >= time.Hour { + return strconv.Itoa(int(d/time.Hour)) + "h" + } + if d >= time.Minute { + return strconv.Itoa(int(d/time.Minute)) + "m" + } + if d >= time.Second { + return strconv.Itoa(int(d/time.Second)) + "s" + } + return "0s" +} + func parseFromDuration(s string, now time.Time) (time.Time, error) { d, err := ParseDuration(s) if err == nil { diff --git a/util/time_test.go b/util/time_test.go index 9cc343fd..e29e5a3b 100644 --- a/util/time_test.go +++ b/util/time_test.go @@ -92,3 +92,27 @@ func TestParseDuration(t *testing.T) { require.Nil(t, err) require.Equal(t, time.Duration(0), d) } + +func TestFormatDuration(t *testing.T) { + values := []struct { + duration time.Duration + expected string + }{ + {24 * time.Second, "24s"}, + {56 * time.Minute, "56m"}, + {time.Hour, "1h"}, + {2 * time.Hour, "2h"}, + {24 * time.Hour, "1d"}, + {3 * 24 * time.Hour, "3d"}, + } + for _, value := range values { + require.Equal(t, value.expected, FormatDuration(value.duration)) + d, err := ParseDuration(FormatDuration(value.duration)) + require.Nil(t, err) + require.Equalf(t, value.duration, d, "duration does not match: %v != %v", value.duration, d) + } +} + +func TestFormatDuration_Rounded(t *testing.T) { + require.Equal(t, "1d", FormatDuration(47*time.Hour)) +} diff --git a/util/util.go b/util/util.go index df0c011c..39bf8798 100644 --- a/util/util.go +++ b/util/util.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "math/rand" "net/netip" "os" @@ -215,6 +216,8 @@ func ParseSize(s string) (int64, error) { return -1, fmt.Errorf("cannot convert number %s", matches[1]) } switch strings.ToUpper(matches[2]) { + case "T": + return int64(value) * 1024 * 1024 * 1024 * 1024, nil case "G": return int64(value) * 1024 * 1024 * 1024, nil case "M": @@ -226,8 +229,23 @@ func ParseSize(s string) (int64, error) { } } -// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB +// FormatSize formats the size in a way that it can be parsed by ParseSize. +// It does not include decimal places. Uneven sizes are rounded down. func FormatSize(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%d%c", int(math.Floor(float64(b)/float64(div))), "KMGT"[exp]) +} + +// FormatSizeHuman formats bytes into a human-readable notation, e.g. 2.1 MB +func FormatSizeHuman(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d bytes", b) @@ -237,7 +255,7 @@ func FormatSize(b int64) string { div *= unit exp++ } - return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGT"[exp]) } // ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the diff --git a/util/util_test.go b/util/util_test.go index f0f45c28..d539d675 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -110,33 +110,47 @@ func TestShortTopicURL(t *testing.T) { func TestParseSize_10GSuccess(t *testing.T) { s, err := ParseSize("10G") - if err != nil { - t.Fatal(err) - } + require.Nil(t, err) require.Equal(t, int64(10*1024*1024*1024), s) } func TestParseSize_10MUpperCaseSuccess(t *testing.T) { s, err := ParseSize("10M") - if err != nil { - t.Fatal(err) - } + require.Nil(t, err) require.Equal(t, int64(10*1024*1024), s) } func TestParseSize_10kLowerCaseSuccess(t *testing.T) { s, err := ParseSize("10k") - if err != nil { - t.Fatal(err) - } + require.Nil(t, err) require.Equal(t, int64(10*1024), s) } func TestParseSize_FailureInvalid(t *testing.T) { _, err := ParseSize("not a size") - if err == nil { - t.Fatalf("expected error, but got none") + require.Nil(t, err) +} + +func TestFormatSize(t *testing.T) { + values := []struct { + size int64 + expected string + }{ + {10, "10"}, + {10 * 1024, "10K"}, + {10 * 1024 * 1024, "10M"}, + {10 * 1024 * 1024 * 1024, "10G"}, } + for _, value := range values { + require.Equal(t, value.expected, FormatSize(value.size)) + s, err := ParseSize(FormatSize(value.size)) + require.Nil(t, err) + require.Equalf(t, value.size, s, "size does not match: %d != %d", value.size, s) + } +} + +func TestFormatSize_Rounded(t *testing.T) { + require.Equal(t, "10K", FormatSize(10*1024+999)) } func TestSplitKV(t *testing.T) {