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")
 		return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
 	} else if attachmentCacheDir != "" && baseURL == "" {
 	} else if attachmentCacheDir != "" && baseURL == "" {
 		return errors.New("if attachment-cache-dir is set, base-url must also be set")
 		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) {
 	} 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'")
 		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) {
 	} 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
     [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.
     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
 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>
 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:
 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
   || 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
 ## 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 
 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. 
 effective. 
@@ -42,11 +53,7 @@ if [ -n "$avail" ]; then
 fi
 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
 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>
 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.
 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"
     body: "{{ inventory_hostname }} reseeding complete"
 ```
 ```
 
 
-## Watchtower notifications (shoutrrr)
+## Watchtower (shoutrrr)
 You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send 
 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.
 [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"
 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.
 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). 
 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)
 ![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):
 An example for a custom alert with [Gatus](https://github.com/TwiN/gatus):
 ``` yaml
 ``` yaml
@@ -435,11 +433,37 @@ notify:
 ```
 ```
 
 
 ## Uptime Kuma
 ## 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
 [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),
 server and listens for incoming notifications. This consumes additional battery (see above),
 but delivers notifications instantly.
 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 
 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.
 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
 ## Public topics
 Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and 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.
 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)
 ## 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:**
 **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))
 * 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))
 * [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))
 * 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**
 **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))
 * 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 {
 .screenshots img {
-    height: 230px;
+    max-height: 230px;
+    max-width: 300px;
     margin: 3px;
     margin: 3px;
     border-radius: 5px;
     border-radius: 5px;
     filter: drop-shadow(2px 2px 2px #ddd);
     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
 ### Subscribe as SSE stream
 Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
 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 
 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)"
 === "Command line (curl)"
     ```
     ```

+ 5 - 4
go.mod

@@ -19,7 +19,7 @@ require (
 	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
 	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
 	golang.org/x/time v0.0.0-20220609170525-579cf78fd858
 	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
 	gopkg.in/yaml.v2 v2.4.0
 )
 )
 
 
@@ -38,19 +38,20 @@ require (
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.8 // indirect
 	github.com/google/go-cmp v0.5.8 // indirect
 	github.com/google/uuid v1.3.0 // 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/gax-go/v2 v2.4.0 // indirect
 	github.com/googleapis/go-type-adapters v1.0.0 // indirect
 	github.com/googleapis/go-type-adapters v1.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	go.opencensus.io v0.23.0 // 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/text v0.3.7 // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine/v2 v2.0.1 // 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/grpc v1.47.0 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // 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.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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 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.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.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
 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-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-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-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 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-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-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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/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-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-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-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-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 h1:8tDJ3aechhddbdPAxpycgXHJRMLpk/Ab+aa4OgdN5/g=
 golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
 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-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-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-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 h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
 golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 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=
 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-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-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-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-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 h1:Zu/JngovGLVi6t2J3nmAf3AoTDwuzw85YZ3b9o4yU7s=
 golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 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-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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
 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-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-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-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 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U=
 golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 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=
 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-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-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-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-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 h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0=
 golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
 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.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
 google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
 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.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 h1:pMvST+6v+46Gabac4zlJlalxZjCeRcepwg2EdBU+nCc=
 google.golang.org/api v0.83.0/go.mod h1:CNywQoj/AfhTw26ZWAa6LwOv+6WFxHmeLPZq2uncLZk=
 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.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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.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-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-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-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-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 h1:ByeiW1F67iV9o8ipGskA+HWzSkMbRJuKLlwCdPxzn7A=
 google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 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"}
 	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"}
 	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"}
 	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", ""}
 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "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"}
 	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"}
 	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"}
 	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"}
 	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"}
 	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", ""}
 	errHTTPInternalError                             = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
 	errHTTPInternalErrorInvalidFilePath              = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
 	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"
 	webConfigPath    = "/config.js"
 	userStatsPath    = "/user/stats"
 	userStatsPath    = "/user/stats"
+	matrixPushPath   = "/_matrix/push/v1/notify"
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
 	docsRegex        = regexp.MustCompile(`^/docs(|/.*)$`)
 	docsRegex        = regexp.MustCompile(`^/docs(|/.*)$`)
 	fileRegex        = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
 	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
 	disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
 	attachURLRegex   = regexp.MustCompile(`^https?://`)
 	attachURLRegex   = regexp.MustCompile(`^https?://`)
 
 
-	//go:embed "example.html"
-	exampleSource string
-
 	//go:embed site
 	//go:embed site
 	webFs        embed.FS
 	webFs        embed.FS
 	webFsCached  = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs}
 	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
 			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)
 		httpErr, ok := err.(*errHTTP)
 		if !ok {
 		if !ok {
 			httpErr = errHTTPInternalError
 			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 {
 func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	if r.Method == http.MethodGet && r.URL.Path == "/" {
 	if r.Method == http.MethodGet && r.URL.Path == "/" {
 		return s.ensureWebEnabled(s.handleHome)(w, r, v)
 		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 == "/" {
 	} else if r.Method == http.MethodHead && r.URL.Path == "/" {
 		return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
 		return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
 	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 		return s.handleUserStats(w, r, v)
 		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) {
 	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
 		return s.ensureWebEnabled(s.handleStatic)(w, r, v)
 		return s.ensureWebEnabled(s.handleStatic)(w, r, v)
 	} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
 	} 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)
 		return s.ensureWebEnabled(s.handleOptions)(w, r, v)
 	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
 	} 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)
 		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) {
 	} 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)
 		return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
 	} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
 	} 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
 	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 {
 func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
 	appRoot := "/"
 	appRoot := "/"
 	if !s.config.WebRootIsApp {
 	if !s.config.WebRootIsApp {
@@ -425,25 +424,29 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
 	return nil
 	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)
 	t, err := s.topicFromPath(r.URL.Path)
 	if err != nil {
 	if err != nil {
-		return err
+		return nil, err
 	}
 	}
 	body, err := util.Peek(r.Body, s.config.MessageLimit)
 	body, err := util.Peek(r.Body, s.config.MessageLimit)
 	if err != nil {
 	if err != nil {
-		return err
+		return nil, err
 	}
 	}
 	m := newDefaultMessage(t.ID, "")
 	m := newDefaultMessage(t.ID, "")
 	cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
 	cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
 	if err != nil {
 	if err != nil {
-		return err
+		return nil, err
 	}
 	}
 	if m.PollID != "" {
 	if m.PollID != "" {
 		m = newPollRequestMessage(t.ID, m.PollID)
 		m = newPollRequestMessage(t.ID, m.PollID)
 	}
 	}
 	if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
 	if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
-		return err
+		return nil, err
 	}
 	}
 	if m.Message == "" {
 	if m.Message == "" {
 		m.Message = emptyMessageBody
 		m.Message = emptyMessageBody
@@ -456,7 +459,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 	}
 	}
 	if !delayed {
 	if !delayed {
 		if err := t.Publish(v, m); err != nil {
 		if err := t.Publish(v, m); err != nil {
-			return err
+			return nil, err
 		}
 		}
 		if s.firebaseClient != nil && firebase {
 		if s.firebaseClient != nil && firebase {
 			go s.sendToFirebase(v, m)
 			go s.sendToFirebase(v, m)
@@ -472,20 +475,36 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 	}
 	}
 	if cache {
 	if cache {
 		if err := s.messageCache.AddMessage(m); err != nil {
 		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("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
 	if err := json.NewEncoder(w).Encode(m); err != nil {
 	if err := json.NewEncoder(w).Encode(m); err != nil {
 		return err
 		return err
 	}
 	}
-	s.mu.Lock()
-	s.messages++
-	s.mu.Unlock()
 	return nil
 	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) {
 func (s *Server) sendToFirebase(v *visitor, m *message) {
 	log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
 	log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
 	if err := s.firebaseClient.Send(v, m); err != nil {
 	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 {
 func (s *Server) authWrite(next handleFunc) handleFunc {
 	return s.withAuth(next, auth.PermissionWrite)
 	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.
 # 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)
 # 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:
 # 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/base64"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
+	"io"
 	"math/rand"
 	"math/rand"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
@@ -171,10 +172,6 @@ func TestServer_StaticSites(t *testing.T) {
 	require.Equal(t, 301, rr.Code)
 	require.Equal(t, 301, rr.Code)
 
 
 	// Docs test removed, it was failing annoyingly.
 	// 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) {
 func TestServer_WebEnabled(t *testing.T) {
@@ -185,9 +182,6 @@ func TestServer_WebEnabled(t *testing.T) {
 	rr := request(t, s, "GET", "/", "", nil)
 	rr := request(t, s, "GET", "/", "", nil)
 	require.Equal(t, 404, rr.Code)
 	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)
 	rr = request(t, s, "GET", "/config.js", "", nil)
 	require.Equal(t, 404, rr.Code)
 	require.Equal(t, 404, rr.Code)
 
 
@@ -201,9 +195,6 @@ func TestServer_WebEnabled(t *testing.T) {
 	rr = request(t, s2, "GET", "/", "", nil)
 	rr = request(t, s2, "GET", "/", "", nil)
 	require.Equal(t, 200, rr.Code)
 	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)
 	rr = request(t, s2, "GET", "/config.js", "", nil)
 	require.Equal(t, 200, rr.Code)
 	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)
 	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) {
 func TestServer_PublishActions_AndPoll(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 	s := newTestServer(t, newTestConfig(t))
 	response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
 	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 {
 func basicAuth(s string) string {
 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
 	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 (
 import (
 	"github.com/emersion/go-smtp"
 	"github.com/emersion/go-smtp"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
-	"io"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
@@ -304,14 +303,6 @@ func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Reques
 	return conf, backend
 	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 {
 func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
 	ip, err := net.ResolveIPAddr("ip", remoteAddr)
 	ip, err := net.ResolveIPAddr("ip", remoteAddr)
 	if err != nil {
 	if err != nil {

+ 2 - 1
util/peek.go

@@ -18,7 +18,8 @@ type PeekedReadCloser struct {
 	closed       bool
 	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) {
 func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
 	if underlying == nil {
 	if underlying == nil {
 		underlying = io.NopCloser(strings.NewReader(""))
 		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