diff --git a/server/errors.go b/server/errors.go index 3b754383..79dad5de 100644 --- a/server/errors.go +++ b/server/errors.go @@ -39,6 +39,7 @@ var ( 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"} + errHTTPBadRequestActionJSONInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions are invalid JSON", ""} // FIXME link 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/message_cache.go b/server/message_cache.go index cd503068..c15818f4 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -2,6 +2,7 @@ package server import ( "database/sql" + "encoding/json" "errors" "fmt" _ "github.com/mattn/go-sqlite3" // SQLite driver @@ -29,6 +30,7 @@ const ( priority INT NOT NULL, tags TEXT NOT NULL, click TEXT NOT NULL, + actions TEXT NOT NULL, attachment_name TEXT NOT NULL, attachment_type TEXT NOT NULL, attachment_size INT NOT NULL, @@ -43,37 +45,37 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, 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, attachment_owner, encoding, published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?` selectMessagesSinceTimeQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesDueQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id @@ -228,6 +230,14 @@ func (c *messageCache) AddMessage(m *message) error { attachmentURL = m.Attachment.URL attachmentOwner = m.Attachment.Owner } + var actionsStr string + if len(m.Actions) > 0 { + actionsBytes, err := json.Marshal(m.Actions) + if err != nil { + return err + } + actionsStr = string(actionsBytes) + } _, err := c.db.Exec( insertMessageQuery, m.ID, @@ -238,6 +248,7 @@ func (c *messageCache) AddMessage(m *message) error { m.Priority, tags, m.Click, + actionsStr, attachmentName, attachmentType, attachmentSize, @@ -399,7 +410,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { for rows.Next() { var timestamp, attachmentSize, attachmentExpires int64 var priority int - var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string + var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string err := rows.Scan( &id, ×tamp, @@ -409,6 +420,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { &priority, &tagsStr, &click, + &actionsStr, &attachmentName, &attachmentType, &attachmentSize, @@ -424,6 +436,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) { if tagsStr != "" { tags = strings.Split(tagsStr, ",") } + var actions []*action + if actionsStr != "" { + if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil { + return nil, err + } + } var att *attachment if attachmentName != "" && attachmentURL != "" { att = &attachment{ @@ -445,6 +463,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { Priority: priority, Tags: tags, Click: click, + Actions: actions, Attachment: att, Encoding: encoding, }) diff --git a/server/server.go b/server/server.go index 3dc29a2d..06f0f0d0 100644 --- a/server/server.go +++ b/server/server.go @@ -93,6 +93,7 @@ const ( emptyMessageBody = "triggered" // Used if message body is empty defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" + actionIDLength = 10 ) // WebSocket constants @@ -537,14 +538,10 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca } actionsStr := readParam(r, "x-actions", "actions", "action") if actionsStr != "" { - actions := make([]action, 0) - if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil { - return false, false, "", false, errHTTPBadRequestDelayNoCache // FIXME error + m.Actions, err = parseActions(actionsStr) + if err != nil { + return false, false, "", false, errHTTPBadRequestActionJSONInvalid } - for i := range actions { - actions[i].ID = util.RandomString(10) // FIXME - } - m.Actions = actions } unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! if unifiedpush { diff --git a/server/types.go b/server/types.go index ccfdc7d9..f17e8179 100644 --- a/server/types.go +++ b/server/types.go @@ -27,13 +27,15 @@ type message struct { Priority int `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` Click string `json:"click,omitempty"` - Actions []action `json:"actions,omitempty"` + Actions []*action `json:"actions,omitempty"` Attachment *attachment `json:"attachment,omitempty"` Title string `json:"title,omitempty"` Message string `json:"message,omitempty"` Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes } +// FIXME persist actions + type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` @@ -46,11 +48,12 @@ type attachment struct { type action struct { ID string `json:"id"` Action string `json:"action"` - Label string `json:"label"` - URL string `json:"url,omitempty"` // used in "view" and "http" - Method string `json:"method,omitempty"` // used in "http" - Headers map[string]string `json:"headers,omitempty"` // used in "http" - Body string `json:"body,omitempty"` // used in "http" + Label string `json:"label"` // "view", "broadcast", or "http" + URL string `json:"url,omitempty"` // used in "view" and "http" actions + Method string `json:"method,omitempty"` // used in "http" action, default is POST (!) + Headers map[string]string `json:"headers,omitempty"` // used in "http" action + Body string `json:"body,omitempty"` // used in "http" action + Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action } // publishMessage is used as input when publishing as JSON diff --git a/server/util.go b/server/util.go index 7c596344..42863f74 100644 --- a/server/util.go +++ b/server/util.go @@ -1,6 +1,10 @@ package server import ( + "encoding/json" + "fmt" + "github.com/pkg/errors" + "heckel.io/ntfy/util" "net/http" "strings" ) @@ -40,3 +44,73 @@ func readQueryParam(r *http.Request, names ...string) string { } return "" } + +func parseActions(s string) (actions []*action, err error) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "[") { + actions, err = parseActionsFromJSON(s) + } else { + actions, err = parseActionsFromSimple(s) + } + if err != nil { + return nil, err + } + for i := range actions { + actions[i].ID = util.RandomString(actionIDLength) + if !util.InStringList([]string{"view", "broadcast", "http"}, actions[i].Action) { + return nil, fmt.Errorf("cannot parse actions: action '%s' unknown", actions[i].Action) + } else if actions[i].Label == "" { + return nil, fmt.Errorf("cannot parse actions: label must be set") + } + } + return actions, nil +} + +func parseActionsFromJSON(s string) ([]*action, error) { + actions := make([]*action, 0) + if err := json.Unmarshal([]byte(s), &actions); err != nil { + return nil, err + } + return actions, nil +} + +func parseActionsFromSimple(s string) ([]*action, error) { + actions := make([]*action, 0) + rawActions := util.SplitNoEmpty(s, ";") + for _, rawAction := range rawActions { + newAction := &action{} + parts := util.SplitNoEmpty(rawAction, ",") + if len(parts) < 3 { + return nil, fmt.Errorf("cannot parse action: action requires at least keys 'action', 'label' and one parameter: %s", rawAction) + } + for i, part := range parts { + key, value := util.SplitKV(part, "=") + if key == "" && i == 0 { + newAction.Action = value + } else if key == "" && i == 1 { + newAction.Label = value + } else if key == "" && i == 2 { + newAction.URL = value // This works, because both "http" and "view" need a URL + } else if key != "" { + switch strings.ToLower(key) { + case "action": + newAction.Action = value + case "label": + newAction.Label = value + case "url": + newAction.URL = value + case "method": + newAction.Method = value + case "body": + newAction.Body = value + default: + return nil, errors.Errorf("cannot parse action: key '%s' not supported, please use JSON format instead", part) + } + } else { + return nil, errors.Errorf("cannot parse action: unknown phrase '%s'", part) + } + } + actions = append(actions, newAction) + } + return actions, nil +} diff --git a/server/util_test.go b/server/util_test.go index 63bc6b40..16268b6a 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -27,3 +27,38 @@ func TestReadBoolParam(t *testing.T) { require.Equal(t, false, up) require.Equal(t, true, firebase) } + +func TestParseActions(t *testing.T) { + actions, err := parseActions("[]") + require.Nil(t, err) + require.Empty(t, actions) + + actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan") + require.Nil(t, err) + require.Equal(t, 2, len(actions)) + require.Equal(t, "http", actions[0].Action) + require.Equal(t, "Open door", actions[0].Label) + require.Equal(t, "https://door.lan/open", actions[0].URL) + require.Equal(t, "view", actions[1].Action) + require.Equal(t, "Show portal", actions[1].Label) + require.Equal(t, "https://door.lan", actions[1].URL) + + actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`) + require.Nil(t, err) + require.Equal(t, 2, len(actions)) + require.Equal(t, "http", actions[0].Action) + require.Equal(t, "Open door", actions[0].Label) + require.Equal(t, "https://door.lan/open", actions[0].URL) + require.Equal(t, "view", actions[1].Action) + require.Equal(t, "Show portal", actions[1].Label) + require.Equal(t, "https://door.lan", actions[1].URL) + + actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT") + require.Nil(t, err) + require.Equal(t, 1, len(actions)) + require.Equal(t, "http", actions[0].Action) + require.Equal(t, "Open door", actions[0].Label) + require.Equal(t, "https://door.lan/open", actions[0].URL) + require.Equal(t, "PUT", actions[0].Method) + require.Equal(t, "this is a body", actions[0].Body) +} diff --git a/util/util.go b/util/util.go index e05736fc..3919d3e2 100644 --- a/util/util.go +++ b/util/util.go @@ -77,6 +77,16 @@ func SplitNoEmpty(s string, sep string) []string { return res } +// SplitKV splits a string into a key/value pair using a separator, and trimming space. If the separator +// is not found, key is empty. +func SplitKV(s string, sep string) (key string, value string) { + kv := strings.SplitN(strings.TrimSpace(s), sep, 2) + if len(kv) == 2 { + return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + } + return "", strings.TrimSpace(kv[0]) +} + // RandomString returns a random string with a given length func RandomString(length int) string { randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!