fopost 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'time'
6
+
7
+ require 'fopost/models'
8
+
9
+ module Fopost
10
+ # Raised when a webhook delivery cannot be trusted.
11
+ class WebhookSignatureError < StandardError; end
12
+
13
+ # Verifies the signature FoPost puts on every outbound webhook, including
14
+ # the website target of the blog.
15
+ #
16
+ # event = Fopost::Webhooks.verify(request.raw_post, request.headers, secret)
17
+ # event.article.title if event.event == 'article.published'
18
+ #
19
+ # Verify the raw request bytes, never re-serialised JSON: key order and
20
+ # whitespace change the bytes and the signature no longer matches.
21
+ module Webhooks
22
+ SIGNATURE_HEADER = 'x-fopost-signature'
23
+ TIMESTAMPED_SIGNATURE_HEADER = 'x-fopost-signature-256'
24
+ TIMESTAMP_HEADER = 'x-fopost-timestamp'
25
+ EVENT_HEADER = 'x-fopost-event'
26
+ DELIVERY_HEADER = 'x-fopost-delivery'
27
+ DEFAULT_TOLERANCE = 300
28
+
29
+ module_function
30
+
31
+ # Returns the parsed envelope as a {Fopost::WebhookEvent}, or raises
32
+ # {Fopost::WebhookSignatureError}.
33
+ #
34
+ # The timestamped signature is checked when `X-FoPost-Timestamp` is
35
+ # present and rejected when it is older than `tolerance` seconds; the
36
+ # plain body signature is accepted only when no timestamp was sent.
37
+ # `headers` may use any casing; a Rack `HTTP_X_FOPOST_*` key also works.
38
+ def verify(raw_body, headers, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
39
+ raise WebhookSignatureError, 'fopost: a webhook secret is required' if secret.nil? || secret.to_s.empty?
40
+
41
+ body = raw_body.to_s
42
+ lookup = normalize_headers(headers)
43
+ timestamp = lookup[TIMESTAMP_HEADER]
44
+
45
+ if timestamp
46
+ check_timestamp(timestamp, tolerance, now)
47
+ expected = sign("#{timestamp}.#{body}", secret)
48
+ match = secure_equal(lookup[TIMESTAMPED_SIGNATURE_HEADER], expected)
49
+ else
50
+ match = secure_equal(lookup[SIGNATURE_HEADER], sign(body, secret))
51
+ end
52
+ raise WebhookSignatureError, 'fopost: webhook signature mismatch' unless match
53
+
54
+ parse(body)
55
+ end
56
+
57
+ # The header value FoPost sends for these exact bytes.
58
+ def sign(payload, secret)
59
+ "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret.to_s, payload)}"
60
+ end
61
+
62
+ def check_timestamp(timestamp, tolerance, now)
63
+ seconds = Integer(timestamp.to_s.strip, exception: false)
64
+ raise WebhookSignatureError, 'fopost: webhook timestamp is not a number' unless seconds
65
+
66
+ drift = ((now || Time.now).to_i - seconds).abs
67
+ raise WebhookSignatureError, 'fopost: webhook timestamp outside tolerance' if drift > tolerance
68
+ end
69
+
70
+ def secure_equal(given, expected)
71
+ return false if given.nil?
72
+
73
+ given = given.to_s.strip
74
+ return false unless given.bytesize == expected.bytesize
75
+
76
+ OpenSSL.fixed_length_secure_compare(given, expected)
77
+ end
78
+
79
+ def parse(body)
80
+ data = JSON.parse(body)
81
+ raise WebhookSignatureError, 'fopost: webhook body is not an object' unless data.is_a?(Hash)
82
+
83
+ WebhookEvent.new(data)
84
+ rescue JSON::ParserError
85
+ raise WebhookSignatureError, 'fopost: webhook body is not JSON'
86
+ end
87
+
88
+ def normalize_headers(headers)
89
+ (headers || {}).each_with_object({}) do |(key, value), out|
90
+ name = key.to_s.downcase.tr('_', '-').sub(/\Ahttp-/, '')
91
+ out[name] = value.is_a?(Array) ? value.first : value
92
+ end
93
+ end
94
+
95
+ private_class_method :check_timestamp, :secure_equal, :parse, :normalize_headers
96
+ end
97
+ end
data/lib/fopost.rb CHANGED
@@ -6,11 +6,13 @@ require 'fopost/unset'
6
6
  require 'fopost/platforms'
7
7
  require 'fopost/model'
8
8
  require 'fopost/models'
9
+ require 'fopost/file_part'
9
10
  require 'fopost/http/response'
10
11
  require 'fopost/http/transport'
11
12
  require 'fopost/http/net_http_transport'
12
13
  require 'fopost/http/client'
13
14
  require 'fopost/client'
15
+ require 'fopost/webhooks'
14
16
 
15
17
  # Official Ruby SDK for the FoPost API.
16
18
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fopost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - FoPost
@@ -22,23 +22,39 @@ files:
22
22
  - lib/fopost.rb
23
23
  - lib/fopost/client.rb
24
24
  - lib/fopost/errors.rb
25
+ - lib/fopost/file_part.rb
25
26
  - lib/fopost/http/client.rb
27
+ - lib/fopost/http/multipart.rb
26
28
  - lib/fopost/http/net_http_transport.rb
27
29
  - lib/fopost/http/response.rb
28
30
  - lib/fopost/http/transport.rb
29
31
  - lib/fopost/model.rb
30
32
  - lib/fopost/models.rb
31
33
  - lib/fopost/platforms.rb
34
+ - lib/fopost/resources/account_groups.rb
32
35
  - lib/fopost/resources/accounts.rb
36
+ - lib/fopost/resources/activity.rb
33
37
  - lib/fopost/resources/ads.rb
34
38
  - lib/fopost/resources/ai.rb
39
+ - lib/fopost/resources/analytics.rb
35
40
  - lib/fopost/resources/base.rb
41
+ - lib/fopost/resources/blog.rb
42
+ - lib/fopost/resources/blogs.rb
43
+ - lib/fopost/resources/broadcasts.rb
44
+ - lib/fopost/resources/contacts.rb
45
+ - lib/fopost/resources/google_ads.rb
46
+ - lib/fopost/resources/google_business.rb
36
47
  - lib/fopost/resources/inbox.rb
48
+ - lib/fopost/resources/knowledge.rb
37
49
  - lib/fopost/resources/labels.rb
50
+ - lib/fopost/resources/media.rb
38
51
  - lib/fopost/resources/posts.rb
52
+ - lib/fopost/resources/validate.rb
53
+ - lib/fopost/resources/whatsapp.rb
39
54
  - lib/fopost/resources/workspaces.rb
40
55
  - lib/fopost/unset.rb
41
56
  - lib/fopost/version.rb
57
+ - lib/fopost/webhooks.rb
42
58
  homepage: https://fopost.com
43
59
  licenses:
44
60
  - MIT