├── LICENSE ├── README.md ├── examples ├── client.coffee └── client_app.coffee ├── package.json ├── src ├── client.coffee ├── index.coffee └── server.coffee ├── test ├── example_test.coffee ├── publish_test.coffee ├── subscribe_test.coffee └── verify_test.coffee └── vendor └── scoped-http-client ├── README.md ├── lib └── index.js ├── src └── index.coffee └── test ├── get_test.coffee ├── post_test.coffee ├── request_test.coffee └── url_test.coffee /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2013 rick olson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nub Nub 2 | 3 | Nub Nub is a Node.js implementation of a PubSubHubbub client and server. At 4 | the core, it's meant to be web server and data store agnostic. Specifics of 5 | feed fetching are out of scope. 6 | 7 | This library attempts to be compliant to the [PubSubHubbub spec][spec]. 8 | 9 | [spec]: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html 10 | 11 | ## Handling a Subscribe or Unsubscribe request 12 | 13 | See [section 6.1][6.1] of the spec. 14 | 15 | var nub = require('nubnub'); 16 | var sub = nub.subscribe(POST_DATA); 17 | sub.callback // some URL 18 | 19 | ## Verifying a Subscription 20 | 21 | See [section 6.2][6.2] of the spec. 22 | 23 | // verify the subscription 24 | sub.check_verification(function(err, resp) { 25 | 26 | }) 27 | 28 | ## Pushing data to subscribers 29 | 30 | See [section 7.3][7.3] of the spec. 31 | 32 | // publish data 33 | sub.publish(items, {format: "atom"}, function(err, resp) { 34 | 35 | }) 36 | 37 | [6.1]: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html#rfc.section.6.1 38 | [6.2]: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html#rfc.section.6.2 39 | [7.3]: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html#rfc.section.7.3 40 | 41 | ## TODO 42 | 43 | * Example server implementations with various backends? (redis, mysql, nstore) 44 | * Implementation of [Content Notification][7.1]. 45 | * Logic for scanning feeds. 46 | * Logic for retrying failed verification or failed pushes as necessary. 47 | 48 | [7.1]: http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html#rfc.section.7.1 49 | 50 | ## Development 51 | 52 | Run this in the main directory to compile coffeescript to javascript as you go: 53 | 54 | coffee -wc -o lib --no-wrap src/**/*.coffee -------------------------------------------------------------------------------- /examples/client.coffee: -------------------------------------------------------------------------------- 1 | # This is a minimal script to subscribe to a topic's updates on a remote hub. 2 | # By default, it goes out to the demo hub on http://pubsubhubbub.appspot.com/. 3 | 4 | Client = require '../src/client' 5 | cli = Client.build( 6 | hub: "http://pubsubhubbub.appspot.com/subscribe" # the hub url 7 | topic: 'http://pubsubhubbub.appspot.com' # the feed/topic url 8 | callback: "path/to/client_app" # your running client app 9 | ) 10 | 11 | console.log "subscribing..." 12 | cli.subscribe (err, resp, body) -> 13 | if err 14 | console.log err 15 | console.log "#{resp.statusCode}: #{body}" -------------------------------------------------------------------------------- /examples/client_app.coffee: -------------------------------------------------------------------------------- 1 | # This is the client endpoint that receives requests from hub servers from 2 | # around the world. This needs to be running on a public server on one of 3 | # these ports: 4 | # 8084,8085,8086,8087,8080,8081,8082,8083,443,8990,8088,8089,8444,4443,80,8188 5 | # Pass the URL as the callback option in client.coffee 6 | 7 | http = require 'http' 8 | Url = require 'url' 9 | Events = require('events').EventEmitter 10 | events = new Events 11 | port = 4012 12 | 13 | server = http.createServer (req, resp) -> 14 | body = '' 15 | req.on 'data', (chunk) -> body += chunk 16 | req.on 'end', -> 17 | # receiving verification challenge from the hub 18 | if req.method == 'GET' 19 | resp.writeHead 200 20 | resp.write Url.parse(req.url, true).query.hub.challenge 21 | # receiving push from the hub 22 | else 23 | resp.writeHead 200 24 | events.emit 'publish', req.headers['content-type'], body 25 | resp.end() 26 | 27 | server.listen port, -> 28 | console.log "Listening on port #{port}" 29 | 30 | events.on 'publish', (contentType, data) -> 31 | console.log "Received #{contentType} (#{data.length})" 32 | # do something when data is pushed -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { "name": "nubnub" 2 | , "version": "0.1.0" 3 | , "author": "technoweenie" 4 | , "engines": [ "node >= 0.2.0" ] 5 | , "directories" : { "lib" : "./lib" } 6 | , "main": "./lib" 7 | } 8 | -------------------------------------------------------------------------------- /src/client.coffee: -------------------------------------------------------------------------------- 1 | Crypto = require 'crypto' 2 | Query = require 'querystring' 3 | ScopedClient = require '../vendor/scoped-http-client/lib' 4 | 5 | # Public: Checks if the given signature is a valid HMAC hash. 6 | # 7 | # sig - String signature to verify. 8 | # secret - String Hmac secret. 9 | # data - The request data that the signature was created with. 10 | # 11 | # Returns a Boolean specifying whether the signature is valid. 12 | exports.is_valid_signature = (sig, secret, data) -> 13 | hmac = Crypto.createHmac 'sha1', secret 14 | hmac.update data 15 | hmac.digest('hex') == sig 16 | 17 | # Represents a single PubSubHubbub (PuSH) subscriber. It is able to subscribe 18 | # to topics on a hub and verify the subscription intent. 19 | class Subscriber 20 | constructor: (options) -> 21 | Subscriber.allowed_keys.forEach (key) => 22 | value = options[key] 23 | @[key] = value if value? 24 | @verify ||= 'sync' 25 | 26 | # Public: Subscribes to a topic on a given hub. 27 | # 28 | # cb - A Function callback. 29 | # err - An optional error object. 30 | # resp - A http.ClientResponse instance. 31 | # 32 | # Returns nothing. 33 | subscribe: (cb) -> 34 | @post_to_hub 'subscribe', cb 35 | @ 36 | 37 | # Public: Unsubscribes from a topic on a given hub. 38 | # 39 | # cb - A Function callback. 40 | # err - An optional error object. 41 | # resp - A http.ClientResponse instance. 42 | # 43 | # Returns nothing. 44 | unsubscribe: (cb) -> 45 | @post_to_hub 'unsubscribe', cb 46 | @ 47 | 48 | # Public: Checks if the given signature is a valid HMAC hash using the set 49 | # @secret property on this Subscriber. 50 | # 51 | # sig - String signature to verify. 52 | # data - The request data that the signature was created with. 53 | # 54 | # Returns a Boolean specifying whether the signature is valid. 55 | is_valid_signature: (sig, body) -> 56 | exports.is_valid_signature sig, @secret, body 57 | 58 | # Creates a POST request to a PuSH hub. 59 | # 60 | # mode - The String hub.mode value: "subscribe" or "unsubscribe". 61 | # cb - A Function callback. 62 | # err - An optional error object. 63 | # resp - A http.ClientResponse instance. 64 | # 65 | # Returns nothing. 66 | post_to_hub: (mode, cb) -> 67 | params = @build_hub_params(mode) 68 | data = Query.stringify params 69 | ScopedClient.create(@hub). 70 | header("content-type", "application/x-www-form-urlencoded"). 71 | post(data) cb 72 | 73 | # Assembles a Hash of params that get passed to a Hub as POST data. 74 | # 75 | # mode - The String hub.mode value: "subscribe" or "unsubscribe" 76 | # 77 | # Returns an Object with hub.* keys. 78 | build_hub_params: (mode) -> 79 | params = 80 | 'hub.mode': mode || 'subscribe' 81 | 'hub.topic': @topic 82 | 'hub.callback': @callback 83 | 'hub.verify': @verify 84 | params['hub.lease_seconds'] = @lease_seconds if @lease_seconds? 85 | params['hub.secret'] = @secret if @secret? 86 | params['hub.verify_token'] = @verify_token if @verify_token? 87 | params 88 | 89 | Subscriber.allowed_keys = [ 90 | 'callback', 'topic', 'verify', 'hub' 91 | 'lease_seconds', 'secret', 'verify_token' 92 | ] 93 | 94 | # Public: Assembles a new Subscriber instance. 95 | # 96 | # options - A property Object. 97 | # 98 | # Returns a Subscriber instance. 99 | exports.build = (options) -> 100 | new Subscriber options -------------------------------------------------------------------------------- /src/index.coffee: -------------------------------------------------------------------------------- 1 | server = require './server' 2 | client = require './client' 3 | 4 | exports.Subscription = server.Subscription 5 | exports.buildSubscription = server.build 6 | exports.handleSubscription = server.subscribe 7 | exports.client = client.build 8 | exports.is_valid_signature = client.is_valid_signature -------------------------------------------------------------------------------- /src/server.coffee: -------------------------------------------------------------------------------- 1 | Query = require 'querystring' 2 | Url = require 'url' 3 | Crypto = require 'crypto' 4 | ScopedClient = require '../vendor/scoped-http-client/lib' 5 | 6 | # Represents a single PubSubHubbub (PuSH) subscription. It is able to verify 7 | # subscription requests and publish new content to subscribers. 8 | class Subscription 9 | constructor: (data) -> 10 | Subscription.allowed_keys.forEach (key) => 11 | value = data.hub[key] 12 | @[key] = value if value? 13 | @lease_seconds = parseInt(@lease_seconds) || 0 14 | @bad_params = null 15 | 16 | # Public: Publishes the given data to the Subscription callback. If a 17 | # format option is given, automatically format the data. 18 | # 19 | # data - Either raw String data, or an Array of items to be formatted. 20 | # options - Hash of options. 21 | # format: Specifies a built-in formatter. 22 | # content_type: Specifies a content type for the request. 23 | # Formatters specify their own content type. 24 | # cb - Function callback to be called with (err, resp) when the 25 | # request is complete. 26 | # 27 | # Returns nothing. 28 | publish: (data, options, cb) -> 29 | format = Subscription.formatters[options.format] 30 | data = format data if format? 31 | data_len = data.length 32 | ctype = format?.content_type || options.content_type 33 | client = ScopedClient.create(@callback). 34 | headers( 35 | "content-type": ctype 36 | "content-length": data_len.toString() 37 | ) 38 | if @secret 39 | hmac = Crypto.createHmac 'sha1', @secret 40 | hmac.update data 41 | client = client.header 'x-hub-signature', hmac.digest('hex') 42 | client.post(data) (err, resp) => 43 | @check_response_for_success err, resp, cb 44 | 45 | # Public: Checks verification of the Subscription by passing a challenge 46 | # string and checking for the response. 47 | # 48 | # cb - A Function callback that is called when the request is finished. 49 | # err - An exception object in case there are problems. 50 | # resp - The http.ServerResponse instance. 51 | # 52 | # Returns nothing. 53 | check_verification: (cb) -> 54 | client = @verify_client() 55 | client.get() (err, resp, body) => 56 | if body != client.options.query['hub.challenge'] 57 | cb {error: "bad challenge"}, resp 58 | else 59 | @check_response_for_success err, resp, cb 60 | 61 | # Public: Checks whether this Subscription is valid according to the PuSH 62 | # spec. If the Subscription is invalid, check @bad_params for an Array of 63 | # bad hub parameters. 64 | # 65 | # refresh - Optional truthy value that that determines whether to reset 66 | # @bad_params and re-check validation. Default: true. 67 | # 68 | # Returns true if the Subscription is valid, or false. 69 | is_valid: (refresh) -> 70 | if !@bad_params || refresh 71 | @bad_params = {} 72 | @check_required_keys() 73 | @check_hub_mode() 74 | @check_urls 'topic' 75 | @check_urls 'callback' 76 | @bad_params = for key of @bad_params 77 | key 78 | @bad_params.length == 0 79 | 80 | # Creates a ScopedClient instance for making the verification request. 81 | # 82 | # Returns a ScopedClient instance. 83 | verify_client: -> 84 | client = ScopedClient.create(@callback). 85 | query( 86 | 'hub.mode': @mode 87 | 'hub.topic': @topic 88 | 'hub.lease_seconds': @lease_seconds 89 | 'hub.challenge': @generate_challenge() 90 | ) 91 | client.query('hub.verify_token', @verify_token) if @verify_token? 92 | client 93 | 94 | # Generates a unique challenge string by MD5ing the Subscription details 95 | # and the current time in milliseconds. 96 | # 97 | # Returns a String MD5 hash. 98 | generate_challenge: -> 99 | data = "#{@mode}#{@topic}#{@callback}#{@secret}#{@verify_token}#{(new Date()).getTime()}" 100 | Crypto.createHash('md5').update(data).digest("hex") 101 | 102 | # Checks whether this Subscription has the required fields for PuSH set. 103 | # 104 | # Returns nothing. 105 | check_required_keys: -> 106 | Subscription.required_keys.forEach (key) => 107 | if !@[key]? 108 | @bad_params["hub.#{key}"] = true 109 | 110 | # Checks whether this Subscription specified a valid hub request mode. 111 | # 112 | # Returns nothing. 113 | check_hub_mode: -> 114 | if Subscription.valid_modes.indexOf(@mode) < 0 115 | @bad_params["hub.mode"] = true 116 | 117 | # Checks whether the callback and topic parameters are valid URLs. 118 | # 119 | # Returns nothing. 120 | check_urls: (key) -> 121 | if value = @[key] 122 | url = Url.parse value 123 | if !(url.hostname? && url.protocol? && url.protocol.match(/^https?:$/i)) 124 | @bad_params["hub.#{key}"] = true 125 | else 126 | @bad_params["hub.#{key}"] = true 127 | 128 | # Checks the given http.ClientResponse for a 200 status. 129 | # 130 | # err - The Error object from an earlier http.Client request. 131 | # resp - The http.ClientResponse instance from an earlier request. 132 | # cb - A Function callback that is called with (err, resp). 133 | check_response_for_success: (err, resp, cb) -> 134 | if resp.statusCode.toString().match(/^2\d\d/) 135 | cb err, resp 136 | else 137 | cb {error: "bad status"}, resp 138 | 139 | Subscription.formatters = {} 140 | Subscription.valid_proto = /^https?:$/ 141 | Subscription.valid_modes = ['subscribe', 'unsubscribe'] 142 | Subscription.required_keys = ['callback', 'mode', 'topic', 'verify'] 143 | Subscription.allowed_keys = [ 144 | 'callback', 'mode', 'topic', 'verify' 145 | 'lease_seconds', 'secret', 'verify_token' 146 | ] 147 | 148 | Subscription.formatters.json = (items) -> 149 | JSON.stringify items 150 | Subscription.formatters.json.content_type = 'application/json' 151 | 152 | # Public: Points to a Subscription object. You can change this if you want 153 | # to subclass Subscription with custom logic. 154 | exports.Subscription = Subscription 155 | 156 | # Public: Assembles a new Subscription instance. 157 | # 158 | # data - A parsed QueryString object. 159 | # 160 | # Returns a Subscription instance. 161 | exports.build = (data) -> 162 | new exports.Subscription data 163 | 164 | # Public: Handles a PuSH subscription request. 165 | # 166 | # post_data - A raw String of the POST data. 167 | # 168 | # Returns a Subscription instance. 169 | exports.subscribe = (post_data) -> 170 | data = Query.parse post_data 171 | exports.build data -------------------------------------------------------------------------------- /test/example_test.coffee: -------------------------------------------------------------------------------- 1 | http = require 'http' 2 | Url = require 'url' 3 | nub = require '../src' 4 | 5 | debugging = process.env.DEBUG 6 | 7 | port = 4011 8 | subscribers = {} 9 | server = http.createServer (req, resp) -> 10 | body = '' 11 | req.on 'data', (chunk) -> body += chunk 12 | 13 | req.on 'end', -> 14 | sub = nub.handleSubscription body 15 | resp.writeHead 200 16 | resp.write(' ') 17 | resp.end() 18 | 19 | console.log "SERVER: Checking verification..." if debugging 20 | sub.check_verification (err, resp) -> 21 | if err 22 | console.log "SERVER: Error with validation:" 23 | console.log err 24 | server.close() 25 | client.close() 26 | else 27 | console.log "SERVER: Verification successful. Publishing..." if debugging 28 | sub.publish [{abc: 1}], {format: 'json'}, (err, resp) -> 29 | if err 30 | console.log "SERVER: Error with publishing:" 31 | console.log err 32 | else 33 | console.log "SERVER: Publishing successful!" if debugging 34 | server.close() 35 | client.close() 36 | 37 | client = http.createServer (req, resp) -> 38 | body = '' 39 | req.on 'data', (chunk) -> body += chunk 40 | 41 | req.on 'end', -> 42 | if req.method == 'GET' 43 | console.log "CLIENT: Receiving verification challenge..." if debugging 44 | resp.writeHead 200 45 | resp.write Url.parse(req.url, true).query.hub.challenge 46 | else 47 | console.log "CLIENT: Receiving published data..." if debugging 48 | resp.writeHead 200 49 | resp.write 'booya' 50 | resp.end() 51 | 52 | client.listen port+1 53 | 54 | client_instance = nub.client( 55 | hub: "http://localhost:#{port}/hub" 56 | topic: "http://server.com/topic" 57 | callback: "http://localhost:#{port+1}/callback" 58 | ) 59 | 60 | server.listen port, -> 61 | console.log "CLIENT: Sending subscription request..." if debugging 62 | client_instance.subscribe (err, resp) -> 63 | if err 64 | console.log "CLIENT: Error with subscription:" 65 | console.log err 66 | server.close() 67 | client.close() 68 | else 69 | console.log "CLIENT: Subscription successful!" if debugging 70 | 71 | process.on 'exit', -> 72 | console.log 'done' -------------------------------------------------------------------------------- /test/publish_test.coffee: -------------------------------------------------------------------------------- 1 | assert = require 'assert' 2 | http = require 'http' 3 | url = require 'url' 4 | query = require 'querystring' 5 | nub = require '../src' 6 | 7 | port = 9999 8 | server = http.createServer (req, resp) -> 9 | body = '' 10 | req.on 'data', (chunk) -> body += chunk 11 | 12 | req.on 'end', -> 13 | req_url = url.parse req.url, true 14 | assert.equal 'application/json', req.headers['content-type'] 15 | switch req_url.query.testing 16 | when 'json' 17 | assert.equal "[{\"abc\":1}]", body 18 | resp.writeHead 200 19 | resp.end "ok" 20 | when 'error' 21 | resp.writeHead 500 22 | resp.end() 23 | when 'secret' 24 | sig = req.headers['x-hub-signature'] 25 | st = if nub.is_valid_signature(sig, 'monkey', body) then 200 else 400 26 | resp.writeHead st 27 | resp.end() 28 | 29 | req = 30 | 'hub.callback': 'http://localhost:9999?testing=json' 31 | 'hub.mode': 'subscribe' 32 | 'hub.topic': 'http://server.com/foo' 33 | 'hub.verify': 'sync' 34 | 'hub.lease_seconds': '1000' 35 | sub = nub.handleSubscription(query.stringify(req)) 36 | 37 | calls = 2 38 | 39 | server.listen port, -> 40 | # successful publishing 41 | sub.publish [{abc: 1}], {format: 'json'}, (err, resp) -> 42 | assert.equal null, err 43 | done() 44 | 45 | # successful with raw body 46 | sub.publish "[{\"abc\":1}]", {content_type: 'application/json'}, (err, resp) -> 47 | assert.equal null, err 48 | done() 49 | 50 | # errored 51 | sub.callback = sub.callback.replace(/json/, 'error') 52 | sub.publish [{abc: 1}], {format: 'json'}, (err, resp) -> 53 | assert.equal 'bad status', err.error 54 | done() 55 | 56 | # successful with secret 57 | sub.callback = sub.callback.replace(/error/, 'secret') 58 | sub.secret = 'monkey' 59 | sub.publish [{abc: 1}], {format: 'json'}, (err, resp) -> 60 | assert.equal null, err 61 | done() 62 | 63 | # errored with secret 64 | sub.secret = 'dog' 65 | sub.publish [{abc: 1}], {format: 'json'}, (err, resp) -> 66 | assert.equal 'bad status', err.error 67 | done() 68 | 69 | done = -> 70 | calls -= 1 71 | if calls == 0 72 | server.close() 73 | 74 | process.on 'exit', -> console.log 'done' -------------------------------------------------------------------------------- /test/subscribe_test.coffee: -------------------------------------------------------------------------------- 1 | assert = require 'assert' 2 | query = require 'querystring' 3 | nub = require '../src/server' 4 | 5 | req = 6 | 'hub.callback': 'http://server.com/foo' 7 | 'hub.mode': 'subscribe' 8 | 'hub.topic': 'http://server.com/foo' 9 | 'hub.verify': 'sync' 10 | 11 | sub = nub.subscribe(query.stringify(req)) 12 | 13 | # check valid request 14 | assert.equal 'http://server.com/foo', sub.callback 15 | assert.equal 'subscribe', sub.mode 16 | assert.equal 'http://server.com/foo', sub.topic 17 | assert.equal 'sync', sub.verify 18 | assert.equal 0, sub.lease_seconds 19 | assert.equal undefined, sub.secret 20 | assert.equal undefined, sub.verify_token 21 | assert.equal true, sub.is_valid() 22 | 23 | # check lease_seconds 24 | req['hub.lease_seconds'] = '55' 25 | sub = nub.subscribe(query.stringify(req)) 26 | assert.equal 55, sub.lease_seconds 27 | assert.equal true, sub.is_valid() 28 | 29 | # missing required value 30 | req = 31 | 'hub.callback': 'http://server.com/foo' 32 | #'hub.mode': 'subscribe' 33 | 'hub.topic': 'http://server.com/foo' 34 | 'hub.verify': 'sync' 35 | sub = nub.subscribe(query.stringify(req)) 36 | assert.equal false, sub.is_valid() 37 | assert.deepEqual ['hub.mode'], sub.bad_params 38 | 39 | # check invalid mode 40 | req['hub.mode'] = 'foo' 41 | sub = nub.subscribe(query.stringify(req)) 42 | assert.equal false, sub.is_valid() 43 | assert.deepEqual ['hub.mode'], sub.bad_params 44 | 45 | # check invalid callback and topic urls 46 | req['hub.mode'] = 'unsubscribe' 47 | ['callback', 'topic'].forEach (key) -> 48 | req["hub.#{key}"] = 'foo' 49 | sub = nub.subscribe(query.stringify(req)) 50 | assert.equal false, sub.is_valid() 51 | assert.equal true, sub.bad_params.indexOf("hub.#{key}") > -1 52 | 53 | # refresh validation 54 | sub.callback = sub.topic = 'http://server.com/foo' 55 | assert.equal false, sub.is_valid() 56 | assert.equal true, sub.is_valid('refresh') 57 | 58 | console.log 'done' -------------------------------------------------------------------------------- /test/verify_test.coffee: -------------------------------------------------------------------------------- 1 | assert = require 'assert' 2 | http = require 'http' 3 | url = require 'url' 4 | query = require 'querystring' 5 | nub = require '../src/server' 6 | 7 | port = 9999 8 | server = http.createServer (req, resp) -> 9 | req_url = url.parse req.url, true 10 | 11 | switch req_url.query.testing 12 | when 'yes' 13 | resp.writeHead 200 14 | resp.write req_url.query.hub.challenge 15 | when 'no' 16 | resp.writeHead 300 17 | resp.write req_url.query.hub.challenge 18 | when 'challenge' 19 | resp.writeHead 200 20 | resp.write 'nada' 21 | 22 | resp.end() 23 | 24 | req = 25 | 'hub.callback': 'http://localhost:9999' 26 | 'hub.mode': 'subscribe' 27 | 'hub.topic': 'http://server.com/foo' 28 | 'hub.verify': 'sync' 29 | 'hub.lease_seconds': '1000' 30 | sub = nub.subscribe(query.stringify(req)) 31 | 32 | client = sub.verify_client() 33 | params = client.options.query 34 | 35 | assert.equal req['hub.mode'], params['hub.mode'] 36 | assert.equal req['hub.topic'], params['hub.topic'] 37 | assert.equal req['hub.lease_seconds'], params['hub.lease_seconds'] 38 | assert.ok params['hub.challenge'] 39 | assert.equal null, params['hub.verify_token'] 40 | challenge = params['hub.challenge'] 41 | 42 | # test params with custom verify_token value 43 | sub.verify_token = 'abc' 44 | params2 = sub.verify_client().options.query 45 | assert.equal 'abc', params2['hub.verify_token'] 46 | assert.notEqual challenge, params2['hub.challenge'] 47 | 48 | # test assembled url 49 | assert.ok client.fullPath().match(/\?hub\./) 50 | 51 | # test assembled url with existing url params 52 | sub.callback += '?testing=yes' 53 | assert.ok sub.verify_client().fullPath().match(/\?testing=yes&hub\./) 54 | 55 | server.listen port, -> 56 | sub.check_verification (err, resp) -> 57 | assert.equal null, err 58 | assert.equal 200, resp.statusCode 59 | 60 | sub.callback = 'http://localhost:9999?testing=no' 61 | sub.check_verification (err, resp) -> 62 | assert.ok err.error? 63 | assert.equal 300, resp.statusCode 64 | 65 | sub.callback = 'http://localhost:9999?testing=challenge' 66 | sub.check_verification (err, resp) -> 67 | assert.ok err.error? 68 | assert.equal 200, resp.statusCode 69 | 70 | server.close() 71 | console.log 'done' -------------------------------------------------------------------------------- /vendor/scoped-http-client/README.md: -------------------------------------------------------------------------------- 1 | # Scoped HTTP Client for Node.js 2 | 3 | [Node.js's HTTP client][client] is great, but a little too low level for 4 | common purposes. It's common practice for [some libraries][example] to 5 | extract this out so it's a bit nicer to work with. 6 | 7 | [client]: http://nodejs.org/api.html#http-client-177 8 | [example]: http://github.com/technoweenie/nori/blob/2b4b367350e5d2aed982e8af869401ab5612378c/lib/index.js#L72-76 9 | 10 | function(method, path, customHeaders, body, callback) { 11 | var client = http.createClient(...) 12 | client.request(method, path, headers) 13 | ... 14 | } 15 | 16 | I hate functions with lots of optional arguments. Let's turn that into: 17 | 18 | var scopedClient = require('./lib') 19 | , sys = require('sys') 20 | 21 | var client = scopedClient.create('http://github.com/api/v2/json') 22 | .header('accept', 'application/json') 23 | .path('user/show/technoweenie') 24 | .get()(function(err, resp, body) { 25 | sys.puts(body) 26 | }) 27 | 28 | You can scope a client to make requests with certain parameters without 29 | affecting the main client instance: 30 | 31 | client.path('/api/v2/json') // reset path 32 | client.scope('user/show/marak', function(cli) { 33 | cli.get()(function(err, resp, body) { 34 | sys.puts(body) 35 | }) 36 | }) 37 | 38 | You can use `.post()`, `.put()`, `.del()`, and `.head()`. 39 | 40 | client.query({login:'technoweenie',token:'...'}) 41 | .scope('user/show/technoweenie', function(cli) { 42 | var data = JSON.stringify({location: 'SF'}) 43 | 44 | // posting data! 45 | cli.post(data)(function(err, resp, body) { 46 | sys.puts(body) 47 | }) 48 | }) 49 | 50 | Sometimes you want to stream the request body to the server. The request 51 | is a standard [http.clientRequest][request]. 52 | 53 | client.post(function (req) { 54 | req.write(...) 55 | req.write(...) 56 | })(function(err, resp, body) { 57 | ... 58 | }) 59 | 60 | And other times, you want to stream the response from the server. Simply 61 | listen for the request's response event yourself and omit the response 62 | callback. 63 | 64 | client.get(function (err, req) { 65 | // do your own thing 66 | req.addListener('response', function (resp) { 67 | resp.addListener('data', function (chunk) { 68 | sys.puts("CHUNK: " + chunk) 69 | }) 70 | }) 71 | })() 72 | 73 | [request]: http://nodejs.org/api.html#http-clientrequest-182 74 | 75 | ## Development 76 | 77 | Run this in the main directory to compile coffeescript to javascript as you go: 78 | 79 | coffee -wc -o lib --no-wrap src/**/*.coffee -------------------------------------------------------------------------------- /vendor/scoped-http-client/lib/index.js: -------------------------------------------------------------------------------- 1 | var ScopedClient, extend, http, path, qs, sys, url; 2 | var __bind = function(func, context) { 3 | return function(){ return func.apply(context, arguments); }; 4 | }; 5 | path = require('path'); 6 | http = require('http'); 7 | sys = require('sys'); 8 | url = require('url'); 9 | qs = require('querystring'); 10 | ScopedClient = function(url, options) { 11 | this.options = this.buildOptions(url, options); 12 | return this; 13 | }; 14 | ScopedClient.prototype.request = function(method, reqBody, callback) { 15 | var client, err, headers, port, req, sendingData; 16 | if (typeof (reqBody) === 'function') { 17 | callback = reqBody; 18 | reqBody = null; 19 | } 20 | try { 21 | headers = extend({}, this.options.headers); 22 | sendingData = method.match(/^P/) && reqBody && reqBody.length > 0; 23 | headers.Host = this.options.hostname; 24 | if (sendingData) { 25 | headers['Content-Length'] = reqBody.length; 26 | } 27 | port = this.options.port || ScopedClient.defaultPort[this.options.protocol] || 80; 28 | client = http.createClient(port, this.options.hostname); 29 | req = client.request(method, this.fullPath(), headers); 30 | if (sendingData) { 31 | req.write(reqBody, 'utf-8'); 32 | } 33 | if (callback) { 34 | callback(null, req); 35 | } 36 | } catch (err) { 37 | if (callback) { 38 | callback(err, req); 39 | } 40 | err = e; 41 | } 42 | return __bind(function(callback) { 43 | if (callback) { 44 | err = null; 45 | req.on('response', function(resp) { 46 | var body; 47 | try { 48 | resp.setEncoding('utf8'); 49 | body = ''; 50 | resp.on('data', function(chunk) { 51 | return body += chunk; 52 | }); 53 | return resp.on('end', function() { 54 | return callback(err, resp, body); 55 | }); 56 | } catch (e) { 57 | return (err = e); 58 | } 59 | }); 60 | } 61 | req.end(); 62 | return this; 63 | }, this); 64 | }; 65 | ScopedClient.prototype.fullPath = function(p) { 66 | var full, search; 67 | search = qs.stringify(this.options.query); 68 | full = this.join(p); 69 | if (search.length > 0) { 70 | full += ("?" + (search)); 71 | } 72 | return full; 73 | }; 74 | ScopedClient.prototype.scope = function(url, options, callback) { 75 | var override, scoped; 76 | override = this.buildOptions(url, options); 77 | scoped = new ScopedClient(this.options).protocol(override.protocol).host(override.hostname).path(override.pathname); 78 | if (typeof (url) === 'function') { 79 | callback = url; 80 | } else if (typeof (options) === 'function') { 81 | callback = options; 82 | } 83 | if (callback) { 84 | callback(scoped); 85 | } 86 | return scoped; 87 | }; 88 | ScopedClient.prototype.join = function(suffix) { 89 | var p; 90 | p = this.options.pathname || '/'; 91 | return suffix && suffix.length > 0 ? (suffix.match(/^\//) ? suffix : path.join(p, suffix)) : p; 92 | }; 93 | ScopedClient.prototype.path = function(p) { 94 | this.options.pathname = this.join(p); 95 | return this; 96 | }; 97 | ScopedClient.prototype.query = function(key, value) { 98 | this.options.query || (this.options.query = {}); 99 | if (typeof (key) === 'string') { 100 | if (value) { 101 | this.options.query[key] = value; 102 | } else { 103 | delete this.options.query[key]; 104 | } 105 | } else { 106 | extend(this.options.query, key); 107 | } 108 | return this; 109 | }; 110 | ScopedClient.prototype.host = function(h) { 111 | if (h && h.length > 0) { 112 | this.options.hostname = h; 113 | } 114 | return this; 115 | }; 116 | ScopedClient.prototype.port = function(p) { 117 | if (p && (typeof (p) === 'number' || p.length > 0)) { 118 | this.options.port = p; 119 | } 120 | return this; 121 | }; 122 | ScopedClient.prototype.protocol = function(p) { 123 | if (p && p.length > 0) { 124 | this.options.protocol = p; 125 | } 126 | return this; 127 | }; 128 | ScopedClient.prototype.auth = function(user, pass) { 129 | if (!user) { 130 | this.options.auth = null; 131 | } else if (!pass && user.match(/:/)) { 132 | this.options.auth = user; 133 | } else { 134 | this.options.auth = ("" + (user) + ":" + (pass)); 135 | } 136 | return this; 137 | }; 138 | ScopedClient.prototype.header = function(name, value) { 139 | this.options.headers[name] = value; 140 | return this; 141 | }; 142 | ScopedClient.prototype.headers = function(h) { 143 | extend(this.options.headers, h); 144 | return this; 145 | }; 146 | ScopedClient.prototype.buildOptions = function() { 147 | var i, options, ty; 148 | options = {}; 149 | i = 0; 150 | while (arguments[i]) { 151 | ty = typeof arguments[i]; 152 | if (ty === 'string') { 153 | options.url = arguments[i]; 154 | } else if (ty !== 'function') { 155 | extend(options, arguments[i]); 156 | } 157 | i += 1; 158 | } 159 | if (options.url) { 160 | extend(options, url.parse(options.url, true)); 161 | delete options.url; 162 | delete options.href; 163 | delete options.search; 164 | } 165 | options.headers || (options.headers = {}); 166 | return options; 167 | }; 168 | ScopedClient.methods = ["GET", "POST", "PUT", "DELETE", "HEAD"]; 169 | ScopedClient.methods.forEach(function(method) { 170 | return (ScopedClient.prototype[method.toLowerCase()] = function(body, callback) { 171 | return this.request(method, body, callback); 172 | }); 173 | }); 174 | ScopedClient.prototype.del = ScopedClient.prototype['delete']; 175 | ScopedClient.defaultPort = { 176 | 'http:': 80, 177 | 'https:': 443, 178 | http: 80, 179 | https: 443 180 | }; 181 | extend = function(a, b) { 182 | var prop; 183 | prop = null; 184 | Object.keys(b).forEach(function(prop) { 185 | return (a[prop] = b[prop]); 186 | }); 187 | return a; 188 | }; 189 | exports.create = function(url, options) { 190 | return new ScopedClient(url, options); 191 | }; -------------------------------------------------------------------------------- /vendor/scoped-http-client/src/index.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | http = require 'http' 3 | sys = require 'sys' 4 | url = require 'url' 5 | qs = require 'querystring' 6 | 7 | class ScopedClient 8 | constructor: (url, options) -> 9 | @options = @buildOptions url, options 10 | 11 | request: (method, reqBody, callback) -> 12 | if typeof(reqBody) == 'function' 13 | callback = reqBody 14 | reqBody = null 15 | 16 | try 17 | headers = extend {}, @options.headers 18 | sendingData = method.match(/^P/) and reqBody and reqBody.length > 0 19 | headers.Host = @options.hostname 20 | 21 | headers['Content-Length'] = reqBody.length if sendingData 22 | 23 | port = @options.port || 24 | ScopedClient.defaultPort[@options.protocol] || 80 25 | client = http.createClient port, @options.hostname 26 | req = client.request method, @fullPath(), headers 27 | 28 | req.write reqBody, 'utf-8' if sendingData 29 | callback null, req if callback 30 | catch err 31 | callback err, req if callback 32 | 33 | (callback) => 34 | if callback 35 | resp.setEncoding 'utf8' 36 | body = '' 37 | resp.on 'data', (chunk) -> 38 | body += chunk 39 | 40 | resp.on 'end', () -> 41 | callback null, resp, body 42 | 43 | req.end() 44 | @ 45 | 46 | # Adds the query string to the path. 47 | fullPath: (p) -> 48 | search = qs.stringify @options.query 49 | full = this.join p 50 | full += "?#{search}" if search.length > 0 51 | full 52 | 53 | scope: (url, options, callback) -> 54 | override = @buildOptions url, options 55 | scoped = new ScopedClient(@options) 56 | .protocol(override.protocol) 57 | .host(override.hostname) 58 | .path(override.pathname) 59 | 60 | if typeof(url) == 'function' 61 | callback = url 62 | else if typeof(options) == 'function' 63 | callback = options 64 | callback scoped if callback 65 | scoped 66 | 67 | join: (suffix) -> 68 | p = @options.pathname || '/' 69 | if suffix and suffix.length > 0 70 | if suffix.match /^\// 71 | suffix 72 | else 73 | path.join p, suffix 74 | else 75 | p 76 | 77 | path: (p) -> 78 | @options.pathname = @join p 79 | @ 80 | 81 | query: (key, value) -> 82 | @options.query ||= {} 83 | if typeof(key) == 'string' 84 | if value 85 | @options.query[key] = value 86 | else 87 | delete @options.query[key] 88 | else 89 | extend @options.query, key 90 | @ 91 | 92 | host: (h) -> 93 | @options.hostname = h if h and h.length > 0 94 | @ 95 | 96 | port: (p) -> 97 | if p and (typeof(p) == 'number' || p.length > 0) 98 | @options.port = p 99 | @ 100 | 101 | protocol: (p) -> 102 | @options.protocol = p if p && p.length > 0 103 | @ 104 | 105 | auth: (user, pass) -> 106 | if !user 107 | @options.auth = null 108 | else if !pass and user.match(/:/) 109 | @options.auth = user 110 | else 111 | @options.auth = "#{user}:#{pass}" 112 | @ 113 | 114 | header: (name, value) -> 115 | @options.headers[name] = value 116 | @ 117 | 118 | headers: (h) -> 119 | extend @options.headers, h 120 | @ 121 | 122 | buildOptions: -> 123 | options = {} 124 | i = 0 125 | while arguments[i] 126 | ty = typeof arguments[i] 127 | if ty == 'string' 128 | options.url = arguments[i] 129 | else if ty != 'function' 130 | extend options, arguments[i] 131 | i += 1 132 | 133 | if options.url 134 | extend options, url.parse(options.url, true) 135 | delete options.url 136 | delete options.href 137 | delete options.search 138 | options.headers ||= {} 139 | options 140 | 141 | ScopedClient.methods = ["GET", "POST", "PUT", "DELETE", "HEAD"] 142 | ScopedClient.methods.forEach (method) -> 143 | ScopedClient.prototype[method.toLowerCase()] = (body, callback) -> 144 | @request method, body, callback 145 | ScopedClient.prototype.del = ScopedClient.prototype['delete'] 146 | 147 | ScopedClient.defaultPort = {'http:':80, 'https:':443, http:80, https:443} 148 | 149 | extend = (a, b) -> 150 | prop = null 151 | Object.keys(b).forEach (prop) -> 152 | a[prop] = b[prop] 153 | a 154 | 155 | exports.create = (url, options) -> 156 | new ScopedClient url, options -------------------------------------------------------------------------------- /vendor/scoped-http-client/test/get_test.coffee: -------------------------------------------------------------------------------- 1 | ScopedClient = require '../lib' 2 | http = require 'http' 3 | assert = require 'assert' 4 | called = 0 5 | 6 | server = http.createServer (req, res) -> 7 | res.writeHead 200, 'Content-Type': 'text/plain' 8 | res.end "#{req.method} #{req.url} -- hello #{req.headers['accept']}" 9 | 10 | server.listen 9999 11 | 12 | server.on 'listening', -> 13 | client = ScopedClient.create 'http://localhost:9999', 14 | headers: 15 | accept: 'text/plain' 16 | 17 | client.get() (err, resp, body) -> 18 | called++ 19 | assert.equal 200, resp.statusCode 20 | assert.equal 'text/plain', resp.headers['content-type'] 21 | assert.equal 'GET / -- hello text/plain', body 22 | client.path('/a').query('b', '1').get() (err, resp, body) -> 23 | called++ 24 | assert.equal 200, resp.statusCode 25 | assert.equal 'text/plain', resp.headers['content-type'] 26 | assert.equal 'GET /a?b=1 -- hello text/plain', body 27 | server.close() 28 | 29 | process.on 'exit', -> 30 | assert.equal 2, called -------------------------------------------------------------------------------- /vendor/scoped-http-client/test/post_test.coffee: -------------------------------------------------------------------------------- 1 | ScopedClient = require '../lib' 2 | http = require 'http' 3 | assert = require 'assert' 4 | called = 0 5 | 6 | server = http.createServer (req, res) -> 7 | body = '' 8 | req.on 'data', (chunk) -> 9 | body += chunk 10 | 11 | req.on 'end', -> 12 | res.writeHead 200, 'Content-Type': 'text/plain' 13 | res.end "#{req.method} hello: #{body}" 14 | 15 | server.listen 9999 16 | 17 | server.on 'listening', -> 18 | client = ScopedClient.create 'http://localhost:9999' 19 | client.post((err, req) -> 20 | called++ 21 | req.write 'boo', 'ascii' 22 | req.write 'ya', 'ascii' 23 | ) (err, resp, body) -> 24 | called++ 25 | assert.equal 200, resp.statusCode 26 | assert.equal 'text/plain', resp.headers['content-type'] 27 | assert.equal 'POST hello: booya', body 28 | 29 | client.post((err, req) -> 30 | req.on 'response', (resp) -> 31 | resp.on 'end', -> 32 | # opportunity to stream response differently 33 | called++ 34 | server.close() 35 | )() 36 | 37 | process.on 'exit', -> 38 | assert.equal 3, called -------------------------------------------------------------------------------- /vendor/scoped-http-client/test/request_test.coffee: -------------------------------------------------------------------------------- 1 | ScopedClient = require('../lib') 2 | http = require('http') 3 | assert = require('assert') 4 | called = 0 5 | curr = null 6 | ua = null 7 | 8 | server = http.createServer (req, res) -> 9 | body = '' 10 | req.on 'data', (chunk) -> 11 | body += chunk 12 | 13 | req.on 'end', -> 14 | curr = req.method 15 | ua = req.headers['user-agent'] 16 | respBody = "#{curr} hello: #{body} #{ua}" 17 | res.writeHead 200, 18 | 'content-type': 'text/plain', 19 | 'content-length': respBody.length 20 | 21 | res.write respBody if curr != 'HEAD' 22 | res.end() 23 | 24 | server.listen 9999 25 | 26 | server.addListener 'listening', -> 27 | client = ScopedClient.create('http://localhost:9999') 28 | .headers({'user-agent':'bob'}) 29 | client.del() (err, resp, body) -> 30 | called++ 31 | assert.equal 'DELETE', curr 32 | assert.equal 'bob', ua 33 | assert.equal "DELETE hello: bob", body 34 | client 35 | .header('user-agent', 'fred') 36 | .put('yea') (err, resp, body) -> 37 | called++ 38 | assert.equal 'PUT', curr 39 | assert.equal 'fred', ua 40 | assert.equal "PUT hello: yea fred", body 41 | client.head() (err, resp, body) -> 42 | called++ 43 | assert.equal 'HEAD', curr 44 | server.close() 45 | 46 | process.on 'exit', -> 47 | assert.equal 3, called -------------------------------------------------------------------------------- /vendor/scoped-http-client/test/url_test.coffee: -------------------------------------------------------------------------------- 1 | ScopedClient = require '../lib' 2 | assert = require 'assert' 3 | called = false 4 | 5 | client = ScopedClient.create 'http://user:pass@foo.com:81/bar/baz?a=1&b[]=2&c[d]=3' 6 | assert.equal 'http:', client.options.protocol 7 | assert.equal 'foo.com', client.options.hostname 8 | assert.equal '/bar/baz', client.options.pathname 9 | assert.equal 81, client.options.port 10 | assert.equal 'user:pass', client.options.auth 11 | assert.equal 1, client.options.query.a 12 | assert.deepEqual [2], client.options.query.b 13 | assert.deepEqual {d:3}, client.options.query.c 14 | 15 | delete client.options.query.b 16 | delete client.options.query.c 17 | client.auth('user', 'monkey').protocol('https') 18 | assert.equal 'user:monkey', client.options.auth 19 | assert.equal 'https', client.options.protocol 20 | assert.deepEqual {a:1}, client.options.query 21 | 22 | client.path('qux').auth('user:pw').port(82) 23 | assert.equal '/bar/baz/qux', client.options.pathname 24 | assert.equal 'user:pw', client.options.auth 25 | assert.equal 82, client.options.port 26 | 27 | client.query('a').host('bar.com').port(443).query('b', 2).query({c: 3}).path('/boom') 28 | assert.equal 'bar.com', client.options.hostname 29 | assert.equal 443, client.options.port 30 | assert.deepEqual {b:2, c:3}, client.options.query 31 | 32 | client.auth().host('foo.com').query('b').query('c') 33 | assert.equal null, client.options.auth 34 | assert.equal 'foo.com', client.options.hostname 35 | assert.deepEqual {}, client.options.query 36 | 37 | client.scope 'api', (scope) -> 38 | called = true 39 | assert.equal '/boom/api', scope.options.pathname 40 | assert.ok called 41 | 42 | called = false 43 | client.scope 'http://', (scope) -> 44 | called = true 45 | assert.equal 'http:', scope.options.protocol 46 | assert.equal 'foo.com', scope.options.hostname 47 | assert.equal '/boom', scope.options.pathname 48 | assert.ok called 49 | 50 | called = false 51 | client.scope 'https://bar.com', (scope) -> 52 | called = true 53 | assert.equal 'https:', scope.options.protocol 54 | assert.equal 'bar.com', scope.options.hostname 55 | assert.equal '/boom', scope.options.pathname 56 | assert.ok called 57 | 58 | called = false 59 | client.scope '/help', {protocol: 'http:'}, (scope) -> 60 | called = true 61 | assert.equal 'http:', scope.options.protocol 62 | assert.equal 'foo.com', scope.options.hostname 63 | assert.equal '/help', scope.options.pathname 64 | 65 | assert.ok called 66 | assert.equal 'https', client.options.protocol 67 | assert.equal 'foo.com', client.options.hostname 68 | assert.equal '/boom', client.options.pathname 69 | 70 | assert.equal '/boom/ok', client.fullPath('ok') 71 | assert.equal '/ok', client.fullPath('/ok') 72 | assert.equal '/boom', client.options.pathname 73 | client.options.pathname = null 74 | assert.equal '/ok', client.fullPath('ok') --------------------------------------------------------------------------------