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.
- 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
|