From 8fcc40942fb2abcadff9ddee3664743aac03aba7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 16 Mar 2022 14:16:54 -0400 Subject: [PATCH] Publish as JSON --- docs/publish.md | 145 +++++++++++++++++++++++++++++++++++++++++- docs/releases.md | 15 +++-- server/errors.go | 9 +-- server/server.go | 20 +++--- server/server_test.go | 25 ++++++++ server/types.go | 16 ++--- 6 files changed, 201 insertions(+), 29 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index c9c3f6e..e3fe13d 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -499,7 +499,7 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim -## Webhooks (Send via GET) +## Webhooks (publish via GET) In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g. like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app). @@ -592,6 +592,141 @@ Here's an example with a custom message, tags and a priority: file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); ``` +## Publish as JSON +For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)), +adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message +as JSON in the request body. + +To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below +the example. + +!!! info + To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're + POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect). + +Here's an example using all supported parameters. The `topic` parameter is the only required one: + +=== "Command line (curl)" + ``` + curl ntfy.sh \ + -d '{ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/" + }' + ``` + +=== "HTTP" + ``` http + POST / HTTP/1.1 + Host: ntfy.sh + + { + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/" + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh', { + method: 'POST', + body: JSON.stringify({ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/" + }) + }) + ``` + +=== "Go" + ``` go + // You should probably use json.Marshal() instead and make a proper struct, + // or even just use req.Header.Set() like in the other examples, but for the + // sake of the example, this is easier. + + body := `{ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/" + }` + req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + http.DefaultClient.Do(req) + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/", + data=json.dumps({ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/" + }) + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => json_encode([ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/" + ]) + ] + ])); + ``` + +The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md) +(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of +all the supported fields: + +| Field | Required | Type | Example | Description | +|------------|----------|----------------------------------|--------------------------------|-----------------------------------------------------------------------| +| `topic` | ✔️ | *string* | `topic1` | Target topic name | +| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | +| `title` | - | *string* | `Some title` | Message [title](#message-title) | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | +| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | +| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) | +| `filename` | - | *string* | `file.jpg` | File name of the attachment | + + ## Click action You can define which URL to open when a notification is clicked. This may be useful if your notification is related to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open @@ -756,7 +891,13 @@ This could be a Dropbox link, a file from social media, or any other publicly av externally hosted, the expiration or size limits from above do not apply here. To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`) -to specify the attachment URL. It can be any type of file. Here's an example showing how to attach an APK file: +to specify the attachment URL. It can be any type of file. + +ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a +filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its +aliases `Filename`, `File` or `f`). + +Here's an example showing how to attach an APK file: === "Command line (curl)" ``` diff --git a/docs/releases.md b/docs/releases.md index 2de9302..5d715f0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -5,25 +5,28 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v1.18.0 Released XXXXXXXXXXXXXXX +**Features:** + +* Publish messages as JSON ([#133](https://github.com/binwiederhier/ntfy/issues/133), thanks [@cmeis](https://github.com/cmeis) for reporting) + **Bug fixes:** -* rpm: do not overwrite server.yaml on package upgrade (#166, thanks @waclaw66 for reporting) +* rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting) **Deprecations:** * Removed the ability to run server as `ntfy serve` as per [deprecation](https://ntfy.sh/docs/deprecations) -## ntfy Android app v1.10.0 -Released XXXXXXXXXXXXXXX +## ntfy Android app v1.10.0 (UNRELEASED) **Features:** -* Support for UnifiedPush 2.0 specification (bytes messages, #130) -* Export/import settings and subscriptions (#115, thanks @cmeis for reporting) +* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130)) +* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting) **Bug fixes:** -* Display locale-specific times, with AM/PM or 24h format (#140, thanks @hl2guide for reporting) +* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting) ## ntfy server v1.17.1 Released Mar 12, 2022 diff --git a/server/errors.go b/server/errors.go index 893178b..695da85 100644 --- a/server/errors.go +++ b/server/errors.go @@ -35,10 +35,11 @@ var ( errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""} - errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} - errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""} - errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} - errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", ""} + errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"} + errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"} + errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + 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"} 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 0a541b6..a0de013 100644 --- a/server/server.go +++ b/server/server.go @@ -263,8 +263,6 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { if r.Method == http.MethodGet && r.URL.Path == "/" { return s.handleHome(w, r) - } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { - return s.limitRequests(s.fromMessageJSON(s.authWrite(s.handlePublish)))(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { return s.handleExample(w, r) } else if r.Method == http.MethodHead && r.URL.Path == "/" { @@ -279,6 +277,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequests(s.handleFile)(w, r, v) } else if r.Method == http.MethodOptions { return s.handleOptions(w, r) + } 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.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) { @@ -1095,7 +1095,9 @@ func (s *Server) limitRequests(next handleFunc) handleFunc { } } -func (s *Server) fromMessageJSON(next handleFunc) handleFunc { +// transformBodyJSON peaks the request body, reads the JSON, and converts it to headers +// before passing it on to the next handler. This is meant to be used in combination with handlePublish. +func (s *Server) transformBodyJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { body, err := util.Peak(r.Body, s.config.MessageLimit) if err != nil { @@ -1104,10 +1106,10 @@ func (s *Server) fromMessageJSON(next handleFunc) handleFunc { defer r.Body.Close() var m publishMessage if err := json.NewDecoder(body).Decode(&m); err != nil { - return err + return errHTTPBadRequestJSONInvalid } if !topicRegex.MatchString(m.Topic) { - return errors.New("invalid message") + return errHTTPBadRequestTopicInvalid } if m.Message == "" { m.Message = emptyMessageBody @@ -1117,11 +1119,11 @@ func (s *Server) fromMessageJSON(next handleFunc) handleFunc { if m.Title != "" { r.Header.Set("X-Title", m.Title) } - if m.Priority != "" { - r.Header.Set("X-Priority", m.Priority) + if m.Priority != 0 { + r.Header.Set("X-Priority", fmt.Sprintf("%d", m.Priority)) } - if m.Tags != "" { - r.Header.Set("X-Tags", m.Tags) + if m.Tags != nil && len(m.Tags) > 0 { + r.Header.Set("X-Tags", strings.Join(m.Tags, ",")) } if m.Attach != "" { r.Header.Set("X-Attach", m.Attach) diff --git a/server/server_test.go b/server/server_test.go index c30368a..e5106d9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -862,6 +862,31 @@ func TestServer_PublishUnifiedPushText(t *testing.T) { require.Equal(t, "this is a unifiedpush text message", m.Message) } +func TestServer_PublishAsJSON(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` + + `"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "A message", m.Message) + require.Equal(t, "a title\nwith lines", m.Title) + require.Equal(t, []string{"tag1", "tag 2"}, m.Tags) + require.Equal(t, "http://google.com", m.Attachment.URL) + require.Equal(t, "google.pdf", m.Attachment.Name) + require.Equal(t, "http://ntfy.sh", m.Click) + require.Equal(t, 4, m.Priority) +} + +func TestServer_PublishAsJSON_Invalid(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic",INVALID` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 400, response.Code) +} + func TestServer_PublishAttachment(t *testing.T) { content := util.RandomString(5000) // > 4096 s := newTestServer(t, newTestConfig(t)) diff --git a/server/types.go b/server/types.go index ec8259c..6594f05 100644 --- a/server/types.go +++ b/server/types.go @@ -44,14 +44,14 @@ type attachment struct { // publishMessage is used as input when publishing as JSON type publishMessage struct { - Topic string `json:"topic"` - Title string `json:"title"` - Message string `json:"message"` - Priority string `json:"priority"` - Tags string `json:"tags"` - Click string `json:"click"` - Attach string `json:"attach"` - Filename string `json:"filename"` + Topic string `json:"topic"` + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + Tags []string `json:"tags"` + Click string `json:"click"` + Attach string `json:"attach"` + Filename string `json:"filename"` } // messageEncoder is a function that knows how to encode a message