koala 1.5.0 → 1.6.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -19,35 +19,23 @@ module Koala
19
19
  end
20
20
 
21
21
  def graph_call_in_batch(path, args = {}, verb = "get", options = {}, &post_processing)
22
- # for batch APIs, we queue up the call details (incl. post-processing)
23
- batch_calls << BatchOperation.new(
24
- :url => path,
25
- :args => args,
26
- :method => verb,
27
- :access_token => options['access_token'] || access_token,
28
- :http_options => options,
29
- :post_processing => post_processing
30
- )
31
- nil # batch operations return nothing immediately
22
+ # for batch APIs, we queue up the call details (incl. post-processing)
23
+ batch_calls << BatchOperation.new(
24
+ :url => path,
25
+ :args => args,
26
+ :method => verb,
27
+ :access_token => options['access_token'] || access_token,
28
+ :http_options => options,
29
+ :post_processing => post_processing
30
+ )
31
+ nil # batch operations return nothing immediately
32
32
  end
33
33
 
34
- def check_graph_batch_api_response(response)
35
- if response.is_a?(Hash) && response["error"] && !response["error"].is_a?(Hash)
36
- # old error format -- see http://developers.facebook.com/blog/post/596/
37
- APIError.new({"type" => "Error #{response["error"]}", "message" => response["error_description"]}.merge(response))
38
- else
39
- check_graph_api_response(response)
40
- end
41
- end
42
-
43
- # redefine the graph_call and check_response methods
44
- # so we can use this API inside the batch block just like any regular Graph API
34
+ # redefine the graph_call method so we can use this API inside the batch block
35
+ # just like any regular Graph API
45
36
  alias_method :graph_call_outside_batch, :graph_call
46
37
  alias_method :graph_call, :graph_call_in_batch
47
38
 
48
- alias_method :check_graph_api_response, :check_response
49
- alias_method :check_response, :check_graph_batch_api_response
50
-
51
39
  # execute the queued batch calls
52
40
  def execute(http_options = {})
53
41
  return [] unless batch_calls.length > 0
@@ -62,7 +50,7 @@ module Koala
62
50
  unless response
63
51
  # Facebook sometimes reportedly returns an empty body at times
64
52
  # see https://github.com/arsduo/koala/issues/184
65
- raise APIError.new({"type" => "BadFacebookResponse", "message" => "Facebook returned invalid batch response: #{response.inspect}"})
53
+ raise BadFacebookResponse.new(200, '', "Facebook returned an empty body")
66
54
  end
67
55
 
68
56
  # map the results with post-processing included
@@ -72,13 +60,16 @@ module Koala
72
60
  batch_op = batch_calls[index]
73
61
  index += 1
74
62
 
63
+ raw_result = nil
75
64
  if call_result
76
- # (see note in regular api method about JSON parsing)
77
- body = MultiJson.load("[#{call_result['body'].to_s}]")[0]
65
+ if ( error = check_response(call_result['code'], call_result['body'].to_s) )
66
+ raw_result = error
67
+ else
68
+ # (see note in regular api method about JSON parsing)
69
+ body = MultiJson.load("[#{call_result['body'].to_s}]")[0]
78
70
 
79
- unless call_result["code"].to_i >= 500 || error = check_response(body)
80
71
  # Get the HTTP component they want
81
- data = case batch_op.http_options[:http_component]
72
+ raw_result = case batch_op.http_options[:http_component]
82
73
  when :status
83
74
  call_result["code"].to_i
84
75
  when :headers
@@ -87,20 +78,19 @@ module Koala
87
78
  else
88
79
  body
89
80
  end
90
-
91
- # process it if we are given a block to process with
92
- batch_op.post_processing ? batch_op.post_processing.call(data) : data
93
- else
94
- error || APIError.new({"type" => "HTTP #{call_result["code"].to_s}", "message" => "Response body: #{body}"})
95
81
  end
82
+ end
83
+
84
+ # turn any results that are pageable into GraphCollections
85
+ # and pass to post-processing callback if given
86
+ result = GraphCollection.evaluate(raw_result, @original_api)
87
+ if batch_op.post_processing
88
+ batch_op.post_processing.call(result)
96
89
  else
97
- nil
90
+ result
98
91
  end
99
92
  end
100
93
  end
101
-
102
- # turn any results that are pageable into GraphCollections
103
- batch_result.inject([]) {|processed_results, raw| processed_results << GraphCollection.evaluate(raw, @original_api)}
104
94
  end
105
95
 
106
96
  end
@@ -1,3 +1,5 @@
1
+ require 'addressable/uri'
2
+
1
3
  module Koala
2
4
  module Facebook
3
5
  class API
@@ -87,7 +89,7 @@ module Koala
87
89
  #
88
90
  # @return an array of parameters that can be provided via graph_call(*parsed_params)
89
91
  def self.parse_page_url(url)
90
- uri = URI.parse(url)
92
+ uri = Addressable::URI.parse(url)
91
93
 
92
94
  base = uri.path.sub(/^\//, '')
93
95
  params = CGI.parse(uri.query)
@@ -21,7 +21,7 @@ module Koala
21
21
  #
22
22
  # @return true if successful, false if not. (This call currently doesn't give useful feedback on failure.)
23
23
  def set_app_properties(properties, args = {}, options = {})
24
- raise APIError.new({"type" => "KoalaMissingAccessToken", "message" => "setAppProperties requires an access token"}) unless @access_token
24
+ raise AuthenticationError.new(nil, nil, "setAppProperties requires an access token") unless @access_token
25
25
  rest_call("admin.setAppProperties", args.merge(:properties => MultiJson.dump(properties)), options, "post")
26
26
  end
27
27
 
@@ -44,8 +44,24 @@ module Koala
44
44
 
45
45
  api("method/#{fb_method}", args.merge('format' => 'json'), verb, options) do |response|
46
46
  # check for REST API-specific errors
47
- if response.is_a?(Hash) && response["error_code"]
48
- raise APIError.new("type" => response["error_code"], "message" => response["error_msg"])
47
+ if response.status >= 400
48
+ begin
49
+ response_hash = MultiJson.load(response.body)
50
+ rescue MultiJson::DecodeError
51
+ response_hash = {}
52
+ end
53
+
54
+ error_info = {
55
+ 'code' => response_hash['error_code'],
56
+ 'error_subcode' => response_hash['error_subcode'],
57
+ 'message' => response_hash['error_msg']
58
+ }
59
+
60
+ if response.status >= 500
61
+ raise ServerError.new(response.status, response.body, error_info)
62
+ else
63
+ raise ClientError.new(response.status, response.body, error_info)
64
+ end
49
65
  end
50
66
  end
51
67
  end
@@ -0,0 +1,86 @@
1
+ module Koala
2
+
3
+ class KoalaError < StandardError; end
4
+
5
+ module Facebook
6
+
7
+ # The OAuth signature is incomplete, invalid, or using an unsupported algorithm
8
+ class OAuthSignatureError < ::Koala::KoalaError; end
9
+
10
+ # Facebook responded with an error to an API request. If the exception contains a nil
11
+ # http_status, then the error was detected before making a call to Facebook. (e.g. missing access token)
12
+ class APIError < ::Koala::KoalaError
13
+ attr_accessor :fb_error_type, :fb_error_code, :fb_error_subcode, :fb_error_message,
14
+ :http_status, :response_body
15
+
16
+ # Create a new API Error
17
+ #
18
+ # @param http_status [Integer] The HTTP status code of the response
19
+ # @param response_body [String] The response body
20
+ # @param error_info One of the following:
21
+ # [Hash] The error information extracted from the request
22
+ # ("type", "code", "error_subcode", "message")
23
+ # [String] The error description
24
+ # If error_info is nil or not provided, the method will attempt to extract
25
+ # the error info from the response_body
26
+ #
27
+ # @return the newly created APIError
28
+ def initialize(http_status, response_body, error_info = nil)
29
+ if response_body
30
+ self.response_body = response_body.strip
31
+ else
32
+ self.response_body = ''
33
+ end
34
+ self.http_status = http_status
35
+
36
+ if error_info && error_info.is_a?(String)
37
+ message = error_info
38
+ else
39
+ unless error_info
40
+ begin
41
+ error_info = MultiJson.load(response_body)['error'] if response_body
42
+ rescue
43
+ end
44
+ error_info ||= {}
45
+ end
46
+
47
+ self.fb_error_type = error_info["type"]
48
+ self.fb_error_code = error_info["code"]
49
+ self.fb_error_subcode = error_info["error_subcode"]
50
+ self.fb_error_message = error_info["message"]
51
+
52
+ error_array = []
53
+ %w(type code error_subcode message).each do |key|
54
+ error_array << "#{key}: #{error_info[key]}" if error_info[key]
55
+ end
56
+
57
+ if error_array.empty?
58
+ message = self.response_body
59
+ else
60
+ message = error_array.join(', ')
61
+ end
62
+ end
63
+ message += " [HTTP #{http_status}]" if http_status
64
+
65
+ super(message)
66
+ end
67
+ end
68
+
69
+ # Facebook returned an invalid response body
70
+ class BadFacebookResponse < APIError; end
71
+
72
+ # Facebook responded with an error while attempting to request an access token
73
+ class OAuthTokenRequestError < APIError; end
74
+
75
+ # Any error with a 5xx HTTP status code
76
+ class ServerError < APIError; end
77
+
78
+ # Any error with a 4xx HTTP status code
79
+ class ClientError < APIError; end
80
+
81
+ # All graph API authentication failures.
82
+ class AuthenticationError < ClientError; end
83
+
84
+ end
85
+
86
+ end
data/lib/koala/oauth.rb CHANGED
@@ -139,7 +139,7 @@ module Koala
139
139
  # @param code (see #url_for_access_token)
140
140
  # @param options any additional parameters to send to Facebook when redeeming the token
141
141
  #
142
- # @raise Koala::Facebook::APIError if Facebook returns an error response
142
+ # @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response
143
143
  #
144
144
  # @return a hash of the access token info returned by Facebook (token, expiration, etc.)
145
145
  def get_access_token_info(code, options = {})
@@ -221,21 +221,21 @@ module Koala
221
221
  #
222
222
  # @param input the signed request from Facebook
223
223
  #
224
- # @raise RuntimeError if the signature is incomplete, invalid, or using an unsupported algorithm
224
+ # @raise OAuthSignatureError if the signature is incomplete, invalid, or using an unsupported algorithm
225
225
  #
226
226
  # @return a hash of the validated request information
227
227
  def parse_signed_request(input)
228
228
  encoded_sig, encoded_envelope = input.split('.', 2)
229
- raise 'SignedRequest: Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
229
+ raise OAuthSignatureError, 'Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
230
230
 
231
231
  signature = base64_url_decode(encoded_sig).unpack("H*").first
232
232
  envelope = MultiJson.load(base64_url_decode(encoded_envelope))
233
233
 
234
- raise "SignedRequest: Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
234
+ raise OAuthSignatureError, "Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
235
235
 
236
236
  # now see if the signature is valid (digest, key, data)
237
237
  hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope)
238
- raise 'SignedRequest: Invalid signature' if (signature != hmac)
238
+ raise OAuthSignatureError, 'Invalid signature' if (signature != hmac)
239
239
 
240
240
  envelope
241
241
  end
@@ -254,10 +254,7 @@ module Koala
254
254
 
255
255
  # Facebook returns an empty body in certain error conditions
256
256
  if response == ""
257
- raise APIError.new({
258
- "type" => "ArgumentError",
259
- "message" => "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!"
260
- })
257
+ raise BadFacebookResponse.new(200, '', "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!")
261
258
  end
262
259
 
263
260
  MultiJson.load(response)
@@ -282,13 +279,8 @@ module Koala
282
279
 
283
280
  def get_token_from_server(args, post = false, options = {})
284
281
  # fetch the result from Facebook's servers
285
- result = fetch_token_string(args, post, "access_token", options)
286
-
287
- # if we have an error, parse the error JSON and raise an error
288
- raise APIError.new((MultiJson.load(result)["error"] rescue nil) || {}) if result =~ /error/
289
-
290
- # otherwise, parse the access token
291
- parse_access_token(result)
282
+ response = fetch_token_string(args, post, "access_token", options)
283
+ parse_access_token(response)
292
284
  end
293
285
 
294
286
  def parse_access_token(response_text)
@@ -318,9 +310,12 @@ module Koala
318
310
  if code = components["code"]
319
311
  begin
320
312
  token_info = get_access_token_info(code, :redirect_uri => '')
321
- rescue Koala::Facebook::APIError => err
322
- return nil if err.message =~ /Code was invalid or expired/
323
- raise
313
+ rescue Koala::Facebook::OAuthTokenRequestError => err
314
+ if err.fb_error_type == 'OAuthException' && err.fb_error_message =~ /Code was invalid or expired/
315
+ return nil
316
+ else
317
+ raise
318
+ end
324
319
  end
325
320
 
326
321
  components.merge(token_info) if token_info
@@ -331,10 +326,15 @@ module Koala
331
326
  end
332
327
 
333
328
  def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
334
- Koala.make_request("/oauth/#{endpoint}", {
329
+ response = Koala.make_request("/oauth/#{endpoint}", {
335
330
  :client_id => @app_id,
336
331
  :client_secret => @app_secret
337
- }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options)).body
332
+ }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options))
333
+
334
+ raise ServerError.new(response.status, response.body) if response.status >= 500
335
+ raise OAuthTokenRequestError.new(response.status, response.body) if response.status >= 400
336
+
337
+ response.body
338
338
  end
339
339
 
340
340
  # base 64
@@ -7,19 +7,19 @@ module Koala
7
7
  # @note: to subscribe to real-time updates, you must have an application access token
8
8
  # or provide the app secret when initializing your RealtimeUpdates object.
9
9
 
10
- # The application API interface used to communicate with Facebook.
11
- # @return [Koala::Facebook::API]
10
+ # The application API interface used to communicate with Facebook.
11
+ # @return [Koala::Facebook::API]
12
12
  attr_reader :api
13
13
  attr_reader :app_id, :app_access_token, :secret
14
14
 
15
- # Create a new RealtimeUpdates instance.
16
- # If you don't have your app's access token, provide the app's secret and
15
+ # Create a new RealtimeUpdates instance.
16
+ # If you don't have your app's access token, provide the app's secret and
17
17
  # Koala will make a request to Facebook for the appropriate token.
18
- #
18
+ #
19
19
  # @param options initialization options.
20
20
  # @option options :app_id the application's ID.
21
21
  # @option options :app_access_token an application access token, if known.
22
- # @option options :secret the application's secret.
22
+ # @option options :secret the application's secret.
23
23
  #
24
24
  # @raise ArgumentError if the application ID and one of the app access token or the secret are not provided.
25
25
  def initialize(options = {})
@@ -39,7 +39,7 @@ module Koala
39
39
  @api = API.new(@app_access_token)
40
40
  end
41
41
 
42
- # Subscribe to realtime updates for certain fields on a given object (user, page, etc.).
42
+ # Subscribe to realtime updates for certain fields on a given object (user, page, etc.).
43
43
  # See {http://developers.facebook.com/docs/reference/api/realtime the realtime updates documentation}
44
44
  # for more information on what objects and fields you can register for.
45
45
  #
@@ -48,11 +48,11 @@ module Koala
48
48
  # @param object a Facebook ID (name or number)
49
49
  # @param fields the fields you want your app to be updated about
50
50
  # @param callback_url the URL Facebook should ping when an update is available
51
- # @param verify_token a token included in the verification request, allowing you to ensure the call is genuine
51
+ # @param verify_token a token included in the verification request, allowing you to ensure the call is genuine
52
52
  # (see the docs for more information)
53
53
  # @param options (see Koala::HTTPService.make_request)
54
54
  #
55
- # @return true if successful, false (or an APIError) otherwise.
55
+ # @raise A subclass of Koala::Facebook::APIError if the subscription request failed.
56
56
  def subscribe(object, fields, callback_url, verify_token, options = {})
57
57
  args = {
58
58
  :object => object,
@@ -60,23 +60,23 @@ module Koala
60
60
  :callback_url => callback_url,
61
61
  }.merge(verify_token ? {:verify_token => verify_token} : {})
62
62
  # a subscription is a success if Facebook returns a 200 (after hitting your server for verification)
63
- @api.graph_call(subscription_path, args, 'post', options.merge(:http_component => :status)) == 200
63
+ @api.graph_call(subscription_path, args, 'post', options)
64
64
  end
65
65
 
66
- # Unsubscribe from updates for a particular object or from updates.
66
+ # Unsubscribe from updates for a particular object or from updates.
67
67
  #
68
- # @param object the object whose subscriptions to delete.
68
+ # @param object the object whose subscriptions to delete.
69
69
  # If no object is provided, all subscriptions will be removed.
70
70
  # @param options (see Koala::HTTPService.make_request)
71
71
  #
72
- # @return true if the unsubscription is successful, false (or an APIError) otherwise.
72
+ # @raise A subclass of Koala::Facebook::APIError if the subscription request failed.
73
73
  def unsubscribe(object = nil, options = {})
74
- @api.graph_call(subscription_path, object ? {:object => object} : {}, "delete", options.merge(:http_component => :status)) == 200
74
+ @api.graph_call(subscription_path, object ? {:object => object} : {}, "delete", options)
75
75
  end
76
76
 
77
77
  # List all active subscriptions for this application.
78
- #
79
- # @param options (see Koala::HTTPService.make_request)
78
+ #
79
+ # @param options (see Koala::HTTPService.make_request)
80
80
  #
81
81
  # @return [Array] a list of active subscriptions
82
82
  def list_subscriptions(options = {})
@@ -89,12 +89,12 @@ module Koala
89
89
  #
90
90
  # @param params the request parameters sent by Facebook. (You can pass in a Rails params hash.)
91
91
  # @param verify_token the verify token sent in the {#subscribe subscription request}, if you provided one
92
- #
92
+ #
93
93
  # @yield verify_token if you need to compute the verification token
94
94
  # (for instance, if your callback URL includes a record ID, which you look up
95
- # and use to calculate a hash), you can pass meet_challenge a block, which
95
+ # and use to calculate a hash), you can pass meet_challenge a block, which
96
96
  # will receive the verify_token received back from Facebook.
97
- #
97
+ #
98
98
  # @return the challenge string to be sent back to Facebook, or false if the request is invalid.
99
99
  def self.meet_challenge(params, verify_token = nil, &verification_block)
100
100
  if params["hub.mode"] == "subscribe" &&
@@ -111,12 +111,33 @@ module Koala
111
111
  false
112
112
  end
113
113
  end
114
-
114
+
115
+ # Public: As a security measure, all updates from facebook are signed using
116
+ # X-Hub-Signature: sha1=XXXX where XXX is the sha1 of the json payload
117
+ # using your application secret as the key.
118
+ #
119
+ # Example:
120
+ # # in Rails controller
121
+ # # @oauth being a previously defined Koala::Facebook::OAuth instance
122
+ # def receive_update
123
+ # if @oauth.validate_update(request.body, headers)
124
+ # ...
125
+ # end
126
+ # end
127
+ def validate_update(body, headers)
128
+ if request_signature = headers['X-Hub-Signature'] || headers['HTTP_X_HUB_SIGNATURE'] and
129
+ signature_parts = request_signature.split("sha1=")
130
+ request_signature = signature_parts[1]
131
+ calculated_signature = OpenSSL::HMAC.hexdigest('sha1', @secret, body)
132
+ calculated_signature == request_signature
133
+ end
134
+ end
135
+
115
136
  # The Facebook subscription management URL for your application.
116
137
  def subscription_path
117
138
  @subscription_path ||= "#{@app_id}/subscriptions"
118
139
  end
119
-
140
+
120
141
  # @private
121
142
  def graph_api
122
143
  Koala::Utils.deprecate("the TestUsers.graph_api accessor is deprecated and will be removed in a future version; please use .api instead.")