Browse Source

WIP: More advanced action parsing

Philipp Heckel 2 years ago
parent
commit
574e72a974
3 changed files with 235 additions and 1 deletions
  1. 207 0
      server/actions_parse.go
  2. 1 1
      server/util.go
  3. 27 0
      server/util_test.go

+ 207 - 0
server/actions_parse.go

@@ -0,0 +1,207 @@
+package server
+
+import (
+	"errors"
+	"fmt"
+	"heckel.io/ntfy/util"
+	"regexp"
+	"strings"
+	"unicode/utf8"
+)
+
+// Heavily inspired by https://go.dev/src/text/template/parse/lex.go
+// And thanks to Rob Pike (for Go, but also) for https://www.youtube.com/watch?v=HxaD_trXwRE
+
+// action=view, label="Look ma, commas and \"quotes\" too", url=https://..
+
+// "Look ma, a button",
+// Look ma a button
+// label=Look ma a=button
+// label="Look ma, a button"
+// "Look ma, \"quotes\""
+// label="Look ma, \"quotes\""
+// label=,
+
+func parseActionsFromSimpleNew(s string) ([]*action, error) {
+	if !utf8.ValidString(s) {
+		return nil, errors.New("invalid string")
+	}
+	parser := &actionParser{
+		pos:   0,
+		input: s,
+	}
+	return parser.Parse()
+}
+
+type actionParser struct {
+	input string
+	pos   int
+}
+
+const eof = rune(0)
+
+func (p *actionParser) Parse() ([]*action, error) {
+	println("------------------------")
+	actions := make([]*action, 0)
+	for !p.eof() {
+		a, err := p.parseAction()
+		if err != nil {
+			return nil, err
+		} else if a == nil {
+			return actions, err
+		}
+		actions = append(actions, a)
+	}
+	return actions, nil
+}
+
+func (p *actionParser) parseAction() (*action, error) {
+	println("parseAction")
+	newAction := &action{
+		Headers: make(map[string]string),
+		Extras:  make(map[string]string),
+	}
+	section := 0
+	for {
+		key, value, last, err := p.parseSection()
+		fmt.Printf("--> key=%s, value=%s, last=%t, err=%#v\n", key, value, last, err)
+		if err != nil {
+			return nil, err
+		} else if key == "" && section == 0 {
+			key = "action"
+		} else if key == "" && section == 1 {
+			key = "label"
+		} else if key == "" && section == 2 && util.InStringList([]string{"view", "http"}, newAction.Action) {
+			key = "url"
+		} else if key == "" {
+			return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "term '%s' unknown", value)
+		}
+		if strings.HasPrefix(key, "headers.") {
+			newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
+		} else if strings.HasPrefix(key, "extras.") {
+			newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
+		} else {
+			switch strings.ToLower(key) {
+			case "action":
+				newAction.Action = value
+			case "label":
+				newAction.Label = value
+			case "clear":
+				lvalue := strings.ToLower(value)
+				if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
+					return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", value)
+				}
+				newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
+			case "url":
+				newAction.URL = value
+			case "method":
+				newAction.Method = value
+			case "body":
+				newAction.Body = value
+			default:
+				return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
+			}
+		}
+		p.slurpSpaces()
+		if last {
+			return newAction, nil
+		}
+		section++
+	}
+}
+
+func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
+	fmt.Printf("parseSection, pos=%d, len(input)=%d, input[pos:]=%s\n", p.pos, len(p.input), p.input[p.pos:])
+	p.slurpSpaces()
+	key = p.parseKey()
+	r, w := p.peek()
+	if r == eof || r == ';' || r == ',' {
+		p.pos += w
+		last = r == ';' || r == eof
+		return
+	} else if r == '"' {
+		value, last, err = p.parseQuotedValue()
+		return
+	}
+	value, last = p.parseValue()
+	return
+}
+
+func (p *actionParser) parseValue() (value string, last bool) {
+	start := p.pos
+	for {
+		r, w := p.peek()
+		if r == eof || r == ';' || r == ',' {
+			last = r == ';' || r == eof
+			value = p.input[start:p.pos]
+			p.pos += w
+			return
+		}
+		p.pos += w
+	}
+}
+
+func (p *actionParser) parseQuotedValue() (value string, last bool, err error) {
+	p.pos++
+	start := p.pos
+	var prev rune
+	for {
+		r, w := p.peek()
+		if r == eof {
+			err = errors.New("unexpected end of input")
+			return
+		} else if r == '"' && prev != '\\' {
+			value = p.input[start:p.pos]
+			p.pos += w
+
+			// Advance until after "," or ";"
+			p.slurpSpaces()
+			r, w := p.peek()
+			last = r == ';' || r == eof
+			if r != eof && r != ';' && r != ',' {
+				err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
+				return
+			}
+			p.pos += w
+			return
+		}
+		prev = r
+		p.pos += w
+	}
+}
+
+var keyRegex = regexp.MustCompile(`^[-.\w]+=`)
+
+func (p *actionParser) parseKey() string {
+	key := keyRegex.FindString(p.input[p.pos:])
+	if key != "" {
+		p.pos += len(key)
+		return key[:len(key)-1]
+	}
+	return key
+}
+
+func (p *actionParser) peek() (rune, int) {
+	if p.pos >= len(p.input) {
+		return eof, 0
+	}
+	return utf8.DecodeRuneInString(p.input[p.pos:])
+}
+
+func (p *actionParser) eof() bool {
+	return p.pos >= len(p.input)
+}
+
+func (p *actionParser) slurpSpaces() {
+	for {
+		r, w := p.peek()
+		if r == eof || !isSpace(r) {
+			return
+		}
+		p.pos += w
+	}
+}
+
+func isSpace(r rune) bool {
+	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
+}

+ 1 - 1
server/util.go

@@ -54,7 +54,7 @@ func parseActions(s string) (actions []*action, err error) {
 	if strings.HasPrefix(s, "[") {
 		actions, err = parseActionsFromJSON(s)
 	} else {
-		actions, err = parseActionsFromSimple(s)
+		actions, err = parseActionsFromSimpleNew(s)
 	}
 	if err != nil {
 		return nil, err

+ 27 - 0
server/util_test.go

@@ -79,4 +79,31 @@ func TestParseActions(t *testing.T) {
 	require.Equal(t, 2, len(actions[0].Headers))
 	require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
 	require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
+
+	actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `Look ma, \"quotes\"; and semicolons`, actions[0].Label)
+	require.Equal(t, `http://example.com`, actions[0].URL)
+
+	actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `Out of order!`, actions[0].Label)
+	require.Equal(t, `http://example.com`, actions[0].URL)
+
+	actions, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
+	require.EqualError(t, err, "unexpected character 'x' at position 22")
+
+	actions, err = parseActions(`label="", action="http", url=http://example.com`)
+	require.EqualError(t, err, "invalid request: actions invalid, parameter 'label' is required")
+
+	actions, err = parseActions(`label=, action="http", url=http://example.com`)
+	require.EqualError(t, err, "invalid request: actions invalid, parameter 'label' is required")
+
+	actions, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
+	require.EqualError(t, err, "invalid request: actions invalid, term 'what is this anyway' unknown")
+
 }