Alison Gale 39e8ceb2cd fix(upgrade): fix AngularJsUrlCodec to support Safari (#32959)
Safari throws an error when the new URL() constructor is called with an
undefined base. This change checks whether the base is undefined and
then calls the corresponding version of the URL constructor.

fix(upgrade): simplify solution by replacing undefined with ''

Co-Authored-By: Pete Bacon Darwin <pete@bacondarwin.com>

Simplify solution by replacing undefined with ''

Co-Authored-By: Pete Bacon Darwin <pete@bacondarwin.com>

fix(upgrade): Avoid passing an empty string as the base as well.

Browsers other than Safari may have issues with the empty string.

PR Close #32959
2019-10-02 10:00:47 -07:00

340 lines
10 KiB
TypeScript

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* A codec for encoding and decoding URL parts.
*
* @publicApi
**/
export abstract class UrlCodec {
/**
* Encodes the path from the provided string
*
* @param path The path string
*/
abstract encodePath(path: string): string;
/**
* Decodes the path from the provided string
*
* @param path The path string
*/
abstract decodePath(path: string): string;
/**
* Encodes the search string from the provided string or object
*
* @param path The path string or object
*/
abstract encodeSearch(search: string|{[k: string]: unknown}): string;
/**
* Decodes the search objects from the provided string
*
* @param path The path string
*/
abstract decodeSearch(search: string): {[k: string]: unknown};
/**
* Encodes the hash from the provided string
*
* @param path The hash string
*/
abstract encodeHash(hash: string): string;
/**
* Decodes the hash from the provided string
*
* @param path The hash string
*/
abstract decodeHash(hash: string): string;
/**
* Normalizes the URL from the provided string
*
* @param path The URL string
*/
abstract normalize(href: string): string;
/**
* Normalizes the URL from the provided string, search, hash, and base URL parameters
*
* @param path The URL path
* @param search The search object
* @param hash The has string
* @param baseUrl The base URL for the URL
*/
abstract normalize(path: string, search: {[k: string]: unknown}, hash: string, baseUrl?: string):
string;
/**
* Checks whether the two strings are equal
* @param valA First string for comparison
* @param valB Second string for comparison
*/
abstract areEqual(valA: string, valB: string): boolean;
/**
* Parses the URL string based on the base URL
*
* @param url The full URL string
* @param base The base for the URL
*/
abstract parse(url: string, base?: string): {
href: string,
protocol: string,
host: string,
search: string,
hash: string,
hostname: string,
port: string,
pathname: string
};
}
/**
* A `UrlCodec` that uses logic from AngularJS to serialize and parse URLs
* and URL parameters.
*
* @publicApi
*/
export class AngularJSUrlCodec implements UrlCodec {
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L15
encodePath(path: string): string {
const segments = path.split('/');
let i = segments.length;
while (i--) {
// decode forward slashes to prevent them from being double encoded
segments[i] = encodeUriSegment(segments[i].replace(/%2F/g, '/'));
}
path = segments.join('/');
return _stripIndexHtml((path && path[0] !== '/' && '/' || '') + path);
}
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L42
encodeSearch(search: string|{[k: string]: unknown}): string {
if (typeof search === 'string') {
search = parseKeyValue(search);
}
search = toKeyValue(search);
return search ? '?' + search : '';
}
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L44
encodeHash(hash: string) {
hash = encodeUriSegment(hash);
return hash ? '#' + hash : '';
}
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L27
decodePath(path: string, html5Mode = true): string {
const segments = path.split('/');
let 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');
}
}
return segments.join('/');
}
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L72
decodeSearch(search: string) { return parseKeyValue(search); }
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L73
decodeHash(hash: string) {
hash = decodeURIComponent(hash);
return hash[0] === '#' ? hash.substring(1) : hash;
}
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L149
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L42
normalize(href: string): string;
normalize(path: string, search: {[k: string]: unknown}, hash: string, baseUrl?: string): string;
normalize(pathOrHref: string, search?: {[k: string]: unknown}, hash?: string, baseUrl?: string):
string {
if (arguments.length === 1) {
const parsed = this.parse(pathOrHref, baseUrl);
if (typeof parsed === 'string') {
return parsed;
}
const serverUrl =
`${parsed.protocol}://${parsed.hostname}${parsed.port ? ':' + parsed.port : ''}`;
return this.normalize(
this.decodePath(parsed.pathname), this.decodeSearch(parsed.search),
this.decodeHash(parsed.hash), serverUrl);
} else {
const encPath = this.encodePath(pathOrHref);
const encSearch = search && this.encodeSearch(search) || '';
const encHash = hash && this.encodeHash(hash) || '';
let joinedPath = (baseUrl || '') + encPath;
if (!joinedPath.length || joinedPath[0] !== '/') {
joinedPath = '/' + joinedPath;
}
return joinedPath + encSearch + encHash;
}
}
areEqual(valA: string, valB: string) { return this.normalize(valA) === this.normalize(valB); }
// https://github.com/angular/angular.js/blob/864c7f0/src/ng/urlUtils.js#L60
parse(url: string, base?: string) {
try {
// Safari 12 throws an error when the URL constructor is called with an undefined base.
const parsed = !base ? new URL(url) : new URL(url, base);
return {
href: parsed.href,
protocol: parsed.protocol ? parsed.protocol.replace(/:$/, '') : '',
host: parsed.host,
search: parsed.search ? parsed.search.replace(/^\?/, '') : '',
hash: parsed.hash ? parsed.hash.replace(/^#/, '') : '',
hostname: parsed.hostname,
port: parsed.port,
pathname: (parsed.pathname.charAt(0) === '/') ? parsed.pathname : '/' + parsed.pathname
};
} catch (e) {
throw new Error(`Invalid URL (${url}) with base (${base})`);
}
}
}
function _stripIndexHtml(url: string): string {
return url.replace(/\/index.html$/, '');
}
/**
* Tries to decode the URI component without throwing an exception.
*
* @private
* @param str value potential URI component to check.
* @returns {boolean} True if `value` can be decoded
* with the decodeURIComponent function.
*/
function tryDecodeURIComponent(value: string) {
try {
return decodeURIComponent(value);
} catch (e) {
// Ignore any invalid uri component.
return undefined;
}
}
/**
* Parses an escaped url query string into key-value pairs. Logic taken from
* https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1382
* @returns {Object.<string,boolean|Array>}
*/
function parseKeyValue(keyValue: string): {[k: string]: unknown} {
const obj: {[k: string]: unknown} = {};
(keyValue || '').split('&').forEach((keyValue) => {
let splitPoint, key, val;
if (keyValue) {
key = keyValue = keyValue.replace(/\+/g, '%20');
splitPoint = keyValue.indexOf('=');
if (splitPoint !== -1) {
key = keyValue.substring(0, splitPoint);
val = keyValue.substring(splitPoint + 1);
}
key = tryDecodeURIComponent(key);
if (typeof key !== 'undefined') {
val = typeof val !== 'undefined' ? tryDecodeURIComponent(val) : true;
if (!obj.hasOwnProperty(key)) {
obj[key] = val;
} else if (Array.isArray(obj[key])) {
(obj[key] as unknown[]).push(val);
} else {
obj[key] = [obj[key], val];
}
}
}
});
return obj;
}
/**
* Serializes into key-value pairs. Logic taken from
* https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1409
*/
function toKeyValue(obj: {[k: string]: unknown}) {
const parts: unknown[] = [];
for (const key in obj) {
let value = obj[key];
if (Array.isArray(value)) {
value.forEach((arrayValue) => {
parts.push(
encodeUriQuery(key, true) +
(arrayValue === true ? '' : '=' + encodeUriQuery(arrayValue, true)));
});
} else {
parts.push(
encodeUriQuery(key, true) +
(value === true ? '' : '=' + encodeUriQuery(value as any, true)));
}
}
return parts.length ? parts.join('&') : '';
}
/**
* We need our custom method because encodeURIComponent is too aggressive and doesn't follow
* http://www.ietf.org/rfc/rfc3986.txt with regards to the character set (pchar) allowed in path
* segments:
* segment = *pchar
* pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
* pct-encoded = "%" HEXDIG HEXDIG
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
* / "*" / "+" / "," / ";" / "="
*
* Logic from https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1437
*/
function encodeUriSegment(val: string) {
return encodeUriQuery(val, true)
.replace(/%26/gi, '&')
.replace(/%3D/gi, '=')
.replace(/%2B/gi, '+');
}
/**
* This method is intended for encoding *key* or *value* parts of query component. We need a custom
* method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be
* encoded per http://tools.ietf.org/html/rfc3986:
* query = *( pchar / "/" / "?" )
* pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
* pct-encoded = "%" HEXDIG HEXDIG
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
* / "*" / "+" / "," / ";" / "="
*
* Logic from https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1456
*/
function encodeUriQuery(val: string, pctEncodeSpaces: boolean = false) {
return encodeURIComponent(val)
.replace(/%40/gi, '@')
.replace(/%3A/gi, ':')
.replace(/%24/g, '$')
.replace(/%2C/gi, ',')
.replace(/%3B/gi, ';')
.replace(/%20/g, (pctEncodeSpaces ? '%20' : '+'));
}