Merge remote-tracking branch 'origin/master' into post_agent_emits_events

Andrew Cantino 9 years ago
parent
commit
2b3a6a2819

+ 2 - 0
CHANGES.md

@@ -1,5 +1,7 @@
1 1
 # Changes
2 2
 
3
+* Jan 14, 2016   - Liquid block tag `regex_replace` and `regex_replace_first` added.
4
+* Jan 12, 2016   - WebhookAgent supports reCAPTCHA.
3 5
 * Dec 26, 2015   - WebsiteAgent can accept a `data_from_event` Liquid template instead of a URL.
4 6
 * Oct 17, 2015   - TwitterSearchAgent added for running period Twitter searches.
5 7
 * Oct 17, 2015   - GapDetectorAgent added to alert when no data has been seen in a certain period of time.

+ 28 - 0
app/concerns/liquid_droppable.rb

@@ -33,6 +33,34 @@ module LiquidDroppable
33 33
     self.class::Drop.new(self)
34 34
   end
35 35
 
36
+  class MatchDataDrop < Liquid::Drop
37
+    def initialize(object)
38
+      @object = object
39
+    end
40
+
41
+    %w[pre_match post_match names size].each { |attr|
42
+      define_method(attr) {
43
+        @object.__send__(attr)
44
+      }
45
+    }
46
+
47
+    def to_s
48
+      @object[0]
49
+    end
50
+
51
+    def before_method(method)
52
+      @object[method]
53
+    rescue IndexError
54
+      nil
55
+    end
56
+  end
57
+
58
+  class ::MatchData
59
+    def to_liquid
60
+      MatchDataDrop.new(self)
61
+    end
62
+  end
63
+
36 64
   require 'uri'
37 65
 
38 66
   class URIDrop < Drop

+ 91 - 0
app/concerns/liquid_interpolatable.rb

@@ -1,3 +1,5 @@
1
+# :markup: markdown
2
+
1 3
 module LiquidInterpolatable
2 4
   extend ActiveSupport::Concern
3 5
 
@@ -311,4 +313,93 @@ module LiquidInterpolatable
311 313
   end
312 314
   Liquid::Template.register_tag('credential', LiquidInterpolatable::Tags::Credential)
313 315
   Liquid::Template.register_tag('line_break', LiquidInterpolatable::Tags::LineBreak)
316
+
317
+  module Blocks
318
+    # Replace every occurrence of a given regex pattern in the first
319
+    # "in" block with the result of the "with" block in which the
320
+    # variable `match` is set for each iteration, which can be used as
321
+    # follows:
322
+    #
323
+    # - `match[0]` or just `match`: the whole matching string
324
+    # - `match[1]`..`match[n]`: strings matching the numbered capture groups
325
+    # - `match.size`: total number of the elements above (n+1)
326
+    # - `match.names`: array of names of named capture groups
327
+    # - `match[name]`..: strings matching the named capture groups
328
+    # - `match.pre_match`: string preceding the match
329
+    # - `match.post_match`: string following the match
330
+    # - `match.***`: equivalent to `match['***']` unless it conflicts with the existing methods above
331
+    #
332
+    # If named captures (`(?<name>...)`) are used in the pattern, they
333
+    # are also made accessible as variables.  Note that if numbered
334
+    # captures are used mixed with named captures, you could get
335
+    # unexpected results.
336
+    #
337
+    # Example usage:
338
+    #
339
+    #     {% regex_replace "\w+" in %}Use me like this.{% with %}{{ match | capitalize }}{% endregex_replace %}
340
+    #     {% assign fullname = "Doe, John A." %}
341
+    #     {% regex_replace_first "\A(?<name1>.+), (?<name2>.+)\z" in %}{{ fullname }}{% with %}{{ name2 }} {{ name1 }}{% endregex_replace_first %}
342
+    #
343
+    #     Use Me Like This.
344
+    #
345
+    #     John A. Doe
346
+    #
347
+    class RegexReplace < Liquid::Block
348
+      Syntax = /\A\s*(#{Liquid::QuotedFragment})(?:\s+in)?\s*\z/
349
+
350
+      def initialize(tag_name, markup, tokens)
351
+        super
352
+
353
+        case markup
354
+        when Syntax
355
+          @regexp = $1
356
+        else
357
+          raise Liquid::SyntaxError, 'Syntax Error in regex_replace tag - Valid syntax: regex_replace pattern in'
358
+        end
359
+        @nodelist = @in_block = []
360
+        @with_block = nil
361
+      end
362
+
363
+      def nodelist
364
+        if @with_block
365
+          @in_block + @with_block
366
+        else
367
+          @in_block
368
+        end
369
+      end
370
+
371
+      def unknown_tag(tag, markup, tokens)
372
+        return super unless tag == 'with'.freeze
373
+        @nodelist = @with_block = []
374
+      end
375
+
376
+      def render(context)
377
+        begin
378
+          regexp = Regexp.new(context[@regexp].to_s)
379
+        rescue ::SyntaxError => e
380
+          raise Liquid::SyntaxError, "Syntax Error in regex_replace tag - #{e.message}"
381
+        end
382
+
383
+        subject = render_all(@in_block, context)
384
+
385
+        subject.send(first? ? :sub : :gsub, regexp) {
386
+          next '' unless @with_block
387
+          m = Regexp.last_match
388
+          context.stack do
389
+            m.names.each do |name|
390
+              context[name] = m[name]
391
+            end
392
+            context['match'.freeze] = m
393
+            render_all(@with_block, context)
394
+          end
395
+        }
396
+      end
397
+
398
+      def first?
399
+        @tag_name.end_with?('_first'.freeze)
400
+      end
401
+    end
402
+  end
403
+  Liquid::Template.register_tag('regex_replace',       LiquidInterpolatable::Blocks::RegexReplace)
404
+  Liquid::Template.register_tag('regex_replace_first', LiquidInterpolatable::Blocks::RegexReplace)
314 405
 end

+ 31 - 1
app/models/agents/webhook_agent.rb

@@ -1,5 +1,7 @@
1 1
 module Agents
2 2
   class WebhookAgent < Agent
3
+    include WebRequestConcern
4
+
3 5
     cannot_be_scheduled!
4 6
     cannot_receive_events!
5 7
 
@@ -24,6 +26,8 @@ module Agents
24 26
           For example, "post,get" will enable POST and GET requests. Defaults
25 27
           to "post".
26 28
         * `response` - The response message to the request. Defaults to 'Event Created'.
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).
27 31
       MD
28 32
     end
29 33
 
@@ -46,10 +50,36 @@ module Agents
46 50
       secret = params.delete('secret')
47 51
       return ["Not Authorized", 401] unless secret == interpolated['secret']
48 52
 
49
-      #check the verbs
53
+      # check the verbs
50 54
       verbs = (interpolated['verbs'] || 'post').split(/,/).map { |x| x.strip.downcase }.select { |x| x.present? }
51 55
       return ["Please use #{verbs.join('/').upcase} requests only", 401] unless verbs.include?(method)
52 56
 
57
+      # check the reCAPTCHA response if required
58
+      if recaptcha_secret = interpolated['recaptcha_secret'].presence
59
+        recaptcha_response = params.delete('g-recaptcha-response') or
60
+          return ["Not Authorized", 401]
61
+
62
+        parameters = {
63
+          secret: recaptcha_secret,
64
+          response: recaptcha_response,
65
+        }
66
+
67
+        if boolify(interpolated['recaptcha_send_remote_addr'])
68
+          parameters[:remoteip] = request.env['REMOTE_ADDR']
69
+        end
70
+
71
+        begin
72
+          response = faraday.post('https://www.google.com/recaptcha/api/siteverify',
73
+                                  parameters)
74
+        rescue => e
75
+          error "Verification failed: #{e.message}"
76
+          return ["Not Authorized", 401]
77
+        end
78
+
79
+        JSON.parse(response.body)['success'] or
80
+          return ["Not Authorized", 401]
81
+      end
82
+
53 83
       [payload_for(params)].flatten.each do |payload|
54 84
         create_event(payload: payload)
55 85
       end

+ 34 - 0
spec/concerns/liquid_interpolatable_spec.rb

@@ -220,4 +220,38 @@ describe LiquidInterpolatable::Filters do
220 220
       expect(agent.interpolated['test']).to eq("foo\\1\nfoobar\\\nfoobaz\\")
221 221
     end
222 222
   end
223
+
224
+  describe 'regex_replace_first block' do
225
+    let(:agent) { Agents::InterpolatableAgent.new(name: "test") }
226
+
227
+    it 'should replace the first occurrence of a string using regex' do
228
+      agent.interpolation_context['something'] = 'foobar zoobar'
229
+      agent.options['cleaned'] = '{% regex_replace_first "(?<word>\S+)(?<suffix>bar)" in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace_first %}'
230
+      expect(agent.interpolated['cleaned']).to eq('FOObar zoobar')
231
+    end
232
+
233
+    it 'should be able to take a pattern in a variable' do
234
+      agent.interpolation_context['something'] = 'foobar zoobar'
235
+      agent.interpolation_context['pattern'] = "(?<word>\\S+)(?<suffix>bar)"
236
+      agent.options['cleaned'] = '{% regex_replace_first pattern in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace_first %}'
237
+      expect(agent.interpolated['cleaned']).to eq('FOObar zoobar')
238
+    end
239
+
240
+    it 'should define a variable named "match" in a "with" block' do
241
+      agent.interpolation_context['something'] = 'foobar zoobar'
242
+      agent.interpolation_context['pattern'] = "(?<word>\\S+)(?<suffix>bar)"
243
+      agent.options['cleaned'] = '{% regex_replace_first pattern in %}{{ something }}{% with %}{{ match.word | upcase }}{{ match["suffix"] }}{% endregex_replace_first %}'
244
+      expect(agent.interpolated['cleaned']).to eq('FOObar zoobar')
245
+    end
246
+  end
247
+
248
+  describe 'regex_replace block' do
249
+    let(:agent) { Agents::InterpolatableAgent.new(name: "test") }
250
+
251
+    it 'should replace the all occurrences of a string using regex' do
252
+      agent.interpolation_context['something'] = 'foobar zoobar'
253
+      agent.options['cleaned'] = '{% regex_replace "(?<word>\S+)(?<suffix>bar)" in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace %}'
254
+      expect(agent.interpolated['cleaned']).to eq('FOObar ZOObar')
255
+    end
256
+  end
223 257
 end

+ 74 - 0
spec/models/agents/webhook_agent_spec.rb

@@ -223,6 +223,80 @@ describe Agents::WebhookAgent do
223 223
 
224 224
       end
225 225
 
226
+      context "with reCAPTCHA" do
227
+        it "should not check a reCAPTCHA response unless recaptcha_secret is set" do
228
+          checked = false
229
+          out = nil
230
+
231
+          stub_request(:any, /verify/).to_return { |request|
232
+            checked = true
233
+            { status: 200, body: '{"success":false}' }
234
+          }
235
+
236
+          expect {
237
+            out= agent.receive_web_request({ 'secret' => 'foobar', 'some_key' => payload }, "post", "text/html")
238
+          }.not_to change { checked }
239
+
240
+          expect(out).to eq(["Event Created", 201])
241
+        end
242
+
243
+        it "should reject a request if recaptcha_secret is set but g-recaptcha-response is not given" do
244
+          agent.options['recaptcha_secret'] = 'supersupersecret'
245
+
246
+          checked = false
247
+          out = nil
248
+
249
+          stub_request(:any, /verify/).to_return { |request|
250
+            checked = true
251
+            { status: 200, body: '{"success":false}' }
252
+          }
253
+
254
+          expect {
255
+            out = agent.receive_web_request({ 'secret' => 'foobar', 'some_key' => payload }, "post", "text/html")
256
+          }.not_to change { checked }
257
+
258
+          expect(out).to eq(["Not Authorized", 401])
259
+        end
260
+
261
+        it "should reject a request if recaptcha_secret is set and g-recaptcha-response given is not verified" do
262
+          agent.options['recaptcha_secret'] = 'supersupersecret'
263
+
264
+          checked = false
265
+          out = nil
266
+
267
+          stub_request(:any, /verify/).to_return { |request|
268
+            checked = true
269
+            { status: 200, body: '{"success":false}' }
270
+          }
271
+
272
+          expect {
273
+            out = agent.receive_web_request({ 'secret' => 'foobar', 'some_key' => payload, 'g-recaptcha-response' => 'somevalue' }, "post", "text/html")
274
+          }.to change { checked }
275
+
276
+          expect(out).to eq(["Not Authorized", 401])
277
+        end
278
+
279
+        it "should accept a request if recaptcha_secret is set and g-recaptcha-response given is verified" do
280
+          agent.options['payload_path'] = '.'
281
+          agent.options['recaptcha_secret'] = 'supersupersecret'
282
+
283
+          checked = false
284
+          out = nil
285
+
286
+          stub_request(:any, /verify/).to_return { |request|
287
+            checked = true
288
+            { status: 200, body: '{"success":true}' }
289
+          }
290
+
291
+          expect {
292
+            out = agent.receive_web_request(payload.merge({ 'secret' => 'foobar', 'g-recaptcha-response' => 'somevalue' }), "post", "text/html")
293
+          }.to change { checked }
294
+
295
+          expect(out).to eq(["Event Created", 201])
296
+          expect(Event.last.payload).to eq(payload)
297
+        end
298
+      end
299
+
226 300
     end
227 301
 
228 302
   end