Browse Source

Merge pull request #326 from binwiederhier/matrix

Matrix gateway
Philipp C. Heckel 3 years ago
parent
commit
1fae61e78f

+ 2 - 2
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) {

+ 50 - 26
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 <tt>curl</tt> call
 directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
 or ⚠️ <i>Laptop backup failed</i> 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 <a href="/example.html">live example</a>.
-
-## 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 <a href="https://en.wikipedia.org/wiki/Linux_PAM">PAM</a>
 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)
+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)**:
+
+<div id="uptimekuma-screenshots" class="screenshots">
+    <a href="../../static/img/uptimekuma-settings.png"><img src="../../static/img/uptimekuma-settings.png"/></a>
+    <a href="../../static/img/uptimekuma-setup.png"><img src="../../static/img/uptimekuma-setup.png"/></a>
+</div>
+
+You can now test the notifications and apply them to monitors:
+
+<div id="uptimekuma-monitor-screenshots" class="screenshots">
+    <a href="../../static/img/uptimekuma-ios-test.jpg"><img src="../../static/img/uptimekuma-ios-test.jpg"/></a>
+    <a href="../../static/img/uptimekuma-ios-down.jpg"><img src="../../static/img/uptimekuma-ios-down.jpg"/></a>
+    <a href="../../static/img/uptimekuma-ios-up.jpg"><img src="../../static/img/uptimekuma-ios-up.jpg"/></a>
+</div>
+
+## 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
+```
+

+ 7 - 0
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... 

+ 16 - 0
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.

+ 5 - 4
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))

+ 2 - 1
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);

+ 1 - 1
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)"
     ```

+ 5 - 4
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

+ 11 - 13
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=

+ 4 - 0
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/"}
 )

+ 0 - 56
server/example.html

@@ -1,56 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <title>ntfy.sh: EventSource Example</title>
-    <meta name="robots" content="noindex, nofollow" />
-    <style>
-        body { font-size: 1.2em; line-height: 130%; }
-        #events { font-family: monospace; }
-    </style>
-</head>
-<body>
-<h1>ntfy.sh: EventSource Example</h1>
-<p>
-    This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
-    <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>.<br/>
-    This example doesn't need a server. You can just save the HTML page and run it from anywhere.
-</p>
-<button id="publishButton">Send test notification</button>
-<p><b>Log:</b></p>
-<div id="events"></div>
-
-<script type="text/javascript">
-    const publishURL = `https://ntfy.sh/example`;
-    const subscribeURL = `https://ntfy.sh/example/sse`;
-    const events = document.getElementById('events');
-    const eventSource = new EventSource(subscribeURL);
-
-    // Publish button
-    document.getElementById("publishButton").onclick = () => {
-        fetch(publishURL, {
-            method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
-            body: `It is ${new Date().toString()}. This is a test.`
-        })
-    };
-
-    // Incoming events
-    eventSource.onopen = () => {
-        let event = document.createElement('div');
-        event.innerHTML = `EventSource connected to ${subscribeURL}`;
-        events.appendChild(event);
-    };
-    eventSource.onerror = (e) => {
-        let event = document.createElement('div');
-        event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
-        events.appendChild(event);
-    };
-    eventSource.onmessage = (e) => {
-        let event = document.createElement('div');
-        event.innerHTML = e.data;
-        events.appendChild(event);
-    };
-</script>
-
-</body>
-</html>

+ 52 - 20
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)
 }

+ 6 - 2
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:
 

+ 175 - 0
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
+}

+ 84 - 0
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())
+}

+ 63 - 10
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(), "</html>")
 }
 
 func TestServer_WebEnabled(t *testing.T) {
@@ -185,9 +182,6 @@ func TestServer_WebEnabled(t *testing.T) {
 	rr := request(t, s, "GET", "/", "", nil)
 	require.Equal(t, 404, rr.Code)
 
-	rr = request(t, s, "GET", "/example.html", "", nil)
-	require.Equal(t, 404, rr.Code)
-
 	rr = request(t, s, "GET", "/config.js", "", nil)
 	require.Equal(t, 404, rr.Code)
 
@@ -201,9 +195,6 @@ func TestServer_WebEnabled(t *testing.T) {
 	rr = request(t, s2, "GET", "/", "", nil)
 	require.Equal(t, 200, rr.Code)
 
-	rr = request(t, s2, "GET", "/example.html", "", nil)
-	require.Equal(t, 200, rr.Code)
-
 	rr = request(t, s2, "GET", "/config.js", "", nil)
 	require.Equal(t, 200, rr.Code)
 
@@ -916,6 +907,60 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
 	require.Equal(t, "this is a unifiedpush text message", m.Message)
 }
 
+func TestServer_MatrixGateway_Discovery(t *testing.T) {
+	s := newTestServer(t, newTestConfig(t))
+	response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil)
+	require.Equal(t, 200, response.Code)
+	require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String())
+}
+
+func TestServer_MatrixGateway_Push_Success(t *testing.T) {
+	s := newTestServer(t, newTestConfig(t))
+	notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
+	response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
+	require.Equal(t, 200, response.Code)
+	require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
+
+	response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+	require.Equal(t, 200, response.Code)
+	m := toMessage(t, response.Body.String())
+	require.Equal(t, notification, m.Message)
+}
+
+func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
+	s := newTestServer(t, newTestConfig(t))
+	notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
+	response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
+	require.Equal(t, 200, response.Code)
+	require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String())
+
+	response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+	require.Equal(t, 200, response.Code)
+	require.Equal(t, "", response.Body.String()) // Empty!
+}
+
+func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) {
+	s := newTestServer(t, newTestConfig(t))
+	notification := `{"message":"this is not really a Matrix message"}`
+	response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
+	require.Equal(t, 400, response.Code)
+	err := toHTTPError(t, response.Body.String())
+	require.Equal(t, 40019, err.Code)
+	require.Equal(t, 400, err.HTTPCode)
+}
+
+func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
+	c := newTestConfig(t)
+	c.BaseURL = ""
+	s := newTestServer(t, c)
+	notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
+	response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
+	require.Equal(t, 500, response.Code)
+	err := toHTTPError(t, response.Body.String())
+	require.Equal(t, 50003, err.Code)
+	require.Equal(t, 500, err.HTTPCode)
+}
+
 func TestServer_PublishActions_AndPoll(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 	response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
@@ -1348,3 +1393,11 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
 func basicAuth(s string) string {
 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
 }
+
+func readAll(t *testing.T, rc io.ReadCloser) string {
+	b, err := io.ReadAll(rc)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return string(b)
+}

+ 0 - 9
server/smtp_server_test.go

@@ -3,7 +3,6 @@ package server
 import (
 	"github.com/emersion/go-smtp"
 	"github.com/stretchr/testify/require"
-	"io"
 	"net"
 	"net/http"
 	"strings"
@@ -304,14 +303,6 @@ func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Reques
 	return conf, backend
 }
 
-func readAll(t *testing.T, rc io.ReadCloser) string {
-	b, err := io.ReadAll(rc)
-	if err != nil {
-		t.Fatal(err)
-	}
-	return string(b)
-}
-
 func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
 	ip, err := net.ResolveIPAddr("ip", remoteAddr)
 	if err != nil {

+ 2 - 1
util/peek.go

@@ -18,7 +18,8 @@ type PeekedReadCloser struct {
 	closed       bool
 }
 
-// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
+// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser.
+// It does not return an error if limit is reached. Instead, LimitReached will be set to true.
 func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
 	if underlying == nil {
 		underlying = io.NopCloser(strings.NewReader(""))

File diff suppressed because it is too large
+ 266 - 268
web/package-lock.json


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