Skip to content
Permalink
ef519c3c3f
Switch branches/tags
Go to file
 
 
Cannot retrieve contributors at this time
552 lines (494 sloc) 18.9 KB
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { AbortSignalLike } from "@azure/abort-controller";
import { HttpHeaders, isNode, URLBuilder } from "@azure/core-http";
import { HeaderConstants, URLConstants, DevelopmentConnectionString } from "./constants";
/**
* Reserved URL characters must be properly escaped for Storage services like Blob or File.
*
* ## URL encode and escape strategy for JS SDKs
*
* When customers pass a URL string into XxxClient classes constrcutor, the URL string may already be URL encoded or not.
* But before sending to Azure Storage server, the URL must be encoded. However, it's hard for a SDK to guess whether the URL
* string has been encoded or not. We have 2 potential strategies, and chose strategy two for the XxxClient constructors.
*
* ### Strategy One: Assume the customer URL string is not encoded, and always encode URL string in SDK.
*
* This is what legacy V2 SDK does, simple and works for most of the cases.
* - When customer URL string is "http://account.blob.core.windows.net/con/b:",
* SDK will encode it to "http://account.blob.core.windows.net/con/b%3A" and send to server. A blob named "b:" will be created.
* - When customer URL string is "http://account.blob.core.windows.net/con/b%3A",
* SDK will encode it to "http://account.blob.core.windows.net/con/b%253A" and send to server. A blob named "b%3A" will be created.
*
* But this strategy will make it not possible to create a blob with "?" in it's name. Because when customer URL string is
* "http://account.blob.core.windows.net/con/blob?name", the "?name" will be treated as URL paramter instead of blob name.
* If customer URL string is "http://account.blob.core.windows.net/con/blob%3Fname", a blob named "blob%3Fname" will be created.
* V2 SDK doesn't have this issue because it doesn't allow customer pass in a full URL, it accepts a separate blob name and encodeURIComponent for it.
* We cannot accept a SDK cannot create a blob name with "?". So we implement strategy two:
*
* ### Strategy Two: SDK doesn't assume the URL has been encoded or not. It will just escape the special characters.
*
* This is what V10 Blob Go SDK does. It accepts a URL type in Go, and call url.EscapedPath() to escape the special chars unescaped.
* - When customer URL string is "http://account.blob.core.windows.net/con/b:",
* SDK will escape ":" like "http://account.blob.core.windows.net/con/b%3A" and send to server. A blob named "b:" will be created.
* - When customer URL string is "http://account.blob.core.windows.net/con/b%3A",
* There is no special characters, so send "http://account.blob.core.windows.net/con/b%3A" to server. A blob named "b:" will be created.
* - When customer URL string is "http://account.blob.core.windows.net/con/b%253A",
* There is no special characters, so send "http://account.blob.core.windows.net/con/b%253A" to server. A blob named "b%3A" will be created.
*
* This strategy gives us flexibility to create with any special characters. But "%" will be treated as a special characters, if the URL string
* is not encoded, there shouldn't a "%" in the URL string, otherwise the URL is not a valid URL.
* If customer needs to create a blob with "%" in it's blob name, use "%25" insead of "%". Just like above 3rd sample.
* And following URL strings are invalid:
* - "http://account.blob.core.windows.net/con/b%"
* - "http://account.blob.core.windows.net/con/b%2"
* - "http://account.blob.core.windows.net/con/b%G"
*
* Another special character is "?", use "%2F" to represent a blob name with "?" in a URL string.
*
* ### Strategy for containerName, blobName or other specific XXXName parameters in methods such as `containerClient.getBlobClient(blobName)`
*
* We will apply strategy one, and call encodeURIComponent for these parameters like blobName. Because what customers passes in is a plain name instead of a URL.
*
* @see https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata
* @see https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-shares--directories--files--and-metadata
*
* @export
* @param {string} url
* @returns {string}
*/
export function escapeURLPath(url: string): string {
const urlParsed = URLBuilder.parse(url);
let path = urlParsed.getPath();
path = path || "/";
path = escape(path);
urlParsed.setPath(path);
return urlParsed.toString();
}
export interface ConnectionString {
kind: "AccountConnString" | "SASConnString";
url: string;
accountName: string;
accountKey?: any;
accountSas?: string;
proxyUri?: string; // Development Connection String may contain proxyUri
}
function getProxyUriFromDevConnString(connectionString: string): string {
// Development Connection String
// https://docs.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string#connect-to-the-emulator-account-using-the-well-known-account-name-and-key
let proxyUri = "";
if (connectionString.search("DevelopmentStorageProxyUri=") !== -1) {
// CONNECTION_STRING=UseDevelopmentStorage=true;DevelopmentStorageProxyUri=http://myProxyUri
const matchCredentials = connectionString.split(";");
for (const element of matchCredentials) {
if (element.trim().startsWith("DevelopmentStorageProxyUri=")) {
proxyUri = element.trim().match("DevelopmentStorageProxyUri=(.*)")![1];
}
}
}
return proxyUri;
}
export function getValueInConnString(
connectionString: string,
argument:
| "BlobEndpoint"
| "AccountName"
| "AccountKey"
| "DefaultEndpointsProtocol"
| "EndpointSuffix"
| "SharedAccessSignature"
) {
const elements = connectionString.split(";");
for (const element of elements) {
if (element.trim().startsWith(argument)) {
return element.trim().match(argument + "=(.*)")![1];
}
}
return "";
}
/**
* Extracts the parts of an Azure Storage account connection string.
*
* @export
* @param {string} connectionString Connection string.
* @returns {ConnectionString} String key value pairs of the storage account's url and credentials.
*/
export function extractConnectionStringParts(connectionString: string): ConnectionString {
let proxyUri = "";
if (connectionString.startsWith("UseDevelopmentStorage=true")) {
// Development connection string
proxyUri = getProxyUriFromDevConnString(connectionString);
connectionString = DevelopmentConnectionString;
}
// Matching BlobEndpoint in the Account connection string
let blobEndpoint = getValueInConnString(connectionString, "BlobEndpoint");
// Slicing off '/' at the end if exists
// (The methods that use `extractConnectionStringParts` expect the url to not have `/` at the end)
blobEndpoint = blobEndpoint.endsWith("/") ? blobEndpoint.slice(0, -1) : blobEndpoint;
if (
connectionString.search("DefaultEndpointsProtocol=") !== -1 &&
connectionString.search("AccountKey=") !== -1
) {
// Account connection string
let defaultEndpointsProtocol = "";
let accountName = "";
let accountKey = Buffer.from("accountKey", "base64");
let endpointSuffix = "";
// Get account name and key
accountName = getValueInConnString(connectionString, "AccountName");
accountKey = Buffer.from(getValueInConnString(connectionString, "AccountKey"), "base64");
if (!blobEndpoint) {
// BlobEndpoint is not present in the Account connection string
// Can be obtained from `${defaultEndpointsProtocol}://${accountName}.blob.${endpointSuffix}`
defaultEndpointsProtocol = getValueInConnString(connectionString, "DefaultEndpointsProtocol");
const protocol = defaultEndpointsProtocol!.toLowerCase();
if (protocol !== "https" && protocol !== "http") {
throw new Error(
"Invalid DefaultEndpointsProtocol in the provided Connection String. Expecting 'https' or 'http'"
);
}
endpointSuffix = getValueInConnString(connectionString, "EndpointSuffix");
if (!endpointSuffix) {
throw new Error("Invalid EndpointSuffix in the provided Connection String");
}
blobEndpoint = `${defaultEndpointsProtocol}://${accountName}.blob.${endpointSuffix}`;
}
if (!accountName) {
throw new Error("Invalid AccountName in the provided Connection String");
} else if (accountKey.length === 0) {
throw new Error("Invalid AccountKey in the provided Connection String");
}
return {
kind: "AccountConnString",
url: blobEndpoint,
accountName,
accountKey,
proxyUri
};
} else {
// SAS connection string
let accountSas = getValueInConnString(connectionString, "SharedAccessSignature");
let accountName = getAccountNameFromUrl(blobEndpoint);
if (!blobEndpoint) {
throw new Error("Invalid BlobEndpoint in the provided SAS Connection String");
} else if (!accountSas) {
throw new Error("Invalid SharedAccessSignature in the provided SAS Connection String");
} else if (!accountName) {
throw new Error("Invalid AccountName in the provided SAS Connection String");
}
return { kind: "SASConnString", url: blobEndpoint, accountName, accountSas };
}
}
/**
* Internal escape method implmented Strategy Two mentioned in escapeURL() description.
*
* @param {string} text
* @returns {string}
*/
function escape(text: string): string {
return encodeURIComponent(text)
.replace(/%2F/g, "/") // Don't escape for "/"
.replace(/'/g, "%27") // Escape for "'"
.replace(/\+/g, "%20")
.replace(/%25/g, "%"); // Revert encoded "%"
}
/**
* Append a string to URL path. Will remove duplicated "/" in front of the string
* when URL path ends with a "/".
*
* @export
* @param {string} url Source URL string
* @param {string} name String to be appended to URL
* @returns {string} An updated URL string
*/
export function appendToURLPath(url: string, name: string): string {
const urlParsed = URLBuilder.parse(url);
let path = urlParsed.getPath();
path = path ? (path.endsWith("/") ? `${path}${name}` : `${path}/${name}`) : name;
urlParsed.setPath(path);
return urlParsed.toString();
}
/**
* Set URL parameter name and value. If name exists in URL parameters, old value
* will be replaced by name key. If not provide value, the parameter will be deleted.
*
* @export
* @param {string} url Source URL string
* @param {string} name Parameter name
* @param {string} [value] Parameter value
* @returns {string} An updated URL string
*/
export function setURLParameter(url: string, name: string, value?: string): string {
const urlParsed = URLBuilder.parse(url);
urlParsed.setQueryParameter(name, value);
return urlParsed.toString();
}
/**
* Get URL parameter by name.
*
* @export
* @param {string} url
* @param {string} name
* @returns {(string | string[] | undefined)}
*/
export function getURLParameter(url: string, name: string): string | string[] | undefined {
const urlParsed = URLBuilder.parse(url);
return urlParsed.getQueryParameterValue(name);
}
/**
* Set URL host.
*
* @export
* @param {string} url Source URL string
* @param {string} host New host string
* @returns An updated URL string
*/
export function setURLHost(url: string, host: string): string {
const urlParsed = URLBuilder.parse(url);
urlParsed.setHost(host);
return urlParsed.toString();
}
/**
* Get URL path from an URL string.
*
* @export
* @param {string} url Source URL string
* @returns {(string | undefined)}
*/
export function getURLPath(url: string): string | undefined {
const urlParsed = URLBuilder.parse(url);
return urlParsed.getPath();
}
/**
* Get URL scheme from an URL string.
*
* @export
* @param {string} url Source URL string
* @returns {(string | undefined)}
*/
export function getURLScheme(url: string): string | undefined {
const urlParsed = URLBuilder.parse(url);
return urlParsed.getScheme();
}
/**
* Get URL path and query from an URL string.
*
* @export
* @param {string} url Source URL string
* @returns {(string | undefined)}
*/
export function getURLPathAndQuery(url: string): string | undefined {
const urlParsed = URLBuilder.parse(url);
const pathString = urlParsed.getPath();
if (!pathString) {
throw new RangeError("Invalid url without valid path.");
}
let queryString = urlParsed.getQuery() || "";
queryString = queryString.trim();
if (queryString != "") {
queryString = queryString.startsWith("?") ? queryString : `?${queryString}`; // Ensure query string start with '?'
}
return `${pathString}${queryString}`;
}
/**
* Get URL query key value pairs from an URL string.
*
* @export
* @param {string} url
* @returns {{[key: string]: string}}
*/
export function getURLQueries(url: string): { [key: string]: string } {
let queryString = URLBuilder.parse(url).getQuery();
if (!queryString) {
return {};
}
queryString = queryString.trim();
queryString = queryString.startsWith("?") ? queryString.substr(1) : queryString;
let querySubStrings: string[] = queryString.split("&");
querySubStrings = querySubStrings.filter((value: string) => {
const indexOfEqual = value.indexOf("=");
const lastIndexOfEqual = value.lastIndexOf("=");
return (
indexOfEqual > 0 && indexOfEqual === lastIndexOfEqual && lastIndexOfEqual < value.length - 1
);
});
const queries: { [key: string]: string } = {};
for (const querySubString of querySubStrings) {
const splitResults = querySubString.split("=");
const key: string = splitResults[0];
const value: string = splitResults[1];
queries[key] = value;
}
return queries;
}
/**
* Rounds a date off to seconds.
*
* @export
* @param {Date} date
* @param {boolean} [withMilliseconds=true] If true, YYYY-MM-DDThh:mm:ss.fffffffZ will be returned;
* If false, YYYY-MM-DDThh:mm:ssZ will be returned.
* @returns {string} Date string in ISO8061 format, with or without 7 milliseconds component
*/
export function truncatedISO8061Date(date: Date, withMilliseconds: boolean = true): string {
// Date.toISOString() will return like "2018-10-29T06:34:36.139Z"
const dateString = date.toISOString();
return withMilliseconds
? dateString.substring(0, dateString.length - 1) + "0000" + "Z"
: dateString.substring(0, dateString.length - 5) + "Z";
}
/**
* Base64 encode.
*
* @export
* @param {string} content
* @returns {string}
*/
export function base64encode(content: string): string {
return !isNode ? btoa(content) : Buffer.from(content).toString("base64");
}
/**
* Base64 decode.
*
* @export
* @param {string} encodedString
* @returns {string}
*/
export function base64decode(encodedString: string): string {
return !isNode ? atob(encodedString) : Buffer.from(encodedString, "base64").toString();
}
/**
* Generate a 64 bytes base64 block ID string.
*
* @export
* @param {number} blockIndex
* @returns {string}
*/
export function generateBlockID(blockIDPrefix: string, blockIndex: number): string {
// To generate a 64 bytes base64 string, source string should be 48
const maxSourceStringLength = 48;
// A blob can have a maximum of 100,000 uncommitted blocks at any given time
const maxBlockIndexLength = 6;
const maxAllowedBlockIDPrefixLength = maxSourceStringLength - maxBlockIndexLength;
if (blockIDPrefix.length > maxAllowedBlockIDPrefixLength) {
blockIDPrefix = blockIDPrefix.slice(0, maxAllowedBlockIDPrefixLength);
}
const res =
blockIDPrefix +
padStart(blockIndex.toString(), maxSourceStringLength - blockIDPrefix.length, "0");
return base64encode(res);
}
/**
* Delay specified time interval.
*
* @export
* @param {number} timeInMs
* @param {AbortSignalLike} [aborter]
* @param {Error} [abortError]
*/
export async function delay(timeInMs: number, aborter?: AbortSignalLike, abortError?: Error) {
return new Promise((resolve, reject) => {
let timeout: any;
const abortHandler = () => {
if (timeout !== undefined) {
clearTimeout(timeout);
}
reject(abortError);
};
const resolveHandler = () => {
if (aborter !== undefined) {
aborter.removeEventListener("abort", abortHandler);
}
resolve();
};
timeout = setTimeout(resolveHandler, timeInMs);
if (aborter !== undefined) {
aborter.addEventListener("abort", abortHandler);
}
});
}
/**
* String.prototype.padStart()
*
* @export
* @param {string} currentString
* @param {number} targetLength
* @param {string} [padString=" "]
* @returns {string}
*/
export function padStart(
currentString: string,
targetLength: number,
padString: string = " "
): string {
if (String.prototype.padStart) {
return currentString.padStart(targetLength, padString);
}
padString = padString || " ";
if (currentString.length > targetLength) {
return currentString;
} else {
targetLength = targetLength - currentString.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length);
}
return padString.slice(0, targetLength) + currentString;
}
}
export function sanitizeURL(url: string): string {
let safeURL: string = url;
if (getURLParameter(safeURL, URLConstants.Parameters.SIGNATURE)) {
safeURL = setURLParameter(safeURL, URLConstants.Parameters.SIGNATURE, "*****");
}
return safeURL;
}
export function sanitizeHeaders(originalHeader: HttpHeaders): HttpHeaders {
const headers: HttpHeaders = new HttpHeaders();
for (const header of originalHeader.headersArray()) {
if (header.name.toLowerCase() === HeaderConstants.AUTHORIZATION.toLowerCase()) {
headers.set(header.name, "*****");
} else if (header.name.toLowerCase() === HeaderConstants.X_MS_COPY_SOURCE) {
headers.set(header.name, sanitizeURL(header.value));
} else {
headers.set(header.name, header.value);
}
}
return headers;
}
/**
* If two strings are equal when compared case insensitive.
*
* @export
* @param {string} str1
* @param {string} str2
* @returns {boolean}
*/
export function iEqual(str1: string, str2: string): boolean {
return str1.toLocaleLowerCase() === str2.toLocaleLowerCase();
}
/**
* Extracts account name from the url
* @param {string} url url to extract the account name from
* @returns {string} with the account name
*/
export function getAccountNameFromUrl(url: string): string {
const parsedUrl: URLBuilder = URLBuilder.parse(url);
let accountName;
try {
if (parsedUrl.getHost()!.split(".")[1] === "blob") {
// `${defaultEndpointsProtocol}://${accountName}.blob.${endpointSuffix}`;
accountName = parsedUrl.getHost()!.split(".")[0];
} else {
// IPv4/IPv6 address hosts... Example - http://192.0.0.10:10001/devstoreaccount1/
// Single word domain without a [dot] in the endpoint... Example - http://localhost:10001/devstoreaccount1/
// .getPath() -> /devstoreaccount1/
accountName = parsedUrl.getPath()!.split("/")[1];
}
if (!accountName) {
throw new Error("Provided accountName is invalid.");
}
return accountName;
} catch (error) {
throw new Error("Unable to extract accountName with provided information.");
}
}