diff --git a/README.md b/README.md index abe60067..d90e98c8 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,39 @@ Rollbar::init(array( ?> ``` +## Integration with Rollbar.js + +In case you want to report your JavaScript errors using [Rollbar.js](https://github.com/rollbar/rollbar.js), you can configure the SDK to enable Rollbar.js on your site. Example: + +```php +$rollbarJs = Rollbar\RollbarJsHelper::buildJs( + array( + "accessToken" => "POST_CLIENT_ITEM_ACCESS_TOKEN", + "captureUncaught" => true, + "payload" => array( + "environment" => "production" + ), + /* other configuration you want to pass to RollbarJS */ + ) +); +``` + +Or if you are using Content-Security-Policy: script-src 'unsafe-inline' +```php +$rollbarJs = Rollbar\RollbarJsHelper::buildJs( + array( + "accessToken" => "POST_CLIENT_ITEM_ACCESS_TOKEN", + "captureUncaught" => true, + "payload" => array( + "environment" => "production" + ), + /* other configuration you want to pass to RollbarJS */ + ), + headers_list(), + $yourNonceString +); +``` + ## Basic Usage That's it! Uncaught errors and exceptions will now be reported to Rollbar. diff --git a/data/rollbar.snippet.js b/data/rollbar.snippet.js new file mode 100644 index 00000000..ef15e25c --- /dev/null +++ b/data/rollbar.snippet.js @@ -0,0 +1 @@ +!function(r){function e(t){if(o[t])return o[t].exports;var n=o[t]={exports:{},id:t,loaded:!1};return r[t].call(n.exports,n,n.exports,e),n.loaded=!0,n.exports}var o={};return e.m=r,e.c=o,e.p="",e(0)}([function(r,e,o){"use strict";var t=o(1).Rollbar,n=o(2);_rollbarConfig.rollbarJsUrl=_rollbarConfig.rollbarJsUrl||"https://d37gvrvc0wt4s1.cloudfront.net/js/v1.9/rollbar.min.js";var a=t.init(window,_rollbarConfig),i=n(a,_rollbarConfig);a.loadFull(window,document,!_rollbarConfig.async,_rollbarConfig,i)},function(r,e){"use strict";function o(r){return function(){try{return r.apply(this,arguments)}catch(e){try{console.error("[Rollbar]: Internal error",e)}catch(o){}}}}function t(r,e,o){window._rollbarWrappedError&&(o[4]||(o[4]=window._rollbarWrappedError),o[5]||(o[5]=window._rollbarWrappedError._rollbarContext),window._rollbarWrappedError=null),r.uncaughtError.apply(r,o),e&&e.apply(window,o)}function n(r){var e=function(){var e=Array.prototype.slice.call(arguments,0);t(r,r._rollbarOldOnError,e)};return e.belongsToShim=!0,e}function a(r){this.shimId=++c,this.notifier=null,this.parentShim=r,this._rollbarOldOnError=null}function i(r){var e=a;return o(function(){if(this.notifier)return this.notifier[r].apply(this.notifier,arguments);var o=this,t="scope"===r;t&&(o=new e(this));var n=Array.prototype.slice.call(arguments,0),a={shim:o,method:r,args:n,ts:new Date};return window._rollbarShimQueue.push(a),t?o:void 0})}function l(r,e){if(e.hasOwnProperty&&e.hasOwnProperty("addEventListener")){var o=e.addEventListener;e.addEventListener=function(e,t,n){o.call(this,e,r.wrap(t),n)};var t=e.removeEventListener;e.removeEventListener=function(r,e,o){t.call(this,r,e&&e._wrapped?e._wrapped:e,o)}}}var c=0;a.init=function(r,e){var t=e.globalAlias||"Rollbar";if("object"==typeof r[t])return r[t];r._rollbarShimQueue=[],r._rollbarWrappedError=null,e=e||{};var i=new a;return o(function(){if(i.configure(e),e.captureUncaught){i._rollbarOldOnError=r.onerror,r.onerror=n(i);var o,a,c="EventTarget,Window,Node,ApplicationCache,AudioTrackList,ChannelMergerNode,CryptoOperation,EventSource,FileReader,HTMLUnknownElement,IDBDatabase,IDBRequest,IDBTransaction,KeyOperation,MediaController,MessagePort,ModalWindow,Notification,SVGElementInstance,Screen,TextTrack,TextTrackCue,TextTrackList,WebSocket,WebSocketWorker,Worker,XMLHttpRequest,XMLHttpRequestEventTarget,XMLHttpRequestUpload".split(",");for(o=0;oconfig = $config; + } + + /** + * Shortcut method for building the RollbarJS Javascript + * + * @param array $config @see addJs() + * @param string $nonce @see addJs() + * + * @return string + */ + public static function buildJs($config, $headers = null, $nonce = null) + { + $helper = new self($config); + return $helper->addJs($headers, $nonce); + } + + /** + * Build Javascript required to include RollbarJS on + * an HTML page + * + * @param array $headers Response headers usually retrieved through + * headers_list() used to verify if nonce should be added to script + * tags based on Content-Security-Policy + * @param string $nonce Content-Security-Policy nonce string if exists + * + * @return string + */ + public function addJs($headers = null, $nonce = null) + { + return $this->scriptTag( + $this->configJsTag() .$this->jsSnippet(), + $headers, + $nonce + ); + } + + /** + * Build RollbarJS config script + * + * @return string + */ + public function configJsTag() + { + $config = isset($this->config['options']) ? $this->config['options'] : new \stdClass(); + return "var _rollbarConfig = " . json_encode($config) . ";"; + } + + /** + * Build rollbar.snippet.js string + * + * @return string + */ + public function jsSnippet() + { + return file_get_contents( + $this->snippetPath() + ); + } + + /** + * @return string Path to the rollbar.snippet.js + */ + public function snippetPath() + { + return realpath(__DIR__ . "/../data/rollbar.snippet.js"); + } + + /** + * Should JS snippet be added to the HTTP response + * + * @param int $status + * @param array $headers + * + * @return boolean + */ + public function shouldAddJs($status, $headers) + { + return + $status == 200 && + $this->isHtml($headers) && + !$this->hasAttachment($headers); + + /** + * @todo not sure if below two conditions will be applicable + */ + /* !env[JS_IS_INJECTED_KEY] */ + /* && !streaming?(env) */ + } + + /** + * Is the HTTP response a valid HTML response + * + * @param array $headers + * + * @return boolean + */ + public function isHtml($headers) + { + return in_array('Content-Type: text/html', $headers); + } + + /** + * Does the HTTP response include an attachment + * + * @param array $headers + * + * @return boolean + */ + public function hasAttachment($headers) + { + return in_array('Content-Disposition: attachment', $headers); + } + + /** + * Is `nonce` attribute on the script tag needed? + * + * @param array $headers + * + * @return boolean + */ + public function shouldAppendNonce($headers) + { + foreach ($headers as $header) { + if (strpos($header, 'Content-Security-Policy') !== false && + strpos($header, "'unsafe-inline'") !== false) { + return true; + } + } + + return false; + } + + /** + * Build safe HTML script tag + * + * @param string $content + * @param array $headers + * @param + * + * @return string + */ + public function scriptTag($content, $headers = null, $nonce = null) + { + if ($headers !== null && $this->shouldAppendNonce($headers)) { + if (!$nonce) { + throw new \Exception('Content-Security-Policy is script-src '. + 'inline-unsafe but nonce value not provided.'); + } + + return "\n"; + } else { + return "\n"; + } + } +} diff --git a/tests/RollbarJsHelperTest.php b/tests/RollbarJsHelperTest.php new file mode 100644 index 00000000..bae6b118 --- /dev/null +++ b/tests/RollbarJsHelperTest.php @@ -0,0 +1,318 @@ +jsHelper = new RollbarJsHelper(array()); + $this->testSnippetPath = realpath(__DIR__ . "/../data/rollbar.snippet.js"); + } + + public function testSnippetPath() + { + $this->assertEquals( + $this->testSnippetPath, + $this->jsHelper->snippetPath() + ); + } + + /** + * @dataProvider shouldAddJsProvider + */ + public function testShouldAddJs($setup, $expected) + { + $mock = \Mockery::mock('Rollbar\RollbarJsHelper'); + + $status = $setup['status']; + + $mock->shouldReceive('isHtml') + ->andReturn($setup['isHtml']); + + $mock->shouldReceive('hasAttachment') + ->andReturn($setup['hasAttachment']); + + $mock->shouldReceive('shouldAddJs') + ->passthru(); + + $this->assertEquals($expected, $mock->shouldAddJs($status, array())); + } + + public function shouldAddJsProvider() + { + return array( + array( + array( + 'status' => 200, + 'isHtml' => true, + 'hasAttachment' => false + ), + true + ), + array( + array( + 'status' => 500, + 'isHtml' => true, + 'hasAttachment' => false + ), + false + ), + array( + array( + 'status' => 200, + 'isHtml' => false, + 'hasAttachment' => false + ), + false + ), + array( + array( + 'status' => 200, + 'isHtml' => true, + 'hasAttachment' => true + ), + false + ), + ); + } + + /** + * @dataProvider isHtmlProvider + */ + public function testIsHtml($headers, $expected) + { + $this->assertEquals( + $expected, + $this->jsHelper->isHtml($headers) + ); + } + + public function isHtmlProvider() + { + return array( + array( + array( + 'Content-Type: text/html' + ), + true + ), + array( + array( + 'Content-Type: text/plain' + ), + false + ), + ); + } + + /** + * @dataProvider hasAttachmentProvider + */ + public function testHasAttachment($headers, $expected) + { + $this->assertEquals( + $expected, + $this->jsHelper->hasAttachment($headers) + ); + } + + public function hasAttachmentProvider() + { + return array( + array( + array( + 'Content-Disposition: attachment' + ), + true + ), + array( + array( + ), + false + ), + ); + } + + public function testJsSnippet() + { + $expected = file_get_contents($this->testSnippetPath); + + $this->assertEquals($expected, $this->jsHelper->jsSnippet()); + } + + /** + * @dataProvider shouldAppendNonceProvider + */ + public function testShouldAppendNonce($headers, $expected) + { + $this->assertEquals( + $expected, + $this->jsHelper->shouldAppendNonce($headers) + ); + } + + public function shouldAppendNonceProvider() + { + return array( + array( + array( + "Content-Security-Policy: script-src 'unsafe-inline'" + ), + true + ), + array( + array( + "Content-Type: text/html" + ), + false + ), + array( + array( + "Content-Security-Policy: default-src 'self'" + ), + false + ), + ); + } + + /** + * @dataProvider scriptTagProvider + */ + public function testScriptTag($content, $headers, $nonce, $expected) + { + if ($expected === 'Exception') { + try { + $result = $this->jsHelper->scriptTag($content, $headers, $nonce); + + $this->fail(); + } catch (\Exception $e) { + $this->assertTrue(true); + return; + } + } else { + $result = $this->jsHelper->scriptTag($content, $headers, $nonce); + + $this->assertEquals($expected, $result); + } + } + + public function scriptTagProvider() + { + return array( + 'nonce script' => array( + 'var test = "value 1";', + array( + "Content-Security-Policy: script-src 'unsafe-inline'" + ), + '123', + "\n" + ), + 'script-src inline-unsafe throws Exception' => array( + 'var test = "value 1";', + array( + "Content-Security-Policy: script-src 'inline-unsafe'" + ), + null, + 'Exception' + ), + array( + 'var test = "value 1";', + array(), + null, + "\n" + ), + ); + } + + public function testConfigJsTag() + { + $config = array( + 'options' => array( + 'config1' => 'value 1' + ) + ); + + $expectedJson = json_encode($config['options']); + $expected = "var _rollbarConfig = $expectedJson;"; + + $helper = new RollbarJsHelper($config); + $result = $helper->configJsTag(); + + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider addJsProvider + */ + public function testBuildJs($setup, $expected) + { + extract($setup); + + $result = RollbarJsHelper::buildJs($config, $headers, $nonce); + + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider addJsProvider + */ + public function testAddJs($setup, $expected) + { + extract($setup); + + $helper = new RollbarJsHelper($config); + + $result = $helper->addJs($headers, $nonce); + + $this->assertEquals($expected, $result); + } + + public function addJsProvider() + { + $this->setUp(); + $expectedJs = file_get_contents($this->testSnippetPath); + return array( + array( + array( + 'config' => array(), + 'headers' => array(), + 'nonce' => null + ), + "\n" + ), + array( + array( + 'config' => array( + 'options' => array( + 'foo' => 'bar' + ) + ), + 'headers' => array(), + 'nonce' => null + ), + "\n" + ), + array( + array( + 'config' => array(), + 'headers' => array( + 'Content-Security-Policy: script-src \'unsafe-inline\'' + ), + 'nonce' => 'stub-nonce' + ), + "\n" + ), + ); + } +}