Philipp Heckel пре 4 година
родитељ
комит
034c81288c

+ 1 - 1
cmd/publish.go

@@ -25,7 +25,7 @@ var cmdPublish = &cli.Command{
 		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
 		&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: "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: "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: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"},
 		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an 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.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-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},

+ 44 - 37
docs/config.md

@@ -36,13 +36,13 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri
 [`since=` parameter](subscribe/api.md#fetch-cached-messages).
 [`since=` parameter](subscribe/api.md#fetch-cached-messages).
 
 
 ## Attachments
 ## Attachments
-If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments-send-files). To enable
+If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable
 this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). 
 this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). 
 Once these options are set and the directory is writable by the server user, you can upload attachments via PUT.
 Once these options are set and the directory is writable by the server user, you can upload attachments via PUT.
 
 
-By default, attachments are stored in the disk-case **for only 3 hours**. The main reason for this is to avoid legal issues
-and such when hosting user controlled content. Typically, this is more than enough time for the user (or the phone) to download 
-the file. The following config options are relevant to attachments:
+By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues
+and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download 
+feature) to download the file. The following config options are relevant to attachments:
 
 
 * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs
 * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs
 * `attachment-cache-dir` is the cache directory for attached files
 * `attachment-cache-dir` is the cache directory for attached files
@@ -356,8 +356,15 @@ request every 10s (defined by `visitor-request-limit-replenish`)
 * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
 * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
 
 
 ### Attachment limits
 ### Attachment limits
+Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant 
+per-visitor limits:
 
 
-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
+* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M.
+  The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`, 
+  see [publishing docs](publish.md#attachments)) do not count here. 
+* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor, 
+  including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in
+  most cloud providers. This defaults to 500M.
 
 
 ### E-mail limits
 ### E-mail limits
 Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications) 
 Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications) 
@@ -470,38 +477,38 @@ Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `l
 CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
 CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
 variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 
 
-| Config option | Env variable | Format | Default | Description |
-|---|---|---|---|---|
-| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
-| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
-| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
-| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
-| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
-| `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. |
-| `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 |
-| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
-| `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-` |
-| `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-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
-| `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. |
+| Config option                              | Env variable                                    | Format           | Default | Description                                                                                                                                                                                                                     |
+|--------------------------------------------|-------------------------------------------------|------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*            | -       | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  |
+| `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`    | `:80`   | Listen address for the HTTP web server                                                                                                                                                                                          |
+| `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`    | -       | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               |
+| `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*       | -       | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 |
+| `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*       | -       | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 |
+| `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`.                                                                                               |
+| `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                                                                                                                                                                           |
+| `smtp-sender-from`                         | `NTFY_SMTP_SENDER_FROM`                         | *e-mail address* | -       | SMTP sender e-mail address; only used if e-mail sending is enabled                                                                                                                                                              |
+| `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    | Rate limiting: 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    | Rate limiting: 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      | Rate limiting: 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     | Rate limiting: 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      | Rate limiting:Initial limit of e-mails per visitor                                                                                                                                                                              |
+| `visitor-email-limit-replenish`            | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH`            | *duration*       | 1h      | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled                                                                                                                        |
 
 
 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.
 The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.

+ 114 - 38
docs/publish.md

@@ -659,26 +659,33 @@ Here's an example that will open Reddit when the notification is clicked:
     ]));
     ]));
     ```
     ```
 
 
-## Attachments (send files)
+## Attachments
 You can send images and other files to your phone as attachments to a notification. The attachments are then downloaded
 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.
 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.  
+There are two different ways to send attachments: 
 
 
-**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. 
+* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3`
+* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` 
 
 
-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`). 
+### Attach local file
+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 (4,096 bytes) 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. To send smaller text-only messages or files 
+as attachments, you must pass a filename 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:
+By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor). 
+Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app
+to auto-download it. Please also check out the [other limits below](#limitations).
 
 
+Here's an example showing how to upload an image:
 
 
 === "Command line (curl)"
 === "Command line (curl)"
     ```
     ```
     curl \
     curl \
         -T flower.jpg \
         -T flower.jpg \
+        -H "Filename: flower.jpg" \
         ntfy.sh/flowers
         ntfy.sh/flowers
     ```
     ```
 
 
@@ -693,6 +700,7 @@ Here's an example showing how to upload an image:
     ``` http
     ``` http
     PUT /flowers HTTP/1.1
     PUT /flowers HTTP/1.1
     Host: ntfy.sh
     Host: ntfy.sh
+    Filename: flower.jpg
 
 
     <binary JPEG data>
     <binary JPEG data>
     ```
     ```
@@ -701,7 +709,8 @@ Here's an example showing how to upload an image:
     ``` javascript
     ``` javascript
     fetch('https://ntfy.sh/flowers', {
     fetch('https://ntfy.sh/flowers', {
         method: 'PUT',
         method: 'PUT',
-        body: document.getElementById("file").files[0]
+        body: document.getElementById("file").files[0],
+        headers: { 'Filename': 'flower.jpg' }
     })
     })
     ```
     ```
 
 
@@ -709,44 +718,108 @@ Here's an example showing how to upload an image:
     ``` go
     ``` go
     file, _ := os.Open("flower.jpg")
     file, _ := os.Open("flower.jpg")
     req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
     req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
+    req.Header.Set("Filename", "flower.jpg")
     http.DefaultClient.Do(req)
     http.DefaultClient.Do(req)
     ```
     ```
 
 
 === "Python"
 === "Python"
     ``` python
     ``` python
     requests.put("https://ntfy.sh/flowers",
     requests.put("https://ntfy.sh/flowers",
-        data=open("flower.jpg", 'rb'))
+        data=open("flower.jpg", 'rb'),
+        headers={ "Filename": "flower.jpg" })
     ```
     ```
 
 
 === "PHP"
 === "PHP"
     ``` php-inline
     ``` php-inline
-    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+    file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([
         'http' => [
         'http' => [
             'method' => 'PUT',
             'method' => 'PUT',
-            'content' => XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx 
+            'header' =>
+                "Content-Type: application/octet-stream\r\n" . // Does not matter
+                "Filename: flower.jpg",
+            'content' => file_get_contents('flower.jpg') // Dangerous for large files 
         ]
         ]
     ]));
     ]));
     ```
     ```
 
 
-```
-- Uploaded attachment
-- External attachment
-- Preview without attachment 
+Here's what that looks like on Android:
 
 
+<figure markdown>
+  ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 }
+  <figcaption>Image attachment sent from a local file</figcaption>
+</figure>
 
 
-# Upload and send attachment with custom message and filename
-curl \
-    -T flower.jpg \
-    -H "Message: Here's a flower for you" \
-    -H "Filename: flower.jpg" \
-    ntfy.sh/howdy
+### Attach file from a URL
+Instead of sending a local file to your phone, you can use an external URL to specify where the attachment is hosted.
+This could be a Google Drive or Dropbox link, or any other publicly available URL. The ntfy server will briefly probe
+the URL to retrieve type and size for you. Since the files are externally hosted, the expiration or size limits from 
+above do not apply here.
 
 
-# Send external attachment from other URL, with custom message 
-curl \
-    -H "Attachment: https://example.com/files.zip" \
-    "ntfy.sh/howdy?m=Important+documents+attached"
+To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
+to specify the attachment URL. It can be any type of file.
+
+Here's an example showing how to upload an image:
+
+=== "Command line (curl)"
+    ```
+    curl \
+        -X POST \
+        -H "Attach: https://f-droid.org/F-Droid.apk" \
+        ntfy.sh/mydownloads
+    ```
+
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --attach="https://f-droid.org/F-Droid.apk" \
+        mydownloads
+    ```
+
+=== "HTTP"
+    ``` http
+    POST /mydownloads HTTP/1.1
+    Host: ntfy.sh
+    Attach: https://f-droid.org/F-Droid.apk
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/mydownloads', {
+        method: 'POST',
+        headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' }
+    })
+    ```
+
+=== "Go"
+    ``` go
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file)
+    req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk")
+    http.DefaultClient.Do(req)
+    ```
+
+=== "Python"
+    ``` python
+    requests.put("https://ntfy.sh/mydownloads",
+        headers={ "Attach": "https://f-droid.org/F-Droid.apk" })
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([
+        'http' => [
+        'method' => 'PUT',
+        'header' =>
+            "Content-Type: text/plain\r\n" . // Does not matter
+            "Attach: https://f-droid.org/F-Droid.apk",
+        ]
+    ]));
+    ```
+
+<figure markdown>
+  ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 }
+  <figcaption>File attachment sent from an external URL</figcaption>
+</figure>
 
 
-```
 
 
 ## E-mail notifications
 ## E-mail notifications
 You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that 
 You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that 
@@ -1029,17 +1102,20 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb
 option is equivalent to `Firebase: no`, but was introduced to allow future flexibility.
 option is equivalent to `Firebase: no`, but was introduced to allow future flexibility.
 
 
 ## Limitations
 ## 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,
+There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings 
+are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
 but just in case, let's list them all:
 but just in case, let's list them all:
 
 
-| Limit | Description |
-|---|---|
-| **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. |
-| **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. |
+| Limit                     | Description                                                                                                                                                               |
+|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Message length**        | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments).                                                                   |
+| **Requests**              | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. |
+| **E-mails**               | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour.          |
+| **Subscription limit**    | By default, the server allows each visitor to keep 30 connections to the server open.                                                                                     |
+| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors.                                      |
+| **Attachment expiry**     | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit.                                              |
+| **Attachment 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
 ## 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**,
 The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
@@ -1053,8 +1129,8 @@ 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-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-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-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
-| `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-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
+| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) 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-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-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
 | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
 | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |

BIN
docs/static/img/android-screenshot-attachment-file.png


BIN
docs/static/img/android-screenshot-attachment-image.png


+ 2 - 1
server/cache_mem.go

@@ -131,7 +131,8 @@ func (c *memCache) AttachmentsSize(owner string) (int64, error) {
 	var size int64
 	var size int64
 	for topic := range c.messages {
 	for topic := range c.messages {
 		for _, m := range c.messages[topic] {
 		for _, m := range c.messages[topic] {
-			if m.Attachment != nil && m.Attachment.Owner == owner {
+			counted := m.Attachment != nil && m.Attachment.Owner == owner && m.Attachment.Expires > time.Now().Unix()
+			if counted {
 				size += m.Attachment.Size
 				size += m.Attachment.Size
 			}
 			}
 		}
 		}

+ 4 - 0
server/cache_mem_test.go

@@ -25,6 +25,10 @@ func TestMemCache_Prune(t *testing.T) {
 	testCachePrune(t, newMemCache())
 	testCachePrune(t, newMemCache())
 }
 }
 
 
+func TestMemCache_Attachments(t *testing.T) {
+	testCacheAttachments(t, newMemCache())
+}
+
 func TestMemCache_NopCache(t *testing.T) {
 func TestMemCache_NopCache(t *testing.T) {
 	c := newNopCache()
 	c := newNopCache()
 	assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
 	assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))

+ 4 - 0
server/cache_sqlite_test.go

@@ -29,6 +29,10 @@ func TestSqliteCache_Prune(t *testing.T) {
 	testCachePrune(t, newSqliteTestCache(t))
 	testCachePrune(t, newSqliteTestCache(t))
 }
 }
 
 
+func TestSqliteCache_Attachments(t *testing.T) {
+	testCacheAttachments(t, newSqliteTestCache(t))
+}
+
 func TestSqliteCache_Migration_From0(t *testing.T) {
 func TestSqliteCache_Migration_From0(t *testing.T) {
 	filename := newSqliteTestCacheFile(t)
 	filename := newSqliteTestCacheFile(t)
 	db, err := sql.Open("sqlite3", filename)
 	db, err := sql.Open("sqlite3", filename)

+ 130 - 57
server/cache_test.go

@@ -1,7 +1,7 @@
 package server
 package server
 
 
 import (
 import (
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"testing"
 	"testing"
 	"time"
 	"time"
 )
 )
@@ -13,71 +13,71 @@ func testCacheMessages(t *testing.T, c cache) {
 	m2 := newDefaultMessage("mytopic", "my other message")
 	m2 := newDefaultMessage("mytopic", "my other message")
 	m2.Time = 2
 	m2.Time = 2
 
 
-	assert.Nil(t, c.AddMessage(m1))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
-	assert.Nil(t, c.AddMessage(m2))
+	require.Nil(t, c.AddMessage(m1))
+	require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
+	require.Nil(t, c.AddMessage(m2))
 
 
 	// Adding invalid
 	// Adding invalid
-	assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
-	assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example")))      // These should not be added!
+	require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
+	require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example")))      // These should not be added!
 
 
 	// mytopic: count
 	// mytopic: count
 	count, err := c.MessageCount("mytopic")
 	count, err := c.MessageCount("mytopic")
-	assert.Nil(t, err)
-	assert.Equal(t, 2, count)
+	require.Nil(t, err)
+	require.Equal(t, 2, count)
 
 
 	// mytopic: since all
 	// mytopic: since all
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false)
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false)
-	assert.Equal(t, 2, len(messages))
-	assert.Equal(t, "my message", messages[0].Message)
-	assert.Equal(t, "mytopic", messages[0].Topic)
-	assert.Equal(t, messageEvent, messages[0].Event)
-	assert.Equal(t, "", messages[0].Title)
-	assert.Equal(t, 0, messages[0].Priority)
-	assert.Nil(t, messages[0].Tags)
-	assert.Equal(t, "my other message", messages[1].Message)
+	require.Equal(t, 2, len(messages))
+	require.Equal(t, "my message", messages[0].Message)
+	require.Equal(t, "mytopic", messages[0].Topic)
+	require.Equal(t, messageEvent, messages[0].Event)
+	require.Equal(t, "", messages[0].Title)
+	require.Equal(t, 0, messages[0].Priority)
+	require.Nil(t, messages[0].Tags)
+	require.Equal(t, "my other message", messages[1].Message)
 
 
 	// mytopic: since none
 	// mytopic: since none
 	messages, _ = c.Messages("mytopic", sinceNoMessages, false)
 	messages, _ = c.Messages("mytopic", sinceNoMessages, false)
-	assert.Empty(t, messages)
+	require.Empty(t, messages)
 
 
 	// mytopic: since 2
 	// mytopic: since 2
 	messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
 	messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
-	assert.Equal(t, 1, len(messages))
-	assert.Equal(t, "my other message", messages[0].Message)
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "my other message", messages[0].Message)
 
 
 	// example: count
 	// example: count
 	count, err = c.MessageCount("example")
 	count, err = c.MessageCount("example")
-	assert.Nil(t, err)
-	assert.Equal(t, 1, count)
+	require.Nil(t, err)
+	require.Equal(t, 1, count)
 
 
 	// example: since all
 	// example: since all
 	messages, _ = c.Messages("example", sinceAllMessages, false)
 	messages, _ = c.Messages("example", sinceAllMessages, false)
-	assert.Equal(t, "my example message", messages[0].Message)
+	require.Equal(t, "my example message", messages[0].Message)
 
 
 	// non-existing: count
 	// non-existing: count
 	count, err = c.MessageCount("doesnotexist")
 	count, err = c.MessageCount("doesnotexist")
-	assert.Nil(t, err)
-	assert.Equal(t, 0, count)
+	require.Nil(t, err)
+	require.Equal(t, 0, count)
 
 
 	// non-existing: since all
 	// non-existing: since all
 	messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
 	messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
-	assert.Empty(t, messages)
+	require.Empty(t, messages)
 }
 }
 
 
 func testCacheTopics(t *testing.T, c cache) {
 func testCacheTopics(t *testing.T, c cache) {
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
 
 
 	topics, err := c.Topics()
 	topics, err := c.Topics()
 	if err != nil {
 	if err != nil {
 		t.Fatal(err)
 		t.Fatal(err)
 	}
 	}
-	assert.Equal(t, 2, len(topics))
-	assert.Equal(t, "topic1", topics["topic1"].ID)
-	assert.Equal(t, "topic2", topics["topic2"].ID)
+	require.Equal(t, 2, len(topics))
+	require.Equal(t, "topic1", topics["topic1"].ID)
+	require.Equal(t, "topic2", topics["topic2"].ID)
 }
 }
 
 
 func testCachePrune(t *testing.T, c cache) {
 func testCachePrune(t *testing.T, c cache) {
@@ -90,23 +90,23 @@ func testCachePrune(t *testing.T, c cache) {
 	m3 := newDefaultMessage("another_topic", "and another one")
 	m3 := newDefaultMessage("another_topic", "and another one")
 	m3.Time = 1
 	m3.Time = 1
 
 
-	assert.Nil(t, c.AddMessage(m1))
-	assert.Nil(t, c.AddMessage(m2))
-	assert.Nil(t, c.AddMessage(m3))
-	assert.Nil(t, c.Prune(time.Unix(2, 0)))
+	require.Nil(t, c.AddMessage(m1))
+	require.Nil(t, c.AddMessage(m2))
+	require.Nil(t, c.AddMessage(m3))
+	require.Nil(t, c.Prune(time.Unix(2, 0)))
 
 
 	count, err := c.MessageCount("mytopic")
 	count, err := c.MessageCount("mytopic")
-	assert.Nil(t, err)
-	assert.Equal(t, 1, count)
+	require.Nil(t, err)
+	require.Equal(t, 1, count)
 
 
 	count, err = c.MessageCount("another_topic")
 	count, err = c.MessageCount("another_topic")
-	assert.Nil(t, err)
-	assert.Equal(t, 0, count)
+	require.Nil(t, err)
+	require.Equal(t, 0, count)
 
 
 	messages, err := c.Messages("mytopic", sinceAllMessages, false)
 	messages, err := c.Messages("mytopic", sinceAllMessages, false)
-	assert.Nil(t, err)
-	assert.Equal(t, 1, len(messages))
-	assert.Equal(t, "my other message", messages[0].Message)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "my other message", messages[0].Message)
 }
 }
 
 
 func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
 func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
@@ -114,12 +114,12 @@ func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
 	m.Tags = []string{"tag1", "tag2"}
 	m.Tags = []string{"tag1", "tag2"}
 	m.Priority = 5
 	m.Priority = 5
 	m.Title = "some title"
 	m.Title = "some title"
-	assert.Nil(t, c.AddMessage(m))
+	require.Nil(t, c.AddMessage(m))
 
 
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false)
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false)
-	assert.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
-	assert.Equal(t, 5, messages[0].Priority)
-	assert.Equal(t, "some title", messages[0].Title)
+	require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
+	require.Equal(t, 5, messages[0].Priority)
+	require.Equal(t, "some title", messages[0].Title)
 }
 }
 
 
 func testCacheMessagesScheduled(t *testing.T, c cache) {
 func testCacheMessagesScheduled(t *testing.T, c cache) {
@@ -130,20 +130,93 @@ func testCacheMessagesScheduled(t *testing.T, c cache) {
 	m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
 	m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
 	m4 := newDefaultMessage("mytopic2", "message 4")
 	m4 := newDefaultMessage("mytopic2", "message 4")
 	m4.Time = time.Now().Add(time.Minute).Unix()
 	m4.Time = time.Now().Add(time.Minute).Unix()
-	assert.Nil(t, c.AddMessage(m1))
-	assert.Nil(t, c.AddMessage(m2))
-	assert.Nil(t, c.AddMessage(m3))
+	require.Nil(t, c.AddMessage(m1))
+	require.Nil(t, c.AddMessage(m2))
+	require.Nil(t, c.AddMessage(m3))
 
 
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
-	assert.Equal(t, 1, len(messages))
-	assert.Equal(t, "message 1", messages[0].Message)
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "message 1", messages[0].Message)
 
 
 	messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
 	messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
-	assert.Equal(t, 3, len(messages))
-	assert.Equal(t, "message 1", messages[0].Message)
-	assert.Equal(t, "message 3", messages[1].Message) // Order!
-	assert.Equal(t, "message 2", messages[2].Message)
+	require.Equal(t, 3, len(messages))
+	require.Equal(t, "message 1", messages[0].Message)
+	require.Equal(t, "message 3", messages[1].Message) // Order!
+	require.Equal(t, "message 2", messages[2].Message)
 
 
 	messages, _ = c.MessagesDue()
 	messages, _ = c.MessagesDue()
-	assert.Empty(t, messages)
+	require.Empty(t, messages)
+}
+
+func testCacheAttachments(t *testing.T, c cache) {
+	expires1 := time.Now().Add(-4 * time.Hour).Unix()
+	m := newDefaultMessage("mytopic", "flower for you")
+	m.ID = "m1"
+	m.Attachment = &attachment{
+		Name:    "flower.jpg",
+		Type:    "image/jpeg",
+		Size:    5000,
+		Expires: expires1,
+		URL:     "https://ntfy.sh/file/AbDeFgJhal.jpg",
+		Owner:   "1.2.3.4",
+	}
+	require.Nil(t, c.AddMessage(m))
+
+	expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
+	m = newDefaultMessage("mytopic", "sending you a car")
+	m.ID = "m2"
+	m.Attachment = &attachment{
+		Name:    "car.jpg",
+		Type:    "image/jpeg",
+		Size:    10000,
+		Expires: expires2,
+		URL:     "https://ntfy.sh/file/aCaRURL.jpg",
+		Owner:   "1.2.3.4",
+	}
+	require.Nil(t, c.AddMessage(m))
+
+	expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
+	m = newDefaultMessage("another-topic", "sending you another car")
+	m.ID = "m3"
+	m.Attachment = &attachment{
+		Name:    "another-car.jpg",
+		Type:    "image/jpeg",
+		Size:    20000,
+		Expires: expires3,
+		URL:     "https://ntfy.sh/file/zakaDHFW.jpg",
+		Owner:   "1.2.3.4",
+	}
+	require.Nil(t, c.AddMessage(m))
+
+	messages, err := c.Messages("mytopic", sinceAllMessages, false)
+	require.Nil(t, err)
+	require.Equal(t, 2, len(messages))
+
+	require.Equal(t, "flower for you", messages[0].Message)
+	require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
+	require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
+	require.Equal(t, int64(5000), messages[0].Attachment.Size)
+	require.Equal(t, expires1, messages[0].Attachment.Expires)
+	require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
+	require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
+
+	require.Equal(t, "sending you a car", messages[1].Message)
+	require.Equal(t, "car.jpg", messages[1].Attachment.Name)
+	require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
+	require.Equal(t, int64(10000), messages[1].Attachment.Size)
+	require.Equal(t, expires2, messages[1].Attachment.Expires)
+	require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
+	require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
+
+	size, err := c.AttachmentsSize("1.2.3.4")
+	require.Nil(t, err)
+	require.Equal(t, int64(30000), size)
+
+	size, err = c.AttachmentsSize("5.6.7.8")
+	require.Nil(t, err)
+	require.Equal(t, int64(0), size)
+
+	ids, err := c.AttachmentsExpired()
+	require.Nil(t, err)
+	require.Equal(t, []string{"m1"}, ids)
 }
 }

+ 25 - 5
server/server.yml

@@ -36,7 +36,7 @@
 #
 #
 # You can disable the cache entirely by setting this to 0.
 # You can disable the cache entirely by setting this to 0.
 #
 #
-# cache-duration: 12h
+# cache-duration: "12h"
 
 
 # If set, the X-Forwarded-For header is used to determine the visitor IP address
 # If set, the X-Forwarded-For header is used to determine the visitor IP address
 # instead of the remote address of the connection.
 # instead of the remote address of the connection.
@@ -46,6 +46,19 @@
 #
 #
 # behind-proxy: false
 # behind-proxy: false
 
 
+# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
+# are "attachment-cache-dir" and "base-url".
+#
+# - attachment-cache-dir is the cache directory for attached files
+# - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size)
+# - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M)
+# - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h)
+#
+# attachment-cache-dir:
+# attachment-total-size-limit: "5G"
+# attachment-file-size-limit: "15M"
+# attachment-expiry-duration: "3h"
+
 # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
 # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
 # messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
 # messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
 # SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
 # SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
@@ -78,12 +91,12 @@
 #
 #
 # Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
 # Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
 #
 #
-# keepalive-interval: 30s
+# keepalive-interval: "30s"
 
 
 # Interval in which the manager prunes old messages, deletes topics
 # Interval in which the manager prunes old messages, deletes topics
 # and prints the stats.
 # and prints the stats.
 #
 #
-# manager-interval: 1m
+# manager-interval: "1m"
 
 
 # Rate limiting: Total number of topics before the server rejects new topics.
 # Rate limiting: Total number of topics before the server rejects new topics.
 #
 #
@@ -98,11 +111,18 @@
 # - visitor-request-limit-replenish is the rate at which the bucket is refilled
 # - visitor-request-limit-replenish is the rate at which the bucket is refilled
 #
 #
 # visitor-request-limit-burst: 60
 # visitor-request-limit-burst: 60
-# visitor-request-limit-replenish: 10s
+# visitor-request-limit-replenish: "10s"
 
 
 # Rate limiting: Allowed emails per visitor:
 # Rate limiting: Allowed emails per visitor:
 # - visitor-email-limit-burst is the initial bucket of emails each visitor has
 # - visitor-email-limit-burst is the initial bucket of emails each visitor has
 # - visitor-email-limit-replenish is the rate at which the bucket is refilled
 # - visitor-email-limit-replenish is the rate at which the bucket is refilled
 #
 #
 # visitor-email-limit-burst: 16
 # visitor-email-limit-burst: 16
-# visitor-email-limit-replenish: 1h
+# visitor-email-limit-replenish: "1h"
+
+# Rate limiting: Attachment size and bandwidth limits per visitor:
+# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
+# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
+#
+# visitor-attachment-total-size-limit: "100M"
+# visitor-attachment-daily-bandwidth-limit: "500M"

+ 22 - 2
server/server_test.go

@@ -699,12 +699,21 @@ func TestServer_PublishAttachment(t *testing.T) {
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, "5000", response.Header().Get("Content-Length"))
 	require.Equal(t, "5000", response.Header().Get("Content-Length"))
 	require.Equal(t, content, response.Body.String())
 	require.Equal(t, content, response.Body.String())
+
+	// Slightly unrelated cross-test: make sure we add an owner for internal attachments
+	size, err := s.cache.AttachmentsSize("9.9.9.9") // See request()
+	require.Nil(t, err)
+	require.Equal(t, int64(5000), size)
 }
 }
 
 
 func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
 func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
-	s := newTestServer(t, newTestConfig(t))
+	c := newTestConfig(t)
+	c.BehindProxy = true
+	s := newTestServer(t, c)
 	content := "this is an ATTACHMENT"
 	content := "this is an ATTACHMENT"
-	response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil)
+	response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, map[string]string{
+		"X-Forwarded-For": "1.2.3.4",
+	})
 	msg := toMessage(t, response.Body.String())
 	msg := toMessage(t, response.Body.String())
 	require.Equal(t, "myfile.txt", msg.Attachment.Name)
 	require.Equal(t, "myfile.txt", msg.Attachment.Name)
 	require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
 	require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
@@ -719,6 +728,11 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, "21", response.Header().Get("Content-Length"))
 	require.Equal(t, "21", response.Header().Get("Content-Length"))
 	require.Equal(t, content, response.Body.String())
 	require.Equal(t, content, response.Body.String())
+
+	// Slightly unrelated cross-test: make sure we add an owner for internal attachments
+	size, err := s.cache.AttachmentsSize("1.2.3.4")
+	require.Nil(t, err)
+	require.Equal(t, int64(21), size)
 }
 }
 
 
 func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
 func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
@@ -734,6 +748,11 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
 	require.Equal(t, int64(0), msg.Attachment.Expires)
 	require.Equal(t, int64(0), msg.Attachment.Expires)
 	require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL)
 	require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL)
 	require.Equal(t, "", msg.Attachment.Owner)
 	require.Equal(t, "", msg.Attachment.Owner)
+
+	// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
+	size, err := s.cache.AttachmentsSize("127.0.0.1")
+	require.Nil(t, err)
+	require.Equal(t, int64(0), size)
 }
 }
 
 
 func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
 func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
@@ -914,6 +933,7 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri
 	if err != nil {
 	if err != nil {
 		t.Fatal(err)
 		t.Fatal(err)
 	}
 	}
+	req.RemoteAddr = "9.9.9.9" // Used for tests
 	for k, v := range headers {
 	for k, v := range headers {
 		req.Header.Set(k, v)
 		req.Header.Set(k, v)
 	}
 	}