Make simple actions parsing work

pull/212/head
Philipp Heckel 2022-04-19 09:14:32 -04:00
parent 55869f551e
commit 5a9b2122c2
7 changed files with 160 additions and 21 deletions

View File

@ -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"} 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"} 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"} 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", ""} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}

View File

@ -2,6 +2,7 @@ package server
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
@ -29,6 +30,7 @@ const (
priority INT NOT NULL, priority INT NOT NULL,
tags TEXT NOT NULL, tags TEXT NOT NULL,
click TEXT NOT NULL, click TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL, attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL, attachment_type TEXT NOT NULL,
attachment_size INT NOT NULL, attachment_size INT NOT NULL,
@ -43,37 +45,37 @@ const (
COMMIT; COMMIT;
` `
insertMessageQuery = ` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
selectMessagesSinceTimeQuery = ` 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 FROM messages
WHERE topic = ? AND time >= ? AND published = 1 WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceTimeIncludeScheduledQuery = ` 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 FROM messages
WHERE topic = ? AND time >= ? WHERE topic = ? AND time >= ?
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceIDQuery = ` 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 FROM messages
WHERE topic = ? AND id > ? AND published = 1 WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceIDIncludeScheduledQuery = ` 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 FROM messages
WHERE topic = ? AND (id > ? OR published = 0) WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id ORDER BY time, id
` `
selectMessagesDueQuery = ` 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 FROM messages
WHERE time <= ? AND published = 0 WHERE time <= ? AND published = 0
ORDER BY time, id ORDER BY time, id
@ -228,6 +230,14 @@ func (c *messageCache) AddMessage(m *message) error {
attachmentURL = m.Attachment.URL attachmentURL = m.Attachment.URL
attachmentOwner = m.Attachment.Owner 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( _, err := c.db.Exec(
insertMessageQuery, insertMessageQuery,
m.ID, m.ID,
@ -238,6 +248,7 @@ func (c *messageCache) AddMessage(m *message) error {
m.Priority, m.Priority,
tags, tags,
m.Click, m.Click,
actionsStr,
attachmentName, attachmentName,
attachmentType, attachmentType,
attachmentSize, attachmentSize,
@ -399,7 +410,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
for rows.Next() { for rows.Next() {
var timestamp, attachmentSize, attachmentExpires int64 var timestamp, attachmentSize, attachmentExpires int64
var priority int 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( err := rows.Scan(
&id, &id,
&timestamp, &timestamp,
@ -409,6 +420,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
&priority, &priority,
&tagsStr, &tagsStr,
&click, &click,
&actionsStr,
&attachmentName, &attachmentName,
&attachmentType, &attachmentType,
&attachmentSize, &attachmentSize,
@ -424,6 +436,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
if tagsStr != "" { if tagsStr != "" {
tags = strings.Split(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 var att *attachment
if attachmentName != "" && attachmentURL != "" { if attachmentName != "" && attachmentURL != "" {
att = &attachment{ att = &attachment{
@ -445,6 +463,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
Priority: priority, Priority: priority,
Tags: tags, Tags: tags,
Click: click, Click: click,
Actions: actions,
Attachment: att, Attachment: att,
Encoding: encoding, Encoding: encoding,
}) })

View File

@ -93,6 +93,7 @@ const (
emptyMessageBody = "triggered" // Used if message body is empty 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 defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
encodingBase64 = "base64" encodingBase64 = "base64"
actionIDLength = 10
) )
// WebSocket constants // 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") actionsStr := readParam(r, "x-actions", "actions", "action")
if actionsStr != "" { if actionsStr != "" {
actions := make([]action, 0) m.Actions, err = parseActions(actionsStr)
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil { if err != nil {
return false, false, "", false, errHTTPBadRequestDelayNoCache // FIXME error 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! unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush { if unifiedpush {

View File

@ -27,13 +27,15 @@ type message struct {
Priority int `json:"priority,omitempty"` Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"` Click string `json:"click,omitempty"`
Actions []action `json:"actions,omitempty"` Actions []*action `json:"actions,omitempty"`
Attachment *attachment `json:"attachment,omitempty"` Attachment *attachment `json:"attachment,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
} }
// FIXME persist actions
type attachment struct { type attachment struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
@ -46,11 +48,12 @@ type attachment struct {
type action struct { type action struct {
ID string `json:"id"` ID string `json:"id"`
Action string `json:"action"` Action string `json:"action"`
Label string `json:"label"` Label string `json:"label"` // "view", "broadcast", or "http"
URL string `json:"url,omitempty"` // used in "view" and "http" URL string `json:"url,omitempty"` // used in "view" and "http" actions
Method string `json:"method,omitempty"` // used in "http" Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
Headers map[string]string `json:"headers,omitempty"` // used in "http" Headers map[string]string `json:"headers,omitempty"` // used in "http" action
Body string `json:"body,omitempty"` // used in "http" 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 // publishMessage is used as input when publishing as JSON

View File

@ -1,6 +1,10 @@
package server package server
import ( import (
"encoding/json"
"fmt"
"github.com/pkg/errors"
"heckel.io/ntfy/util"
"net/http" "net/http"
"strings" "strings"
) )
@ -40,3 +44,73 @@ func readQueryParam(r *http.Request, names ...string) string {
} }
return "" 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
}

View File

@ -27,3 +27,38 @@ func TestReadBoolParam(t *testing.T) {
require.Equal(t, false, up) require.Equal(t, false, up)
require.Equal(t, true, firebase) 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)
}

View File

@ -77,6 +77,16 @@ func SplitNoEmpty(s string, sep string) []string {
return res 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 // RandomString returns a random string with a given length
func RandomString(length int) string { func RandomString(length int) string {
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?! randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!