erc-contacts 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,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