erc-contacts 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,123 @@
1
+ module Contacts
2
+ class Consumer
3
+ #
4
+ # Configure this consumer from the given hash.
5
+ #
6
+ def self.configure(configuration)
7
+ @configuration = Util.symbolize_keys(configuration)
8
+ end
9
+
10
+ #
11
+ # The configuration for this consumer.
12
+ #
13
+ def self.configuration
14
+ @configuration
15
+ end
16
+
17
+ #
18
+ # Define an instance-level reader for the named configuration
19
+ # attribute.
20
+ #
21
+ # Example:
22
+ #
23
+ # class MyConsumer < Consumer
24
+ # configuration_attribute :app_id
25
+ # end
26
+ #
27
+ # MyConsumer.configure(:app_id => 'foo')
28
+ # consumer = MyConsumer.new
29
+ # consumer.app_id # "foo"
30
+ #
31
+ def self.configuration_attribute(name)
32
+ class_eval <<-EOS
33
+ def #{name}
34
+ self.class.configuration[:#{name}]
35
+ end
36
+ EOS
37
+ end
38
+
39
+ def initialize(options={})
40
+ end
41
+
42
+ #
43
+ # Return a string of serialized data.
44
+ #
45
+ # You may reconstruct the consumer by passing this string to
46
+ # .deserialize.
47
+ #
48
+ def serialize
49
+ params_to_query(serializable_data)
50
+ end
51
+
52
+ #
53
+ # Create a consumer from the given +string+ of serialized data.
54
+ #
55
+ # The serialized data should have been returned by #serialize.
56
+ #
57
+ def self.deserialize(string)
58
+ data = string ? query_to_params(string) : {}
59
+ consumer = new
60
+ consumer.initialize_serialized(data) if data
61
+ consumer
62
+ end
63
+
64
+ #
65
+ # Authorize the consumer's token from the given
66
+ # parameters. +params+ is the request parameters the user is
67
+ # redirected to your site with.
68
+ #
69
+ # Return true if authorization is successful, false otherwise. If
70
+ # unsuccessful, an error message is set in #error. Authorization
71
+ # may fail, for example, if the user denied access, or the
72
+ # authorization is forged.
73
+ #
74
+ def authorize(params)
75
+ raise NotImplementedError, 'abstract'
76
+ end
77
+
78
+ #
79
+ # An error message for the last call to #authorize.
80
+ #
81
+ attr_accessor :error
82
+
83
+ #
84
+ # Return the list of contacts, or nil if none could be retrieved.
85
+ #
86
+ def contacts
87
+ raise NotImplementedError, 'abstract'
88
+ end
89
+
90
+ protected
91
+
92
+ def initialize_serialized(data)
93
+ raise NotImplementedError, 'abstract'
94
+ end
95
+
96
+ def serialized_data
97
+ raise NotImplementedError, 'abstract'
98
+ end
99
+
100
+ def self.params_to_query(params)
101
+ params.map do |key, value|
102
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
103
+ end.join('&')
104
+ end
105
+
106
+ def self.query_to_params(data)
107
+ params={}
108
+ data.split(/&/).each do |pair|
109
+ key, value = *pair.split(/=/)
110
+ params[CGI.unescape(key)] = value ? CGI.unescape(value) : ''
111
+ end
112
+ params
113
+ end
114
+
115
+ def params_to_query(params)
116
+ self.class.params_to_query(params)
117
+ end
118
+
119
+ def query_to_params(data)
120
+ self.class.query_to_params(data)
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,85 @@
1
+ require 'contacts'
2
+ require 'nokogiri'
3
+
4
+ module Contacts
5
+ class Google < OAuthConsumer
6
+ CONSUMER_OPTIONS = Util.frozen_hash(
7
+ :site => "https://www.google.com",
8
+ :request_token_path => "/accounts/OAuthGetRequestToken",
9
+ :access_token_path => "/accounts/OAuthGetAccessToken",
10
+ :authorize_path => "/accounts/OAuthAuthorizeToken"
11
+ )
12
+
13
+ REQUEST_TOKEN_PARAMS = {'scope' => "https://www.google.com/m8/feeds/"}
14
+
15
+ def initialize(options={})
16
+ super(CONSUMER_OPTIONS, REQUEST_TOKEN_PARAMS)
17
+ end
18
+
19
+ # retrieve the contacts for the user's account
20
+ #
21
+ # The options it takes are:
22
+ #
23
+ # - limit - the max number of results to return
24
+ # ("max-results"). Defaults to 200
25
+ # - offset - the index to start returning results for pagination ("start-index")
26
+ # - projection - defaults to thin
27
+ # http://code.google.com/apis/contacts/docs/3.0/reference.html#Projections
28
+ def contacts(options={})
29
+ return nil if @access_token.nil?
30
+ params = {:limit => 200}.update(options)
31
+ google_params = translate_parameters(params)
32
+ query = params_to_query(google_params)
33
+ projection = options[:projection] || "thin"
34
+ begin
35
+ response = @access_token.get("https://www.google.com/m8/feeds/contacts/default/#{projection}?#{query}")
36
+ rescue OAuth::Unauthorized => error
37
+ # Token probably expired.
38
+ @error = error.message
39
+ return nil
40
+ end
41
+ parse_contacts(response.body)
42
+ end
43
+
44
+ private
45
+
46
+ def translate_parameters(params)
47
+ params.inject({}) do |all, pair|
48
+ key, value = pair
49
+ unless value.nil?
50
+ key = case key
51
+ when :limit
52
+ 'max-results'
53
+ when :offset
54
+ value = value.to_i + 1
55
+ 'start-index'
56
+ when :order
57
+ all['sortorder'] = 'descending' if params[:descending].nil?
58
+ 'orderby'
59
+ when :descending
60
+ value = value ? 'descending' : 'ascending'
61
+ 'sortorder'
62
+ when :updated_after
63
+ value = value.strftime("%Y-%m-%dT%H:%M:%S%Z") if value.respond_to? :strftime
64
+ 'updated-min'
65
+ else key
66
+ end
67
+
68
+ all[key] = value
69
+ end
70
+ all
71
+ end
72
+ end
73
+
74
+ def parse_contacts(body)
75
+ document = Nokogiri::XML(body)
76
+ document.search('/xmlns:feed/xmlns:entry').map do |entry|
77
+ emails = entry.search('./gd:email[@address]').map{|e| e['address'].to_s}
78
+ next if emails.empty?
79
+ title = entry.at('title') and
80
+ name = title.inner_text
81
+ Contact.new(emails, name)
82
+ end.compact
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,71 @@
1
+ require 'oauth'
2
+
3
+ module Contacts
4
+ class OAuthConsumer < Consumer
5
+ configuration_attribute :consumer_key
6
+ configuration_attribute :consumer_secret
7
+ configuration_attribute :return_url
8
+
9
+ def initialize(consumer_options, request_token_params)
10
+ @consumer_options = consumer_options
11
+ @request_token_params = request_token_params
12
+ end
13
+
14
+ def initialize_serialized(data)
15
+ value = data['request_token'] and
16
+ @request_token = deserialize_oauth_token(consumer, value)
17
+ value = data['access_token'] and
18
+ @access_token = deserialize_oauth_token(consumer, value)
19
+ end
20
+
21
+ def serializable_data
22
+ data = {}
23
+ data['access_token'] = serialize_oauth_token(@access_token) if @access_token
24
+ data['request_token'] = serialize_oauth_token(@request_token) if @request_token
25
+ data
26
+ end
27
+
28
+ attr_accessor :request_token
29
+ attr_accessor :access_token
30
+
31
+ def authentication_url(target = self.return_url)
32
+ @request_token = consumer.get_request_token({:oauth_callback => target}, @request_token_params)
33
+ @request_token.authorize_url
34
+ end
35
+
36
+ def authorize(params)
37
+ begin
38
+ @access_token = @request_token.get_access_token(:oauth_verifier => params['oauth_verifier'])
39
+ rescue OAuth::Unauthorized => error
40
+ @error = error.message
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def consumer
47
+ @consumer ||= OAuth::Consumer.new(consumer_key, consumer_secret, @consumer_options)
48
+ end
49
+
50
+ #
51
+ # Marshal sucks for persistence. This provides a prettier, more
52
+ # future-proof persistable representation of a token.
53
+ #
54
+ def serialize_oauth_token(token)
55
+ params = {
56
+ 'version' => '1', # serialization format
57
+ 'type' => token.is_a?(OAuth::AccessToken) ? 'access' : 'request',
58
+ 'oauth_token' => token.token,
59
+ 'oauth_token_secret' => token.secret,
60
+ }
61
+ params_to_query(params)
62
+ end
63
+
64
+ def deserialize_oauth_token(consumer, data)
65
+ params = query_to_params(data)
66
+ klass = params['type'] == 'access' ? OAuth::AccessToken : OAuth::RequestToken
67
+ token = klass.new(consumer, params.delete('oauth_token'), params.delete('oauth_token_secret'))
68
+ token
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,14 @@
1
+ require 'contacts'
2
+ require 'rails'
3
+
4
+ module Contacts
5
+ class Railtie < Rails::Railtie
6
+ initializer "setup configuration" do
7
+ config_file = Rails.root.join("config", "contacts.yml")
8
+ if config_file.file?
9
+ configuration = YAML.load(ERB.new(config_file.read).result)[Rails.env]
10
+ Contacts.configure(configuration) if configuration.present?
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,24 @@
1
+ module Contacts
2
+ module Util
3
+ #
4
+ # Freeze the given hash, and any hash values recursively.
5
+ #
6
+ def self.frozen_hash(hash={})
7
+ hash.freeze
8
+ hash.keys.each{|k| k.freeze}
9
+ hash.values.each{|v| v.freeze}
10
+ hash
11
+ end
12
+
13
+ #
14
+ # Return a copy of +hash+ with the keys turned into Symbols.
15
+ #
16
+ def self.symbolize_keys(hash)
17
+ result = {}
18
+ hash.each do |key, value|
19
+ result[key.to_sym] = value
20
+ end
21
+ result
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,10 @@
1
+ module Contacts
2
+ module VERSION #:nodoc:
3
+ MAJOR = 0
4
+ MINOR = 2
5
+ TINY = 5
6
+ PATCH = 5
7
+
8
+ STRING = [MAJOR, MINOR, TINY, PATCH].join('.')
9
+ end
10
+ end
@@ -0,0 +1,192 @@
1
+ require 'contacts'
2
+ require 'nokogiri'
3
+
4
+ module Contacts
5
+ class WindowsLive < Consumer
6
+ configuration_attribute :application_id
7
+ configuration_attribute :secret_key
8
+ configuration_attribute :privacy_policy_url
9
+ configuration_attribute :return_url
10
+
11
+ #
12
+ # If this is set, then #authentication_url will force the given
13
+ # +target+ URL to have this origin (= scheme + host + port). This
14
+ # should match the domain that your Windows Live project is
15
+ # configured to live on.
16
+ #
17
+ # Instead of calling #authorize(params) when the user returns, you
18
+ # will need to call #forced_redirect_url(params) to redirect the
19
+ # user to the true contacts handler. #forced_redirect_url will
20
+ # handle construction of the query string based on the incoming
21
+ # parameters.
22
+ #
23
+ # The intended use is for development mode on localhost, which
24
+ # Windows Live forbids redirection to. Instead, you may register
25
+ # your app to live on "http://myapp.local", set :force_origin =>
26
+ # 'http://myapp.local:3000', and map the domain to 127.0.0.1 via
27
+ # your local hosts file. Your handlers will then look something
28
+ # like:
29
+ #
30
+ # def handler
31
+ # if ENV['HTTP_METHOD'] == 'POST'
32
+ # consumer = Contacts::WindowsLive.new
33
+ # redirect_to consumer.authentication_url(session)
34
+ # else
35
+ # consumer = Contacts::WindowsLive.deserialize(session[:consumer])
36
+ # consumer.authorize(params)
37
+ # contacts = consumer.contacts
38
+ # end
39
+ # end
40
+ #
41
+ # Since only the origin is forced -- not the path part of the URL
42
+ # -- the handler typically redirects to itself. The second time
43
+ # through it is a GET request.
44
+ #
45
+ # Default: nil
46
+ #
47
+ # Example: http://myapp.local
48
+ #
49
+ configuration_attribute :force_origin
50
+
51
+ attr_accessor :token_expires_at, :delegation_token
52
+
53
+ def initialize(options={})
54
+ @token_expires_at = nil
55
+ @location_id = nil
56
+ @delegation_token = nil
57
+ end
58
+
59
+ def initialize_serialized(data)
60
+ @token_expires_at = Time.at(data['token_expires_at'].to_i)
61
+ @location_id = data['location_id']
62
+ @delegation_token = data['delegation_token']
63
+ end
64
+
65
+ def serializable_data
66
+ data = {}
67
+ data['token_expires_at'] = @token_expires_at.to_i if @token_expires_at
68
+ data['location_id'] = @location_id if @location_id
69
+ data['delegation_token'] = @delegation_token if @delegation_token
70
+ data
71
+ end
72
+
73
+ def authentication_url(target=self.return_url, options={})
74
+ if force_origin
75
+ context = target
76
+ target = force_origin + URI.parse(target).path
77
+ end
78
+
79
+ url = "https://consent.live.com/Delegation.aspx"
80
+ query = {
81
+ 'ps' => 'Contacts.Invite',
82
+ 'ru' => target,
83
+ 'pl' => privacy_policy_url,
84
+ 'app' => app_verifier,
85
+ }
86
+ query['appctx'] = context if context
87
+ "#{url}?#{params_to_query(query)}"
88
+ end
89
+
90
+ def forced_redirect_url(params)
91
+ target_origin = params['appctx'] and
92
+ "#{target_origin}?#{params_to_query(params)}"
93
+ end
94
+
95
+ def authorize(params)
96
+ consent_token_data = params['ConsentToken'] or
97
+ raise Error, "no ConsentToken from Windows Live"
98
+ eact = backwards_query_to_params(consent_token_data)['eact'] or
99
+ raise Error, "missing eact from Windows Live"
100
+ query = decode_eact(eact)
101
+ consent_authentic?(query) or
102
+ raise Error, "inauthentic Windows Live consent"
103
+ params = query_to_params(query)
104
+ @token_expires_at = Time.at(params['exp'].to_i)
105
+ @location_id = params['lid']
106
+ @delegation_token = params['delt']
107
+ true
108
+ rescue Error => error
109
+ @error = error.message
110
+ false
111
+ end
112
+
113
+ def contacts(options={})
114
+ return nil if @delegation_token.nil? || @token_expires_at < Time.now
115
+ # TODO: Handle expired token.
116
+ xml = request_contacts
117
+ parse_xml(xml)
118
+ end
119
+
120
+ private
121
+
122
+ def signature_key
123
+ OpenSSL::Digest::SHA256.digest("SIGNATURE#{secret_key}")[0...16]
124
+ end
125
+
126
+ def encryption_key
127
+ OpenSSL::Digest::SHA256.digest("ENCRYPTION#{secret_key}")[0...16]
128
+ end
129
+
130
+ def app_verifier
131
+ token = params_to_query({
132
+ 'appid' => application_id,
133
+ 'ts' => Time.now.to_i,
134
+ })
135
+ token << "&sig=#{CGI.escape(Base64.encode64(sign(token)))}"
136
+ end
137
+
138
+ def sign(token)
139
+ OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, signature_key, token)
140
+ end
141
+
142
+ def decode_eact(eact)
143
+ token = Base64.decode64(CGI.unescape(eact))
144
+ iv, crypted = token[0...16], token[16..-1]
145
+ cipher = OpenSSL::Cipher::AES128.new("CBC")
146
+ cipher.decrypt
147
+ cipher.iv = iv
148
+ cipher.key = encryption_key
149
+ cipher.update(crypted) + cipher.final
150
+ end
151
+
152
+ def consent_authentic?(query)
153
+ body, encoded_signature = query.split(/&sig=/)
154
+ signature = Base64.decode64(CGI.unescape(encoded_signature))
155
+ sign(body) == signature
156
+ end
157
+
158
+ #
159
+ # Like #query_to_params, but do the unescaping *before* the
160
+ # splitting on '&' and '=', like Microsoft does it.
161
+ #
162
+ def backwards_query_to_params(data)
163
+ params={}
164
+ CGI.unescape(data).split(/&/).each do |pair|
165
+ key, value = *pair.split(/=/)
166
+ params[key] = value ? value : ''
167
+ end
168
+ params
169
+ end
170
+
171
+ def request_contacts
172
+ http = Net::HTTP.new('livecontacts.services.live.com', 443)
173
+ http.use_ssl = true
174
+ url = "/users/@L@#{@location_id}/rest/invitationsbyemail"
175
+ authorization = "DelegatedToken dt=\"#{@delegation_token}\""
176
+ http.get(url, {"Authorization" => authorization}).body
177
+ end
178
+
179
+ def parse_xml(xml)
180
+ document = Nokogiri::XML(xml)
181
+ document.search('/LiveContacts/Contacts/Contact').map do |contact|
182
+ email = contact.at('PreferredEmail').inner_text.strip
183
+ names = []
184
+ element = contact.at('Profiles/Personal/FirstName') and
185
+ names << element.inner_text.strip
186
+ element = contact.at('Profiles/Personal/LastName') and
187
+ names << element.inner_text.strip
188
+ Contact.new(email,names.join(' '))
189
+ end
190
+ end
191
+ end
192
+ end