lotus 0.0.12

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. data/.gitignore +6 -0
  2. data/.travis.yml +9 -0
  3. data/Gemfile +16 -0
  4. data/README.md +233 -0
  5. data/Rakefile +7 -0
  6. data/lib/lotus.rb +232 -0
  7. data/lib/lotus/activity.rb +134 -0
  8. data/lib/lotus/atom/account.rb +50 -0
  9. data/lib/lotus/atom/address.rb +56 -0
  10. data/lib/lotus/atom/author.rb +167 -0
  11. data/lib/lotus/atom/category.rb +41 -0
  12. data/lib/lotus/atom/entry.rb +159 -0
  13. data/lib/lotus/atom/feed.rb +174 -0
  14. data/lib/lotus/atom/generator.rb +40 -0
  15. data/lib/lotus/atom/link.rb +79 -0
  16. data/lib/lotus/atom/name.rb +57 -0
  17. data/lib/lotus/atom/organization.rb +62 -0
  18. data/lib/lotus/atom/portable_contacts.rb +117 -0
  19. data/lib/lotus/atom/source.rb +168 -0
  20. data/lib/lotus/atom/thread.rb +60 -0
  21. data/lib/lotus/author.rb +177 -0
  22. data/lib/lotus/category.rb +45 -0
  23. data/lib/lotus/crypto.rb +146 -0
  24. data/lib/lotus/feed.rb +190 -0
  25. data/lib/lotus/generator.rb +53 -0
  26. data/lib/lotus/identity.rb +59 -0
  27. data/lib/lotus/link.rb +56 -0
  28. data/lib/lotus/notification.rb +220 -0
  29. data/lib/lotus/publisher.rb +40 -0
  30. data/lib/lotus/subscription.rb +117 -0
  31. data/lib/lotus/version.rb +3 -0
  32. data/lotus.gemspec +27 -0
  33. data/spec/activity_spec.rb +84 -0
  34. data/spec/atom/feed_spec.rb +681 -0
  35. data/spec/author_spec.rb +150 -0
  36. data/spec/crypto_spec.rb +138 -0
  37. data/spec/feed_spec.rb +252 -0
  38. data/spec/helper.rb +8 -0
  39. data/spec/identity_spec.rb +67 -0
  40. data/spec/link_spec.rb +30 -0
  41. data/spec/notification_spec.rb +77 -0
  42. data/test/example_feed.atom +393 -0
  43. data/test/example_feed_empty_author.atom +336 -0
  44. data/test/example_feed_false_connected.atom +359 -0
  45. data/test/example_feed_link_without_href.atom +134 -0
  46. data/test/example_page.html +4 -0
  47. data/test/mime_type_bug_feed.atom +874 -0
  48. metadata +204 -0
@@ -0,0 +1,53 @@
1
+ module Lotus
2
+ # The generator element identifies the agent used to generate the feed.
3
+ class Generator
4
+ # Holds the base URI for relative URIs contained in uri.
5
+ attr_reader :base
6
+
7
+ # Holds the language of the name, when it exists. The language
8
+ # should be specified as RFC 3066 as either 2 or 3 letter codes.
9
+ # For example: 'en' for English or more specifically 'en-us'
10
+ attr_reader :lang
11
+
12
+ # Holds the optional uri that SHOULD produce a representation that is
13
+ # relevant to the agent.
14
+ attr_reader :uri
15
+
16
+ # Holds the optional string identifying the version of the generating
17
+ # agent.
18
+ attr_reader :version
19
+
20
+ # Holds the string that provides a human-readable name that identifies
21
+ # the generating agent. The content of this field is language sensitive.
22
+ attr_reader :name
23
+
24
+ # Creates a representation of a generator.
25
+ #
26
+ # options:
27
+ # :base => Optional base URI for use with a relative URI in uri.
28
+ # :lang => Optional string identifying the language of the name field.
29
+ # :uri => Optional string identifying the URL that SHOULD produce
30
+ # a representation that is relevant to the agent.
31
+ # :version => Optional string indicating the version of the generating
32
+ # agent.
33
+ # :name => Optional name of the agent.
34
+ def initialize(options = {})
35
+ @base = options[:base]
36
+ @lang = options[:lang]
37
+ @uri = options[:uri]
38
+ @version = options[:version]
39
+ @name = options[:name]
40
+ end
41
+
42
+ # Yields a hash that represents the generator.
43
+ def to_hash
44
+ {
45
+ :base => self.base,
46
+ :lang => self.lang,
47
+ :version => self.version,
48
+ :uri => self.uri,
49
+ :name => self.name
50
+ }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,59 @@
1
+ module Lotus
2
+ # Holds information about an Identity. This is more specific to identifying
3
+ # an Author than that structure. It is generally hosted in one place and
4
+ # not replicated. It holds identifying information that allow you to
5
+ # ensure verification of communication with the author.
6
+ class Identity
7
+ # Holds the public key for this identity.
8
+ attr_reader :public_key
9
+
10
+ # Holds the salmon endpoint used for direct notifications for this
11
+ # identity.
12
+ attr_reader :salmon_endpoint
13
+
14
+ # Holds the dialback endpoint used for capability transfer and
15
+ # authentication for this identity.
16
+ attr_reader :dialback_endpoint
17
+
18
+ # Holds the activity streams inbox endpoint for this identity.
19
+ attr_reader :activity_inbox_endpoint
20
+
21
+ # Holds the activity streams outbox endpoint for this identity.
22
+ attr_reader :activity_outbox_endpoint
23
+
24
+ # Holds the url to this identity's profile.
25
+ attr_reader :profile_page
26
+
27
+ # Create an instance of an Identity.
28
+ #
29
+ # options:
30
+ # :public_key => The identity's public key.
31
+ # :salmon_endpoint => The salmon endpoint for this identity.
32
+ # :dialback_endpoint => The dialback endpoint for this identity.
33
+ # :activity_inbox_endpoint => The activity streams inbox for this
34
+ # identity.
35
+ # :activity_outbox_endpoint => The activity streams outbox for this
36
+ # identity.
37
+ # :profile_page => The url for this identity's profile page.
38
+ def initialize(options = {})
39
+ @public_key = options[:public_key]
40
+ @salmon_endpoint = options[:salmon_endpoint]
41
+ @dialback_endpoint = options[:dialback_endpoint]
42
+ @activity_inbox_endpoint = options[:activity_inbox_endpoint]
43
+ @activity_outbox_endpoint = options[:activity_outbox_endpoint]
44
+ @profile_page = options[:profile_page]
45
+ end
46
+
47
+ # Returns a hash of the properties of the identity.
48
+ def to_hash
49
+ {
50
+ :public_key => self.public_key,
51
+ :salmon_endpoint => self.salmon_endpoint,
52
+ :dialback_endpoint => self.dialback_endpoint,
53
+ :activity_inbox_endpoint => self.activity_inbox_endpoint,
54
+ :activity_outbox_endpoint => self.activity_outbox_endpoint,
55
+ :profile_page => self.profile_page
56
+ }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,56 @@
1
+ module Lotus
2
+ require 'atom'
3
+
4
+ class Link
5
+ # The URL for the related resource.
6
+ attr_reader :href
7
+
8
+ # A string indicating the relationship type with the current
9
+ # document.
10
+ #
11
+ # Standard:
12
+ # "alternate" = Signifies that the URL in href identifies an alternative
13
+ # version of the resource described by the containing
14
+ # element.
15
+ # "related" = Signifies that the URL in href identifies a resource that
16
+ # is related to the contained resource. For example, the
17
+ # feed for a site that discusses the performance of the
18
+ # search engine at "http://search.example.com" might
19
+ # contain, as a link of Feed, a related link to that
20
+ # "http://search.example.com".
21
+ # "self" = Signifies that href contains a URL to the containing
22
+ # resource.
23
+ # "enclosure" = Signifies that the URL in href identifies a related
24
+ # resource that is potentially large in size and
25
+ # require special handling. SHOULD use the length field.
26
+ # "via" = Signifies that the URL in href identifies a resource that
27
+ # is the source of the information provided in the
28
+ # containing element.
29
+ attr_reader :rel
30
+
31
+ # Advises to the content MIME type of the linked resource.
32
+ attr_reader :type
33
+
34
+ # Advises to the language of the linked resource. When used with
35
+ # rel="alternate" it depicts a translated version of the entry.
36
+ # Use with a RFC3066 language tag.
37
+ attr_reader :hreflang
38
+
39
+ # Conveys human-readable information about the linked resource.
40
+ attr_reader :title
41
+
42
+ # Advises the length of the linked content in number of bytes. It is simply
43
+ # a hint about the length based upon prior information. It may change, and
44
+ # cannot override the actual content length.
45
+ attr_reader :length
46
+
47
+ def initialize(options = {})
48
+ @href = options[:href]
49
+ @rel = options[:rel]
50
+ @type = options[:type]
51
+ @hreflang = options[:hreflang]
52
+ @title = options[:title]
53
+ @length = options[:length]
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,220 @@
1
+ module Lotus
2
+ # This represents a notification that can be sent to a server when you wish
3
+ # to send information to a server that has not yet subscribed to you. Since
4
+ # this implies a lack of trust, a notification adds a layer so that the
5
+ # recipiant can verify the message contents.
6
+ class Notification
7
+ require 'xml'
8
+ require 'digest/sha2'
9
+
10
+ attr_reader :activity
11
+
12
+ # Create an instance for a particular Lotus::Activity.
13
+ def initialize activity, signature = nil, plaintext = nil
14
+ @activity = activity
15
+ @signature = signature
16
+ @plaintext = plaintext
17
+ end
18
+
19
+ # Creates an activity for following a particular Author.
20
+ def self.from_follow(user_author, followed_author)
21
+ activity = Lotus::Activity.new(
22
+ :verb => :follow,
23
+ :object => followed_author,
24
+ :actor => user_author,
25
+ :title => "Now following #{followed_author.name}",
26
+ :content => "Now following #{followed_author.name}",
27
+ :content_type => "html"
28
+ )
29
+
30
+ self.new(activity)
31
+ end
32
+
33
+ # Creates an activity for unfollowing a particular Author.
34
+ def self.from_unfollow(user_author, followed_author)
35
+ activity = Lotus::Activity.new(
36
+ :verb => "http://ostatus.org/schema/1.0/unfollow",
37
+ :object => followed_author,
38
+ :actor => user_author,
39
+ :title => "Stopped following #{followed_author.name}",
40
+ :content => "Stopped following #{followed_author.name}",
41
+ :content_type => "html"
42
+ )
43
+
44
+ self.new(activity)
45
+ end
46
+
47
+ # Creates an activity for a profile update.
48
+ def self.from_profile_update(user_author)
49
+ activity = Lotus::Activity.new(
50
+ :verb => "http://ostatus.org/schema/1.0/update-profile",
51
+ :actor => user_author,
52
+ :title => "#{user_author.name} changed their profile information.",
53
+ :content => "#{user_author.name} changed their profile information.",
54
+ :content_type => "html"
55
+ )
56
+
57
+ self.new(activity)
58
+ end
59
+
60
+ # Will pull a Lotus::Activity from the given payload and MIME type.
61
+ def self.from_data(content, content_type)
62
+ case content_type
63
+ when 'xml',
64
+ 'magic-envelope+xml',
65
+ 'application/xml',
66
+ 'application/text+xml',
67
+ 'application/magic-envelope+xml'
68
+ self.from_xml content
69
+ when 'json',
70
+ 'magic-envelope+json',
71
+ 'application/json',
72
+ 'application/text+json',
73
+ 'application/magic-envelope+json'
74
+ self.from_json content
75
+ end
76
+ end
77
+
78
+ # Will pull a Lotus::Activity from a magic envelope described by the JSON.
79
+ def self.from_json(source)
80
+ end
81
+
82
+ # Will pull a Lotus::Activity from a magic envelope described by the XML.
83
+ def self.from_xml(source)
84
+ if source.is_a?(String)
85
+ if source.length == 0
86
+ return nil
87
+ end
88
+
89
+ source = XML::Document.string(source,
90
+ :options => XML::Parser::Options::NOENT)
91
+ else
92
+ return nil
93
+ end
94
+
95
+ # Retrieve the envelope
96
+ envelope = source.find('/me:env',
97
+ 'me:http://salmon-protocol.org/ns/magic-env').first
98
+
99
+ return nil unless envelope
100
+
101
+ data = envelope.find('me:data',
102
+ 'me:http://salmon-protocol.org/ns/magic-env').first
103
+ return nil unless data
104
+
105
+ data_type = data.attributes["type"]
106
+ if data_type.nil?
107
+ data_type = 'application/atom+xml'
108
+ armored_data_type = ''
109
+ else
110
+ armored_data_type = Base64::urlsafe_encode64(data_type)
111
+ end
112
+
113
+ encoding = envelope.find('me:encoding',
114
+ 'me:http://salmon-protocol.org/ns/magic-env').first
115
+
116
+ algorithm = envelope.find(
117
+ 'me:alg',
118
+ 'me:http://salmon-protocol.org/ns/magic-env').first
119
+
120
+ signature = source.find('me:sig',
121
+ 'me:http://salmon-protocol.org/ns/magic-env').first
122
+
123
+ # Parse fields
124
+
125
+ # Well, if we cannot verify, we don't accept
126
+ return nil unless signature
127
+
128
+ # XXX: Handle key_id attribute
129
+ signature = signature.content
130
+ signature = Base64::urlsafe_decode64(signature)
131
+
132
+ if encoding.nil?
133
+ # When the encoding is omitted, use base64url
134
+ # Cite: Magic Envelope Draft Spec Section 3.3
135
+ armored_encoding = ''
136
+ encoding = 'base64url'
137
+ else
138
+ armored_encoding = Base64::urlsafe_encode64(encoding.content)
139
+ encoding = encoding.content.downcase
140
+ end
141
+
142
+ if algorithm.nil?
143
+ # When algorithm is omitted, use 'RSA-SHA256'
144
+ # Cite: Magic Envelope Draft Spec Section 3.3
145
+ armored_algorithm = ''
146
+ algorithm = 'rsa-sha256'
147
+ else
148
+ armored_algorithm = Base64::urlsafe_encode64(algorithm.content)
149
+ algorithm = algorithm.content.downcase
150
+ end
151
+
152
+ # Retrieve and decode data payload
153
+
154
+ data = data.content
155
+ armored_data = data
156
+
157
+ case encoding
158
+ when 'base64url'
159
+ data = Base64::urlsafe_decode64(data)
160
+ else
161
+ # Unsupported data encoding
162
+ return nil
163
+ end
164
+
165
+ # Signature plaintext
166
+ plaintext = "#{armored_data}.#{armored_data_type}.#{armored_encoding}.#{armored_algorithm}"
167
+
168
+ # Interpret data payload
169
+ payload = XML::Reader.string(data)
170
+ self.new Lotus::Atom::Entry.new(payload).to_canonical, signature, plaintext
171
+ end
172
+
173
+ # Generate the xml for this notice and sign with the given private key.
174
+ def to_xml private_key
175
+ # Generate magic envelope
176
+ magic_envelope = XML::Document.new
177
+
178
+ magic_envelope.root = XML::Node.new 'env'
179
+
180
+ me_ns = XML::Namespace.new(magic_envelope.root,
181
+ 'me', 'http://salmon-protocol.org/ns/magic-env')
182
+
183
+ magic_envelope.root.namespaces.namespace = me_ns
184
+
185
+ # Armored Data <me:data>
186
+ data = @activity.to_atom
187
+ @plaintext = data
188
+ data_armored = Base64::urlsafe_encode64(data)
189
+ elem = XML::Node.new 'data', data_armored, me_ns
190
+ elem.attributes['type'] = 'application/atom+xml'
191
+ data_type_armored = 'YXBwbGljYXRpb24vYXRvbSt4bWw='
192
+ magic_envelope.root << elem
193
+
194
+ # Encoding <me:encoding>
195
+ magic_envelope.root << XML::Node.new('encoding', 'base64url', me_ns)
196
+ encoding_armored = 'YmFzZTY0dXJs'
197
+
198
+ # Signing Algorithm <me:alg>
199
+ magic_envelope.root << XML::Node.new('alg', 'RSA-SHA256', me_ns)
200
+ algorithm_armored = 'UlNBLVNIQTI1Ng=='
201
+
202
+ # Signature <me:sig>
203
+ plaintext =
204
+ "#{data_armored}.#{data_type_armored}.#{encoding_armored}.#{algorithm_armored}"
205
+
206
+ # Assign @signature to the signature generated from the plaintext
207
+ @signature = Lotus::Crypto.emsa_sign(plaintext, private_key)
208
+
209
+ signature_armored = Base64::urlsafe_encode64(@signature)
210
+ magic_envelope.root << XML::Node.new('sig', signature_armored, me_ns)
211
+
212
+ magic_envelope.to_s :indent => true, :encoding => XML::Encoding::UTF_8
213
+ end
214
+
215
+ # Check the origin of this notification.
216
+ def verified? key
217
+ Lotus::Crypto.emsa_verify(@plaintext, @signature, key)
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,40 @@
1
+ module Lotus
2
+ class Publisher
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ # The url of the feed.
7
+ attr_reader :url
8
+
9
+ # The array of feed urls used to push content. Default: []
10
+ attr_reader :hubs
11
+
12
+ # Creates a representation of a Publisher entity.
13
+ #
14
+ # options:
15
+ # :feed => A feed to use to populate the other fields.
16
+ # :url => The url of the feed that will be published.
17
+ # :hubs => An array of hub urls that are used to handle load
18
+ # balancing pushes of new data. Default: []
19
+ def initialize(options = {})
20
+ if options[:feed]
21
+ @url = options[:feed].url
22
+ @hubs = options[:feed].hubs
23
+ end
24
+
25
+ @url ||= options[:url]
26
+ @hubs ||= options[:hubs] || []
27
+ end
28
+
29
+ # Will ping PuSH hubs so that they know there is new/updated content. The
30
+ # hub should respond by pulling the new data and then sending it to
31
+ # subscribers.
32
+ def ping_hubs
33
+ @hubs.each do |hub_url|
34
+ res = Net::HTTP.post_form(URI.parse(hub_url),
35
+ { 'hub.mode' => 'publish',
36
+ 'hub.url' => @topic_url })
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,117 @@
1
+ module Lotus
2
+ class Subscription
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'base64'
6
+ require 'hmac-sha1'
7
+
8
+ # The url that should be used to handle subscription handshakes.
9
+ attr_reader :callback_url
10
+
11
+ # The url of the feed one wishes to subscribe to.
12
+ attr_reader :topic_url
13
+
14
+ # The hub this subscription is made with.
15
+ attr_reader :hub
16
+
17
+ # Creates a representation of a subscription.
18
+ #
19
+ # options:
20
+ # :callback_url => The url that should be used to handle subscription
21
+ # handshakes.
22
+ # :topic_url => The url of the feed one wishes to subscribe to.
23
+ # :secret => A secret that will be passed to the callback to better
24
+ # verify that communication is not replayed. Default:
25
+ # A secure random hex.
26
+ # :hubs => A list of hubs to negotiate the subscription with.
27
+ # Default: attempts to discover the hubs when it
28
+ # subscribes for the first time.
29
+ # :hub => The hub we have a subscription with already.
30
+ def initialize(options = {})
31
+ @tokens = []
32
+
33
+ secret = options[:secret] || SecureRandom.hex(32)
34
+ @secret = secret.to_s
35
+
36
+ @callback_url = options[:callback_url]
37
+ @topic_url = options[:topic_url]
38
+ @tokens << options[:token] if options[:token]
39
+
40
+ @hubs = options[:hubs] || []
41
+
42
+ @hub = options[:hub]
43
+ end
44
+
45
+ # Actively searches for hubs by talking to publisher directly
46
+ def discover_hubs_for_topic
47
+ @hubs = Lotus.feed_from_url(self.topic_url).hubs
48
+ end
49
+
50
+ # Subscribe to the topic through the given hub.
51
+ def subscribe
52
+ return unless self.hub.nil?
53
+
54
+ # Discover hubs if none exist
55
+ @hubs = discover_hubs_for_topic(self.topic_url) if self.hubs.empty?
56
+ @hub = self.hubs.first
57
+ change_subscription(:subscribe, token)
58
+
59
+ # TODO: Check response, if failed, try a different hub
60
+ end
61
+
62
+ # Unsubscribe to the topic.
63
+ def unsubscribe
64
+ return if self.hub.nil?
65
+
66
+ change_subscription(:unsubscribe)
67
+ end
68
+
69
+ # Change our subscription to this topic at a hub.
70
+ # mode: Either :subscribe or :unsubscribe
71
+ # hub_url: The url of the hub to negotiate with
72
+ # token: A token to verify the response from the hub.
73
+ def change_subscription(mode)
74
+ token ||= SecureRandom.hex(32)
75
+ @tokens << token.to_s
76
+
77
+ # TODO: Set up HTTPS foo
78
+ res = Net::HTTP.post_form(URI.parse(self.hub),
79
+ {
80
+ 'hub.mode' => mode.to_s,
81
+ 'hub.callback' => @callback_url,
82
+ 'hub.verify' => 'async',
83
+ 'hub.verify_token' => token,
84
+ 'hub.lease_seconds' => '',
85
+ 'hub.secret' => @secret,
86
+ 'hub.topic' => @topic_url
87
+ })
88
+ end
89
+
90
+ # Verify that a subscription response is valid.
91
+ def verify_subscription(token)
92
+ # Is there a token?
93
+ result = @tokens.include?(token)
94
+
95
+ # Ensure we cannot reuse the token
96
+ @tokens.delete(token)
97
+
98
+ result
99
+ end
100
+
101
+ # Determines if the given body matches the signature.
102
+ def verify_content(body, signature)
103
+ hmac = HMAC::SHA1.hexdigest(@secret, body)
104
+ check = "sha1=" + hmac
105
+ check == signature
106
+ end
107
+
108
+ # Gives the content of a challenge response given the challenge
109
+ # body.
110
+ def challenge_response(challenge_code)
111
+ {
112
+ :body => challenge_code,
113
+ :status => 200
114
+ }
115
+ end
116
+ end
117
+ end