lotus 0.0.12

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