From 574e72a974ff95d2d4de9b888bda45d76c892707 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 26 Apr 2022 23:07:31 -0400 Subject: [PATCH] WIP: More advanced action parsing --- server/actions_parse.go | 207 ++++++++++++++++++++++++++++++++++++++++ server/util.go | 2 +- server/util_test.go | 27 ++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 server/actions_parse.go diff --git a/server/actions_parse.go b/server/actions_parse.go new file mode 100644 index 00000000..9a428656 --- /dev/null +++ b/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' +} diff --git a/server/util.go b/server/util.go index 34d706f8..fe53160d 100644 --- a/server/util.go +++ b/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 diff --git a/server/util_test.go b/server/util_test.go index 9386cd84..d5dd5c6d 100644 --- a/server/util_test.go +++ b/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") + }