lperichon-contacts 1.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.
data/.gitmodules ADDED
@@ -0,0 +1,3 @@
1
+ [submodule "vendor/fakeweb"]
2
+ path = vendor/fakeweb
3
+ url = git://github.com/mislav/fakeweb.git
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2009 Mislav Marohnić
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.markdown ADDED
@@ -0,0 +1,111 @@
1
+ ## Contacts
2
+
3
+ Fetch users' contact lists without asking them to provide their
4
+ passwords, as painlessly as possible.
5
+
6
+ Contacts provides adapters for:
7
+
8
+ * Google
9
+ * Yahoo!
10
+ * Windows Live
11
+
12
+ ## Basic usage instructions
13
+
14
+ First, register your application with the service providers you
15
+ require. Instructions below under "Setting up your accounts".
16
+
17
+ Now, create a consumer:
18
+
19
+ consumer = Contacts::Google.new
20
+ consumer = Contacts::Yahoo.new
21
+ consumer = Contacts::WindowsLive.new
22
+ # OR by parameter:
23
+ # provider is one of :google, :yahoo, :windows_live
24
+ consumer = Contacts.new_consumer(provider)
25
+
26
+ Now, direct your user to:
27
+
28
+ consumer.authentication_url(return_url)
29
+
30
+ `return_url` is the page the user will be redirected to by the service
31
+ once authorization has been granted. You should also persist the
32
+ consumer object so you can grab the contacts once the user returns:
33
+
34
+ session[:consumer] = consumer.serialize
35
+
36
+ Now in the request handler of the return_url above:
37
+
38
+ consumer = Contacts.deserialize(session[:consumer])
39
+ if consumer.authorize(params)
40
+ @contacts = consumer.contacts
41
+ else
42
+ # handle error
43
+ end
44
+
45
+ Here, `params` is the hash of request parameters that the user returns
46
+ with. `consumer.authorize` returns true if the authorization was
47
+ successful, false otherwise.
48
+
49
+ The list of `@contacts` are `Contacts::Contact` objects which have
50
+ these attributes:
51
+
52
+ * name
53
+ * emails
54
+
55
+ ## Setting up your accounts
56
+
57
+ ### Google
58
+
59
+ Set up your projects
60
+ [here](http://code.google.com/apis/accounts/docs/RegistrationForWebAppsAuto.html).
61
+
62
+ * When redirecting to an unverified domain (e.g., localhost), the
63
+ user sees a warning when authorizing access.
64
+
65
+ What this means:
66
+
67
+ * You can use the same consumer key for development as production.
68
+
69
+ ### Yahoo
70
+
71
+ Set up your projects [here](https://developer.apps.yahoo.com/projects)
72
+
73
+ * When a project's domain is verified, you must redirect to it.
74
+ * When a project's domain is not verified, you can redirect anywhere, and the
75
+ user sees a warning when authorizing access.
76
+
77
+ What this means:
78
+
79
+ * Set up separate production and development projects.
80
+ * Verify the domain of your production project. You will only be able to
81
+ redirect to your production domain when using this key.
82
+ * Don't verify the domain of your development project. You will be able to
83
+ redirect to any domain when using this key.
84
+
85
+ ## Windows Live
86
+
87
+ Set up your projects [here](http://msdn.microsoft.com/en-us/library/cc287659.aspx).
88
+
89
+ * There is no domain verification step.
90
+ * The domain cannot be localhost, an IP address, or have a query string.
91
+ * You must redirect to the project's domain, on any port, and the URL may not
92
+ have a query string or fragment.
93
+ * You must specify a privacy policy URL.
94
+
95
+ What this means:
96
+
97
+ * Set up separate production and development projects.
98
+ * For development, use a domain like myapp.local.
99
+ * Map this domain to 127.0.0.1 in /etc/hosts or a local DNS server.
100
+ * If you want to run your app on a different domain (e.g., localhost:3000),
101
+ redirect the POST from Windows Live to a GET on the original domain. This
102
+ ensures the popup window has the same origin as the opener page, in
103
+ accordance with browser same origin policies.
104
+
105
+ ## Copyright
106
+
107
+ Copyright (c) 2010 [George Ogata](mailto:george.ogata@gmail.com) See
108
+ LICENSE for details.
109
+
110
+ Derived from [Mislav's Contacts](http://github.com/mislav/contacts),
111
+ Copyright (c) 2009 [Mislav Marohnić](mailto:mislav.marohnic@gmail.com)
data/Rakefile ADDED
@@ -0,0 +1,55 @@
1
+ require 'spec/rake/spectask'
2
+ require 'rake/rdoctask'
3
+
4
+ task :default => :spec
5
+
6
+ spec_opts = 'spec/spec.opts'
7
+ spec_glob = FileList['spec/**/*_spec.rb']
8
+ libs = ['lib', 'spec', 'vendor/fakeweb/lib']
9
+
10
+ desc 'Run all specs in spec directory'
11
+ Spec::Rake::SpecTask.new(:spec) do |t|
12
+ t.libs = libs
13
+ t.spec_opts = ['--options', spec_opts]
14
+ t.spec_files = spec_glob
15
+ # t.warning = true
16
+ end
17
+
18
+ namespace :spec do
19
+ desc 'Analyze spec coverage with RCov'
20
+ Spec::Rake::SpecTask.new(:rcov) do |t|
21
+ t.libs = libs
22
+ t.spec_files = spec_glob
23
+ t.spec_opts = ['--options', spec_opts]
24
+ t.rcov = true
25
+ t.rcov_opts = lambda do
26
+ IO.readlines('spec/rcov.opts').map { |l| l.chomp.split(" ") }.flatten
27
+ end
28
+ end
29
+
30
+ desc 'Print Specdoc for all specs'
31
+ Spec::Rake::SpecTask.new(:doc) do |t|
32
+ t.libs = libs
33
+ t.spec_opts = ['--format', 'specdoc', '--dry-run']
34
+ t.spec_files = spec_glob
35
+ end
36
+
37
+ desc 'Generate HTML report'
38
+ Spec::Rake::SpecTask.new(:html) do |t|
39
+ t.libs = libs
40
+ t.spec_opts = ['--format', 'html:doc/spec.html', '--diff']
41
+ t.spec_files = spec_glob
42
+ t.fail_on_error = false
43
+ end
44
+ end
45
+
46
+ desc 'Generate RDoc documentation'
47
+ Rake::RDocTask.new(:rdoc) do |rdoc|
48
+ rdoc.rdoc_files.add ['README.rdoc', 'MIT-LICENSE', 'lib/**/*.rb']
49
+ rdoc.main = 'README.rdoc'
50
+ rdoc.title = 'Ruby Contacts library'
51
+
52
+ rdoc.rdoc_dir = 'doc'
53
+ rdoc.options << '--inline-source'
54
+ rdoc.options << '--charset=UTF-8'
55
+ end
@@ -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,70 @@
1
+ require 'nokogiri'
2
+
3
+ module Contacts
4
+ class Google < OAuthConsumer
5
+ CONSUMER_OPTIONS = Util.frozen_hash(
6
+ :site => "https://www.google.com",
7
+ :request_token_path => "/accounts/OAuthGetRequestToken",
8
+ :access_token_path => "/accounts/OAuthGetAccessToken",
9
+ :authorize_path => "/accounts/OAuthAuthorizeToken"
10
+ )
11
+
12
+ REQUEST_TOKEN_PARAMS = Util.frozen_hash(
13
+ 'scope' => "https://www.google.com/m8/feeds/"
14
+ )
15
+
16
+ def initialize(options={})
17
+ super(CONSUMER_OPTIONS, REQUEST_TOKEN_PARAMS)
18
+ end
19
+
20
+ def contacts(options={})
21
+ return nil if @access_token.nil?
22
+ params = {:limit => 200}.update(options)
23
+ google_params = translate_parameters(params)
24
+ query = params_to_query(google_params)
25
+ response = @access_token.get("/m8/feeds/contacts/default/thin?#{query}")
26
+ parse_contacts(response.body)
27
+ end
28
+
29
+ private
30
+
31
+ def translate_parameters(params)
32
+ params.inject({}) do |all, pair|
33
+ key, value = pair
34
+ unless value.nil?
35
+ key = case key
36
+ when :limit
37
+ 'max-results'
38
+ when :offset
39
+ value = value.to_i + 1
40
+ 'start-index'
41
+ when :order
42
+ all['sortorder'] = 'descending' if params[:descending].nil?
43
+ 'orderby'
44
+ when :descending
45
+ value = value ? 'descending' : 'ascending'
46
+ 'sortorder'
47
+ when :updated_after
48
+ value = value.strftime("%Y-%m-%dT%H:%M:%S%Z") if value.respond_to? :strftime
49
+ 'updated-min'
50
+ else key
51
+ end
52
+
53
+ all[key] = value
54
+ end
55
+ all
56
+ end
57
+ end
58
+
59
+ def parse_contacts(body)
60
+ document = Nokogiri::XML(body)
61
+ document.search('/xmlns:feed/xmlns:entry').map do |entry|
62
+ emails = entry.search('./gd:email[@address]').map{|e| e['address'].to_s}
63
+ next if emails.empty?
64
+ title = entry.at('title') and
65
+ name = title.inner_text
66
+ Contact.new(name, emails)
67
+ end.compact
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,70 @@
1
+ require 'oauth'
2
+
3
+ module Contacts
4
+ class OAuthConsumer < Consumer
5
+ configuration_attribute :consumer_key
6
+ configuration_attribute :consumer_secret
7
+
8
+ def initialize(consumer_options, request_token_params)
9
+ @consumer_options = consumer_options
10
+ @request_token_params = request_token_params
11
+ end
12
+
13
+ def initialize_serialized(data)
14
+ value = data['request_token'] and
15
+ @request_token = deserialize_oauth_token(consumer, value)
16
+ value = data['access_token'] and
17
+ @access_token = deserialize_oauth_token(consumer, value)
18
+ end
19
+
20
+ def serializable_data
21
+ data = {}
22
+ data['access_token'] = serialize_oauth_token(@access_token) if @access_token
23
+ data['request_token'] = serialize_oauth_token(@request_token) if @request_token
24
+ data
25
+ end
26
+
27
+ attr_accessor :request_token
28
+ attr_accessor :access_token
29
+
30
+ def authentication_url(target, options={})
31
+ @request_token = consumer.get_request_token({:oauth_callback => target}, @request_token_params)
32
+ @request_token.authorize_url
33
+ end
34
+
35
+ def authorize(params)
36
+ begin
37
+ @access_token = @request_token.get_access_token(:oauth_verifier => params['oauth_verifier'])
38
+ rescue OAuth::Unauthorized => error
39
+ @error = error.message
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def consumer
46
+ @consumer ||= OAuth::Consumer.new(consumer_key, consumer_secret, @consumer_options)
47
+ end
48
+
49
+ #
50
+ # Marshal sucks for persistence. This provides a prettier, more
51
+ # future-proof persistable representation of a token.
52
+ #
53
+ def serialize_oauth_token(token)
54
+ params = {
55
+ 'version' => '1', # serialization format
56
+ 'type' => token.is_a?(OAuth::AccessToken) ? 'access' : 'request',
57
+ 'oauth_token' => token.token,
58
+ 'oauth_token_secret' => token.secret,
59
+ }
60
+ params_to_query(params)
61
+ end
62
+
63
+ def deserialize_oauth_token(consumer, data)
64
+ params = query_to_params(data)
65
+ klass = params['type'] == 'access' ? OAuth::AccessToken : OAuth::RequestToken
66
+ token = klass.new(consumer, params.delete('oauth_token'), params.delete('oauth_token_secret'))
67
+ token
68
+ end
69
+ end
70
+ 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,9 @@
1
+ module Contacts
2
+ module VERSION #:nodoc:
3
+ MAJOR = 0
4
+ MINOR = 2
5
+ TINY = 5
6
+
7
+ STRING = [MAJOR, MINOR, TINY].join('.')
8
+ end
9
+ end
@@ -0,0 +1,188 @@
1
+ require 'nokogiri'
2
+
3
+ module Contacts
4
+ class WindowsLive < Consumer
5
+ configuration_attribute :application_id
6
+ configuration_attribute :secret_key
7
+ configuration_attribute :privacy_policy_url
8
+
9
+ #
10
+ # If this is set, then #authentication_url will force the given
11
+ # +target+ URL to have this origin (= scheme + host + port). This
12
+ # should match the domain that your Windows Live project is
13
+ # configured to live on.
14
+ #
15
+ # Instead of calling #authorize(params) when the user returns, you
16
+ # will need to call #forced_redirect_url(params) to redirect the
17
+ # user to the true contacts handler. #forced_redirect_url will
18
+ # handle construction of the query string based on the incoming
19
+ # parameters.
20
+ #
21
+ # The intended use is for development mode on localhost, which
22
+ # Windows Live forbids redirection to. Instead, you may register
23
+ # your app to live on "http://myapp.local", set :force_origin =>
24
+ # 'http://myapp.local:3000', and map the domain to 127.0.0.1 via
25
+ # your local hosts file. Your handlers will then look something
26
+ # like:
27
+ #
28
+ # def handler
29
+ # if ENV['HTTP_METHOD'] == 'POST'
30
+ # consumer = Contacts::WindowsLive.new
31
+ # redirect_to consumer.authentication_url(session)
32
+ # else
33
+ # consumer = Contacts::WindowsLive.deserialize(session[:consumer])
34
+ # consumer.authorize(params)
35
+ # contacts = consumer.contacts
36
+ # end
37
+ # end
38
+ #
39
+ # Since only the origin is forced -- not the path part of the URL
40
+ # -- the handler typically redirects to itself. The second time
41
+ # through it is a GET request.
42
+ #
43
+ # Default: nil
44
+ #
45
+ # Example: http://myapp.local
46
+ #
47
+ configuration_attribute :force_origin
48
+
49
+ def initialize(options={})
50
+ @token_expires_at = nil
51
+ @location_id = nil
52
+ @delegation_token = nil
53
+ end
54
+
55
+ def initialize_serialized(data)
56
+ @token_expires_at = Time.at(data['token_expires_at'].to_i)
57
+ @location_id = data['location_id']
58
+ @delegation_token = data['delegation_token']
59
+ end
60
+
61
+ def serializable_data
62
+ data = {}
63
+ data['token_expires_at'] = @token_expires_at.to_i if @token_expires_at
64
+ data['location_id'] = @location_id if @location_id
65
+ data['delegation_token'] = @delegation_token if @delegation_token
66
+ data
67
+ end
68
+
69
+ def authentication_url(target, options={})
70
+ if force_origin
71
+ context = target
72
+ target = force_origin + URI.parse(target).path
73
+ end
74
+
75
+ url = "https://consent.live.com/Delegation.aspx"
76
+ query = {
77
+ 'ps' => 'Contacts.Invite',
78
+ 'ru' => target,
79
+ 'pl' => privacy_policy_url,
80
+ 'app' => app_verifier,
81
+ }
82
+ query['appctx'] = context if context
83
+ "#{url}?#{params_to_query(query)}"
84
+ end
85
+
86
+ def forced_redirect_url(params)
87
+ target_origin = params['appctx'] and
88
+ "#{target_origin}?#{params_to_query(params)}"
89
+ end
90
+
91
+ def authorize(params)
92
+ consent_token_data = params['ConsentToken'] or
93
+ raise Error, "no ConsentToken from Windows Live"
94
+ eact = backwards_query_to_params(consent_token_data)['eact'] or
95
+ raise Error, "missing eact from Windows Live"
96
+ query = decode_eact(eact)
97
+ consent_authentic?(query) or
98
+ raise Error, "inauthentic Windows Live consent"
99
+ params = query_to_params(query)
100
+ @token_expires_at = Time.at(params['exp'].to_i)
101
+ @location_id = params['lid']
102
+ @delegation_token = params['delt']
103
+ true
104
+ rescue Error => error
105
+ @error = error.message
106
+ false
107
+ end
108
+
109
+ def contacts(options={})
110
+ return nil if @delegation_token.nil? || @token_expires_at < Time.now
111
+ # TODO: Handle expired token.
112
+ xml = request_contacts
113
+ parse_xml(xml)
114
+ end
115
+
116
+ private
117
+
118
+ def signature_key
119
+ OpenSSL::Digest::SHA256.digest("SIGNATURE#{secret_key}")[0...16]
120
+ end
121
+
122
+ def encryption_key
123
+ OpenSSL::Digest::SHA256.digest("ENCRYPTION#{secret_key}")[0...16]
124
+ end
125
+
126
+ def app_verifier
127
+ token = params_to_query({
128
+ 'appid' => application_id,
129
+ 'ts' => Time.now.to_i,
130
+ })
131
+ token << "&sig=#{CGI.escape(Base64.encode64(sign(token)))}"
132
+ end
133
+
134
+ def sign(token)
135
+ OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, signature_key, token)
136
+ end
137
+
138
+ def decode_eact(eact)
139
+ token = Base64.decode64(CGI.unescape(eact))
140
+ iv, crypted = token[0...16], token[16..-1]
141
+ cipher = OpenSSL::Cipher::AES128.new("CBC")
142
+ cipher.decrypt
143
+ cipher.iv = iv
144
+ cipher.key = encryption_key
145
+ cipher.update(crypted) + cipher.final
146
+ end
147
+
148
+ def consent_authentic?(query)
149
+ body, encoded_signature = query.split(/&sig=/)
150
+ signature = Base64.decode64(CGI.unescape(encoded_signature))
151
+ sign(body) == signature
152
+ end
153
+
154
+ #
155
+ # Like #query_to_params, but do the unescaping *before* the
156
+ # splitting on '&' and '=', like Microsoft does it.
157
+ #
158
+ def backwards_query_to_params(data)
159
+ params={}
160
+ CGI.unescape(data).split(/&/).each do |pair|
161
+ key, value = *pair.split(/=/)
162
+ params[key] = value ? value : ''
163
+ end
164
+ params
165
+ end
166
+
167
+ def request_contacts
168
+ http = Net::HTTP.new('livecontacts.services.live.com', 443)
169
+ http.use_ssl = true
170
+ url = "/users/@L@#{@location_id}/rest/invitationsbyemail"
171
+ authorization = "DelegatedToken dt=\"#{@delegation_token}\""
172
+ http.get(url, {"Authorization" => authorization}).body
173
+ end
174
+
175
+ def parse_xml(xml)
176
+ document = Nokogiri::XML(xml)
177
+ document.search('/LiveContacts/Contacts/Contact').map do |contact|
178
+ email = contact.at('PreferredEmail').inner_text.strip
179
+ names = []
180
+ element = contact.at('Profiles/Personal/FirstName') and
181
+ names << element.inner_text.strip
182
+ element = contact.at('Profiles/Personal/LastName') and
183
+ names << element.inner_text.strip
184
+ Contact.new(email, names.join(' '))
185
+ end
186
+ end
187
+ end
188
+ end