Hunter Kehoe 3 лет назад
Родитель
Сommit
d519fd999b

+ 8 - 0
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

+ 5 - 0
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 {

+ 6 - 0
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", " ")))
 	}

+ 1 - 0
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",

+ 79 - 0
docs/publish.md

@@ -2349,6 +2349,84 @@ Here's an example showing how to attach an APK file:
   <figcaption>File attachment sent from an external URL</figcaption>
 </figure>
 
+## 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:
+
+<figure markdown>
+  ![file attachment](static/img/android-screenshot-icon.png){ width=500 }
+  <figcaption>Custom icon from an external URL</figcaption>
+</figure>
+
 ## 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)                                          |

+ 2 - 1
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=<id>` 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

BIN
docs/static/img/android-screenshot-icon.png


+ 1 - 0
server/errors.go

@@ -52,6 +52,7 @@ var (
 	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"}
+	errHTTPBadRequestIconURLInvalid                  = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
 	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"}

+ 56 - 11
server/message_cache.go

@@ -38,44 +38,47 @@ const (
 			attachment_url TEXT NOT NULL,
 			sender TEXT NOT NULL,
 			encoding TEXT NOT NULL,
-			published INT NOT NULL
+			published INT NOT NULL,
+			icon_url TEXT NOT NULL,
+			icon_type TEXT NOT NULL,
+			icon_size INT NOT NULL
 		);
 		CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
 		CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
 		COMMIT;
 	`
 	insertMessageQuery = `
-		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published) 
-		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published, icon_url, icon_type, icon_size)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 	`
 	pruneMessagesQuery           = `DELETE FROM messages WHERE time < ? AND published = 1`
 	selectRowIDFromMessageID     = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
 	selectMessagesSinceTimeQuery = `
-		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 >= ? 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,
 			&timestamp,
@@ -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
 }

+ 12 - 0
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 {

+ 5 - 0
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

+ 11 - 0
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"}}]`,

+ 3 - 1
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)

+ 8 - 0
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"`