koala 0.4 → 3.7.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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +32 -0
- data/.gitignore +9 -0
- data/.rspec +1 -0
- data/.yardopts +3 -0
- data/Gemfile +25 -0
- data/ISSUE_TEMPLATE +25 -0
- data/LICENSE +22 -0
- data/Manifest +32 -5
- data/PULL_REQUEST_TEMPLATE +11 -0
- data/Rakefile +12 -12
- data/changelog.md +781 -0
- data/code_of_conduct.md +74 -0
- data/koala.gemspec +28 -24
- data/lib/koala/api/batch_operation.rb +86 -0
- data/lib/koala/api/graph_api_methods.rb +504 -0
- data/lib/koala/api/graph_batch_api.rb +167 -0
- data/lib/koala/api/graph_collection.rb +129 -0
- data/lib/koala/api/graph_error_checker.rb +72 -0
- data/lib/koala/api.rb +159 -0
- data/lib/koala/configuration.rb +56 -0
- data/lib/koala/errors.rb +126 -0
- data/lib/koala/http_service/request.rb +133 -0
- data/lib/koala/http_service/response.rb +20 -0
- data/lib/koala/http_service/uploadable_io.rb +183 -0
- data/lib/koala/http_service.rb +108 -0
- data/lib/koala/oauth.rb +342 -0
- data/lib/koala/realtime_updates.rb +151 -0
- data/lib/koala/test_users.rb +189 -0
- data/lib/koala/utils.rb +41 -0
- data/lib/koala/version.rb +3 -0
- data/lib/koala.rb +51 -291
- data/readme.md +269 -21
- data/spec/cases/api_spec.rb +362 -0
- data/spec/cases/configuration_spec.rb +11 -0
- data/spec/cases/error_spec.rb +143 -0
- data/spec/cases/graph_api_batch_spec.rb +788 -0
- data/spec/cases/graph_api_spec.rb +76 -0
- data/spec/cases/graph_collection_spec.rb +192 -0
- data/spec/cases/graph_error_checker_spec.rb +147 -0
- data/spec/cases/http_service/request_spec.rb +250 -0
- data/spec/cases/http_service/response_spec.rb +24 -0
- data/spec/cases/http_service_spec.rb +280 -0
- data/spec/cases/koala_spec.rb +57 -0
- data/spec/cases/koala_test_spec.rb +5 -0
- data/spec/cases/oauth_spec.rb +647 -0
- data/spec/cases/realtime_updates_spec.rb +327 -0
- data/spec/cases/test_users_spec.rb +383 -0
- data/spec/cases/uploadable_io_spec.rb +266 -0
- data/spec/cases/utils_spec.rb +55 -0
- data/spec/fixtures/beach.jpg +0 -0
- data/spec/fixtures/cat.m4v +0 -0
- data/spec/fixtures/facebook_data.yml +63 -0
- data/spec/fixtures/mock_facebook_responses.yml +483 -0
- data/spec/fixtures/vcr_cassettes/app_test_accounts.yml +97 -0
- data/spec/fixtures/vcr_cassettes/friend_list_next_page.yml +121 -0
- data/spec/integration/graph_collection_spec.rb +24 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/support/custom_matchers.rb +28 -0
- data/spec/support/graph_api_shared_examples.rb +534 -0
- data/spec/support/koala_test.rb +251 -0
- data/spec/support/mock_http_service.rb +140 -0
- data/spec/support/uploadable_io_shared_examples.rb +70 -0
- metadata +206 -62
- data/CHANGELOG +0 -24
- data/init.rb +0 -2
- data/lib/http_services.rb +0 -60
- data/test/facebook_data.yml +0 -5
- data/test/koala/facebook_no_access_token_tests.rb +0 -119
- data/test/koala/facebook_with_access_token_tests.rb +0 -106
- data/test/koala_tests.rb +0 -30
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Koala
|
|
2
|
+
module HTTPService
|
|
3
|
+
class Response
|
|
4
|
+
attr_reader :status, :body, :headers
|
|
5
|
+
|
|
6
|
+
# Creates a new Response object, which standardizes the response received by Facebook for use within Koala.
|
|
7
|
+
def initialize(status, body, headers)
|
|
8
|
+
@status = status
|
|
9
|
+
@body = body
|
|
10
|
+
@headers = headers
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def data
|
|
14
|
+
# quirks_mode is needed because Facebook sometimes returns a raw true or false value --
|
|
15
|
+
# in Ruby 2.4 we can drop that.
|
|
16
|
+
@data ||= JSON.parse(body, quirks_mode: true) unless body.empty?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require "tempfile"
|
|
2
|
+
|
|
3
|
+
module Koala
|
|
4
|
+
module HTTPService
|
|
5
|
+
class UploadableIO
|
|
6
|
+
attr_reader :io_or_path, :content_type, :filename
|
|
7
|
+
|
|
8
|
+
def initialize(io_or_path_or_mixed, content_type = nil, filename = nil)
|
|
9
|
+
# see if we got the right inputs
|
|
10
|
+
parse_init_mixed_param io_or_path_or_mixed, content_type
|
|
11
|
+
|
|
12
|
+
# filename is used in the Ads API
|
|
13
|
+
# if it's provided, take precedence over the detected filename
|
|
14
|
+
# otherwise, fall back to a dummy name
|
|
15
|
+
@filename = filename || @filename || "koala-io-file.dum"
|
|
16
|
+
|
|
17
|
+
raise KoalaError.new("Invalid arguments to initialize an UploadableIO") unless @io_or_path
|
|
18
|
+
raise KoalaError.new("Unable to determine MIME type for UploadableIO") if !@content_type
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_upload_io
|
|
22
|
+
UploadIO.new(@io_or_path, @content_type, @filename)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_file
|
|
26
|
+
@io_or_path.is_a?(String) ? File.open(@io_or_path) : @io_or_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.binary_content?(content)
|
|
30
|
+
content.is_a?(UploadableIO) || DETECTION_STRATEGIES.detect {|method| send(method, content)}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
DETECTION_STRATEGIES = [
|
|
35
|
+
:sinatra_param?,
|
|
36
|
+
:rails_3_param?,
|
|
37
|
+
:file_param?
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
PARSE_STRATEGIES = [
|
|
41
|
+
:parse_rails_3_param,
|
|
42
|
+
:parse_sinatra_param,
|
|
43
|
+
:parse_file_object,
|
|
44
|
+
:parse_string_path,
|
|
45
|
+
:parse_io
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def parse_init_mixed_param(mixed, content_type = nil)
|
|
49
|
+
PARSE_STRATEGIES.each do |method|
|
|
50
|
+
send(method, mixed, content_type)
|
|
51
|
+
return if @io_or_path && @content_type
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Expects a parameter of type ActionDispatch::Http::UploadedFile
|
|
56
|
+
def self.rails_3_param?(uploaded_file)
|
|
57
|
+
uploaded_file.respond_to?(:content_type) and uploaded_file.respond_to?(:tempfile) and uploaded_file.tempfile.respond_to?(:path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_rails_3_param(uploaded_file, content_type = nil)
|
|
61
|
+
if UploadableIO.rails_3_param?(uploaded_file)
|
|
62
|
+
@io_or_path = uploaded_file.tempfile.path
|
|
63
|
+
@content_type = content_type || uploaded_file.content_type
|
|
64
|
+
@filename = uploaded_file.original_filename
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Expects a Sinatra hash of file info
|
|
69
|
+
def self.sinatra_param?(file_hash)
|
|
70
|
+
file_hash.kind_of?(Hash) and file_hash.has_key?(:type) and file_hash.has_key?(:tempfile)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_sinatra_param(file_hash, content_type = nil)
|
|
74
|
+
if UploadableIO.sinatra_param?(file_hash)
|
|
75
|
+
@io_or_path = file_hash[:tempfile]
|
|
76
|
+
@content_type = content_type || file_hash[:type] || detect_mime_type(tempfile)
|
|
77
|
+
@filename = file_hash[:filename]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# takes a file object
|
|
82
|
+
def self.file_param?(file)
|
|
83
|
+
file.kind_of?(File) || file.kind_of?(Tempfile)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_file_object(file, content_type = nil)
|
|
87
|
+
if UploadableIO.file_param?(file)
|
|
88
|
+
@io_or_path = file
|
|
89
|
+
@content_type = content_type || detect_mime_type(file.path)
|
|
90
|
+
@filename = File.basename(file.path)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_string_path(path, content_type = nil)
|
|
95
|
+
if path.kind_of?(String)
|
|
96
|
+
@io_or_path = path
|
|
97
|
+
@content_type = content_type || detect_mime_type(path)
|
|
98
|
+
@filename = File.basename(path)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_io(io, content_type = nil)
|
|
103
|
+
if io.respond_to?(:read)
|
|
104
|
+
@io_or_path = io
|
|
105
|
+
@content_type = content_type
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
MIME_TYPE_STRATEGIES = [
|
|
110
|
+
:use_mime_module,
|
|
111
|
+
:use_simple_detection
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def detect_mime_type(filename)
|
|
115
|
+
if filename
|
|
116
|
+
MIME_TYPE_STRATEGIES.each do |method|
|
|
117
|
+
result = send(method, filename)
|
|
118
|
+
return result if result
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
nil # if we can't find anything
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def use_mime_module(filename)
|
|
125
|
+
# if the user has installed mime/types, we can use that
|
|
126
|
+
# if not, rescue and return nil
|
|
127
|
+
begin
|
|
128
|
+
type = MIME::Types.type_for(filename).first
|
|
129
|
+
type ? type.to_s : nil
|
|
130
|
+
rescue
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def use_simple_detection(filename)
|
|
136
|
+
# very rudimentary extension analysis for images
|
|
137
|
+
# first, get the downcased extension, or an empty string if it doesn't exist
|
|
138
|
+
extension = ((filename.match(/\.([a-zA-Z0-9]+)$/) || [])[1] || "").downcase
|
|
139
|
+
case extension
|
|
140
|
+
when ""
|
|
141
|
+
nil
|
|
142
|
+
# images
|
|
143
|
+
when "jpg", "jpeg"
|
|
144
|
+
"image/jpeg"
|
|
145
|
+
when "png"
|
|
146
|
+
"image/png"
|
|
147
|
+
when "gif"
|
|
148
|
+
"image/gif"
|
|
149
|
+
|
|
150
|
+
# video
|
|
151
|
+
when "3g2"
|
|
152
|
+
"video/3gpp2"
|
|
153
|
+
when "3gp", "3gpp"
|
|
154
|
+
"video/3gpp"
|
|
155
|
+
when "asf"
|
|
156
|
+
"video/x-ms-asf"
|
|
157
|
+
when "avi"
|
|
158
|
+
"video/x-msvideo"
|
|
159
|
+
when "flv"
|
|
160
|
+
"video/x-flv"
|
|
161
|
+
when "m4v"
|
|
162
|
+
"video/x-m4v"
|
|
163
|
+
when "mkv"
|
|
164
|
+
"video/x-matroska"
|
|
165
|
+
when "mod"
|
|
166
|
+
"video/mod"
|
|
167
|
+
when "mov", "qt"
|
|
168
|
+
"video/quicktime"
|
|
169
|
+
when "mp4", "mpeg4"
|
|
170
|
+
"video/mp4"
|
|
171
|
+
when "mpe", "mpeg", "mpg", "tod", "vob"
|
|
172
|
+
"video/mpeg"
|
|
173
|
+
when "nsv"
|
|
174
|
+
"application/x-winamp"
|
|
175
|
+
when "ogm", "ogv"
|
|
176
|
+
"video/ogg"
|
|
177
|
+
when "wmv"
|
|
178
|
+
"video/x-ms-wmv"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'faraday/multipart' unless defined? Faraday::FilePart # hack for faraday < 1.9 to avoid warnings
|
|
3
|
+
require 'koala/http_service/uploadable_io'
|
|
4
|
+
require 'koala/http_service/response'
|
|
5
|
+
require 'koala/http_service/request'
|
|
6
|
+
|
|
7
|
+
module Koala
|
|
8
|
+
module HTTPService
|
|
9
|
+
class << self
|
|
10
|
+
# A customized stack of Faraday middleware that will be used to make each request.
|
|
11
|
+
attr_accessor :faraday_middleware
|
|
12
|
+
# A default set of HTTP options (see https://github.com/arsduo/koala/wiki/HTTP-Services)
|
|
13
|
+
attr_accessor :http_options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@http_options ||= {}
|
|
17
|
+
|
|
18
|
+
# Koala's default middleware stack.
|
|
19
|
+
# We encode requests in a Facebook-compatible multipart request,
|
|
20
|
+
# and use whichever adapter has been configured for this application.
|
|
21
|
+
DEFAULT_MIDDLEWARE = Proc.new do |builder|
|
|
22
|
+
builder.request :multipart
|
|
23
|
+
builder.request :url_encoded
|
|
24
|
+
builder.adapter Faraday.default_adapter
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Default server information for Facebook. These can be overridden by setting config values.
|
|
28
|
+
# See Koala.config.
|
|
29
|
+
DEFAULT_SERVERS = {
|
|
30
|
+
:graph_server => 'graph.facebook.com',
|
|
31
|
+
:dialog_host => 'www.facebook.com',
|
|
32
|
+
:host_path_matcher => /\.facebook/,
|
|
33
|
+
:video_replace => '-video.facebook',
|
|
34
|
+
:beta_replace => '.beta.facebook'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Makes a request directly to Facebook.
|
|
38
|
+
# @note You'll rarely need to call this method directly.
|
|
39
|
+
#
|
|
40
|
+
# @see Koala::Facebook::API#api
|
|
41
|
+
# @see Koala::Facebook::GraphAPIMethods#graph_call
|
|
42
|
+
#
|
|
43
|
+
# @param request a Koala::HTTPService::Request object
|
|
44
|
+
#
|
|
45
|
+
# @raise an appropriate connection error if unable to make the request to Facebook
|
|
46
|
+
#
|
|
47
|
+
# @return [Koala::HTTPService::Response] a response object representing the results from Facebook
|
|
48
|
+
def self.make_request(request)
|
|
49
|
+
# set up our Faraday connection
|
|
50
|
+
conn = Faraday.new(request.server, faraday_options(request.options), &(faraday_middleware || DEFAULT_MIDDLEWARE))
|
|
51
|
+
|
|
52
|
+
filtered_args = request.raw_args.dup.transform_keys(&:to_s)
|
|
53
|
+
|
|
54
|
+
if Koala.config.mask_tokens
|
|
55
|
+
%w(access_token input_token).each do |arg_token|
|
|
56
|
+
if (token = filtered_args[arg_token])
|
|
57
|
+
filtered_args[arg_token] = token[0, 10] + '*****' + token[-5, 5]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Koala::Utils.debug "STARTED => #{request.verb.upcase}: #{request.path} params: #{filtered_args.inspect}"
|
|
63
|
+
|
|
64
|
+
if request.verb == "post" && request.json?
|
|
65
|
+
# JSON requires a bit more handling
|
|
66
|
+
# remember, all non-GET requests are turned into POSTs, so this covers everything but GETs
|
|
67
|
+
response = conn.post do |req|
|
|
68
|
+
req.path = request.path
|
|
69
|
+
req.headers["Content-Type"] = "application/json"
|
|
70
|
+
req.body = request.post_args.to_json
|
|
71
|
+
req
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
response = conn.send(request.verb, request.path, request.post_args)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Koala::Utils.debug "FINISHED => #{request.verb.upcase}: #{request.path} params: #{filtered_args.inspect}"
|
|
78
|
+
Koala::HTTPService::Response.new(response.status.to_i, response.body, response.headers)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Encodes a given hash into a query string.
|
|
82
|
+
# This is used mainly by the Batch API nowadays, since Faraday handles this for regular cases.
|
|
83
|
+
#
|
|
84
|
+
# @param params_hash a hash of values to CGI-encode and appropriately join
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# Koala.http_service.encode_params({:a => 2, :b => "My String"})
|
|
88
|
+
# => "a=2&b=My+String"
|
|
89
|
+
#
|
|
90
|
+
# @return the appropriately-encoded string
|
|
91
|
+
def self.encode_params(param_hash)
|
|
92
|
+
((param_hash || {}).sort_by{|k, v| k.to_s}.collect do |key_and_value|
|
|
93
|
+
value = key_and_value[1]
|
|
94
|
+
unless value.is_a? String
|
|
95
|
+
value = value.to_json
|
|
96
|
+
end
|
|
97
|
+
"#{key_and_value[0].to_s}=#{CGI.escape value}"
|
|
98
|
+
end).join("&")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def self.faraday_options(options)
|
|
104
|
+
valid_options = [:request, :proxy, :ssl, :builder, :url, :parallel_manager, :params, :headers, :builder_class]
|
|
105
|
+
Hash[ options.select { |key,value| valid_options.include?(key) } ]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/koala/oauth.rb
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# OpenSSL and Base64 are required to support signed_request
|
|
2
|
+
require 'openssl'
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Koala
|
|
6
|
+
module Facebook
|
|
7
|
+
class OAuth
|
|
8
|
+
attr_reader :app_id, :app_secret, :oauth_callback_url
|
|
9
|
+
|
|
10
|
+
# Creates a new OAuth client.
|
|
11
|
+
#
|
|
12
|
+
# @param app_id [String, Integer] a Facebook application ID
|
|
13
|
+
# @param app_secret a Facebook application secret
|
|
14
|
+
# @param oauth_callback_url the URL in your app to which users authenticating with OAuth will be sent
|
|
15
|
+
def initialize(app_id = nil, app_secret = nil, oauth_callback_url = nil)
|
|
16
|
+
@app_id = app_id || Koala.config.app_id
|
|
17
|
+
@app_secret = app_secret || Koala.config.app_secret
|
|
18
|
+
@oauth_callback_url = oauth_callback_url || Koala.config.oauth_callback_url
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Parses the cookie set Facebook's JavaScript SDK.
|
|
22
|
+
#
|
|
23
|
+
# @note this method can only be called once per session, as the OAuth code
|
|
24
|
+
# Facebook supplies can only be redeemed once. Your application
|
|
25
|
+
# must handle cross-request storage of this information; you can no
|
|
26
|
+
# longer call this method multiple times. (This works out, as the
|
|
27
|
+
# method has to make a call to FB's servers anyway, which you don't
|
|
28
|
+
# want on every call.)
|
|
29
|
+
#
|
|
30
|
+
# @param cookie_hash a set of cookies that includes the Facebook cookie.
|
|
31
|
+
# You can pass Rack/Rails/Sinatra's cookie hash directly to this method.
|
|
32
|
+
#
|
|
33
|
+
# @return the authenticated user's information as a hash, or nil.
|
|
34
|
+
def get_user_info_from_cookies(cookie_hash)
|
|
35
|
+
if signed_cookie = cookie_hash["fbsr_#{@app_id}"]
|
|
36
|
+
parse_signed_cookie(signed_cookie)
|
|
37
|
+
elsif unsigned_cookie = cookie_hash["fbs_#{@app_id}"]
|
|
38
|
+
parse_unsigned_cookie(unsigned_cookie)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
alias_method :get_user_info_from_cookie, :get_user_info_from_cookies
|
|
42
|
+
|
|
43
|
+
# URLs
|
|
44
|
+
|
|
45
|
+
# Builds an OAuth URL, where users will be prompted to log in and for any desired permissions.
|
|
46
|
+
# When the users log in, you receive a callback with their
|
|
47
|
+
# See http://developers.facebook.com/docs/authentication/.
|
|
48
|
+
#
|
|
49
|
+
# @see #url_for_access_token
|
|
50
|
+
#
|
|
51
|
+
# @note The server-side authentication and dialog methods should only be used
|
|
52
|
+
# if your application can't use the Facebook Javascript SDK,
|
|
53
|
+
# which provides a much better user experience.
|
|
54
|
+
# See http://developers.facebook.com/docs/reference/javascript/.
|
|
55
|
+
#
|
|
56
|
+
# @param options any query values to add to the URL, as well as any special/required values listed below.
|
|
57
|
+
# @option options permissions an array or comma-separated string of desired permissions
|
|
58
|
+
# @option options state a unique string to serve as a CSRF (cross-site request
|
|
59
|
+
# forgery) token -- highly recommended for security. See
|
|
60
|
+
# https://developers.facebook.com/docs/howtos/login/server-side-login/
|
|
61
|
+
#
|
|
62
|
+
# @raise ArgumentError if no OAuth callback was specified in OAuth#new or in options as :redirect_uri
|
|
63
|
+
#
|
|
64
|
+
# @return an OAuth URL you can send your users to
|
|
65
|
+
def url_for_oauth_code(options = {})
|
|
66
|
+
# for permissions, see http://developers.facebook.com/docs/authentication/permissions
|
|
67
|
+
if permissions = options.delete(:permissions)
|
|
68
|
+
options[:scope] = permissions.is_a?(Array) ? permissions.join(",") : permissions
|
|
69
|
+
end
|
|
70
|
+
url_options = {:client_id => @app_id}.merge(options)
|
|
71
|
+
|
|
72
|
+
# Creates the URL for oauth authorization for a given callback and optional set of permissions
|
|
73
|
+
build_url(:dialog_host, "/dialog/oauth", true, url_options)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Once you receive an OAuth code, you need to redeem it from Facebook using an appropriate URL.
|
|
77
|
+
# (This is done by your server behind the scenes.)
|
|
78
|
+
# See http://developers.facebook.com/docs/authentication/.
|
|
79
|
+
#
|
|
80
|
+
# @see #url_for_oauth_code
|
|
81
|
+
#
|
|
82
|
+
# @note (see #url_for_oauth_code)
|
|
83
|
+
#
|
|
84
|
+
# @param code an OAuth code received from Facebook
|
|
85
|
+
# @param options any additional query parameters to add to the URL
|
|
86
|
+
#
|
|
87
|
+
# @raise (see #url_for_oauth_code)
|
|
88
|
+
#
|
|
89
|
+
# @return an URL your server can query for the user's access token
|
|
90
|
+
def url_for_access_token(code, options = {})
|
|
91
|
+
# Creates the URL for the token corresponding to a given code generated by Facebook
|
|
92
|
+
url_options = {
|
|
93
|
+
:client_id => @app_id,
|
|
94
|
+
:code => code,
|
|
95
|
+
:client_secret => @app_secret
|
|
96
|
+
}.merge(options)
|
|
97
|
+
build_url(:graph_server, "/oauth/access_token", true, url_options)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Builds a URL for a given dialog (feed, friends, OAuth, pay, send, etc.)
|
|
101
|
+
# See http://developers.facebook.com/docs/reference/dialogs/.
|
|
102
|
+
#
|
|
103
|
+
# @note (see #url_for_oauth_code)
|
|
104
|
+
#
|
|
105
|
+
# @param dialog_type the kind of Facebook dialog you want to show
|
|
106
|
+
# @param options any additional query parameters to add to the URL
|
|
107
|
+
#
|
|
108
|
+
# @return an URL your server can query for the user's access token
|
|
109
|
+
def url_for_dialog(dialog_type, options = {})
|
|
110
|
+
# some endpoints require app_id, some client_id, supply both doesn't seem to hurt
|
|
111
|
+
url_options = {:app_id => @app_id, :client_id => @app_id}.merge(options)
|
|
112
|
+
build_url(:dialog_host, "/dialog/#{dialog_type}", true, url_options)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Generates a 'client code' from a server side long-lived access token. With the generated
|
|
116
|
+
# code, it can be sent to a client application which can then use it to get a long-lived
|
|
117
|
+
# access token from Facebook. After which the clients can use that access token to make
|
|
118
|
+
# requests to Facebook without having to use the server token, yet the server access token
|
|
119
|
+
# remains valid.
|
|
120
|
+
# See https://developers.facebook.com/docs/facebook-login/access-tokens/#long-via-code
|
|
121
|
+
#
|
|
122
|
+
# @param access_token a user's long lived (server) access token
|
|
123
|
+
#
|
|
124
|
+
# @raise Koala::Facebook::ServerError if Facebook returns a server error (status >= 500)
|
|
125
|
+
# @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response (status >= 400)
|
|
126
|
+
# @raise Koala::Facebook::BadFacebookResponse if Facebook returns a blank response
|
|
127
|
+
# @raise Koala::KoalaError if response does not contain 'code' hash key
|
|
128
|
+
#
|
|
129
|
+
# @return a string of the generated 'code'
|
|
130
|
+
def generate_client_code(access_token)
|
|
131
|
+
response = fetch_token_string({:redirect_uri => @oauth_callback_url, :access_token => access_token}, false, 'client_code')
|
|
132
|
+
|
|
133
|
+
# Facebook returns an empty body in certain error conditions
|
|
134
|
+
if response == ''
|
|
135
|
+
raise BadFacebookResponse.new(200, '', 'generate_client_code received an error: empty response body')
|
|
136
|
+
else
|
|
137
|
+
result = JSON.parse(response)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
result.has_key?('code') ? result['code'] : raise(Koala::KoalaError.new("Facebook returned a valid response without the expected 'code' in the body (response = #{response})"))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# access tokens
|
|
144
|
+
|
|
145
|
+
# Fetches an access token, token expiration, and other info from Facebook.
|
|
146
|
+
# Useful when you've received an OAuth code using the server-side authentication process.
|
|
147
|
+
# @see url_for_oauth_code
|
|
148
|
+
#
|
|
149
|
+
# @note (see #url_for_oauth_code)
|
|
150
|
+
#
|
|
151
|
+
# @param code (see #url_for_access_token)
|
|
152
|
+
# @param options any additional parameters to send to Facebook when redeeming the token
|
|
153
|
+
#
|
|
154
|
+
# @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response
|
|
155
|
+
#
|
|
156
|
+
# @return a hash of the access token info returned by Facebook (token, expiration, etc.)
|
|
157
|
+
def get_access_token_info(code, options = {})
|
|
158
|
+
# convenience method to get a parsed token from Facebook for a given code
|
|
159
|
+
# should this require an OAuth callback URL?
|
|
160
|
+
get_token_from_server({:code => code, :redirect_uri => options[:redirect_uri] || @oauth_callback_url}, false, options)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Fetches the access token (ignoring expiration and other info) from Facebook.
|
|
164
|
+
# Useful when you've received an OAuth code using the server-side authentication process.
|
|
165
|
+
# @see get_access_token_info
|
|
166
|
+
#
|
|
167
|
+
# @note (see #url_for_oauth_code)
|
|
168
|
+
#
|
|
169
|
+
# @param (see #get_access_token_info)
|
|
170
|
+
#
|
|
171
|
+
# @raise (see #get_access_token_info)
|
|
172
|
+
#
|
|
173
|
+
# @return the access token
|
|
174
|
+
def get_access_token(code, options = {})
|
|
175
|
+
# upstream methods will throw errors if needed
|
|
176
|
+
if info = get_access_token_info(code, options)
|
|
177
|
+
string = info["access_token"]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Fetches the application's access token, along with any other information provided by Facebook.
|
|
182
|
+
# See http://developers.facebook.com/docs/authentication/ (search for App Login).
|
|
183
|
+
#
|
|
184
|
+
# @param options any additional parameters to send to Facebook when redeeming the token
|
|
185
|
+
#
|
|
186
|
+
# @return the application access token and other information (expiration, etc.)
|
|
187
|
+
def get_app_access_token_info(options = {})
|
|
188
|
+
# convenience method to get a the application's sessionless access token
|
|
189
|
+
get_token_from_server({:grant_type => 'client_credentials'}, true, options)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Fetches the application's access token (ignoring expiration and other info).
|
|
193
|
+
# @see get_app_access_token_info
|
|
194
|
+
#
|
|
195
|
+
# @param (see #get_app_access_token_info)
|
|
196
|
+
#
|
|
197
|
+
# @return the application access token
|
|
198
|
+
def get_app_access_token(options = {})
|
|
199
|
+
if info = get_app_access_token_info(options)
|
|
200
|
+
info["access_token"]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Fetches an access_token with extended expiration time, along with any other information provided by Facebook.
|
|
205
|
+
# See https://developers.facebook.com/docs/offline-access-deprecation/#extend_token (search for fb_exchange_token).
|
|
206
|
+
#
|
|
207
|
+
# @param access_token the access token to exchange
|
|
208
|
+
# @param options any additional parameters to send to Facebook when exchanging tokens.
|
|
209
|
+
#
|
|
210
|
+
# @return the access token with extended expiration time and other information (expiration, etc.)
|
|
211
|
+
def exchange_access_token_info(access_token, options = {})
|
|
212
|
+
get_token_from_server({
|
|
213
|
+
:grant_type => 'fb_exchange_token',
|
|
214
|
+
:fb_exchange_token => access_token
|
|
215
|
+
}, true, options)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Fetches an access token with extended expiration time (ignoring expiration and other info).
|
|
219
|
+
|
|
220
|
+
# @see exchange_access_token_info
|
|
221
|
+
#
|
|
222
|
+
# @param (see #exchange_access_token_info)
|
|
223
|
+
#
|
|
224
|
+
# @return A new access token or the existing one, set to expire in 60 days.
|
|
225
|
+
def exchange_access_token(access_token, options = {})
|
|
226
|
+
if info = exchange_access_token_info(access_token, options)
|
|
227
|
+
info["access_token"]
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Parses a signed request string provided by Facebook to canvas apps or in a secure cookie.
|
|
232
|
+
#
|
|
233
|
+
# @param input the signed request from Facebook
|
|
234
|
+
#
|
|
235
|
+
# @raise OAuthSignatureError if the signature is incomplete, invalid, or using an unsupported algorithm
|
|
236
|
+
#
|
|
237
|
+
# @return a hash of the validated request information
|
|
238
|
+
def parse_signed_request(input)
|
|
239
|
+
encoded_sig, encoded_envelope = input.split('.', 2)
|
|
240
|
+
raise OAuthSignatureError, 'Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
|
|
241
|
+
|
|
242
|
+
signature = base64_url_decode(encoded_sig).unpack("H*").first
|
|
243
|
+
envelope = JSON.parse(base64_url_decode(encoded_envelope))
|
|
244
|
+
|
|
245
|
+
raise OAuthSignatureError, "Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
|
|
246
|
+
|
|
247
|
+
# now see if the signature is valid (digest, key, data)
|
|
248
|
+
hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope)
|
|
249
|
+
raise OAuthSignatureError, 'Invalid signature' if (signature != hmac)
|
|
250
|
+
|
|
251
|
+
envelope
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
protected
|
|
255
|
+
|
|
256
|
+
def get_token_from_server(args, post = false, options = {})
|
|
257
|
+
# fetch the result from Facebook's servers
|
|
258
|
+
response = fetch_token_string(args, post, "access_token", options)
|
|
259
|
+
parse_access_token(response)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def parse_access_token(response_text)
|
|
263
|
+
JSON.parse(response_text)
|
|
264
|
+
rescue JSON::ParserError
|
|
265
|
+
response_text.split("&").inject({}) do |hash, bit|
|
|
266
|
+
key, value = bit.split("=")
|
|
267
|
+
hash.merge!(key => value)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def parse_unsigned_cookie(fb_cookie)
|
|
272
|
+
# remove the opening/closing quote
|
|
273
|
+
fb_cookie = fb_cookie.gsub(/\"/, "")
|
|
274
|
+
|
|
275
|
+
# since we no longer get individual cookies, we have to separate out the components ourselves
|
|
276
|
+
components = {}
|
|
277
|
+
fb_cookie.split("&").map {|param| param = param.split("="); components[param[0]] = param[1]}
|
|
278
|
+
|
|
279
|
+
# generate the signature and make sure it matches what we expect
|
|
280
|
+
auth_string = components.keys.sort.collect {|a| a == "sig" ? nil : "#{a}=#{components[a]}"}.reject {|a| a.nil?}.join("")
|
|
281
|
+
sig = Digest::MD5.hexdigest(auth_string + @app_secret)
|
|
282
|
+
sig == components["sig"] && (components["expires"] == "0" || Time.now.to_i < components["expires"].to_i) ? components : nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def parse_signed_cookie(fb_cookie)
|
|
286
|
+
components = parse_signed_request(fb_cookie)
|
|
287
|
+
if code = components["code"]
|
|
288
|
+
begin
|
|
289
|
+
token_info = get_access_token_info(code, :redirect_uri => '')
|
|
290
|
+
rescue Koala::Facebook::OAuthTokenRequestError => err
|
|
291
|
+
if err.fb_error_type == 'OAuthException' && err.fb_error_message =~ /Code was invalid or expired/
|
|
292
|
+
return nil
|
|
293
|
+
else
|
|
294
|
+
raise
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
components.merge(token_info) if token_info
|
|
299
|
+
else
|
|
300
|
+
Koala::Utils.logger.warn("Signed cookie didn't contain Facebook OAuth code! Components: #{components}")
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
|
|
306
|
+
response = Koala.make_request("/oauth/#{endpoint}", {
|
|
307
|
+
:client_id => @app_id,
|
|
308
|
+
:client_secret => @app_secret
|
|
309
|
+
}.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options))
|
|
310
|
+
|
|
311
|
+
raise ServerError.new(response.status, response.body) if response.status >= 500
|
|
312
|
+
raise OAuthTokenRequestError.new(response.status, response.body) if response.status >= 400
|
|
313
|
+
|
|
314
|
+
response.body
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# base 64
|
|
318
|
+
# directly from https://github.com/facebook/crypto-request-examples/raw/master/sample.rb
|
|
319
|
+
def base64_url_decode(str)
|
|
320
|
+
str += '=' * (4 - str.length.modulo(4))
|
|
321
|
+
Base64.decode64(str.tr('-_', '+/'))
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def server_url(type)
|
|
325
|
+
url = "https://#{Koala.config.send(type)}"
|
|
326
|
+
if version = Koala.config.api_version
|
|
327
|
+
"#{url}/#{version}"
|
|
328
|
+
else
|
|
329
|
+
url
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def build_url(type, path, require_redirect_uri = false, url_options = {})
|
|
334
|
+
if require_redirect_uri && !(url_options[:redirect_uri] ||= url_options.delete(:callback) || @oauth_callback_url)
|
|
335
|
+
raise ArgumentError, "build_url must get a callback either from the OAuth object or in the parameters!"
|
|
336
|
+
end
|
|
337
|
+
params = Koala::HTTPService.encode_params(url_options)
|
|
338
|
+
"#{server_url(type)}#{path}?#{params}"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|