1
0

webhook_agent.rb 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. module Agents
  2. class WebhookAgent < Agent
  3. include EventHeadersConcern
  4. include WebRequestConcern # to make reCAPTCHA verification requests
  5. cannot_be_scheduled!
  6. cannot_receive_events!
  7. description do
  8. <<~MD
  9. The Webhook Agent will create events by receiving webhooks from any source. In order to create events with this agent, make a POST request to:
  10. ```
  11. https://#{ENV['DOMAIN']}/users/#{user.id}/web_requests/#{id || ':id'}/#{options['secret'] || ':secret'}
  12. ```
  13. #{'The placeholder symbols above will be replaced by their values once the agent is saved.' unless id}
  14. Options:
  15. * `secret` - A token that the host will provide for authentication.
  16. * `expected_receive_period_in_days` - How often you expect to receive
  17. events this way. Used to determine if the agent is working.
  18. * `payload_path` - JSONPath of the attribute in the POST body to be
  19. used as the Event payload. Set to `.` to return the entire message.
  20. If `payload_path` points to an array, Events will be created for each element.
  21. * `event_headers` - Comma-separated list of HTTP headers your agent will include in the payload.
  22. * `event_headers_key` - The key to use to store all the headers received
  23. * `verbs` - Comma-separated list of http verbs your agent will accept.
  24. For example, "post,get" will enable POST and GET requests. Defaults
  25. to "post".
  26. * `response` - The response message to the request. Defaults to 'Event Created'.
  27. * `response_headers` - An object with any custom response headers. (example: `{"Access-Control-Allow-Origin": "*"}`)
  28. * `code` - The response code to the request. Defaults to '201'. If the code is '301' or '302' the request will automatically be redirected to the url defined in "response".
  29. * `recaptcha_secret` - Setting this to a reCAPTCHA "secret" key makes your agent verify incoming requests with reCAPTCHA. Don't forget to embed a reCAPTCHA snippet including your "site" key in the originating form(s).
  30. * `recaptcha_send_remote_addr` - Set this to true if your server is properly configured to set REMOTE_ADDR to the IP address of each visitor (instead of that of a proxy server).
  31. * `score_threshold` - Setting this when using reCAPTCHA v3 to define the treshold when a submission is verified. Defaults to 0.5
  32. MD
  33. end
  34. event_description do
  35. <<~MD
  36. The event payload is based on the value of the `payload_path` option,
  37. which is set to `#{interpolated['payload_path']}`.
  38. MD
  39. end
  40. def default_options
  41. {
  42. "secret" => SecureRandom.uuid,
  43. "expected_receive_period_in_days" => 1,
  44. "payload_path" => ".",
  45. "event_headers" => "",
  46. "event_headers_key" => "headers",
  47. "score_threshold" => 0.5
  48. }
  49. end
  50. def receive_web_request(request)
  51. # check the secret
  52. secret = request.path_parameters[:secret]
  53. return ["Not Authorized", 401] unless secret == interpolated['secret']
  54. params = request.query_parameters.dup
  55. begin
  56. params.update(request.request_parameters)
  57. rescue EOFError
  58. end
  59. method = request.method_symbol.to_s
  60. headers = request.headers.each_with_object({}) { |(name, value), hash|
  61. case name
  62. when /\AHTTP_([A-Z0-9_]+)\z/
  63. hash[$1.tr('_', '-').gsub(/[^-]+/, &:capitalize)] = value
  64. end
  65. }
  66. # check the verbs
  67. verbs = (interpolated['verbs'] || 'post').split(/,/).map { |x| x.strip.downcase }.select { |x| x.present? }
  68. return ["Please use #{verbs.join('/').upcase} requests only", 401] unless verbs.include?(method)
  69. # check the code
  70. code = (interpolated['code'].presence || 201).to_i
  71. # check the reCAPTCHA response if required
  72. if recaptcha_secret = interpolated['recaptcha_secret'].presence
  73. recaptcha_response = params.delete('g-recaptcha-response') or
  74. return ["Not Authorized", 401]
  75. parameters = {
  76. secret: recaptcha_secret,
  77. response: recaptcha_response,
  78. }
  79. if boolify(interpolated['recaptcha_send_remote_addr'])
  80. parameters[:remoteip] = request.env['REMOTE_ADDR']
  81. end
  82. begin
  83. response = faraday.post('https://www.google.com/recaptcha/api/siteverify',
  84. parameters)
  85. rescue StandardError => e
  86. error "Verification failed: #{e.message}"
  87. return ["Not Authorized", 401]
  88. end
  89. body = JSON.parse(response.body)
  90. if interpolated['score_threshold'].present? && body['score'].present?
  91. body['score'] > interpolated['score_threshold'].to_f or
  92. return ["Not Authorized", 401]
  93. else
  94. body['success'] or
  95. return ["Not Authorized", 401]
  96. end
  97. end
  98. [payload_for(params)].flatten.each do |payload|
  99. create_event(payload: payload.merge(event_headers_payload(headers)))
  100. end
  101. if interpolated['response_headers'].presence
  102. [
  103. interpolated(params)['response'] || 'Event Created',
  104. code,
  105. "text/plain",
  106. interpolated['response_headers'].presence
  107. ]
  108. else
  109. [
  110. interpolated(params)['response'] || 'Event Created',
  111. code
  112. ]
  113. end
  114. end
  115. def working?
  116. event_created_within?(interpolated['expected_receive_period_in_days']) && !recent_error_logs?
  117. end
  118. def validate_options
  119. unless options['secret'].present?
  120. errors.add(:base, "Must specify a secret for 'Authenticating' requests")
  121. end
  122. if options['code'].present? && options['code'].to_s !~ /\A\s*(\d+|\{.*)\s*\z/
  123. errors.add(:base, "Must specify a code for request responses")
  124. end
  125. if options['code'].to_s.in?(['301', '302']) && !options['response'].present?
  126. errors.add(:base, "Must specify a url for request redirect")
  127. end
  128. validate_event_headers_options!
  129. end
  130. def payload_for(params)
  131. Utils.value_at(params, interpolated['payload_path']) || {}
  132. end
  133. end
  134. end