Browse Source

Webhooks (#55), more tests (#35) and python examples (#50)

Philipp Heckel 3 years ago
parent
commit
534b93e142
10 changed files with 339 additions and 24 deletions
  1. 1 1
      Makefile
  2. 154 1
      docs/publish.md
  3. 12 0
      examples/publish-python/publish.py
  4. 4 6
      go.mod
  5. 8 11
      go.sum
  6. 9 4
      server/server.go
  7. 104 1
      server/server_test.go
  8. 2 0
      tools/fbsend/README.md
  9. 1 0
      util/embedfs/test.txt
  10. 44 0
      util/embedfs_test.go

+ 1 - 1
Makefile

@@ -50,7 +50,7 @@ docs: docs-deps
 check: test fmt-check vet lint staticcheck
 
 test: .PHONY
-	$(GO) test ./...
+	$(GO) test -v ./...
 
 race: .PHONY
 	$(GO) test -race ./...

+ 154 - 1
docs/publish.md

@@ -30,6 +30,12 @@ Here's an example showing how to publish a simple message using a POST request:
         strings.NewReader("Backup successful ๐Ÿ˜€"))
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/mytopic", 
+        data="Backup successful ๐Ÿ˜€".encode(encoding='utf-8'))
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -95,6 +101,17 @@ a [title](#message-title), and [tag messages](#tags-emojis) ๐Ÿฅณ ๐ŸŽ‰. Here's an
 	http.DefaultClient.Do(req)
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/phil_alerts",
+        data="Remote access to phils-laptop detected. Act right away.",
+        headers={
+            "Title": "Unauthorized access detected",
+            "Priority": "urgent",
+            "Tags": "warning,skull"
+        })
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([
@@ -151,6 +168,13 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
     http.DefaultClient.Do(req)
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/controversial",
+        data="Oh my ...",
+        headers={ "Title": "Dogs are better than cats" })
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/controversial', false, stream_context_create([
@@ -217,6 +241,13 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
     http.DefaultClient.Do(req)
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/phil_alerts",
+        data="An urgent message",
+        headers={ "Priority": "5" })
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([
@@ -314,6 +345,13 @@ them with a comma, e.g. `tag1,tag2,tag3`.
     http.DefaultClient.Do(req)
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/backups",
+        data="Backup of mailsrv13 failed",
+        headers={ "Tags": "warning,mailsrv13,daily-backup" })
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
@@ -382,6 +420,13 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
     http.DefaultClient.Do(req)
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/hello",
+        data="Good morning",
+        headers={ "At": "tomorrow, 10am" })
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
@@ -397,7 +442,6 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
 
 Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**):
 
-
 <table class="remove-md-box"><tr>
 <td>
     <table><thead><tr><th><code>Delay/At/In</code> header</th><th>Message will be delivered at</th><th>Explanation</th></tr></thead><tbody>
@@ -411,6 +455,87 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
 </td>
 </tr></table>
 
+## Webhooks (Send via GET) 
+In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use 
+a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
+like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
+
+To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without 
+any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are 
+also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all 
+[supported parameters and headers](#list-of-all-parameters) for details.
+
+For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message 
+(aka trigger the webhook):
+
+=== "Command line (curl)"
+    ```
+    curl ntfy.sh/mywebhook/trigger
+    ```
+
+=== "HTTP"
+    ``` http
+    GET /mywebhook/trigger HTTP/1.1
+    Host: ntfy.sh
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/mywebhook/trigger')
+    ```
+
+=== "Go"
+    ``` go
+    http.Get("https://ntfy.sh/mywebhook/trigger")
+    ```
+
+=== "Python"
+    ``` python
+    requests.get("https://ntfy.sh/mywebhook/trigger")
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/mywebhook/trigger');
+    ```
+
+To add a custom message, simply append the `message=` URL parameter. And of course you can set the 
+[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well. 
+For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters).
+
+Here's an example with a custom message, tags and a priority:
+
+=== "Command line (curl)"
+    ```
+    curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
+    ```
+
+=== "HTTP"
+    ``` http
+    GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1
+    Host: ntfy.sh
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull')
+    ```
+
+=== "Go"
+    ``` go
+    http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
+    ```
+
+=== "Python"
+    ``` python
+    requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
+    ```
+
 ## Advanced features
 
 ### Message caching
@@ -459,6 +584,13 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
     http.DefaultClient.Do(req)
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/mytopic",
+        data="This message won't be stored server-side",
+        headers={ "Cache": "no" })
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -517,6 +649,13 @@ to `no`. This will instruct the server not to forward messages to Firebase.
     http.DefaultClient.Do(req)
     ```
 
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/mytopic",
+        data="This message won't be forwarded to FCM",
+        headers={ "Firebase": "no" })
+    ```
+
 === "PHP"
     ``` php-inline
     file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -529,3 +668,17 @@ to `no`. This will instruct the server not to forward messages to Firebase.
         ]
     ]));
     ```
+
+## 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**,
+and can be passed as **HTTP headers** or **query parameters in the URL**.
+
+| Parameter | Aliases (case-insensitive) | Description |
+|---|---|---|
+| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
+| `X-Title` | `Title`, `t` | [Message title](#message-title) |
+| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
+| `X-Tags` | `Tags`, `ta` | [Tags and emojis](#tags-emojis) |
+| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
+| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
+| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |

+ 12 - 0
examples/publish-python/publish.py

@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+
+import requests
+
+resp = requests.get("https://ntfy.sh/mytopic/trigger",
+    data="Backup successful ๐Ÿ˜€".encode(encoding='utf-8'),
+    headers={
+        "Priority": "high",
+        "Tags": "warning,skull",
+        "Title": "Hello there"
+    })
+resp.raise_for_status()

+ 4 - 6
go.mod

@@ -2,8 +2,6 @@ module heckel.io/ntfy
 
 go 1.17
 
-replace github.com/olebedev/when => github.com/binwiederhier/when v0.0.1-binwiederhier2
-
 require (
 	cloud.google.com/go/firestore v1.6.1 // indirect
 	cloud.google.com/go/storage v1.18.2 // indirect
@@ -11,12 +9,12 @@ require (
 	github.com/BurntSushi/toml v0.4.1 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/mattn/go-sqlite3 v1.14.9
-	github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254
+	github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
 	github.com/stretchr/testify v1.7.0
 	github.com/urfave/cli/v2 v2.3.0
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
 	golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
-	google.golang.org/api v0.62.0
+	google.golang.org/api v0.63.0
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 )
 
@@ -39,12 +37,12 @@ require (
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
 	golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
-	golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
+	golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
-	google.golang.org/grpc v1.42.0 // indirect
+	google.golang.org/grpc v1.43.0 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
 )

+ 8 - 11
go.sum

@@ -25,7 +25,6 @@ cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aD
 cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
 cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
-cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
 cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
 cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -60,8 +59,6 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/binwiederhier/when v0.0.1-binwiederhier2 h1:BjQC7OQI4MK0vXeltn2BEuf0Tdh/M6YNh1JrepnVr2I=
-github.com/binwiederhier/when v0.0.1-binwiederhier2/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
 github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -204,6 +201,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
 github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
+github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -400,8 +399,8 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
-golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
+golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -506,8 +505,8 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr
 google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
 google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
 google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
-google.golang.org/api v0.62.0 h1:PhGymJMXfGBzc4lBRmrx9+1w4w2wEzURHNGF/sD/xGc=
-google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
+google.golang.org/api v0.63.0 h1:n2bqqK895ygnBpdPDYetfy23K7fJ22wsrZKCyfuRkkA=
+google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -577,8 +576,6 @@ google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ6
 google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
@@ -608,8 +605,8 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
 google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
 google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
-google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM=
+google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

+ 9 - 4
server/server.go

@@ -76,7 +76,7 @@ var (
 	jsonRegex  = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
 	sseRegex   = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
 	rawRegex   = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
-	sendRegex  = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(send|trigger)$`)
+	sendRegex  = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
 
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
 	docsRegex        = regexp.MustCompile(`^/docs(|/.*)$`)
@@ -311,13 +311,12 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito
 			return err
 		}
 	}
+	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
 	if err := json.NewEncoder(w).Encode(m); err != nil {
 		return err
 	}
-	s.mu.Lock()
-	s.messages++
-	s.mu.Unlock()
+	s.inc(&s.messages)
 	return nil
 }
 
@@ -691,6 +690,12 @@ func (s *Server) visitor(r *http.Request) *visitor {
 	return v
 }
 
+func (s *Server) inc(counter *int64) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	*counter++
+}
+
 func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, err error) {
 	log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, code, err.Error())
 	w.WriteHeader(code)

+ 104 - 1
server/server_test.go

@@ -4,10 +4,12 @@ import (
 	"bufio"
 	"context"
 	"encoding/json"
+	"fmt"
 	"github.com/stretchr/testify/require"
 	"heckel.io/ntfy/config"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -34,7 +36,7 @@ func TestServer_PublishAndPoll(t *testing.T) {
 	require.Equal(t, "my first message", messages[0].Message)
 	require.Equal(t, "my second\n\nmessage", messages[1].Message)
 
-	response = request(t, s, "GET", "/mytopic/sse?poll=1", "", nil)
+	response = request(t, s, "GET", "/mytopic/sse?poll=1&since=all", "", nil)
 	lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
 	require.Equal(t, 3, len(lines))
 	require.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message)
@@ -132,6 +134,9 @@ func TestServer_StaticSites(t *testing.T) {
 	rr = request(t, s, "HEAD", "/", "", nil)
 	require.Equal(t, 200, rr.Code)
 
+	rr = request(t, s, "OPTIONS", "/", "", nil)
+	require.Equal(t, 200, rr.Code)
+
 	rr = request(t, s, "GET", "/does-not-exist.txt", "", nil)
 	require.Equal(t, 404, rr.Code)
 
@@ -150,6 +155,10 @@ func TestServer_StaticSites(t *testing.T) {
 	require.Equal(t, 200, rr.Code)
 	require.Contains(t, rr.Body.String(), `Made with โค๏ธ by Philipp C. Heckel`)
 	require.Contains(t, rr.Body.String(), `<script src=static/js/extra.js></script>`)
+
+	rr = request(t, s, "GET", "/example.html", "", nil)
+	require.Equal(t, 200, rr.Code)
+	require.Contains(t, rr.Body.String(), "</html>")
 }
 
 func TestServer_PublishLargeMessage(t *testing.T) {
@@ -168,6 +177,34 @@ func TestServer_PublishLargeMessage(t *testing.T) {
 	require.Equal(t, truncated, messages[0].Message)
 }
 
+func TestServer_PublishPriority(t *testing.T) {
+	s := newTestServer(t, newTestConfig(t))
+
+	for prio := 1; prio <= 5; prio++ {
+		response := request(t, s, "GET", fmt.Sprintf("/mytopic/publish?priority=%d", prio), fmt.Sprintf("priority %d", prio), nil)
+		msg := toMessage(t, response.Body.String())
+		require.Equal(t, prio, msg.Priority)
+	}
+
+	response := request(t, s, "GET", "/mytopic/publish?priority=min", "test", nil)
+	require.Equal(t, 1, toMessage(t, response.Body.String()).Priority)
+
+	response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil)
+	require.Equal(t, 2, toMessage(t, response.Body.String()).Priority)
+
+	response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil)
+	require.Equal(t, 3, toMessage(t, response.Body.String()).Priority)
+
+	response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil)
+	require.Equal(t, 4, toMessage(t, response.Body.String()).Priority)
+
+	response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil)
+	require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
+
+	response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil)
+	require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
+}
+
 func TestServer_PublishNoCache(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 
@@ -182,6 +219,7 @@ func TestServer_PublishNoCache(t *testing.T) {
 	messages := toMessages(t, response.Body.String())
 	require.Empty(t, messages)
 }
+
 func TestServer_PublishAt(t *testing.T) {
 	c := newTestConfig(t)
 	c.MinDelay = time.Second
@@ -302,6 +340,59 @@ func TestServer_PublishWithNopCache(t *testing.T) {
 	require.Empty(t, messages)
 }
 
+func TestServer_PublishAndPollSince(t *testing.T) {
+	s := newTestServer(t, newTestConfig(t))
+
+	request(t, s, "PUT", "/mytopic", "test 1", nil)
+	time.Sleep(1100 * time.Millisecond)
+
+	since := time.Now().Unix()
+	request(t, s, "PUT", "/mytopic", "test 2", nil)
+
+	response := request(t, s, "GET", fmt.Sprintf("/mytopic/json?poll=1&since=%d", since), "", nil)
+	messages := toMessages(t, response.Body.String())
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "test 2", messages[0].Message)
+}
+
+func TestServer_PublishViaGET(t *testing.T) {
+	s := newTestServer(t, newTestConfig(t))
+
+	response := request(t, s, "GET", "/mytopic/trigger", "", nil)
+	msg := toMessage(t, response.Body.String())
+	require.NotEmpty(t, msg.ID)
+	require.Equal(t, "triggered", msg.Message)
+
+	response = request(t, s, "GET", "/mytopic/send?message=This+is+a+test&t=This+is+a+title&tags=skull&x-priority=5&delay=24h", "", nil)
+	msg = toMessage(t, response.Body.String())
+	require.NotEmpty(t, msg.ID)
+	require.Equal(t, "This is a test", msg.Message)
+	require.Equal(t, "This is a title", msg.Title)
+	require.Equal(t, []string{"skull"}, msg.Tags)
+	require.Equal(t, 5, msg.Priority)
+	require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())
+}
+
+func TestServer_PublishFirebase(t *testing.T) {
+	// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
+	// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
+	// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ...
+
+	c := newTestConfig(t)
+	c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test!
+	s := newTestServer(t, c)
+
+	// Normal message
+	response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil)
+	msg := toMessage(t, response.Body.String())
+	require.NotEmpty(t, msg.ID)
+
+	// Keepalive message
+	require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic)))
+
+	time.Sleep(500 * time.Millisecond) // Time for sends
+}
+
 func newTestConfig(t *testing.T) *config.Config {
 	conf := config.New(":80")
 	conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
@@ -363,3 +454,15 @@ func toMessage(t *testing.T, s string) *message {
 	require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m))
 	return &m
 }
+
+func firebaseServiceAccountFile(t *testing.T) string {
+	if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
+		return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
+	} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
+		filename := filepath.Join(t.TempDir(), "firebase.json")
+		require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
+		return filename
+	}
+	t.SkipNow()
+	return ""
+}

+ 2 - 0
tools/fbsend/README.md

@@ -0,0 +1,2 @@
+# fbsend
+fbsend is a tiny tool to send data messages to Firebase. It's only used for testing.

+ 1 - 0
util/embedfs/test.txt

@@ -0,0 +1 @@
+This is a test file for embedfs_test.go

+ 44 - 0
util/embedfs_test.go

@@ -0,0 +1,44 @@
+package util
+
+import (
+	"embed"
+	"github.com/stretchr/testify/require"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+)
+
+var (
+	modTime = time.Now()
+
+	//go:embed embedfs
+	testFs       embed.FS
+	testFsCached = &CachingEmbedFS{ModTime: modTime, FS: testFs}
+)
+
+func TestCachingEmbedFS(t *testing.T) {
+	s := http.FileServer(http.FS(testFsCached))
+
+	rr := httptest.NewRecorder()
+	req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
+	s.ServeHTTP(rr, req)
+	require.Equal(t, 200, rr.Code)
+	lastModified := rr.Header().Get("Last-Modified")
+
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest("GET", "/embedfs/test.txt", nil)
+	req.Header.Set("If-Modified-Since", lastModified)
+	s.ServeHTTP(rr, req)
+	require.Equal(t, 304, rr.Code) // Huzzah!
+}
+
+func TestCachingEmbedFS_Range(t *testing.T) {
+	s := http.FileServer(http.FS(testFsCached))
+	rr := httptest.NewRecorder()
+	req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
+	req.Header.Set("Range", "bytes=1-20")
+	s.ServeHTTP(rr, req)
+	require.Equal(t, 206, rr.Code)
+	require.Equal(t, "his is a test file f", rr.Body.String())
+}

Some files were not shown because too many files changed in this diff