From d519fd999b16bda2c331c96e53d6eb693bcd4215 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 16 Jul 2022 13:31:03 -0600 Subject: [PATCH] notification icons --- client/client.go | 8 ++ client/options.go | 5 ++ cmd/publish.go | 6 ++ cmd/publish_test.go | 1 + docs/publish.md | 79 ++++++++++++++++++++ docs/releases.md | 3 +- docs/static/img/android-screenshot-icon.png | Bin 0 -> 39513 bytes server/errors.go | 1 + server/message_cache.go | 67 ++++++++++++++--- server/server.go | 12 +++ server/server_firebase.go | 5 ++ server/server_firebase_test.go | 11 +++ server/server_test.go | 4 +- server/types.go | 8 ++ 14 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 docs/static/img/android-screenshot-icon.png diff --git a/client/client.go b/client/client.go index 8b05a39..1f0862f 100644 --- a/client/client.go +++ b/client/client.go @@ -47,6 +47,7 @@ type Message struct { // TODO combine with server.message Priority int Tags []string Click string + Icon *Icon Attachment *Attachment // Additional fields @@ -65,6 +66,13 @@ type Attachment struct { Owner string `json:"-"` // IP address of uploader, used for rate limiting } +// Icon represents a message icon +type Icon struct { + Url string `json:"url"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` +} + type subscription struct { ID string topicURL string diff --git a/client/options.go b/client/options.go index 7d59969..fdcbe1d 100644 --- a/client/options.go +++ b/client/options.go @@ -56,6 +56,11 @@ func WithClick(url string) PublishOption { return WithHeader("X-Click", url) } +// WithIcon makes the notification use the given URL as its icon +func WithIcon(icon string) PublishOption { + return WithHeader("X-Icon", icon) +} + // WithActions adds custom user actions to the notification. The value can be either a JSON array or the // simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details. func WithActions(value string) PublishOption { diff --git a/cmd/publish.go b/cmd/publish.go index 4d6ec78..c6b4a05 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -28,6 +28,7 @@ var flagsPublish = append( &cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, &cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, + &cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"}, &cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, @@ -64,6 +65,7 @@ Examples: ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked + ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment ntfy pub -u phil:mypass secret Psst # Publish with username/password @@ -90,6 +92,7 @@ func execPublish(c *cli.Context) error { tags := c.String("tags") delay := c.String("delay") click := c.String("click") + icon := c.String("icon") actions := c.String("actions") attach := c.String("attach") filename := c.String("filename") @@ -120,6 +123,9 @@ func execPublish(c *cli.Context) error { if click != "" { options = append(options, client.WithClick(click)) } + if icon != "" { + options = append(options, client.WithIcon(icon)) + } if actions != "" { options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " "))) } diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 2b9ad3f..ee8b116 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -52,6 +52,7 @@ func TestCLI_Publish_All_The_Things(t *testing.T) { "--tags", "tag1,tag2", // No --delay, --email "--click", "https://ntfy.sh", + "--icon", "https://ntfy.sh/static/img/ntfy.png", "--attach", "https://f-droid.org/F-Droid.apk", "--filename", "fdroid.apk", "--no-cache", diff --git a/docs/publish.md b/docs/publish.md index b1bb09f..390a376 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2349,6 +2349,84 @@ Here's an example showing how to attach an APK file:
File attachment sent from an external URL
+## Icons +_Supported on:_ :material-android: + +You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query +parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download +the icon (up to 300KB) and show it in the notification. Only jpeg and png images are supported at this time. + +Here's an example showing how to include an icon: + +=== "Command line (curl)" + ``` + curl \ + -X POST \ + -H "Icon: https://ntfy.sh/docs/static/img/ntfy.png" \ + ntfy.sh/customIcons + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --icon="https://ntfy.sh/docs/static/img/ntfy.png" \ + customIcons + ``` + +=== "HTTP" + ``` http + POST /customIcons HTTP/1.1 + Host: ntfy.sh + Icon: https://ntfy.sh/docs/static/img/ntfy.png + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/customIcons', { + method: 'POST', + headers: { 'Icon': 'https://ntfy.sh/docs/static/img/ntfy.png' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/customIcons", file) + req.Header.Set("Icon", "https://ntfy.sh/docs/static/img/ntfy.png") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $uri = "https://ntfy.sh/customIcons" + $headers = @{ Icon="https://ntfy.sh/docs/static/img/ntfy.png" } + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -UseBasicParsing + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/customIcons", + headers={ "Icon": "https://ntfy.sh/docs/static/img/ntfy.png" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/customIcons', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Icon: https://ntfy.sh/docs/static/img/ntfy.png", + ] + ])); + ``` + +Here's an example of how it will look on Android: + +
+ ![file attachment](static/img/android-screenshot-icon.png){ width=500 } +
Custom icon from an external URL
+
+ ## E-mail notifications _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -2804,6 +2882,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a | `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) | | `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) | | `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment | +| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | | `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | diff --git a/docs/releases.md b/docs/releases.md index 3ae44d0..b615b03 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -13,6 +13,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Polling is now done with `since=` API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165)) * Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket) * Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8)) +* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8)) **Bugs:** @@ -41,12 +42,12 @@ Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up s * `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting) * Ignore new draft HTTP `Priority` header ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting) * Delete expired attachments based on mod time instead of DB entry to avoid races (no ticket) +* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8)) **Documentation:** * Fix some PowerShell publish docs ([#345](https://github.com/binwiederhier/ntfy/pull/345), thanks to [@noahpeltier](https://github.com/noahpeltier)) ---> ## ntfy server v1.27.2 Released June 23, 2022 diff --git a/docs/static/img/android-screenshot-icon.png b/docs/static/img/android-screenshot-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9d9ffb2ad15d4ba3ff07d3ea52c51766a92563cc GIT binary patch literal 39513 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 ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesDueQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id @@ -89,7 +92,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 7 + currentSchemaVersion = 8 createSchemaVersionTableQuery = ` CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, @@ -177,6 +180,13 @@ const ( migrate6To7AlterMessagesTableQuery = ` ALTER TABLE messages RENAME COLUMN attachment_owner TO sender; ` + + // 7 -> 8 + migrate7To8AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN icon_url TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN icon_type TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN icon_size INT NOT NULL DEFAULT('0'); + ` ) type messageCache struct { @@ -248,6 +258,13 @@ func (c *messageCache) addMessages(ms []*message) error { attachmentExpires = m.Attachment.Expires attachmentURL = m.Attachment.URL } + var iconURL, iconType string + var iconSize int64 + if m.Icon != nil { + iconURL = m.Icon.URL + iconType = m.Icon.Type + iconSize = m.Icon.Size + } var actionsStr string if len(m.Actions) > 0 { actionsBytes, err := json.Marshal(m.Actions) @@ -275,6 +292,9 @@ func (c *messageCache) addMessages(ms []*message) error { m.Sender, m.Encoding, published, + iconURL, + iconType, + iconSize, ) if err != nil { return err @@ -412,9 +432,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) { defer rows.Close() messages := make([]*message, 0) for rows.Next() { - var timestamp, attachmentSize, attachmentExpires int64 + var timestamp, attachmentSize, attachmentExpires, iconSize int64 var priority int - var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string + var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding, iconURL, iconType string err := rows.Scan( &id, ×tamp, @@ -432,6 +452,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) { &attachmentURL, &sender, &encoding, + &iconURL, + &iconType, + &iconSize, ) if err != nil { return nil, err @@ -456,6 +479,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) { URL: attachmentURL, } } + var ico *icon + if iconURL != "" { + ico = &icon{ + URL: iconURL, + Type: iconType, + Size: iconSize, + } + } messages = append(messages, &message{ ID: id, Time: timestamp, @@ -466,6 +497,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { Priority: priority, Tags: tags, Click: click, + Icon: ico, Actions: actions, Attachment: att, Sender: sender, @@ -524,6 +556,8 @@ func setupCacheDB(db *sql.DB, startupQueries string) error { return migrateFrom5(db) } else if schemaVersion == 6 { return migrateFrom6(db) + } else if schemaVersion == 7 { + return migrateFrom7(db) } return fmt.Errorf("unexpected schema version found: %d", schemaVersion) } @@ -618,5 +652,16 @@ func migrateFrom6(db *sql.DB) error { if _, err := db.Exec(updateSchemaVersion, 7); err != nil { return err } + return migrateFrom7(db) +} + +func migrateFrom7(db *sql.DB) error { + log.Info("Migrating cache database schema: from 7 to 8") + if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(updateSchemaVersion, 8); err != nil { + return err + } return nil // Update this when a new version is added } diff --git a/server/server.go b/server/server.go index 94f3580..6d25e2d 100644 --- a/server/server.go +++ b/server/server.go @@ -75,6 +75,7 @@ var ( fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app attachURLRegex = regexp.MustCompile(`^https?://`) + iconURLRegex = regexp.MustCompile(`^https?://`) //go:embed site webFs embed.FS @@ -568,6 +569,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") m.Click = readParam(r, "x-click", "click") + ico := readParam(r, "x-icon", "icon") filename := readParam(r, "x-filename", "filename", "file", "f") attach := readParam(r, "x-attach", "attach", "a") if attach != "" || filename != "" { @@ -594,6 +596,13 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca m.Attachment.Name = "attachment" } } + if ico != "" { + m.Icon = &icon{} + if !iconURLRegex.MatchString(ico) { + return false, false, "", false, errHTTPBadRequestIconURLInvalid + } + m.Icon.URL = ico + } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if email != "" { if err := v.EmailAllowed(); err != nil { @@ -1336,6 +1345,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Click != "" { r.Header.Set("X-Click", m.Click) } + if m.Icon != "" { + r.Header.Set("X-Icon", m.Icon) + } if len(m.Actions) > 0 { actionsStr, err := json.Marshal(m.Actions) if err != nil { diff --git a/server/server_firebase.go b/server/server_firebase.go index 99f0ba1..016f54b 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -166,6 +166,11 @@ 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 } + if m.Icon != nil { + data["icon_url"] = m.Icon.URL + data["icon_type"] = m.Icon.Type + data["icon_size"] = fmt.Sprintf("%d", m.Icon.Size) + } apnsConfig = createAPNSAlertConfig(m, data) } else { // If anonymous read for a topic is not allowed, we cannot send the message along diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index b004b0f..7301057 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -123,6 +123,11 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { m.Priority = 4 m.Tags = []string{"tag 1", "tag2"} m.Click = "https://google.com" + m.Icon = &icon{ + URL: "https://ntfy.sh/static/img/ntfy.png", + Type: "image/jpeg", + Size: 4567, + } m.Title = "some title" m.Actions = []*action{ { @@ -173,6 +178,9 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { "priority": "4", "tags": strings.Join(m.Tags, ","), "click": "https://google.com", + "icon_url": "https://ntfy.sh/static/img/ntfy.png", + "icon_type": "image/jpeg", + "icon_size": "4567", "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"}}]`, @@ -193,6 +201,9 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { "priority": "4", "tags": strings.Join(m.Tags, ","), "click": "https://google.com", + "icon_url": "https://ntfy.sh/static/img/ntfy.png", + "icon_type": "image/jpeg", + "icon_size": "4567", "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"}}]`, diff --git a/server/server_test.go b/server/server_test.go index d68cfa1..13e5137 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1046,7 +1046,7 @@ 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,` + - `"delay":"30min"}` + `"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) @@ -1058,6 +1058,8 @@ func TestServer_PublishAsJSON(t *testing.T) { 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, "https://ntfy.sh/static/img/ntfy.png", m.Icon.URL) + require.Equal(t, 4, m.Priority) require.True(t, m.Time > time.Now().Unix()+29*60) require.True(t, m.Time < time.Now().Unix()+31*60) diff --git a/server/types.go b/server/types.go index 44fe9e9..3a5e9fa 100644 --- a/server/types.go +++ b/server/types.go @@ -31,6 +31,7 @@ type message struct { Click string `json:"click,omitempty"` Actions []*action `json:"actions,omitempty"` Attachment *attachment `json:"attachment,omitempty"` + Icon *icon `json:"icon,omitempty"` PollID string `json:"poll_id,omitempty"` Sender string `json:"-"` // IP address of uploader, used for rate limiting Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes @@ -44,6 +45,12 @@ type attachment struct { URL string `json:"url"` } +type icon struct { + URL string `json:"url"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` +} + type action struct { ID string `json:"id"` Action string `json:"action"` // "view", "broadcast", or "http" @@ -74,6 +81,7 @@ type publishMessage struct { Click string `json:"click"` Actions []action `json:"actions"` Attach string `json:"attach"` + Icon string `json:"icon"` Filename string `json:"filename"` Email string `json:"email"` Delay string `json:"delay"`