From 96bb357435b578d4bfb4e4f255f2492e018c7ceb Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Fri, 27 May 2022 20:30:20 -0400
Subject: [PATCH] Polish the poll_request stuff

---
 cmd/serve.go                   |  10 +-
 go.mod                         |   3 +-
 go.sum                         |  28 +----
 server/config.go               |   2 +-
 server/server.go               |  95 +++++++++--------
 server/server.yml              |  12 +++
 server/server_firebase.go      | 188 ++++++++++++++++++---------------
 server/server_firebase_test.go | 118 +++++++++++++++++++++
 web/package-lock.json          |  72 ++++++-------
 9 files changed, 328 insertions(+), 200 deletions(-)

diff --git a/cmd/serve.go b/cmd/serve.go
index cfaecda..52008c2 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -41,7 +41,7 @@ var flagsServe = []cli.Flag{
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
-	altsrc.NewStringFlag(&cli.StringFlag{Name: "forward-poll-url", Aliases: []string{"forward_poll_url"}, EnvVars: []string{"NTFY_FORWARD_POLL_URL"}, Value: "", Usage: ""}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
@@ -103,7 +103,7 @@ func execServe(c *cli.Context) error {
 	keepaliveInterval := c.Duration("keepalive-interval")
 	managerInterval := c.Duration("manager-interval")
 	webRoot := c.String("web-root")
-	forwardPollURL := c.String("forward-poll-url")
+	upstreamBaseURL := c.String("upstream-base-url")
 	smtpSenderAddr := c.String("smtp-sender-addr")
 	smtpSenderUser := c.String("smtp-sender-user")
 	smtpSenderPass := c.String("smtp-sender-pass")
@@ -149,8 +149,8 @@ func execServe(c *cli.Context) error {
 		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) {
 		return errors.New("if set, web-root must be 'home' or 'app'")
-	} else if forwardPollURL != "" && !strings.HasPrefix(forwardPollURL, "http://") && !strings.HasPrefix(forwardPollURL, "https://") {
-		return errors.New("if set, forward-poll-url must start with http:// or https://")
+	} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
+		return errors.New("if set, upstream-base-url must start with http:// or https://")
 	}
 
 	webRootIsApp := webRoot == "app"
@@ -219,7 +219,7 @@ func execServe(c *cli.Context) error {
 	conf.KeepaliveInterval = keepaliveInterval
 	conf.ManagerInterval = managerInterval
 	conf.WebRootIsApp = webRootIsApp
-	conf.ForwardPollURL = forwardPollURL
+	conf.UpstreamBaseURL = upstreamBaseURL
 	conf.SMTPSenderAddr = smtpSenderAddr
 	conf.SMTPSenderUser = smtpSenderUser
 	conf.SMTPSenderPass = smtpSenderPass
diff --git a/go.mod b/go.mod
index c7abd48..f00c1c3 100644
--- a/go.mod
+++ b/go.mod
@@ -31,7 +31,6 @@ require (
 	cloud.google.com/go/compute v1.6.1 // indirect
 	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/AlekSi/pointer v1.2.0 // indirect
-	github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
@@ -49,7 +48,7 @@ require (
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220526192754-51939a95c655 // indirect
+	google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58 // indirect
 	google.golang.org/grpc v1.46.2 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
diff --git a/go.sum b/go.sum
index 5f020b9..4a4b581 100644
--- a/go.sum
+++ b/go.sum
@@ -27,8 +27,6 @@ cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW
 cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
 cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
 cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
-cloud.google.com/go v0.101.1 h1:3+/0TAm9JD/PyhkrDWQWi2L197h3euCsM+H+J4iYTR8=
-cloud.google.com/go v0.101.1/go.mod h1:55HwjsGW4CHD3JrNuMdZtSDsgTs0CuCB/bBTugD+7AA=
 cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
 cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -58,7 +56,6 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
 cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg=
 cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -73,8 +70,6 @@ github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 h1:R/qAiUxFT3mNgQaNqJe0IVznjKRNm23ohAIh9lgtlzc=
-github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0/go.mod h1:v3ZDlfVAL1OrkKHbGSFFK60k0/7hruHPDq2XMs9Gu6U=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -175,8 +170,6 @@ github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIG
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
 github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
-github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
-github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -242,8 +235,6 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/urfave/cli/v2 v2.7.1 h1:DsAOFeI9T0vmUW4LiGR5mhuCIn5kqGIE4WMU2ytmH00=
-github.com/urfave/cli/v2 v2.7.1/go.mod h1:TYFbtzt/azQoJOrGH5mDfZtS0jIkl/OeFwlRWPR9KRM=
 github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4=
 github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
@@ -267,8 +258,6 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 h1:SLP7Q4Di66FONjDJbCYrCRrh97focO6sLogHO7/g8F0=
-golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
 golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -348,10 +337,7 @@ 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-20220520000938-2e3eb7b945c2 h1:NWy5+hlRbC7HK+PmcXVUmW1IMyFce7to56IUvhUFm7Y=
 golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220524220425-1d687d428aca h1:xTaFYiPROfpPhqrfTIDXj0ri1SpfueYT951s4bAuDO8=
-golang.org/x/net v0.0.0-20220524220425-1d687d428aca/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220526153639-5463443f8c37 h1:lUkvobShwKsOesNfWWlCS5q7fnbG1MEliIzwu886fn8=
 golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -373,7 +359,6 @@ golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 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 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
 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=
@@ -451,8 +436,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbuf
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
-golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/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/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -565,9 +548,7 @@ google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/S
 google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
 google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
 google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
-google.golang.org/api v0.77.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 h1:IQWaGVCYnsm4MO3hh+WtSXMzMzuyFx/fuR8qkN3A0Qo=
 google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
 google.golang.org/api v0.81.0 h1:o8WF5AvfidafWbFjsRyupxyEQJNUWxLZJCK5NXrxZZ8=
 google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko=
@@ -649,22 +630,17 @@ google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2
 google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
-google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/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-20220519153652-3a47de7e79bd h1:e0TwkXOdbnH/1x5rc5MZ/VYyiZ4v+RdVfrGMqEwT68I=
 google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
 google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
-google.golang.org/genproto v0.0.0-20220525015930-6ca3db687a9d h1:8BnRR08DxAQ+e2pFx64Q3Ltg/AkrrxyG1LLa1WpomyA=
-google.golang.org/genproto v0.0.0-20220525015930-6ca3db687a9d/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
-google.golang.org/genproto v0.0.0-20220526192754-51939a95c655 h1:56rmjc5LUAanErbiNrY+s/Nd47wDQEJkpqS7i43M1I0=
-google.golang.org/genproto v0.0.0-20220526192754-51939a95c655/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
+google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58 h1:a221mAAEAzq4Lz6ZWRkcS8ptb2mxoxYSt4N68aRyQHM=
+google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
diff --git a/server/config.go b/server/config.go
index 2bb4b89..4db52a3 100644
--- a/server/config.go
+++ b/server/config.go
@@ -69,7 +69,7 @@ type Config struct {
 	AtSenderInterval                     time.Duration
 	FirebaseKeepaliveInterval            time.Duration
 	FirebasePollInterval                 time.Duration
-	ForwardPollURL                       string
+	UpstreamBaseURL                      string
 	SMTPSenderAddr                       string
 	SMTPSenderUser                       string
 	SMTPSenderPass                       string
diff --git a/server/server.go b/server/server.go
index e3b738d..e7439f5 100644
--- a/server/server.go
+++ b/server/server.go
@@ -440,40 +440,13 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 		}
 	}
 	if s.firebase != nil && firebase && !delayed {
-		go func() {
-			if err := s.firebase(m); err != nil {
-				log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
-			}
-		}()
+		go s.sendToFirebase(v, m)
 	}
 	if s.mailer != nil && email != "" && !delayed {
-		go func() {
-			if err := s.mailer.Send(v.ip, email, m); err != nil {
-				log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
-			}
-		}()
+		go s.sendEmail(v, m, email)
 	}
-	if s.config.ForwardPollURL != "" {
-		go func() {
-			topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
-			topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
-			forwardURL := fmt.Sprintf("%s/%s", s.config.ForwardPollURL, topicHash)
-			log.Printf("forwarding: topicURL %s, to upstream url %s", topicURL, forwardURL)
-			req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
-			if err != nil {
-				log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
-				return
-			}
-			req.Header.Set("X-Poll-ID", m.ID)
-			response, err := http.DefaultClient.Do(req)
-			if err != nil {
-				log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
-				return
-			} else if response.StatusCode != http.StatusOK {
-				log.Printf("[%s] FWD - Unable to forward poll request, unexpected status: %d", v.ip, response.StatusCode)
-				return
-			}
-		}()
+	if s.config.UpstreamBaseURL != "" {
+		go s.forwardPollRequest(v, m)
 	}
 	if cache {
 		if err := s.messageCache.AddMessage(m); err != nil {
@@ -491,6 +464,38 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 	return nil
 }
 
+func (s *Server) sendToFirebase(v *visitor, m *message) {
+	if err := s.firebase(m); err != nil {
+		log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
+	}
+}
+
+func (s *Server) sendEmail(v *visitor, m *message, email string) {
+	if err := s.mailer.Send(v.ip, email, m); err != nil {
+		log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
+	}
+}
+
+func (s *Server) forwardPollRequest(v *visitor, m *message) {
+	topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
+	topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
+	forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
+	req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
+	if err != nil {
+		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
+		return
+	}
+	req.Header.Set("X-Poll-ID", m.ID)
+	response, err := http.DefaultClient.Do(req)
+	if err != nil {
+		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
+		return
+	} else if response.StatusCode != http.StatusOK {
+		log.Printf("[%s] FWD - Unable to forward poll request, unexpected status: %d", v.ip, response.StatusCode)
+		return
+	}
+}
+
 func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
 	cache = readBoolParam(r, true, "x-cache", "cache")
 	firebase = readBoolParam(r, true, "x-firebase", "firebase")
@@ -587,29 +592,31 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
 
 // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
 //
-// 1. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
+// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
+//    If a message is flagged as poll request, the body does not matter and is discarded
+// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
 //    If body is binary, encode as base64, if not do not encode
-// 2. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
+// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
 //    Body must be a message, because we attached an external URL
-// 3. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
+// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
 //    Body must be attachment, because we passed a filename
-// 4. curl -T file.txt ntfy.sh/mytopic
-//    If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
 // 5. curl -T file.txt ntfy.sh/mytopic
+//    If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
+// 6. curl -T file.txt ntfy.sh/mytopic
 //    If file.txt is > message limit, treat it as an attachment
 func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
-	if m.Event == pollRequestEvent {
-		return nil // Ignore body
+	if m.Event == pollRequestEvent { // Case 1
+		return nil
 	} else if unifiedpush {
-		return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
+		return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
 	} else if m.Attachment != nil && m.Attachment.URL != "" {
-		return s.handleBodyAsTextMessage(m, body) // Case 2
+		return s.handleBodyAsTextMessage(m, body) // Case 3
 	} else if m.Attachment != nil && m.Attachment.Name != "" {
-		return s.handleBodyAsAttachment(r, v, m, body) // Case 3
+		return s.handleBodyAsAttachment(r, v, m, body) // Case 4
 	} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
-		return s.handleBodyAsTextMessage(m, body) // Case 4
+		return s.handleBodyAsTextMessage(m, body) // Case 5
 	}
-	return s.handleBodyAsAttachment(r, v, m, body) // Case 5
+	return s.handleBodyAsAttachment(r, v, m, body) // Case 6
 }
 
 func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
@@ -745,7 +752,6 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 	w.Header().Set("Access-Control-Allow-Origin", "*")            // CORS, allow cross-origin requests
 	w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
 	if poll {
-		log.Printf("polling %#v", r.URL)
 		return s.sendOldMessages(topics, since, scheduled, sub)
 	}
 	subscriberIDs := make([]int, 0)
@@ -1114,7 +1120,6 @@ func (s *Server) runFirebaseKeepaliver() {
 				log.Printf("error sending Firebase keepalive message to %s: %s", firebaseControlTopic, err.Error())
 			}
 		case <-time.After(s.config.FirebasePollInterval):
-			log.Printf("Sending to timer topic %s", firebasePollTopic)
 			if err := s.firebase(newKeepaliveMessage(firebasePollTopic)); err != nil {
 				log.Printf("error sending Firebase keepalive message to %s: %s", firebasePollTopic, err.Error())
 			}
diff --git a/server/server.yml b/server/server.yml
index 9502ebc..ce7b1c7 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -135,6 +135,18 @@
 #
 # web-root: app
 
+# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh").
+#
+# iOS users:
+#   If you use the iOS ntfy app, you MUST configure this to receive timely notifications. You'll like want this:
+#   upstream-base-url: "https://ntfy.sh"
+#
+# If set, all incoming messages will publish a "poll_request" message to the configured upstream server, containing
+# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
+# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
+#
+# upstream-base-url:
+
 # Rate limiting: Total number of topics before the server rejects new topics.
 #
 # global-topic-limit: 15000
diff --git a/server/server_firebase.go b/server/server_firebase.go
index 373cb45..40ca806 100644
--- a/server/server_firebase.go
+++ b/server/server_firebase.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"log"
 	"strings"
 
 	firebase "firebase.google.com/go"
@@ -18,39 +17,6 @@ const (
 	fcmApnsBodyMessageLimit = 100
 )
 
-// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
-// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
-// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
-func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
-	s, err := json.Marshal(m)
-	if err != nil {
-		return m
-	}
-	if len(s) > fcmMessageLimit {
-		over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
-		message, ok := m.Data["message"]
-		if ok && len(message) > over {
-			m.Data["truncated"] = "1"
-			m.Data["message"] = message[:len(message)-over]
-		}
-	}
-	return m
-}
-
-// maybeTruncateAPNSBodyMessage truncates the body for APNS.
-//
-// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
-// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
-// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
-// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
-func maybeTruncateAPNSBodyMessage(s string) string {
-	if len(s) >= fcmApnsBodyMessageLimit {
-		over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
-		return s[:len(s)-over] + "..."
-	}
-	return s
-}
-
 func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
 	fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
 	if err != nil {
@@ -65,12 +31,31 @@ func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subsc
 		if err != nil {
 			return err
 		}
-		log.Printf("Sending %#v %#v", m, fbm)
 		_, err = msg.Send(context.Background(), fbm)
 		return err
 	}, nil
 }
 
+// toFirebaseMessage converts a message to a Firebase message.
+//
+// Normal messages ("message"):
+//   - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields
+//     in the "data" attribute. In the Android app, we then turn those into a notification and display it.
+//   - On iOS, we are not allowed to receive data-only messages, so we build messages with an "alert" (with title and
+//     message), and still send the rest of the data along in the "aps" attribute. We can then locally modify the
+//     message in the Notification Service Extension.
+//
+// Keepalive messages ("keepalive"):
+//   - On Android, we subscribe to the "~control" topic, which is used to restart the foreground service (if it died,
+//     e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval).
+//   - On iOS, we subscribe to the "~poll" topic, which is used to poll all topics regularly. This is because iOS
+//     does not allow any background or scheduled activity at all.
+//
+// Poll request messages ("poll_request"):
+//   - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message.
+//     On Android, this will trigger the app to poll the topic and thereby displaying new messages.
+//   - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
+//     to Firebase here. This is mainly for iOS to support self-hosted servers.
 func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
 	var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
 	var apnsConfig *messaging.APNSConfig
@@ -82,24 +67,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
 			"event": m.Event,
 			"topic": m.Topic,
 		}
-		// Silent notification; only 2-3 per hour are allowed; delivery not guaranteed
-		// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
-		apnsData := make(map[string]interface{})
-		for k, v := range data {
-			apnsData[k] = v
-		}
-		apnsConfig = &messaging.APNSConfig{
-			Headers: map[string]string{
-				"apns-push-type": "background",
-				"apns-priority":  "5",
-			},
-			Payload: &messaging.APNSPayload{
-				Aps: &messaging.Aps{
-					ContentAvailable: true,
-				},
-				CustomData: apnsData,
-			},
-		}
+		apnsConfig = createAPNSBackgroundConfig(data)
 	case pollRequestEvent:
 		data = map[string]string{
 			"id":      m.ID,
@@ -109,22 +77,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
 			"message": m.Message,
 			"poll_id": m.PollID,
 		}
-		apnsData := make(map[string]interface{})
-		for k, v := range data {
-			apnsData[k] = v
-		}
-		apnsConfig = &messaging.APNSConfig{
-			Payload: &messaging.APNSPayload{
-				CustomData: apnsData,
-				Aps: &messaging.Aps{
-					MutableContent: true,
-					Alert: &messaging.ApsAlert{
-						Title: m.Title,
-						Body:  maybeTruncateAPNSBodyMessage(m.Message),
-					},
-				},
-			},
-		}
+		apnsConfig = createAPNSAlertConfig(m, data)
 	case messageEvent:
 		allowForward := true
 		if auther != nil {
@@ -157,22 +110,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
 				data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
 				data["attachment_url"] = m.Attachment.URL
 			}
-			apnsData := make(map[string]interface{})
-			for k, v := range data {
-				apnsData[k] = v
-			}
-			apnsConfig = &messaging.APNSConfig{
-				Payload: &messaging.APNSPayload{
-					CustomData: apnsData,
-					Aps: &messaging.Aps{
-						MutableContent: true,
-						Alert: &messaging.ApsAlert{
-							Title: m.Title,
-							Body:  maybeTruncateAPNSBodyMessage(m.Message),
-						},
-					},
-				},
-			}
+			apnsConfig = createAPNSAlertConfig(m, data)
 		} else {
 			// If anonymous read for a topic is not allowed, we cannot send the message along
 			// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
@@ -182,6 +120,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
 				"event": pollRequestEvent,
 				"topic": m.Topic,
 			}
+			// TODO Handle APNS?
 		}
 	}
 	var androidConfig *messaging.AndroidConfig
@@ -197,3 +136,82 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
 		APNS:    apnsConfig,
 	}), nil
 }
+
+// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
+// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
+// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
+func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
+	s, err := json.Marshal(m)
+	if err != nil {
+		return m
+	}
+	if len(s) > fcmMessageLimit {
+		over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
+		message, ok := m.Data["message"]
+		if ok && len(message) > over {
+			m.Data["truncated"] = "1"
+			m.Data["message"] = message[:len(message)-over]
+		}
+	}
+	return m
+}
+
+// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
+// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
+// Extension in iOS can modify the message.
+func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
+	apnsData := make(map[string]interface{})
+	for k, v := range data {
+		apnsData[k] = v
+	}
+	return &messaging.APNSConfig{
+		Payload: &messaging.APNSPayload{
+			CustomData: apnsData,
+			Aps: &messaging.Aps{
+				MutableContent: true,
+				Alert: &messaging.ApsAlert{
+					Title: m.Title,
+					Body:  maybeTruncateAPNSBodyMessage(m.Message),
+				},
+			},
+		},
+	}
+}
+
+// createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only
+// allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll
+// topic, which triggers the iOS app to poll all topics for changes.
+//
+// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
+func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {
+	apnsData := make(map[string]interface{})
+	for k, v := range data {
+		apnsData[k] = v
+	}
+	return &messaging.APNSConfig{
+		Headers: map[string]string{
+			"apns-push-type": "background",
+			"apns-priority":  "5",
+		},
+		Payload: &messaging.APNSPayload{
+			Aps: &messaging.Aps{
+				ContentAvailable: true,
+			},
+			CustomData: apnsData,
+		},
+	}
+}
+
+// maybeTruncateAPNSBodyMessage truncates the body for APNS.
+//
+// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
+// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
+// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
+// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
+func maybeTruncateAPNSBodyMessage(s string) string {
+	if len(s) >= fcmApnsBodyMessageLimit {
+		over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
+		return s[:len(s)-over] + "..."
+	}
+	return s
+}
diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go
index 1fdd8a6..c990b93 100644
--- a/server/server_firebase_test.go
+++ b/server/server_firebase_test.go
@@ -32,6 +32,23 @@ func TestToFirebaseMessage_Keepalive(t *testing.T) {
 	require.Nil(t, err)
 	require.Equal(t, "mytopic", fbm.Topic)
 	require.Nil(t, fbm.Android)
+	require.Equal(t, &messaging.APNSConfig{
+		Headers: map[string]string{
+			"apns-push-type": "background",
+			"apns-priority":  "5",
+		},
+		Payload: &messaging.APNSPayload{
+			Aps: &messaging.Aps{
+				ContentAvailable: true,
+			},
+			CustomData: map[string]interface{}{
+				"id":    m.ID,
+				"time":  fmt.Sprintf("%d", m.Time),
+				"event": m.Event,
+				"topic": m.Topic,
+			},
+		},
+	}, fbm.APNS)
 	require.Equal(t, map[string]string{
 		"id":    m.ID,
 		"time":  fmt.Sprintf("%d", m.Time),
@@ -46,6 +63,23 @@ func TestToFirebaseMessage_Open(t *testing.T) {
 	require.Nil(t, err)
 	require.Equal(t, "mytopic", fbm.Topic)
 	require.Nil(t, fbm.Android)
+	require.Equal(t, &messaging.APNSConfig{
+		Headers: map[string]string{
+			"apns-push-type": "background",
+			"apns-priority":  "5",
+		},
+		Payload: &messaging.APNSPayload{
+			Aps: &messaging.Aps{
+				ContentAvailable: true,
+			},
+			CustomData: map[string]interface{}{
+				"id":    m.ID,
+				"time":  fmt.Sprintf("%d", m.Time),
+				"event": m.Event,
+				"topic": m.Topic,
+			},
+		},
+	}, fbm.APNS)
 	require.Equal(t, map[string]string{
 		"id":    m.ID,
 		"time":  fmt.Sprintf("%d", m.Time),
@@ -60,6 +94,25 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
 	m.Tags = []string{"tag 1", "tag2"}
 	m.Click = "https://google.com"
 	m.Title = "some title"
+	m.Actions = []*action{
+		{
+			ID:     "123",
+			Action: "view",
+			Label:  "Open page",
+			Clear:  true,
+			URL:    "https://ntfy.sh",
+		},
+		{
+			ID:     "456",
+			Action: "http",
+			Label:  "Close door",
+			URL:    "https://door.com/close",
+			Method: "PUT",
+			Headers: map[string]string{
+				"really": "yes",
+			},
+		},
+	}
 	m.Attachment = &attachment{
 		Name:    "some file.jpg",
 		Type:    "image/jpeg",
@@ -74,6 +127,35 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
 	require.Equal(t, &messaging.AndroidConfig{
 		Priority: "high",
 	}, fbm.Android)
+	require.Equal(t, &messaging.APNSConfig{
+		Payload: &messaging.APNSPayload{
+			Aps: &messaging.Aps{
+				MutableContent: true,
+				Alert: &messaging.ApsAlert{
+					Title: "some title",
+					Body:  "this is a message",
+				},
+			},
+			CustomData: map[string]interface{}{
+				"id":                 m.ID,
+				"time":               fmt.Sprintf("%d", m.Time),
+				"event":              "message",
+				"topic":              "mytopic",
+				"priority":           "4",
+				"tags":               strings.Join(m.Tags, ","),
+				"click":              "https://google.com",
+				"title":              "some title",
+				"message":            "this is a message",
+				"actions":            `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
+				"encoding":           "",
+				"attachment_name":    "some file.jpg",
+				"attachment_type":    "image/jpeg",
+				"attachment_size":    "12345",
+				"attachment_expires": "98765543",
+				"attachment_url":     "https://example.com/file.jpg",
+			},
+		},
+	}, fbm.APNS)
 	require.Equal(t, map[string]string{
 		"id":                 m.ID,
 		"time":               fmt.Sprintf("%d", m.Time),
@@ -84,6 +166,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
 		"click":              "https://google.com",
 		"title":              "some title",
 		"message":            "this is a message",
+		"actions":            `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
 		"encoding":           "",
 		"attachment_name":    "some file.jpg",
 		"attachment_type":    "image/jpeg",
@@ -112,6 +195,41 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
 	}, fbm.Data)
 }
 
+func TestToFirebaseMessage_PollRequest(t *testing.T) {
+	m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6")
+	fbm, err := toFirebaseMessage(m, nil)
+	require.Nil(t, err)
+	require.Equal(t, "mytopic", fbm.Topic)
+	require.Nil(t, fbm.Android)
+	require.Equal(t, &messaging.APNSConfig{
+		Payload: &messaging.APNSPayload{
+			Aps: &messaging.Aps{
+				MutableContent: true,
+				Alert: &messaging.ApsAlert{
+					Title: "",
+					Body:  "New message",
+				},
+			},
+			CustomData: map[string]interface{}{
+				"id":      m.ID,
+				"time":    fmt.Sprintf("%d", m.Time),
+				"event":   "poll_request",
+				"topic":   "mytopic",
+				"message": "New message",
+				"poll_id": "fOv6k1QbCzo6",
+			},
+		},
+	}, fbm.APNS)
+	require.Equal(t, map[string]string{
+		"id":      m.ID,
+		"time":    fmt.Sprintf("%d", m.Time),
+		"event":   "poll_request",
+		"topic":   "mytopic",
+		"message": "New message",
+		"poll_id": "fOv6k1QbCzo6",
+	}, fbm.Data)
+}
+
 func TestMaybeTruncateFCMMessage(t *testing.T) {
 	origMessage := strings.Repeat("this is a long string", 300)
 	origFCMMessage := &messaging.Message{
diff --git a/web/package-lock.json b/web/package-lock.json
index 0c37763..50828d4 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -5335,9 +5335,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001343",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001343.tgz",
-      "integrity": "sha512-8KeCrAtPMabo/XW14B+R9sZYoClx1n0b+WYgwDKZPtWR3TcdvWzdSy7mPyFEmR5WU1St9v1PW6sdO5dkFOEzfA==",
+      "version": "1.0.30001344",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz",
+      "integrity": "sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g==",
       "funding": [
         {
           "type": "opencollective",
@@ -5694,7 +5694,7 @@
     "node_modules/cookie-signature": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
-      "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
     "node_modules/core-js": {
       "version": "3.22.7",
@@ -6372,13 +6372,13 @@
       "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
     },
     "node_modules/detective": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
-      "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==",
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz",
+      "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==",
       "dependencies": {
-        "acorn-node": "^1.6.1",
+        "acorn-node": "^1.8.2",
         "defined": "^1.0.0",
-        "minimist": "^1.1.1"
+        "minimist": "^1.2.6"
       },
       "bin": {
         "detective": "bin/detective.js"
@@ -6595,9 +6595,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.139",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.139.tgz",
-      "integrity": "sha512-lYxzcUCjWxxVug+A7UxBCUiVr13TCjfZFYJS9Lq1VpU/ErwV4a6zUQo9dfojuGpw/L/x9REGuBl6ICQPGgbs3g=="
+      "version": "1.4.140",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.140.tgz",
+      "integrity": "sha512-NLz5va823QfJBYOO/hLV4AfU4Crmkl/6Hl2pH3qdJcmi0ySZ3YTWHxOlDm3uJOFBEPy3pIhu8gKQo6prQTWKKA=="
     },
     "node_modules/emittery": {
       "version": "0.8.1",
@@ -7817,9 +7817,9 @@
       "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
     },
     "node_modules/follow-redirects": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.0.tgz",
-      "integrity": "sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ==",
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
+      "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
       "funding": [
         {
           "type": "individual",
@@ -14359,9 +14359,9 @@
       }
     },
     "node_modules/rollup": {
-      "version": "2.74.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.74.1.tgz",
-      "integrity": "sha512-K2zW7kV8Voua5eGkbnBtWYfMIhYhT9Pel2uhBk2WO5eMee161nPze/XRfvEQPFYz7KgrCCnmh2Wy0AMFLGGmMA==",
+      "version": "2.75.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.0.tgz",
+      "integrity": "sha512-1/wxtweHJ7YwI2AIK3ZgCBU3nbW8sLnBIFwN46cwOTnVzt8f1o6J8zPKjwoiuADvzSjmnLqJce31p0q2vQ+dqw==",
       "bin": {
         "rollup": "dist/bin/rollup"
       },
@@ -20477,9 +20477,9 @@
       }
     },
     "caniuse-lite": {
-      "version": "1.0.30001343",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001343.tgz",
-      "integrity": "sha512-8KeCrAtPMabo/XW14B+R9sZYoClx1n0b+WYgwDKZPtWR3TcdvWzdSy7mPyFEmR5WU1St9v1PW6sdO5dkFOEzfA=="
+      "version": "1.0.30001344",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz",
+      "integrity": "sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g=="
     },
     "case-sensitive-paths-webpack-plugin": {
       "version": "2.4.0",
@@ -20747,7 +20747,7 @@
     "cookie-signature": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
-      "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
     "core-js": {
       "version": "3.22.7",
@@ -21217,13 +21217,13 @@
       }
     },
     "detective": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
-      "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==",
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz",
+      "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==",
       "requires": {
-        "acorn-node": "^1.6.1",
+        "acorn-node": "^1.8.2",
         "defined": "^1.0.0",
-        "minimist": "^1.1.1"
+        "minimist": "^1.2.6"
       }
     },
     "dexie": {
@@ -21384,9 +21384,9 @@
       }
     },
     "electron-to-chromium": {
-      "version": "1.4.139",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.139.tgz",
-      "integrity": "sha512-lYxzcUCjWxxVug+A7UxBCUiVr13TCjfZFYJS9Lq1VpU/ErwV4a6zUQo9dfojuGpw/L/x9REGuBl6ICQPGgbs3g=="
+      "version": "1.4.140",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.140.tgz",
+      "integrity": "sha512-NLz5va823QfJBYOO/hLV4AfU4Crmkl/6Hl2pH3qdJcmi0ySZ3YTWHxOlDm3uJOFBEPy3pIhu8gKQo6prQTWKKA=="
     },
     "emittery": {
       "version": "0.8.1",
@@ -22296,9 +22296,9 @@
       "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
     },
     "follow-redirects": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.0.tgz",
-      "integrity": "sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ=="
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
+      "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
     },
     "fork-ts-checker-webpack-plugin": {
       "version": "6.5.2",
@@ -26848,9 +26848,9 @@
       }
     },
     "rollup": {
-      "version": "2.74.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.74.1.tgz",
-      "integrity": "sha512-K2zW7kV8Voua5eGkbnBtWYfMIhYhT9Pel2uhBk2WO5eMee161nPze/XRfvEQPFYz7KgrCCnmh2Wy0AMFLGGmMA==",
+      "version": "2.75.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.0.tgz",
+      "integrity": "sha512-1/wxtweHJ7YwI2AIK3ZgCBU3nbW8sLnBIFwN46cwOTnVzt8f1o6J8zPKjwoiuADvzSjmnLqJce31p0q2vQ+dqw==",
       "requires": {
         "fsevents": "~2.3.2"
       }
-- 
GitLab