omnigroupcontacts 0.3.10 → 0.3.11

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile +3 -0
  4. data/Gemfile.lock +39 -0
  5. data/README.md +132 -0
  6. data/Rakefile +7 -0
  7. data/lib/omnigroupcontacts.rb +19 -0
  8. data/lib/omnigroupcontacts/authorization/oauth1.rb +122 -0
  9. data/lib/omnigroupcontacts/authorization/oauth2.rb +87 -0
  10. data/lib/omnigroupcontacts/builder.rb +30 -0
  11. data/lib/omnigroupcontacts/http_utils.rb +101 -0
  12. data/lib/omnigroupcontacts/importer.rb +5 -0
  13. data/lib/omnigroupcontacts/importer/gmailgroup.rb +238 -0
  14. data/lib/omnigroupcontacts/integration_test.rb +36 -0
  15. data/lib/omnigroupcontacts/middleware/base_oauth.rb +120 -0
  16. data/lib/omnigroupcontacts/middleware/oauth1.rb +70 -0
  17. data/lib/omnigroupcontacts/middleware/oauth2.rb +80 -0
  18. data/lib/omnigroupcontacts/parse_utils.rb +56 -0
  19. data/omnigroupcontacts-0.3.10.gem +0 -0
  20. data/omnigroupcontacts-0.3.8.gem +0 -0
  21. data/omnigroupcontacts-0.3.9.gem +0 -0
  22. data/omnigroupcontacts.gemspec +25 -0
  23. data/spec/omnicontacts/authorization/oauth1_spec.rb +82 -0
  24. data/spec/omnicontacts/authorization/oauth2_spec.rb +92 -0
  25. data/spec/omnicontacts/http_utils_spec.rb +79 -0
  26. data/spec/omnicontacts/importer/facebook_spec.rb +120 -0
  27. data/spec/omnicontacts/importer/gmail_spec.rb +194 -0
  28. data/spec/omnicontacts/importer/hotmail_spec.rb +106 -0
  29. data/spec/omnicontacts/importer/linkedin_spec.rb +67 -0
  30. data/spec/omnicontacts/importer/yahoo_spec.rb +124 -0
  31. data/spec/omnicontacts/integration_test_spec.rb +51 -0
  32. data/spec/omnicontacts/middleware/base_oauth_spec.rb +53 -0
  33. data/spec/omnicontacts/middleware/oauth1_spec.rb +78 -0
  34. data/spec/omnicontacts/middleware/oauth2_spec.rb +67 -0
  35. data/spec/omnicontacts/parse_utils_spec.rb +53 -0
  36. data/spec/spec_helper.rb +12 -0
  37. metadata +37 -2
@@ -0,0 +1,101 @@
1
+ require "net/http"
2
+ require "net/https"
3
+ require "cgi"
4
+ require "openssl"
5
+
6
+ # This module contains a set of utility methods related to the HTTP protocol.
7
+ module OmniGroupContacts
8
+ module HTTPUtils
9
+
10
+ SSL_PORT = 443
11
+
12
+ module_function
13
+
14
+ def query_string_to_map query_string
15
+ query_string.split('&').reduce({}) do |memo, key_value|
16
+ (key, value) = key_value.split('=')
17
+ memo[key]= value
18
+ memo
19
+ end
20
+ end
21
+
22
+ def to_query_string map
23
+ map.collect do |key, value|
24
+ key.to_s + "=" + value.to_s
25
+ end.join("&")
26
+ end
27
+
28
+ # Encodes the given input according to RFC 3986
29
+ def encode to_encode
30
+ CGI.escape(to_encode)
31
+ end
32
+
33
+ # Calculates the url of the host from a Rack environment.
34
+ # The result is in the form scheme://host:port
35
+ # If port is 80 the result is scheme://host
36
+ # According to Rack specification the HTTP_HOST variable is preferred over SERVER_NAME.
37
+ def host_url_from_rack_env env
38
+ port = ((env["SERVER_PORT"] == 80) && "") || ":#{env['SERVER_PORT']}"
39
+ host = (env["HTTP_HOST"]) || (env["SERVER_NAME"] + port)
40
+ "#{scheme(env)}://#{host}"
41
+ end
42
+
43
+ def scheme env
44
+ if env['HTTPS'] == 'on'
45
+ 'https'
46
+ elsif env['HTTP_X_FORWARDED_SSL'] == 'on'
47
+ 'https'
48
+ elsif env['HTTP_X_FORWARDED_PROTO']
49
+ env['HTTP_X_FORWARDED_PROTO'].split(',').first
50
+ else
51
+ env["rack.url_scheme"]
52
+ end
53
+ end
54
+
55
+ # Classes including the module must respond to the ssl_ca_file message in order to use the following methods.
56
+ # The response will be the path to the CA file to use when making https requests.
57
+ # If the result of ssl_ca_file is nil no file is used. In this case a warn message is logged.
58
+ private
59
+
60
+ # Executes an HTTP GET request.
61
+ # It raises a RuntimeError if the response code is not equal to 200
62
+ def http_get host, path, params
63
+ connection = Net::HTTP.new(host)
64
+ process_http_response connection.request_get(path + "?" + to_query_string(params))
65
+ end
66
+
67
+ # Executes an HTTP POST request over SSL
68
+ # It raises a RuntimeError if the response code is not equal to 200
69
+ def https_post host, path, params
70
+ https_connection host do |connection|
71
+ connection.request_post(path, to_query_string(params))
72
+ end
73
+ end
74
+
75
+ # Executes an HTTP GET request over SSL
76
+ # It raises a RuntimeError if the response code is not equal to 200
77
+ def https_get host, path, params, headers = nil
78
+ https_connection host do |connection|
79
+ connection.request_get(path + "?" + to_query_string(params), headers)
80
+ end
81
+ end
82
+
83
+ def https_connection (host)
84
+ connection = Net::HTTP.new(host, SSL_PORT)
85
+ connection.use_ssl = true
86
+ if ssl_ca_file
87
+ connection.ca_file = ssl_ca_file
88
+ else
89
+ logger << "No SSL ca file provided. It is highly reccomended to use one in production envinronments" if respond_to?(:logger) && logger
90
+ connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
91
+ end
92
+ process_http_response(yield(connection))
93
+ end
94
+
95
+ def process_http_response response
96
+ raise response.body if response.code != "200"
97
+ response.body
98
+ end
99
+
100
+ end
101
+ end
@@ -0,0 +1,5 @@
1
+ module OmniGroupContacts
2
+ module Importer
3
+ autoload :Gmailgroup, "omnigroupcontacts/importer/gmailgroup"
4
+ end
5
+ end
@@ -0,0 +1,238 @@
1
+ require "omnigroupcontacts/parse_utils"
2
+ require "omnigroupcontacts/middleware/oauth2"
3
+
4
+ module OmniGroupContacts
5
+ module Importer
6
+ class Gmailgroup < Middleware::OAuth2
7
+ include ParseUtils
8
+
9
+ attr_reader :auth_host, :authorize_path, :auth_token_path, :scope
10
+
11
+ def initialize *args
12
+ super *args
13
+ @auth_host = "accounts.google.com"
14
+ @authorize_path = "/o/oauth2/auth"
15
+ @auth_token_path = "/o/oauth2/token"
16
+ @scope = (args[3] && args[3][:scope]) || "https://www.google.com/m8/feeds https://www.googleapis.com/auth/userinfo#email https://www.googleapis.com/auth/userinfo.profile"
17
+ @contacts_host = "www.google.com"
18
+ @contacts_path = "/m8/feeds/groups/default/full"
19
+ @max_results = (args[3] && args[3][:max_results]) || 100
20
+ @self_host = "www.googleapis.com"
21
+ @profile_path = "/oauth2/v1/userinfo"
22
+ end
23
+
24
+ def fetch_groups_using_access_token access_token, token_type
25
+ fetch_current_user(access_token, token_type)
26
+ groups_response = https_get(@contacts_host, @contacts_path, contacts_req_params, contacts_req_headers(access_token, token_type))
27
+ groups_from_response groups_response
28
+ end
29
+
30
+ def fetch_contacts_using_access_token access_token, token_type, group_id
31
+ fetch_current_user(access_token, token_type)
32
+ @contacts_path = "/m8/feeds/contacts/default/full?&group=#{group_id}"
33
+ contacts_response = https_get(@contacts_host, @contacts_path, contacts_req_params, contacts_req_headers(access_token, token_type))
34
+ contacts_from_response contacts_response
35
+ end
36
+
37
+ def fetch_current_user access_token, token_type
38
+ self_response = https_get(@self_host, @profile_path, contacts_req_params, contacts_req_headers(access_token, token_type))
39
+ user = current_user(self_response, access_token, token_type)
40
+ set_current_user user
41
+ end
42
+
43
+ private
44
+
45
+ def contacts_req_params
46
+ {'max-results' => @max_results.to_s, 'alt' => 'json'}
47
+ end
48
+
49
+ def contacts_req_headers token, token_type
50
+ {"GData-Version" => "3.0", "Authorization" => "#{token_type} #{token}"}
51
+ end
52
+
53
+ def groups_from_response response_as_json
54
+ response = JSON.parse(response_as_json)
55
+
56
+ return [] if response['feed'].nil? || response['feed']['entry'].nil?
57
+ groups = []
58
+ return groups if response.nil?
59
+ response['feed']['entry'].each do |entry|
60
+ group = {
61
+ :id => nil,
62
+ :title => nil
63
+ }
64
+ group[:id] = entry['id']["$t"]
65
+ group[:title] = entry['title']["$t"]
66
+
67
+ groups << group
68
+ end
69
+ groups
70
+
71
+ end
72
+
73
+ def contacts_from_response response_as_json
74
+ response = JSON.parse(response_as_json)
75
+ return [] if response['feed'].nil? || response['feed']['entry'].nil?
76
+ contacts = []
77
+ return contacts if response.nil?
78
+ response['feed']['entry'].each do |entry|
79
+ # creating nil fields to keep the fields consistent across other networks
80
+
81
+ contact = { :id => nil,
82
+ :first_name => nil,
83
+ :last_name => nil,
84
+ :name => nil,
85
+ :emails => nil,
86
+ :gender => nil,
87
+ :birthday => nil,
88
+ :profile_picture=> nil,
89
+ :relation => nil,
90
+ :addresses => nil,
91
+ :phone_numbers => nil,
92
+ :dates => nil,
93
+ :company => nil,
94
+ :position => nil,
95
+ :groups => nil
96
+ }
97
+ contact[:id] = entry['id']['$t'] if entry['id']
98
+ if entry['gd$name']
99
+ gd_name = entry['gd$name']
100
+ contact[:first_name] = normalize_name(entry['gd$name']['gd$givenName']['$t']) if gd_name['gd$givenName']
101
+ contact[:last_name] = normalize_name(entry['gd$name']['gd$familyName']['$t']) if gd_name['gd$familyName']
102
+ contact[:name] = normalize_name(entry['gd$name']['gd$fullName']['$t']) if gd_name['gd$fullName']
103
+ contact[:name] = full_name(contact[:first_name],contact[:last_name]) if contact[:name].nil?
104
+ end
105
+
106
+ contact[:emails] = []
107
+ entry['gd$email'].each do |email|
108
+ if email['rel']
109
+ split_index = email['rel'].index('#')
110
+ contact[:emails] << {:name => email['rel'][split_index + 1, email['rel'].length - 1], :email => email['address']}
111
+ elsif email['label']
112
+ contact[:emails] << {:name => email['label'], :email => email['address']}
113
+ end
114
+ end if entry['gd$email']
115
+
116
+ # Support older versions of the gem by keeping singular entries around
117
+ contact[:email] = contact[:emails][0][:email] if contact[:emails][0]
118
+ contact[:first_name], contact[:last_name], contact[:name] = email_to_name(contact[:name]) if !contact[:name].nil? && contact[:name].include?('@')
119
+ contact[:first_name], contact[:last_name], contact[:name] = email_to_name(contact[:emails][0][:email]) if (contact[:name].nil? && contact[:emails][0] && contact[:emails][0][:email])
120
+ #format - year-month-date
121
+ contact[:birthday] = birthday(entry['gContact$birthday']['when']) if entry['gContact$birthday']
122
+
123
+ # value is either "male" or "female"
124
+ contact[:gender] = entry['gContact$gender']['value'] if entry['gContact$gender']
125
+
126
+ if entry['gContact$relation']
127
+ if entry['gContact$relation'].is_a?(Hash)
128
+ contact[:relation] = entry['gContact$relation']['rel']
129
+ elsif entry['gContact$relation'].is_a?(Array)
130
+ contact[:relation] = entry['gContact$relation'].first['rel']
131
+ end
132
+ end
133
+
134
+ contact[:addresses] = []
135
+ entry['gd$structuredPostalAddress'].each do |address|
136
+ if address['rel']
137
+ split_index = address['rel'].index('#')
138
+ new_address = {:name => address['rel'][split_index + 1, address['rel'].length - 1]}
139
+ elsif address['label']
140
+ new_address = {:name => address['label']}
141
+ end
142
+
143
+ new_address[:address_1] = address['gd$street']['$t'] if address['gd$street']
144
+ new_address[:address_1] = address['gd$formattedAddress']['$t'] if new_address[:address_1].nil? && address['gd$formattedAddress']
145
+ if new_address[:address_1].index("\n")
146
+ parts = new_address[:address_1].split("\n")
147
+ new_address[:address_1] = parts.first
148
+ # this may contain city/state/zip if user jammed it all into one string.... :-(
149
+ new_address[:address_2] = parts[1..-1].join(', ')
150
+ end
151
+ new_address[:city] = address['gd$city']['$t'] if address['gd$city']
152
+ new_address[:region] = address['gd$region']['$t'] if address['gd$region'] # like state or province
153
+ new_address[:country] = address['gd$country']['code'] if address['gd$country']
154
+ new_address[:postcode] = address['gd$postcode']['$t'] if address['gd$postcode']
155
+ contact[:addresses] << new_address
156
+ end if entry['gd$structuredPostalAddress']
157
+
158
+ # Support older versions of the gem by keeping singular entries around
159
+ if contact[:addresses][0]
160
+ contact[:address_1] = contact[:addresses][0][:address_1]
161
+ contact[:address_2] = contact[:addresses][0][:address_2]
162
+ contact[:city] = contact[:addresses][0][:city]
163
+ contact[:region] = contact[:addresses][0][:region]
164
+ contact[:country] = contact[:addresses][0][:country]
165
+ contact[:postcode] = contact[:addresses][0][:postcode]
166
+ end
167
+
168
+ contact[:phone_numbers] = []
169
+ entry['gd$phoneNumber'].each do |phone_number|
170
+ if phone_number['rel']
171
+ split_index = phone_number['rel'].index('#')
172
+ contact[:phone_numbers] << {:name => phone_number['rel'][split_index + 1, phone_number['rel'].length - 1], :number => phone_number['$t']}
173
+ elsif phone_number['label']
174
+ contact[:phone_numbers] << {:name => phone_number['label'], :number => phone_number['$t']}
175
+ end
176
+ end if entry['gd$phoneNumber']
177
+
178
+ # Support older versions of the gem by keeping singular entries around
179
+ contact[:phone_number] = contact[:phone_numbers][0][:number] if contact[:phone_numbers][0]
180
+
181
+ if entry['gContact$website'] && entry['gContact$website'][0]["rel"] == "profile"
182
+ contact[:id] = contact_id(entry['gContact$website'][0]["href"])
183
+ contact[:profile_picture] = image_url(contact[:id])
184
+ else
185
+ contact[:profile_picture] = image_url_from_email(contact[:email])
186
+ end
187
+
188
+ if entry['gContact$event']
189
+ contact[:dates] = []
190
+ entry['gContact$event'].each do |event|
191
+ if event['rel']
192
+ contact[:dates] << {:name => event['rel'], :date => birthday(event['gd$when']['startTime'])}
193
+ elsif event['label']
194
+ contact[:dates] << {:name => event['label'], :date => birthday(event['gd$when']['startTime'])}
195
+ end
196
+ end
197
+ end
198
+
199
+ if entry['gd$organization']
200
+ contact[:company] = entry['gd$organization'][0]['gd$orgName']['$t'] if entry['gd$organization'][0]['gd$orgName']
201
+ contact[:position] = entry['gd$organization'][0]['gd$orgTitle']['$t'] if entry['gd$organization'][0]['gd$orgTitle']
202
+ end
203
+
204
+ contacts << contact if contact[:name]
205
+ end
206
+ contacts.uniq! {|c| c[:email] || c[:profile_picture] || c[:name]}
207
+ contacts
208
+ end
209
+
210
+ def image_url gmail_id
211
+ return "https://profiles.google.com/s2/photos/profile/" + gmail_id if gmail_id
212
+ end
213
+
214
+ def current_user me, access_token, token_type
215
+ return nil if me.nil?
216
+ me = JSON.parse(me)
217
+ user = {:id => me['id'], :email => me['email'], :name => me['name'], :first_name => me['given_name'],
218
+ :last_name => me['family_name'], :gender => me['gender'], :birthday => birthday(me['birthday']), :profile_picture => image_url(me['id']),
219
+ :access_token => access_token, :token_type => token_type
220
+ }
221
+ user
222
+ end
223
+
224
+ def birthday dob
225
+ return nil if dob.nil?
226
+ birthday = dob.split('-')
227
+ return birthday_format(birthday[2], birthday[3], nil) if birthday.size == 4
228
+ return birthday_format(birthday[1], birthday[2], birthday[0]) if birthday.size == 3
229
+ end
230
+
231
+ def contact_id(profile_url)
232
+ id = (profile_url.present?) ? File.basename(profile_url) : nil
233
+ id
234
+ end
235
+
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,36 @@
1
+ require 'singleton'
2
+
3
+ class IntegrationTest
4
+ include Singleton
5
+
6
+ attr_accessor :enabled
7
+
8
+ def initialize
9
+ enabled = false
10
+ clear_mocks
11
+ end
12
+
13
+ def clear_mocks
14
+ @mock = {}
15
+ end
16
+
17
+ def mock provider, mock
18
+ @mock[provider.to_sym] = mock
19
+ end
20
+
21
+ def mock_authorization_from_user provider
22
+ [302, {"Content-Type" => "application/x-www-form-urlencoded", "location" => provider.redirect_path}, []]
23
+ end
24
+
25
+ def mock_fetch_contacts provider
26
+ result = @mock[provider.class_name.to_sym] || []
27
+ if result.is_a? Array
28
+ result
29
+ elsif result.is_a? Hash
30
+ [result]
31
+ else
32
+ raise result.to_s
33
+ end
34
+ end
35
+
36
+ end
@@ -0,0 +1,120 @@
1
+ # This class contains the common behavior for middlewares
2
+ # implementing either versions of OAuth.
3
+ #
4
+ # Extending classes are required to implement
5
+ # the following methods:
6
+ # * request_authorization_from_user
7
+ # * fetch_contatcs
8
+ module OmniGroupContacts
9
+ module Middleware
10
+ class BaseOAuth
11
+
12
+ attr_reader :ssl_ca_file
13
+
14
+ def initialize app, options
15
+ @app = app
16
+ @listening_path = MOUNT_PATH + class_name
17
+ @ssl_ca_file = options[:ssl_ca_file]
18
+ end
19
+
20
+ def class_name
21
+ self.class.name.split('::').last.downcase
22
+ end
23
+
24
+ # Rack callback. It handles three cases:
25
+ # * user visit middleware entry point.
26
+ # In this case request_authorization_from_user is called
27
+ # * user is redirected back to the application
28
+ # from the authorization site. In this case the list
29
+ # of contacts is fetched and stored in the variables
30
+ # omnigroupcontacts.contacts within the Rack env variable.
31
+ # Once that is done the next middleware component is called.
32
+ # * user visits any other resource. In this case the request
33
+ # is simply forwarded to the next middleware component.
34
+ def call env
35
+ @env = env
36
+ if env["PATH_INFO"] =~ /^#{@listening_path}\/?$/
37
+ handle_initial_request
38
+ elsif env["PATH_INFO"] =~ /^#{redirect_path}/
39
+ handle_callback
40
+ else
41
+ @app.call(env)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def test_mode?
48
+ IntegrationTest.instance.enabled
49
+ end
50
+
51
+ def handle_initial_request
52
+ execute_and_rescue_exceptions do
53
+ if test_mode?
54
+ IntegrationTest.instance.mock_authorization_from_user(self)
55
+ else
56
+ request_authorization_from_user
57
+ end
58
+ end
59
+ end
60
+
61
+ def handle_callback
62
+ execute_and_rescue_exceptions do
63
+ @env["omnicontacts.contacts"] = if test_mode?
64
+ IntegrationTest.instance.mock_fetch_contacts(self)
65
+ else
66
+ fetch_groups
67
+ end
68
+ @app.call(@env)
69
+ end
70
+ end
71
+
72
+ def set_current_user user
73
+ @env["omnigroupcontacts.user"] = user
74
+ end
75
+
76
+ # This method rescues executes a block of code and
77
+ # rescue all exceptions. In case of an exception the
78
+ # user is redirected to the failure endpoint.
79
+ def execute_and_rescue_exceptions
80
+ yield
81
+ rescue AuthorizationError => e
82
+ handle_error :not_authorized, e
83
+ rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e
84
+ handle_error :timeout, e
85
+ rescue ::RuntimeError => e
86
+ handle_error :internal_error, e
87
+ end
88
+
89
+ def handle_error error_type, exception
90
+ logger.puts("Error #{error_type} while processing #{@env["PATH_INFO"]}: #{exception.message}") if logger
91
+ failure_url = "#{ MOUNT_PATH }failure?error_message=#{error_type}&importer=#{class_name}"
92
+ target_url = append_state_query(failure_url)
93
+ [302, {"Content-Type" => "text/html", "location" => target_url}, []]
94
+ end
95
+
96
+ def session
97
+ raise "You must provide a session to use omnigroupcontacts" unless @env["rack.session"]
98
+ @env["rack.session"]
99
+ end
100
+
101
+ def logger
102
+ @env["rack.errors"] if @env
103
+ end
104
+
105
+ def base_prop_name
106
+ "omnigroupcontacts." + class_name
107
+ end
108
+
109
+ def append_state_query(target_url)
110
+ state = Rack::Utils.parse_query(@env['QUERY_STRING'])['state']
111
+
112
+ unless state.nil?
113
+ target_url = target_url + (target_url.include?("?")?"&":"?") + 'state=' + state
114
+ end
115
+
116
+ return target_url
117
+ end
118
+ end
119
+ end
120
+ end