actions.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. package server
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "regexp"
  7. "strings"
  8. "unicode/utf8"
  9. "heckel.io/ntfy/v2/util"
  10. )
  11. const (
  12. actionIDLength = 10
  13. actionEOF = rune(0)
  14. actionsMax = 3
  15. )
  16. const (
  17. actionView = "view"
  18. actionBroadcast = "broadcast"
  19. actionHTTP = "http"
  20. actionCopy = "copy"
  21. )
  22. var (
  23. actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
  24. actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
  25. actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
  26. actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
  27. )
  28. type actionParser struct {
  29. input string
  30. pos int
  31. }
  32. // parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
  33. // It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
  34. // and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
  35. func parseActions(s string) (actions []*action, err error) {
  36. // Parse JSON or simple format
  37. s = strings.TrimSpace(s)
  38. if strings.HasPrefix(s, "[") {
  39. actions, err = parseActionsFromJSON(s)
  40. } else {
  41. actions, err = parseActionsFromSimple(s)
  42. }
  43. if err != nil {
  44. return nil, err
  45. }
  46. // Add ID field, ensure correct uppercase/lowercase
  47. for i := range actions {
  48. actions[i].ID = util.RandomString(actionIDLength)
  49. actions[i].Action = strings.ToLower(actions[i].Action)
  50. actions[i].Method = strings.ToUpper(actions[i].Method)
  51. }
  52. // Validate
  53. if len(actions) > actionsMax {
  54. return nil, fmt.Errorf("only %d actions allowed", actionsMax)
  55. }
  56. for _, action := range actions {
  57. if !util.Contains(actionsAll, action.Action) {
  58. return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
  59. } else if action.Label == "" {
  60. return nil, fmt.Errorf("parameter 'label' is required")
  61. } else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
  62. return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
  63. } else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
  64. return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
  65. } else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
  66. return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
  67. }
  68. }
  69. return actions, nil
  70. }
  71. // parseActionsFromJSON converts a JSON array into an array of actions
  72. func parseActionsFromJSON(s string) ([]*action, error) {
  73. actions := make([]*action, 0)
  74. if err := json.Unmarshal([]byte(s), &actions); err != nil {
  75. return nil, fmt.Errorf("JSON error: %w", err)
  76. }
  77. return actions, nil
  78. }
  79. // parseActionsFromSimple parses the "simple" actions string (as described in
  80. // https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
  81. //
  82. // It can parse an actions string like this:
  83. //
  84. // view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
  85. //
  86. // It works by advancing the position ("pos") through the input string ("input").
  87. //
  88. // The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
  89. // is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
  90. // though it does not use state functions at all.
  91. //
  92. // Other resources:
  93. //
  94. // https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
  95. // https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
  96. // https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
  97. // https://blog.gopheracademy.com/advent-2014/parsers-lexers/
  98. func parseActionsFromSimple(s string) ([]*action, error) {
  99. if !utf8.ValidString(s) {
  100. return nil, errors.New("invalid utf-8 string")
  101. }
  102. parser := &actionParser{
  103. pos: 0,
  104. input: s,
  105. }
  106. return parser.Parse()
  107. }
  108. // Parse loops trough parseAction() until the end of the string is reached
  109. func (p *actionParser) Parse() ([]*action, error) {
  110. actions := make([]*action, 0)
  111. for !p.eof() {
  112. a, err := p.parseAction()
  113. if err != nil {
  114. return nil, err
  115. }
  116. actions = append(actions, a)
  117. }
  118. return actions, nil
  119. }
  120. // parseAction parses the individual sections of an action using parseSection into key/value pairs,
  121. // and then uses populateAction to interpret the keys/values. The function terminates
  122. // when EOF or ";" is reached.
  123. func (p *actionParser) parseAction() (*action, error) {
  124. a := newAction()
  125. section := 0
  126. for {
  127. key, value, last, err := p.parseSection()
  128. if err != nil {
  129. return nil, err
  130. }
  131. if err := populateAction(a, section, key, value); err != nil {
  132. return nil, err
  133. }
  134. p.slurpSpaces()
  135. if last {
  136. return a, nil
  137. }
  138. section++
  139. }
  140. }
  141. // populateAction is the "business logic" of the parser. It applies the key/value
  142. // pair to the action instance.
  143. func populateAction(newAction *action, section int, key, value string) error {
  144. // Auto-expand keys based on their index
  145. if key == "" && section == 0 {
  146. key = "action"
  147. } else if key == "" && section == 1 {
  148. key = "label"
  149. } else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
  150. key = "url"
  151. } else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
  152. key = "value"
  153. }
  154. // Validate
  155. if key == "" {
  156. return fmt.Errorf("term '%s' unknown", value)
  157. }
  158. // Populate
  159. if strings.HasPrefix(key, "headers.") {
  160. newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
  161. } else if strings.HasPrefix(key, "extras.") {
  162. newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
  163. } else {
  164. switch strings.ToLower(key) {
  165. case "action":
  166. newAction.Action = value
  167. case "label":
  168. newAction.Label = value
  169. case "clear":
  170. lvalue := strings.ToLower(value)
  171. if !util.Contains([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
  172. return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
  173. }
  174. newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
  175. case "url":
  176. newAction.URL = value
  177. case "method":
  178. newAction.Method = value
  179. case "body":
  180. newAction.Body = value
  181. case "value":
  182. newAction.Value = value
  183. case "intent":
  184. newAction.Intent = value
  185. default:
  186. return fmt.Errorf("key '%s' unknown", key)
  187. }
  188. }
  189. return nil
  190. }
  191. // parseSection parses a section ("key=value") and returns a key/value pair. It terminates
  192. // when EOF or "," is reached.
  193. func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
  194. p.slurpSpaces()
  195. key = p.parseKey()
  196. r, w := p.peek()
  197. if isSectionEnd(r) {
  198. p.pos += w
  199. last = isLastSection(r)
  200. return
  201. } else if r == '"' || r == '\'' {
  202. value, last, err = p.parseQuotedValue(r)
  203. return
  204. }
  205. value, last = p.parseValue()
  206. return
  207. }
  208. // parseKey uses a regex to determine whether the current position is a key definition ("key =")
  209. // and returns the key if it is, or an empty string otherwise.
  210. func (p *actionParser) parseKey() string {
  211. matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
  212. if len(matches) == 2 {
  213. p.pos += len(matches[0])
  214. return matches[1]
  215. }
  216. return ""
  217. }
  218. // parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
  219. // this function does not support "," or ";" in the value itself, and spaces in the beginning and end of the
  220. // string are trimmed.
  221. func (p *actionParser) parseValue() (value string, last bool) {
  222. start := p.pos
  223. for {
  224. r, w := p.peek()
  225. if isSectionEnd(r) {
  226. last = isLastSection(r)
  227. value = strings.TrimSpace(p.input[start:p.pos])
  228. p.pos += w
  229. return
  230. }
  231. p.pos += w
  232. }
  233. }
  234. // parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
  235. // advances the position beyond the section end. It supports quoting strings using backslash (\).
  236. func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
  237. p.pos++
  238. start := p.pos
  239. var prev rune
  240. for {
  241. r, w := p.peek()
  242. if r == actionEOF {
  243. err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
  244. return
  245. } else if r == quote && prev != '\\' {
  246. value = strings.ReplaceAll(p.input[start:p.pos], "\\"+string(quote), string(quote)) // \" -> "
  247. p.pos += w
  248. // Advance until section end (after "," or ";")
  249. p.slurpSpaces()
  250. r, w := p.peek()
  251. last = isLastSection(r)
  252. if !isSectionEnd(r) {
  253. err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
  254. return
  255. }
  256. p.pos += w
  257. return
  258. }
  259. prev = r
  260. p.pos += w
  261. }
  262. }
  263. // slurpSpaces reads all space characters and advances the position
  264. func (p *actionParser) slurpSpaces() {
  265. for {
  266. r, w := p.peek()
  267. if r == actionEOF || !isSpace(r) {
  268. return
  269. }
  270. p.pos += w
  271. }
  272. }
  273. // peek returns the next run and its width
  274. func (p *actionParser) peek() (rune, int) {
  275. if p.eof() {
  276. return actionEOF, 0
  277. }
  278. return utf8.DecodeRuneInString(p.input[p.pos:])
  279. }
  280. // eof returns true if the end of the input has been reached
  281. func (p *actionParser) eof() bool {
  282. return p.pos >= len(p.input)
  283. }
  284. func isSpace(r rune) bool {
  285. return r == ' ' || r == '\t' || r == '\r' || r == '\n'
  286. }
  287. func isSectionEnd(r rune) bool {
  288. return r == actionEOF || r == ';' || r == ','
  289. }
  290. func isLastSection(r rune) bool {
  291. return r == actionEOF || r == ';'
  292. }