erc-contacts 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitmodules +3 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +96 -0
- data/LICENSE +18 -0
- data/README.markdown +111 -0
- data/Rakefile +32 -0
- data/VERSION +1 -0
- data/erc-contacts.gemspec +91 -0
- data/lib/contacts.rb +90 -0
- data/lib/contacts/consumer.rb +123 -0
- data/lib/contacts/google.rb +85 -0
- data/lib/contacts/oauth_consumer.rb +71 -0
- data/lib/contacts/railtie.rb +14 -0
- data/lib/contacts/util.rb +24 -0
- data/lib/contacts/version.rb +10 -0
- data/lib/contacts/windows_live.rb +192 -0
- data/lib/contacts/yahoo.rb +89 -0
- data/pkg/kulesa-contacts-0.2.6.gem +0 -0
- data/spec/config/contacts.yml +15 -0
- data/spec/contact_spec.rb +29 -0
- data/spec/feeds/google-many.xml +76 -0
- data/spec/feeds/wl_contacts.xml +29 -0
- data/spec/feeds/yh_contacts.txt +241 -0
- data/spec/gmail/google_spec.rb +54 -0
- data/spec/spec_helper.rb +21 -0
- data/spec/windows_live/windows_live_spec.rb +44 -0
- data/spec/yahoo/yahoo_spec.rb +52 -0
- metadata +200 -0
@@ -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,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
|