lperichon-contacts 1.0

Sign up to get free protection for your applications and to get access to all the features.
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