From 534b93e1424ac37abe32c56b65f4f2dee938cad0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 15 Dec 2021 16:12:40 -0500 Subject: [PATCH] Webhooks (#55), more tests (#35) and python examples (#50) --- Makefile | 2 +- docs/publish.md | 155 ++++++++++++++++++++++++++++- examples/publish-python/publish.py | 12 +++ go.mod | 10 +- go.sum | 19 ++-- server/server.go | 13 ++- server/server_test.go | 105 ++++++++++++++++++- tools/fbsend/README.md | 2 + util/embedfs/test.txt | 1 + util/embedfs_test.go | 44 ++++++++ util/limit.go | 5 - util/limit_test.go | 30 ++++++ util/util_test.go | 56 +++++++++++ 13 files changed, 425 insertions(+), 29 deletions(-) create mode 100755 examples/publish-python/publish.py create mode 100644 tools/fbsend/README.md create mode 100644 util/embedfs/test.txt create mode 100644 util/embedfs_test.go create mode 100644 util/limit_test.go create mode 100644 util/util_test.go diff --git a/Makefile b/Makefile index d4da687c..006e6381 100644 --- a/Makefile +++ b/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 ./... diff --git a/docs/publish.md b/docs/publish.md index 5b2c9ae6..dce8a1be 100644 --- a/docs/publish.md +++ b/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**): -
@@ -411,6 +455,87 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
Delay/At/In headerMessage will be delivered atExplanation
+## 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) | diff --git a/examples/publish-python/publish.py b/examples/publish-python/publish.py new file mode 100755 index 00000000..79bb1fde --- /dev/null +++ b/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() diff --git a/go.mod b/go.mod index 2d4db264..c61d0f99 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index e04bb27f..c7b18e18 100644 --- a/go.sum +++ b/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= diff --git a/server/server.go b/server/server.go index d5ea31ea..1cb6edad 100644 --- a/server/server.go +++ b/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) diff --git a/server/server_test.go b/server/server_test.go index cbe24a5f..d06377ad 100644 --- a/server/server_test.go +++ b/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(), ``) + + rr = request(t, s, "GET", "/example.html", "", nil) + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), "") } 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 "" +} diff --git a/tools/fbsend/README.md b/tools/fbsend/README.md new file mode 100644 index 00000000..1cd785a0 --- /dev/null +++ b/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. diff --git a/util/embedfs/test.txt b/util/embedfs/test.txt new file mode 100644 index 00000000..920d0357 --- /dev/null +++ b/util/embedfs/test.txt @@ -0,0 +1 @@ +This is a test file for embedfs_test.go diff --git a/util/embedfs_test.go b/util/embedfs_test.go new file mode 100644 index 00000000..35e5d57a --- /dev/null +++ b/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()) +} diff --git a/util/limit.go b/util/limit.go index 962757f2..e5561247 100644 --- a/util/limit.go +++ b/util/limit.go @@ -58,8 +58,3 @@ func (l *Limiter) Value() int64 { defer l.mu.Unlock() return l.value } - -// Limit returns the defined limit -func (l *Limiter) Limit() int64 { - return l.limit -} diff --git a/util/limit_test.go b/util/limit_test.go new file mode 100644 index 00000000..f6d56c6d --- /dev/null +++ b/util/limit_test.go @@ -0,0 +1,30 @@ +package util + +import ( + "testing" +) + +func TestLimiter_Add(t *testing.T) { + l := NewLimiter(10) + if err := l.Add(5); err != nil { + t.Fatal(err) + } + if err := l.Add(5); err != nil { + t.Fatal(err) + } + if err := l.Add(5); err != ErrLimitReached { + t.Fatalf("expected ErrLimitReached, got %#v", err) + } +} + +func TestLimiter_AddSub(t *testing.T) { + l := NewLimiter(10) + l.Add(5) + if l.Value() != 5 { + t.Fatalf("expected value to be %d, got %d", 5, l.Value()) + } + l.Sub(2) + if l.Value() != 3 { + t.Fatalf("expected value to be %d, got %d", 3, l.Value()) + } +} diff --git a/util/util_test.go b/util/util_test.go new file mode 100644 index 00000000..2d901521 --- /dev/null +++ b/util/util_test.go @@ -0,0 +1,56 @@ +package util + +import ( + "github.com/stretchr/testify/require" + "io/ioutil" + "path/filepath" + "testing" + "time" +) + +func TestDurationToHuman_SevenDays(t *testing.T) { + d := 7 * 24 * time.Hour + require.Equal(t, "7d", DurationToHuman(d)) +} + +func TestDurationToHuman_MoreThanOneDay(t *testing.T) { + d := 49 * time.Hour + require.Equal(t, "2d1h", DurationToHuman(d)) +} + +func TestDurationToHuman_LessThanOneDay(t *testing.T) { + d := 17*time.Hour + 15*time.Minute + require.Equal(t, "17h15m", DurationToHuman(d)) +} + +func TestDurationToHuman_TenOfThings(t *testing.T) { + d := 10*time.Hour + 10*time.Minute + 10*time.Second + require.Equal(t, "10h10m10s", DurationToHuman(d)) +} + +func TestDurationToHuman_Zero(t *testing.T) { + require.Equal(t, "0", DurationToHuman(0)) +} + +func TestRandomString(t *testing.T) { + s1 := RandomString(10) + s2 := RandomString(10) + s3 := RandomString(12) + require.Equal(t, 10, len(s1)) + require.Equal(t, 10, len(s2)) + require.Equal(t, 12, len(s3)) + require.NotEqual(t, s1, s2) +} + +func TestFileExists(t *testing.T) { + filename := filepath.Join(t.TempDir(), "somefile.txt") + require.Nil(t, ioutil.WriteFile(filename, []byte{0x25, 0x86}, 0600)) + require.True(t, FileExists(filename)) + require.False(t, FileExists(filename+".doesnotexist")) +} + +func TestInStringList(t *testing.T) { + s := []string{"one", "two"} + require.True(t, InStringList(s, "two")) + require.False(t, InStringList(s, "three")) +}