├── demokeys.json ├── src ├── utils │ ├── empty-function.js │ ├── clone.js │ ├── load-polyfills.coffee │ ├── throw.coffee │ ├── cookies.coffee │ ├── eventbus.js │ ├── decode-hash.coffee │ ├── queue.coffee │ ├── location-origin.coffee │ ├── ui │ │ ├── captcha.html │ │ ├── platform.html │ │ ├── display-banner.coffee │ │ └── platform.css │ ├── entity.coffee │ ├── get-key.coffee │ ├── pool.coffee │ ├── promises.coffee │ ├── utils.js │ ├── dom-events.js │ ├── console-shim.coffee │ ├── set-style.coffee │ ├── query-string-encoder.coffee │ ├── logger.coffee │ ├── find-url.coffee │ ├── raven.coffee │ ├── script-loader.coffee │ ├── dom-walker.coffee │ ├── lodash.js │ ├── get-current-script.js │ ├── config-normalizer.coffee │ ├── domready.js │ ├── uuid.js │ ├── is-mobile.coffee │ ├── base64.js │ └── leaky-bucket.js ├── client │ ├── flag │ │ └── index.coffee │ ├── track │ │ ├── autotrack.coffee │ │ └── index.coffee │ ├── querystring │ │ └── index.coffee │ ├── embeds │ │ ├── sandbox.coffee │ │ ├── index.js │ │ ├── strategies │ │ │ └── js.coffee │ │ └── deployment.coffee │ ├── config-check.coffee │ ├── traits │ │ └── index.coffee │ ├── index.coffee │ ├── sharer │ │ └── index.coffee │ ├── parse-opts.coffee │ ├── current-user.coffee │ ├── api.coffee │ ├── initialize-platform.js │ ├── script-tag-config.coffee │ ├── channel.coffee │ ├── current-config.coffee │ └── auth.coffee ├── flux │ ├── constants │ │ └── RemoteConstants.js │ ├── dispatcher │ │ └── RemoteDispatcher.js │ ├── stores │ │ ├── ClientConfigStore.js │ │ ├── RemoteUserStore.js │ │ ├── RemoteConfigStore.js │ │ └── RemoteHeaderStore.js │ └── actions │ │ └── RemoteActions.js ├── remote │ ├── services │ │ ├── tumblr.coffee │ │ ├── twitter.coffee │ │ ├── soundcloud.coffee │ │ ├── list.coffee │ │ ├── generic-service.coffee │ │ ├── angellist.coffee │ │ ├── google.coffee │ │ ├── linkedin.coffee │ │ ├── github.coffee │ │ ├── instagram.coffee │ │ ├── admin.coffee │ │ ├── track.coffee │ │ ├── hull.coffee │ │ └── facebook.coffee │ ├── wrapped-request.js │ ├── channel.coffee │ ├── services.coffee │ └── gateway.coffee ├── polyfills │ ├── assign.js │ └── xhr-xdr.js ├── hull-remote.coffee ├── hull.coffee └── styles │ └── style.scss ├── .gitignore ├── .travis.yml ├── .babelrc ├── scripts └── sentry_release.sh ├── .github ├── issue_template.md └── pull_request_template.md ├── app ├── app.example.js └── index.html ├── manifest.json ├── .eslintrc ├── .editorconfig ├── circle.yml ├── test_utils └── preprocessor.js ├── __tests__ └── utils │ ├── analytics-id.coffee │ └── trait-test.js ├── LICENSE ├── __mocks__ └── superagent.js ├── README.md ├── package.json ├── webpack.config.js ├── config.js └── gulpfile.js /demokeys.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "54dba72a8bca245d19000001", 3 | "orgUrl": "https://ships-demos.hullbeta.io" 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/empty-function.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function emptyFunction() {} 4 | 5 | module.exports = emptyFunction; 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env.sh 3 | /lib 4 | app/*html 5 | app/app.js 6 | bower_components 7 | node_modules 8 | npm-debug.log 9 | .idea 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | before_script: 5 | - npm install -g gulp 6 | branches: 7 | only: 8 | - master 9 | -------------------------------------------------------------------------------- /src/utils/clone.js: -------------------------------------------------------------------------------- 1 | module.exports = function(obj){ 2 | if (obj === undefined){ return obj; } 3 | return JSON.parse(JSON.stringify(obj)) 4 | } 5 | -------------------------------------------------------------------------------- /src/client/flag/index.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (api)-> 2 | (id)-> 3 | api 4 | provider: "hull" 5 | path: [id, 'flag'].join('/') 6 | ,'post' 7 | -------------------------------------------------------------------------------- /src/utils/load-polyfills.coffee: -------------------------------------------------------------------------------- 1 | require "../polyfills/xhr-xdr" #TODO : Test if we can remove this now it's in Polyfill service 2 | require "../utils/console-shim" 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": ["transform-es3-member-expression-literals", "babel-plugin-transform-es3-property-literals"] 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/throw.coffee: -------------------------------------------------------------------------------- 1 | logger = require './logger' 2 | Raven = require './raven' 3 | 4 | module.exports = (err)-> 5 | Raven.captureException(err) 6 | logger.error err.message, err.stack 7 | -------------------------------------------------------------------------------- /src/utils/cookies.coffee: -------------------------------------------------------------------------------- 1 | Cookies = require 'cookies-js' 2 | COOKIES_ENABLED = '_ce' 3 | module.exports = 4 | set: Cookies.set 5 | get: Cookies.get 6 | remove: Cookies.expire 7 | enabled: -> Cookies._areEnabled() 8 | 9 | -------------------------------------------------------------------------------- /src/utils/eventbus.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter2'; 2 | var emitter = new EventEmitter({ 3 | wildcard: true, 4 | maxListeners: 200, 5 | newListener: false, 6 | delimiter: '.' 7 | }); 8 | module.exports = emitter; 9 | -------------------------------------------------------------------------------- /scripts/sentry_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -n "$SENTRY_URL" ]; then 3 | curl -v $SENTRY_URL -X POST -H 'Content-Type: application/json' -d '{"version":"'"$CIRCLE_SHA1"'", "ref":"'"$CIRCLE_SHA1"'"}' 4 | else 5 | echo "SENTRY_URL is not set" 6 | fi 7 | -------------------------------------------------------------------------------- /src/utils/decode-hash.coffee: -------------------------------------------------------------------------------- 1 | Base64 = require '../utils/base64' 2 | module.exports = ()-> 3 | try 4 | h = document.location.hash.replace('#', '') 5 | hash = JSON.parse(Base64.decode(h)) if !!h 6 | catch e 7 | hash = null 8 | return hash 9 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ## Expected Behavior 2 | 3 | 4 | ## Actual Behavior 5 | 6 | 7 | ## Steps to Reproduce the Problem 8 | 9 | 1. 10 | 1. 11 | 1. 12 | 13 | ## Specifications 14 | 15 | - Version: 16 | - Platform: 17 | - Subsystem: 18 | -------------------------------------------------------------------------------- /src/utils/queue.coffee: -------------------------------------------------------------------------------- 1 | Queue = ()-> 2 | _open = false 3 | _cbs = [] 4 | run: (cb)-> 5 | if _open 6 | cb() 7 | else 8 | _cbs.push cb 9 | flush: ()-> 10 | _open = true 11 | cb() for cb in _cbs 12 | module.exports = Queue 13 | -------------------------------------------------------------------------------- /app/app.example.js: -------------------------------------------------------------------------------- 1 | Hull.init({ 2 | platformId: 'YOUR_PLATFORM_ID', 3 | orgUrl: 'http://your.app.domain.tld', 4 | // jsUrl: 'http://your.local.dev/' 5 | // name: "name your app", 6 | debug: true 7 | }); 8 | 9 | Hull.ready(function(hull, me, platform, org){ 10 | }) 11 | -------------------------------------------------------------------------------- /src/utils/location-origin.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = -> 3 | return window.location.origin if window.location.origin 4 | port = '' 5 | port = ':' + window.location.port if window.location.port 6 | origin = window.location.protocol + "//" + window.location.hostname + port 7 | origin 8 | -------------------------------------------------------------------------------- /src/utils/ui/captcha.html: -------------------------------------------------------------------------------- 1 |
2 |

A lot of users have been created with your IP!

3 |

Please prove us that you are a Human.

4 |
5 |
6 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "Hull.js", 3 | "index": "dist/index.html", 4 | "version": "0.10.0", 5 | "index": "index.html", 6 | "demoKeys":{ 7 | "appId": "52fb86bedea4dfd8de000003", 8 | "orgUrl": "https://super.hullbeta.io" 9 | }, 10 | "settings": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | "quotes": [2, "single"], 9 | "key-spacing" : [0], 10 | "no-multi-spaces": [0], 11 | "no-mixed-requires": [0], 12 | "no-underscore-dangle": [0] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/flux/constants/RemoteConstants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | UPDATE_CLIENT_CONFIG:'UPDATE_CLIENT_CONFIG', 3 | UPDATE_REMOTE_CONFIG:'UPDATE_REMOTE_CONFIG', 4 | UPDATE_SERVICES:'UPDATE_SERVICES', 5 | 6 | SET_HEADER:'SET_HEADER', 7 | 8 | LOGOUT_USER:'LOGOUT_USER', 9 | UPDATE_USER:'UPDATE_USER' 10 | }; 11 | -------------------------------------------------------------------------------- /src/remote/services/tumblr.coffee: -------------------------------------------------------------------------------- 1 | GenericService = require './generic-service' 2 | 3 | class TumblrService extends GenericService 4 | name : 'twitter' 5 | path: 'tumblr/v2' 6 | constructor: (config, gateway)-> 7 | super(config,gateway) 8 | @request = @wrappedRequest 9 | 10 | module.exports = TumblrService 11 | -------------------------------------------------------------------------------- /src/flux/dispatcher/RemoteDispatcher.js: -------------------------------------------------------------------------------- 1 | var flux = require('flux'); 2 | var assign = require('../../polyfills/assign'); 3 | 4 | 5 | var RemoteDispatcher = assign(new flux.Dispatcher(), { 6 | handleAction: function(action) { 7 | this.dispatch({action:action}); 8 | }, 9 | }); 10 | 11 | module.exports = RemoteDispatcher; 12 | -------------------------------------------------------------------------------- /src/remote/services/twitter.coffee: -------------------------------------------------------------------------------- 1 | GenericService = require './generic-service' 2 | 3 | class TwitterService extends GenericService 4 | name : 'twitter' 5 | path: 'twitter/1.1' 6 | constructor: (config, gateway)-> 7 | super(config,gateway) 8 | @request = @wrappedRequest 9 | 10 | module.exports = TwitterService 11 | -------------------------------------------------------------------------------- /src/remote/services/soundcloud.coffee: -------------------------------------------------------------------------------- 1 | GenericService = require './generic-service' 2 | 3 | class SoundCloudService extends GenericService 4 | name : 'soundcloud' 5 | path: 'soundcloud' 6 | constructor: (config, gateway)-> 7 | super(config,gateway) 8 | @request = @wrappedRequest 9 | 10 | module.exports = SoundCloudService 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; EditorConfig helps developers define and maintain consistent 2 | ; coding styles between different editors and IDEs 3 | ; editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | -------------------------------------------------------------------------------- /src/client/track/autotrack.coffee: -------------------------------------------------------------------------------- 1 | Base64 = require '../../utils/base64' 2 | 3 | module.exports = (headers, emitter)-> 4 | hullTrack = headers['Hull-Track'] 5 | if hullTrack 6 | try 7 | [eventName, trackParams] = JSON.parse(Base64.decode(hullTrack)) 8 | emitter.emit(eventName, trackParams) 9 | catch error 10 | false 11 | -------------------------------------------------------------------------------- /src/utils/entity.coffee: -------------------------------------------------------------------------------- 1 | Base64 = require './base64.js' 2 | 3 | module.exports = 4 | decode: (input)-> 5 | unless /^~[a-z0-9_\-\+\/\=]+$/i.test(input) 6 | throw "'#{input}' cannot be decoded because it has not been correctly encoded" 7 | 8 | Base64.decodeURL(input.substr(1)) 9 | 10 | encode: (input)-> 11 | "~#{Base64.encodeURL(input)}" 12 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8.12 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | - run: yarn install 10 | - run: ./node_modules/.bin/gulp deploy:release 11 | - run: ./scripts/sentry_release.sh 12 | branches: 13 | only: 14 | - release/0.10.0 15 | -------------------------------------------------------------------------------- /src/utils/ui/platform.html: -------------------------------------------------------------------------------- 1 |
2 |

Congratulations! Hull is ready.

3 |

Your snippet is correctly installed and configured
This banner is only visible to you.

4 | Close Window 5 |
6 | -------------------------------------------------------------------------------- /src/utils/get-key.coffee: -------------------------------------------------------------------------------- 1 | clone = require './clone' 2 | _ = require './lodash' 3 | 4 | module.exports = (hash, key)-> 5 | return hash unless key and _.isObject(hash) 6 | _.each key.split('.'), (k)-> 7 | return hash if hash == undefined 8 | if _.includes(_.keys(hash), k) 9 | hash = hash[k] 10 | else 11 | hash = undefined 12 | 13 | clone(hash) 14 | -------------------------------------------------------------------------------- /src/utils/pool.coffee: -------------------------------------------------------------------------------- 1 | _pool = _pool || {} 2 | createPool = (name)-> 3 | _pool[name] ?= [] 4 | (args...)-> _pool[name].push args 5 | deletePool = (name)-> 6 | delete _pool[name] 7 | run = (name, obj)-> 8 | obj[name](data...) for data in _pool[name] 9 | deletePool(name) 10 | 11 | module.exports={ 12 | create:createPool, 13 | delete:deletePool, 14 | run:run 15 | } 16 | -------------------------------------------------------------------------------- /src/polyfills/assign.js: -------------------------------------------------------------------------------- 1 | Object.assign = Object.assign || function assign(target, source) { 2 | for (var index = 1, key; index in arguments; ++index) { 3 | source = arguments[index]; 4 | 5 | for (key in source) { 6 | if (Object.prototype.hasOwnProperty.call(source, key)) { 7 | target[key] = source[key]; 8 | } 9 | } 10 | } 11 | return target; 12 | }; 13 | module.exports = Object.assign 14 | -------------------------------------------------------------------------------- /src/utils/promises.coffee: -------------------------------------------------------------------------------- 1 | Promise = require 'bluebird/js/browser/bluebird.core' 2 | 3 | Promise.config({ 4 | warnings: false, 5 | longStackTraces: true, 6 | cancellation: true, 7 | monitoring: true 8 | }) 9 | 10 | Promise.deferred = Promise.deferred || -> 11 | dfd = {} 12 | dfd.promise = new Promise (resolve, reject)=> 13 | dfd.resolve = resolve 14 | dfd.reject = reject 15 | dfd 16 | 17 | module.exports = Promise 18 | -------------------------------------------------------------------------------- /test_utils/preprocessor.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* global require, module */ 3 | 4 | var CoffeeScript = require("coffee-script"); 5 | var Babel = require("../node_modules/babel-jest"); 6 | 7 | module.exports = { 8 | process: function(src, path) { 9 | if (CoffeeScript.helpers.isCoffee(path)) { 10 | return CoffeeScript.compile(src, {"bare": true}); 11 | } else { 12 | return Babel.process(src, path); 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cookies : require("cookies-js"), 3 | eventemitter2 : require("eventemitter2"), 4 | assign : require("../polyfills/assign"), 5 | _ : require("./lodash"), 6 | isMobile : require("./is-mobile"), 7 | uuid : require("./uuid"), 8 | domready : require("./domready"), 9 | Promise : require("./promises"), 10 | superagent : require("superagent") 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/dom-events.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export function addEvent(node, eventName, handler) { 4 | if (node.attachEvent) { 5 | node.attachEvent(`on${eventName}`, handler); 6 | } else { 7 | node.addEventListener(eventName, handler, false); 8 | } 9 | } 10 | 11 | export function removeEvent(node, eventName, handler) { 12 | if (node.detachEvent) { 13 | node.detachEvent(`on${eventName}`, handler); 14 | } else { 15 | node.removeEventListener(eventName, handler, false); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/remote/services/list.coffee: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | admin : require './admin' 3 | track : require './track' 4 | hull : require './hull' 5 | angellist : require './angellist' 6 | facebook : require './facebook' 7 | github : require './github' 8 | google : require './google' 9 | instagram : require './instagram' 10 | linkedin : require './linkedin' 11 | soundcloud : require './soundcloud' 12 | tumblr : require './tumblr' 13 | twitter : require './twitter' 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/console-shim.coffee: -------------------------------------------------------------------------------- 1 | methods = ["assert", "clear", "count", "debug", "dir", "dirxml", "error", "exception", "group", "groupCollapsed", "groupEnd", "info", "log", "markTimeline", "profile", "profileEnd", "markTimeline", "table", "time", "timeEnd", "timeStamp", "trace", "warn"] 2 | 3 | unless window.console and console.log 4 | (-> 5 | noop = ()-> 6 | console = window.console = {} 7 | console[method] = noop for method in methods 8 | )() 9 | else if typeof console.log=='object' 10 | log = console.log 11 | console.log = (args...)-> log(args) 12 | -------------------------------------------------------------------------------- /src/utils/set-style.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../utils/lodash' 2 | 3 | 4 | getDocHeight = (doc)-> 5 | return unless doc 6 | body = doc.body 7 | html = doc.documentElement 8 | Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight); 9 | 10 | setDimension = (el, dim, val)-> 11 | if val? 12 | val = "#{val}px" if /[0-9]+$/.test(val.toString()) 13 | el.style[dim] = val 14 | 15 | module.exports = (el,style) -> 16 | return unless _.isObject(style) 17 | _.map style, (value,key)-> setDimension(el,key,value) 18 | -------------------------------------------------------------------------------- /src/utils/query-string-encoder.coffee: -------------------------------------------------------------------------------- 1 | Qs = require('qs'); 2 | 3 | keywords = 4 | true: true 5 | false: false 6 | null: null 7 | undefined: undefined 8 | 9 | decoder = (value) -> 10 | return parseFloat(value) if (/^(\d+|\d*\.\d+)$/.test(value)) 11 | return keywords[value] if (value of keywords) 12 | return value 13 | 14 | 15 | module.exports = 16 | encode : (data)-> 17 | Qs.stringify(data) 18 | decode : (search)-> 19 | return Qs.parse( 20 | window.location.search.slice(1), 21 | decoder: decoder 22 | ) unless search 23 | Qs.parse(search) 24 | -------------------------------------------------------------------------------- /src/remote/services/generic-service.coffee: -------------------------------------------------------------------------------- 1 | RemoteConfigStore = require '../../flux/stores/RemoteConfigStore' 2 | getWrappedRequest = require '../wrapped-request' 3 | jsonp = require 'browser-jsonp' 4 | 5 | class GenericService 6 | name : null 7 | path : null 8 | constructor : (config,gateway)-> 9 | @config = config 10 | @gateway = gateway 11 | @wrappedRequest = getWrappedRequest({name:@name,path:@path},gateway) 12 | 13 | getSettings: (provider)-> RemoteConfigStore.getAuth(@name||provider) 14 | 15 | request_jsonp : (request)=> jsonp(request) 16 | 17 | module.exports = GenericService 18 | -------------------------------------------------------------------------------- /__tests__/utils/analytics-id.coffee: -------------------------------------------------------------------------------- 1 | jest 2 | .dontMock "../../src/utils/analytics-id" 3 | .dontMock "../../src/utils/uuid" 4 | .dontMock "../../src/utils/cookies" 5 | .dontMock "cookies-js" 6 | 7 | describe "analyticsJS IDs", ()-> 8 | analyticsId = require "../../src/utils/analytics-id" 9 | it "Returns the same browser ID when called twice", ()-> 10 | browserId1 = analyticsId.getBrowserId() 11 | browserId2 = analyticsId.getBrowserId() 12 | expect(browserId1).toBe browserId2 13 | 14 | it "Returns the same session ID when called twice", ()-> 15 | browserId1 = analyticsId.getSessionId() 16 | browserId2 = analyticsId.getSessionId() 17 | expect(browserId1).toBe browserId2 18 | -------------------------------------------------------------------------------- /src/utils/logger.coffee: -------------------------------------------------------------------------------- 1 | enabled = false 2 | verbose = false 3 | 4 | log = (args...)-> 5 | # TODO : 6 | # Dont use spread if we want IE8: It doesnt' treat console.log as a real function hence can't do console.log.apply. 7 | # Duh... 8 | console.log(args...) 9 | 10 | error = (args...)-> 11 | # TODO : 12 | # Dont use spread if we want IE8: It doesnt' treat console.log as a real function hence can't do console.log.apply. 13 | # Duh... 14 | console.error(args...) 15 | 16 | module.exports = { 17 | enabled: ()-> 18 | return !!enabled 19 | init: (debug)-> 20 | enabled = !!debug.enabled 21 | verbose = !!debug.verbose 22 | log: (args...)-> 23 | log(args...) if enabled 24 | info: (args...)-> 25 | log(args...) if enabled 26 | verbose: (args...)-> 27 | log(args...) if enabled and verbose 28 | warn: (args...)-> 29 | console.warn(args...) 30 | error: error 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/find-url.coffee: -------------------------------------------------------------------------------- 1 | _ = require './lodash' 2 | domWalker = require './dom-walker' 3 | 4 | getDOMUrl = (sourceNode)-> 5 | return unless domWalker.isNode(sourceNode) 6 | body = document.body 7 | node = sourceNode 8 | while node and node != body 9 | # Find link on self 10 | # or find first link from siblings 11 | link = domWalker.getLinkValue(node) 12 | unless link 13 | matchingNode = _.find(domWalker.getSiblings(node), domWalker.getLinkValue) 14 | link = domWalker.getLinkValue matchingNode 15 | 16 | # If we found something, return. 17 | return link if link? 18 | 19 | # Else, walk up 20 | node = node.parentNode 21 | 22 | getPageUrl = ()-> 23 | window.location.href 24 | 25 | getOGUrl = ()-> 26 | domWalker.getMetaValue('og:url') 27 | 28 | module.exports = (node)-> 29 | u = getDOMUrl(node) || getOGUrl() || getPageUrl() 30 | u.replace(/[#\/]*$/, '') 31 | 32 | -------------------------------------------------------------------------------- /src/remote/services/angellist.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../../polyfills/assign' 2 | GenericService = require './generic-service' 3 | 4 | class AngelListService extends GenericService 5 | name : 'angellist' 6 | path : 'angellist' 7 | constructor: (config, gateway)-> 8 | 9 | request: (request, callback, errback)-> 10 | {method, path, params} = request 11 | method = method.toLowerCase() 12 | return errback('Unable to perform non-GET requests on AngelList') unless method=='get' 13 | 14 | token = @getSettings() 15 | path = path.substring(1) if (path[0] == "/") 16 | 17 | params = 18 | url : "https://api.linkedin.com/v1/#{path}" 19 | data : assign({}, params, {access_token : @token.access_token}) 20 | error: (err)-> errback(err.url) 21 | success: (response)-> 22 | callback 23 | body: response.data 24 | provider: @name 25 | @request_jsonp(params) 26 | 27 | 28 | module.exports = AngelListService 29 | -------------------------------------------------------------------------------- /src/utils/raven.coffee: -------------------------------------------------------------------------------- 1 | script = require "./script-loader" 2 | _ = require './lodash' 3 | pending = [] 4 | userContext = {} 5 | 6 | 7 | 8 | module.exports = 9 | init: (dsn, context)-> 10 | if dsn && !window.Raven 11 | script(src: 'https://cdn.ravenjs.com/2.1.1/raven.min.js').then -> 12 | window.Raven.config(dsn, { 13 | release: REVISION, 14 | whitelistUrls: [/hull/] 15 | }).install() 16 | window.Raven.setExtraContext(context) 17 | window.Raven.setUserContext(userContext) 18 | _.map pending.splice(0), (e)-> 19 | window.Raven.captureException(e.err, e.ctx) 20 | 21 | setUserContext: (ctx)-> 22 | if window.Raven && window.Raven.setUserContext 23 | window.Raven.setUserContext(ctx) 24 | else 25 | userContext = ctx 26 | 27 | captureException: (err, ctx)-> 28 | if window.Raven && window.Raven.captureException 29 | window.Raven.captureException(err, ctx) 30 | else 31 | pending.push({ err: err, ctx: ctx }) 32 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description of the change 2 | 3 | > Description here 4 | 5 | ## Type of change 6 | - [ ] Bug fix (non-breaking change that fixes an issue) 7 | - [ ] New feature (non-breaking change that adds functionality) 8 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 9 | 10 | ## Related issues 11 | 12 | > Fix [#1]() 13 | 14 | ## Checklists 15 | 16 | ### Development 17 | 18 | - [ ] Lint rules pass locally 19 | - [ ] The code changed/added as part of this pull request has been covered with tests 20 | - [ ] All tests related to the changed code pass in development 21 | 22 | ### Code review 23 | 24 | - [ ] This pull request has a descriptive title and information useful to a reviewer. There may be a screenshot or screencast attached 25 | - [ ] "Ready for review" label attached to the PR and reviewers mentioned in a comment 26 | - [ ] Changes have been reviewed by at least one other engineer 27 | - [ ] Issue from task tracker has a link to this pull request 28 | -------------------------------------------------------------------------------- /src/remote/services/google.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../../polyfills/assign' 2 | GenericService = require './generic-service' 3 | 4 | class GoogleService extends GenericService 5 | name : 'google' 6 | path: 'google' 7 | 8 | constructor: (config, gateway)-> super(config,gateway) 9 | 10 | request : (request,callback,errback)=> 11 | token = @getSettings().credentials?.token 12 | return errback('No Google+ User') unless token? 13 | 14 | {method, path, params} = request 15 | method = method.toLowerCase() 16 | return errback('Unable to perform non-GET requests on Google+') unless method=='get' 17 | path = path.substring(1) if (path[0] == "/") 18 | 19 | params = 20 | url : "https://www.googleapis.com/plus/v1/#{path}" 21 | data : assign({}, params, {access_token : token}) 22 | error: (err)-> errback(err.url) 23 | success: (response)=> 24 | callback 25 | provider: @name 26 | body: response 27 | @request_jsonp(params) 28 | 29 | module.exports = GoogleService 30 | -------------------------------------------------------------------------------- /src/remote/services/linkedin.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../../polyfills/assign' 2 | GenericService = require './generic-service' 3 | 4 | class LinkedInService extends GenericService 5 | name : 'linkedin' 6 | path: 'linkedin' 7 | 8 | constructor: (config, gateway)-> super(config,gateway) 9 | 10 | request : (request,callback,errback)=> 11 | token = @getSettings().credentials?.token 12 | return errback('No Likedin User') unless token? 13 | 14 | {method, path, params} = request 15 | method = method.toLowerCase() 16 | return errback('Unable to perform non-GET requests on Likedin') unless method=='get' 17 | path = path.substring(1) if (path[0] == "/") 18 | 19 | params = 20 | url : "https://api.linkedin.com/v1/#{path}" 21 | data : assign({}, params, {oauth2_access_token : token}) 22 | error: (err)-> errback(err.url) 23 | success: (response)=> 24 | callback 25 | provider: @name 26 | body: response 27 | @request_jsonp(params) 28 | 29 | module.exports = LinkedInService 30 | -------------------------------------------------------------------------------- /src/utils/ui/display-banner.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../../utils/lodash' 2 | platform = require 'html!./platform.html' 3 | captcha = require 'html!./captcha.html' 4 | 5 | platformCss = require 'raw!inline-style!./platform.css' 6 | 7 | banners = 8 | platform: 9 | html: platform, 10 | css: platformCss 11 | captcha: 12 | html: captcha 13 | css: platformCss 14 | 15 | module.exports = (banner, root = document.body)-> 16 | node = document.createElement('div'); 17 | html = banners[banner]?.html 18 | css = banners[banner]?.css 19 | node.innerHTML = html 20 | 21 | for selector, style of css 22 | elements = node.querySelectorAll(selector) 23 | element.setAttribute('style', style) for element in elements 24 | 25 | a = node.getElementsByTagName('a') 26 | _.each a, (element)-> 27 | intent = element.getAttribute('data-hull-intent') 28 | element.addEventListener 'click',(e)-> 29 | e.stopPropagation() 30 | e.preventDefault() 31 | Hull.emit(intent) 32 | 33 | root.appendChild(node) if root 34 | 35 | node 36 | -------------------------------------------------------------------------------- /src/remote/services/github.coffee: -------------------------------------------------------------------------------- 1 | GenericService = require './generic-service' 2 | superagent = require 'superagent' 3 | QSEncoder = require '../../utils/query-string-encoder' 4 | 5 | class GithubService extends GenericService 6 | name : 'github' 7 | path : 'github' 8 | 9 | constructor: (config, gateway)-> super(config,gateway) 10 | 11 | request : (request,callback,errback)=> 12 | token = @getSettings().credentials?.token 13 | 14 | {method, path, params} = request 15 | method = method.toUpperCase() 16 | path = path.substring(1) if (path[0] == "/") 17 | 18 | url = "https://api.github.com/#{path}" 19 | 20 | s = superagent(method, url) 21 | if (method=='GET' and params?) then s.query(QSEncoder.encode(params)) else s.send(params) 22 | s.set('Authorization', "token #{token}") if token 23 | s.end (err, response)-> 24 | return errback(response.error.message) if response.error 25 | callback 26 | provider: @name 27 | headers: response.headers 28 | body: response.body 29 | 30 | module.exports = GithubService 31 | -------------------------------------------------------------------------------- /src/client/querystring/index.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../../utils/lodash' 2 | qs = require '../../utils/query-string-encoder' 3 | 4 | pick = (prefix, obj) -> _.reduce obj, (m, v, k) -> 5 | m[k.replace(prefix,'')] = v if k.indexOf(prefix) == 0 6 | m 7 | , {} 8 | 9 | class QueryString 10 | constructor : (traits, track, alias, currentUser)-> 11 | @alias = alias 12 | @currentUser = currentUser 13 | @traits = traits 14 | @track = track 15 | @parse() 16 | 17 | getCurrentUserId: -> @currentUser.get('id') 18 | 19 | parse: () => 20 | q = qs.decode() 21 | return unless _.size(q) 22 | 23 | { hjs_email, hjs_uid, hjs_event, hjs_aid } = q 24 | 25 | @alias(hjs_aid) if hjs_aid 26 | 27 | traits = pick('hjs_trait_', q) 28 | @traits(traits) if _.size(traits) 29 | 30 | @traits({ email: { operation: "setIfNull", value: hjs_email} }) if hjs_email 31 | 32 | attrs = pick('hjs_attr_', q) 33 | @traits(attrs) if _.size(attrs) 34 | 35 | props = pick('hjs_prop_', q) 36 | @tracker.track(hjs_event, props) if hjs_event 37 | 38 | 39 | module.exports = QueryString 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 hull 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/flux/stores/ClientConfigStore.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var assign = require('../../polyfills/assign'); 3 | var RemoteDispatcher = require('../dispatcher/RemoteDispatcher'); 4 | var RemoteConstants = require('../constants/RemoteConstants'); 5 | 6 | var CHANGE_EVENT = 'change'; 7 | 8 | var state = {}; 9 | 10 | var ClientConfigStore = assign({}, EventEmitter.prototype, { 11 | emitChange : function() {this.emit(CHANGE_EVENT); }, 12 | addChangeListener : function(callback) {this.on(CHANGE_EVENT, callback); }, 13 | removeChangeListener: function(callback) {this.removeListener(CHANGE_EVENT, callback); }, 14 | getState : function() {return state;}, 15 | 16 | dispatcherIndex: RemoteDispatcher.register(function(payload) { 17 | var action = payload.action; 18 | var text; 19 | 20 | switch(action.actionType) { 21 | case RemoteConstants.UPDATE_CLIENT_CONFIG: 22 | state = action.config 23 | ClientConfigStore.emitChange(); 24 | break; 25 | } 26 | return true; 27 | }) 28 | 29 | }); 30 | 31 | module.exports = ClientConfigStore; 32 | -------------------------------------------------------------------------------- /src/remote/services/instagram.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../../polyfills/assign' 2 | GenericService = require './generic-service' 3 | 4 | class InstagramService extends GenericService 5 | name : 'instagram' 6 | path: 'instagram' 7 | 8 | constructor: (config, gateway)-> super(config,gateway) 9 | 10 | request : (request,callback,errback)=> 11 | config = @getSettings() 12 | return errback('No Likedin User') unless token? 13 | 14 | {method, path, params} = request 15 | method = method.toLowerCase() 16 | path = path.substring(1) if (path[0] == "/") 17 | if method != 'get' 18 | return @wrappedRequest(request, callback, errback) 19 | else 20 | params = 21 | url : "https://api.instagram.com/v1/#{path}" 22 | data : assign({}, params, config) 23 | error: (err)-> errback(err.url) 24 | success: (response)-> 25 | callback 26 | provider: @name 27 | body: response.data 28 | headers: 29 | pagination: response.pagination 30 | meta: response.meta 31 | @request_jsonp(params) 32 | 33 | module.exports = InstagramService 34 | -------------------------------------------------------------------------------- /src/utils/script-loader.coffee: -------------------------------------------------------------------------------- 1 | Promise = require './promises' 2 | _ = require './lodash' 3 | logger = require './logger' 4 | 5 | module.exports = (opts={})-> 6 | doc = opts.document || window.document 7 | sc = document.createElement "script" 8 | new Promise (resolve, reject)-> 9 | # Reject loading polyfills after 10 seconds, 10 | 11 | errorTimeout = setTimeout ()-> 12 | error = new Error("Error loading #{opts.src}.\nConnectivity issue?") 13 | logger.error(error, error.message, error.stack) 14 | reject(error); 15 | , 60000 16 | 17 | if opts.attributes 18 | _.map opts.attributes, (value, key)-> sc.setAttribute(key, value) 19 | 20 | sc.id = opts.id if opts.id 21 | 22 | sc.src = opts.src 23 | done = false 24 | 25 | # http://stackoverflow.com/questions/4845762/onload-handler-for-script-tag-in-internet-explorer 26 | sc.onload = sc.onreadystatechange = () -> 27 | if !done && (!@readyState || @readyState == "loaded" || @readyState == "complete") 28 | clearTimeout(errorTimeout) 29 | done = true; 30 | resolve(sc) 31 | # Handle memory leak in IE 32 | sc.onload = sc.onreadystatechange = null; 33 | 34 | doc.getElementsByTagName('head')[0]?.appendChild(sc) 35 | -------------------------------------------------------------------------------- /src/utils/dom-walker.coffee: -------------------------------------------------------------------------------- 1 | _ = require './lodash' 2 | 3 | # Returns Link in either data-hull-link='...' or a href='...' 4 | getLinkValue = (node)-> 5 | return unless node? 6 | node.dataset?.hullLink || (node.nodeName=='A' && node.href) 7 | 8 | # Returns a Meta Tag 9 | getMetaValue = (name)-> 10 | metas = document.getElementsByTagName('meta'); 11 | meta = _.find metas, (meta)-> meta.getAttribute('property')==name || meta.getAttribute('name')==name 12 | return unless meta? 13 | meta.content 14 | 15 | # Returns true if it is a DOM node 16 | # http://stackoverflow.com/questions/384286/javascript-isdom-how-do-you-check-if-a-javascript-object-is-a-dom-object 17 | isNode = (o)-> 18 | if typeof Node == "object" then o instanceof Node else o and typeof o == "object" and typeof o.nodeType == "number" and typeof o.nodeName=="string" 19 | 20 | getChildren = (n, skipMe)-> 21 | r = [] 22 | while n and n = n.nextSibling 23 | r.push n if n.nodeType == 1 and n != skipMe 24 | r 25 | 26 | getSiblings = (n) -> 27 | getChildren(n.parentNode.firstChild, n) 28 | 29 | module.exports = { 30 | getSiblings : getSiblings 31 | getChildren : getChildren 32 | getMetaValue : getMetaValue 33 | getLinkValue : getLinkValue 34 | isNode : isNode 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/lodash.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* global require */ 3 | 4 | module.exports = { 5 | compact: require('lodash/compact'), 6 | uniq: require('lodash/uniq'), 7 | size: require('lodash/size'), 8 | includes: require('lodash/includes'), 9 | reject: require('lodash/reject'), 10 | toArray: require('lodash/toArray'), 11 | defaults: require('lodash/defaults'), 12 | isElement: require('lodash/isElement'), 13 | each: require('lodash/each'), 14 | partition: require('lodash/partition'), 15 | every: require('lodash/every'), 16 | indexOf: require('lodash/indexOf'), 17 | isEmpty: require('lodash/isEmpty'), 18 | throttle: require('lodash/throttle'), 19 | isArray: require('lodash/isArray'), 20 | isEqual: require('lodash/isEqual'), 21 | isFunction: require('lodash/isFunction'), 22 | isObject: require('lodash/isObject'), 23 | isString: require('lodash/isString'), 24 | isUndefined: require('lodash/isObject'), 25 | keys: require('lodash/keys'), 26 | map: require('lodash/map'), 27 | find: require('lodash/find'), 28 | filter: require('lodash/filter'), 29 | values: require('lodash/values'), 30 | omit: require('lodash/omit'), 31 | pick: require('lodash/pick'), 32 | reduce: require('lodash/reduce'), 33 | some: require('lodash/some'), 34 | fromPairs: require('lodash/fromPairs') 35 | }; 36 | -------------------------------------------------------------------------------- /src/remote/wrapped-request.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import _ from "../utils/lodash"; 4 | import assign from "../polyfills/assign"; 5 | 6 | var wrappedRequest = function(service, gateway, middlewares=[]){ 7 | return function query(request, callback, errback){ 8 | if(!callback){callback = function(){};} 9 | if(!errback){errback = function(){};} 10 | 11 | var path = request.path; 12 | 13 | if (path[0] === "/"){ 14 | path = path.substring(1); 15 | } 16 | path = (service.name!=="hull")? "services/#{service.path}/#{path}":path; 17 | 18 | if (service.name !== "hull"){ 19 | request.params = (request.method.toLowerCase()==="delete")?JSON.stringify(request.params||{}):request.params; 20 | } 21 | 22 | var req = assign({}, request, {path}); 23 | var handle = gateway.handle(req); 24 | _.each(middlewares, function(m){ 25 | handle = handle.then(m, m); 26 | }); 27 | 28 | handle = handle.then((response)=>{ 29 | response = assign({}, response, { provider: service.name }); 30 | 31 | if (response.status >= 200 && response.status < 300) { 32 | return response; 33 | } else { 34 | throw response.body || response; 35 | } 36 | }) 37 | 38 | handle.then(callback, errback); 39 | 40 | return handle; 41 | }; 42 | }; 43 | 44 | module.exports = wrappedRequest; 45 | -------------------------------------------------------------------------------- /src/utils/ui/platform.css: -------------------------------------------------------------------------------- 1 | .hull-banner-container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | padding: 30px 0; 7 | background: white; 8 | -webkit-font-smoothing: antialiased; 9 | text-align: center; 10 | } 11 | 12 | .hull-captcha-banner-container { 13 | position: fixed; 14 | top: 0; 15 | left: 0; 16 | right: 0; 17 | height: 100%; 18 | padding: 30px 0; 19 | background: white; 20 | background: rgba(255,255,255,.95) 21 | -webkit-font-smoothing: antialiased; 22 | text-align: center; 23 | } 24 | 25 | .hull-banner-title{ 26 | color:#666; 27 | margin:0 0 5px 0; 28 | font-size: 24px; 29 | font-weight: normal; 30 | font-family:'Helvetica Neue',Helvetica,arial,sans-serif; 31 | } 32 | 33 | .hull-banner-text{ 34 | font-size: 14px; 35 | margin:0 0 10px 0; 36 | color:#838383; 37 | font-weight: normal; 38 | font-family:'Helvetica Neue',Helvetica,arial,sans-serif; 39 | } 40 | 41 | .hull-banner-button{ 42 | -webkit-border-radius: 3px; 43 | -moz-border-radius: 3px; 44 | -ms-border-radius: 3px; 45 | -o-border-radius: 3px; 46 | border-radius: 3px; 47 | margin:0; 48 | background: white; 49 | color:#33ADEC; 50 | border:1px solid #33ADEC; 51 | padding:10px 30px; 52 | font-size: 14px; 53 | line-height: 50px; 54 | font-weight: normal; 55 | } 56 | 57 | .hull-captcha-container { 58 | width: 304px; 59 | margin: 30px auto 0 auto; 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/get-current-script.js: -------------------------------------------------------------------------------- 1 | import _ from './lodash'; 2 | 3 | const URL_REGEX = /(data:text\/javascript(?:;[^,]+)?,.+?|(?:|blob:)(?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/ 4 | 5 | function getErrorStack(){ 6 | let e = new Error(); 7 | let stack = e.stack 8 | if(typeof stack !== 'string' || !stack){ 9 | try{ 10 | throw e 11 | } catch(err){ 12 | stack = err.stack 13 | } 14 | } 15 | return stack 16 | } 17 | function getUrlFromStack(stack=''){ 18 | let lines = stack.split('\n'); 19 | let urls = _.reduce(lines, function(u, line){ 20 | let t = line.match(URL_REGEX) 21 | if(t) { 22 | u.push(t[1]) 23 | } 24 | return u 25 | },[]); 26 | // Eliminate Hull.js from the stack, get next closest URL 27 | return _.uniq(urls)[1] 28 | } 29 | 30 | function getCurrentScriptFromUrl(url, scripts){ 31 | var i, script = null; 32 | 33 | if (typeof url === 'string' && url) { 34 | for (i = scripts.length; i--; ) { 35 | if (scripts[i].src === url) { 36 | script = scripts[i]; 37 | break; 38 | } 39 | } 40 | } 41 | return script; 42 | } 43 | 44 | function getCurrentScript(){ 45 | let scripts = document.getElementsByTagName('script'); 46 | let stack = getErrorStack(); 47 | let url = getUrlFromStack(stack); 48 | return getCurrentScriptFromUrl(url, scripts); 49 | } 50 | 51 | module.exports = getCurrentScript 52 | -------------------------------------------------------------------------------- /src/remote/services/admin.coffee: -------------------------------------------------------------------------------- 1 | superagent = require 'superagent' 2 | GenericService = require './generic-service' 3 | QSEncoder = require '../../utils/query-string-encoder' 4 | logger = require '../../utils/logger' 5 | RemoteConfigStore = require '../../flux/stores/RemoteConfigStore' 6 | 7 | class HullAdminService extends GenericService 8 | name : 'hull' 9 | 10 | constructor: (config, gateway)-> 11 | 12 | request: (request, callback, errback)-> 13 | {method, path, params, organization} = request 14 | throw new Error('No organization defined') unless organization? 15 | 16 | method = method.toUpperCase() 17 | 18 | path = path.substring(1) if (path[0] == "/") 19 | top_domain = document.location.host.split('.') 20 | top_domain.shift() 21 | top_domain = top_domain.join('.') 22 | protocol = document.location.protocol 23 | url = "#{protocol}//#{organization}.#{top_domain}/api/v1/#{path}" 24 | 25 | headers = {} 26 | token = RemoteConfigStore.getHullToken() 27 | 28 | headers['AccessToken'] = token if token 29 | 30 | s = superagent(method, url).set(headers) 31 | 32 | if (method=='GET' and params?) then s.query(QSEncoder.encode(params)) else s.send(params) 33 | s.end (err, response = {})-> 34 | return errback(response.body, response.error) if err || response.error 35 | callback 36 | provider: 'admin' 37 | body: response.body 38 | 39 | module.exports = HullAdminService 40 | -------------------------------------------------------------------------------- /src/utils/config-normalizer.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../utils/lodash' 2 | clone = require '../utils/clone' 3 | cookies = require '../utils/cookies' 4 | qs = require '../utils/query-string-encoder' 5 | 6 | flattenSettings = (settings, name)-> 7 | nameArray = name.split('_') 8 | nameArray.pop() if nameArray.length > 1 9 | [nameArray.join('_'), settings[name] || {}] 10 | 11 | applyUserCredentials = (config, creds={})-> 12 | _.each creds, (c, k)-> 13 | return unless _.keys(c).length 14 | config?[k] ?= {} #Never happens except for `hull` 15 | config?[k].credentials = c 16 | config 17 | 18 | sortServicesByType = (settings, types)-> 19 | ret = _.map types, (names, type)-> 20 | typeSettings = _.fromPairs _.map(names, (name)->flattenSettings(settings,name)) 21 | [type, typeSettings] 22 | _.fromPairs ret 23 | 24 | module.exports = (_config={})-> 25 | config = clone(_config) 26 | config.debug ?= false 27 | 28 | # This exists because we have 3 endpoints where Credentials and Settings are scattered: 29 | # - remote.html config.settings 30 | # - remote.html config.data.credentials 31 | # - /app/settings 32 | services = sortServicesByType config.services.settings, config.services.types 33 | services.auth ?= {} 34 | services.auth = applyUserCredentials services.auth, config.data.credentials 35 | config.services = services 36 | 37 | config.cookiesEnabled = cookies.enabled() 38 | 39 | config.queryParams = qs.decode() || {} 40 | 41 | config 42 | -------------------------------------------------------------------------------- /src/client/embeds/sandbox.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../../polyfills/assign' 2 | _ = require '../../utils/lodash' 3 | findUrl = require '../../utils/find-url' 4 | clone = require '../../utils/clone' 5 | throwErr = require '../../utils/throw' 6 | 7 | class Sandbox 8 | constructor : (deployment)-> 9 | @ship = deployment.ship 10 | @shipClassName = ".ship-#{@ship.id}" 11 | @deployment = deployment 12 | 13 | @callbacks = [] 14 | @_scopes = [] 15 | 16 | @hull = assign({}, window.Hull, { 17 | getDocument : @getDocument 18 | getShipClassName : @getShipClassName 19 | track : @track 20 | share : @share 21 | }); 22 | 23 | ###* 24 | * Performs a track that has the `ship_id` field set correctly 25 | * @param {[type]} name [description] 26 | * @param {[type]} event={} [description] 27 | * @return {[type]} [description] 28 | ### 29 | track : (name, event={})=> 30 | event.ship_id = @ship.id 31 | Hull.track(name, event) 32 | 33 | share: (opts={}, event={})=> 34 | opts.params = assign({}, opts.params, { ship_id: @ship.id }) 35 | Hull.share(opts, event) 36 | 37 | 38 | setDocument : (doc)-> 39 | return unless doc? 40 | @_document = doc 41 | 42 | getDocument : ()=> @_document 43 | 44 | getShipClassName : => @shipClassName 45 | 46 | get : ()-> @hull 47 | 48 | destroy: ()=> 49 | @_scopes = [] 50 | 51 | module.exports = Sandbox 52 | -------------------------------------------------------------------------------- /__tests__/utils/trait-test.js: -------------------------------------------------------------------------------- 1 | jest.dontMock("../../src/client/traits"); 2 | var Trait = require("../../src/client/traits"); 3 | 4 | function makeTrait(key, value) { 5 | var api = jest.genMockFn(); 6 | var trait = Trait(api)('foo', value); 7 | return [api, trait]; 8 | } 9 | 10 | describe("Traits", function() { 11 | 12 | 13 | describe("with no initial value", function() { 14 | 15 | var api, trait; 16 | beforeEach(() => [api, trait] = makeTrait('foo')); 17 | 18 | 19 | it("should not call api in initialization", ()=> 20 | expect(api).not.toBeCalled() 21 | ); 22 | 23 | describe("and an operation after", function() { 24 | 25 | 26 | var methods = [['inc', 1], ['set', 'yeah'], ['dec', 2]]; 27 | 28 | methods.map(function([op, val]) { 29 | 30 | it("works with " + op, function() { 31 | trait[op](val); 32 | expect(api).toBeCalledWith('me/traits', 'put', { 33 | name: 'foo', 34 | operation: op, 35 | value: val 36 | }); 37 | }); 38 | 39 | }); 40 | 41 | }); 42 | 43 | }); 44 | 45 | 46 | describe("with an initial value", function() { 47 | 48 | var api, trait; 49 | beforeEach(() => [api, trait] = makeTrait('foo', 'bar')); 50 | 51 | it("records the value with set on initialization", function() { 52 | expect(api).toBeCalledWith('me/traits', 'put', { 53 | name: 'foo', 54 | operation: 'set', 55 | value: 'bar' 56 | }); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/remote/channel.coffee: -------------------------------------------------------------------------------- 1 | Promise = require '../utils/promises' 2 | xdm = require '../utils/xdm' 3 | 4 | catchAll = (res)-> res 5 | 6 | class Channel 7 | constructor: (config, services)-> 8 | @_ready = {} 9 | @promise = new Promise (resolve, reject)=> 10 | @_ready.resolve = resolve 11 | @_ready.reject = reject 12 | 13 | @rpc = null; 14 | 15 | 16 | try 17 | local = services.getMethods() 18 | @rpc = new xdm.Rpc 19 | acl: config.appDomains 20 | , 21 | remote: 22 | ready : {} 23 | message : {} 24 | userUpdate : {} 25 | configUpdate : {} 26 | getClientConfig : {} 27 | track : {} 28 | show : {} 29 | hide : {} 30 | local: local 31 | 32 | # Send config to client 33 | @rpc.ready(config) 34 | @rpc.getClientConfig(@_ready.resolve) 35 | 36 | catch e 37 | try 38 | domains = (config.appDomains || []).toString() 39 | whitelisted = domains.replace(/\(\:\[0-9\]\+\)\?\$/g,'').replace(/\^\(https\?\:\/\/\)\?/g,'').replace(/\\/g,'') 40 | e = new Error("#{e.message}, You should whitelist this domain. The following domains are authorized : \n#{whitelisted}"); 41 | @_ready.reject(e) 42 | @rpc = new xdm.Rpc({}, local: {}, remote: { loadError: {} }) 43 | @rpc.loadError e.message 44 | catch e 45 | throw new Error("Unable to establish communication between Hull Remote and your page. #{e.message}") 46 | 47 | module.exports = Channel 48 | -------------------------------------------------------------------------------- /__mocks__/superagent.js: -------------------------------------------------------------------------------- 1 | /* global jest */ 2 | 3 | var superagent = jest.genMockFunction().mockReturnThis(); 4 | 5 | var Response = jest.genMockFunction().mockImplementation(function() { 6 | this.status = 200; 7 | this.ok = true; 8 | }); 9 | 10 | Response.prototype.get = jest.genMockFunction(); 11 | Response.prototype.toError = jest.genMockFunction(); 12 | 13 | var Request = jest.genMockFunction().mockImplementation(function(method, url) { 14 | this.method = method; 15 | this.url = url; 16 | }); 17 | 18 | Request.prototype.accept = jest.genMockFunction().mockReturnThis(); 19 | Request.prototype.set = jest.genMockFunction().mockReturnThis(); 20 | Request.prototype.send = jest.genMockFunction().mockReturnThis(); 21 | Request.prototype.field = jest.genMockFunction().mockReturnThis(); 22 | Request.prototype.query = jest.genMockFunction().mockReturnThis(); 23 | 24 | Request.prototype.end = jest.genMockFunction().mockImplementation(function(callback) { 25 | if (superagent.mockDelay) { 26 | this.delayTimer = setTimeout(callback, 0, superagent.mockError, superagent.mockResponse); 27 | 28 | return; 29 | } 30 | 31 | callback(superagent.mockError, superagent.mockResponse); 32 | }); 33 | 34 | Request.prototype.abort = jest.genMockFunction().mockImplementation(function() { 35 | this.aborted = true; 36 | 37 | if (this.delayTimer) { 38 | clearTimeout(this.delayTimer); 39 | } 40 | }); 41 | 42 | superagent.Request = Request; 43 | superagent.Response = Response; 44 | 45 | superagent.mockResponse = new Response(); 46 | superagent.mockError = null; 47 | superagent.mockDelay = false; 48 | 49 | module.exports = superagent; 50 | -------------------------------------------------------------------------------- /src/utils/domready.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * domready (c) Dustin Diaz 2012 - License MIT 3 | */ 4 | !function (name, definition) { 5 | if (typeof module != 'undefined') module.exports = definition() 6 | else if (typeof define == 'function' && typeof define.amd == 'object') define(definition) 7 | else this[name] = definition() 8 | }('domready', function (ready, scope) { 9 | 10 | var fns = [], fn, f = false 11 | , doc = scope || document 12 | , testEl = doc.documentElement 13 | , hack = testEl.doScroll 14 | , domContentLoaded = 'DOMContentLoaded' 15 | , addEventListener = 'addEventListener' 16 | , onreadystatechange = 'onreadystatechange' 17 | , readyState = 'readyState' 18 | , loadedRgx = hack ? /^loaded|^c/ : /^loaded|^i|^c/ 19 | , loaded = loadedRgx.test(doc[readyState]) 20 | 21 | function flush(f) { 22 | loaded = 1 23 | while (f = fns.shift()) f() 24 | } 25 | 26 | doc[addEventListener] && doc[addEventListener](domContentLoaded, fn = function () { 27 | doc.removeEventListener(domContentLoaded, fn, f) 28 | flush() 29 | }, f) 30 | 31 | 32 | hack && doc.attachEvent(onreadystatechange, fn = function () { 33 | if (/^c/.test(doc[readyState])) { 34 | doc.detachEvent(onreadystatechange, fn) 35 | flush() 36 | } 37 | }) 38 | 39 | return (ready = hack ? 40 | function (fn) { 41 | self != top ? 42 | loaded ? fn() : fns.push(fn) : 43 | function () { 44 | try { 45 | testEl.doScroll('left') 46 | } catch (e) { 47 | return setTimeout(function() { ready(fn) }, 50) 48 | } 49 | fn() 50 | }() 51 | } : 52 | function (fn) { 53 | loaded ? fn() : fns.push(fn) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/flux/actions/RemoteActions.js: -------------------------------------------------------------------------------- 1 | var RemoteDispatcher= require('../dispatcher/RemoteDispatcher'); 2 | var RemoteConstants = require('../constants/RemoteConstants'); 3 | var RemoteUserStore = require('../stores/RemoteUserStore'); 4 | 5 | var RemoteSettingsActions = { 6 | updateClientConfig: function(config){ 7 | RemoteDispatcher.handleAction({ 8 | actionType: RemoteConstants.UPDATE_CLIENT_CONFIG, 9 | config: config 10 | }); 11 | }, 12 | updateRemoteConfig: function(config, options={}){ 13 | RemoteDispatcher.handleAction({ 14 | actionType: RemoteConstants.UPDATE_REMOTE_CONFIG, 15 | config: config, 16 | options:options 17 | }); 18 | }, 19 | updateServices: function(services, options={}){ 20 | RemoteDispatcher.handleAction({ 21 | actionType: RemoteConstants.UPDATE_SERVICES, 22 | services: services, 23 | options:options 24 | }); 25 | }, 26 | logoutUser: function(options={}){ 27 | RemoteDispatcher.handleAction({ 28 | actionType: RemoteConstants.LOGOUT_USER, 29 | options: options 30 | }); 31 | }, 32 | updateUser: function(user, options={}){ 33 | RemoteDispatcher.handleAction({ 34 | actionType: RemoteConstants.UPDATE_USER, 35 | user:user, 36 | options:options 37 | }); 38 | }, 39 | updateUserIfMe: function(data,options={}){ 40 | // We don't know if it's a Me object. for now it's just a bundle of data from the API. 41 | if(data.body && RemoteUserStore.isSameId(data.body.id)){ 42 | RemoteDispatcher.handleAction({ 43 | actionType: RemoteConstants.UPDATE_USER, 44 | user:data.body, 45 | options:options 46 | }); 47 | } 48 | } 49 | }; 50 | 51 | module.exports = RemoteSettingsActions; 52 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Hull JS Library 10 | 17 | 38 | 39 | 40 |
41 |
42 | Share 43 |
44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /src/flux/stores/RemoteUserStore.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var assign = require('../../polyfills/assign'); 3 | var RemoteDispatcher = require('../dispatcher/RemoteDispatcher'); 4 | var RemoteConstants = require('../constants/RemoteConstants'); 5 | 6 | var CHANGE_EVENT = 'change'; 7 | 8 | var state = { 9 | user:null, 10 | }; 11 | 12 | var isSameId = function(value){ 13 | return value === (state.user && state.user.id); 14 | } 15 | 16 | var isUpToDate = function(user){ 17 | if(!user && !state.user){return true} 18 | return user && state.user && user.updated_at===state.user.updated_at 19 | } 20 | 21 | var RemoteUserStore = assign({}, EventEmitter.prototype, { 22 | emitChange : function(changeEvent) {this.emit(CHANGE_EVENT, changeEvent); }, 23 | addChangeListener : function(callback) {this.on(CHANGE_EVENT, callback); }, 24 | removeChangeListener: function(callback) {this.removeListener(CHANGE_EVENT, callback); }, 25 | getState : function() {return state;}, 26 | isSameId : isSameId, 27 | isUpToDate : isUpToDate, 28 | 29 | dispatcherIndex: RemoteDispatcher.register(function(payload) { 30 | var action = payload.action; 31 | var text; 32 | 33 | switch(action.actionType) { 34 | case RemoteConstants.LOGOUT_USER: 35 | state.user = null 36 | if(!action.options.silent===true){ 37 | RemoteUserStore.emitChange(action.actionType); 38 | } 39 | break; 40 | 41 | case RemoteConstants.UPDATE_USER: 42 | if(!isUpToDate(action.user)){ 43 | state.user = action.user 44 | if(!action.options.silent===true){ 45 | RemoteUserStore.emitChange(action.actionType); 46 | } 47 | } 48 | break; 49 | } 50 | return true; 51 | }) 52 | 53 | }); 54 | 55 | module.exports = RemoteUserStore; 56 | -------------------------------------------------------------------------------- /src/client/config-check.coffee: -------------------------------------------------------------------------------- 1 | Promise = require '../utils/promises' 2 | 3 | # Wraps config failure 4 | onConfigFailure = (err)-> 5 | throw err 6 | 7 | 8 | # Parse the tracked events configuration and standardize it. 9 | formatTrackConfig = (config={})-> 10 | switch (Object.prototype.toString.call(config).match(/^\[object (.*)\]$/)[1]) 11 | when "Object" 12 | if config.only? 13 | config = { only: (m.toString() for m in config.only) } 14 | else if config.ignore? 15 | config = { ignore: (m.toString() for m in config.ignore) } 16 | else 17 | config 18 | when "RegExp" 19 | config = { only: config.toString() } 20 | when "Array" 21 | config = { only: (m.toString() for m in config) } 22 | # Setup initial referrer 23 | config.referrer = document.referrer if document?.referrer 24 | config 25 | 26 | 27 | module.exports = (config)-> 28 | config.track = formatTrackConfig(config.track) 29 | promise = new Promise (resolve, reject)-> 30 | msg = "You need to pass some keys to Hull to start it: " 31 | readMore = "Read more about this here : http://www.hull.io/docs/references/hull_js/#hull-init-params-cb-errb" 32 | # Fail right now if we don't have the required setup 33 | if config.orgUrl and config.appId 34 | # Auto add protocol if we dont have one of http://, https://, // 35 | reject(new Error(" You specified orgUrl as #{config.orgUrl}. We do not support protocol-relative URLs in organization URLs yet.")) if config.orgUrl.match(/^\/\//) 36 | config.orgUrl ="https://#{config.orgUrl}" unless config.orgUrl.match(/^http[s]?:\/\//) 37 | resolve() 38 | else 39 | reject(new Error "#{msg} We couldn't find `orgUrl` in the config object you passed to `Hull.init`\n #{readMore}") unless config.orgUrl 40 | reject(new Error "#{msg} We couldn't find `platformId` in the config object you passed to `Hull.init`\n #{readMore}") unless config.appId 41 | promise.then(null, onConfigFailure) 42 | promise 43 | -------------------------------------------------------------------------------- /src/utils/uuid.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /*global module*/ 3 | 4 | // Private array of chars to use 5 | var CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split(""); 6 | 7 | var uuid = function(len, radix) { 8 | var chars = CHARS, uu = [], i; 9 | radix = radix || chars.length; 10 | 11 | if (len) { 12 | // Compact form 13 | for (i = 0; i < len; i++){ 14 | uu[i] = chars[0 | Math.random() * radix]; 15 | } 16 | } else { 17 | // rfc4122, version 4 form 18 | var r; 19 | 20 | // rfc4122 requires these characters 21 | uu[8] = uu[13] = uu[18] = uu[23] = "-"; 22 | uu[14] = "4"; 23 | 24 | // Fill in random data. At i==19 set the high bits of clock sequence as 25 | // per rfc4122, sec. 4.1.5 26 | for (i = 0; i < 36; i++) { 27 | if (!uu[i]) { 28 | r = 0 | Math.random() * 16; 29 | uu[i] = chars[(i === 19) ? (r & 0x3) | 0x8 : r]; 30 | } 31 | } 32 | } 33 | 34 | return uu.join(""); 35 | }; 36 | 37 | // A more performant, but slightly bulkier, RFC4122v4 solution. We boost performance 38 | // by minimizing calls to random() 39 | var uuidFast = function() { 40 | var chars = CHARS, uu = new Array(36), rnd = 0, r; 41 | for (var i = 0; i < 36; i++) { 42 | if (i === 8 || i === 13 || i === 18 || i === 23) { 43 | uu[i] = "-"; 44 | } else if (i === 14) { 45 | uu[i] = "4"; 46 | } else { 47 | if (rnd <= 0x02) { 48 | rnd = 0x2000000 + (Math.random() * 0x1000000) | 0; 49 | } 50 | r = rnd & 0xf; 51 | rnd = rnd >> 4; 52 | uu[i] = chars[(i === 19) ? (r & 0x3) | 0x8 : r]; 53 | } 54 | } 55 | return uu.join(""); 56 | }; 57 | 58 | // A more compact, but less performant, RFC4122v4 solution: 59 | var uuidCompact = function() { 60 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { 61 | var r = Math.random() * 16 | 0, 62 | v = c === "x" ? r : (r & 0x3 | 0x8); 63 | return v.toString(16); 64 | }); 65 | }; 66 | 67 | module.exports = {uuid, uuidFast, uuidCompact}; 68 | -------------------------------------------------------------------------------- /src/client/traits/index.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../../utils/lodash' 2 | assign = require '../../polyfills/assign' 3 | 4 | class Trait 5 | constructor: (api, name, value)-> 6 | @_api = api 7 | @name = name 8 | if value? 9 | # If value is an object, then it has to be compatible 10 | if _.isObject(value) && _.includes(Object.keys(value), 'value') && _.includes(['inc', 'dec', 'set', 'increment', 'decrement', 'setIfNull'], value.operation) 11 | # new Trait('a_number', {value: 20, operation: 'dec'}); 12 | @raw assign({},value,{name:@name}) 13 | else 14 | # new Trait('a_number', 20); 15 | @set value 16 | 17 | inc: (step = 1)-> 18 | @raw({ name: @name, operation: 'inc', value: step }) 19 | 20 | increment: (step = 1)-> 21 | @raw({ name: @name, operation: 'inc', value: step }) 22 | 23 | dec: (step = 1)-> 24 | @raw({ name: @name, operation: 'dec', value: step }) 25 | 26 | decrement: (step = 1)-> 27 | @raw({ name: @name, operation: 'dec', value: step }) 28 | 29 | set: (value)-> 30 | @raw({ name: @name, operation: 'set', value: value }) 31 | 32 | setIfNull: (value)-> 33 | @raw({ name: @name, operation: 'setIfNull', value: value }) 34 | 35 | raw: (payload)-> 36 | @_api.message('me/traits', 'put', payload) 37 | 38 | 39 | module.exports = (api)-> 40 | (name, value)-> 41 | # We've been passed a Hash, iterate on it to create a trait for each. 42 | if _.isObject(name) 43 | # Hull.traits({ 44 | # 'a_number' : {value: 20, operation: 'dec'}, 45 | # 'a_number_2': {value: 20, operation: 'dec'}, 46 | # 'a_number_3': 20 47 | # }); 48 | _.map name, (value, key)-> new Trait(api, key, value) 49 | Hull.emit('hull.traits',name) 50 | Hull.emit('hull.identify',name) 51 | else 52 | # Hull.traits('a_number', {value: 20, operation: 'dec'}); 53 | # Hull.traits('a_number', 20); 54 | traits = {} 55 | traits[name] = value 56 | Hull.emit('hull.traits',traits) 57 | new Trait(api, name, value) 58 | -------------------------------------------------------------------------------- /src/remote/services/track.coffee: -------------------------------------------------------------------------------- 1 | LeakyBucket = require '../../utils/leaky-bucket' 2 | assign = require '../../polyfills/assign' 3 | EventBus = require '../../utils/eventbus' 4 | RemoteUserStore = require '../../flux/stores/RemoteUserStore' 5 | GenericService = require './generic-service' 6 | Base64 = require '../../utils/base64' 7 | 8 | 9 | StructuredEventProps = ['category', 'action', 'label', 'property', 'value'] 10 | MarketingProps = ['campaign', 'source', 'medium', 'term', 'content'] 11 | TopLevelProps = ['hull_ship_id'].concat(StructuredEventProps) 12 | 13 | Identity = (o)-> o 14 | 15 | class HullTrackService extends GenericService 16 | name : 'hull' 17 | 18 | constructor: (config, gateway)-> 19 | super(config, gateway) 20 | 21 | @_request = @wrappedRequest 22 | 23 | if (config.trackRateLimit && config.trackRateLimit.capacity) 24 | @rateLimitter = new LeakyBucket(config.trackRateLimit) 25 | else 26 | @rateLimitter = false 27 | 28 | RemoteUserStore.addChangeListener (change)=> 29 | currentUser = RemoteUserStore.getState().user 30 | currentUserId = currentUser?.id 31 | 32 | 33 | request: (opts, callback, errback) => 34 | if @rateLimitter 35 | @rateLimitter.throttle().then => 36 | @_sendRequest(opts, callback, errback) 37 | else 38 | @_sendRequest(opts, callback, errback) 39 | 40 | _sendRequest: (opts, callback, errback) => 41 | { params, path } = opts 42 | 43 | event = path 44 | 45 | EventBus.emit('remote.tracked', { event, params: params.payload }); 46 | 47 | @_request({ 48 | path: 't', 49 | method: 'post', 50 | params: { 51 | event: event, 52 | properties: params.payload, 53 | url: params.url, 54 | referer: params.referer || "" 55 | }, 56 | nocallback: true 57 | }).then (response)=> 58 | response.provider = 'track' 59 | response 60 | .then (callback || Identity), (errback || Identity) 61 | 62 | 63 | module.exports = HullTrackService 64 | -------------------------------------------------------------------------------- /src/utils/is-mobile.coffee: -------------------------------------------------------------------------------- 1 | isMobile = -> 2 | # http://stackoverflow.com/questions/11381673/javascript-solution-to-detect-mobile-browser 3 | n = navigator.userAgent||navigator.vendor||window.opera 4 | !! /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(n)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(n.substr(0,4)); 5 | 6 | module.exports = isMobile 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hull.js [ ![Codeship Status for hull/hull-js](https://circleci.com/gh/hull/hull-js/tree/develop.png?circle-token=26a17dad6ac378f6028a460a5857d5ca15a8aa13) ](https://circleci.com/gh/hull/hull-js) 2 | 3 | # Building the library 4 | Checkout 5 | 6 | git clone git@github.com:hull/hull-js.git 7 | 8 | First, install gulp 9 | 10 | sudo npm install -g gulp 11 | 12 | Then switch to hull-js dir 13 | 14 | cd hull-js 15 | npm install 16 | npm run build 17 | 18 | The last command will start a static HTTP server (port `3001`) that serves the files located in the root folder. 19 | 20 | ## Developing 21 | 22 | A boilerplate app is located in the `app` folder. Here's how to use it: 23 | 24 | ``` 25 | cp app/app.example.js app/app.js 26 | gulp 27 | ``` 28 | 29 | Gulp will automatically start a Webpack server with live reloading. 30 | When it is done, you can point your browser to [http://localhost:3001](http://localhost:3001) 31 | 32 | __Note__: You must enter some keys in `app/app.js`. Find them by creating an Organization and a Platform at [https://dashboard.hullapp.io](https://dashboard.hullapp.io). 33 | 34 | # Main `gulp` tasks 35 | 36 | * `build`: Builds and executes the tests 37 | * `server` (default): `dist` + starts a live reloading server for development 38 | 39 | # Releasing 40 | 41 | * We use continuous integration. 42 | 43 | * Checkout `master` 44 | * `git flow release start 'YOUR_RELEASE_VERSION_NAME'` 45 | * Merge your changes 46 | * Bump `YOUR_RELEASE_VERSION_NAME` in `bower.json` and `package.json` 47 | * Write Changelog 48 | * Commit changes 49 | * `git flow release finish 'YOUR_RELEASE_VERSION_NAME'` 50 | 51 | # Contributing 52 | You're encouraged to submit pull requests, 53 | propose features and [discuss issues](http://github.com/hull/hull.js/issues). 54 | 55 | If you want to submit code: 56 | 57 | * Fork the project 58 | * Write tests for your new feature or a test that reproduces a bug 59 | * Implement your feature or make a bug fix 60 | * Commit, push and make a pull request. Bonus points for topic branches. 61 | 62 | # License 63 | MIT License. See LICENSE for details. 64 | 65 | # Copyright 66 | Copyright (c) 2015 Hull, Inc. 67 | -------------------------------------------------------------------------------- /src/client/index.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../utils/lodash' 2 | clone = require '../utils/clone' 3 | EventBus = require '../utils/eventbus' 4 | Entity = require '../utils/entity' 5 | findUrl = require '../utils/find-url' 6 | logger = require '../utils/logger' 7 | 8 | Api = require './api' 9 | Auth = require './auth' 10 | Flag = require './flag/index' 11 | Tracker = require './track/index' 12 | Traits = require './traits/index' 13 | Sharer = require './sharer/index' 14 | QueryString = require './querystring/index' 15 | 16 | utils = require '../utils/utils' 17 | 18 | 19 | class Client 20 | constructor: (channel, currentUser, currentConfig)-> 21 | 22 | @currentConfig = currentConfig 23 | 24 | api = new Api(channel, currentUser, currentConfig) 25 | alias = (id) -> api.message({ path: "/me/alias" }, "post", { anonymous_id: id }) 26 | auth = new Auth(api, currentUser, currentConfig) 27 | tracker = new Tracker(api) 28 | 29 | sharer = new Sharer(currentConfig); 30 | flag = new Flag(api) 31 | traits = new Traits(api) 32 | qs = new QueryString(traits, tracker.track, alias) 33 | 34 | 35 | if @currentConfig.get('debug.enabled') 36 | EventBus.on 'hull.**', (args...)-> 37 | logger.log("--HULL EVENT--[#{@event}]--", args...); 38 | 39 | # Creating the complete hull object we'll send back to the API 40 | @hull = 41 | config : @currentConfig.get 42 | utils : utils 43 | api : api.message 44 | currentUser : currentUser.get 45 | entity : Entity 46 | signup : auth.signup 47 | logout : auth.logout 48 | login : auth.login 49 | resetPassword : auth.resetPassword 50 | confirmEmail : auth.confirmEmail 51 | linkIdentity : auth.linkIdentity 52 | unlinkIdentity : auth.unlinkIdentity 53 | track : tracker.track 54 | trackForm : tracker.trackForm 55 | alias : alias 56 | flag : flag 57 | identify : traits 58 | traits : traits 59 | trait : traits 60 | share : sharer.share 61 | findUrl : findUrl 62 | parseQueryString : qs.parse 63 | 64 | # Return an object that will be digested by Hull main file and 65 | # has everything 66 | 67 | @hull 68 | 69 | module.exports = Client 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hull-js", 3 | "version": "0.10.0", 4 | "main": "lib/hull.js", 5 | "dependencies": { 6 | "babel-runtime": "^6.6.1", 7 | "bluebird": "^3.3.5", 8 | "browser-jsonp": "^1.1.4", 9 | "cookies-js": "^1.2.2", 10 | "currentscript": "^1.1.0", 11 | "eventemitter2": "^1.0.3", 12 | "flux": "^2.1.1", 13 | "git-rev-sync": "^1.5.1", 14 | "html-loader": "^0.4.3", 15 | "inline-style-loader": "0.0.5", 16 | "lodash": "^4.11.1", 17 | "merge-stream": "^1.0.0", 18 | "putainde-localstorage": "^4.0.0", 19 | "qs": "^6.1.0", 20 | "raw-loader": "^0.5.1", 21 | "superagent": "^1.8.3" 22 | }, 23 | "devDependencies": { 24 | "babel-core": "^6.7.7", 25 | "babel-eslint": "^6.0.3", 26 | "babel-jest": "^12.0.2", 27 | "babel-loader": "^6.2.4", 28 | "babel-plugin-lodash": "^3.1.4", 29 | "babel-plugin-transform-es3-member-expression-literals": "^6.5.0", 30 | "babel-plugin-transform-es3-property-literals": "^6.5.0", 31 | "babel-preset-es2015": "^6.6.0", 32 | "babel-preset-stage-0": "^6.5.0", 33 | "coffee-loader": "^0.7.2", 34 | "coffee-script": "^1.10.0", 35 | "concurrent-transform": "^1.0.0", 36 | "del": "^2.2.0", 37 | "eslint": "^2.8.0", 38 | "gulp": "^3.9.1", 39 | "gulp-awspublish": "^3.0.2", 40 | "gulp-gh-pages": "^0.5.4", 41 | "gulp-invalidate-cloudfront": "hull/gulp-invalidate-cloudfront", 42 | "gulp-notify": "^2.2.0", 43 | "gulp-rename": "^1.2.2", 44 | "gulp-util": "^3.0.7", 45 | "harmonize": "^1.4.4", 46 | "jest-cli": "^12.0.2", 47 | "moment": "^2.13.0", 48 | "ngrok": "^2.1.7", 49 | "node-notifier": "^4.5.0", 50 | "object-assign": "^4.0.1", 51 | "run-sequence": "^1.1.5", 52 | "stats-webpack-plugin": "0.3.1", 53 | "webpack": "^1.13.0", 54 | "webpack-dev-server": "^1.14.1" 55 | }, 56 | "jest": { 57 | "testDirectoryName": "__tests__", 58 | "scriptPreprocessor": "/test_utils/preprocessor.js", 59 | "testFileExtensions": [ 60 | "coffee", 61 | "js" 62 | ], 63 | "testPathIgnorePatterns": [ 64 | "node_modules" 65 | ], 66 | "unmockedModulePathPatterns": [ 67 | "/node_modules/object-assign", 68 | "/node_modules/lodash", 69 | "/src/utils/lodash" 70 | ], 71 | "moduleFileExtensions": [ 72 | "js", 73 | "json", 74 | "coffee" 75 | ] 76 | }, 77 | "scripts": { 78 | "test": "jest", 79 | "build": "gulp build", 80 | "start": "gulp" 81 | }, 82 | "engines": { 83 | "node": "8.x" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/client/embeds/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /*global require, module*/ 3 | 4 | import _ from '../../utils/lodash'; 5 | import throwErr from '../../utils/throw'; 6 | import logger from '../../utils/logger'; 7 | import Promise from '../../utils/promises'; 8 | import getCurrentScript from '../../utils/get-current-script'; 9 | import currentScript from 'currentscript' 10 | import Deployment from './deployment'; 11 | 12 | let _initialized = false; 13 | let _context = {}; 14 | 15 | module.exports = { 16 | initialize: function(context) { 17 | _initialized = true; 18 | _context = context; 19 | }, 20 | 21 | embed: function(deployments, opts, callback, errback) { 22 | if (!deployments || !deployments.length) { return; } 23 | if (opts && opts.reset !== false) { Deployment.resetDeployments()} 24 | 25 | let embeds = []; 26 | let promises = []; 27 | 28 | logger.log('Embedding Deployments', deployments) 29 | for (let d = 0, l = deployments.length; d < l; d++) { 30 | let dpl = new Deployment(deployments[d], _context); 31 | promises.push(dpl.embed({ refresh: true })); 32 | } 33 | 34 | Promise.all(promises).then((deployments)=>{ 35 | logger.log('Deployments resolved', deployments) 36 | if (_.isFunction(callback)){ 37 | callback(_.map(deployments, 'value')) 38 | } 39 | },errback).catch(throwErr); 40 | }, 41 | 42 | onEmbed: function() { 43 | let args = Array.prototype.slice.call(arguments); 44 | let callback; 45 | 46 | while (args.length>0 && !_.isFunction(callback)){ 47 | callback = args.shift(); 48 | } 49 | 50 | if (!_.isFunction(callback)) return false; 51 | 52 | let currentScript = document.currentScript || getCurrentScript() || document._currentScript; 53 | 54 | 55 | logger.verbose('currentScript', currentScript); 56 | 57 | // detectedScript is null on Chrome. Use this to use either the polyfill or the native implementation. 58 | // Fallback to script detection (how will this work ?) 59 | 60 | // Detect JS embed mode first. 61 | let shipId = currentScript.getAttribute('data-hull-ship-script'); 62 | let deployments = Deployment.getDeployments(shipId); 63 | // In JS-mode, callbacks are passed down to the DeploymentStrategy, 64 | // In theother modes, they are ignored because we retreive the callbacks from the import document. 65 | if(deployments && deployments.length){ 66 | for (var i = deployments.length - 1; i >= 0; i--) { 67 | if(deployments[i]){ 68 | deployments[i].onEmbed(callback) 69 | } 70 | }; 71 | } 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /src/client/sharer/index.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../../utils/lodash' 2 | findUrl = require '../../utils/find-url' 3 | assign = require '../../polyfills/assign' 4 | domWalker = require '../../utils/dom-walker' 5 | Promise = require '../../utils/promises' 6 | qs = require '../../utils/query-string-encoder' 7 | EventBus = require '../../utils/eventbus' 8 | 9 | popup = (location, opts={}, params={})-> 10 | return unless location? 11 | share = window.open("#{location}?#{qs.encode(params)}", 'hull_share', "location=0,status=0,width=#{opts.width},height=#{opts.height}") 12 | new Promise (resolve, reject)-> 13 | interval = setInterval -> 14 | try 15 | if !share? || share.closed 16 | window.clearInterval(interval) 17 | resolve({}) 18 | catch e 19 | reject(e) 20 | , 500 21 | 22 | 23 | class Sharer 24 | 25 | constructor: (currentConfig)-> 26 | @currentConfig = currentConfig 27 | 28 | share: (opts, event={})=> 29 | if !_.isObject(opts) 30 | throw new Error("You need to pass an options hash to the share method. You passed: #{JSON.stringify(opts)}") 31 | else if !opts.provider? 32 | throw new Error("You need specify the provider on which to share. You passed: #{opts.provider}") 33 | else if !_.isObject(opts.params) 34 | opts.params = {} 35 | # throw new Error("You need specify some parameters to pass the provider. You passed: #{opts.params}") 36 | 37 | opts.params ||= {} 38 | # If the Sharing URL is not specified, then walk up the DOM to find some URL to share. 39 | # If No url is specified, will walk up to window.location.href. 40 | # 41 | # Lookup Order 42 | # 1. Passed-in url 43 | # 2. Find url from Click Targt 44 | # 3. Ship container node 45 | 46 | opts.params.url = opts.params.url || opts.params.href 47 | 48 | if (!opts.params.url) 49 | opts.params.url = findUrl(event.target) 50 | opts.params.title = opts.params.title || domWalker.getMetaValue('og:title') || document.title 51 | 52 | params = assign({ 53 | platform_id: @currentConfig.get('appId') 54 | }, opts.params) 55 | 56 | provider = opts.provider 57 | 58 | popupUrl = @currentConfig.get('orgUrl') + "/api/v1/intent/share/" + opts.provider 59 | sharePromise = popup(popupUrl, { width: 550, height: 420 }, params) 60 | 61 | sharePromise.then (response)=> 62 | data = assign({ url: opts.params.url }, params, { provider }) 63 | EventBus.emit("hull.#{opts.provider}.share", data) 64 | assign({}, data, { response }) 65 | , (err)-> throw err 66 | 67 | 68 | module.exports = Sharer 69 | -------------------------------------------------------------------------------- /src/client/track/index.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../../utils/lodash' 2 | EventBus = require '../../utils/eventbus' 3 | assign = require '../../polyfills/assign' 4 | 5 | bnd = if window.addEventListener then 'addEventListener' else 'attachEvent'; 6 | unbnd = if window.removeEventListener then 'removeEventListener' else 'detachEvent'; 7 | prefix = if (bnd != 'addEventListener') then 'on' else ''; 8 | 9 | listen = (el, type, fn, capture) => el[bnd](prefix + type, fn, capture || false); 10 | 11 | class Tracker 12 | constructor : (api, currentUser)-> 13 | @api = api 14 | @currentUser = currentUser 15 | @setupTracking() 16 | 17 | getCurrentUserId: -> @currentUser.get('id') 18 | 19 | setupTracking: -> 20 | return if @setup 21 | 22 | @setup = true 23 | 24 | self = this 25 | 26 | EventBus.on 'hull.*.share', (res)-> 27 | self.track(this.event, res) 28 | 29 | EventBus.on 'hull.user.create', (me)-> 30 | providers = _.map me.identities, 'provider' 31 | self.track 'hull.user.create', { providers: providers, main_identity: me.main_identity } 32 | 33 | EventBus.on 'hull.user.update', (me)-> 34 | self.track 'hull.user.update', {} 35 | 36 | EventBus.on 'hull.user.login', (me, provider)-> 37 | providers = _.map me.identities, 'provider' 38 | provider = provider || me.main_identity 39 | self.track 'hull.user.login', { provider: provider, providers: providers, main_identity: me.main_identity } 40 | 41 | EventBus.on 'hull.user.logout', ()-> 42 | self.track('hull.user.logout') 43 | 44 | trackForm: (forms, ev, properties) => 45 | return false unless !!forms 46 | forms = [forms] if _.isElement(forms) 47 | _.map forms, (form) => 48 | return console.log("Not an HTML element", form) unless _.isElement(form) 49 | trackSubmit = (e) => 50 | e.preventDefault(); 51 | evt = if _.isFunction(ev) then ev(form) else ev 52 | props = if _.isFunction(properties) then properties(form) else properties 53 | 54 | _isSubmitted = false 55 | submit = => 56 | return if _isSubmitted 57 | _isSubmitted = true 58 | form.submit() 59 | 60 | setTimeout submit 61 | , 1000 62 | 63 | @track(evt, props).then submit, submit 64 | $ = (window.jQuery || window.Zepto) 65 | if $ then $(form).submit(trackSubmit) else listen(form, 'submit', trackSubmit) 66 | true 67 | 68 | track: (event, payload, success, failure)=> 69 | @api.message 70 | provider: 'track' 71 | path: event 72 | , 'post', { payload: payload, url: document.location.href, referer: document.referrer } 73 | 74 | module.exports = Tracker 75 | -------------------------------------------------------------------------------- /src/flux/stores/RemoteConfigStore.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var _ = require('../../utils/lodash'); 3 | var assign = require('../../polyfills/assign'); 4 | var RemoteDispatcher = require('../dispatcher/RemoteDispatcher'); 5 | var RemoteConstants = require('../constants/RemoteConstants'); 6 | 7 | var CHANGE_EVENT = 'change'; 8 | 9 | var state = {}; 10 | 11 | var RemoteConfigStore = assign({}, EventEmitter.prototype, { 12 | emitChange : function(changeEvent) {this.emit(CHANGE_EVENT, changeEvent); }, 13 | addChangeListener : function(callback) {this.on(CHANGE_EVENT, callback); }, 14 | removeChangeListener: function(callback) {this.removeListener(CHANGE_EVENT, callback); }, 15 | getAuth : function(provider) { 16 | if(state && state.services && state.services.auth && state.services.auth[provider]){ 17 | return state.services.auth[provider] 18 | } 19 | return undefined 20 | }, 21 | getHullToken : function(){ return state.access_token; }, 22 | getState : function(){ return state; }, 23 | 24 | dispatcherIndex: RemoteDispatcher.register(function(payload) { 25 | var action = payload.action; 26 | var text; 27 | 28 | switch(action.actionType) { 29 | case RemoteConstants.UPDATE_REMOTE_CONFIG: 30 | state = action.config 31 | if(!action.options.silent===true){ 32 | RemoteConfigStore.emitChange(action.actionType); 33 | } 34 | break; 35 | 36 | case RemoteConstants.UPDATE_SERVICES: 37 | state.services = action.services; 38 | if(!action.options.silent===true){ 39 | RemoteConfigStore.emitChange(action.actionType); 40 | } 41 | break; 42 | 43 | case RemoteConstants.UPDATE_USER: 44 | state.access_token = action.user.access_token 45 | if(!action.options.silent===true){ 46 | RemoteConfigStore.emitChange(action.actionType); 47 | } 48 | break; 49 | 50 | case RemoteConstants.LOGOUT_USER: 51 | delete state.access_token 52 | if(state.services && state.services.auth){ 53 | _.map(state.services, function(services, type){ 54 | _.map(services, function(service, key){ 55 | delete service.credentials 56 | // Since Hull Storage doesnt have the same format as the others :( 57 | if(type==='storage'){ delete service.params } 58 | }); 59 | }); 60 | } 61 | if(!action.options.silent===true){ 62 | RemoteConfigStore.emitChange(action.actionType); 63 | } 64 | break; 65 | 66 | } 67 | return true; 68 | }) 69 | 70 | }); 71 | 72 | module.exports = RemoteConfigStore; 73 | -------------------------------------------------------------------------------- /src/client/parse-opts.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../utils/lodash' 2 | assign = require '../polyfills/assign' 3 | 4 | ### 5 | # Parses the parameters for an API call. At this point, they can have two forms 6 | # * [String, ...] where the String is an uri. The request will be made to the default provider 7 | # * [Object, ...] where the Object describes more completely the request. It must provide a "path" key, can provide a "provider" key as well as some default parameters in the "params" key 8 | # In the second form, the optional params can be overridden through parameters at data.api calls 9 | # 10 | # The normalized form is the first one. 11 | # 12 | # @param {Array} the parameters for the API calls 13 | # @return {Array} The normalized form of parameters 14 | ### 15 | 16 | defaultProvider='hull' 17 | 18 | _stringDescription = (desc)-> 19 | [desc, defaultProvider, {}] 20 | _objectDescription = (desc)-> 21 | path = desc.path 22 | organization = desc.organization if desc.organization? 23 | provider = desc.provider || defaultProvider 24 | params = desc.params || {} 25 | [path, provider, params, organization] 26 | 27 | 28 | parse = (argsArray)-> 29 | description = argsArray.shift() 30 | [path, provider, params] = _stringDescription(description) if _.isString(description) 31 | [path, provider, params, organization] = _objectDescription(description) if _.isObject(description) 32 | 33 | throw """ 34 | Hull cannot find the Path (URI) you want to send your request to. 35 | Could you check your API Call parameters ? Here's what we have received : 36 | ${JSON.stringify(path)} 37 | """ unless path 38 | 39 | path = path.substring(1) if path[0] == "/" 40 | 41 | ret = [] 42 | ret.push(params) if params? 43 | ret = ret.concat(argsArray) 44 | 45 | callback = errback = null 46 | params = {} 47 | 48 | # Parses the params into the correct format to pass to the channel 49 | # Allows flexible signature 50 | # Expects : 51 | # {params}, method, callback, errback 52 | while (next = ret.shift()) 53 | type = typeof next 54 | if type == 'string' && !method 55 | method = next.toLowerCase() 56 | else if (type == 'function' && (!callback || !errback)) 57 | if !callback 58 | callback = next 59 | else if (!errback) 60 | errback = next 61 | else if (type == 'object') 62 | params = assign(params, next) 63 | else 64 | throw new TypeError("Invalid argument passed to Hull.api(): " + next) 65 | 66 | method ?= 'get' 67 | 68 | callback = callback || -> 69 | errback = errback || -> 70 | 71 | { 72 | opts: { provider, path, method, params, organization }, 73 | callback: callback 74 | errback : errback 75 | } 76 | 77 | 78 | module.exports = parse 79 | -------------------------------------------------------------------------------- /src/client/current-user.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../utils/lodash' 2 | EventBus = require '../utils/eventbus' 3 | cookies = require '../utils/cookies' 4 | Base64 = require '../utils/base64' 5 | clone = require '../utils/clone' 6 | getKey = require '../utils/get-key' 7 | Raven = require '../utils/raven' 8 | 9 | 10 | # Fix for http://www.hull.io/docs/users/backend on browsers where 3rd party cookies disabled 11 | fixCookies = (userSig)-> 12 | try 13 | if window.jQuery && _.isFunction(window.jQuery.fn.ajaxSend) 14 | window.jQuery(document).ajaxSend (e, xhr, opts)-> 15 | if userSig && !opts.crossDomain 16 | xhr.setRequestHeader('Hull-User-Sig', userSig) 17 | catch e 18 | 19 | 20 | setUserContext = (user)-> 21 | try 22 | if user && user.id 23 | Raven.setUserContext({ 24 | id: user.id, 25 | email: user.email 26 | }) 27 | catch e 28 | 29 | 30 | 31 | class CurrentUser 32 | 33 | constructor: -> 34 | @clear() 35 | 36 | get: (key) => 37 | # Ensure logged out user gives Null, not False 38 | return null if !key and !@me 39 | getKey(@me, key) 40 | 41 | clear: ()=> 42 | @me = null 43 | 44 | hasIdentity : (identity)=> 45 | return false unless identity? 46 | identities = @me?.identities 47 | identity = identity.toLowerCase() 48 | return false unless identities and identity 49 | _.some identities, (i) -> i.provider.toLowerCase()==identity 50 | 51 | # We force create and emit a user. 52 | setLoginStatus : (me, provider)=> 53 | @me = me 54 | EventBus.emit('hull.user.create', me) unless me?.stats?.sign_in_count? 55 | EventBus.emit('hull.user.login', me, provider) 56 | 57 | init : (me)=> 58 | # Init is silent 59 | @me = me 60 | setUserContext(me) 61 | me 62 | 63 | set : (me) => 64 | prevUpdatedAt = @me?.updated_at 65 | prevId = @me?.id 66 | 67 | # Silently update now 68 | @me = me 69 | setUserContext(me) 70 | 71 | # User was updated. Emit Update 72 | if prevUpdatedAt != me?.updated_at 73 | EventBus.emit('hull.user.update', me) 74 | if me?.id 75 | # We have a user 76 | # User changed. Do the full update. 77 | if prevId != me.id 78 | @setLoginStatus(me) 79 | else 80 | # We have no user anymore 81 | # Emit logout event 82 | if prevId 83 | EventBus.emit('hull.user.logout') 84 | 85 | me 86 | 87 | setCookies : (headers={}, appId)-> 88 | cookieName = "hull_#{appId}" 89 | if headers && headers['Hull-User-Id'] && headers['Hull-User-Sig'] 90 | val = Base64.encode(JSON.stringify(headers)) 91 | @signature = val 92 | fixCookies(val) 93 | cookies.set(cookieName, val, path: "/") 94 | else 95 | @signature = false 96 | cookies.remove(cookieName, path: "/") 97 | 98 | 99 | module.exports = CurrentUser 100 | -------------------------------------------------------------------------------- /src/flux/stores/RemoteHeaderStore.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var assign = require('../../polyfills/assign'); 3 | var RemoteDispatcher = require('../dispatcher/RemoteDispatcher'); 4 | var RemoteConstants = require('../constants/RemoteConstants'); 5 | var RemoteUserStore = require('../stores/RemoteUserStore'); 6 | 7 | var CHANGE_EVENT = 'change'; 8 | var ACCESS_TOKEN_HEADER = 'Hull-Access-Token'; 9 | var USER_ID_HEADER = 'Hull-User-Id'; 10 | var APP_ID_HEADER = 'Hull-App-Id'; 11 | 12 | var state = { 13 | headers: { 14 | 'Accept':'application/json', 15 | 'Content-Type':'application/json' 16 | } 17 | }; 18 | function getHeader(header) { 19 | return state.headers[header]; 20 | } 21 | function setHeader(header,value) { 22 | state.headers[header] = value; 23 | } 24 | function destroyHeader(header) { 25 | delete state.headers[header]; 26 | } 27 | 28 | var RemoteHeaderStore = assign({}, EventEmitter.prototype, { 29 | emitChange : function(change) {this.emit(CHANGE_EVENT, change); }, 30 | addChangeListener : function(callback) {this.on(CHANGE_EVENT, callback); }, 31 | removeChangeListener: function(callback) {this.removeListener(CHANGE_EVENT, callback); }, 32 | getState : function() {return state.headers;}, 33 | getHeader : function(h) {return state.headers[h];}, 34 | 35 | dispatcherIndex: RemoteDispatcher.register(function(payload) { 36 | var action = payload.action; 37 | var text; 38 | switch(action.actionType) { 39 | 40 | case RemoteConstants.UPDATE_REMOTE_CONFIG: 41 | if(action){ 42 | if(action.config && action.config.appId){ 43 | setHeader(APP_ID_HEADER, action.config.appId); 44 | } 45 | } 46 | break; 47 | 48 | case RemoteConstants.UPDATE_SETTINGS: 49 | if(action && action.services && action.services.auth && action.services.auth.hull && action.services.auth.hull.credentials){ 50 | setHeader(ACCESS_TOKEN_HEADER, action.services.auth.hull.credentials.access_token); 51 | } 52 | break; 53 | 54 | case RemoteConstants.UPDATE_USER: 55 | if(action.user.id != getHeader(USER_ID_HEADER) || action.user.access_token != getHeader(ACCESS_TOKEN_HEADER)){ 56 | if (action.user && action.user.access_token){ 57 | setHeader(USER_ID_HEADER, action.user.id) 58 | setHeader(ACCESS_TOKEN_HEADER,action.user.access_token); 59 | } else { 60 | destroyHeader(ACCESS_TOKEN_HEADER); 61 | destroyHeader(USER_ID_HEADER); 62 | } 63 | RemoteHeaderStore.emitChange(action.actionType); 64 | } 65 | break; 66 | 67 | case RemoteConstants.LOGOUT_USER: 68 | destroyHeader(ACCESS_TOKEN_HEADER); 69 | destroyHeader(USER_ID_HEADER); 70 | if(!action.options.silent===true){ 71 | RemoteHeaderStore.emitChange(action.actionType); 72 | } 73 | break; 74 | 75 | } 76 | return true; 77 | }) 78 | 79 | }); 80 | 81 | module.exports = RemoteHeaderStore; 82 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var path = require('path'); 3 | var webpack = require('webpack'); 4 | var config = require('./config'); 5 | var StatsPlugin = require('stats-webpack-plugin'); 6 | var assign = require('object-assign'); 7 | 8 | var devOutput = _.extend({},config.output,{publicPath: config.previewUrl+config.assetsFolder+'/'}); 9 | 10 | if(config.hotReload){ 11 | var devEntry = _.reduce(config.entry,function(entries,v,k){ 12 | entries[k] = ['webpack-dev-server/client?'+config.previewUrl, 'webpack/hot/dev-server', v]; 13 | return entries; 14 | },{}); 15 | var devPlugins = [new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin()] 16 | } else { 17 | var devEntry = config.entry 18 | var devPlugins = [new webpack.NoErrorsPlugin()] 19 | } 20 | 21 | debugOutput = assign({},config.output,{filename: '[name].debug.js',}); 22 | 23 | var uglifyPlugin = new webpack.optimize.UglifyJsPlugin({ 24 | comments: false, 25 | minimize:true, 26 | ascii_only:true, 27 | quote_keys:true, 28 | sourceMap: true, 29 | beautify: false, 30 | compress: { 31 | warnings: false, 32 | drop_console: false, 33 | drop_debugger:false, 34 | dead_code:true, 35 | // comparisons: true, 36 | // conditionals:true 37 | join_vars:true 38 | } 39 | }) 40 | 41 | module.exports = { 42 | development:{ 43 | browser: { 44 | name : 'browser', 45 | devtool : '#source-map', 46 | devServer: true, 47 | entry : devEntry, 48 | output : devOutput, 49 | resolve : { 50 | root: [path.join(__dirname, "bower_components")], 51 | extensions: config.extensions 52 | }, 53 | module : {loaders: config.loaders}, 54 | plugins: config.plugins.concat([ 55 | new webpack.DefinePlugin({'process.env': {'NODE_ENV': JSON.stringify('development') } }) 56 | ]).concat(devPlugins) 57 | } 58 | }, 59 | production:{ 60 | browser: { 61 | name : 'browser', 62 | devtool : '#source-map', 63 | entry : config.entry, 64 | output : config.output, 65 | resolve : { 66 | root: [path.join(__dirname, "bower_components")], 67 | extensions: config.extensions 68 | }, 69 | module : {loaders: config.loaders}, 70 | plugins : config.plugins.concat([ 71 | new webpack.DefinePlugin({'process.env': {'NODE_ENV': JSON.stringify('production') } }), 72 | uglifyPlugin, 73 | new webpack.optimize.DedupePlugin(), 74 | new StatsPlugin(path.join(config.outputFolder, 'stats.json'), { chunkModules: true, profile: true }) 75 | ]) 76 | } 77 | }, 78 | debug:{ 79 | browser: { 80 | name : 'browser', 81 | devtool : '#source-map', 82 | entry : config.entry, 83 | output : debugOutput, 84 | resolve : { 85 | root: [path.join(__dirname, "bower_components")], 86 | extensions: config.extensions 87 | }, 88 | module : {loaders: config.loaders}, 89 | plugins : config.plugins.concat([ 90 | new webpack.DefinePlugin({'process.env': {'NODE_ENV': JSON.stringify('production') } }), 91 | new webpack.optimize.DedupePlugin() 92 | ]) 93 | } 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /src/client/api.coffee: -------------------------------------------------------------------------------- 1 | Promise = require '../utils/promises' 2 | EventBus = require '../utils/eventbus' 3 | logger = require '../utils/logger' 4 | parseOpts = require './parse-opts' 5 | channel = require './channel' 6 | 7 | class Api 8 | constructor : (channel, currentUser, currentConfig)-> 9 | @channel = channel 10 | @currentConfig = currentConfig 11 | @currentUser = currentUser 12 | 13 | EventBus.on 'hull.settings.update', (settings)-> currentConfig.setRemote(settings,'settings') 14 | 15 | data = @currentConfig.getRemote('data') 16 | authScope = if data.headers?['Hull-Auth-Scope'] then data.headers['Hull-Auth-Scope'].split(":")[0] else '' 17 | 18 | @currentUser.setCookies(data.headers, currentConfig.get('appId')) if (data.headers?) 19 | 20 | @_message = (params, userSuccessCallback, userErrorCallback)=> 21 | 22 | ### 23 | # Sends the message described by @params to xdm 24 | # @param {Object} contains the provider, uri and parameters for the message 25 | # @param {Function} optional a success callback 26 | # @param {Function} optional an error callback 27 | # @return {Promise} 28 | ### 29 | message: ()=> 30 | return logger.error("Hull Api is not initialized yet. You should run your app from the callback of Hull.ready()") unless @channel.rpc 31 | {opts, callback, errback} = parseOpts(Array.prototype.slice.call(arguments)) 32 | 33 | new Promise (resolve, reject)=> 34 | onSuccess = (res={})=> 35 | # intercept calls and update current user 36 | @updateCurrentUser(res).then () => 37 | @updateCurrentUserCookies(res.headers, res.provider) 38 | callback(res.body) 39 | resolve(res.body) 40 | 41 | onError = (response, error)=> 42 | errback(response.message, error) 43 | reject(response.message, error) 44 | 45 | @channel.rpc.message(opts, onSuccess, onError) 46 | 47 | refreshUser: ()=> 48 | new Promise (resolve, reject)=> 49 | onSuccess = (response={})=> 50 | @currentUser.set(response.me) 51 | @currentConfig.setRemote(response.services, 'services') 52 | @updateCurrentUserCookies(response.headers, 'hull') 53 | resolve(response.me) 54 | 55 | onError = (err={})=> 56 | reject(err) 57 | 58 | @channel.rpc.refreshUser(onSuccess, onError) 59 | 60 | updateCurrentUser: (response) => 61 | header = response.headers?['Hull-User-Id'] 62 | 63 | p = Promise.resolve() 64 | return p unless header? 65 | 66 | if response.body?.id == header 67 | # Set currentUser to the response body if it contains the current user. 68 | @currentUser.set(response.body) 69 | else 70 | # Fetch the currentUser from the server if the header does not match with 71 | # the currentUser id. 72 | u = @currentUser.get() 73 | p = @refreshUser() if !u || u.id != header 74 | 75 | return p 76 | 77 | _updateCurrentUser: (user={}, headers={})=> 78 | header = headers?['Hull-User-Id'] 79 | @currentUser.set(user) if header && user?.id == header 80 | 81 | updateCurrentUserCookies: (headers, provider)=> 82 | @currentUser.setCookies(headers, @currentConfig.get('appId')) if (headers? and provider == 'hull') 83 | 84 | module.exports = Api 85 | -------------------------------------------------------------------------------- /src/hull-remote.coffee: -------------------------------------------------------------------------------- 1 | require './utils/load-polyfills' 2 | 3 | Raven = require './utils/raven' 4 | EventBus = require './utils/eventbus' 5 | logger = require './utils/logger' 6 | configNormalize = require './utils/config-normalizer' 7 | locationOrigin = require './utils/location-origin' 8 | qs = require './utils/query-string-encoder' 9 | Services = require './remote/services' 10 | Gateway = require './remote/gateway' 11 | Channel = require './remote/channel' 12 | 13 | ClientConfigStore = require './flux/stores/ClientConfigStore' 14 | RemoteHeaderStore = require './flux/stores/RemoteHeaderStore' 15 | RemoteUserStore = require './flux/stores/RemoteUserStore' 16 | RemoteConfigStore = require './flux/stores/RemoteConfigStore' 17 | 18 | RemoteActions = require './flux/actions/RemoteActions' 19 | RemoteConstants = require './flux/constants/RemoteConstants' 20 | 21 | 22 | 23 | captureException = (err, ctx)-> 24 | Raven.captureException(err, ctx) 25 | 26 | hull = undefined 27 | 28 | 29 | Hull = (remoteConfig)-> 30 | return hull if hull 31 | config = configNormalize(remoteConfig) 32 | 33 | Raven.init(config.queryParams.ravenDsn, { 34 | runtime: 'hull-remote', 35 | orgUrl: locationOrigin(), 36 | appId: config.appId 37 | }) 38 | 39 | # The access token stuff is a Safari hack: 40 | # Safari doesn't send response tokens for remote exchange 41 | RemoteActions.updateRemoteConfig(config) 42 | 43 | hull = {config} 44 | gateway = new Gateway(config) 45 | services = new Services(config, gateway) 46 | channel = new Channel(config, services) 47 | 48 | ClientConfigStore.addChangeListener (change)=> 49 | logger.init((ClientConfigStore.getState()||{}).debug) 50 | 51 | RemoteConfigStore.addChangeListener (change)=> 52 | # Notify client whenever settings change 53 | switch change 54 | when RemoteConstants.UPDATE_REMOTE_CONFIG, RemoteConstants.UPDATE_SETTINGS 55 | channel.rpc.configUpdate(RemoteConfigStore.getState()) 56 | break 57 | 58 | RemoteUserStore.addChangeListener (change)=> 59 | # Notify client whenever user changes 60 | switch change 61 | when RemoteConstants.UPDATE_USER, RemoteConstants.CLEAR_USER 62 | channel.rpc.userUpdate(RemoteUserStore.getState().user) 63 | break 64 | 65 | request = services.services.hull.request 66 | 67 | hideOnClick = (e)-> channel.rpc.hide() 68 | 69 | subscribeToEvents = (clientConfig)-> 70 | 71 | if document.addEventListener 72 | document.addEventListener('click', hideOnClick) 73 | else if document.attachEvent 74 | document.attachEvent('onclick', hideOnClick) 75 | 76 | EventBus.on 'remote.iframe.show', ()-> channel.rpc.show() 77 | EventBus.on 'remote.iframe.hide', ()-> channel.rpc.hide() 78 | 79 | EventBus.on 'remote.track', (payload)-> 80 | services.services.track.request({params:payload.params, path:payload.event}) 81 | 82 | EventBus.on 'remote.tracked', (payload)-> 83 | channel.rpc.track(payload) 84 | 85 | clientConfig 86 | 87 | 88 | channel.promise 89 | .then(subscribeToEvents) 90 | .then (clientConfig)-> 91 | RemoteActions.updateClientConfig(clientConfig) 92 | .catch (err)-> 93 | captureException(err) 94 | console.error("Could not initialize Hull: #{err.message}") 95 | 96 | Hull.version = VERSION 97 | module.exports = Hull 98 | -------------------------------------------------------------------------------- /src/client/initialize-platform.js: -------------------------------------------------------------------------------- 1 | import emptyFunction from '../utils/empty-function'; 2 | import request from 'superagent'; 3 | 4 | function initializeShopifyPlatform(context, currentConfig, hull) { 5 | const { customerId, accessToken, callbackUrl } = currentConfig.get(); 6 | 7 | try { 8 | const { pathname, search, hash } = document.location; 9 | const { domain, domain_redirect, ssl_enabled, alias_cart_enabled } = (context.app && context.app.settings) || {}; 10 | 11 | if (domain_redirect && domain && pathname.match(/^\/account\/login/) && domain !== document.location.host) { 12 | 13 | const protocol = ssl_enabled ? 'https:' : 'http:'; 14 | document.location.href = [ 15 | protocol, 16 | '//', 17 | domain, 18 | pathname, 19 | search, 20 | hash 21 | ].join(''); 22 | } 23 | } catch (err) {} 24 | 25 | const currentUser = hull.currentUser(); 26 | 27 | if (!customerId && currentUser) { 28 | hull.api('services/shopify/login', { return_to: document.location.href }).then(function(r) { 29 | // If the platform has multipass enabled and we are NOT inside the customizer 30 | // we can log the customer in without knowing his password. 31 | 32 | const { inits_count } = currentConfig.identifySession() || {}; 33 | 34 | if (r.auth === 'multipass' && inits_count < 2) { 35 | if (!(callbackUrl || '').match('__hull_proxy__')) { 36 | let l = 'https://' + document.location.host + '/account/login/multipass/' + r.token; 37 | window.location = l; 38 | } 39 | } else { 40 | hull.logout(); 41 | } 42 | }); 43 | } else if (/^[0-9]+$/.test(customerId) && (!accessToken || !currentUser)) { 44 | hull.api('services/shopify/customers/' + customerId, 'put'); 45 | } 46 | 47 | if (customerId) { 48 | Hull.on('hull.user.logout', function() { 49 | document.location = '/account/logout'; 50 | }); 51 | } 52 | 53 | function aliasCart(cart = {}) { 54 | if (cart && cart.token) { 55 | const CART_ALIAS_KEY = 'cartAliasToken'; 56 | const aliasToken = [cart.token, customerId].join('-'); 57 | if (aliasToken !== currentConfig.storageGet(CART_ALIAS_KEY)) { 58 | hull.api('services/shopify/cart', { cart }, 'post').then(function() { 59 | currentConfig.storageSet(CART_ALIAS_KEY, aliasToken); 60 | }); 61 | } 62 | } 63 | } 64 | 65 | // if (window.HullShopify && window.HullShopify.cart && window.HullShopify.cart.token) { 66 | // aliasCart(window.HullShopify.cart); 67 | // } else if (window.HullShopify && window.HullShopify.template) { 68 | // let browser = {}; 69 | // try { 70 | // browser = currentConfig.identifyBrowser(); 71 | // } catch (err) { 72 | // browser = {} 73 | // } 74 | // request.post('/cart.js') 75 | // .send({}) 76 | // .set('Accept', 'application/json') 77 | // .end((err, res={}) => { 78 | // aliasCart(res.body); 79 | // }) 80 | // } 81 | } 82 | 83 | function getPlatformInitializer(platform) { 84 | if (platform.type === 'platforms/shopify_shop') { 85 | return initializeShopifyPlatform; 86 | } else { 87 | return emptyFunction; 88 | } 89 | } 90 | 91 | function initializePlatform(context, currentConfig, hull) { 92 | const initializer = getPlatformInitializer(context.app); 93 | return initializer(context, currentConfig, hull); 94 | } 95 | 96 | module.exports = initializePlatform; 97 | -------------------------------------------------------------------------------- /src/client/script-tag-config.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../utils/lodash' 2 | logger = require '../utils/logger' 3 | 4 | dasherize = (str)-> 5 | str.replace /[A-Z]/, (c) -> "-" + c.toLowerCase() 6 | 7 | origin = window.location.origin || window.location.protocol + "//" + window.location.hostname 8 | if window.location.port 9 | origin += ':' + window.location.port 10 | 11 | httpsRegex = /^https:|^\/\// 12 | 13 | valid = 14 | regex: (reg)-> (val, key)-> 15 | check = reg.test(val) 16 | if !check 17 | logger.error("[Hull.init] invalid format for '#{key}'. Value '#{val}' should match #{reg.toString()}") 18 | check 19 | 20 | https: (val, key)-> 21 | check = httpsRegex.test(val) 22 | return true if check 23 | logger.warn("[Hull.init] #{key} should be loaded via https. Current value is ", val) 24 | true 25 | 26 | 27 | transform = 28 | url: (url, key)-> 29 | if url && url.length > 0 30 | a = document.createElement('a') 31 | a.href = url 32 | a.href 33 | 34 | bool: (str, key)-> 35 | switch str 36 | when 'true', true then true 37 | when 'false', false then false 38 | else undefined 39 | 40 | initParams = { 41 | appId: { 42 | validation: valid.regex(/^[a-f0-9]{24}$/i) 43 | required: true 44 | altKeys: ['shipId', 'platformId'] 45 | }, 46 | orgUrl: { 47 | validation: valid.https 48 | required: true 49 | }, 50 | jsUrl: { 51 | transform: transform.url 52 | validation: valid.https 53 | altKeys: ['src'] 54 | }, 55 | callbackUrl: { 56 | transform: transform.url 57 | validation: valid.regex(new RegExp("^" + origin + "/")) 58 | }, 59 | debug : { default: false, transform: transform.bool }, 60 | verbose : { default: false, transform: transform.bool }, 61 | embed : { default: true, transform: transform.bool }, 62 | autoStart : { default: true, transform: transform.bool }, 63 | accessToken : { default: null }, 64 | customerId : { default: null }, 65 | anonymousId : { default: null }, 66 | sessionId : { default: null }, 67 | ravenDsn : { default: null } 68 | } 69 | 70 | getAttribute = (el, k)-> 71 | el.getAttribute("data-" + dasherize(k)) || el.getAttribute("data-" + k) || el.getAttribute(dasherize(k)) || el.getAttribute(k) 72 | 73 | getParamValue = (el, param, key)-> 74 | keys = [key].concat(param.altKeys || []) 75 | transform = param.transform || (o)-> o 76 | value = _.reduce(keys, (ret, k)-> 77 | if ret == null 78 | value = transform(getAttribute(el, k), key) 79 | valid = value? && (!param.validation || param.validation(value, k, el)) 80 | ret = value if valid 81 | ret 82 | , null) 83 | 84 | if value? then value else param.default 85 | 86 | module.exports = -> 87 | hull_js_sdk = document.getElementById('hull-js-sdk') || document.querySelector('[org-url][platform-id][data-org-url][data-platform-id]') 88 | 89 | return unless hull_js_sdk 90 | 91 | # Map known config values to Hull init 92 | out = _.reduce initParams, (config, param, key)-> 93 | return unless config 94 | value = getParamValue(hull_js_sdk, param, key) 95 | if value? 96 | config[key] = value 97 | else if param.required 98 | config = false 99 | config 100 | , {} 101 | if out 102 | logger.error("[Hull.init] jsUrl NEEDS be loaded via https if orgUrl is https ") if httpsRegex.test(out.orgUrl) and not httpsRegex.test(out.jsUrl) 103 | out 104 | -------------------------------------------------------------------------------- /src/client/embeds/strategies/js.coffee: -------------------------------------------------------------------------------- 1 | Promise = require '../../../utils/promises' 2 | _ = require '../../../utils/lodash' 3 | setStyle = require '../../../utils/set-style' 4 | logger = require '../../../utils/logger' 5 | throwErr = require '../../../utils/throw' 6 | scriptLoader = require '../../../utils/script-loader' 7 | Sandbox = require '../sandbox' 8 | 9 | scripts = {} 10 | 11 | getScript = (deployment)-> scripts[deployment.ship.id] 12 | setScript = (deployment, sc) -> scripts[deployment.ship.id] = sc 13 | 14 | class JSDeploymentStrategy 15 | scopeStyles : false 16 | 17 | constructor : (deployment)-> 18 | @deployment = deployment 19 | @sandbox = new Sandbox(deployment) 20 | @ready = {} 21 | @insertions = [] 22 | @ready.promise = new Promise (resolve, reject)=> 23 | @ready.resolve = resolve 24 | @ready.reject = reject 25 | 26 | addShipClasses : (el)-> 27 | el.className = el.className + " ship-#{@deployment.ship.id} ship-deployment-#{@deployment.id}" 28 | el.setAttribute('data-hull-deployment', @deployment.id) 29 | el.setAttribute('data-hull-ship', @deployment.ship.id) 30 | 31 | ###* 32 | * Insert an element at the right position relative to a target. 33 | * @param {Node} el The element to insert 34 | * @param {Node} target The target where to insert the content 35 | * @return {Node} el 36 | ### 37 | insert: (el, target)-> 38 | @addShipClasses(el) 39 | setStyle(el, {width:@deployment.settings._width || '100%', height:@deployment.settings.height}) 40 | switch @deployment.settings._placement 41 | when 'before' then target.parentNode.insertBefore(el, target) 42 | when 'after' then target.parentNode.insertBefore(el, target.nextSibling) 43 | when 'top' then target.insertBefore(el, target.firstChild) 44 | when 'replace' 45 | if target.nodeName == 'IMG' 46 | target.parentNode.replaceChild(el, target) 47 | else 48 | target.removeChild(target.firstChild) while target.firstChild 49 | target.appendChild(el) 50 | else 51 | # Embed at append 52 | target.appendChild(el) 53 | el 54 | 55 | destroy: ()=> 56 | _.map @insertions, (insertion)-> insertion.el?.parentNode?.removeChild(insertion.el) 57 | @insertions = [] 58 | 59 | ###* 60 | * Embeds a Script in the page 61 | * @return {promise} a promise for the onLoad event 62 | ### 63 | embed : (targets)-> 64 | @elements = [] 65 | 66 | for target in targets 67 | el = document.createElement('div') 68 | @addInsertion(@insert(el, target)) 69 | 70 | 71 | sc = document.querySelector("[data-hull-ship-script=\"#{@deployment.ship.id}\"]"); 72 | if !getScript(@deployment) 73 | setScript(@deployment, true) 74 | attributes = 75 | 'data-hull-deployment' : @deployment.id 76 | 'data-hull-ship-script' : @deployment.ship.id 77 | 78 | scriptLoader({src:@deployment.ship.index, attributes}) 79 | .then (args...)=> 80 | @ready.resolve(args...) 81 | .catch (err)=> 82 | @ready.reject(err) 83 | throwErr(err) 84 | else 85 | new Promise (resolve, reject)=> 86 | @ready.resolve() 87 | resolve() 88 | 89 | addInsertion : (el)=> 90 | @insertions.push el 91 | 92 | onEmbed: (callback)=> 93 | if callback 94 | @ready.promise.then ()=> 95 | _.map @insertions, (insertion)=> 96 | callback(insertion, @deployment.getPublicData(), @sandbox.hull) 97 | .catch throwErr 98 | 99 | module.exports = JSDeploymentStrategy 100 | -------------------------------------------------------------------------------- /src/client/channel.coffee: -------------------------------------------------------------------------------- 1 | xdm = require '../utils/xdm' 2 | domready = require '../utils/domready' 3 | _ = require '../utils/lodash' 4 | EventBus = require '../utils/eventbus' 5 | logger = require '../utils/logger' 6 | Promise = require '../utils/promises' 7 | 8 | hiddenFrameStyle = 9 | top: '-20px' 10 | left: '-20px' 11 | bottom: 'auto' 12 | right: 'auto' 13 | width: '1px' 14 | height: '1px' 15 | display: 'block' 16 | position: 'fixed' 17 | zIndex: undefined 18 | overflow: 'hidden' 19 | 20 | shownFrameStyle = 21 | top: '0px' 22 | left: '0px' 23 | right: '0px' 24 | bottom: '0px' 25 | width: '100%' 26 | height: '100%' 27 | display: 'block' 28 | position: 'fixed' 29 | zIndex: 10000 30 | overflow: 'auto' 31 | 32 | rpcFrameInitStyle = 33 | tabIndex: -1 34 | height: "0" 35 | width: "1px" 36 | style: 37 | position: 'fixed' 38 | width: "1px" 39 | height: "1px" 40 | top: '-20px' 41 | left: '-20px' 42 | overflow: 'hidden' 43 | 44 | class Channel 45 | 46 | constructor : (currentUser, currentConfig)-> 47 | @allowRetry = true 48 | @retryCount = 0 49 | @currentConfig = currentConfig 50 | @currentUser = currentUser 51 | @timeout = null 52 | @rpc = null 53 | @_ready = {} 54 | @promise = new Promise (resolve, reject)=> 55 | @_ready.resolve = resolve 56 | @_ready.reject = reject 57 | domready(@startRpc) 58 | 59 | startRpc: => 60 | @retryCount += 1 61 | @timeout = setTimeout(@loadingFailed, @retryCount * 10000) 62 | @rpc = new xdm.Rpc 63 | remote : @currentConfig.getRemoteUrl() 64 | container : document.body 65 | channel : [@currentConfig.get('appId'), @retryCount].join("-") 66 | props : rpcFrameInitStyle 67 | , 68 | remote : 69 | message : {}, 70 | ready : {}, 71 | refreshUser : {}, 72 | resetIdentify : {} 73 | local: 74 | message : @onMessage 75 | ready : @ready 76 | loadError : @loadError 77 | userUpdate : @userUpdate 78 | configUpdate : @configUpdate 79 | show : @showIframe 80 | hide : @hideIframe 81 | track : @emitTrackEvent 82 | getClientConfig : @getClientConfig 83 | @rpc 84 | 85 | loadError: (err)=> 86 | window.clearTimeout(@timeout) 87 | @allowRetry = false 88 | @_ready.reject new Error(err) 89 | 90 | loadingFailed: (err) => 91 | @rpc && @rpc.destroy() 92 | if @allowRetry && @retryCount < 4 93 | @startRpc() 94 | else 95 | @_ready.reject(new Error('Remote loading has failed. Please check "orgUrl" and "appId" in your configuration. This may also be about connectivity.')) 96 | 97 | onMessage : (e)-> 98 | if e.error then @_ready.reject(e.error) else logger.log("RPC Message", arguments) 99 | 100 | ready : (remoteConfig) => 101 | try 102 | window.clearTimeout(@timeout) 103 | @currentConfig.initRemote(remoteConfig) 104 | @_ready.resolve @ 105 | catch err 106 | @_ready.reject(err) 107 | 108 | getClientConfig : () => @currentConfig.get() 109 | showIframe : () => @applyFrameStyle(shownFrameStyle) 110 | hideIframe : () => @applyFrameStyle(hiddenFrameStyle) 111 | emitTrackEvent : (args...) => EventBus.emit('hull.track',args...) 112 | configUpdate : (config)=> @currentConfig.setRemote(config) 113 | userUpdate : (me)=> @currentUser.set(me) 114 | applyFrameStyle : (styles)=> 115 | _.map styles, (v,k)=> @rpc.iframe.style[k] = v 116 | 117 | 118 | module.exports = Channel 119 | -------------------------------------------------------------------------------- /src/remote/services.coffee: -------------------------------------------------------------------------------- 1 | _ = require '../utils/lodash' 2 | logger = require '../utils/logger' 3 | Promise = require '../utils/promises' 4 | assign = require '../polyfills/assign' 5 | 6 | ServiceList = require './services/list' 7 | 8 | cookies = require '../utils/cookies' 9 | 10 | RemoteHeaderStore = require '../flux/stores/RemoteHeaderStore' 11 | RemoteUserStore = require '../flux/stores/RemoteUserStore' 12 | RemoteConfigStore = require '../flux/stores/RemoteConfigStore' 13 | 14 | RemoteActions = require '../flux/actions/RemoteActions' 15 | RemoteConstants = require '../flux/constants/RemoteConstants' 16 | 17 | handleSpecialRoutes = (request)-> 18 | switch request.path 19 | when '/api/v1/logout' 20 | request.nocallback = true 21 | RemoteActions.logoutUser() 22 | break 23 | 24 | request 25 | 26 | maybeUpdateUser = (response)-> 27 | # Pass every return call to the API to maybe update the User if the api call was a '/me' call 28 | # We do this because Me can have aliases such as the user's ID. 29 | RemoteActions.updateUserIfMe(response) 30 | response 31 | 32 | class Services 33 | constructor : (remoteConfig, gateway)-> 34 | RemoteActions.updateUser(remoteConfig.data.me) if (remoteConfig.data.me) 35 | @gateway = gateway 36 | gateway.before(handleSpecialRoutes) 37 | gateway.after(maybeUpdateUser) 38 | 39 | @services = _.reduce ServiceList, (memo,Service,key)-> 40 | memo[key] = new Service(remoteConfig,gateway) 41 | return memo 42 | , {} 43 | 44 | RemoteHeaderStore.addChangeListener (change)=> 45 | switch change 46 | when RemoteConstants.UPDATE_USER 47 | # Auto-Fetch the user everytime the Hull-User-Id header changes 48 | userHeaderId = RemoteHeaderStore.getHeader('Hull-User-Id') 49 | user = RemoteUserStore.getState().user 50 | @onRefreshUser() if userHeaderId != user?.id 51 | break 52 | 53 | getMethods : ()-> 54 | { 55 | ready : @onReady 56 | message : @onMessage 57 | refreshUser : @onRefreshUser, 58 | resetIdentify: @onResetIdentify 59 | } 60 | 61 | onResetIdentify: (xdmCallback, xdmErrback)=> 62 | cookies.remove('_bid', path: '/', domain: document.location.host) 63 | cookies.remove('_sid', path: '/', domain: document.location.host) 64 | @gateway.resetIdentify() 65 | 66 | onReady : (req, xdmCallback, xdmErrback) -> 67 | 68 | onRefreshUser : (xdmCallback, xdmErrback)=> 69 | xdmCallback ?=-> 70 | xdmErrback ?=-> 71 | # Those calls skip the middleware queue to prevent request loops 72 | me = @services.hull.request({path:'me',nocallback:true}) 73 | settings = @services.hull.request({path:'app/settings',nocallback:true}) 74 | 75 | onSuccess = (res)=> 76 | # Refreshing the User results in us setting everything up again 77 | me = res[0].body 78 | services = res[1].body 79 | RemoteActions.updateUser(me,{silent:true}) 80 | RemoteActions.updateServices(services,{silent:true}) 81 | # Do not send the data back. We're just refreshing stuff. 82 | # Data will come back through even handlers on Flux Stores 83 | xdmCallback({me,services, headers: res[0].headers}) 84 | undefined 85 | 86 | onError = (res={})-> 87 | xdmErrback(res.response || res) 88 | error = new Error(res?.response?.message || res?.response || res) 89 | logger.error 'Failed refreshing User', error.message, error.stack 90 | throw error 91 | 92 | Promise.all([me, settings]).then onSuccess, onError 93 | 94 | undefined 95 | 96 | handleAdminCall: (request, callback, errback)=> 97 | 98 | onMessage : (request, xdmCallback, xdmErrback)=> 99 | xdmCallback ?=-> 100 | xdmErrback ?=-> 101 | throw new Error("Path not recognized #{JSON.stringify(request, null, 2)}") unless request.path 102 | service = @services[request.provider] 103 | if _.isFunction service.request 104 | service.request request, xdmCallback, xdmErrback 105 | return undefined 106 | else 107 | xdmErrback request 108 | 109 | undefined 110 | 111 | module.exports = Services 112 | -------------------------------------------------------------------------------- /src/remote/services/hull.coffee: -------------------------------------------------------------------------------- 1 | Promise = require '../../utils/promises' 2 | _ = require '../../utils/lodash' 3 | EventBus = require '../../utils/eventbus' 4 | Base64 = require '../../utils/base64' 5 | getWrappedRequest = require '../wrapped-request' 6 | scriptLoader = require '../../utils/script-loader'; 7 | assign = require '../../polyfills/assign'; 8 | displayBanner = require '../../utils/ui/display-banner' 9 | { addEvent, removeEvent } = require '../../utils/dom-events' 10 | 11 | RECAPTCHA_ONLOAD = '___recaptcha___' 12 | RECAPTCHA_SRC = "https://www.google.com/recaptcha/api.js?onload=#{RECAPTCHA_ONLOAD}&render=explicit" 13 | 14 | _loadRecaptchaScriptPromise = null; 15 | loadRecaptchaScript = -> 16 | if (_loadRecaptchaScriptPromise?) 17 | return _loadRecaptchaScriptPromise 18 | 19 | _loadRecaptchaScriptPromise = new Promise (resolve) -> 20 | window[RECAPTCHA_ONLOAD] = resolve 21 | scriptLoader({ src: RECAPTCHA_SRC }); 22 | return null 23 | 24 | return _loadRecaptchaScriptPromise; 25 | 26 | class TrackEventMatcher 27 | #TODO Make this clearer. 28 | constructor: (config)-> 29 | if config == false || config?.ignore? 30 | @mode = 'ignore' 31 | @_initMatchers(config.ignore) 32 | else if !config? 33 | @mode = 'match_all' 34 | else 35 | @mode = 'match' 36 | @_initMatchers(config?.only || config) 37 | 38 | _initMatchers: (m)-> 39 | m = [m] if _.isString(m) 40 | @matchers = _.map _.compact(m), (c)-> 41 | _c = c.toString() 42 | if /^\/.*\/$/.test(_c) 43 | new RegExp(_c.slice(1,-1)) 44 | else 45 | _c 46 | 47 | isTracked: (event)-> 48 | return false unless event? 49 | return true if @mode == 'match_all' 50 | ret = _.some _.map @matchers, (m)-> 51 | if _.isFunction(m.test) 52 | m.test(event) 53 | else 54 | m == event 55 | if @mode == 'ignore' 56 | return !ret 57 | else 58 | return ret 59 | 60 | # Ajax Response Middlewares 61 | trackResponse = (response={})=> 62 | if track = response.headers?['Hull-Track'] 63 | try 64 | [eventName, payload] = JSON.parse(Base64.decode(track)) 65 | EventBus.emit('remote.track', { event:eventName, params: { payload, url: document.referrer } }) 66 | catch error 67 | # Don't throw an error here but report what happened. 68 | "Invalid Tracking header : ${JSON.stringify(errror,null,2)}" 69 | return response 70 | 71 | stopEventPropagation = (e) -> 72 | e.stopPropagation() 73 | 74 | removeBanner = (banner) -> 75 | removeEvent(banner, 'click', stopEventPropagation) 76 | banner.remove() 77 | 78 | class HullService 79 | constructor: (config, gateway)-> 80 | @config = config 81 | middlewares = [@ensureIsHuman, trackResponse] 82 | 83 | @request = getWrappedRequest({ name:'hull', path: null }, gateway, middlewares) 84 | @trackMatcher = new TrackEventMatcher(config.track) 85 | 86 | ensureIsHuman: (response) => 87 | return response if (!response.body?) 88 | 89 | { sitekey, stoken } = response.body; 90 | return response if (response.status != 429 || !sitekey? || !stoken?) 91 | 92 | banner = @_getCaptchaBanner() 93 | performRequest = @request 94 | return new Promise (resolve, reject) -> 95 | loadRecaptchaScript().then -> 96 | grecaptcha.render banner.querySelector('.hull-captcha-container'), 97 | sitekey: sitekey, 98 | stoken: stoken, 99 | callback: (captchaResponse) -> 100 | EventBus.emit('remote.iframe.hide'); 101 | removeBanner(banner) 102 | 103 | requestWithCaptcha = assign {}, response.request, 104 | headers: assign {}, response.request.headers, { 'X-Captcha-Response': captchaResponse } 105 | performRequest(requestWithCaptcha).then(resolve, reject) 106 | 107 | document.body.appendChild(banner) 108 | 109 | EventBus.emit('remote.iframe.show'); 110 | 111 | _getCaptchaBanner: -> 112 | removeBanner(@_banner) if @_banner 113 | 114 | @_banner = displayBanner('captcha', false) 115 | addEvent(@_banner, 'click', stopEventPropagation) 116 | 117 | @_banner 118 | 119 | module.exports = HullService 120 | -------------------------------------------------------------------------------- /src/utils/base64.js: -------------------------------------------------------------------------------- 1 | // https://code.google.com/p/stringencoders/source/browse/trunk/javascript/base64.js?r=230 2 | 3 | const PADCHAR = '='; 4 | const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; 5 | 6 | function makeDOMException() { 7 | // sadly in FF,Safari,Chrome you can't make a DOMException 8 | var e, tmp; 9 | 10 | try { 11 | return new DOMException(DOMException.INVALID_CHARACTER_ERR); 12 | } catch (tmp) { 13 | // not available, just passback a duck-typed equiv 14 | // https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Error 15 | // https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Error/prototype 16 | var ex = new Error("DOM Exception 5"); 17 | 18 | // ex.number and ex.description is IE-specific. 19 | ex.code = ex.number = 5; 20 | ex.name = ex.description = "INVALID_CHARACTER_ERR"; 21 | 22 | // Safari/Chrome output format 23 | ex.toString = function() { return 'Error: ' + ex.name + ': ' + ex.message; }; 24 | return ex; 25 | } 26 | } 27 | 28 | function getbyte64(s,i) { 29 | // This is oddly fast, except on Chrome/V8. 30 | // Minimal or no improvement in performance by using a 31 | // object with properties mapping chars to value (eg. 'A': 0) 32 | var idx = ALPHA.indexOf(s.charAt(i)); 33 | if (idx === -1) { 34 | throw makeDOMException(); 35 | } 36 | return idx; 37 | } 38 | 39 | function getbyte(s,i) { 40 | var x = s.charCodeAt(i); 41 | if (x > 255) { 42 | throw makeDOMException(); 43 | } 44 | return x; 45 | } 46 | 47 | function encode(s) { 48 | if (arguments.length !== 1) { 49 | throw new SyntaxError("Not enough arguments"); 50 | } 51 | var padchar = PADCHAR; 52 | var alpha = ALPHA; 53 | 54 | var i, b10; 55 | var x = []; 56 | 57 | // convert to string 58 | s = '' + s; 59 | 60 | var imax = s.length - s.length % 3; 61 | 62 | if (s.length === 0) { 63 | return s; 64 | } 65 | for (i = 0; i < imax; i += 3) { 66 | b10 = (getbyte(s,i) << 16) | (getbyte(s,i+1) << 8) | getbyte(s,i+2); 67 | x.push(alpha.charAt(b10 >> 18)); 68 | x.push(alpha.charAt((b10 >> 12) & 0x3F)); 69 | x.push(alpha.charAt((b10 >> 6) & 0x3f)); 70 | x.push(alpha.charAt(b10 & 0x3f)); 71 | } 72 | switch (s.length - imax) { 73 | case 1: 74 | b10 = getbyte(s,i) << 16; 75 | x.push(alpha.charAt(b10 >> 18) + alpha.charAt((b10 >> 12) & 0x3F) + 76 | padchar + padchar); 77 | break; 78 | case 2: 79 | b10 = (getbyte(s,i) << 16) | (getbyte(s,i+1) << 8); 80 | x.push(alpha.charAt(b10 >> 18) + alpha.charAt((b10 >> 12) & 0x3F) + 81 | alpha.charAt((b10 >> 6) & 0x3f) + padchar); 82 | break; 83 | } 84 | return x.join(''); 85 | } 86 | 87 | function decode(s) { 88 | // convert to string 89 | s = '' + s; 90 | var pads, i, b10; 91 | var imax = s.length 92 | if (imax === 0) { 93 | return s; 94 | } 95 | 96 | if (imax % 4 !== 0) { 97 | throw makeDOMException(); 98 | } 99 | 100 | pads = 0 101 | if (s.charAt(imax - 1) === PADCHAR) { 102 | pads = 1; 103 | if (s.charAt(imax - 2) === PADCHAR) { 104 | pads = 2; 105 | } 106 | // either way, we want to ignore this last block 107 | imax -= 4; 108 | } 109 | 110 | var x = []; 111 | for (i = 0; i < imax; i += 4) { 112 | b10 = (getbyte64(s,i) << 18) | (getbyte64(s,i+1) << 12) | 113 | (getbyte64(s,i+2) << 6) | getbyte64(s,i+3); 114 | x.push(String.fromCharCode(b10 >> 16, (b10 >> 8) & 0xff, b10 & 0xff)); 115 | } 116 | 117 | switch (pads) { 118 | case 1: 119 | b10 = (getbyte64(s,i) << 18) | (getbyte64(s,i+1) << 12) | (getbyte64(s,i+2) << 6); 120 | x.push(String.fromCharCode(b10 >> 16, (b10 >> 8) & 0xff)); 121 | break; 122 | case 2: 123 | b10 = (getbyte64(s,i) << 18) | (getbyte64(s,i+1) << 12); 124 | x.push(String.fromCharCode(b10 >> 16)); 125 | break; 126 | } 127 | return x.join(''); 128 | } 129 | 130 | function encodeURL(input) { 131 | return encodeURIComponent(encode(input).replace(/\+/g, '-').replace(/\//g, '_')); 132 | } 133 | 134 | function decodeURL(input) { 135 | return decode(decodeURIComponent(input).replace(/-/g, '+').replace(/_/g, '/')); 136 | } 137 | 138 | module.exports = { encode, decode, encodeURL, decodeURL }; 139 | -------------------------------------------------------------------------------- /src/remote/services/facebook.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../../polyfills/assign' 2 | Promise = require '../../utils/promises' 3 | _ = require '../../utils/lodash' 4 | EventBus = require '../../utils/eventbus' 5 | clone = require '../../utils/clone' 6 | GenericService = require './generic-service' 7 | 8 | FB_EVENTS = ["auth.authResponseChanged", "auth.statusChange", "auth.login", "auth.logout", "comment.create", "comment.remove", "edge.create", "edge.remove", "message.send", "xfbml.render"] 9 | 10 | class FacebookService extends GenericService 11 | name : 'facebook' 12 | path : 'facebook' 13 | constructor: (config, gateway)-> 14 | super(config, gateway) 15 | settings = @getSettings() 16 | @loadFbSdk(settings) if settings?.appId 17 | 18 | request : (args...)=> 19 | @ensureLoggedIn().then ()=> @performRequest(args...) 20 | 21 | ensureLoggedIn : ()=> 22 | self = this 23 | new Promise (resolve, reject)=> 24 | args = Array.prototype.slice.call(arguments) 25 | if @fbUser?.status=='connected' 26 | resolve() 27 | else 28 | FB.getLoginStatus (res)=> 29 | self.updateFBUserStatus(res) 30 | if res.status=='connected' then resolve() else reject() 31 | , true 32 | # , true ~ Maybe we dont need a roundtrip each time. 33 | 34 | performRequest: (request, callback, errback) => 35 | path = request.path 36 | isUICall = (path=='ui' and request.params?.method?) 37 | 38 | fbErrback = (msg, res)-> 39 | res.time = new Date() 40 | errback(res) 41 | 42 | fbCallback = @fbRequestCallback(request, {isUICall, path}, callback, errback) 43 | 44 | if path == 'fql.query' 45 | FB.api({ method: 'fql.query', query: request.params.query },fbCallback) 46 | 47 | else if isUICall 48 | params = clone(request.params) 49 | 50 | @showIframe() 51 | trackParams = { ui_request_id: utils?.uuid?() || (new Date()).getTime() } 52 | @track {event:"facebook.#{path}.open", params:assign({}, request.params, trackParams)} 53 | 54 | setTimeout ()=> 55 | FB.ui params, (response)=> 56 | event = "facebook.#{path}." 57 | event += if !response || response.error_code then "error" else "success" 58 | @track {event, params:assign({}, response, trackParams)} 59 | fbCallback(response) 60 | , 100 61 | 62 | else 63 | FB.api path, request.method, request.params, @fbRequestCallback(request, {isUICall:false, path}, callback, fbErrback) 64 | 65 | 66 | showIframe : -> 67 | EventBus.emit('remote.iframe.show') if @fb 68 | 69 | hideIframe : -> 70 | EventBus.emit('remote.iframe.hide') if @fb 71 | 72 | track : (args...)-> 73 | EventBus.emit('remote.track', args...) 74 | 75 | fbRequestCallback : (request, opts={}, callback, errback) => 76 | (response) => 77 | @fbUiCallback(request,response,opts.path) if opts.isUICall 78 | if !response or response?.error 79 | errorMsg = if (response) then "[Facebook Error] #{response.error.type} : #{response.error.message}" else "[Facebook Error] Unknown error" 80 | return errback(errorMsg, { response, request }) 81 | callback({ body: response, provider: 'facebook' }) 82 | 83 | fbUiCallback : (req, res, path)=> 84 | @hideIframe() 85 | opts = { path: path, method: 'post', params: res } 86 | @wrappedRequest(opts) 87 | 88 | updateFBUserStatus: (res)=> 89 | @fbUser = res 90 | 91 | subscribeToFBEvents : ()=> 92 | return unless FB?.Event? 93 | self = this 94 | _.map FB_EVENTS, (event)=> 95 | FB.Event.subscribe event, (args...)=> 96 | if event.indexOf('auth.')>-1 97 | self.updateFBUserStatus(args...) 98 | EventBus.emit("fb.#{event}", args...) 99 | 100 | 101 | loadFbSdk: (config)-> 102 | new Promise (resolve, reject)=> 103 | config = _.omit(assign({},config,{status:true}), 'credentials') 104 | fb = document.createElement 'script' 105 | fb.type = 'text/javascript' 106 | fb.async = true 107 | fb.src = "https://connect.facebook.net/en_US/all.js" 108 | (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(fb); 109 | window.fbAsyncInit = ()=> 110 | @fb = true 111 | FB.init config 112 | FB.getLoginStatus @updateFBUserStatus 113 | @subscribeToFBEvents() 114 | resolve({}) 115 | 116 | 117 | module.exports = FacebookService 118 | -------------------------------------------------------------------------------- /src/client/embeds/deployment.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../../polyfills/assign' 2 | _ = require '../../utils/lodash' 3 | clone = require '../../utils/clone' 4 | throwErr = require '../../utils/throw' 5 | logger = require '../../utils/logger' 6 | Promise = require '../../utils/promises' 7 | 8 | # RawDeploymentStrategy = require './strategies/raw' 9 | # ScopeDeploymentStrategy = require './strategies/scope' 10 | # IframeDeploymentStrategy = require './strategies/iframe' 11 | JSDeploymentStrategy = require './strategies/js' 12 | 13 | deploymentRegistry = {} 14 | shipRegistry = {} 15 | 16 | resetDeployments = () -> 17 | deployment.destroy() for own key, deployment of deploymentRegistry 18 | shipRegistry = {} 19 | deploymentRegistry = {} 20 | 21 | getDeployment = (id)-> deploymentRegistry[id] 22 | getDeployments = (id)-> _.values(shipRegistry[id]) 23 | 24 | class Deployment 25 | @resetDeployments: resetDeployments 26 | @getDeployment : getDeployment 27 | @getDeployments : getDeployments 28 | 29 | constructor: (dpl, context)-> 30 | return deploymentRegistry[dpl.id] if deploymentRegistry[dpl.id] 31 | deploymentRegistry[dpl.id] = @ 32 | 33 | @id = dpl.id 34 | @name = dpl.ship.name 35 | 36 | @organization = assign({}, context.org) 37 | @platform = dpl.platform 38 | @ship = dpl.ship 39 | 40 | shipRegistry[dpl.ship.id] ||= {} 41 | shipRegistry[dpl.ship.id][dpl.id] = @ 42 | 43 | 44 | # onUpdate is used to attach handlers for ship.update events emitted from Hull's dashboard 45 | @onUpdate = dpl.onUpdate 46 | 47 | @settings = dpl.settings 48 | # "_selector" : ".ship", //CSS3 Selector on which to embed the ship(s) 49 | # "_multi": true, //Wether to embed on the first matching element or all 50 | # "_placement" : "before"|"after"|"append"|"top"|"replace", //Position relative to selector 51 | # "_sandbox" : true //Wether to sandbox the platform : true 52 | # "_width" : "100%", //Dimensions to give the containing element. Passed as-is as style tag 53 | # "_height" : "50px", //Dimensions to give the containing element. Passed as-is as style tag 54 | 55 | ###* 56 | * Fetches all targets specified in a deployment 57 | * @param {object} opts options object. opts.refresh = true|false // Force Refresh 58 | * @return {Nodes Array} A memoized array of Nodes matching the Query Selector (this.targets) 59 | ### 60 | getTargets : (opts={})-> 61 | return @targets if @targets and !opts.refresh 62 | return [] unless @settings._selector 63 | 64 | targets = if @settings._multi 65 | document.querySelectorAll(@settings._selector) 66 | else 67 | target = document.querySelector(@settings._selector) 68 | if target then [target] else [] 69 | 70 | logger.info("No deployment targets for selector #{@settings._selector}", @) unless targets.length 71 | targets 72 | 73 | getPublicData: ()=> 74 | assign(clone({ 75 | ship : @ship 76 | organization : @organization 77 | platform : @platform 78 | settings : @settings 79 | }), { 80 | onUpdate: (@onUpdate || ->) 81 | }) 82 | 83 | getDeploymentStrategy : ()=> 84 | return @deploymentStrategy if @deploymentStrategy? 85 | 86 | # DS = if @ship.index.match(/\.js$/) 87 | # JSDeploymentStrategy 88 | # else if @settings._sandbox == 'raw' 89 | # RawDeploymentStrategy 90 | # else if !!@settings._sandbox 91 | # IframeDeploymentStrategy 92 | # else 93 | # ScopeDeploymentStrategy 94 | 95 | # @deploymentStrategy = new DS(@) 96 | @deploymentStrategy = new JSDeploymentStrategy(@) 97 | @deploymentStrategy 98 | 99 | embed : (opts={})=> 100 | # If we're refreshing, rebuild the target list 101 | @targets = @getTargets(opts) 102 | ds = @getDeploymentStrategy() 103 | if @targets.length 104 | ds.embed(@targets, opts).then ()=> 105 | @onEmbed() 106 | ,throwErr 107 | .catch throwErr 108 | else 109 | Promise.resolve() 110 | 111 | boot: ()=> 112 | @getDeploymentStrategy().boot() if @targets?.length 113 | 114 | onEmbed : (callback)=> 115 | @getDeploymentStrategy().onEmbed(callback) if @targets?.length 116 | 117 | destroy: ()=> 118 | if @targets.length 119 | @getDeploymentStrategy().destroy() 120 | @deploymentStrategy = null 121 | 122 | module.exports = Deployment 123 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var _ = require('lodash'); 5 | var webpack = require('webpack'); 6 | var path = require('path'); 7 | var pkg = require('./package.json'); 8 | var moment = require('moment'); 9 | var git = require('git-rev-sync'); 10 | 11 | // DO NOT CHANGE FOLDERS 12 | // WIHTOUT UPDATING PACKAGE.JSON TOO. 13 | var sourceFolder = 'src'; 14 | var outputFolder = 'lib'; 15 | var assetsFolder = ''; 16 | var serverPort = process.env.PORT||3001; 17 | var previewUrl = 'http://localhost:'+serverPort; 18 | 19 | var hotReload = false; 20 | 21 | // DO NOT CHANGE SHIP ENTRY 22 | // WITHOUT UPDATING PACKAGE.JSON TOO 23 | // THESE ARE THE JS FILES USED AS ENTRY POINTS TO COMPILE YOUR APP 24 | var entry = { 25 | hull: './'+sourceFolder+'/hull.coffee' 26 | } 27 | 28 | // ADDITIONAL FILES TO BE COPIED BY GULP 29 | function gulpDest(out){ 30 | return path.join(outputFolder,assetsFolder,out); 31 | } 32 | 33 | var files = { 34 | 'app/**/*' : outputFolder, 35 | 'src/vendors/**/*' : gulpDest('vendors/'), 36 | 'src/images/**/*' : gulpDest('images/'), 37 | 'src/*.html' : outputFolder 38 | } 39 | 40 | var libName = pkg.name; 41 | var displayName = 'Hull.js'; 42 | 43 | var getAWSConfig = function(){ 44 | return { 45 | gzip: { ext:".gz" }, 46 | config : { 47 | "params":{ 48 | "Bucket": process.env.AWS_BUCKET, 49 | }, 50 | "region": process.env.AWS_REGION, 51 | "accessKeyId":process.env.AWS_KEY, 52 | "secretAccessKey":process.env.AWS_SECRET, 53 | }, 54 | cloudfront:{ 55 | credentials:{ 56 | "accessKeyId":process.env.AWS_KEY, 57 | "secretAccessKey":process.env.AWS_SECRET, 58 | }, 59 | distributionId:process.env.CLOUDFRONT_DISTRIBUTION_ID, 60 | region:"us-east-1" 61 | }, 62 | publish:{ 63 | options:{ 64 | simulate: false, 65 | }, 66 | headers: { 67 | "Cache-Control": "max-age=3600, no-transform, public" 68 | } 69 | } 70 | }; 71 | }; 72 | 73 | // ------------------------------------------------ 74 | // ------------------------------------------------ 75 | // NO NEED TO TOUCH ANYTHING BELOW THIS 76 | // ------------------------------------------------ 77 | // ------------------------------------------------ 78 | 79 | var outputPath = path.join(__dirname, outputFolder); 80 | 81 | var output = { 82 | path: path.join(outputPath,assetsFolder), 83 | pathinfo: true, 84 | filename: '[name].js', 85 | chunkFileName: '[name].chunk.js', 86 | libraryTarget: 'umd', 87 | library: displayName, 88 | publicPath: '/'+assetsFolder+'/' 89 | } 90 | 91 | var extensions = ['', '.js', '.css', '.scss', '.coffee']; 92 | var modulesDirectories = ['node_modules', 'bower_components', 'src/vendor']; 93 | 94 | var sassIncludePaths = modulesDirectories.map(function(include){ 95 | return ('includePaths[]='+path.resolve(__dirname, include)) 96 | }).join('&'); 97 | 98 | 99 | // https://github.com/webpack/react-starter/blob/master/make-webpack-config.js 100 | // 'imports?define=>false': Yeah, we're going big and disabling AMD completely. F**k it. 101 | // This is because webpack strips the `this` context when requiring those, while they expect it. 102 | // Basically, this fixes all of our problems with badly constructed AMD modules. 103 | // Among which: vex, datepicker 104 | var loaders = [ 105 | {test: /\.json$/, loader: 'json' }, 106 | {test: /\.coffee$/, loader: 'coffee'}, 107 | {test: /\.js$/, loader: 'babel', exclude: /node_modules|bower_components/}, 108 | {test: /\.jpe?g$|\.gif$|\.png$/, loader: 'file' }, 109 | {test: /\.svg$|\.woff$|\.ttf$|\.wav$|\.mp3$/, loader: 'file' }, 110 | ]; 111 | 112 | var REVISION = process.env.CIRCLE_SHA1 || git.short(); 113 | 114 | // We remove the 'dist' from the filenames for demo and index.html in package.json 115 | // Package.json expects our files to be addressable from the same repo 116 | // We put them in `dist` to have a clean structure but then we need to build them in the right place 117 | var plugins = [ 118 | new webpack.DefinePlugin({ 119 | 'VERSION' : JSON.stringify(pkg.version), 120 | 'REVISION' : JSON.stringify(REVISION), 121 | 'BUILD_DATE' : JSON.stringify(moment().format('MMMM, DD, YYYY, HH:mm:ss')), 122 | 'PUBLIC_PATH': JSON.stringify(output.publicPath) 123 | }), 124 | new webpack.ResolverPlugin( 125 | new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin("bower.json", ["main"]) 126 | ), 127 | new webpack.optimize.OccurenceOrderPlugin() 128 | ] 129 | 130 | var externals = {} 131 | 132 | module.exports = { 133 | hotReload : hotReload, 134 | aws : getAWSConfig(), 135 | libName : libName, 136 | displayName : displayName, 137 | version : pkg.version, 138 | 139 | files : files, 140 | 141 | outputFolder : outputFolder, 142 | assetsFolder : assetsFolder, 143 | serverPort : serverPort, 144 | previewUrl : previewUrl, 145 | 146 | entry : entry, 147 | output : output, 148 | extensions : extensions, 149 | modulesDirectories : modulesDirectories, 150 | plugins : plugins, 151 | loaders : loaders, 152 | externals : externals, 153 | 154 | pkg : pkg 155 | } 156 | -------------------------------------------------------------------------------- /src/remote/gateway.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../polyfills/assign' 2 | Promise = require '../utils/promises' 3 | _ = require '../utils/lodash' 4 | cookies = require '../utils/cookies' 5 | Base64 = require '../utils/base64' 6 | logger = require '../utils/logger' 7 | EventBus = require '../utils/eventbus' 8 | clone = require '../utils/clone' 9 | RemoteHeaderStore = require '../flux/stores/RemoteHeaderStore' 10 | QSEncoder = require '../utils/query-string-encoder' 11 | 12 | # require 'whatwg-fetch' #Polyfill for Global -> Not ready from primetime 13 | superagent = require 'superagent' 14 | API_PATH = '/api/v1/' 15 | API_PATH_REGEXP = /^\/?api\/v1\// 16 | RESPONSE_HEADERS = ['Hull-Auth-Scope', 'Hull-Track', 'Hull-User-Id', 'Hull-User-Sig', 'X-Hits-Count', 'Link'] 17 | 18 | TRACKING_PATHS = ["/api/v1/t", "/api/v1/me/traits", "/api/v1/me/alias"]; 19 | 20 | normalizePath = (path) -> 21 | return path.replace(API_PATH_REGEXP, API_PATH) if API_PATH_REGEXP.test(path) 22 | path = path.substring(1) if path[0] == '/' 23 | API_PATH + path 24 | 25 | batchable = (threshold, callback) -> 26 | timeout = null 27 | args = [] 28 | 29 | -> 30 | args.push Array::slice.call(arguments) 31 | clearTimeout timeout 32 | delayed = => 33 | callback.call(@, args) 34 | timeout = null 35 | args = [] 36 | timeout = setTimeout delayed, threshold 37 | 38 | reduceHeaders = (headers)-> 39 | _headers = _.reduce headers, (memo, value, key) -> 40 | memo[key.toLowerCase()] = value 41 | memo 42 | , {} 43 | _.reduce RESPONSE_HEADERS, (memo, name) -> 44 | value = _headers[name.toLowerCase()] 45 | memo[name] = value if value? 46 | memo 47 | , {} 48 | 49 | formatBatchParams = (requests) -> 50 | params = { sequential: true } 51 | params.ops = for request in requests 52 | r = request[0] 53 | c = {method: r.method, url: r.path} 54 | c.params = r.params if r.params? 55 | c.headers = r.headers if r.headers? 56 | c 57 | 58 | params 59 | 60 | resolveResponse = (request, response={}, resolve, reject)-> 61 | headers = reduceHeaders(response.headers) 62 | 63 | h = { 64 | body: response.body, 65 | status: response.status, 66 | headers: headers, 67 | request: request, 68 | } 69 | 70 | return resolve(h) 71 | 72 | class Gateway 73 | 74 | constructor: (config={}) -> 75 | { batching, appId, identify, location } = config 76 | @apiEndpoint = config.apiEndpoint 77 | @trackingEndpoint = config.trackingEndpoint 78 | @identify = identify 79 | @location = location || {} 80 | @options = _.defaults({}, batching, { min:1, max:1, delay:2 }) 81 | @queue = batchable @options.delay, (requests) -> @flush(requests) 82 | 83 | identifyBrowserAndSession: -> 84 | ident = {} 85 | ident['Hull-Bid'] = @identify.browser 86 | ident['Hull-Sid'] = @identify.session 87 | if @location 88 | ident['X-Track-Url'] = @location.url 89 | ident['X-Track-Referer'] = @location.referer 90 | ident 91 | 92 | resetIdentify: -> 93 | @identify = {} 94 | 95 | fetch : (options={}) => 96 | {method, headers, path, params} = options 97 | 98 | method = (method||'get').toUpperCase() 99 | 100 | headers = assign(@identifyBrowserAndSession(), RemoteHeaderStore.getState(), headers) 101 | if @trackingEndpoint && TRACKING_PATHS.includes(path) 102 | endpoint = [@trackingEndpoint, path].join('') 103 | else if @apiEndpoint 104 | endpoint = [@apiEndpoint, path].join('') 105 | else 106 | endpoint = path 107 | 108 | #TODO Check SuperAgent under IE8 and below 109 | s = superagent(method, endpoint).set(headers) 110 | 111 | if params? and method=='GET' then s.query(QSEncoder.encode(params)) else s.send(params) 112 | 113 | new Promise (resolve, reject)-> 114 | logger.verbose(">", method, path, params, headers) 115 | 116 | s.end (err, response={})=> 117 | logger.verbose("<", method, path, response) 118 | h = {body:response.body, headers: response.headers, status: response.status} 119 | # if (response.ok) then resolve(h) else reject(h) 120 | resolve(h) 121 | 122 | flush: (requests) -> 123 | if requests.length <= @options.min 124 | while requests.length 125 | [request, resolve, reject] = requests.pop() 126 | @handleOne(request, resolve, reject) 127 | else 128 | @handleMany(requests.splice(0, @options.max)) 129 | @flush(requests) if requests.length 130 | 131 | after_middlewares : [] 132 | before_middlewares : [] 133 | 134 | # Connect-like middleware syntax 135 | after : (cb)=> 136 | return unless (cb? and _.isFunction(cb)) 137 | @after_middlewares.push(cb) 138 | cb 139 | 140 | before : (cb)=> 141 | return unless (cb? and _.isFunction(cb)) 142 | @before_middlewares.push(cb) 143 | cb 144 | 145 | handle: (request) -> 146 | request.path = normalizePath request.path 147 | 148 | promise = new Promise (resolve, reject)=> 149 | _.each @before_middlewares, (middleware)-> middleware(request) 150 | @queue(request, resolve, reject) 151 | 152 | 153 | unless request.nocallback 154 | _.each @after_middlewares, (middleware)-> 155 | promise = promise.then middleware 156 | 157 | promise.catch (err)-> 158 | throw err 159 | 160 | # Single request posting 161 | handleOne: (request, resolve, reject) -> 162 | success = (response)-> 163 | resolveResponse(request, response, resolve, reject) 164 | 165 | error = (error)-> 166 | response = error.body 167 | reject({response, request, headers: {}}) 168 | 169 | @fetch(request).then(success, error) 170 | 171 | # Batching API posting 172 | handleMany: (requests) -> 173 | params = formatBatchParams(requests) 174 | 175 | success = (responses)-> 176 | resolveResponse(requests[i], response, requests[i][1], requests[i][2]) for response, i in responses.body.results 177 | undefined #Don't forget this to keep the promise chain alive 178 | 179 | error = (response)-> 180 | request[1].reject({response:response, headers: {}, request: request}) for request in requests 181 | 182 | @fetch({method: 'post', path: '/api/v1/batch', params }).then(success, error) 183 | 184 | module.exports = Gateway 185 | -------------------------------------------------------------------------------- /src/client/current-config.coffee: -------------------------------------------------------------------------------- 1 | localstorage = require('putainde-localstorage') 2 | _ = require '../utils/lodash' 3 | EventBus = require '../utils/eventbus' 4 | clone = require '../utils/clone' 5 | throwErr = require '../utils/throw' 6 | Base64 = require '../utils/base64' 7 | assign = require '../polyfills/assign' 8 | Promise = require '../utils/promises' 9 | getKey = require '../utils/get-key' 10 | 11 | getReferralContext = -> 12 | { 13 | initial_url: document.location.href, 14 | initial_referrer: document.referrer || '$direct', 15 | initial_referring_domain: extractDomainFromUrl(document.referrer) || '$direct', 16 | initial_utm_tags: extractUtmTags(), 17 | first_seen_at: new Date().getTime() 18 | } 19 | 20 | getRemoteUrl = (config, identifiers)-> 21 | url = "#{config.orgUrl}/api/v1/#{config.appId}/remote.html?v=#{VERSION}" 22 | url += "&url=#{encodeURIComponent(document.location.href)}" 23 | url += "&r=#{encodeURIComponent(document.referrer)}" 24 | url += "&js=#{config.jsUrl}" if config.jsUrl 25 | url += "&uid=#{config.uid}" if config.uid 26 | url += "&debug_remote=true" if config.debugRemote 27 | url += "&access_token=#{config.accessToken}" if config.accessToken? 28 | url += "&user_hash=#{config.userHash}" if config.userHash != undefined 29 | url += "&_bid=#{identifiers.bid}" if identifiers?.bid 30 | url += "&_sid=#{identifiers.sid}" if identifiers?.sid 31 | url += "&ravenDsn=#{config.ravenDsn}" if config.ravenDsn 32 | url 33 | 34 | # Parse the tracked events configuration and standardize it. 35 | formatTrackConfig = (config={})-> 36 | switch (Object.prototype.toString.call(config).match(/^\[object (.*)\]$/)[1]) 37 | when "Object" 38 | if config.only? 39 | config = { only: (m.toString() for m in config.only) } 40 | else if config.ignore? 41 | config = { ignore: (m.toString() for m in config.ignore) } 42 | else 43 | config 44 | when "RegExp" 45 | config = { only: config.toString() } 46 | when "Array" 47 | config = { only: (m.toString() for m in config) } 48 | # Setup initial referrer 49 | config.referrer = document.referrer if document?.referrer 50 | config 51 | 52 | checkConfig = (config)-> 53 | config = clone(config) 54 | config.track = formatTrackConfig(config.track) 55 | promise = new Promise (resolve, reject)=> 56 | msg = "You need to pass some keys to Hull to start it: " 57 | readMore = "Read more about this here : http://www.hull.io/docs/references/hull_js/#hull-init-params-cb-errb" 58 | # Fail right now if we don't have the required setup 59 | if config.orgUrl and config.appId 60 | # Auto add protocol if we dont have one of http://, https://, // 61 | reject(new Error(" You specified orgUrl as #{config.orgUrl}. We do not support protocol-relative URLs in organization URLs yet.")) if config.orgUrl.match(/^\/\//) 62 | config.orgUrl ="https://#{config.orgUrl}" unless config.orgUrl.match(/^http[s]?:\/\//) 63 | resolve(config) 64 | else 65 | reject(new Error "#{msg} We couldn't find `orgUrl` in the config object you passed to `Hull.init`\n #{readMore}") unless config.orgUrl 66 | reject(new Error "#{msg} We couldn't find `platformId` in the config object you passed to `Hull.init`\n #{readMore}") unless config.appId 67 | promise.then(null, throwErr) 68 | promise 69 | 70 | extractDomainFromUrl = (url)-> 71 | return unless url && url.length 72 | u = url.match(/^(https?:\/\/)?([\da-z\.-]+)([\/\w \.-]*)*\/?/) 73 | u[2] if u 74 | 75 | extractUtmTags = -> 76 | _.reduce document.location.search.slice(1).split('&'), (tags, t)-> 77 | [k,v] = t.split('=', 2) 78 | tags[k.replace(/^utm_/, '')] = v if /^utm_/.test(k) 79 | tags 80 | , {} 81 | 82 | class CurrentConfig 83 | 84 | constructor: ()-> 85 | 86 | init: (config)-> 87 | # # Init is silent 88 | # @_clientConfig = config 89 | @_remoteConfig = {} 90 | @_clientConfig = {} 91 | 92 | 93 | checkConfig(config).then (config)=> 94 | org = extractDomainFromUrl(config.orgUrl) 95 | ns = ['hull'].concat(org.split('.')).join('_') 96 | @storage = localstorage.create({ namespace: ns }) 97 | @_clientConfig = config 98 | @ 99 | , throwErr 100 | 101 | initRemote: (cfg={})-> 102 | @identifyBrowser(cfg.identify.browser, count: true) 103 | @identifySession(cfg.identify.session, count: true) 104 | @_remoteConfig = cfg 105 | 106 | set: (config, key)-> 107 | if key? then @_clientConfig[key] = config else @_clientConfig = config 108 | 109 | setSettings: ()-> 110 | 111 | setRemote: (hash, key)-> 112 | if(key) 113 | previousConfig = @_remoteConfig[key] 114 | @_remoteConfig[key] = assign({},@_remoteConfig[key],hash) 115 | else 116 | previousConfig = @_remoteConfig 117 | @_remoteConfig = assign({}, @_remoteConfig, hash) 118 | @onUpdate() unless _.isEqual(previousConfig, hash) 119 | 120 | 121 | get: (key)=> 122 | hash = clone(@_clientConfig); 123 | hash.services = clone(@_remoteConfig.services); 124 | getKey(hash, key) 125 | 126 | getRemote: (key)-> 127 | getKey(@_remoteConfig, key) 128 | 129 | getRemoteUrl: ()=> 130 | browser = @identifyBrowser(@_clientConfig.anonymousId || @_clientConfig.browserId) 131 | session = @identifySession(@_clientConfig.sessionId) 132 | getRemoteUrl(@_clientConfig, { bid: browser.id, sid: session.id }) 133 | 134 | identifySession: (id, options={})=> 135 | session = @identify('session', id, assign({}, options, expires: 60 * 1000 * 30)) 136 | @_clientConfig.sessionId = session.id 137 | session 138 | 139 | identifyBrowser: (id, options={})=> 140 | browser = @identify('browser', id, options) 141 | @_clientConfig.browserId = browser.id 142 | @_clientConfig.anonymousId = browser.id 143 | browser 144 | 145 | storageSet: (key, value)=> 146 | try 147 | @storage.set(key, value) 148 | catch e 149 | null 150 | 151 | storageGet: (key)=> 152 | try 153 | @storage.get(key) 154 | catch e 155 | null 156 | 157 | resetIdentify: -> 158 | try @storage.clear() 159 | 160 | identify: (key, id, options={})=> 161 | ident = @storageGet(key) 162 | now = new Date().getTime() 163 | if options.expires 164 | # Auto expire after 30 minutes 165 | if !ident || ident.expires_at < now 166 | ident = getReferralContext() 167 | ident.expires_at = now + options.expires 168 | else 169 | ident ?= getReferralContext() 170 | ident.id = id if id? 171 | 172 | if options.count 173 | ident.inits_count = (ident.inits_count || 0) + 1 174 | 175 | @storageSet(key, ident) 176 | 177 | ident 178 | 179 | onUpdate : () => 180 | EventBus.emit('hull.config.update', @get()) 181 | 182 | 183 | module.exports = CurrentConfig 184 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /*global require*/ 3 | var _ = require('lodash'); 4 | var del = require('del'); 5 | var path = require('path'); 6 | var runSequence = require('run-sequence'); 7 | 8 | var gulp = require('gulp'); 9 | var harmonize = require('harmonize'); 10 | var awspublish = require('gulp-awspublish'); 11 | var rename = require('gulp-rename'); 12 | var parallelize = require('concurrent-transform'); 13 | var gutil = require('gulp-util'); 14 | var deploy = require('gulp-gh-pages'); 15 | var cloudfront = require('gulp-invalidate-cloudfront'); 16 | var notifier = require('node-notifier'); 17 | var merge = require('merge-stream'); 18 | 19 | var ngrok = require('ngrok'); 20 | var webpack = require('webpack'); 21 | var WebpackDevServer = require('webpack-dev-server'); 22 | 23 | 24 | // Get our Config. 25 | var config = require('./config'); 26 | var webpackConfig = require('./webpack.config'); 27 | 28 | harmonize(); 29 | 30 | var notify = function(message){ 31 | notifier.notify({title: config.displayName+" Gulp",message:message}); 32 | }; 33 | 34 | 35 | // Setup a Ngrok server 36 | var ngrokServe = function(subdomain){ 37 | var options = { port: config.serverPort }; 38 | var env = process.env; 39 | if (env.NGROK_AUTHTOKEN) { 40 | options.authtoken = env.NGROK_AUTHTOKEN; 41 | 42 | if(env.NGROK_SUBDOMAIN || subdomain){ 43 | options.subdomain = (env.NGROK_SUBDOMAIN || subdomain).replace(/-/g,''); 44 | } 45 | } 46 | ngrok.connect(options, function (error, url) { 47 | if (error) { 48 | throw new gutil.PluginError('ship:server', error); 49 | } 50 | 51 | url = url.replace('https', 'http'); 52 | notify({message:"Ngrok Started on "+url}); 53 | 54 | gutil.log('[ship:server]', url); 55 | }); 56 | } 57 | 58 | 59 | gulp.task('default', ['server']); 60 | gulp.task('serve', ['server']); 61 | gulp.task('clean', function(callback) { del(['./'+config.outputFolder+'/**/*']).then(function(){callback()}); }); 62 | gulp.task('server', function(callback) { runSequence('clean', 'copy-files:watch', 'webpack:server', callback); }); 63 | gulp.task('build', function(callback) { runSequence('clean', 'copy-files', 'webpack:build', callback); }); 64 | gulp.task('deploy', function(callback) { runSequence('build', 'publish:sha', callback); }); 65 | gulp.task('deploy:release', function(callback) { runSequence('build', 'publish:release', callback); }); 66 | 67 | 68 | var notify = function(message){ 69 | notifier.notify({title: config.displayName+" Gulp", message: message}); 70 | }; 71 | var handleError = function(err, taskName){ 72 | if(err){ 73 | notify(taskName+" Error: "+ err); 74 | throw new gutil.PluginError("webpack:build", err); 75 | } 76 | }; 77 | // Copy static files from the source to the destination 78 | var copyFiles = function(callback){ 79 | _.map(config.files, function(dest, src){ 80 | gulp.src(src).pipe(gulp.dest(dest)); 81 | }); 82 | notify("Vendors Updated"); 83 | if (_.isFunction(callback)){ 84 | callback(); 85 | } 86 | }; 87 | 88 | gulp.task("copy-files", copyFiles); 89 | 90 | gulp.task("copy-files:watch", function(){ 91 | copyFiles(); 92 | gulp.watch(_.keys(config.files), copyFiles); 93 | }); 94 | 95 | //Production Build. 96 | //Minified, clean code. No demo keys inside. 97 | //demo.html WILL NOT WORK with this build. 98 | // 99 | //Webpack handles CSS/SCSS, JS, and HTML files. 100 | var afterBuild = function(err, stats){ 101 | if (!!err) {throw new gutil.PluginError("webpack:build", err); } 102 | 103 | var jsonStats = stats.toJson(); 104 | 105 | if (jsonStats.errors.length > 0) { 106 | return new gutil.PluginError("webpack:build", JSON.stringify(jsonStats.errors)); 107 | } 108 | 109 | if (jsonStats.warnings.length > 0) { 110 | new gutil.PluginError("webpack:build", JSON.stringify(jsonStats.warnings)); 111 | } 112 | 113 | 114 | gutil.log("[webpack:build]", stats.toString({colors: true})); 115 | notify("App Built"); 116 | }; 117 | gulp.task("webpack:build", function(callback) { 118 | // Then, use Webpack to bundle all JS and html files to the destination folder 119 | notify("Building App"); 120 | webpack(_.values(webpackConfig.production), function(err, stats) { 121 | afterBuild(err, stats); 122 | webpack(_.values(webpackConfig.debug), function(err, stats){ 123 | afterBuild(err, stats); 124 | callback(); 125 | }); 126 | }); 127 | }); 128 | 129 | // Dev Build 130 | // Create the webpack compiler here for caching and performance. 131 | var devCompiler = webpack(webpackConfig.development.browser); 132 | 133 | // Build a Dev version of the project. Launched once on startup so we can have eveything copied. 134 | gulp.task("webpack:build:dev", function(callback) { 135 | // run webpack with Dev profile. 136 | // Embeds the Hull config keys, and the necessary stuff to make demo.html work 137 | devCompiler.run(function(err, stats) { 138 | if (err){ 139 | throw new gutil.PluginError("webpack:build:dev", err); 140 | } 141 | 142 | var jsonStats = stats.toJson(); 143 | 144 | if(jsonStats.errors.length > 0){ 145 | return new gutil.PluginError("webpack:build:dev", JSON.stringify(jsonStats.errors)); 146 | } 147 | 148 | if(jsonStats.warnings.length > 0){ 149 | new gutil.PluginError("webpack:build:dev", JSON.stringify(jsonStats.warnings)); 150 | } 151 | 152 | gutil.log("[webpack:build:dev]", stats.toString({colors: true})); 153 | notify({message: "Webpack Updated"}); 154 | callback(); 155 | }); 156 | }); 157 | 158 | // Launch webpack dev server. 159 | gulp.task("webpack:server", function() { 160 | var taskName = "webpack:server"; 161 | new WebpackDevServer(devCompiler, { 162 | disableHostCheck: true, 163 | contentBase: config.outputFolder, 164 | publicPath: config.assetsFolder+"/", 165 | hot: config.hotReload, 166 | stats: {colors: true } 167 | }).listen(config.serverPort, function(err) { 168 | handleError(err, taskName); 169 | // Dump the preview URL in the console, and open Chrome when launched for convenience. 170 | notify({message: "Dev Server Started"}); 171 | var url = webpackConfig.development.browser.output.publicPath+"webpack-dev-server/"; 172 | ngrokServe(config.libName) 173 | gutil.log("["+taskName+"]", url); 174 | }); 175 | }); 176 | 177 | var publish = function(versions){ 178 | var aws = config.aws 179 | var publisher = awspublish.create(aws.config); 180 | var files = path.join(config.outputFolder, config.assetsFolder, "*") 181 | var streams = []; 182 | var cloudfrontInvalidations = [] 183 | for (var i = 0; i < versions.length; i++) { 184 | var version = versions[i]; 185 | if(version){ 186 | console.log('Deploying to ',version); 187 | var plain = gulp.src(files) 188 | .pipe(rename(function(p){ 189 | p.dirname += '/'+version; 190 | console.log('Publishing '+path.join(p.dirname,p.basename+p.extname)) 191 | })) 192 | var gzip = gulp.src(files) 193 | .pipe(rename(function(p){ 194 | p.dirname += '/'+version; 195 | console.log('Publishing '+path.join(p.dirname,p.basename+p.extname)) 196 | })) 197 | .pipe(awspublish.gzip(aws.gzip)) 198 | cloudfrontInvalidations.push('/'+version+'/*'); 199 | streams.push(plain); 200 | streams.push(gzip); 201 | } 202 | }; 203 | 204 | var invalidationBatch = { 205 | CallerReference: new Date().toString(), 206 | Paths:{ 207 | Quantity:cloudfrontInvalidations.length, 208 | Items:cloudfrontInvalidations 209 | } 210 | } 211 | return merge.apply(merge,streams) 212 | .pipe(parallelize(publisher.publish(aws.publish.headers,aws.publish.options))) 213 | .pipe(publisher.cache()) 214 | .pipe(cloudfront(invalidationBatch, aws.cloudfront)) 215 | .pipe(awspublish.reporter()) 216 | } 217 | 218 | // Deploys to S3 219 | gulp.task('publish:sha',function(){ 220 | var SHA1 = process.env.CIRCLE_SHA1; 221 | if( !SHA1 ){ return; } 222 | return publish([SHA1]); 223 | }); 224 | 225 | gulp.task('publish:release',function(){ 226 | var SHA1 = process.env.CIRCLE_SHA1; 227 | var RELEASE = config.pkg.version; 228 | if( !SHA1 || !RELEASE ){ return; } 229 | return publish([SHA1,RELEASE]); 230 | }); 231 | -------------------------------------------------------------------------------- /src/hull.coffee: -------------------------------------------------------------------------------- 1 | # # This file is responsible for defining window.Hull 2 | # # and providing pooled methods to the user while 3 | # # Hull is actually loading. 4 | 5 | require './utils/load-polyfills' 6 | assign = require './polyfills/assign' 7 | Promise = require './utils/promises' 8 | _ = require './utils/lodash' 9 | logger = require './utils/logger' 10 | Raven = require './utils/raven' 11 | Client = require './client' 12 | CurrentUser = require './client/current-user' 13 | CurrentConfig = require './client/current-config' 14 | Channel = require './client/channel' 15 | Api = require './client/api' 16 | 17 | EventBus = require './utils/eventbus' 18 | Pool = require './utils/pool' 19 | HullRemote = require './hull-remote' 20 | embeds = require './client/embeds' 21 | scriptTagConfig = require './client/script-tag-config' 22 | initializePlatform = require './client/initialize-platform' 23 | decodeHash = require './utils/decode-hash' 24 | displayBanner = require './utils/ui/display-banner' 25 | 26 | ###* 27 | * Wraps the success callback 28 | * 29 | * Extends the global object 30 | * Reinjects events in the live app from the pool 31 | * Replays the track events 32 | * @param {function} userSuccessCallback the function that will be called if everything went well 33 | * @param {object} _hull an partial build of the Hull object 34 | * @param {object} data config data coming from the Remote 35 | * @return {object} the Hull object 36 | ### 37 | onInitSuccess = (userSuccessCallback, hull, data)-> 38 | userSuccessCallback = userSuccessCallback || -> 39 | {me, app, org} = data 40 | 41 | 42 | if app.track_page_inits 43 | hull.track('hull.app.init') 44 | 45 | embeds.initialize({ org }); 46 | hull.embed = embeds.embed 47 | hull.onEmbed = embeds.onEmbed 48 | 49 | # We're on the client. 50 | delete hull.initRemote 51 | 52 | # Prune init queue 53 | Pool.run('identify', hull) 54 | Pool.run('alias', hull) 55 | Pool.run('track', hull) 56 | Pool.run('traits', hull) 57 | Pool.run('trackForm', hull) 58 | 59 | # Execute Hull.init callback 60 | ready.resolve {hull, me, app, org} 61 | 62 | EventBus.emit('hull.ready', hull, me, app, org) 63 | EventBus.emit('hull.init', hull, me, app, org) 64 | logger.log("Hull.js version \"#{hull.version}\" started") 65 | 66 | # Do Hull.embed(platform.deployments) automatically 67 | if hull.config().embed != false 68 | if _.isArray(app?.deployments) and app.deployments.length>0 69 | embeds.embed(app.deployments,{},onEmbedComplete, onEmbedError) 70 | else 71 | onEmbedComplete() 72 | 73 | hash = decodeHash() 74 | snippet = hash?.hull?.snippet 75 | if snippet 76 | config = hull.config() 77 | origin = snippet.origin 78 | platformOk = snippet.platformId == config.appId 79 | 80 | snippetOrgUrl = snippet.orgUrl.replace(/^http:/,'https:') 81 | orgUrl = config.orgUrl.replace(/^http:/,'https:') 82 | orgOk = snippetOrgUrl == orgUrl 83 | 84 | check = snippet.check 85 | 86 | window.location.hash="" 87 | if(orgOk && platformOk) 88 | opener.postMessage({ result: check }, origin); 89 | EventBus.once 'hull.snippet.success', ()-> 90 | opener.postMessage({ result: check }, origin); 91 | window.close() 92 | displayBanner('platform') 93 | else 94 | response = { code:'invalid', orgUrl: orgUrl, platformId: config.appId } 95 | opener.postMessage({ error: btoa(JSON.stringify(response)) }, origin); 96 | # Everything went well, call the init callback 97 | userSuccessCallback(hull, me, app, org) 98 | 99 | hull 100 | 101 | # Wraps init failure 102 | onInitFailure = (err)-> throw err 103 | 104 | onEmbedComplete = ()-> 105 | EventBus.emit('hull.ships.ready'); 106 | logger.log("Hull Embeds Completed successfully") 107 | 108 | onEmbedError = (err...)-> 109 | logger.error("Failed embedding Ships", err...) 110 | 111 | parseHash = ()-> 112 | return if window.location.href.match('//.+\.hullapp\.io/.+/remote.html') # prevent this when in remote.html 113 | 114 | hash = decodeHash() 115 | if hash? && hash.hasOwnProperty('success') 116 | window.location.hash = '' 117 | 118 | if window?.opener?.Hull? and window?.opener?.__hull_login_status__ 119 | window.opener.__hull_login_status__(hash) 120 | window.close() 121 | 122 | parseHash() 123 | 124 | captureException = (err, ctx)-> 125 | Raven.captureException(err, ctx) 126 | 127 | ###* 128 | * Main Hull Entry Point 129 | * 130 | * Will only be executed once. 131 | * @param {[type]} config={} [description] 132 | * @param {[type]} userSuccessCallback [description] 133 | * @param {[type]} userFailureCallback [description] 134 | * @return {[type]} [description] 135 | ### 136 | init = (config={}, userSuccessCallback, userFailureCallback)-> 137 | 138 | if !!hull._initialized 139 | throw new Error('Hull.init can be called only once') 140 | return 141 | 142 | config.version = VERSION 143 | # Process this as a single object we send to Remote side (cleaner) 144 | config.debug = if config.debug then { enabled: true, verbose: config.verbose } else { } 145 | logger.init(config.debug) 146 | config.appId = config.appId || config.platformId || config.shipId 147 | delete config.platformId if config.platformId? 148 | delete config.shipId if config.shipId? 149 | 150 | missing = [] 151 | missing.push "orgUrl" unless config.orgUrl? 152 | missing.push "platformId" unless config.appId? 153 | httpsRegex = /^https:|^\/\// 154 | throw new Error("[Hull.init] jsUrl NEEDS be loaded via https if orgUrl is https") if config.jsUrl and not httpsRegex.test(config.jsUrl) and httpsRegex.test(config.jsUrl) 155 | throw new Error("[Hull.init] You forgot to pass #{missing.join(',')} needed to initialize hull properly") if missing.length 156 | 157 | hull._initialized = true 158 | 159 | client = {} 160 | channel = {} 161 | 162 | currentUser = new CurrentUser() 163 | currentConfig = new CurrentConfig() 164 | 165 | Raven.init(config.ravenDsn, { 166 | runtime: 'hull-js', 167 | orgUrl: config.orgUrl, 168 | appId: config.appId || config.platformId 169 | }) 170 | 171 | throwErr = (err)-> 172 | # Something was wrong while initializing 173 | logger.error(err.stack) 174 | userFailureCallback = userFailureCallback || -> 175 | userFailureCallback(err) 176 | ready.reject(err) 177 | Raven.captureException(err) 178 | 179 | # Ensure we have everything we need before starting Hull 180 | currentConfig.init(config).then (currentConfig)=> 181 | # Create the communication channel with Remote 182 | channel = new Channel(currentUser, currentConfig) 183 | channel.promise 184 | , throwErr 185 | .then (channel)=> 186 | # Create the Hull client that stores the API, Auth, Sharing and Tracking objects. 187 | client = new Client(channel, currentUser, currentConfig) 188 | , onInitFailure 189 | , throwErr 190 | .then (hullClient)=> 191 | # Initialize 192 | client.hull = assign(hull,client.hull) 193 | data = currentConfig.getRemote('data') 194 | currentUser.init(data?.me) 195 | initializePlatform(data, currentConfig, client.hull) 196 | onInitSuccess(userSuccessCallback, client.hull, data) 197 | , throwErr 198 | .catch throwErr 199 | 200 | 201 | 202 | ready = {} 203 | ready.promise = new Promise (resolve, reject)=> 204 | ready.reject = reject 205 | ready.resolve = resolve 206 | 207 | ready.promise.catch (err)-> throw new Error('Hull.ready callback error', err) 208 | 209 | hullReady = (callback, errback)-> 210 | callback = callback || -> 211 | errback = errback || -> 212 | ready.promise.then (res)-> 213 | callback(res.hull, res.me, res.app, res.org) 214 | res 215 | , errback 216 | .catch (err)-> logger.error err.message, err.stack 217 | 218 | shimmedMethod = (method)-> 219 | logger.log("Hull.#{method} is only useful when Ships are sandboxed. This method does nothing here") 220 | false 221 | 222 | hull = 223 | _initialized : false 224 | initRemote : HullRemote 225 | init : init 226 | ready : hullReady 227 | version : VERSION 228 | revision : REVISION 229 | trackForm : Pool.create('trackForm') 230 | track : Pool.create('track') 231 | traits : Pool.create('traits') 232 | identify : Pool.create('identify') 233 | alias : Pool.create('alias') 234 | captureException: captureException 235 | 236 | # Assign EventBus methods to Hull 237 | eeMethods = ['on', 'onAny', 'offAny', 'once', 'many', 'off', 'emit'] 238 | _.map eeMethods, (m)-> 239 | hull[m] = (args...) -> EventBus[m](args...) 240 | 241 | unless window.Hull? 242 | autoStartConfig = scriptTagConfig() 243 | if autoStartConfig && autoStartConfig.autoStart 244 | if !hull._initialized 245 | autoStartConfig && autoStartConfig.autoStart && init(autoStartConfig) 246 | 247 | window.Hull = hull 248 | setTimeout -> 249 | window.hullAsyncInit(hull) if window?.hullAsyncInit and _.isFunction(window.hullAsyncInit) 250 | , 0 251 | else 252 | logger.error "Hull Snippet found more than once (or you already have a global variable named window.Hull). Either way, we can't launch Hull more than once. We only use the first one in the page" 253 | 254 | module.exports = hull 255 | -------------------------------------------------------------------------------- /src/styles/style.scss: -------------------------------------------------------------------------------- 1 | @mixin reset(){ 2 | div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, div.form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary, time, mark, audio, video, button, textarea, input, input[type]{ 3 | alignment-adjust:auto; 4 | alignment-baseline:baseline; 5 | animation-play-state:running; 6 | animation:none 0 ease 0 1 normal; 7 | appearance:normal; 8 | azimuth:center; 9 | backface-visibility:visible; 10 | background-color:transparent; 11 | background-image:none; 12 | background:none 0 0 auto repeat scroll padding-box transparent; 13 | baseline-shift:baseline; 14 | binding:none; 15 | bleed:6pt; 16 | bookmark-label:content(); 17 | bookmark-level:none; 18 | bookmark-state:open; 19 | bookmark-target:none; 20 | border-radius:0; 21 | border:0 none transparent; 22 | bottom:auto; 23 | box-align:stretch; 24 | box-decoration-break:slice; 25 | box-direction:normal; 26 | box-flex-group:1; 27 | box-flex:0.0; 28 | box-lines:single; 29 | box-ordinal-group:1; 30 | box-orient:inline-axis; 31 | box-pack:start; 32 | box-shadow:none; 33 | box-sizing:content-box; 34 | break-after:auto; 35 | break-before:auto; 36 | break-inside:auto; 37 | caption-side:top; 38 | clear:none; 39 | clip:auto; 40 | color-profile:auto; 41 | color:inherit; 42 | column-count:auto; 43 | column-fill:balance; 44 | column-gap:normal; 45 | column-rule:medium medium #1f1f1f; 46 | column-span:1; 47 | column-width:auto; 48 | columns:auto auto; 49 | content:normal; 50 | counter-increment:none; 51 | counter-reset:none; 52 | crop:auto; 53 | cursor:auto; 54 | direction:ltr; 55 | display:inline; 56 | dominant-baseline:auto; 57 | drop-initial-after-adjust:text-after-edge; 58 | drop-initial-after-align:baseline; 59 | drop-initial-before-adjust:text-before-edge; 60 | drop-initial-before-align:caps-height; 61 | drop-initial-size:auto; 62 | drop-initial-value:initial; 63 | elevation:level; 64 | empty-cells:show; 65 | filter:progid:DXImageTransform.Microsoft.gradient(enabled=false); 66 | fit-position:0 0; 67 | fit:fill; 68 | float-offset:0 0; 69 | float:none; 70 | font-family:"Helvetica Neue", Helvetica, Arial, sans-serif; 71 | font-size-adjust:none; 72 | font-size:100%; 73 | font-stretch:normal; 74 | font-style:normal; 75 | font-variant:normal; 76 | font-weight:400; 77 | font:normal normal 100% "Helvetica Neue", Helvetica, Arial, sans-serif; 78 | grid-columns:none; 79 | grid-rows:none; 80 | hanging-punctuation:none; 81 | height:auto; 82 | hyphenate-after:auto; 83 | hyphenate-before:auto; 84 | hyphenate-character:auto; 85 | hyphenate-lines:no-limit; 86 | hyphenate-resource:none; 87 | hyphens:manual; 88 | icon:auto; 89 | image-orientation:auto; 90 | image-rendering:auto; 91 | image-resolution:normal; 92 | inline-box-align:last; 93 | left:auto; 94 | letter-spacing:normal; 95 | line-height:inherit; 96 | line-stacking:inline-line-height exclude-ruby consider-shifts; 97 | list-style:disc outside none; 98 | margin:0; 99 | marks:none; 100 | marquee-direction:forward; 101 | marquee-loop:1; 102 | marquee-play-count:1; 103 | marquee-speed:normal; 104 | marquee-style:scroll; 105 | max-height:none; 106 | max-width:none; 107 | min-height:0; 108 | min-width:0; 109 | move-to:normal; 110 | nav-down:auto; 111 | nav-index:auto; 112 | nav-left:auto; 113 | nav-right:auto; 114 | nav-up:auto; 115 | opacity:1; 116 | orphans:2; 117 | outline-offset:0; 118 | outline:invert none medium; 119 | overflow-style:auto; 120 | overflow:visible; 121 | padding:0; 122 | page-break-after:auto; 123 | page-break-before:auto; 124 | page-break-inside:auto; 125 | page-policy:start; 126 | page:auto; 127 | perspective-origin:50% 50%; 128 | perspective:none; 129 | position:static; 130 | presentation-level:0; 131 | punctuation-trim:none; 132 | quotes:none; 133 | rendering-intent:auto; 134 | resize:none; 135 | right:auto; 136 | rotation-point:50% 50%; 137 | rotation:0; 138 | ruby-align:auto; 139 | ruby-overhang:none; 140 | ruby-position:before; 141 | ruby-span:none; 142 | size:auto; 143 | string-set:none; 144 | table-layout:auto; 145 | text-align-last:start; 146 | text-align:start; 147 | text-decoration:none; 148 | text-emphasis:none; 149 | text-height:auto; 150 | text-indent:0; 151 | text-justify:auto; 152 | text-outline:none; 153 | text-shadow:none; 154 | text-transform:none; 155 | text-wrap:normal; 156 | top:auto; 157 | transform-origin:50% 50% 0; 158 | transform-style:flat; 159 | transform:none; 160 | transition:all 0 ease 0; 161 | unicode-bidi:normal; 162 | vertical-align:baseline; 163 | white-space-collapse:collapse; 164 | white-space:normal; 165 | widows:2; 166 | width:auto; 167 | word-break:normal; 168 | word-spacing:normal; 169 | word-wrap:normal; 170 | z-index:auto; 171 | } 172 | 173 | address, 174 | article, 175 | aside, 176 | blockquote, 177 | canvas, 178 | center, 179 | dd, 180 | details, 181 | dir, 182 | div, 183 | div.form, 184 | dl, 185 | dt, 186 | fieldset, 187 | figcaption, 188 | figure, 189 | footer, 190 | form, 191 | frame, 192 | frameset, 193 | h1, 194 | h2, 195 | h3, 196 | h4, 197 | h5, 198 | h6, 199 | header, 200 | hgroup, 201 | hr, 202 | menu, 203 | menu, 204 | nav, 205 | noframes, 206 | ol, 207 | p, 208 | pre, 209 | section, 210 | summary, 211 | ul { 212 | display: block; 213 | } 214 | 215 | li { 216 | display: list-item; 217 | } 218 | 219 | table { 220 | display: table; 221 | } 222 | 223 | tr { 224 | display: table-row; 225 | } 226 | 227 | thead { 228 | display: table-header-group; 229 | } 230 | 231 | tbody { 232 | display: table-row-group; 233 | } 234 | 235 | tfoot { 236 | display: table-footer-group; 237 | } 238 | 239 | col { 240 | display: table-column; 241 | } 242 | 243 | colgroup { 244 | display: table-column-group; 245 | } 246 | 247 | td, 248 | th { 249 | display: table-cell; 250 | } 251 | 252 | caption { 253 | display: table-caption; 254 | } 255 | 256 | input, 257 | select { 258 | display: inline-block; 259 | } 260 | 261 | b, 262 | strong { 263 | font-weight: bold; 264 | } 265 | 266 | textarea, 267 | input { 268 | cursor: text; 269 | } 270 | 271 | textarea::-webkit-input-placeholder, 272 | input::-webkit-input-placeholder { 273 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 274 | font-size-adjust: none; 275 | font-size: 100%; 276 | font-style: normal; 277 | letter-spacing: normal; 278 | font-stretch: normal; 279 | font-variant: normal; 280 | font-weight: normal; 281 | font: normal normal 100% "Helvetica Neue", Helvetica, Arial, sans-serif; 282 | text-align: left; 283 | text-align-last: start; 284 | text-decoration: none; 285 | text-emphasis: none; 286 | text-height: auto; 287 | text-indent: 0; 288 | text-justify: auto; 289 | text-outline: none; 290 | text-shadow: none; 291 | text-transform: none; 292 | text-wrap: normal; 293 | background-color: inherit; 294 | color: inherit; 295 | } 296 | 297 | textarea::-moz-placeholder, 298 | input::-moz-placeholder { 299 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 300 | font-size-adjust: none; 301 | font-size: 100%; 302 | font-style: normal; 303 | letter-spacing: normal; 304 | font-stretch: normal; 305 | font-variant: normal; 306 | font-weight: normal; 307 | font: normal normal 100% "Helvetica Neue", Helvetica, Arial, sans-serif; 308 | text-align: left; 309 | text-align-last: start; 310 | text-decoration: none; 311 | text-emphasis: none; 312 | text-height: auto; 313 | text-indent: 0; 314 | text-justify: auto; 315 | text-outline: none; 316 | text-shadow: none; 317 | text-transform: none; 318 | text-wrap: normal; 319 | background-color: inherit; 320 | color: inherit; 321 | } 322 | 323 | textarea:-ms-input-placeholder, 324 | input:-ms-input-placeholder { 325 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 326 | font-size-adjust: none; 327 | font-size: 100%; 328 | font-style: normal; 329 | letter-spacing: normal; 330 | font-stretch: normal; 331 | font-variant: normal; 332 | font-weight: normal; 333 | font: normal normal 100% "Helvetica Neue", Helvetica, Arial, sans-serif; 334 | text-align: left; 335 | text-align-last: start; 336 | text-decoration: none; 337 | text-emphasis: none; 338 | text-height: auto; 339 | text-indent: 0; 340 | text-justify: auto; 341 | text-outline: none; 342 | text-shadow: none; 343 | text-transform: none; 344 | text-wrap: normal; 345 | background-color: inherit; 346 | color: inherit; 347 | } 348 | 349 | input[type=checkbox], 350 | input[type=radio] { 351 | cursor: default; 352 | } 353 | 354 | a, 355 | a *, 356 | a span, 357 | button, 358 | button *, 359 | button span, 360 | input[type=submit], 361 | input[type=reset] { 362 | cursor: pointer; 363 | } 364 | 365 | a:active 366 | a:hover, 367 | a:link, 368 | a:visited { 369 | color: inherit; 370 | background: transparent; 371 | text-shadow: none; 372 | } 373 | 374 | button::-moz-focus-inner { 375 | border: 0; 376 | padding: 0; 377 | } 378 | } 379 | 380 | .hull-reset.hull-reset{ 381 | @include reset(); 382 | } 383 | -------------------------------------------------------------------------------- /src/polyfills/xhr-xdr.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | xhr-xdr-adapter 4 | 5 | Use XDomainRequest on IE 8 and IE 9 for cross-origin requests only. 6 | Default to standard XMLHttpRequest otherwise. 7 | 8 | Include this file on IE 8 & 9 before making cross-origin ajax requests 9 | using libraries or frameworks such as jQuery or AngularJS. This will allow 10 | you to do things like AJAX GET templates/assets from a CDN at run time using 11 | the standard XMLHttpRequest API on IE 8/9, or do simple cross-domain POSTs. 12 | 13 | But it doesn't get around the basic limitations of IE 8 & 9's XDomainRequest: 14 | * No authentication or cookies can be sent 15 | * POST or GET only 16 | * No custom headers can be sent 17 | * text/plain contentType only 18 | 19 | 20 | The MIT License (MIT) 21 | 22 | Copyright (c) 2014 Intuit Inc. 23 | 24 | Permission is hereby granted, free of charge, to any person obtaining a copy 25 | of this software and associated documentation files (the "Software"), to deal 26 | in the Software without restriction, including without limitation the rights 27 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 28 | copies of the Software, and to permit persons to whom the Software is 29 | furnished to do so, subject to the following conditions: 30 | 31 | The above copyright notice and this permission notice shall be included in all 32 | copies or substantial portions of the Software. 33 | 34 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 38 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 39 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 40 | SOFTWARE. 41 | */ 42 | 43 | (function () { 44 | "use strict"; 45 | // Ignore everything below if not on IE 8 or IE 9. 46 | if (!window.XDomainRequest) { // typeof XDomainRequest is 'object' in IE 8, 'function' in IE 9 47 | return; 48 | } 49 | if ('withCredentials' in new window.XMLHttpRequest()) { 50 | return; 51 | } 52 | if (window.XMLHttpRequest.supportsXDR === true) { 53 | // already set up 54 | return; 55 | } 56 | 57 | var OriginalXMLHttpRequest = window.XMLHttpRequest; 58 | var urlRegEx = /^([\w.+-]+:)(?:\/\/([^\/?#:]*)(?::(\d+)|)|)/; 59 | var httpRegEx = /^https?:\/\//i; 60 | var getOrPostRegEx = /^get|post$/i; 61 | var sameSchemeRegEx = new RegExp('^' + location.protocol, 'i'); 62 | 63 | // Determine whether XDomainRequest should be used for a given request 64 | var useXDR = function (method, url, async) { 65 | var remoteUrl = url; 66 | var baseHref; 67 | var myLocationParts; 68 | var remoteLocationParts; 69 | var crossDomain; 70 | 71 | try { 72 | // account for the possibility of a setting, which could make a URL that looks relative actually be cross-domain 73 | if ((remoteUrl && remoteUrl.indexOf("://") < 0) && document.getElementsByTagName('base').length > 0) { 74 | baseHref = document.getElementsByTagName('base')[0].href; 75 | if (baseHref) { 76 | remoteUrl = baseHref + remoteUrl; 77 | } 78 | } 79 | 80 | myLocationParts = urlRegEx.exec(location.href); 81 | remoteLocationParts = urlRegEx.exec(remoteUrl); 82 | crossDomain = (myLocationParts[2].toLowerCase() !== remoteLocationParts[2].toLowerCase()); 83 | 84 | // XDomainRequest can only be used for async get/post requests across same scheme, which must be http: or https: 85 | return crossDomain && async && getOrPostRegEx.test(method) && httpRegEx.test(remoteUrl) && sameSchemeRegEx.test(remoteUrl); 86 | } 87 | catch (ex) { 88 | return false; 89 | } 90 | }; 91 | 92 | window.XMLHttpRequest = function () { 93 | var self = this; 94 | this._setReadyState = function (readyState) { 95 | if (self.readyState !== readyState) { 96 | self.readyState = readyState; 97 | if (typeof self.onreadystatechange === "function") { 98 | self.onreadystatechange(); 99 | } 100 | self._runCallbacks('readystatechange'); 101 | } 102 | }; 103 | this._callbacks = {}; 104 | this.readyState = 0; 105 | this.responseText = ""; 106 | this.status = 0; 107 | this.statusText = ""; 108 | this.withCredentials = false; 109 | }; 110 | 111 | window.XMLHttpRequest.prototype._runCallbacks = function(event, args) { 112 | var self = this; 113 | var callbacks = (self._callbacks[event] || []).slice(); 114 | var cb = callbacks.pop(); 115 | while (cb) { 116 | cb.apply(self, args); 117 | cb = callbacks.pop(); 118 | } 119 | }; 120 | 121 | window.XMLHttpRequest.prototype.addEventListener = function(event, callback) { 122 | var self = this; 123 | this._callbacks[event] = this._callbacks[event] || []; 124 | this._callbacks[event].push(callback); 125 | }; 126 | 127 | window.XMLHttpRequest.prototype.open = function (method, url, async) { 128 | var self = this; 129 | var request; 130 | 131 | if (useXDR(method, url, async)) { 132 | 133 | // Use XDR 134 | request = new XDomainRequest(); 135 | request._xdr = true; 136 | request.onerror = function () { 137 | self.status = 400; 138 | self.statusText = "Error"; 139 | self._setReadyState(4); 140 | if (self.onerror) { 141 | self.onerror(); 142 | self._runCallbacks('error'); 143 | } 144 | }; 145 | request.ontimeout = function () { 146 | self.status = 408; 147 | self.statusText = "Timeout"; 148 | self._setReadyState(2); 149 | if (self.ontimeout) { 150 | self.ontimeout(); 151 | } 152 | self._runCallbacks('timeout'); 153 | }; 154 | request.onload = function () { 155 | self.responseText = request.responseText; 156 | self.status = 200; 157 | self.statusText = "OK"; 158 | self._setReadyState(4); 159 | if (self.onload) { 160 | self.onload(); 161 | } 162 | self._runCallbacks('load', [].slice.call(arguments)); 163 | }; 164 | request.onprogress = function () { 165 | if (self.onprogress) { 166 | self.onprogress.apply(self, arguments); 167 | } 168 | self._runCallbacks('progress', [].slice.call(arguments)); 169 | }; 170 | } 171 | 172 | else { 173 | 174 | // Use standard XHR 175 | request = new OriginalXMLHttpRequest(); 176 | request.withCredentials = this.withCredentials; 177 | request.onreadystatechange = function () { 178 | if (request.readyState === 4) { 179 | try { 180 | self.status = request.status; 181 | self.statusText = request.statusText; 182 | self.responseText = request.responseText; 183 | self.responseXML = request.responseXML; 184 | } catch (e) { 185 | 186 | } 187 | } 188 | self._setReadyState(request.readyState); 189 | }; 190 | request.onabort = function () { 191 | if (self.onabort) { 192 | self.onabort.apply(self, arguments); 193 | } 194 | }; 195 | request.onerror = function () { 196 | if (self.onerror) { 197 | self.onerror.apply(self, arguments); 198 | } 199 | }; 200 | request.onload = function () { 201 | if (self.onload) { 202 | self.onload.apply(self, arguments); 203 | } 204 | }; 205 | request.onloadend = function () { 206 | if (self.onloadend) { 207 | self.onloadend.apply(self, arguments); 208 | } 209 | }; 210 | request.onloadstart = function () { 211 | if (self.onloadstart) { 212 | self.onloadstart.apply(self, arguments); 213 | } 214 | }; 215 | request.onprogress = function () { 216 | if (self.onprogress) { 217 | self.onprogress.apply(self, arguments); 218 | } 219 | }; 220 | } 221 | 222 | this._request = request; 223 | request.open.apply(request, arguments); 224 | this._setReadyState(1); 225 | }; 226 | 227 | window.XMLHttpRequest.prototype.abort = function () { 228 | this._request.abort(); 229 | this._setReadyState(0); 230 | this.onreadystatechange = null; 231 | }; 232 | 233 | window.XMLHttpRequest.prototype.send = function (body) { 234 | var self = this; 235 | this._request.withCredentials = this.withCredentials; 236 | 237 | if (this._request._xdr) { 238 | setTimeout(function () { 239 | self._request.send(body); 240 | }, 0); 241 | } 242 | else { 243 | this._request.send(body); 244 | } 245 | 246 | if (this._request.readyState === 4) { 247 | // when async==false the browser is blocked until the transfer is complete and readyState becomes 4 248 | // onreadystatechange should not get called in this case 249 | this.status = this._request.status; 250 | this.statusText = this._request.statusText; 251 | this.responseText = this._request.responseText; 252 | this.readyState = this._request.readyState; 253 | } 254 | else { 255 | this._setReadyState(2); 256 | } 257 | }; 258 | 259 | window.XMLHttpRequest.prototype.setRequestHeader = function () { 260 | if (this._request.setRequestHeader) { 261 | this._request.setRequestHeader.apply(this._request, arguments); 262 | } 263 | }; 264 | 265 | window.XMLHttpRequest.prototype.getAllResponseHeaders = function () { 266 | if (this._request.getAllResponseHeaders) { 267 | return this._request.getAllResponseHeaders(); 268 | } 269 | else { 270 | return ("Content-Length: " + this.responseText.length + 271 | "\r\nContent-Type:" + this._request.contentType); 272 | } 273 | }; 274 | 275 | window.XMLHttpRequest.prototype.getResponseHeader = function (header) { 276 | if (this._request.getResponseHeader) { 277 | return this._request.getResponseHeader.apply(this._request, arguments); 278 | } 279 | if (typeof header !== "string") { 280 | return; 281 | } 282 | header = header.toLowerCase(); 283 | if (header === "content-type") { 284 | return this._request.contentType; 285 | } 286 | else if (header === "content-length") { 287 | return this.responseText.length; 288 | } 289 | }; 290 | 291 | window.XMLHttpRequest.supportsXDR = true; 292 | })(); 293 | -------------------------------------------------------------------------------- /src/client/auth.coffee: -------------------------------------------------------------------------------- 1 | assign = require '../polyfills/assign' 2 | Promise = require '../utils/promises' 3 | _ = require '../utils/lodash' 4 | logger = require '../utils/logger' 5 | EventBus = require '../utils/eventbus' 6 | isMobile = require '../utils/is-mobile' 7 | 8 | getNoUserPromise = ()-> 9 | Promise.reject({ 10 | reason: 'no_current_user', 11 | message: 'User must be logged in to perform this action' 12 | }) 13 | 14 | getUser = ()-> 15 | user = Hull.currentUser() 16 | return (!!user && user.id?) 17 | 18 | parseParams = (argsArray)-> 19 | 20 | opts = {} 21 | 22 | while (next = argsArray.shift()) 23 | if _.isString next 24 | if !login 25 | login = next 26 | else 27 | password = next 28 | else if _.isFunction next 29 | if !callback 30 | callback = next 31 | else 32 | errback = next 33 | else if _.isObject(next) and !_.isEmpty(next) 34 | if !login and !password 35 | opts = next 36 | else 37 | opts.params = assign(next, opts.params||{}) 38 | 39 | if login 40 | opts = if password then assign(opts, {login, password}) else assign(opts, {provider:login}) 41 | 42 | opts.params = opts.params || {} 43 | callback = callback || -> 44 | errback = errback || -> 45 | 46 | opts.params.display ||= 'touch' if isMobile() 47 | 48 | # Setup defaults for Popup login for Facebook on Desktop 49 | 50 | # # Redirect login by default. if on Mobile. 51 | # # Setup 'display' to be 'touch' for Facebook Login if on Mobile 52 | if opts.provider == 'facebook' 53 | opts.params.display ||= if opts.strategy == 'redirect' then 'page' else 'popup' 54 | 55 | # TODO : OK to ignore `params` in this email login scenario ? 56 | delete opts.params if opts.password? 57 | 58 | { 59 | options: opts 60 | callback : callback 61 | errback : errback 62 | } 63 | 64 | postForm = (path, method='post', params={}) -> 65 | form = document.createElement("form") 66 | form.setAttribute("method", method) 67 | form.setAttribute("action", path) 68 | 69 | for key of params 70 | if params.hasOwnProperty key 71 | hiddenField = document.createElement("input") 72 | hiddenField.setAttribute("type", "hidden") 73 | hiddenField.setAttribute("name", key) 74 | hiddenField.setAttribute("value", params[key]) 75 | form.appendChild(hiddenField) 76 | document.body.appendChild(form) 77 | form.submit() 78 | 79 | class Auth 80 | constructor: (api, currentUser, currentConfig)-> 81 | @api = api 82 | @currentUser = currentUser 83 | @currentConfig = currentConfig 84 | @_popupInterval = null 85 | @_authenticating = null 86 | @authServices = _.keys(@currentConfig.get('services.auth')) 87 | 88 | isAuthenticating : -> @_authenticating? 89 | 90 | # Generates the complete URL to be reached to validate login 91 | generateAuthUrl : (opts={})-> 92 | @createAuthCallback() 93 | params = opts.params || {} 94 | params.app_id = @currentConfig.get('appId') 95 | # The following is here for backward compatibility. Must be removed at first sight next time 96 | params.callback_url = opts.redirect_url || params.callback_url || @currentConfig.get('callback_url') || @currentConfig.get('callbackUrl') || document.location.toString() 97 | params.auth_referer = document.location.toString() 98 | params.version = @currentConfig.get('version') 99 | params._bid = @currentConfig.identifyBrowser().id 100 | params._sid = @currentConfig.identifySession().id 101 | querystring = _.map params,(v,k) -> 102 | encodeURIComponent(k)+'='+encodeURIComponent(v) 103 | .join('&') 104 | "#{@currentConfig.get('orgUrl')}/auth/#{opts.provider}?#{querystring}" 105 | 106 | createAuthCallback: => 107 | window.__hull_login_status__ = (hash) => 108 | window.__hull_login_status__ = null 109 | @onAuthComplete(hash) 110 | 111 | popupAuthWindow: (path, opts={})-> 112 | 113 | # Handle smaller Facebook popup windows 114 | [width, height] = if opts.provider == 'facebook' and opts.params.display == 'popup' then [500, 400] else [1030, 550] 115 | 116 | openerString = "location=0,status=0,width=#{width},height=#{height}" 117 | 118 | w = window.open(path, "_auth",openerString) 119 | 120 | # Support for cordova events 121 | if window.device?.cordova 122 | w?.addEventListener 'loadstart', (event)-> 123 | hash = try JSON.parse(Base64.decode(event.url.split('#')[1])) 124 | if hash 125 | window.__hull_login_status__(hash) 126 | w.close() 127 | 128 | # 30 seconds after creating popup, reject promise if still active. 129 | 130 | setTimeout ()=> 131 | @onAuthComplete({ success: false, error: { reason: 'timeout', message: 'Timeout for login (after 30 seconds), User never finished the auth' } }) 132 | , 90000 133 | 134 | # Reject Promise if window has been closed 135 | @_popupInterval = w? && setInterval => 136 | @onAuthComplete({ success: false, error: { reason: 'window_closed', message: 'User closed the window before finishing. He might have canceled' } }) if w?.closed 137 | , 200 138 | 139 | onAuthComplete : (hash)=> 140 | return unless @_authenticating 141 | if hash.success 142 | @_authenticating.resolve({}) 143 | else 144 | error = new Error("Login failed : #{hash?.error?.reason}") 145 | error[k] = v for k, v of hash.error 146 | @_authenticating.reject(error) 147 | 148 | @_authenticating = null 149 | clearInterval(@_popupInterval) 150 | @_popupInterval = null 151 | 152 | undefined 153 | 154 | loginWithProvider : (opts)=> 155 | isAuthenticating = @isAuthenticating() 156 | return isAuthenticating if isAuthenticating 157 | 158 | @_authenticating = {} 159 | promise = new Promise (resolve, reject)=> 160 | @_authenticating.resolve = resolve 161 | @_authenticating.reject = reject 162 | 163 | unless ~(_.indexOf(@authServices, opts.provider)) 164 | @_authenticating.reject 165 | message: "No authentication service #{opts.provider} configured for the app" 166 | reason: 'no_such_service' 167 | return promise 168 | 169 | @_authenticating.provider = opts.provider.toLowerCase() 170 | 171 | authUrl = @generateAuthUrl(opts) 172 | 173 | if opts.strategy == 'redirect' 174 | # Don't do an early return to not break promise chain 175 | window.location.href = authUrl 176 | else 177 | # Classic Popup Strategy 178 | @popupAuthWindow(authUrl, opts) 179 | 180 | promise 181 | 182 | login : () => 183 | if @isAuthenticating() 184 | # Return promise even if login is in progress. 185 | msg = "Login in progress. Use `Hull.on('hull.user.login', callback)` to call `callback` when done." 186 | logger.info msg 187 | return Promise.reject {error: {reason:'in_progress', message: 'Login already in progress'}} 188 | 189 | # Handle Legacy Format, 190 | # Ensure New Format: Hash signature 191 | # Preprocess Options 192 | # Opts format is now : {login:"", password:"", params:{}} or {provider:"", params:{}} 193 | {options, callback, errback} = parseParams(Array.prototype.slice.call(arguments)) 194 | 195 | if !(options?.provider? || options.login? || options.access_token?) 196 | # UserName+Password 197 | # Hull.login({login:'abcd@ef.com', password:'passwd', strategy:'redirect|popup', redirect:'...'}) 198 | unless options.login? and options.password? 199 | msg = 'Seems like something is wrong in your Hull.login() call, We need a login and password fields to login. Read up here: http://www.hull.io/docs/references/hull_js/#user-signup-and-login' 200 | logger.warn msg 201 | return Promise.reject({error:{ reason:'missing_parameters', message:'Empty login or password' }}) 202 | 203 | 204 | 205 | if options.provider? 206 | # Social Login 207 | if options.access_token? 208 | # Hull.login({provider:'facebook', access_token:'xxxx'}) 209 | provider = assign({}, options) 210 | delete provider.provider 211 | op = {} 212 | op[options.provider] = provider 213 | promise = @api.message('users', 'post', op) 214 | else 215 | # Hull.login({provider:'facebook', strategy:'redirect|popup', redirect:'...'}) 216 | promise = @loginWithProvider(options) 217 | 218 | else 219 | 220 | # Email Login 221 | # Hull.login({login:'user@host.com', password:'xxxx'}) 222 | # Hull.login({access_token:'xxxxx'}) 223 | 224 | promise = @api.message('users/login', 'post', _.pick(options, 'login', 'password', 'access_token')) 225 | 226 | if options.strategy == 'redirect' || !@currentConfig.getRemote('cookiesEnabled') 227 | postUrl = @currentConfig.get('orgUrl')+'/api/v1/users/login' 228 | options.redirect_url ||= window.location.href 229 | promise = promise.then -> postForm(postUrl, 'post', options) 230 | 231 | @completeLoginPromiseChain(promise, callback, errback) 232 | 233 | logout: (options={}, callback, errback) => 234 | @currentConfig.resetIdentify() 235 | @api.channel.rpc.resetIdentify() 236 | promise = @api.message('logout') 237 | 238 | if options.strategy == 'redirect' || !@currentConfig.getRemote('cookiesEnabled') 239 | redirect_url = options.redirect_url || document.location.href 240 | # Add this param to make sure safari actually redirects to the logoutUrl 241 | b = new Date().getTime() 242 | logoutUrl = @currentConfig.get('orgUrl') + '/api/v1/logout?b=' + b + '&redirect_url=' + encodeURIComponent(redirect_url) 243 | promise.then -> document.location = logoutUrl 244 | 245 | @completeLoginPromiseChain(promise,callback,errback) 246 | 247 | 248 | resetPassword : (email=@currentUser.get('email'), callback, errback) => 249 | promise = @api.message('/users/request_password_reset', 'post', {email}) 250 | @completeLoginPromiseChain(promise,callback,errback) 251 | 252 | confirmEmail : (email=@currentUser.get('email'), callback, errback) => 253 | promise = @api.message('/users/request_confirmation_email', 'post', {email}) 254 | @completeLoginPromiseChain(promise,callback,errback) 255 | 256 | signup : (attrs, callback, errback) => 257 | promise = @api.message('users', 'POST', attrs) 258 | if !@currentConfig.getRemote('cookiesEnabled') 259 | postUrl = @currentConfig.get('orgUrl')+'/api/v1/users/login' 260 | promise = promise.then (user)-> 261 | params = { 262 | redirect_url: window.location.href, 263 | access_token: user.access_token 264 | } 265 | postForm(postUrl, 'post', params) 266 | @completeLoginPromiseChain(promise, callback, errback) 267 | 268 | ###* 269 | * link an Identity to a Hull User 270 | * @param {options} An options Hash 271 | * @param {callback} Success callback 272 | * @param {errback} error callback 273 | * @return {Promise} A promise 274 | ### 275 | linkIdentity : ()=> 276 | return getNoUserPromise() unless getUser() 277 | {options, callback, errback} = parseParams(Array.prototype.slice.call(arguments)) 278 | options.params.mode = 'connect' 279 | @login(options, callback, errback) 280 | 281 | ###* 282 | * unlink an Identity from a Hull User 283 | * @param {options} An options Hash 284 | * @param {callback} Success callback 285 | * @param {errback} error callback 286 | * @return {Promise} A promise 287 | * 288 | ### 289 | unlinkIdentity : ()=> 290 | return getNoUserPromise() unless getUser() 291 | {options, callback, errback} = parseParams(Array.prototype.slice.call(arguments)) 292 | promise = @api.message("me/identities/#{options.provider}", 'delete') 293 | @completeLoginPromiseChain(promise,callback,errback) 294 | 295 | completeLoginPromiseChain: (promise, callback,errback)=> 296 | if promise && promise.then 297 | callback = callback || -> 298 | errback = errback || -> 299 | 300 | p = promise.then @api.refreshUser, @emitLoginFailure 301 | p.then callback, errback 302 | p 303 | 304 | emitLoginFailure : (err)-> 305 | EventBus.emit("hull.user.fail", err) 306 | err 307 | throw err 308 | 309 | module.exports = Auth 310 | -------------------------------------------------------------------------------- /src/utils/leaky-bucket.js: -------------------------------------------------------------------------------- 1 | // Origin: https://github.com/linaGirl/leaky-bucket 2 | // Version: 3.0.4 3 | // License: MIT 4 | 5 | 6 | class LeakyBucket { 7 | 8 | 9 | /** 10 | * Sets up the leaky bucket. The bucket is designed so that it can 11 | * burst by the capacity it is given. after that items can be queued 12 | * until a timeout of n seonds is reached. 13 | * 14 | * example: throttle 10 actions per minute that have each a cost of 1, reject 15 | * everything theat is overflowing. there will no more than 10 items queued 16 | * at any time 17 | * capacity: 10 18 | * interval: 60 19 | * timeout: 60 20 | * 21 | * example: throttle 100 actions per minute that have a cost of 1, reject 22 | * items that have to wait more thatn 2 minutes. there will be no more thatn 23 | * 200 items queued at any time. of those 200 items 100 will be bursted within 24 | * a minute, the rest will be executed evenly spread over a mintue. 25 | * capacity: 100 26 | * interval: 60 27 | * timeout: 120 28 | * 29 | * @param {number} capacity the capacity the bucket has per interval 30 | * @param {number} timeout the total time items are allowed to wait for execution 31 | * @param {number} interval the interval for the capacity in seconds 32 | */ 33 | constructor({ 34 | capacity = 60, 35 | timeout, 36 | interval = 60000, 37 | } = {}) { 38 | // set the timeout to the interval if not set, so that the bucket overflows as soon 39 | // the capacity is reached 40 | if (isNaN(timeout)) timeout = interval; 41 | 42 | // queue containing all items to execute 43 | this.queue = []; 44 | 45 | // the value f all items currently enqueued 46 | this.totalCost = 0; 47 | 48 | // the capacity, which can be used at this moment 49 | // to execute items 50 | this.currentCapacity = capacity; 51 | 52 | // time when the last refill occured 53 | this.lastRefill = null; 54 | 55 | 56 | this.setCapacity(capacity); 57 | this.setTimeout(timeout); 58 | this.setInterval(interval); 59 | } 60 | 61 | 62 | 63 | 64 | /** 65 | * dthe throttle method is used to throttle things. it is async and will resolve either 66 | * immediatelly, if there is space in the bucket, than can be bursted, or it will wait 67 | * until there is enough capacity left to execute the item with the given cost. if the 68 | * bucket is overflowing, and the item cannot be executed within the timeout of the bucket, 69 | * the call will be rejected with an error. 70 | * 71 | * @param {number} cost=1 the cost of the item to be throttled. is the cost is unknown, 72 | * the cost can be payed after execution using the pay method. 73 | * defaults to 1. 74 | * @param {boolean} append = true set to false if the item needs ot be added to the 75 | * beginning of the queue 76 | * @param {boolean} isPause = false defines if the element is a pause elemtn, if yes, it 77 | * will not be cleaned off of the queue when checking 78 | * for overflowing elements 79 | * @returns {promise} resolves when the item can be executed, rejects if the item cannot 80 | * be executed in time 81 | */ 82 | throttle(cost = 1, append = true, isPause = false) { 83 | const maxCurrentCapacity = this.getCurrentMaxCapacity(); 84 | 85 | // if items are added at the beginning, the excess items will be remove 86 | // later on 87 | if (append && this.totalCost + cost > maxCurrentCapacity) { 88 | throw new Error(`Cannot throttle item, bucket is overflowing: the maximum capacity is ${maxCurrentCapacity}, the current total capacity is ${this.totalCost}!`); 89 | } 90 | 91 | return new Promise((resolve, reject) => { 92 | const item = { 93 | resolve, 94 | reject, 95 | cost, 96 | isPause, 97 | }; 98 | 99 | this.totalCost += cost; 100 | 101 | if (append) { 102 | this.queue.push(item); 103 | } else { 104 | this.queue.unshift(item); 105 | this.cleanQueue(); 106 | } 107 | 108 | 109 | this.startTimer(); 110 | }); 111 | } 112 | 113 | 114 | 115 | /** 116 | * either executes directly when enough capacity is present or delays the 117 | * execution until enough capacity is available. 118 | * 119 | * @private 120 | */ 121 | startTimer() { 122 | if (!this.timer && this.queue.length > 0) { 123 | const item = this.getFirstItem(); 124 | 125 | this.refill(); 126 | 127 | if (this.currentCapacity >= item.cost) { 128 | item.resolve(); 129 | 130 | // remove the item from the queue 131 | this.shiftQueue(); 132 | 133 | // pay it's cost 134 | this.pay(item.cost); 135 | 136 | // go to the next item 137 | this.startTimer(); 138 | } else { 139 | const requiredDelta = item.cost + (this.currentCapacity * -1); 140 | const timeToDelta = requiredDelta / this.refillRate * 1000; 141 | 142 | // wait until the next item can be handled 143 | this.timer = setTimeout(() => { 144 | this.timer = 0; 145 | this.startTimer(); 146 | }, timeToDelta); 147 | } 148 | } 149 | } 150 | 151 | 152 | /** 153 | * removes the first item in the queue, resolves the promise that indicated 154 | * that the bucket is empty and no more items are waiting 155 | * 156 | * @private 157 | */ 158 | shiftQueue() { 159 | this.queue.shift(); 160 | 161 | if (this.queue.length === 0 && this.emptyPromiseResolver) { 162 | this.emptyPromiseResolver(); 163 | } 164 | } 165 | 166 | 167 | 168 | /** 169 | * is resolved as soon as the bucket is empty. is basically an event 170 | * that is emitted 171 | */ 172 | isEmpty() { 173 | if (!this.emptyPromiseResolver) { 174 | this.emptyPromise = new Promise((resolve) => { 175 | this.emptyPromiseResolver = () => { 176 | this.emptyPromiseResolver = null; 177 | this.emptyPromise = null; 178 | resolve(); 179 | }; 180 | }); 181 | } 182 | 183 | return this.emptyPromise; 184 | } 185 | 186 | 187 | 188 | 189 | /** 190 | * ends the bucket. The bucket may be recycled after this call 191 | */ 192 | end() { 193 | this.stopTimer(); 194 | this.clear(); 195 | } 196 | 197 | 198 | 199 | /** 200 | * removes all items from the queue, does not stop the timer though 201 | * 202 | * @privae 203 | */ 204 | clear() { 205 | this.queue = []; 206 | } 207 | 208 | 209 | 210 | /** 211 | * can be used to pay costs for items where the cost is clear after exection 212 | * this will devcrease the current capacity availabe on the bucket. 213 | * 214 | * @param {number} cost the ost to pay 215 | */ 216 | pay(cost) { 217 | 218 | // reduce the current capacity, so that bursts 219 | // as calculated correctly 220 | this.currentCapacity -= cost; 221 | 222 | // keep track of the total cost for the bucket 223 | // so that we know when we're overflowing 224 | this.totalCost -= cost; 225 | 226 | // store the date the leky bucket was starting to leak 227 | // so that it can be refilled correctly 228 | if (this.lastRefill === null) { 229 | this.lastRefill = Date.now(); 230 | } 231 | } 232 | 233 | 234 | 235 | /** 236 | * stops the running times 237 | * 238 | * @private 239 | */ 240 | stopTimer() { 241 | if (this.timer) { 242 | clearTimeout(this.timer); 243 | this.timer = null; 244 | } 245 | } 246 | 247 | 248 | 249 | /** 250 | * refills the bucket with capacity which has become available since the 251 | * last refill. starts to refill after a call has started using capacity 252 | * 253 | * @private 254 | */ 255 | refill() { 256 | 257 | // don't do refills, if we're already full 258 | if (this.currentCapacity < this.capacity) { 259 | 260 | // refill the currently avilable capacity 261 | const refillAmount = ((Date.now() - this.lastRefill) / 1000) * this.refillRate; 262 | this.currentCapacity += refillAmount; 263 | 264 | // make sure, that no more capacity is added than is the maximum 265 | if (this.currentCapacity >= this.capacity) { 266 | this.currentCapacity = this.capacity; 267 | this.lastRefill = null; 268 | } else { 269 | // date of last refill, ued for the next refill 270 | this.lastRefill = Date.now(); 271 | } 272 | } 273 | } 274 | 275 | 276 | 277 | /** 278 | * gets the currenlty avilable max capacity, respecintg 279 | * the capacity that is already used in the moment 280 | * 281 | * @private 282 | */ 283 | getCurrentMaxCapacity() { 284 | this.refill(); 285 | return this.maxCapacity - (this.capacity - this.currentCapacity); 286 | } 287 | 288 | 289 | 290 | /** 291 | * removes all items that cannot be executed in time due to items 292 | * that were added in front of them in the queue (mostly pause items) 293 | * 294 | * @private 295 | */ 296 | cleanQueue() { 297 | const maxCapacity = this.getCurrentMaxCapacity(); 298 | let currentCapacity = 0; 299 | 300 | // find the first item, that goes over the thoretical maximal 301 | // capacity that is available 302 | const index = this.queue.findIndex((item) => { 303 | currentCapacity += item.cost; 304 | return currentCapacity > maxCapacity; 305 | }); 306 | 307 | 308 | // reject all items that cannot be enqueued 309 | if (index >= 0) { 310 | this.queue.splice(index).forEach((item) => { 311 | if (!item.isPause) { 312 | item.reject(new Error(`Cannot throttle item because an item was added in front of it which caused the queue to overflow!`)); 313 | this.totalCost -= item.cost; 314 | } 315 | }); 316 | } 317 | } 318 | 319 | 320 | 321 | /** 322 | * returns the first item from the queue 323 | * 324 | * @private 325 | */ 326 | getFirstItem() { 327 | if (this.queue.length > 0) { 328 | return this.queue[0]; 329 | } else { 330 | return null; 331 | } 332 | } 333 | 334 | 335 | 336 | /** 337 | * pasue the bucket for the given cost. means that an item is added in the 338 | * front of the queue with the cost passed to this method 339 | * 340 | * @param {number} cost the cost to pasue by 341 | */ 342 | pauseByCost(cost) { 343 | this.stopTimer(); 344 | this.throttle(cost, false, true); 345 | } 346 | 347 | 348 | /** 349 | * pause the bucket for n seconds. means that an item with the cost for one 350 | * second is added at the beginning of the queue 351 | * 352 | * @param {number} seconds the number of seconds to pause the bucket by 353 | */ 354 | pause(seconds = 1) { 355 | this.drain(); 356 | this.stopTimer(); 357 | const cost = this.refillRate * seconds; 358 | this.pauseByCost(cost); 359 | } 360 | 361 | 362 | 363 | /** 364 | * drains the bucket, so that nothing can be exuted at the moment 365 | * 366 | * @private 367 | */ 368 | drain() { 369 | this.currentCapacity = 0; 370 | this.lastRefill = Date.now(); 371 | } 372 | 373 | 374 | 375 | /** 376 | * set the timeout value for the bucket. this is the amount of time no item 377 | * may longer wait for. 378 | * 379 | * @param {number} timeout in seonds 380 | */ 381 | setTimeout(timeout) { 382 | this.timeout = timeout; 383 | this.updateVariables(); 384 | return this; 385 | } 386 | 387 | 388 | /** 389 | * set the interval within whch the capacity can be used 390 | * 391 | * @param {number} interval in seonds 392 | */ 393 | setInterval(interval) { 394 | this.interval = interval; 395 | this.updateVariables(); 396 | return this; 397 | } 398 | 399 | 400 | /** 401 | * set the capacity of the bucket. this si the capacity that can be used per interval 402 | * 403 | * @param {number} capacity 404 | */ 405 | setCapacity(capacity) { 406 | this.capacity = capacity; 407 | this.updateVariables(); 408 | return this; 409 | } 410 | 411 | 412 | 413 | /** 414 | * claculates the values of some frequently used variables on the bucket 415 | * 416 | * @private 417 | */ 418 | updateVariables() { 419 | // take one as default for each variable since this method may be called 420 | // before every variable was set 421 | this.maxCapacity = ((this.timeout || 1) / (this.interval || 1)) * (this.capacity || 1); 422 | 423 | // the rate, at which the leaky bucket is filled per second 424 | this.refillRate = (this.capacity || 1) / (this.interval || 1); 425 | 426 | } 427 | } 428 | 429 | module.exports = LeakyBucket; --------------------------------------------------------------------------------