diff --git a/src/Angular.js b/src/Angular.js index f5ab043dc8a3..7e82133763d3 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -1405,20 +1405,26 @@ function tryDecodeURIComponent(value) { * Parses an escaped url query string into key-value pairs. * @returns {Object.} */ -function parseKeyValue(/**string*/keyValue) { +function parseKeyValue(/**string*/keyValue, decodeQueryKeyValue) { + var decode = decodeQueryKeyValue || function(x) { + return tryDecodeURIComponent(x.replace(/\+/g, '%20')); + }; + var obj = {}; + forEach((keyValue || '').split('&'), function(keyValue) { var splitPoint, key, val; if (keyValue) { - key = keyValue = keyValue.replace(/\+/g,'%20'); splitPoint = keyValue.indexOf('='); - if (splitPoint !== -1) { + if (splitPoint === -1) { + key = keyValue; + } else { key = keyValue.substring(0, splitPoint); val = keyValue.substring(splitPoint + 1); } - key = tryDecodeURIComponent(key); + key = decode(key); if (isDefined(key)) { - val = isDefined(val) ? tryDecodeURIComponent(val) : true; + val = isDefined(val) ? decode(val) : true; if (!hasOwnProperty.call(obj, key)) { obj[key] = val; } else if (isArray(obj[key])) { @@ -1429,23 +1435,27 @@ function parseKeyValue(/**string*/keyValue) { } } }); + return obj; } -function toKeyValue(obj) { +function toKeyValue(obj, encodeQueryKeyValue) { + var encode = encodeQueryKeyValue || function(x) { return encodeUriQuery(x, true); }; + var processPair = function(key, value) { + parts.push(encode(String(key)) + (value === true ? '' : '=' + encode(String(value)))); + }; + var parts = []; + forEach(obj, function(value, key) { if (isArray(value)) { - forEach(value, function(arrayValue) { - parts.push(encodeUriQuery(key, true) + - (arrayValue === true ? '' : '=' + encodeUriQuery(arrayValue, true))); - }); + forEach(value, processPair.bind(null, key)); } else { - parts.push(encodeUriQuery(key, true) + - (value === true ? '' : '=' + encodeUriQuery(value, true))); + processPair(key, value); } }); - return parts.length ? parts.join('&') : ''; + + return parts.join('&'); } diff --git a/src/AngularPublic.js b/src/AngularPublic.js index c18889911a50..ba19b454fe84 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -77,6 +77,12 @@ $xhrFactoryProvider, $jsonpCallbacksProvider, $LocationProvider, + $$DecodePathSegmentProvider, + $$DecodeQueryKeyValueProvider, + $$DecodeHashProvider, + $$EncodePathSegmentProvider, + $$EncodeQueryKeyValueProvider, + $$EncodeHashProvider, $LogProvider, $$MapProvider, $ParseProvider, @@ -246,6 +252,12 @@ function publishExternalAPI(angular) { $xhrFactory: $xhrFactoryProvider, $jsonpCallbacks: $jsonpCallbacksProvider, $location: $LocationProvider, + $$decodePathSegment: $$DecodePathSegmentProvider, + $$decodeQueryKeyValue: $$DecodeQueryKeyValueProvider, + $$decodeHash: $$DecodeHashProvider, + $$encodePathSegment: $$EncodePathSegmentProvider, + $$encodeQueryKeyValue: $$EncodeQueryKeyValueProvider, + $$encodeHash: $$EncodeHashProvider, $log: $LogProvider, $parse: $ParseProvider, $rootScope: $RootScopeProvider, diff --git a/src/ng/location.js b/src/ng/location.js index 09f08c09cdfe..ee995ba09856 100644 --- a/src/ng/location.js +++ b/src/ng/location.js @@ -9,30 +9,26 @@ var $locationMinErr = minErr('$location'); * Encode path using encodeUriSegment, ignoring forward slashes * * @param {string} path Path to encode + * @param {Function} encodePathSegment Function for encoding a path segment * @returns {string} */ -function encodePath(path) { +function encodePath(path, encodePathSegment) { var segments = path.split('/'), i = segments.length; while (i--) { - // decode forward slashes to prevent them from being double encoded - segments[i] = encodeUriSegment(segments[i].replace(/%2F/g, '/')); + segments[i] = encodePathSegment(segments[i]); } return segments.join('/'); } -function decodePath(path, html5Mode) { +function decodePath(path, decodePathSegment) { var segments = path.split('/'), i = segments.length; while (i--) { - segments[i] = decodeURIComponent(segments[i]); - if (html5Mode) { - // encode forward slashes to prevent them from being mistaken for path separators - segments[i] = segments[i].replace(/\//g, '%2F'); - } + segments[i] = decodePathSegment(segments[i]); } return segments.join('/'); @@ -47,7 +43,7 @@ function parseAbsoluteUrl(absoluteUrl, locationObj) { } var DOUBLE_SLASH_REGEX = /^\s*[\\/]{2,}/; -function parseAppUrl(url, locationObj, html5Mode) { +function parseAppUrl(url, locationObj) { if (DOUBLE_SLASH_REGEX.test(url)) { throw $locationMinErr('badpath', 'Invalid url "{0}".', url); @@ -59,9 +55,9 @@ function parseAppUrl(url, locationObj, html5Mode) { } var match = urlResolve(url); var path = prefixed && match.pathname.charAt(0) === '/' ? match.pathname.substring(1) : match.pathname; - locationObj.$$path = decodePath(path, html5Mode); - locationObj.$$search = parseKeyValue(match.search); - locationObj.$$hash = decodeURIComponent(match.hash); + locationObj.$$path = decodePath(path, locationObj.$$decodePathSegment); + locationObj.$$search = parseKeyValue(match.search, locationObj.$$decodeQueryKeyValue); + locationObj.$$hash = locationObj.$$decodeHash(match.hash); // make sure path starts with '/'; if (locationObj.$$path && locationObj.$$path.charAt(0) !== '/') { @@ -134,7 +130,7 @@ function LocationHtml5Url(appBase, appBaseNoFile, basePrefix) { appBaseNoFile); } - parseAppUrl(pathUrl, this, true); + parseAppUrl(pathUrl, this); if (!this.$$path) { this.$$path = '/'; @@ -148,10 +144,11 @@ function LocationHtml5Url(appBase, appBaseNoFile, basePrefix) { * @private */ this.$$compose = function() { - var search = toKeyValue(this.$$search), - hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; + var path = encodePath(this.$$path, this.$$encodePathSegment), + search = toKeyValue(this.$$search, this.$$encodeQueryKeyValue), + hash = this.$$hash ? '#' + this.$$encodeHash(this.$$hash) : ''; - this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + this.$$url = path + (search ? '?' + search : '') + hash; this.$$absUrl = appBaseNoFile + this.$$url.substr(1); // first char is always '/' this.$$urlUpdatedByLocation = true; @@ -237,7 +234,7 @@ function LocationHashbangUrl(appBase, appBaseNoFile, hashPrefix) { } } - parseAppUrl(withoutHashUrl, this, false); + parseAppUrl(withoutHashUrl, this); this.$$path = removeWindowsDriveName(this.$$path, withoutHashUrl, appBase); @@ -283,10 +280,11 @@ function LocationHashbangUrl(appBase, appBaseNoFile, hashPrefix) { * @private */ this.$$compose = function() { - var search = toKeyValue(this.$$search), - hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; + var path = encodePath(this.$$path, this.$$encodePathSegment), + search = toKeyValue(this.$$search, this.$$encodeQueryKeyValue), + hash = this.$$hash ? '#' + this.$$encodeHash(this.$$hash) : ''; - this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + this.$$url = path + (search ? '?' + search : '') + hash; this.$$absUrl = appBase + (this.$$url ? hashPrefix + this.$$url : ''); this.$$urlUpdatedByLocation = true; @@ -341,16 +339,15 @@ function LocationHashbangInHtml5Url(appBase, appBaseNoFile, hashPrefix) { }; this.$$compose = function() { - var search = toKeyValue(this.$$search), - hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; + var path = encodePath(this.$$path, this.$$encodePathSegment), + search = toKeyValue(this.$$search, this.$$encodeQueryKeyValue), + hash = this.$$hash ? '#' + this.$$encodeHash(this.$$hash) : ''; - this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; - // include hashPrefix in $$absUrl when $$url is empty so IE9 does not reload page because of removal of '#' + this.$$url = path + (search ? '?' + search : '') + hash; this.$$absUrl = appBase + hashPrefix + this.$$url; this.$$urlUpdatedByLocation = true; }; - } @@ -422,9 +419,9 @@ var locationPrototype = { } var match = PATH_MATCH.exec(url); - if (match[1] || url === '') this.path(decodeURIComponent(match[1])); + if (match[1] || url === '') this.path(decodePath(match[1], this.$$decodePathSegment)); if (match[2] || match[1] || url === '') this.search(match[3] || ''); - this.hash(match[5] || ''); + this.hash(this.$$decodeHash(match[5] || '')); return this; }, @@ -578,7 +575,7 @@ var locationPrototype = { case 1: if (isString(search) || isNumber(search)) { search = search.toString(); - this.$$search = parseKeyValue(search); + this.$$search = parseKeyValue(search, this.$$decodeQueryKeyValue); } else if (isObject(search)) { search = copy(search, {}); // remove object undefined or null properties @@ -706,6 +703,83 @@ function locationGetterSetter(property, preprocess) { } +/** + * @private + * A function for decoding URL path segments. + */ +$$DecodePathSegmentProvider.$inject = ['$locationProvider']; +/** @this */ function $$DecodePathSegmentProvider($locationProvider) { + this.$get = ['$sniffer', function($sniffer) { + var html5Mode = $locationProvider.html5Mode().enabled && $sniffer.history; + + return function(segment) { + segment = decodeURIComponent(segment); + if (html5Mode) { + // encode forward slashes to prevent them from being mistaken for path separators + segment = segment.replace(/\//g, '%2F'); + } + return segment; + }; + }]; +} +/** + * @private + * A function for decoding URL query keys/values. + */ +/** @this */ function $$DecodeQueryKeyValueProvider() { + this.$get = function() { + return function(keyOrValue) { + return tryDecodeURIComponent(keyOrValue.replace(/\+/g, '%20')); + }; + }; +} +/** + * @private + * A function for decoding URL hash fragments. + */ +/** @this */ function $$DecodeHashProvider() { + this.$get = function() { + return function(hash) { + return decodeURIComponent(hash); + }; + }; +} +/** + * @private + * A function for encoding URL path segments. + */ +/** @this */ function $$EncodePathSegmentProvider() { + this.$get = function() { + return function(segment) { + // decode forward slashes to prevent them from being double encoded + return encodeUriSegment(segment.replace(/%2F/g, '/')); + }; + }; +} +/** + * @private + * A function for encoding URL query keys/values. + */ +/** @this */ function $$EncodeQueryKeyValueProvider() { + this.$get = function() { + return function(keyOrValue) { + return encodeUriQuery(keyOrValue, true); + }; + }; +} +/** + * @private + * A function for encoding URL hash fragments. + */ +/** @this */ function $$EncodeHashProvider() { + this.$get = function() { + return function(hash) { + return encodeUriSegment(hash); + }; + }; +} + + /** * @ngdoc service * @name $location @@ -852,7 +926,11 @@ function $LocationProvider() { */ this.$get = ['$rootScope', '$browser', '$sniffer', '$rootElement', '$window', - function($rootScope, $browser, $sniffer, $rootElement, $window) { + '$$decodePathSegment', '$$decodeQueryKeyValue', '$$decodeHash', + '$$encodePathSegment', '$$encodeQueryKeyValue', '$$encodeHash', + function($rootScope, $browser, $sniffer, $rootElement, $window, + $$decodePathSegment, $$decodeQueryKeyValue, $$decodeHash, + $$encodePathSegment, $$encodeQueryKeyValue, $$encodeHash) { var $location, LocationMode, baseHref = $browser.baseHref(), // if base[href] is undefined, it defaults to '' @@ -872,6 +950,15 @@ function $LocationProvider() { } var appBaseNoFile = stripFile(appBase); + extend(LocationMode.prototype, { + $$decodePathSegment: $$decodePathSegment, + $$decodeQueryKeyValue: $$decodeQueryKeyValue, + $$decodeHash: $$decodeHash, + $$encodePathSegment: $$encodePathSegment, + $$encodeQueryKeyValue: $$encodeQueryKeyValue, + $$encodeHash: $$encodeHash + }); + $location = new LocationMode(appBase, appBaseNoFile, '#' + hashPrefix); $location.$$parseLinkUrl(initialUrl, initialUrl); diff --git a/test/AngularSpec.js b/test/AngularSpec.js index ffe157de589f..48436db635ed 100644 --- a/test/AngularSpec.js +++ b/test/AngularSpec.js @@ -1191,66 +1191,142 @@ describe('angular', function() { describe('parseKeyValue', function() { - it('should parse a string into key-value pairs', function() { - expect(parseKeyValue('')).toEqual({}); - expect(parseKeyValue('simple=pair')).toEqual({simple: 'pair'}); - expect(parseKeyValue('first=1&second=2')).toEqual({first: '1', second: '2'}); - expect(parseKeyValue('escaped%20key=escaped%20value')). - toEqual({'escaped key': 'escaped value'}); - expect(parseKeyValue('emptyKey=')).toEqual({emptyKey: ''}); - expect(parseKeyValue('flag1&key=value&flag2')). - toEqual({flag1: true, key: 'value', flag2: true}); - }); - it('should ignore key values that are not valid URI components', function() { - expect(function() { parseKeyValue('%'); }).not.toThrow(); - expect(parseKeyValue('%')).toEqual({}); - expect(parseKeyValue('invalid=%')).toEqual({ invalid: undefined }); - expect(parseKeyValue('invalid=%&valid=good')).toEqual({ invalid: undefined, valid: 'good' }); - }); - it('should parse a string into key-value pairs with duplicates grouped in an array', function() { - expect(parseKeyValue('')).toEqual({}); - expect(parseKeyValue('duplicate=pair')).toEqual({duplicate: 'pair'}); - expect(parseKeyValue('first=1&first=2')).toEqual({first: ['1','2']}); - expect(parseKeyValue('escaped%20key=escaped%20value&&escaped%20key=escaped%20value2')). - toEqual({'escaped key': ['escaped value','escaped value2']}); - expect(parseKeyValue('flag1&key=value&flag1')). - toEqual({flag1: [true,true], key: 'value'}); - expect(parseKeyValue('flag1&flag1=value&flag1=value2&flag1')). - toEqual({flag1: [true,'value','value2',true]}); - }); - - - it('should ignore properties higher in the prototype chain', function() { - expect(parseKeyValue('toString=123')).toEqual({ - 'toString': '123' - }); - }); + var toUpperCase = function(x) { return isString(x) ? x.toUpperCase() : x; }; + var decoders = [ + undefined, + function(x) { return toUpperCase(tryDecodeURIComponent(x)); } + ]; + var expectedGetters = [ + identity, + function(x) { + var y = {}; + Object.keys(x).forEach(function(key) { + var value = x[key]; + y[toUpperCase(key)] = isArray(value) ? value.map(toUpperCase) : toUpperCase(value); + }); + return y; + } + ]; + + forEach(decoders, function(decoder, i) { + var description = '(with ' + (decoder ? 'custom' : 'default') + ' decoder)'; + var actual = function(input) { return parseKeyValue(input, decoder); }; + var expected = expectedGetters[i]; + + describe(description, function() { + it('should parse a string into key-value pairs', function() { + expect(actual('')).toEqual(expected({})); + expect(actual('simple=pair')).toEqual(expected({simple: 'pair'})); + expect(actual('first=1&second=2')).toEqual(expected({first: '1', second: '2'})); + expect(actual('escaped%20key=escaped%20value')). + toEqual(expected({'escaped key': 'escaped value'})); + expect(actual('emptyKey=')).toEqual(expected({emptyKey: ''})); + expect(actual('flag1&key=value&flag2')). + toEqual(expected({flag1: true, key: 'value', flag2: true})); + }); + + it('should ignore key values that are not valid URI components', function() { + expect(function() { actual('%'); }).not.toThrow(); + expect(actual('%')).toEqual(expected({})); + expect(actual('invalid=%')).toEqual(expected({invalid: undefined})); + expect(actual('invalid=%&valid=good')). + toEqual(expected({invalid: undefined, valid: 'good'})); + }); + + it('should parse a string into key-value pairs with duplicates grouped in an array', function() { + expect(actual('')).toEqual(expected({})); + expect(actual('duplicate=pair')).toEqual(expected({duplicate: 'pair'})); + expect(actual('first=1&first=2')).toEqual(expected({first: ['1', '2']})); + expect(actual('escaped%20key=escaped%20value&&escaped%20key=escaped%20value2')). + toEqual(expected({'escaped key': ['escaped value', 'escaped value2']})); + expect(actual('flag1&key=value&flag1')). + toEqual(expected({flag1: [true, true], key: 'value'})); + expect(actual('flag1&flag1=value&flag1=value2&flag1')). + toEqual(expected({flag1: [true, 'value', 'value2', true]})); + }); + + + it('should ignore properties higher in the prototype chain', function() { + expect(actual('toString=123')).toEqual(expected({toString: '123'})); + }); + + it('should ignore badly escaped = characters', function() { + expect(actual('test=a=b')).toEqual(expected({test: 'a=b'})); + }); + + if (decoder) { + it('should call the decoder with each key and value', function() { + const decoderSpy = jasmine.createSpy('decoder').and.callFake(decoder); + + // Flags have no value. + parseKeyValue('key=value&num=123&flag', decoderSpy); + var allArgs = decoderSpy.calls.allArgs().map(function(arr) { return arr[0]; }); + expect(decoderSpy).toHaveBeenCalledTimes(5); + expect(allArgs).toEqual(['key', 'value', 'num', '123', 'flag']); - it('should ignore badly escaped = characters', function() { - expect(parseKeyValue('test=a=b')).toEqual({ - 'test': 'a=b' + decoderSpy.calls.reset(); + + // Decoder called with key for each value; flags have no value. + parseKeyValue('arr=val&arr=456&arr', decoderSpy); + allArgs = decoderSpy.calls.allArgs().map(function(arr) { return arr[0]; }); + expect(decoderSpy).toHaveBeenCalledTimes(5); + expect(allArgs).toEqual(['arr', 'val', 'arr', '456', 'arr']); + }); + } }); }); }); describe('toKeyValue', function() { - it('should serialize key-value pairs into string', function() { - expect(toKeyValue({})).toEqual(''); - expect(toKeyValue({simple: 'pair'})).toEqual('simple=pair'); - expect(toKeyValue({first: '1', second: '2'})).toEqual('first=1&second=2'); - expect(toKeyValue({'escaped key': 'escaped value'})). - toEqual('escaped%20key=escaped%20value'); - expect(toKeyValue({emptyKey: ''})).toEqual('emptyKey='); - }); + var encoders = [undefined, function(x) { return encodeUriQuery(x, true).toUpperCase(); }]; + var expectedGetters = [identity, function(x) { return x.toUpperCase(); }]; + + forEach(encoders, function(encoder, i) { + var description = '(with ' + (encoder ? 'custom' : 'default') + ' encoder)'; + var actual = function(input) { return toKeyValue(input, encoder); }; + var expected = expectedGetters[i]; + + describe(description, function() { + it('should serialize key-value pairs into string', function() { + expect(actual({})).toBe(expected('')); + expect(actual({simple: 'pair'})).toBe(expected('simple=pair')); + expect(actual({first: '1', second: '2'})).toBe(expected('first=1&second=2')); + expect(actual({'escaped key': 'escaped value'})). + toBe(expected('escaped%20key=escaped%20value')); + expect(actual({emptyKey: ''})).toBe(expected('emptyKey=')); + }); - it('should serialize true values into flags', function() { - expect(toKeyValue({flag1: true, key: 'value', flag2: true})).toEqual('flag1&key=value&flag2'); - }); + it('should serialize true values into flags', function() { + expect(actual({flag1: true, key: 'value', flag2: true})). + toBe(expected('flag1&key=value&flag2')); + }); - it('should serialize duplicates into duplicate param strings', function() { - expect(toKeyValue({key: [323,'value',true]})).toEqual('key=323&key=value&key'); - expect(toKeyValue({key: [323,'value',true, 1234]})). - toEqual('key=323&key=value&key&key=1234'); + it('should serialize duplicates into duplicate param strings', function() { + expect(actual({key: [323, 'value', true]})).toBe(expected('key=323&key=value&key')); + expect(actual({key: [323, 'value', true, 1234]})). + toBe(expected('key=323&key=value&key&key=1234')); + }); + + if (encoder) { + it('should call the encoder with each key and value', function() { + const encoderSpy = jasmine.createSpy('encoder').and.callFake(encoder); + + // Numbers are stringified first; flags have no value. + toKeyValue({key: 'value', num: 123, flag: true}, encoderSpy); + var allArgs = encoderSpy.calls.allArgs().map(function(arr) { return arr[0]; }); + expect(encoderSpy).toHaveBeenCalledTimes(5); + expect(allArgs).toEqual(['key', 'value', 'num', '123', 'flag']); + + encoderSpy.calls.reset(); + + // Encoder called with key for each value; flags have no value. + toKeyValue({arr: ['val', 456, true]}, encoderSpy); + allArgs = encoderSpy.calls.allArgs().map(function(arr) { return arr[0]; }); + expect(encoderSpy).toHaveBeenCalledTimes(5); + expect(allArgs).toEqual(['arr', 'val', 'arr', '456', 'arr']); + }); + } + }); }); });