Philipp Heckel 3 лет назад
Родитель
Сommit
38b28f9bf4
10 измененных файлов с 309 добавлено и 154 удалено
  1. 8 2
      client/client.go
  2. 15 0
      client/options.go
  3. 42 2
      cmd/publish.go
  4. 8 8
      cmd/serve.go
  5. 52 37
      docs/config.md
  6. 87 8
      docs/publish.md
  7. 74 74
      server/config.go
  8. 14 14
      server/server.go
  9. 5 5
      server/server_test.go
  10. 4 4
      server/visitor.go

+ 8 - 2
client/client.go

@@ -67,6 +67,12 @@ func New(config *Config) *Client {
 }
 
 // Publish sends a message to a specific topic, optionally using options.
+// See PublishReader for details.
+func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {
+	return c.PublishReader(topic, strings.NewReader(message), options...)
+}
+
+// PublishReader sends a message to a specific topic, optionally using options.
 //
 // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
 // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
@@ -74,9 +80,9 @@ func New(config *Config) *Client {
 //
 // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
 // WithNoFirebase, and the generic WithHeader.
-func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {
+func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
 	topicURL := c.expandTopicURL(topic)
-	req, _ := http.NewRequest("POST", topicURL, strings.NewReader(message))
+	req, _ := http.NewRequest("POST", topicURL, body)
 	for _, option := range options {
 		if err := option(req); err != nil {
 			return nil, err

+ 15 - 0
client/options.go

@@ -16,6 +16,11 @@ type PublishOption = RequestOption
 // SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call
 type SubscribeOption = RequestOption
 
+// WithMessage sets the notification message. This is an alternative way to passing the message body.
+func WithMessage(message string) PublishOption {
+	return WithHeader("X-Message", message)
+}
+
 // WithTitle adds a title to a message
 func WithTitle(title string) PublishOption {
 	return WithHeader("X-Title", title)
@@ -50,6 +55,16 @@ func WithClick(url string) PublishOption {
 	return WithHeader("X-Click", url)
 }
 
+// WithAttach sets a URL that will be used by the client to download an attachment
+func WithAttach(attach string) PublishOption {
+	return WithHeader("X-Attach", attach)
+}
+
+// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
+func WithFilename(filename string) PublishOption {
+	return WithHeader("X-Filename", filename)
+}
+
 // WithEmail instructs the server to also send the message to the given e-mail address
 func WithEmail(email string) PublishOption {
 	return WithHeader("X-Email", email)

+ 42 - 2
cmd/publish.go

@@ -5,6 +5,9 @@ import (
 	"fmt"
 	"github.com/urfave/cli/v2"
 	"heckel.io/ntfy/client"
+	"io"
+	"os"
+	"path/filepath"
 	"strings"
 )
 
@@ -21,6 +24,9 @@ var cmdPublish = &cli.Command{
 		&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"},
 		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
 		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"},
+		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"},
+		&cli.StringFlag{Name: "filename", Aliases: []string{"n"}, Usage: "Filename for the attachment"},
+		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"},
 		&cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"},
 		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
 		&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
@@ -37,6 +43,9 @@ Examples:
   ntfy pub --at=8:30am delayed_topic Laterzz              # Send message at 8:30am
   ntfy pub -e phil@example.com alerts 'App is down!'      # Also send email to phil@example.com
   ntfy pub --click="https://reddit.com" redd 'New msg'    # Opens Reddit when notification is clicked
+  ntfy pub --attach="http://some.tld/file.zip" files      # Send ZIP archive from URL as attachment
+  ntfy pub --file=flower.jpg flowers 'Nice!'              # Send image.jpg as attachment
+  cat flower.jpg | ntfy pub --file=- flowers 'Nice!'      # Same as above, send image.jpg as attachment
   ntfy trigger mywebhook                                  # Sending without message, useful for webhooks
 
 Please also check out the docs on publishing messages. Especially for the --tags and --delay options, 
@@ -59,6 +68,9 @@ func execPublish(c *cli.Context) error {
 	tags := c.String("tags")
 	delay := c.String("delay")
 	click := c.String("click")
+	attach := c.String("attach")
+	filename := c.String("filename")
+	file := c.String("file")
 	email := c.String("email")
 	noCache := c.Bool("no-cache")
 	noFirebase := c.Bool("no-firebase")
@@ -82,7 +94,13 @@ func execPublish(c *cli.Context) error {
 		options = append(options, client.WithDelay(delay))
 	}
 	if click != "" {
-		options = append(options, client.WithClick(email))
+		options = append(options, client.WithClick(click))
+	}
+	if attach != "" {
+		options = append(options, client.WithAttach(attach))
+	}
+	if filename != "" {
+		options = append(options, client.WithFilename(filename))
 	}
 	if email != "" {
 		options = append(options, client.WithEmail(email))
@@ -93,8 +111,30 @@ func execPublish(c *cli.Context) error {
 	if noFirebase {
 		options = append(options, client.WithNoFirebase())
 	}
+	var body io.Reader
+	if file == "" {
+		body = strings.NewReader(message)
+	} else {
+		if message != "" {
+			options = append(options, client.WithMessage(message))
+		}
+		if file == "-" {
+			if filename == "" {
+				options = append(options, client.WithFilename("stdin"))
+			}
+			body = c.App.Reader
+		} else {
+			if filename == "" {
+				options = append(options, client.WithFilename(filepath.Base(file)))
+			}
+			body, err = os.Open(file)
+			if err != nil {
+				return err
+			}
+		}
+	}
 	cl := client.New(conf)
-	m, err := cl.Publish(topic, message, options...)
+	m, err := cl.PublishReader(topic, body, options...)
 	if err != nil {
 		return err
 	}

+ 8 - 8
cmd/serve.go

@@ -23,7 +23,7 @@ var flagsServe = []cli.Flag{
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "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{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "1G", Usage: "limit of the on-disk attachment cache"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"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{"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{"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{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
@@ -37,8 +37,8 @@ var flagsServe = []cli.Flag{
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "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", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "50M", Usage: "total storage limit used for attachments per visitor"}),
-	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-traffic-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_TRAFFIC_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload traffic limit per visitor"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "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-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", 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", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
@@ -93,7 +93,7 @@ func execServe(c *cli.Context) error {
 	totalTopicLimit := c.Int("global-topic-limit")
 	visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
 	visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
-	visitorAttachmentDailyTrafficLimitStr := c.String("visitor-attachment-daily-traffic-limit")
+	visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
 	visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
 	visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
@@ -134,11 +134,11 @@ func execServe(c *cli.Context) error {
 	if err != nil {
 		return err
 	}
-	visitorAttachmentDailyTrafficLimit, err := parseSize(visitorAttachmentDailyTrafficLimitStr, server.DefaultVisitorAttachmentDailyTrafficLimit)
+	visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
 	if err != nil {
 		return err
-	} else if visitorAttachmentDailyTrafficLimit > math.MaxInt {
-		return fmt.Errorf("config option visitor-attachment-daily-traffic-limit must be lower than %d", math.MaxInt)
+	} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
+		return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
 	}
 
 	// Run server
@@ -167,7 +167,7 @@ func execServe(c *cli.Context) error {
 	conf.TotalTopicLimit = totalTopicLimit
 	conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
 	conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
-	conf.VisitorAttachmentDailyTrafficLimit = int(visitorAttachmentDailyTrafficLimit)
+	conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
 	conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
 	conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst

+ 52 - 37
docs/config.md

@@ -153,6 +153,7 @@ or the root domain:
         proxy_http_version 1.1;
     
         proxy_buffering off;
+        proxy_request_buffering off;
         proxy_redirect off;
      
         proxy_set_header Host $http_host;
@@ -161,6 +162,8 @@ or the root domain:
         proxy_connect_timeout 3m;
         proxy_send_timeout 3m;
         proxy_read_timeout 3m;
+
+        client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
       }
     }
     
@@ -179,8 +182,9 @@ or the root domain:
       location / {
         proxy_pass http://127.0.0.1:2586;
         proxy_http_version 1.1;
-    
+
         proxy_buffering off;
+        proxy_request_buffering off;
         proxy_redirect off;
      
         proxy_set_header Host $http_host;
@@ -189,6 +193,8 @@ or the root domain:
         proxy_connect_timeout 3m;
         proxy_send_timeout 3m;
         proxy_read_timeout 3m;
+        
+        client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
       }
     }
     ```
@@ -413,7 +419,12 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 | `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
 | `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
 | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
-| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
+| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
+| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
+| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
+| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
+| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
+| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
 | `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
 | `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
 | `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
@@ -421,26 +432,24 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 | `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
 | `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
 | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - |  Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
-| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
-| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
 | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
 | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
+| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
+| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
 | `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
 | `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
-| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 |Initial limit of e-mails per visitor |
+| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Initial limit of e-mails per visitor |
 | `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
-xx daily traffic limit
-xx DefaultVisitorAttachmentTotalSizeLimit
-xx attachment cache dir
-xx attachment 
+| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
 
-The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
+The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.   
+The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
 
 ## Command line options
 ```
 $ ntfy serve --help
 NAME:
-   ntfy serve - Run the ntfy server
+   main serve - Run the ntfy server
 
 USAGE:
    ntfy serve [OPTIONS..]
@@ -456,31 +465,37 @@ DESCRIPTION:
      ntfy serve --listen-http :8080  # Starts server with alternate port
 
 OPTIONS:
-   --config value, -c value                 config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
-   --base-url value, -B value               externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
-   --listen-http value, -l value            ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
-   --listen-https value, -L value           ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
-   --key-file value, -K value               private key file, if listen-https is set [$NTFY_KEY_FILE]
-   --cert-file value, -E value              certificate file, if listen-https is set [$NTFY_CERT_FILE]
-   --firebase-key-file value, -F value      Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
-   --cache-file value, -C value             cache file used for message caching [$NTFY_CACHE_FILE]
-   --cache-duration since, -b since         buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
-   --keepalive-interval value, -k value     interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL]
-   --manager-interval value, -m value       interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
-   --smtp-sender-addr value                 SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
-   --smtp-sender-user value                 SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
-   --smtp-sender-pass value                 SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
-   --smtp-sender-from value                 SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
-   --smtp-server-listen value               SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
-   --smtp-server-domain value               SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
-   --smtp-server-addr-prefix value          SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
-   --global-topic-limit value, -T value     total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT]
-   --visitor-subscription-limit value       number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
-   --visitor-request-limit-burst value      initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
-   --visitor-request-limit-replenish value  interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
-   --visitor-email-limit-burst value        initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
-   --visitor-email-limit-replenish value    interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
-   --behind-proxy, -P                       if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
-   --help, -h                               show help (default: false)
+   --config value, -c value                          config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
+   --base-url value, -B value                        externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
+   --listen-http value, -l value                     ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
+   --listen-https value, -L value                    ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
+   --key-file value, -K value                        private key file, if listen-https is set [$NTFY_KEY_FILE]
+   --cert-file value, -E value                       certificate file, if listen-https is set [$NTFY_CERT_FILE]
+   --firebase-key-file value, -F value               Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
+   --cache-file value, -C value                      cache file used for message caching [$NTFY_CACHE_FILE]
+   --cache-duration since, -b since                  buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
+   --attachment-cache-dir value                      cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
+   --attachment-total-size-limit value, -A value     limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
+   --attachment-file-size-limit value, -Y value      per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
+   --attachment-expiry-duration value, -X value      duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
+   --keepalive-interval value, -k value              interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL]
+   --manager-interval value, -m value                interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
+   --smtp-sender-addr value                          SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
+   --smtp-sender-user value                          SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
+   --smtp-sender-pass value                          SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
+   --smtp-sender-from value                          SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
+   --smtp-server-listen value                        SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
+   --smtp-server-domain value                        SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
+   --smtp-server-addr-prefix value                   SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
+   --global-topic-limit value, -T value              total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
+   --visitor-subscription-limit value                number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
+   --visitor-attachment-total-size-limit value       total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
+   --visitor-attachment-daily-bandwidth-limit value  total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
+   --visitor-request-limit-burst value               initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
+   --visitor-request-limit-replenish value           interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
+   --visitor-email-limit-burst value                 initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
+   --visitor-email-limit-replenish value             interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
+   --behind-proxy, -P                                if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
+   --help, -h                                        show help (default: false)
 ```
 

+ 87 - 8
docs/publish.md

@@ -659,16 +659,81 @@ Here's an example that will open Reddit when the notification is clicked:
     ]));
     ```
 
-## Send files + URLs
+## Attachments (send files)
+You can send images and other files to your phone as attachments to a notification. The attachments are then downloaded
+onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
+
+There are two different ways to send attachments, either via PUT or by passing an external URL.  
+
+**Upload attachments from your computer**: To send an attachment from your computer as a file, you can send it as the 
+PUT request body. If a message is greater than the maximum message size or consists of non-UTF-8 characters, the ntfy 
+server will automatically detect the mime type and size, and send the message as an attachment file. 
+
+You can optionally pass a filename (or force attachment mode for small text-messages) by passing the `X-Filename` header
+or query parameter (or any of its aliases `Filename`, `File` or `f`). 
+
+Here's an example showing how to upload an image:
+
+
+=== "Command line (curl)"
+    ```
+    curl \
+        -T flower.jpg \
+        ntfy.sh/flowers
+    ```
+
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --file=flower.jpg \
+        flowers
+    ```
+
+=== "HTTP"
+    ``` http
+    PUT /flowers HTTP/1.1
+    Host: ntfy.sh
+
+    <binary JPEG data>
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/flowers', {
+        method: 'PUT',
+        body: document.getElementById("file").files[0]
+    })
+    ```
+
+=== "Go"
+    ``` go
+    file, _ := os.Open("flower.jpg")
+    req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
+    http.DefaultClient.Do(req)
+    ```
+
+=== "Python"
+    ``` python
+    requests.put("https://ntfy.sh/flowers",
+        data=open("flower.jpg", 'rb'))
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+        'http' => [
+            'method' => 'PUT',
+            'content' => XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx 
+        ]
+    ]));
+    ```
+
 ```
 - Uploaded attachment
 - External attachment
 - Preview without attachment 
 
 
-# Upload and send attachment
-curl -T image.jpg ntfy.sh/howdy
-
 # Upload and send attachment with custom message and filename
 curl \
     -T flower.jpg \
@@ -951,17 +1016,30 @@ to `no`. This will instruct the server not to forward messages to Firebase.
     ]));
     ```
 
+### UnifiedPush
+!!! info
+    This setting is not relevant to users, only to app developers and people interested in [UnifiedPush](https://unifiedpush.org). 
+
+[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
+[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
+in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
+
+When publishing messages to a topic, apps using ntfy as a UnifiedPush distributor can set the `X-UnifiedPush` header or query
+parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Firebase](#disable-firebase). As of today, this
+option is equivalent to `Firebase: no`, but was introduced to allow future flexibility.
+
 ## Limitations
 There are a few limitations to the API to prevent abuse and to keep the server healthy. Most of them you won't run into,
 but just in case, let's list them all:
 
 | Limit | Description |
 |---|---|
-| **Message length** | Each message can be up to 4096 bytes long. Longer messages are truncated. |
+| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are truncated. |
 | **Requests** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
 | **E-mails** | By default, the server is configured to allow sending 16 e-mails at once, and then refills the your allowed e-mail bucket at a rate of one per hour. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
 | **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. |
-| **Total number of topics** | By default, the server is configured to allow 5,000 topics. The ntfy.sh server has higher limits though. |
+| **Bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
+| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
 
 ## List of all parameters
 The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
@@ -975,8 +1053,9 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
 | `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
 | `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
 | `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
-| `X-Filename` | `Filename`, `file`, `f` | XXXXXXXXXXXXXXXX |
+| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments-send-files), as an alternative to PUT/POST-ing an attachment |
+| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments-send-files) filename, as it appears in the client |
 | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
 | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
 | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
-| `X-UnifiedPush` | `UnifiedPush`, `up` | XXXXXXXXXXXXXXXX |
+| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, currently equivalent to `Firebase: no` |

+ 74 - 74
server/config.go

@@ -23,8 +23,8 @@ const (
 const (
 	DefaultMessageLengthLimit       = 4096 // Bytes
 	DefaultTotalTopicLimit          = 15000
-	DefaultAttachmentTotalSizeLimit = int64(10 * 1024 * 1024 * 1024) // 10 GB
-	DefaultAttachmentFileSizeLimit  = int64(15 * 1024 * 1024)        // 15 MB
+	DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
+	DefaultAttachmentFileSizeLimit  = int64(15 * 1024 * 1024)       // 15 MB
 	DefaultAttachmentExpiryDuration = 3 * time.Hour
 )
 
@@ -33,87 +33,87 @@ const (
 // - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
 // - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
 // - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server
-// - per visitor attachment daily traffic limit: number of bytes that can be transferred to/from the server
+// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server
 const (
-	DefaultVisitorSubscriptionLimit           = 30
-	DefaultVisitorRequestLimitBurst           = 60
-	DefaultVisitorRequestLimitReplenish       = 10 * time.Second
-	DefaultVisitorEmailLimitBurst             = 16
-	DefaultVisitorEmailLimitReplenish         = time.Hour
-	DefaultVisitorAttachmentTotalSizeLimit    = 100 * 1024 * 1024 // 100 MB
-	DefaultVisitorAttachmentDailyTrafficLimit = 500 * 1024 * 1024 // 500 MB
+	DefaultVisitorSubscriptionLimit             = 30
+	DefaultVisitorRequestLimitBurst             = 60
+	DefaultVisitorRequestLimitReplenish         = 10 * time.Second
+	DefaultVisitorEmailLimitBurst               = 16
+	DefaultVisitorEmailLimitReplenish           = time.Hour
+	DefaultVisitorAttachmentTotalSizeLimit      = 100 * 1024 * 1024 // 100 MB
+	DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
 )
 
 // Config is the main config struct for the application. Use New to instantiate a default config struct.
 type Config struct {
-	BaseURL                            string
-	ListenHTTP                         string
-	ListenHTTPS                        string
-	KeyFile                            string
-	CertFile                           string
-	FirebaseKeyFile                    string
-	CacheFile                          string
-	CacheDuration                      time.Duration
-	AttachmentCacheDir                 string
-	AttachmentTotalSizeLimit           int64
-	AttachmentFileSizeLimit            int64
-	AttachmentExpiryDuration           time.Duration
-	KeepaliveInterval                  time.Duration
-	ManagerInterval                    time.Duration
-	AtSenderInterval                   time.Duration
-	FirebaseKeepaliveInterval          time.Duration
-	SMTPSenderAddr                     string
-	SMTPSenderUser                     string
-	SMTPSenderPass                     string
-	SMTPSenderFrom                     string
-	SMTPServerListen                   string
-	SMTPServerDomain                   string
-	SMTPServerAddrPrefix               string
-	MessageLimit                       int
-	MinDelay                           time.Duration
-	MaxDelay                           time.Duration
-	TotalTopicLimit                    int
-	TotalAttachmentSizeLimit           int64
-	VisitorSubscriptionLimit           int
-	VisitorAttachmentTotalSizeLimit    int64
-	VisitorAttachmentDailyTrafficLimit int
-	VisitorRequestLimitBurst           int
-	VisitorRequestLimitReplenish       time.Duration
-	VisitorEmailLimitBurst             int
-	VisitorEmailLimitReplenish         time.Duration
-	BehindProxy                        bool
+	BaseURL                              string
+	ListenHTTP                           string
+	ListenHTTPS                          string
+	KeyFile                              string
+	CertFile                             string
+	FirebaseKeyFile                      string
+	CacheFile                            string
+	CacheDuration                        time.Duration
+	AttachmentCacheDir                   string
+	AttachmentTotalSizeLimit             int64
+	AttachmentFileSizeLimit              int64
+	AttachmentExpiryDuration             time.Duration
+	KeepaliveInterval                    time.Duration
+	ManagerInterval                      time.Duration
+	AtSenderInterval                     time.Duration
+	FirebaseKeepaliveInterval            time.Duration
+	SMTPSenderAddr                       string
+	SMTPSenderUser                       string
+	SMTPSenderPass                       string
+	SMTPSenderFrom                       string
+	SMTPServerListen                     string
+	SMTPServerDomain                     string
+	SMTPServerAddrPrefix                 string
+	MessageLimit                         int
+	MinDelay                             time.Duration
+	MaxDelay                             time.Duration
+	TotalTopicLimit                      int
+	TotalAttachmentSizeLimit             int64
+	VisitorSubscriptionLimit             int
+	VisitorAttachmentTotalSizeLimit      int64
+	VisitorAttachmentDailyBandwidthLimit int
+	VisitorRequestLimitBurst             int
+	VisitorRequestLimitReplenish         time.Duration
+	VisitorEmailLimitBurst               int
+	VisitorEmailLimitReplenish           time.Duration
+	BehindProxy                          bool
 }
 
 // NewConfig instantiates a default new server config
 func NewConfig() *Config {
 	return &Config{
-		BaseURL:                            "",
-		ListenHTTP:                         DefaultListenHTTP,
-		ListenHTTPS:                        "",
-		KeyFile:                            "",
-		CertFile:                           "",
-		FirebaseKeyFile:                    "",
-		CacheFile:                          "",
-		CacheDuration:                      DefaultCacheDuration,
-		AttachmentCacheDir:                 "",
-		AttachmentTotalSizeLimit:           DefaultAttachmentTotalSizeLimit,
-		AttachmentFileSizeLimit:            DefaultAttachmentFileSizeLimit,
-		AttachmentExpiryDuration:           DefaultAttachmentExpiryDuration,
-		KeepaliveInterval:                  DefaultKeepaliveInterval,
-		ManagerInterval:                    DefaultManagerInterval,
-		MessageLimit:                       DefaultMessageLengthLimit,
-		MinDelay:                           DefaultMinDelay,
-		MaxDelay:                           DefaultMaxDelay,
-		AtSenderInterval:                   DefaultAtSenderInterval,
-		FirebaseKeepaliveInterval:          DefaultFirebaseKeepaliveInterval,
-		TotalTopicLimit:                    DefaultTotalTopicLimit,
-		VisitorSubscriptionLimit:           DefaultVisitorSubscriptionLimit,
-		VisitorAttachmentTotalSizeLimit:    DefaultVisitorAttachmentTotalSizeLimit,
-		VisitorAttachmentDailyTrafficLimit: DefaultVisitorAttachmentDailyTrafficLimit,
-		VisitorRequestLimitBurst:           DefaultVisitorRequestLimitBurst,
-		VisitorRequestLimitReplenish:       DefaultVisitorRequestLimitReplenish,
-		VisitorEmailLimitBurst:             DefaultVisitorEmailLimitBurst,
-		VisitorEmailLimitReplenish:         DefaultVisitorEmailLimitReplenish,
-		BehindProxy:                        false,
+		BaseURL:                              "",
+		ListenHTTP:                           DefaultListenHTTP,
+		ListenHTTPS:                          "",
+		KeyFile:                              "",
+		CertFile:                             "",
+		FirebaseKeyFile:                      "",
+		CacheFile:                            "",
+		CacheDuration:                        DefaultCacheDuration,
+		AttachmentCacheDir:                   "",
+		AttachmentTotalSizeLimit:             DefaultAttachmentTotalSizeLimit,
+		AttachmentFileSizeLimit:              DefaultAttachmentFileSizeLimit,
+		AttachmentExpiryDuration:             DefaultAttachmentExpiryDuration,
+		KeepaliveInterval:                    DefaultKeepaliveInterval,
+		ManagerInterval:                      DefaultManagerInterval,
+		MessageLimit:                         DefaultMessageLengthLimit,
+		MinDelay:                             DefaultMinDelay,
+		MaxDelay:                             DefaultMaxDelay,
+		AtSenderInterval:                     DefaultAtSenderInterval,
+		FirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval,
+		TotalTopicLimit:                      DefaultTotalTopicLimit,
+		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit,
+		VisitorAttachmentTotalSizeLimit:      DefaultVisitorAttachmentTotalSizeLimit,
+		VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
+		VisitorRequestLimitBurst:             DefaultVisitorRequestLimitBurst,
+		VisitorRequestLimitReplenish:         DefaultVisitorRequestLimitReplenish,
+		VisitorEmailLimitBurst:               DefaultVisitorEmailLimitBurst,
+		VisitorEmailLimitReplenish:           DefaultVisitorEmailLimitReplenish,
+		BehindProxy:                          false,
 	}
 }

+ 14 - 14
server/server.go

@@ -123,11 +123,6 @@ var (
 	docsStaticFs     embed.FS
 	docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
 
-	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
-	errHTTPTooManyRequestsLimitRequests              = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
-	errHTTPTooManyRequestsLimitEmails                = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
-	errHTTPTooManyRequestsLimitSubscriptions         = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
-	errHTTPTooManyRequestsLimitTotalTopics           = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
 	errHTTPBadRequestEmailDisabled                   = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
 	errHTTPBadRequestDelayNoCache                    = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
 	errHTTPBadRequestDelayNoEmail                    = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
@@ -139,22 +134,27 @@ var (
 	errHTTPBadRequestTopicInvalid                    = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
 	errHTTPBadRequestTopicDisallowed                 = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
 	errHTTPBadRequestMessageNotUTF8                  = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
-	errHTTPBadRequestAttachmentTooLarge              = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or traffic limit reached", ""}
+	errHTTPBadRequestAttachmentTooLarge              = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
 	errHTTPBadRequestAttachmentURLInvalid            = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""}
 	errHTTPBadRequestAttachmentURLPeakGeneral        = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachment URL peak failed", ""}
 	errHTTPBadRequestAttachmentURLPeakNon2xx         = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment URL peak failed with non-2xx status code", ""}
 	errHTTPBadRequestAttachmentsDisallowed           = &errHTTP{40016, http.StatusBadRequest, "invalid request: attachments not allowed", ""}
 	errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40017, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""}
-	errHTTPTooManyRequestsAttachmentTrafficLimit     = &errHTTP{42901, http.StatusTooManyRequests, "too many requests: daily traffic limit reached", "https://ntfy.sh/docs/publish/#limitations"}
+	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
+	errHTTPTooManyRequestsLimitRequests              = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
+	errHTTPTooManyRequestsLimitEmails                = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
+	errHTTPTooManyRequestsLimitSubscriptions         = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
+	errHTTPTooManyRequestsLimitTotalTopics           = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
+	errHTTPTooManyRequestsAttachmentBandwidthLimit   = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
 	errHTTPInternalError                             = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
 	errHTTPInternalErrorInvalidFilePath              = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
 )
 
 const (
-	firebaseControlTopic     = "~control" // See Android if changed
-	emptyMessageBody         = "triggered"
-	fcmMessageLimit          = 4000 // see maybeTruncateFCMMessage for details
-	defaultAttachmentMessage = "You received a file: %s"
+	firebaseControlTopic     = "~control"                // See Android if changed
+	emptyMessageBody         = "triggered"               // Used if message body is empty
+	defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
+	fcmMessageLimit          = 4000                      // see maybeTruncateFCMMessage for details
 )
 
 // New instantiates a new Server. It creates the cache and adds a Firebase
@@ -432,8 +432,8 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
 	if err != nil {
 		return errHTTPNotFound
 	}
-	if err := v.TrafficLimiter().Allow(stat.Size()); err != nil {
-		return errHTTPTooManyRequestsAttachmentTrafficLimit
+	if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
+		return errHTTPTooManyRequestsAttachmentBandwidthLimit
 	}
 	w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
 	f, err := os.Open(file)
@@ -652,7 +652,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
 	if m.Message == "" {
 		m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
 	}
-	m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.TrafficLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize))
+	m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize))
 	if err == util.ErrLimitReached {
 		return errHTTPBadRequestAttachmentTooLarge
 	} else if err != nil {

+ 5 - 5
server/server_test.go

@@ -844,11 +844,11 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
 	require.Equal(t, 404, response.Code)
 }
 
-func TestServer_PublishAttachmentTrafficLimit(t *testing.T) {
+func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
 	content := util.RandomString(5000) // > 4096
 
 	c := newTestConfig(t)
-	c.VisitorAttachmentDailyTrafficLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads
+	c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads
 	s := newTestServer(t, c)
 
 	// Publish attachment
@@ -868,14 +868,14 @@ func TestServer_PublishAttachmentTrafficLimit(t *testing.T) {
 	response = request(t, s, "GET", path, "", nil)
 	err := toHTTPError(t, response.Body.String())
 	require.Equal(t, 429, response.Code)
-	require.Equal(t, 42901, err.Code)
+	require.Equal(t, 42905, err.Code)
 }
 
-func TestServer_PublishAttachmentTrafficLimitUploadOnly(t *testing.T) {
+func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
 	content := util.RandomString(5000) // > 4096
 
 	c := newTestConfig(t)
-	c.VisitorAttachmentDailyTrafficLimit = 5*5000 + 500 // 5 successful uploads
+	c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads
 	s := newTestServer(t, c)
 
 	// 5 successful uploads

+ 4 - 4
server/visitor.go

@@ -26,7 +26,7 @@ type visitor struct {
 	requests      *rate.Limiter
 	emails        *rate.Limiter
 	subscriptions util.Limiter
-	traffic       util.Limiter
+	bandwidth     util.Limiter
 	seen          time.Time
 	mu            sync.Mutex
 }
@@ -38,7 +38,7 @@ func newVisitor(conf *Config, ip string) *visitor {
 		requests:      rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
 		emails:        rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
 		subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
-		traffic:       util.NewBytesLimiter(conf.VisitorAttachmentDailyTrafficLimit, 24*time.Hour),
+		bandwidth:     util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
 		seen:          time.Now(),
 	}
 }
@@ -82,8 +82,8 @@ func (v *visitor) Keepalive() {
 	v.seen = time.Now()
 }
 
-func (v *visitor) TrafficLimiter() util.Limiter {
-	return v.traffic
+func (v *visitor) BandwidthLimiter() util.Limiter {
+	return v.bandwidth
 }
 
 func (v *visitor) Stale() bool {