Explorar el Código

Add message-{size|delay}-limit

binwiederhier hace 1 año
padre
commit
36b33030f3
Se han modificado 12 ficheros con 210 adiciones y 90 borrados
  1. 92 48
      cmd/serve.go
  2. 3 3
      cmd/tier.go
  3. 9 9
      server/config.go
  4. 5 5
      server/server.go
  5. 10 6
      server/server.yml
  6. 2 2
      server/server_account_test.go
  7. 2 2
      server/smtp_server.go
  8. 2 2
      server/visitor.go
  9. 16 0
      util/time.go
  10. 24 0
      util/time_test.go
  11. 20 2
      util/util.go
  12. 25 11
      util/util_test.go

+ 92 - 48
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)

+ 3 - 3
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)
 }

+ 9 - 9
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,

+ 5 - 5
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 {

+ 10 - 6
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:

+ 2 - 2
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"))
 

+ 2 - 2
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"))

+ 2 - 2
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 (

+ 16 - 0
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 {

+ 24 - 0
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))
+}

+ 20 - 2
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

+ 25 - 11
util/util_test.go

@@ -110,35 +110,49 @@ 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) {
 	key, value := SplitKV(" key = value ", "=")
 	require.Equal(t, "key", key)