diff --git a/build/pjax-base/pjax-base-debug.js b/build/pjax-base/pjax-base-debug.js index 55acc137b7a..b0b79e3dc00 100644 --- a/build/pjax-base/pjax-base-debug.js +++ b/build/pjax-base/pjax-base-debug.js @@ -1,22 +1,93 @@ YUI.add('pjax-base', function(Y) { -var win = Y.config.win, - - CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'), +/** +`Y.Router` extension that provides the core plumbing for enhanced navigation +implemented using the pjax technique (HTML5 `pushState` + Ajax). + +@submodule pjax-base +@since 3.5.0 +**/ + +var win = Y.config.win, + location = win.location, + + Lang = Y.Lang, + + // The CSS class name used to filter link clicks from only the links which + // the pjax enhanced navigation should be used. + CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'), + + /** + Fired when navigating to the specified URL is being enhanced by the router. + + When the `navigate()` method is called or a "pjax" link is clicked, this + event will be fired if: the browser is HTML5-history enabled, and the router + has a route-handler for the specified URL. + + This is a useful event to listen to for adding a visual loading indicator + while the route handlers are busy handling the URL change. + + @event navigate + @param {String} url The URL that the router will dispatch to its route + handlers in order to fulfill the enhanced navigation "request". + @param {Event} [originEvent] The event that caused the navigation, usually + this would be a click event from a "pjax" anchor element. + @param {Boolean} [replace] Whether or not the current history entry will be + replaced, or a new entry will be created. Will default to `true` if the + specified `url` is the same as the current URL. + @param {Boolean} [force=false] Whether the enhanced navigation should occur + even in browsers without HTML5 history. + **/ EVT_NAVIGATE = 'navigate'; -// PjaxBase is a mixin for Router. +/** +`Y.Router` extension that provides the core plumbing for enhanced navigation +implemented using the pjax technique (HTML5 `pushState` + Ajax). + +This makes it easy to enhance the navigation between the URLs of an application +in HTML5 history capable browsers by delegating to the router to fulfill the +"request" and seamlessly falling-back to using standard full-page reloads in +older, less-capable browsers. + +@class PjaxBase +@extensionfor Router +**/ function PjaxBase() {} PjaxBase.prototype = { - // -- Properties ----------------------------------------------------------- - _resolved: {}, - _regexUrl: /^((?:([^:]+):(?:\/\/)?|\/\/)[^\/]*)?([^?#]*)(.*)$/i, + // -- Protected Properties ------------------------------------------------- + + /** + Holds the delegated pjax-link click handler. + + @property _pjaxEvents + @type EventHandle + @default undefined + @protected + **/ + + /** + Regex used to break-up a URL string around the URL's path. + + Subpattern captures: + + 1. Origin, everything before the URL's path-part. + 2. The URL's path-part. + 3. Suffix, everything after the URL's path-part. + + @property _regexURL + @type RegExp + @protected + **/ + _regexURL: /^((?:[^\/#?:]+:\/\/|\/\/)[^\/]*)?([^?#]*)(.*)$/, // -- Lifecycle Methods ---------------------------------------------------- initializer: function () { this.publish(EVT_NAVIGATE, {defaultFn: this._defNavigateFn}); + // Pjax is all about progressively-enhancing the navigation between + // "pages", so by default we only want to handle and route link clicks + // in HTML5 `pushState`-compatible browsers. if (this.get('html5')) { this._pjaxBindUI(); } @@ -26,138 +97,349 @@ PjaxBase.prototype = { this._pjaxEvents && this._pjaxEvents.detach(); }, - // -- Public Prototype Methods --------------------------------------------- + // -- Public Methods ------------------------------------------------------- + + /** + Navigates to the specified URL if there is a router-handler that matches. In + browsers capable of using HTML5 history, the navigation will be enhanced by + firing the `navigate` and having the router handle the "request". Non-HTML5 + browsers will navigate to the new URL via manipulation of `window.location`. + + When there is a route-handler for the specified URL and it is being + navigated to, this method will return `true`, otherwise it will return + `false`. + + **Note:** The specified URL _must_ be of the same origin as the current URL, + otherwise an error will be logged and the navigation will not be performed. + This is intended as both a security constraint and an purposely imposed + limitation as it does not make sense to tell the router to navigate to some + URL on a different scheme, host, or port. + + @method navigate + @param {String} url The URL to navigate to. This must be of the same-origin + as the current URL. + @param {Object} [options] Additional options to configure the navigation, + these are mixed into the `navigate` event facade. + @param {Boolean} [options.replace] Whether or not the current history + entry will be replaced, or a new entry will be created. Will default + to `true` if the specified `url` is the same as the current URL. + @param {Boolean} [options.force=false] Whether the enhanced navigation + should occur even in browsers without HTML5 history. + @return {Boolean} `true` if the URL was navigated to, `false` otherwise. + **/ navigate: function (url, options) { - options || (options = {}); - options.url = url; + // The `_navigate()` method expects fully-resolved URLs. + url = this._resolveURL(url); - this.fire(EVT_NAVIGATE, options); + if (this._navigate(url, options)) { + return true; + } + + if (!this._hasSameOrigin(url)) { + Y.error('Security error: The new URL must be of the same origin as the current URL.'); + } + + return false; }, - // -- Protected Prototype Methods ------------------------------------------ + // -- Protected Methods ---------------------------------------------------- + + /** + Returns the current path root after popping-off the last path segment making + it useful for resolving other URL paths against. + + The path root will always begin and end with a '/'. + + @method _getRoot + @return {String} The URL's path root. + @protected + **/ _getRoot: function () { - var segments = (win && win.location.pathname.split('/')) || []; + var slash = '/', + path = location.pathname, + segments; + + if (path.charAt(path.length - 1) === slash) { + return path; + } + + segments = path.split(slash); segments.pop(); - return segments.join('/'); + + return segments.join(slash) + slash; }, + /** + Navigates to the specified URL if there is a router-handler that matches. In + browsers capable of using HTML5 history, the navigation will be enhanced by + firing the `navigate` and having the router handle the "request". Non-HTML5 + browsers will navigate to the new URL via manipulation of `window.location`. + + When there is a route-handler for the specified URL and it is being + navigated to, this method will return `true`, otherwise it will return + `false`. + + The enhanced navigation flow can be forced causing all navigation to route + through the router; but this is not advised as it can will produce less + desirable hash-based URLs in non-HTML5 browsers. + + @method _navigate + @param {String} url The fully-resolved URL that the router should dispatch + to its route handlers to fulfill the enhanced navigation "request", or use + to update `window.location` in non-HTML5 history capable browsers. + @param {Object} [options] Additional options to configure the navigation, + these are mixed into the `navigate` event facade. + @param {Boolean} [options.replace] Whether or not the current history + entry will be replaced, or a new entry will be created. Will default + to `true` if the specified `url` is the same as the current URL. + @param {Boolean} [options.force=false] Whether the enhanced navigation + should occur even in browsers without HTML5 history. + @protected + **/ + _navigate: function (url, options) { + // Navigation can only be enhanced if there is a route-handler. + if (!this.hasRoute(url)) { + return false; + } + + options || (options = {}); + options.url = url; + + // When navigating to the same URL as the current URL, behave like a + // browser and replace the history entry instead of creating a new one. + Lang.isValue(options.replace) || (options.replace = url === this._getURL()); + + // The `navigate` event will only fire and therefore enhance the + // navigation to the new URL in HTML5 history enabled browsers or when + // forced. Otherwise it will fallback to assigning or replacing the URL + // on `window.location`. + if (this.get('html5') || options.force) { + this.fire(EVT_NAVIGATE, options); + } else { + if (options.replace) { + location.replace(url); + } else { + win.location = url; + } + } + + return true; + }, + + /** + Returns a normalized path, riding it of any '..' segments and properly + handling leading and trailing '/'s. + + @method _normalizePath + @param {String} path The URL path to normalize. + @return {String} The normalized path. + @protected + **/ _normalizePath: function (path) { var dots = '..', slash = '/', - i, len, normalized, parts, part, stack; + i, len, normalized, segments, segment, stack; if (!path) { - return path; + return slash; } - parts = path.split(slash); - stack = []; + segments = path.split(slash); + stack = []; - for (i = 0, len = parts.length; i < len; ++i) { - part = parts[i]; + for (i = 0, len = segments.length; i < len; ++i) { + segment = segments[i]; - if (part === dots) { + if (segment === dots) { stack.pop(); - } else if (part) { - stack.push(part); + } else if (segment) { + stack.push(segment); } } - normalized = stack.join(slash); + normalized = slash + stack.join(slash); - // Append a slash if necessary. - if (path.charAt(path.length - 1) === slash) { + // Append trailing slash if necessary. + if (normalized !== slash && path.charAt(path.length - 1) === slash) { normalized += slash; } return normalized; }, + /** + Binds the delegation of link-click events that match the `linkSelector` to + the `_onLinkClick()` handler. + + By default this method will only be called if the browser is capable of + using HTML5 history. + + @method _pjaxBindUI + @protected + **/ _pjaxBindUI: function () { + // Only bind link if we haven't already. if (!this._pjaxEvents) { this._pjaxEvents = Y.one('body').delegate('click', this._onLinkClick, this.get('linkSelector'), this); } }, - _resolvePath: function (path, root) { - root || (root = this._getRoot()); + /** + Returns the normalized result of resolving the `path` against the current + path. + + A host-relative `path` (one that begins with '/') is assumed to be resolved + and is returned as is. Falsy values for `path` will return just the current + path. + @method _resolvePath + @param {String} path The URL path to resolve. + @return {String} The resolved path. + @protected + **/ + _resolvePath: function (path) { if (!path) { - return root; + return this._getPath(); } - // Path is host relative. + // Path is host-relative and assumed to be resolved and normalized, + // meaning silly paths like: '/foo/../bar/' will be returned as-is. if (path.charAt(0) === '/') { return path; } - return this._normalizePath(root + '/' + path); + return this._normalizePath(this._getRoot() + path); }, - _resolveUrl: function (url) { - var self = this, - root = self._getRoot(), - resolved, resolvedUrl; - - resolved = self._resolved[root] || (self._resolved[root] = {}); - resolvedUrl = resolved[url]; - - if (resolvedUrl) { - return resolvedUrl; + /** + Resolves the specified URL against the current URL. + + This method resolves URLs like a browser does and will always return an + absolute URL. When the specified URL is already absolute, it is assumed to + be fully resolved and is simply returned as is. Scheme-relative URLs are + prefixed with the current protocol. Relative URLs are giving the current + URL's origin and are resolved and normalized against the current path-root. + + @method _resolveURL + @param {String} url The URL to resolve. + @return {String} The resolved URL. + @protected + **/ + _resolveURL: function (url) { + var parts = url && url.match(this._regexURL), + origin, path, suffix; + + if (!parts) { + return this._getURL(); } - function resolve(match, prefix, scheme, path, suffix) { - if (scheme && scheme.toLowerCase().indexOf('http') !== 0) { - return match; + origin = parts[1]; + path = parts[2]; + suffix = parts[3]; + + // Absolute and scheme-relative URLs are assumed to be fully-resolved. + if (origin) { + // Prepend the current scheme for scheme-relative URLs. + if (origin.indexOf('//') === 0) { + origin = location.protocol + origin; } - return (prefix || '') + self._resolvePath(path, root) + (suffix || ''); + return origin + (path || '/') + (suffix + ''); } - // Cache resolved URL. - resolvedUrl = resolved[url] = url.replace(self._regexUrl, resolve); - - return resolvedUrl; + return this._getOrigin() + this._resolvePath(path) + (suffix || ''); }, // -- Protected Event Handlers --------------------------------------------- + + /** + Default handler for the `navigate` event. + + Adds a new history entry or replaces the current entry for the specified URL + and will scroll the page to the top if configured to do so. + + @method _defNavigateFn + @param {EventFacade} e + @protected + **/ _defNavigateFn: function (e) { - this.save(this._resolveUrl(e.url)); + this[e.replace ? 'replace' : 'save'](e.url); - if (this.get('scrollToTop') && Y.config.win) { + if (win && this.get('scrollToTop')) { // Scroll to the top of the page. The timeout ensures that the // scroll happens after navigation begins, so that the current // scroll position will be restored if the user clicks the back // button. setTimeout(function () { - Y.config.win.scroll(0, 0); + win.scroll(0, 0); }, 1); } }, + /** + Handler for the delegated link-click events which match the `linkSelector`. + + This will attempt to enhance the navigation to the link element's `href` by + passing the URL to the `_navigate()` method. When the navigation is being + enhanced, the default action is prevented. + + If the user clicks a link with the middle/right mouse buttons, or is holding + down the Ctrl or Command keys, this method's behavior is not applied and + allows the native behavior to occur. Similarly, if the router is not capable + or handling the URL because no route-handlers match, the link click will + behave natively. + + @method _onLinkClick + @param {EventFacade} e + @protected + **/ _onLinkClick: function (e) { - var url = this._resolveUrl(e.currentTarget.get('href')); + var url; // Allow the native behavior on middle/right-click, or when Ctrl or // Command are pressed. if (e.button !== 1 || e.ctrlKey || e.metaKey) { return; } - // Do nothing if there's no matching route for this URL. - if (!this.hasRoute(url)) { return; } + // All browsers fully resolve an anchor's `href` property. + url = e.currentTarget.get('href'); - e.preventDefault(); - - this.navigate(url, {originEvent: e}); + // Try and navigate to the URL via the router, and prevent the default + // link-click action if we do. + url && this._navigate(url, {originEvent: e}) && e.preventDefault(); } }; PjaxBase.ATTRS = { + /** + This selector is used so only the click events who's links match will have + the enhanced navigation behavior applied. + + When a link being clicked on matches this selector, the browsers default of + navigating to the URL by doing a full-page reload will be prevented; + instead, navigating to the URL will be enhanced by have the router fulfill + the "request" by updating the URL and content of the page. + + @attribute linkSelector + @type String|Function + @default `'a.pjax'` + @initOnly + **/ linkSelector: { value : 'a.' + CLASS_PJAX, writeOnce: 'initOnly' }, + /** + Whether the page should be scrolled to the top after navigating to a URL. + + When the user clicks the browser's back button, the previous scroll-position + will be maintained. + + @attribute scrollToTop + @type Boolean + @default `true` + **/ scrollToTop: { value: true } diff --git a/build/pjax-base/pjax-base-min.js b/build/pjax-base/pjax-base-min.js index ab59776dc1f..1abc51f67dd 100644 --- a/build/pjax-base/pjax-base-min.js +++ b/build/pjax-base/pjax-base-min.js @@ -1 +1 @@ -YUI.add("pjax-base",function(e){var c=e.config.win,d=e.ClassNameManager.getClassName("pjax"),a="navigate";function b(){}b.prototype={_resolved:{},_regexUrl:/^((?:([^:]+):(?:\/\/)?|\/\/)[^\/]*)?([^?#]*)(.*)$/i,initializer:function(){this.publish(a,{defaultFn:this._defNavigateFn});if(this.get("html5")){this._pjaxBindUI();}},destructor:function(){this._pjaxEvents&&this._pjaxEvents.detach();},navigate:function(g,f){f||(f={});f.url=g;this.fire(a,f);},_getRoot:function(){var f=(c&&c.location.pathname.split("/"))||[];f.pop();return f.join("/");},_normalizePath:function(o){var l="..",h="/",j,k,n,g,f,m;if(!o){return o;}g=o.split(h);m=[];for(j=0,k=g.length;j '/foo/bar' router._joinURL('/bar'); // => '/foo/bar' - router.root = '/foo/' + router.set('root', '/foo/'); router._joinURL('bar'); // => '/foo/bar' router._joinURL('/bar'); // => '/foo/bar' @@ -802,6 +847,10 @@ Y.Router = Y.extend(Router, Y.Base, { /** Saves a history entry using either `pushState()` or the location hash. + This method enforces the same-origin security constraint; attempting to save + a `url` that is not from the same origin as the current URL will result in + an error. + @method _save @param {String} [url] URL for the history entry. @param {Boolean} [replace=false] If `true`, the current history entry will @@ -812,6 +861,12 @@ Y.Router = Y.extend(Router, Y.Base, { _save: function (url, replace) { var urlIsString = typeof url === 'string'; + // Perform same-origin check on the specified URL. + if (urlIsString && !this._hasSameOrigin(url)) { + Y.error('Security error: The new URL must be of the same origin as the current URL.'); + return this; + } + // Force _ready to true to ensure that the history change is handled // even if _save is called before the `ready` event fires. this._ready = true; diff --git a/build/router/router-min.js b/build/router/router-min.js index 1aaa428b6e7..c4f6036d41d 100644 --- a/build/router/router-min.js +++ b/build/router/router-min.js @@ -1 +1 @@ -YUI.add("router",function(a){var g=a.HistoryHash,e=a.Lang,c=a.QueryString,h=a.Array,f=a.config.win,j=f.location,i=[],d="ready";function b(){b.superclass.constructor.apply(this,arguments);}a.Router=a.extend(b,a.Base,{_regexPathParam:/([:*])([\w-]+)/g,_regexUrlQuery:/\?([^#]*).*$/,_regexUrlStrip:/^https?:\/\/[^\/]*/i,initializer:function(l){var k=this;k._html5=k.get("html5");k._routes=[];this._setRoutes(l&&l.routes?l.routes:this.get("routes"));if(k._html5){k._history=new a.HistoryHTML5({force:true});a.after("history:change",k._afterHistoryChange,k);}else{a.on("hashchange",k._afterHistoryChange,f,k);}k.publish(d,{defaultFn:k._defReadyFn,fireOnce:true,preventable:false});k.once("initializedChange",function(){a.once("load",function(){setTimeout(function(){k.fire(d,{dispatched:!!k._dispatched});},20);});});},destructor:function(){if(this._html5){a.detach("history:change",this._afterHistoryChange,this);}else{a.detach("hashchange",this._afterHistoryChange,f);}},dispatch:function(){this.once(d,function(){this._ready=true;if(this._html5&&this.upgrade()){return;}else{this._dispatch(this._getPath(),this._getURL());}});return this;},getPath:function(){return this._getPath();},hasRoute:function(k){return !!this.match(this.removeRoot(k)).length;},match:function(k){return h.filter(this._routes,function(l){return k.search(l.regex)>-1;});},removeRoot:function(l){var k=this.get("root");l=l.replace(this._regexUrlStrip,"");if(k&&l.indexOf(k)===0){l=l.substring(k.length);}return l.charAt(0)==="/"?l:"/"+l;},replace:function(k){return this._queue(k,true);},route:function(l,m){var k=[];this._routes.push({callback:m,keys:k,path:l,regex:this._getRegex(l,k)});return this;},save:function(k){return this._queue(k);},upgrade:function(){if(!this._html5){return false;}var k=this._getHashPath();if(k&&k.charAt(0)==="/"){this.once(d,function(){this.replace(k);});return true;}return false;},_decode:function(k){return decodeURIComponent(k.replace(/\+/g," "));},_dequeue:function(){var k=this,l;if(!YUI.Env.windowLoaded){a.once("load",function(){k._dequeue();});return this;}l=i.shift();return l?l():this;},_dispatch:function(p,m,q){var l=this,k=l.match(p),o,n;l._dispatching=l._dispatched=true;if(!k||!k.length){l._dispatching=false;return l;}o=l._getRequest(p,m,q);n=l._getResponse(o);o.next=function(s){var u,t,r;if(s){a.error(s);}else{if((r=k.shift())){t=r.regex.exec(p);u=typeof r.callback==="string"?l[r.callback]:r.callback;if(t.length===r.keys.length+1){o.params=h.hash(r.keys,t.slice(1));}else{o.params=t.concat();}u.call(l,o,n,o.next);}}};o.next();l._dispatching=false;return l._dequeue();},_getHashPath:function(){return g.getHash().replace(this._regexUrlQuery,"");},_getPath:function(){return(!this._html5&&this._getHashPath())||this.removeRoot(j.pathname);},_getQuery:function(){if(this._html5){return j.search.substring(1);}var l=g.getHash(),k=l.match(this._regexUrlQuery);return l&&k?k[1]:j.search.substring(1);},_getRegex:function(l,k){if(l instanceof RegExp){return l;}if(l==="*"){return/.*/;}l=l.replace(this._regexPathParam,function(n,m,o){k.push(o);return m==="*"?"(.*?)":"([^/]*)";});return new RegExp("^"+l+"$");},_getRequest:function(l,k,m){return{path:l,query:this._parseQuery(this._getQuery()),url:k,src:m};},_getResponse:function(l){var k=function(){return l.next.apply(this,arguments);};k.req=l;return k;},_getRoutes:function(){return this._routes.concat();},_getURL:function(){return j.toString();},_joinURL:function(l){var k=this.get("root");l=this.removeRoot(l);if(l.charAt(0)==="/"){l=l.substring(1);}return k&&k.charAt(k.length-1)==="/"?k+l:k+"/"+l;},_parseQuery:c&&c.parse?c.parse:function(n){var o=this._decode,q=n.split("&"),m=0,l=q.length,k={},p;for(;m=3)});a.Controller=a.Router;},"@VERSION@",{optional:["querystring-parse"],requires:["array-extras","base-build","history"]}); \ No newline at end of file +YUI.add("router",function(a){var f=a.HistoryHash,c=a.QueryString,g=a.Array,e=a.config.win,j=e.location,i=j.origin||(j.protocol+"//"+j.host),h=[],d="ready";function b(){b.superclass.constructor.apply(this,arguments);}a.Router=a.extend(b,a.Base,{_regexPathParam:/([:*])([\w\-]+)/g,_regexUrlQuery:/\?([^#]*).*$/,_regexUrlOrigin:/^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/,initializer:function(l){var k=this;k._html5=k.get("html5");k._routes=[];this._setRoutes(l&&l.routes?l.routes:this.get("routes"));if(k._html5){k._history=new a.HistoryHTML5({force:true});a.after("history:change",k._afterHistoryChange,k);}else{a.on("hashchange",k._afterHistoryChange,e,k);}k.publish(d,{defaultFn:k._defReadyFn,fireOnce:true,preventable:false});k.once("initializedChange",function(){a.once("load",function(){setTimeout(function(){k.fire(d,{dispatched:!!k._dispatched});},20);});});},destructor:function(){if(this._html5){a.detach("history:change",this._afterHistoryChange,this);}else{a.detach("hashchange",this._afterHistoryChange,e);}},dispatch:function(){this.once(d,function(){this._ready=true;if(this._html5&&this.upgrade()){return;}else{this._dispatch(this._getPath(),this._getURL());}});return this;},getPath:function(){return this._getPath();},hasRoute:function(k){if(!this._hasSameOrigin(k)){return false;}return !!this.match(this.removeRoot(k)).length;},match:function(k){return g.filter(this._routes,function(l){return k.search(l.regex)>-1;});},removeRoot:function(l){var k=this.get("root");l=l.replace(this._regexUrlOrigin,"");if(k&&l.indexOf(k)===0){l=l.substring(k.length);}return l.charAt(0)==="/"?l:"/"+l;},replace:function(k){return this._queue(k,true);},route:function(l,m){var k=[];this._routes.push({callback:m,keys:k,path:l,regex:this._getRegex(l,k)});return this;},save:function(k){return this._queue(k);},upgrade:function(){if(!this._html5){return false;}var k=this._getHashPath();if(k&&k.charAt(0)==="/"){this.once(d,function(){this.replace(k);});return true;}return false;},_decode:function(k){return decodeURIComponent(k.replace(/\+/g," "));},_dequeue:function(){var k=this,l;if(!YUI.Env.windowLoaded){a.once("load",function(){k._dequeue();});return this;}l=h.shift();return l?l():this;},_dispatch:function(p,m,q){var l=this,k=l.match(p),o,n;l._dispatching=l._dispatched=true;if(!k||!k.length){l._dispatching=false;return l;}o=l._getRequest(p,m,q);n=l._getResponse(o);o.next=function(s){var u,t,r;if(s){a.error(s);}else{if((r=k.shift())){t=r.regex.exec(p);u=typeof r.callback==="string"?l[r.callback]:r.callback;if(t.length===r.keys.length+1){o.params=g.hash(r.keys,t.slice(1));}else{o.params=t.concat();}u.call(l,o,n,o.next);}}};o.next();l._dispatching=false;return l._dequeue();},_getHashPath:function(){return f.getHash().replace(this._regexUrlQuery,"");},_getOrigin:function(){return i;},_getPath:function(){var k=(!this._html5&&this._getHashPath())||j.pathname;return this.removeRoot(k);},_getQuery:function(){if(this._html5){return j.search.substring(1);}var l=f.getHash(),k=l.match(this._regexUrlQuery);return l&&k?k[1]:j.search.substring(1);},_getRegex:function(l,k){if(l instanceof RegExp){return l;}if(l==="*"){return/.*/;}l=l.replace(this._regexPathParam,function(n,m,o){k.push(o);return m==="*"?"(.*?)":"([^/]*)";});return new RegExp("^"+l+"$");},_getRequest:function(l,k,m){return{path:l,query:this._parseQuery(this._getQuery()),url:k,src:m};},_getResponse:function(l){var k=function(){return l.next.apply(this,arguments);};k.req=l;return k;},_getRoutes:function(){return this._routes.concat();},_getURL:function(){return j.toString();},_hasSameOrigin:function(l){var k=((l&&l.match(this._regexUrlOrigin))||[])[0];if(k&&k.indexOf("//")===0){k=j.protocol+k;}return !k||k===this._getOrigin();},_joinURL:function(l){var k=this.get("root");l=this.removeRoot(l);if(l.charAt(0)==="/"){l=l.substring(1);}return k&&k.charAt(k.length-1)==="/"?k+l:k+"/"+l;},_parseQuery:c&&c.parse?c.parse:function(n){var o=this._decode,q=n.split("&"),m=0,l=q.length,k={},p;for(;m=3)});a.Controller=a.Router;},"@VERSION@",{optional:["querystring-parse"],requires:["array-extras","base-build","history"]}); \ No newline at end of file diff --git a/build/router/router.js b/build/router/router.js index a6fef4c6ae3..6d6737228cc 100644 --- a/build/router/router.js +++ b/build/router/router.js @@ -8,12 +8,12 @@ Provides URL-based routing using HTML5 `pushState()` or the location hash. **/ var HistoryHash = Y.HistoryHash, - Lang = Y.Lang, QS = Y.QueryString, YArray = Y.Array, win = Y.config.win, location = win.location, + origin = location.origin || (location.protocol + '//' + location.host), // We have to queue up pushState calls to avoid race conditions, since the // popstate event doesn't actually provide any info on what URL it's @@ -111,7 +111,7 @@ Y.Router = Y.extend(Router, Y.Base, { @type RegExp @protected **/ - _regexPathParam: /([:*])([\w-]+)/g, + _regexPathParam: /([:*])([\w\-]+)/g, /** Regex that matches and captures the query portion of a URL, minus the @@ -124,15 +124,15 @@ Y.Router = Y.extend(Router, Y.Base, { _regexUrlQuery: /\?([^#]*).*$/, /** - Regex that matches everything before the path portion of an HTTP or HTTPS - URL. This will be used to strip this part of the URL from a string when we + Regex that matches everything before the path portion of a URL (the origin). + This will be used to strip this part of the URL from a string when we only want the path. - @property _regexUrlStrip + @property _regexUrlOrigin @type RegExp @protected **/ - _regexUrlStrip: /^https?:\/\/[^\/]*/i, + _regexUrlOrigin: /^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/, // -- Lifecycle Methods ---------------------------------------------------- initializer: function (config) { @@ -220,12 +220,20 @@ Y.Router = Y.extend(Router, Y.Base, { Returns `true` if this router has at least one route that matches the specified URL, `false` otherwise. + This method enforces the same-origin security constraint on the specified + `url`; any URL which is not from the same origin as the current URL will + always return `false`. + @method hasRoute @param {String} url URL to match. @return {Boolean} `true` if there's at least one matching route, `false` otherwise. **/ hasRoute: function (url) { + if (!this._hasSameOrigin(url)) { + return false; + } + return !!this.match(this.removeRoot(url)).length; }, @@ -275,7 +283,7 @@ Y.Router = Y.extend(Router, Y.Base, { // Strip out the non-path part of the URL, if any (e.g. // "http://foo.com"), so that we're left with just the path. - url = url.replace(this._regexUrlStrip, ''); + url = url.replace(this._regexUrlOrigin, ''); if (root && url.indexOf(root) === 0) { url = url.substring(root.length); @@ -308,9 +316,9 @@ Y.Router = Y.extend(Router, Y.Base, { // New URL: http://example.com/ @method replace - @param {String} [url] URL to set. Should be a relative URL. If this - router's `root` property is set, this URL must be relative to the - root URL. If no URL is specified, the page's current URL will be used. + @param {String} [url] URL to set. This URL needs to be of the same origin as + the current URL. This can be a URL relative to the router's `root` + attribute. If no URL is specified, the page's current URL will be used. @chainable @see save() **/ @@ -422,9 +430,9 @@ Y.Router = Y.extend(Router, Y.Base, { // New URL: http://example.com/ @method save - @param {String} [url] URL to set. Should be a relative URL. If this - router's `root` property is set, this URL must be relative to the - root URL. If no URL is specified, the page's current URL will be used. + @param {String} [url] URL to set. This URL needs to be of the same origin as + the current URL. This can be a URL relative to the router's `root` + attribute. If no URL is specified, the page's current URL will be used. @chainable @see replace() **/ @@ -574,15 +582,29 @@ Y.Router = Y.extend(Router, Y.Base, { }, /** - Gets the current route path. + Gets the location origin (i.e., protocol, host, and port) as a URL. + + @example + http://example.com + + @method _getOrigin + @return {String} Location origin (i.e., protocol, host, and port). + @protected + **/ + _getOrigin: function () { + return origin; + }, + + /** + Gets the current route path, relative to the `root` (if any). @method _getPath @return {String} Current route path. @protected **/ _getPath: function () { - return (!this._html5 && this._getHashPath()) || - this.removeRoot(location.pathname); + var path = (!this._html5 && this._getHashPath()) || location.pathname; + return this.removeRoot(path); }, /** @@ -660,8 +682,8 @@ Y.Router = Y.extend(Router, Y.Base, { @protected **/ _getResponse: function (req) { - // For backcompat, the response object is a function that calls `next()` - // on the request object and returns the result. + // For backwards compatibility, the response object is a function that + // calls `next()` on the request object and returns the result. var res = function () { return req.next.apply(this, arguments); }; @@ -692,16 +714,39 @@ Y.Router = Y.extend(Router, Y.Base, { return location.toString(); }, + /** + Returns `true` when the specified `url` is from the same origin as the + current URL; i.e., the protocol, host, and port of the URLs are the same. + + All host or path relative URLs are of the same origin. A scheme-relative URL + is first prefixed with the current scheme before being evaluated. + + @method _hasSameOrigin + @param {String} url URL to compare origin with the current URL. + @return {Boolean} Whether the URL has the same origin of the current URL. + @protected + **/ + _hasSameOrigin: function (url) { + var origin = ((url && url.match(this._regexUrlOrigin)) || [])[0]; + + // Prepend current scheme to scheme-relative URLs. + if (origin && origin.indexOf('//') === 0) { + origin = location.protocol + origin; + } + + return !origin || origin === this._getOrigin(); + }, + /** Joins the `root` URL to the specified _url_, normalizing leading/trailing `/` characters. @example - router.root = '/foo' + router.set('root', '/foo'); router._joinURL('bar'); // => '/foo/bar' router._joinURL('/bar'); // => '/foo/bar' - router.root = '/foo/' + router.set('root', '/foo/'); router._joinURL('bar'); // => '/foo/bar' router._joinURL('/bar'); // => '/foo/bar' @@ -800,6 +845,10 @@ Y.Router = Y.extend(Router, Y.Base, { /** Saves a history entry using either `pushState()` or the location hash. + This method enforces the same-origin security constraint; attempting to save + a `url` that is not from the same origin as the current URL will result in + an error. + @method _save @param {String} [url] URL for the history entry. @param {Boolean} [replace=false] If `true`, the current history entry will @@ -810,6 +859,12 @@ Y.Router = Y.extend(Router, Y.Base, { _save: function (url, replace) { var urlIsString = typeof url === 'string'; + // Perform same-origin check on the specified URL. + if (urlIsString && !this._hasSameOrigin(url)) { + Y.error('Security error: The new URL must be of the same origin as the current URL.'); + return this; + } + // Force _ready to true to ensure that the history change is handled // even if _save is called before the `ready` event fires. this._ready = true;