From e9f3edb76bd0ffbf9bd1d9a99e5415e035c8652f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 13 Jun 2022 22:07:30 -0400 Subject: [PATCH 1/9] WIP: Matrix --- cmd/serve.go | 4 ++-- go.sum | 13 ------------- server/errors.go | 2 ++ server/server.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index c281f3ec..b28bf46e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -152,8 +152,8 @@ func execServe(c *cli.Context) error { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { return errors.New("if attachment-cache-dir is set, base-url must also be set") - } else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { - return errors.New("if set, base-url must start with http:// or https://") + } else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") && strings.HasSuffix(baseURL, "/") { + return errors.New("if set, base-url must start with http:// or https://, and must not end with a slash (/)") } else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) { diff --git a/go.sum b/go.sum index 941fa920..d3e95d01 100644 --- a/go.sum +++ b/go.sum @@ -341,9 +341,6 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA= -golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -366,7 +363,6 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 h1:zwrSfklXn0gxyLRX/aR+q6cgHbV/ItVyzbPlbA+dkAw= golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb h1:8tDJ3aechhddbdPAxpycgXHJRMLpk/Ab+aa4OgdN5/g= golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= @@ -381,7 +377,6 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -443,7 +438,6 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d h1:Zu/JngovGLVi6t2J3nmAf3AoTDwuzw85YZ3b9o4yU7s= golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -464,8 +458,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= -golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U= golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -524,7 +516,6 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0= golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= @@ -571,8 +562,6 @@ google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRR google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= -google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw= -google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os= google.golang.org/api v0.83.0 h1:pMvST+6v+46Gabac4zlJlalxZjCeRcepwg2EdBU+nCc= google.golang.org/api v0.83.0/go.mod h1:CNywQoj/AfhTw26ZWAa6LwOv+6WFxHmeLPZq2uncLZk= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -669,8 +658,6 @@ google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= -google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM= google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac h1:ByeiW1F67iV9o8ipGskA+HWzSkMbRJuKLlwCdPxzn7A= google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= diff --git a/server/errors.go b/server/errors.go index 32c1b3b9..6d217352 100644 --- a/server/errors.go +++ b/server/errors.go @@ -50,6 +50,7 @@ var ( errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"} errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"} errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"} + errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} @@ -61,4 +62,5 @@ var ( errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} + errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"} ) diff --git a/server/server.go b/server/server.go index 2f01b07f..5042e477 100644 --- a/server/server.go +++ b/server/server.go @@ -68,6 +68,7 @@ var ( webConfigPath = "/config.js" userStatsPath = "/user/stats" + matrixPushPath = "/_matrix/push/v1/notify" staticRegex = regexp.MustCompile(`^/static/.+`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) @@ -296,6 +297,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleOptions)(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath { + return s.limitRequests(s.transformMatrixJSON(s.authWrite(s.handlePublish)))(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { @@ -1286,6 +1289,51 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { } } +type matrixMessage struct { + Notification *matrixNotification `json:"notification"` +} + +type matrixNotification struct { + Devices []*matrixDevice `json:"devices"` +} + +type matrixDevice struct { + PushKey string `json:"pushkey"` +} + +func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.BaseURL == "" { + return errHTTPInternalErrorMissingBaseURL + } + body, err := util.Peek(r.Body, s.config.MessageLimit) + if err != nil { + return err + } + defer r.Body.Close() + var m matrixMessage + if err := json.NewDecoder(body).Decode(&m); err != nil { + return errHTTPBadRequestMatrixMessageInvalid + } else if m.Notification == nil || len(m.Notification.Devices) == 0 { + return errHTTPBadRequestMatrixMessageInvalid + } else if !strings.HasPrefix(m.Notification.Devices[0].PushKey, s.config.BaseURL+"/") { + return errHTTPBadRequestMatrixMessageInvalid + } + u, err := url.Parse(m.Notification.Devices[0].PushKey) + if err != nil { + return errHTTPBadRequestMatrixMessageInvalid + } + r.URL.Path = u.Path + r.URL.RawQuery = u.RawQuery + r.RequestURI = u.RequestURI() + r.Body = io.NopCloser(bytes.NewReader(body.PeekedBytes)) + if err := next(w, r, v); err != nil { + return nil + } + return nil + } +} + func (s *Server) authWrite(next handleFunc) handleFunc { return s.withAuth(next, auth.PermissionWrite) } From 27910772f09362c7c13f5579349fb116138d6675 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 14 Jun 2022 20:43:17 -0400 Subject: [PATCH 2/9] Derpyderp --- server/server.go | 79 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/server/server.go b/server/server.go index 5042e477..deb94b54 100644 --- a/server/server.go +++ b/server/server.go @@ -298,7 +298,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath { - return s.limitRequests(s.transformMatrixJSON(s.authWrite(s.handlePublish)))(w, r, v) + return s.limitRequests(s.transformMatrixJSON(s.authWrite(s.handlePublishMatrix)))(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { @@ -428,25 +428,25 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) return nil } -func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) { t, err := s.topicFromPath(r.URL.Path) if err != nil { - return err + return nil, err } body, err := util.Peek(r.Body, s.config.MessageLimit) if err != nil { - return err + return nil, err } m := newDefaultMessage(t.ID, "") cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m) if err != nil { - return err + return nil, err } if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { - return err + return nil, err } if m.Message == "" { m.Message = emptyMessageBody @@ -459,7 +459,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito } if !delayed { if err := t.Publish(v, m); err != nil { - return err + return nil, err } if s.firebaseClient != nil && firebase { go s.sendToFirebase(v, m) @@ -475,17 +475,44 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito } if cache { if err := s.messageCache.AddMessage(m); err != nil { - return err + return nil, err } } + s.mu.Lock() + s.messages++ + s.mu.Unlock() + return m, nil +} + +func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { + m, err := s.handlePublishWithoutResponse(r, v) + if err != nil { + return err + } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests if err := json.NewEncoder(w).Encode(m); err != nil { return err } - s.mu.Lock() - s.messages++ - s.mu.Unlock() + return nil +} + +func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { + pushKey := r.Header.Get("X-Matrix-Pushkey") + if pushKey == "" { + return errHTTPBadRequestMatrixMessageInvalid + } + response := &matrixResponse{ + Rejected: make([]string, 0), + } + _, err := s.handlePublishWithoutResponse(r, v) + if err != nil { + response.Rejected = append(response.Rejected, pushKey) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } return nil } @@ -1301,6 +1328,10 @@ type matrixDevice struct { PushKey string `json:"pushkey"` } +type matrixResponse struct { + Rejected []string `json:"rejected"` +} + func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.BaseURL == "" { @@ -1314,26 +1345,40 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { var m matrixMessage if err := json.NewDecoder(body).Decode(&m); err != nil { return errHTTPBadRequestMatrixMessageInvalid - } else if m.Notification == nil || len(m.Notification.Devices) == 0 { - return errHTTPBadRequestMatrixMessageInvalid - } else if !strings.HasPrefix(m.Notification.Devices[0].PushKey, s.config.BaseURL+"/") { + } else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" { return errHTTPBadRequestMatrixMessageInvalid } - u, err := url.Parse(m.Notification.Devices[0].PushKey) + pushKey := m.Notification.Devices[0].PushKey + if !strings.HasPrefix(pushKey, s.config.BaseURL+"/") { + return matrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) + } + u, err := url.Parse(pushKey) if err != nil { - return errHTTPBadRequestMatrixMessageInvalid + return matrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) } r.URL.Path = u.Path r.URL.RawQuery = u.RawQuery r.RequestURI = u.RequestURI() r.Body = io.NopCloser(bytes.NewReader(body.PeekedBytes)) + r.Header.Set("X-Matrix-Pushkey", pushKey) if err := next(w, r, v); err != nil { - return nil + return matrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) } return nil } } +func matrixError(w http.ResponseWriter, pushKey string, err error) error { + log.Debug("Matrix message with push key %s rejected: %s", pushKey, err.Error()) + response := &matrixResponse{ + Rejected: []string{pushKey}, + } + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } + return nil +} + func (s *Server) authWrite(next handleFunc) handleFunc { return s.withAuth(next, auth.PermissionWrite) } From 91375b2e8e5b3d7e3bdd0aebba8ecb9a08ed59a7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 15 Jun 2022 16:03:12 -0400 Subject: [PATCH 3/9] Minor refactor, added GET --- server/errors.go | 1 + server/server.go | 57 ++++++++++------------------------------- server/server_matrix.go | 56 ++++++++++++++++++++++++++++++++++++++++ server/server_test.go | 42 ++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 44 deletions(-) create mode 100644 server/server_matrix.go diff --git a/server/errors.go b/server/errors.go index 6d217352..28aa4be6 100644 --- a/server/errors.go +++ b/server/errors.go @@ -51,6 +51,7 @@ var ( errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"} errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"} errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"} + errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: Push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} diff --git a/server/server.go b/server/server.go index deb94b54..78d7fd9b 100644 --- a/server/server.go +++ b/server/server.go @@ -287,6 +287,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { return s.handleUserStats(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { + return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { @@ -428,6 +430,10 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) return nil } +func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error { + return handleMatrixDiscovery(w) +} + func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) { t, err := s.topicFromPath(r.URL.Path) if err != nil { @@ -498,22 +504,12 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { - pushKey := r.Header.Get("X-Matrix-Pushkey") - if pushKey == "" { - return errHTTPBadRequestMatrixMessageInvalid - } - response := &matrixResponse{ - Rejected: make([]string, 0), - } _, err := s.handlePublishWithoutResponse(r, v) if err != nil { - response.Rejected = append(response.Rejected, pushKey) + pushKey := r.Header.Get(matrixPushkeyHeader) + return writeMatrixError(w, pushKey, err) } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - return err - } - return nil + return writeMatrixSuccess(w) } func (s *Server) sendToFirebase(v *visitor, m *message) { @@ -1316,22 +1312,6 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { } } -type matrixMessage struct { - Notification *matrixNotification `json:"notification"` -} - -type matrixNotification struct { - Devices []*matrixDevice `json:"devices"` -} - -type matrixDevice struct { - PushKey string `json:"pushkey"` -} - -type matrixResponse struct { - Rejected []string `json:"rejected"` -} - func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.BaseURL == "" { @@ -1350,35 +1330,24 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { } pushKey := m.Notification.Devices[0].PushKey if !strings.HasPrefix(pushKey, s.config.BaseURL+"/") { - return matrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) + return writeMatrixError(w, pushKey, errHTTPBadRequestMatrixPushkeyBaseURLMismatch) } u, err := url.Parse(pushKey) if err != nil { - return matrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) + return writeMatrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) } r.URL.Path = u.Path r.URL.RawQuery = u.RawQuery r.RequestURI = u.RequestURI() r.Body = io.NopCloser(bytes.NewReader(body.PeekedBytes)) - r.Header.Set("X-Matrix-Pushkey", pushKey) + r.Header.Set(matrixPushkeyHeader, pushKey) if err := next(w, r, v); err != nil { - return matrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) + return writeMatrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) } return nil } } -func matrixError(w http.ResponseWriter, pushKey string, err error) error { - log.Debug("Matrix message with push key %s rejected: %s", pushKey, err.Error()) - response := &matrixResponse{ - Rejected: []string{pushKey}, - } - if err := json.NewEncoder(w).Encode(response); err != nil { - return err - } - return nil -} - func (s *Server) authWrite(next handleFunc) handleFunc { return s.withAuth(next, auth.PermissionWrite) } diff --git a/server/server_matrix.go b/server/server_matrix.go new file mode 100644 index 00000000..c8b3eca4 --- /dev/null +++ b/server/server_matrix.go @@ -0,0 +1,56 @@ +package server + +import ( + "encoding/json" + "heckel.io/ntfy/log" + "io" + "net/http" +) + +const ( + matrixPushkeyHeader = "X-Matrix-Pushkey" +) + +type matrixMessage struct { + Notification *matrixNotification `json:"notification"` +} + +type matrixNotification struct { + Devices []*matrixDevice `json:"devices"` +} + +type matrixDevice struct { + PushKey string `json:"pushkey"` +} + +type matrixResponse struct { + Rejected []string `json:"rejected"` +} + +func handleMatrixDiscovery(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(w, `{"unifiedpush":{"gateway":"matrix"}}`+"\n") + return err +} + +func writeMatrixError(w http.ResponseWriter, pushKey string, err error) error { + log.Debug("Matrix message with push key %s rejected: %s", pushKey, err.Error()) + response := &matrixResponse{ + Rejected: []string{pushKey}, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } + return nil +} + +func writeMatrixSuccess(w http.ResponseWriter) error { + response := &matrixResponse{ + Rejected: make([]string, 0), + } + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } + return nil +} diff --git a/server/server_test.go b/server/server_test.go index 32f4fc2d..343d502b 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -916,6 +916,48 @@ func TestServer_PublishUnifiedPushText(t *testing.T) { require.Equal(t, "this is a unifiedpush text message", m.Message) } +func TestServer_MatrixGateway_Discovery(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String()) +} + +func TestServer_MatrixGateway_Push_Success(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, notification, m.Message) +} + +func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String()) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "", response.Body.String()) // Empty! +} + +func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + notification := `{"message":"this is not really a Matrix message"}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 400, response.Code) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 40019, err.Code) + require.Equal(t, 400, err.HTTPCode) +} + func TestServer_PublishActions_AndPoll(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{ From ebbc2838ba5472ee10fe0c4bba09494bc08c814d Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 15 Jun 2022 20:36:49 -0400 Subject: [PATCH 4/9] Move error handling to main error handling; move matrix logic to its own file --- server/errors.go | 2 +- server/server.go | 36 +++++--------------- server/server_matrix.go | 73 +++++++++++++++++++++++++++++++++-------- 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/server/errors.go b/server/errors.go index 28aa4be6..5a62de2e 100644 --- a/server/errors.go +++ b/server/errors.go @@ -51,7 +51,7 @@ var ( errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"} errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"} errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"} - errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: Push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"} + errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} diff --git a/server/server.go b/server/server.go index 78d7fd9b..2aa114a2 100644 --- a/server/server.go +++ b/server/server.go @@ -259,6 +259,10 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { } return // Do not attempt to write to upgraded connection } + if matrixErr, ok := err.(*errMatrix); ok { + writeMatrixError(w, r, v, matrixErr) + return + } httpErr, ok := err.(*errHTTP) if !ok { httpErr = errHTTPInternalError @@ -506,8 +510,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { _, err := s.handlePublishWithoutResponse(r, v) if err != nil { - pushKey := r.Header.Get(matrixPushkeyHeader) - return writeMatrixError(w, pushKey, err) + return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err} } return writeMatrixSuccess(w) } @@ -1314,35 +1317,12 @@ 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 { - if s.config.BaseURL == "" { - return errHTTPInternalErrorMissingBaseURL - } - body, err := util.Peek(r.Body, s.config.MessageLimit) + newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit) if err != nil { return err } - defer r.Body.Close() - var m matrixMessage - if err := json.NewDecoder(body).Decode(&m); err != nil { - return errHTTPBadRequestMatrixMessageInvalid - } else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" { - return errHTTPBadRequestMatrixMessageInvalid - } - pushKey := m.Notification.Devices[0].PushKey - if !strings.HasPrefix(pushKey, s.config.BaseURL+"/") { - return writeMatrixError(w, pushKey, errHTTPBadRequestMatrixPushkeyBaseURLMismatch) - } - u, err := url.Parse(pushKey) - if err != nil { - return writeMatrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) - } - r.URL.Path = u.Path - r.URL.RawQuery = u.RawQuery - r.RequestURI = u.RequestURI() - r.Body = io.NopCloser(bytes.NewReader(body.PeekedBytes)) - r.Header.Set(matrixPushkeyHeader, pushKey) - if err := next(w, r, v); err != nil { - return writeMatrixError(w, pushKey, errHTTPBadRequestMatrixMessageInvalid) + if err := next(w, newRequest, v); err != nil { + return &errMatrix{pushKey: newRequest.Header.Get(matrixPushKeyHeader), err: err} } return nil } diff --git a/server/server_matrix.go b/server/server_matrix.go index c8b3eca4..c20aff46 100644 --- a/server/server_matrix.go +++ b/server/server_matrix.go @@ -1,14 +1,18 @@ package server import ( + "bytes" "encoding/json" + "fmt" "heckel.io/ntfy/log" + "heckel.io/ntfy/util" "io" "net/http" + "strings" ) const ( - matrixPushkeyHeader = "X-Matrix-Pushkey" + matrixPushKeyHeader = "X-Matrix-Pushkey" ) type matrixMessage struct { @@ -27,16 +31,67 @@ type matrixResponse struct { Rejected []string `json:"rejected"` } +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) +} + +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() + var m matrixMessage + if err := json.NewDecoder(body).Decode(&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 + 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.Header.Set(matrixPushKeyHeader, pushKey) + return newRequest, nil +} + func handleMatrixDiscovery(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") _, err := io.WriteString(w, `{"unifiedpush":{"gateway":"matrix"}}`+"\n") return err } -func writeMatrixError(w http.ResponseWriter, pushKey string, err error) error { - log.Debug("Matrix message with push key %s rejected: %s", pushKey, err.Error()) +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) +} + +func writeMatrixSuccess(w http.ResponseWriter) error { + return writeMatrixResponse(w, "") +} + +func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error { + rejected := make([]string, 0) + if rejectedPushKey != "" { + rejected = append(rejected, rejectedPushKey) + } response := &matrixResponse{ - Rejected: []string{pushKey}, + Rejected: rejected, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { @@ -44,13 +99,3 @@ func writeMatrixError(w http.ResponseWriter, pushKey string, err error) error { } return nil } - -func writeMatrixSuccess(w http.ResponseWriter) error { - response := &matrixResponse{ - Rejected: make([]string, 0), - } - if err := json.NewEncoder(w).Encode(response); err != nil { - return err - } - return nil -} From 0ff8e968ca2aa7f2109f25249e29b07b15fee944 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 15 Jun 2022 20:51:42 -0400 Subject: [PATCH 5/9] Docs --- server/server_matrix.go | 46 +++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/server/server_matrix.go b/server/server_matrix.go index c20aff46..3bc59d35 100644 --- a/server/server_matrix.go +++ b/server/server_matrix.go @@ -15,16 +15,27 @@ const ( matrixPushKeyHeader = "X-Matrix-Pushkey" ) -type matrixMessage struct { - Notification *matrixNotification `json:"notification"` -} - -type matrixNotification struct { - Devices []*matrixDevice `json:"devices"` -} - -type matrixDevice struct { - PushKey string `json:"pushkey"` +// 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"` } type matrixResponse struct { @@ -43,6 +54,19 @@ func (e errMatrix) Error() string { return fmt.Sprintf("message with push key %s rejected", e.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 @@ -52,7 +76,7 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) return nil, err } defer r.Body.Close() - var m matrixMessage + var m matrixRequest if err := json.NewDecoder(body).Decode(&m); err != nil { return nil, errHTTPBadRequestMatrixMessageInvalid } else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" { From 18bd3c0e55c6bb1fddb128af8ecf25df37d14808 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 16 Jun 2022 11:40:56 -0400 Subject: [PATCH 6/9] Docs and Matrix tests --- docs/examples.md | 77 ++++++++++++++++++++++++------------ docs/publish.md | 16 ++++++++ docs/releases.md | 41 +++++++++---------- docs/static/css/extra.css | 3 +- docs/subscribe/api.md | 2 +- server/example.html | 56 -------------------------- server/server.go | 12 +----- server/server_matrix.go | 53 ++++++++++++++++++++++--- server/server_matrix_test.go | 21 ++++++++++ server/server_test.go | 19 +++++---- server/smtp_server_test.go | 9 ----- util/peek.go | 3 +- 12 files changed, 172 insertions(+), 140 deletions(-) delete mode 100644 server/example.html create mode 100644 server/server_matrix_test.go diff --git a/docs/examples.md b/docs/examples.md index 6183b670..bbda91a8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -9,7 +9,9 @@ those out, too. [create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that I cannot guarantee that all of these examples are functional. Many of them I have not tried myself. -## A long process is done: backups, copying data, pipelines, ... +## Cronjobs +ntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...). + I started adding notifications pretty much all of my scripts. Typically, I just chain the curl call directly to the command I'm running. The following example will either send Laptop backup succeeded or ⚠️ Laptop backup failed directly to my phone: @@ -21,6 +23,15 @@ rsync -a root@laptop /backups/laptop \ || curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups ``` +Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with +GitHub have been hopeless. In case it ever becomes available, I want to know immediately. + +``` cron +# Check github/ntfy user +*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi +``` + + ## Low disk space alerts Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but effective. @@ -42,11 +53,7 @@ if [ -n "$avail" ]; then fi ``` -## Server-sent messages in your web app -Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own -web application. Check out the live example. - -## Notify on SSH login +## SSH login alerts Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I own, I now message myself. Here's an example of how to use PAM to notify yourself on SSH login. @@ -102,7 +109,7 @@ One of my co-workers uses the following Ansible task to let him know when things body: "{{ inventory_hostname }} reseeding complete" ``` -## Watchtower notifications (shoutrrr) +## Watchtower (shoutrrr) You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send [Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic. @@ -121,16 +128,7 @@ Or, if you only want to send notifications using shoutrrr: shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" ``` -## Random cronjobs -Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with -GitHub have been hopeless. In case it ever becomes available, I want to know immediately. - -``` cron -# Check github/ntfy user -*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi -``` - -## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd) +## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc. Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts). @@ -343,7 +341,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder ![Node red picture flow](static/img/nodered-picture.png) -## Gatus service health check +## Gatus An example for a custom alert with [Gatus](https://github.com/TwiN/gatus): ``` yaml @@ -435,11 +433,38 @@ notify: ``` ## Uptime Kuma -- Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification** -- ![Uptime Kuma Settings](static/img/uptimekuma-settings.png) -- Set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)** -- ![Uptime Kuma Setup](static/img/uptimekuma-setup.png) -- You can now test the notifications and apply them to monitors. -- ![Uptime Kuma iOS Test](static/img/uptimekuma-ios-test.jpg) -- ![Uptime Kuma iOS Down](static/img/uptimekuma-ios-down.jpg) -- ![Uptime Kuma iOS Up](static/img/uptimekuma-ios-up.jpg) \ No newline at end of file +Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**. +Then set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)**: + +
+ + +
+ + +You can now test the notifications and apply them to monitors: + +
+ + + +
+ +## Apprise +ntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the +[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)). + +You can use it like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://mytopic +``` + +Or with your own server like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://ntfy.example.com/mytopic +``` + diff --git a/docs/publish.md b/docs/publish.md index 5f0ae585..82f7beb0 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2735,6 +2735,22 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64. +### Matrix Gateway +The ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with +[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate +with self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since +you don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)). + +In short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)), +and forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the +ntfy Android app, and passed on to the Matrix client there. + +There is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the +ntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol. + +!!! info + This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy. + ## Public topics Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics that you can use to try out what [authentication and access control](#authentication) looks like. diff --git a/docs/releases.md b/docs/releases.md index 56e7fe1f..9c0c6203 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,6 +2,27 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). +## ntfy server v1.26.0 (UNRELEASED) + +**Features:** + +* ntfy now is a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with [UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/), [#319](https://github.com/binwiederhier/ntfy/issues/319)/[#326](https://github.com/binwiederhier/ntfy/pull/326), thanks to [@MayeulC](https://github.com/MayeulC) for reporting) +* Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu)) +* [Uptime Kuma](https://github.com/louislam/uptime-kuma) now allows publishing to ntfy ([uptime-kuma#1674](https://github.com/louislam/uptime-kuma/pull/1674), thanks to [@philippdormann](https://github.com/philippdormann)) +* Display ntfy version in `ntfy serve` command ([#314](https://github.com/binwiederhier/ntfy/issues/314), thanks to [@poblabs](https://github.com/poblabs)) + +**Bugs:** + +* Web app: Show "notifications not supported" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) + +**Documentation** + +* Added [example](examples.md) for [Uptime Kuma](https://github.com/louislam/uptime-kuma) integration ([#315](https://github.com/binwiederhier/ntfy/pull/315), thanks to [@philippdormann](https://github.com/philippdormann)) +* Fix Docker install instructions ([#320](https://github.com/binwiederhier/ntfy/issues/320), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) +* Add clarifying comments to base-url ([#322](https://github.com/binwiederhier/ntfy/issues/322), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) +* Update FAQ for iOS app ([#321](https://github.com/binwiederhier/ntfy/issues/321), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) + + 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/). @@ -30,6 +57,7 @@ const ( // ] // } // } +// type matrixRequest struct { Notification *struct { Devices []*struct { @@ -38,10 +66,13 @@ type matrixRequest struct { } `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 @@ -54,6 +85,12 @@ func (e errMatrix) Error() string { 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. // @@ -82,7 +119,7 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) } else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" { return nil, errHTTPBadRequestMatrixMessageInvalid } - pushKey := m.Notification.Devices[0].PushKey + 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} } @@ -94,21 +131,27 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) return newRequest, nil } -func handleMatrixDiscovery(w http.ResponseWriter) error { +// 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 != "" { diff --git a/server/server_matrix_test.go b/server/server_matrix_test.go new file mode 100644 index 00000000..d8263775 --- /dev/null +++ b/server/server_matrix_test.go @@ -0,0 +1,21 @@ +package server + +import ( + "github.com/stretchr/testify/require" + "net/http" + "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)) +} diff --git a/server/server_test.go b/server/server_test.go index 343d502b..c9af9459 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "math/rand" "net/http" "net/http/httptest" @@ -171,10 +172,6 @@ func TestServer_StaticSites(t *testing.T) { require.Equal(t, 301, rr.Code) // Docs test removed, it was failing annoyingly. - - rr = request(t, s, "GET", "/example.html", "", nil) - require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), "") } func TestServer_WebEnabled(t *testing.T) { @@ -185,9 +182,6 @@ func TestServer_WebEnabled(t *testing.T) { rr := request(t, s, "GET", "/", "", nil) require.Equal(t, 404, rr.Code) - rr = request(t, s, "GET", "/example.html", "", nil) - require.Equal(t, 404, rr.Code) - rr = request(t, s, "GET", "/config.js", "", nil) require.Equal(t, 404, rr.Code) @@ -201,9 +195,6 @@ func TestServer_WebEnabled(t *testing.T) { rr = request(t, s2, "GET", "/", "", nil) require.Equal(t, 200, rr.Code) - rr = request(t, s2, "GET", "/example.html", "", nil) - require.Equal(t, 200, rr.Code) - rr = request(t, s2, "GET", "/config.js", "", nil) require.Equal(t, 200, rr.Code) @@ -1390,3 +1381,11 @@ func toHTTPError(t *testing.T, s string) *errHTTP { func basicAuth(s string) string { return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s))) } + +func readAll(t *testing.T, rc io.ReadCloser) string { + b, err := io.ReadAll(rc) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go index 8e9d5892..c0de7079 100644 --- a/server/smtp_server_test.go +++ b/server/smtp_server_test.go @@ -3,7 +3,6 @@ package server import ( "github.com/emersion/go-smtp" "github.com/stretchr/testify/require" - "io" "net" "net/http" "strings" @@ -304,14 +303,6 @@ func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Reques return conf, backend } -func readAll(t *testing.T, rc io.ReadCloser) string { - b, err := io.ReadAll(rc) - if err != nil { - t.Fatal(err) - } - return string(b) -} - func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState { ip, err := net.ResolveIPAddr("ip", remoteAddr) if err != nil { diff --git a/util/peek.go b/util/peek.go index f7219253..40150cbc 100644 --- a/util/peek.go +++ b/util/peek.go @@ -18,7 +18,8 @@ type PeekedReadCloser struct { closed bool } -// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser +// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser. +// It does not return an error if limit is reached. Instead, LimitReached will be set to true. func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) { if underlying == nil { underlying = io.NopCloser(strings.NewReader("")) From 6122cf20aa802fa354322da003a4baf2d11a232f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 16 Jun 2022 12:37:02 -0400 Subject: [PATCH 7/9] More tests --- docs/faq.md | 7 ++++ docs/releases.md | 18 +++++------ server/errors.go | 1 + server/server_matrix.go | 5 ++- server/server_matrix_test.go | 63 ++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 9846170b..cc3e59c0 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -42,3 +42,10 @@ decent now. [Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the server and listens for incoming notifications. This consumes additional battery (see above), but delivers notifications instantly. + +## Where can I donate? +Many people have asked (thanks for that!), but I am currently not accepting any donations. The cost is manageable +($25/month for hosting, and $99/year for the Apple cert) right now, and I don't want to have to feel obligated to +anyone by accepting their money. + +I may ask for donations in the future, though. After all, $400 per year isn't nothing... diff --git a/docs/releases.md b/docs/releases.md index 9c0c6203..9150251a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,6 +2,15 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). +