diff --git a/cmd/serve.go b/cmd/serve.go index c281f3ec..b28bf46e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -152,8 +152,8 @@ func execServe(c *cli.Context) error { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { return errors.New("if attachment-cache-dir is set, base-url must also be set") - } else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { - return errors.New("if set, base-url must start with http:// or https://") + } else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") && strings.HasSuffix(baseURL, "/") { + return errors.New("if set, base-url must start with http:// or https://, and must not end with a slash (/)") } else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) { diff --git a/docs/examples.md b/docs/examples.md index 6183b670..fc4bc1a2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -9,7 +9,9 @@ those out, too. [create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that I cannot guarantee that all of these examples are functional. Many of them I have not tried myself. -## A long process is done: backups, copying data, pipelines, ... +## Cronjobs +ntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...). + I started adding notifications pretty much all of my scripts. Typically, I just chain the curl call directly to the command I'm running. The following example will either send Laptop backup succeeded or ⚠️ Laptop backup failed directly to my phone: @@ -21,6 +23,15 @@ rsync -a root@laptop /backups/laptop \ || curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups ``` +Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with +GitHub have been hopeless. In case it ever becomes available, I want to know immediately. + +``` cron +# Check github/ntfy user +*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi +``` + + ## Low disk space alerts Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but effective. @@ -42,11 +53,7 @@ if [ -n "$avail" ]; then fi ``` -## Server-sent messages in your web app -Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own -web application. Check out the live example. - -## Notify on SSH login +## SSH login alerts Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I own, I now message myself. Here's an example of how to use PAM to notify yourself on SSH login. @@ -102,7 +109,7 @@ One of my co-workers uses the following Ansible task to let him know when things body: "{{ inventory_hostname }} reseeding complete" ``` -## Watchtower notifications (shoutrrr) +## Watchtower (shoutrrr) You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send [Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic. @@ -121,16 +128,7 @@ Or, if you only want to send notifications using shoutrrr: shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" ``` -## Random cronjobs -Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with -GitHub have been hopeless. In case it ever becomes available, I want to know immediately. - -``` cron -# Check github/ntfy user -*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi -``` - -## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd) +## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc. Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts). @@ -343,7 +341,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder ![Node red picture flow](static/img/nodered-picture.png) -## Gatus service health check +## Gatus An example for a custom alert with [Gatus](https://github.com/TwiN/gatus): ``` yaml @@ -435,11 +433,37 @@ notify: ``` ## Uptime Kuma -- Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification** -- ![Uptime Kuma Settings](static/img/uptimekuma-settings.png) -- Set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)** -- ![Uptime Kuma Setup](static/img/uptimekuma-setup.png) -- You can now test the notifications and apply them to monitors. -- ![Uptime Kuma iOS Test](static/img/uptimekuma-ios-test.jpg) -- ![Uptime Kuma iOS Down](static/img/uptimekuma-ios-down.jpg) -- ![Uptime Kuma iOS Up](static/img/uptimekuma-ios-up.jpg) \ No newline at end of file +Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**. +Then set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)**: + +
+ +You can now test the notifications and apply them to monitors: + + + +## Apprise +ntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the +[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)). + +You can use it like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://mytopic +``` + +Or with your own server like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://ntfy.example.com/mytopic +``` + diff --git a/docs/faq.md b/docs/faq.md index 9846170b..cc3e59c0 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -42,3 +42,10 @@ decent now. [Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the server and listens for incoming notifications. This consumes additional battery (see above), but delivers notifications instantly. + +## Where can I donate? +Many people have asked (thanks for that!), but I am currently not accepting any donations. The cost is manageable +($25/month for hosting, and $99/year for the Apple cert) right now, and I don't want to have to feel obligated to +anyone by accepting their money. + +I may ask for donations in the future, though. After all, $400 per year isn't nothing... diff --git a/docs/publish.md b/docs/publish.md index 5f0ae585..82f7beb0 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2735,6 +2735,22 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64. +### Matrix Gateway +The ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with +[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate +with self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since +you don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)). + +In short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)), +and forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the +ntfy Android app, and passed on to the Matrix client there. + +There is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the +ntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol. + +!!! info + This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy. + ## Public topics Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics that you can use to try out what [authentication and access control](#authentication) looks like. diff --git a/docs/releases.md b/docs/releases.md index 56e7fe1f..9150251a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -13,16 +13,17 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v1.26.0 (UNRELEASED) -**Bugs:** - -* Web app: Show "notifications not supported" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) - **Features:** +* ntfy now is a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with [UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/), [#319](https://github.com/binwiederhier/ntfy/issues/319)/[#326](https://github.com/binwiederhier/ntfy/pull/326), thanks to [@MayeulC](https://github.com/MayeulC) for reporting) * Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu)) * [Uptime Kuma](https://github.com/louislam/uptime-kuma) now allows publishing to ntfy ([uptime-kuma#1674](https://github.com/louislam/uptime-kuma/pull/1674), thanks to [@philippdormann](https://github.com/philippdormann)) * Display ntfy version in `ntfy serve` command ([#314](https://github.com/binwiederhier/ntfy/issues/314), thanks to [@poblabs](https://github.com/poblabs)) +**Bugs:** + +* Web app: Show "notifications not supported" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) + **Documentation** * Added [example](examples.md) for [Uptime Kuma](https://github.com/louislam/uptime-kuma) integration ([#315](https://github.com/binwiederhier/ntfy/pull/315), thanks to [@philippdormann](https://github.com/philippdormann)) diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css index a7370399..6ac0b7e7 100644 --- a/docs/static/css/extra.css +++ b/docs/static/css/extra.css @@ -60,7 +60,8 @@ figure video { } .screenshots img { - height: 230px; + max-height: 230px; + max-width: 300px; margin: 3px; border-radius: 5px; filter: drop-shadow(2px 2px 2px #ddd); diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index 7eb3a95e..4c46d221 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -87,7 +87,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript, ### Subscribe as SSE stream Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly -easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html). +easy to use. Here's what it looks like. You may also want to check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-eventsource). === "Command line (curl)" ``` diff --git a/go.mod b/go.mod index f022c731..a5caea5f 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 golang.org/x/time v0.0.0-20220609170525-579cf78fd858 - google.golang.org/api v0.83.0 + google.golang.org/api v0.84.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -38,19 +38,20 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.8 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect github.com/googleapis/gax-go/v2 v2.4.0 // indirect github.com/googleapis/go-type-adapters v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect - golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d // indirect + golang.org/x/net v0.0.0-20220615171555-694bf12d69de // indirect + golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine/v2 v2.0.1 // indirect - google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac // indirect + google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90 // indirect google.golang.org/grpc v1.47.0 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect diff --git a/go.sum b/go.sum index 941fa920..0eaa090a 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,9 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw= +github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= @@ -341,11 +344,10 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA= -golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220615171555-694bf12d69de h1:ogOG2+P6LjO2j55AkRScrkB2BFpd+Z8TY2wcM0Z3MGo= +golang.org/x/net v0.0.0-20220615171555-694bf12d69de/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -366,7 +368,6 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 h1:zwrSfklXn0gxyLRX/aR+q6cgHbV/ItVyzbPlbA+dkAw= golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb h1:8tDJ3aechhddbdPAxpycgXHJRMLpk/Ab+aa4OgdN5/g= golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= @@ -381,7 +382,6 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -443,10 +443,11 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d h1:Zu/JngovGLVi6t2J3nmAf3AoTDwuzw85YZ3b9o4yU7s= golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c h1:aFV+BgZ4svzjfabn8ERpuB4JI4N6/rdy1iusx77G3oU= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= @@ -464,8 +465,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= -golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U= golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -524,7 +523,6 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0= golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= @@ -571,10 +569,10 @@ google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRR google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= -google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw= -google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os= google.golang.org/api v0.83.0 h1:pMvST+6v+46Gabac4zlJlalxZjCeRcepwg2EdBU+nCc= google.golang.org/api v0.83.0/go.mod h1:CNywQoj/AfhTw26ZWAa6LwOv+6WFxHmeLPZq2uncLZk= +google.golang.org/api v0.84.0 h1:NMB9J4cCxs9xEm+1Z9QiO3eFvn7EnQj3Eo3hN6ugVlg= +google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= 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= @@ -669,11 +667,11 @@ google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= -google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM= google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac h1:ByeiW1F67iV9o8ipGskA+HWzSkMbRJuKLlwCdPxzn7A= google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90 h1:4SPz2GL2CXJt28MTF8V6Ap/9ZiVbQlJeGSd9qtA7DLs= +google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/server/errors.go b/server/errors.go index 32c1b3b9..28dbca3a 100644 --- a/server/errors.go +++ b/server/errors.go @@ -50,10 +50,13 @@ var ( errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"} errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"} errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"} + errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"} + errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPEntityTooLargeMatrixRequestTooLarge = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""} errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} @@ -61,4 +64,5 @@ var ( errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} + errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"} ) diff --git a/server/example.html b/server/example.html deleted file mode 100644 index e558ef12..00000000 --- a/server/example.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - -
- This is an example showing how to use ntfy.sh with
- EventSource.
- This example doesn't need a server. You can just save the HTML page and run it from anywhere.
-
Log:
- - - - - - diff --git a/server/server.go b/server/server.go index 2f01b07f..21a6c584 100644 --- a/server/server.go +++ b/server/server.go @@ -68,15 +68,13 @@ var ( webConfigPath = "/config.js" userStatsPath = "/user/stats" + matrixPushPath = "/_matrix/push/v1/notify" staticRegex = regexp.MustCompile(`^/static/.+`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app attachURLRegex = regexp.MustCompile(`^https?://`) - //go:embed "example.html" - exampleSource string - //go:embed site webFs embed.FS webFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs} @@ -258,6 +256,10 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { } return // Do not attempt to write to upgraded connection } + if matrixErr, ok := err.(*errMatrix); ok { + writeMatrixError(w, r, v, matrixErr) + return + } httpErr, ok := err.(*errHTTP) if !ok { httpErr = errHTTPInternalError @@ -278,14 +280,14 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { if r.Method == http.MethodGet && r.URL.Path == "/" { return s.ensureWebEnabled(s.handleHome)(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { - return s.ensureWebEnabled(s.handleExample)(w, r, v) } else if r.Method == http.MethodHead && r.URL.Path == "/" { return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { return s.handleUserStats(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { + return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { @@ -296,6 +298,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleOptions)(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath { + return s.limitRequests(s.transformMatrixJSON(s.authWrite(s.handlePublishMatrix)))(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { @@ -348,11 +352,6 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi return err } -func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - _, err := io.WriteString(w, exampleSource) - return err -} - func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { appRoot := "/" if !s.config.WebRootIsApp { @@ -425,25 +424,29 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) return nil } -func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error { + return writeMatrixDiscoveryResponse(w) +} + +func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) { t, err := s.topicFromPath(r.URL.Path) if err != nil { - return err + return nil, err } body, err := util.Peek(r.Body, s.config.MessageLimit) if err != nil { - return err + return nil, err } m := newDefaultMessage(t.ID, "") cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m) if err != nil { - return err + return nil, err } if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { - return err + return nil, err } if m.Message == "" { m.Message = emptyMessageBody @@ -456,7 +459,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito } if !delayed { if err := t.Publish(v, m); err != nil { - return err + return nil, err } if s.firebaseClient != nil && firebase { go s.sendToFirebase(v, m) @@ -472,20 +475,36 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito } if cache { if err := s.messageCache.AddMessage(m); err != nil { - return err + return nil, err } } + s.mu.Lock() + s.messages++ + s.mu.Unlock() + return m, nil +} + +func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { + m, err := s.handlePublishWithoutResponse(r, v) + if err != nil { + 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() return nil } +func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { + _, err := s.handlePublishWithoutResponse(r, v) + if err != nil { + return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err} + } + return writeMatrixSuccess(w) +} + func (s *Server) sendToFirebase(v *visitor, m *message) { log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m)) if err := s.firebaseClient.Send(v, m); err != nil { @@ -1286,6 +1305,19 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { } } +func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit) + if err != nil { + return err + } + if err := next(w, newRequest, v); err != nil { + return &errMatrix{pushKey: newRequest.Header.Get(matrixPushKeyHeader), err: err} + } + return nil + } +} + func (s *Server) authWrite(next handleFunc) handleFunc { return s.withAuth(next, auth.PermissionWrite) } diff --git a/server/server.yml b/server/server.yml index 01a212d0..65f1db3c 100644 --- a/server/server.yml +++ b/server/server.yml @@ -4,8 +4,12 @@ # All options also support underscores (_) instead of dashes (-) to comply with the YAML spec. # Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com) -# This setting is currently only used by attachments, e-mail sending (outgoing mail only), as well -# as in combination with upstream-base-url (for iOS push notifications for self-hosted servers). +# +# This setting is required for any of the following features: +# - attachments (to return a download URL) +# - e-mail sending (for the topic URL in the email footer) +# - iOS push notifications for self-hosted servers (to calculate the Firebase poll_request topic) +# - Matrix Push Gateway (to validate that the pushkey is correct) # # base-url: diff --git a/server/server_matrix.go b/server/server_matrix.go new file mode 100644 index 00000000..2113c5ac --- /dev/null +++ b/server/server_matrix.go @@ -0,0 +1,175 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "heckel.io/ntfy/log" + "heckel.io/ntfy/util" + "io" + "net/http" + "strings" +) + +// Matrix Push Gateway / UnifiedPush / ntfy integration: +// +// ntfy implements a Matrix Push Gateway (as defined in https://spec.matrix.org/v1.2/push-gateway-api/), +// in combination with UnifiedPush as the Provider Push Protocol (as defined in https://unifiedpush.org/developers/gateway/). +// +// In the picture below, ntfy is the Push Gateway (mostly in this file), as well as the Push Provider (ntfy's +// main functionality). UnifiedPush is the Provider Push Protocol, as implemented by the ntfy server and the +// ntfy Android app. +// +// +--------------------+ +-------------------+ +// Matrix HTTP | | | | +// Notification Protocol | App Developer | | Device Vendor | +// | | | | +// +-------------------+ | +----------------+ | | +---------------+ | +// | | | | | | | | | | +// | Matrix homeserver +-----> Push Gateway +------> Push Provider | | +// | | | | | | | | | | +// +-^-----------------+ | +----------------+ | | +----+----------+ | +// | | | | | | +// Matrix | | | | | | +// Client/Server API + | | | | | +// | | +--------------------+ +-------------------+ +// | +--+-+ | +// | | <-------------------------------------------+ +// +---+ | +// | | Provider Push Protocol +// +----+ +// +// Mobile Device or Client +// + +// matrixRequest represents a Matrix message, as it is sent to a Push Gateway (as per +// this spec: https://spec.matrix.org/v1.2/push-gateway-api/). +// +// From the message, we only require the "pushkey", as it represents our target topic URL. +// A message may look like this (excerpt): +// { +// "notification": { +// "devices": [ +// { +// "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", +// ... +// } +// ] +// } +// } +// +type matrixRequest struct { + Notification *struct { + Devices []*struct { + PushKey string `json:"pushkey"` + } `json:"devices"` + } `json:"notification"` +} + +// matrixResponse represents the response to a Matrix push gateway message, as defined +// in the spec (https://spec.matrix.org/v1.2/push-gateway-api/). +type matrixResponse struct { + Rejected []string `json:"rejected"` +} + +// errMatrix represents an error when handing Matrix gateway messages +type errMatrix struct { + pushKey string + err error +} + +func (e errMatrix) Error() string { + if e.err != nil { + return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error()) + } + return fmt.Sprintf("message with push key %s rejected", e.pushKey) +} + +const ( + // matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest) + // along with the request. The push key is only used if an error occurs down the line. + matrixPushKeyHeader = "X-Matrix-Pushkey" +) + +// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new +// HTTP request that looks like a normal ntfy request from it. +// +// It basically converts a Matrix push gatewqy request: +// +// POST /_matrix/push/v1/notify HTTP/1.1 +// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } } +// +// to a ntfy request, looking like this: +// +// POST /upDAHJKFFDFD?up=1 HTTP/1.1 +// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } } +// +func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) (*http.Request, error) { + if baseURL == "" { + return nil, errHTTPInternalErrorMissingBaseURL + } + body, err := util.Peek(r.Body, messageLimit) + if err != nil { + return nil, err + } + defer r.Body.Close() + if body.LimitReached { + return nil, errHTTPEntityTooLargeMatrixRequestTooLarge + } + var m matrixRequest + if err := json.Unmarshal(body.PeekedBytes, &m); err != nil { + return nil, errHTTPBadRequestMatrixMessageInvalid + } else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" { + return nil, errHTTPBadRequestMatrixMessageInvalid + } + pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316 + if !strings.HasPrefix(pushKey, baseURL+"/") { + return nil, &errMatrix{pushKey: pushKey, err: errHTTPBadRequestMatrixPushkeyBaseURLMismatch} + } + newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes))) + if err != nil { + return nil, &errMatrix{pushKey: pushKey, err: err} + } + newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted + if r.Header.Get("X-Forwarded-For") != "" { + newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) + } + newRequest.Header.Set(matrixPushKeyHeader, pushKey) + return newRequest, nil +} + +// writeMatrixDiscoveryResponse writes the UnifiedPush Matrix Gateway Discovery response to the given http.ResponseWriter, +// as per the spec (https://unifiedpush.org/developers/gateway/). +func writeMatrixDiscoveryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(w, `{"unifiedpush":{"gateway":"matrix"}}`+"\n") + return err +} + +// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse +func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error { + log.Debug("%s Matrix gateway error: %s", logHTTPPrefix(v, r), err.Error()) + return writeMatrixResponse(w, err.pushKey) +} + +// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter +func writeMatrixSuccess(w http.ResponseWriter) error { + return writeMatrixResponse(w, "") +} + +// writeMatrixResponse writes a matrixResponse to the given http.ResponseWriter, as defined in +// the spec (https://spec.matrix.org/v1.2/push-gateway-api/) +func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error { + rejected := make([]string, 0) + if rejectedPushKey != "" { + rejected = append(rejected, rejectedPushKey) + } + response := &matrixResponse{ + Rejected: rejected, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } + return nil +} diff --git a/server/server_matrix_test.go b/server/server_matrix_test.go new file mode 100644 index 00000000..9129d6c4 --- /dev/null +++ b/server/server_matrix_test.go @@ -0,0 +1,84 @@ +package server + +import ( + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 4096 + body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + newRequest, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Nil(t, err) + require.Equal(t, "POST", newRequest.Method) + require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String()) + require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey")) + require.Equal(t, body, readAll(t, newRequest.Body)) +} + +func TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 10 // Small + body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Equal(t, errHTTPEntityTooLargeMatrixRequestTooLarge, err) +} + +func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 4096 + body := `this is not json` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err) +} + +func TestMatrix_NewRequestFromMatrixJSON_NotAMatrixMessage(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 4096 + body := `{"message":"this is not a matrix message, but valid json"}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err) +} + +func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) { + baseURL := "https://ntfy.sh" // Mismatch! + maxLength := 4096 + body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + matrixErr, ok := err.(*errMatrix) + require.True(t, ok) + require.Equal(t, errHTTPBadRequestMatrixPushkeyBaseURLMismatch, matrixErr.err) + require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey) +} + +func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) { + w := httptest.NewRecorder() + require.Nil(t, writeMatrixDiscoveryResponse(w)) + require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", w.Body.String()) +} + +func TestMatrix_WriteMatrixError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil) + v := newVisitor(newTestConfig(t), nil, "1.2.3.4") + require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch})) + require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String()) +} + +func TestMatrix_WriteMatrixSuccess(t *testing.T) { + w := httptest.NewRecorder() + require.Nil(t, writeMatrixSuccess(w)) + require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, `{"rejected":[]}`+"\n", w.Body.String()) +} diff --git a/server/server_test.go b/server/server_test.go index 32f4fc2d..55125c3e 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "math/rand" "net/http" "net/http/httptest" @@ -171,10 +172,6 @@ func TestServer_StaticSites(t *testing.T) { require.Equal(t, 301, rr.Code) // Docs test removed, it was failing annoyingly. - - rr = request(t, s, "GET", "/example.html", "", nil) - require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), "