"use strict";
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ADLSAdapter = void 0;
const msal = require("@azure/msal-node");
const crypto = require("crypto");
const url_1 = require("url");
const Network_1 = require("../Utilities/Network");
const StorageUtils_1 = require("../Utilities/StorageUtils");
const NetworkAdapter_1 = require("./NetworkAdapter");
const azureCloudEndpoint_1 = require("../Enums/azureCloudEndpoint");
const StringUtils_1 = require("../Utilities/StringUtils");
const StorageAdapterException_1 = require("./StorageAdapterException");
class ADLSAdapter extends NetworkAdapter_1.NetworkAdapter {
    // The ADLS constructor for clientId/secret authentication.
    constructor(hostname, root, tenantOrSharedKeyorTokenProvider, clientId, secret, endpoint) {
        super();
        /**
         * @internal
         */
        this.type = 'adls';
        this.adlsDefaultTimeout = 8000;
        this.httpMaxResults = 5000;
        // The authorization header key, used during shared key auth.
        this.httpAuthorization = 'Authorization';
        // The MS date header key, used during shared key auth.
        this.httpXmsDate = 'x-ms-date';
        //  The MS version key, used during shared key auth.
        this.httpXmsVersion = 'x-ms-version';
        // The MS continuation header key, used when building request url.
        this.httpXmsContinuation = 'x-ms-continuation';
        this.resource = 'https://storage.azure.com';
        this.scopes = ['https://storage.azure.com/.default'];
        this.formattedHostname = '';
        this.formattedHostnameNoProtocol = '';
        this.rootBlobContainer = '';
        this.unescapedRootSubPath = '';
        this.escapedRootSubPath = '';
        this.fileModifiedTimeCache = new Map();
        if (hostname && root) {
            this.hostname = hostname;
            this.root = root;
            if (tenantOrSharedKeyorTokenProvider) {
                if (typeof tenantOrSharedKeyorTokenProvider === 'string') {
                    if (tenantOrSharedKeyorTokenProvider && !clientId && !secret) {
                        this.sharedKey = tenantOrSharedKeyorTokenProvider;
                    }
                    else if (tenantOrSharedKeyorTokenProvider && clientId && secret) {
                        this._tenant = tenantOrSharedKeyorTokenProvider;
                        this.clientId = clientId;
                        this.secret = secret;
                        this.endpoint = endpoint === undefined ? azureCloudEndpoint_1.azureCloudEndpoint.AzurePublic : endpoint;
                    }
                }
                else {
                    this.tokenProvider = tenantOrSharedKeyorTokenProvider;
                }
            }
        }
        this.timeout = this.adlsDefaultTimeout;
        this.adapterPaths = new Map();
        this.httpClient = new Network_1.CdmHttpClient();
    }
    get root() {
        return this._root;
    }
    set root(val) {
        this._root = this.extractRootBlobContainerAndSubPath(val);
    }
    get tenant() {
        return this._tenant;
    }
    get hostname() {
        return this._hostname;
    }
    set hostname(val) {
        if (StringUtils_1.StringUtils.isNullOrWhiteSpace(val)) {
            throw new URIError('Hostname cannot be null or whitespace.');
        }
        this._hostname = val;
        this.formattedHostname = this.formatHostname(this._hostname);
        this.formattedHostnameNoProtocol = this.formatHostname(this.removeProtocolFromHostname(this._hostname));
    }
    get sasToken() {
        return this._sasToken;
    }
    /**
     * The SAS token. If supplied string begins with '?' symbol, the symbol gets stripped away.
     */
    set sasToken(val) {
        // Remove the leading question mark, so we can append this token to URLs that already have it
        this._sasToken = val != null ?
            (val.startsWith('?') ? val.substr(1) : val)
            : null;
    }
    canRead() {
        return true;
    }
    readAsync(corpusPath) {
        const _super = Object.create(null, {
            executeRequest: { get: () => super.executeRequest }
        });
        return __awaiter(this, void 0, void 0, function* () {
            const url = this.createFormattedAdapterPath(corpusPath);
            const cdmHttpRequest = yield this.buildRequest(url, 'GET');
            const cdmHttpResponse = yield _super.executeRequest.call(this, cdmHttpRequest);
            return cdmHttpResponse.content;
        });
    }
    writeAsync(corpusPath, data) {
        const _super = Object.create(null, {
            executeRequest: { get: () => super.executeRequest }
        });
        return __awaiter(this, void 0, void 0, function* () {
            if (!this.ensurePath(`${this.root}${corpusPath}`)) {
                throw new Error(`Could not create folder for document ${corpusPath}`);
            }
            const url = this.createFormattedAdapterPath(corpusPath);
            let response = yield this.createFileAtPath(corpusPath, url);
            try {
                let request = yield this.buildRequest(`${url}?action=append&position=0`, 'PATCH', data, "application/json; charset=utf-8");
                response = yield _super.executeRequest.call(this, request);
                if (response.statusCode === 202) { // The uploaded data was accepted.
                    // Building a request and setting a URL with a position argument to be the length of the byte array
                    // of the string content (or length of UTF-8 string content).
                    request = yield this.buildRequest(`${url}?action=flush&position=${Buffer.from(request.content).length}`, 'PATCH');
                    response = yield _super.executeRequest.call(this, request);
                    if (response.statusCode !== 200) { // Data was not flushed correctly. Delete empty file.
                        yield this.deleteContentAtPath(corpusPath, url, undefined);
                        throw new StorageAdapterException_1.StorageAdapterException(`Could not write ADLS content at path, there was an issue at "${corpusPath}" during the flush action. Reason: ${response.reason}.`);
                    }
                }
                else {
                    yield this.deleteContentAtPath(corpusPath, url, undefined);
                    throw new StorageAdapterException_1.StorageAdapterException(`Could not write ADLS content at path, there was an issue at "${corpusPath}" during the append action. Reason: ${response.reason}.`);
                }
            }
            catch (e) {
                if (e instanceof StorageAdapterException_1.StorageAdapterException) {
                    throw e;
                }
                else {
                    yield this.deleteContentAtPath(corpusPath, url, e);
                    throw new StorageAdapterException_1.StorageAdapterException("Could not write ADLS content at path, there was an issue at: " + corpusPath + e);
                }
            }
        });
    }
    canWrite() {
        return true;
    }
    createAdapterPath(corpusPath) {
        if (corpusPath === undefined || corpusPath === null) {
            return undefined;
        }
        const formattedCorpusPath = this.formatCorpusPath(corpusPath);
        if (formattedCorpusPath === undefined || formattedCorpusPath === null) {
            return undefined;
        }
        if (this.adapterPaths.has(formattedCorpusPath)) {
            return this.adapterPaths.get(formattedCorpusPath);
        }
        else {
            return `https://${this.removeProtocolFromHostname(this.hostname)}${this.getEscapedRoot()}${this.escapePath(formattedCorpusPath)}`;
        }
    }
    createCorpusPath(adapterPath) {
        if (adapterPath) {
            const startIndex = 'https://'.length;
            const endIndex = adapterPath.indexOf('/', startIndex + 1);
            if (endIndex < startIndex) {
                throw new Error(`Unexpected adapter path: ${adapterPath}`);
            }
            const hostname = this.formatHostname(adapterPath.substring(startIndex, endIndex));
            if (hostname === this.formattedHostnameNoProtocol
                && adapterPath.substring(endIndex)
                    .startsWith(this.getEscapedRoot())) {
                const escapedCorpusPath = adapterPath.substring(endIndex + this.getEscapedRoot().length);
                const corpusPath = decodeURIComponent(escapedCorpusPath);
                if (!this.adapterPaths.has(corpusPath)) {
                    this.adapterPaths.set(corpusPath, adapterPath);
                }
                return corpusPath;
            }
        }
        return undefined;
    }
    computeLastModifiedTimeAsync(corpusPath) {
        const _super = Object.create(null, {
            executeRequest: { get: () => super.executeRequest }
        });
        return __awaiter(this, void 0, void 0, function* () {
            const cachedValue = this.isCacheEnabled() ? this.fileModifiedTimeCache.get(corpusPath) : undefined;
            if (cachedValue) {
                return cachedValue;
            }
            else {
                const url = this.createFormattedAdapterPath(corpusPath);
                const request = yield this.buildRequest(url, 'HEAD');
                const cdmResponse = yield _super.executeRequest.call(this, request);
                if (cdmResponse.statusCode === 200) {
                    // http nodejs lib returns lowercase headers.
                    // tslint:disable-next-line: no-backbone-get-set-outside-model
                    const lastTimeString = cdmResponse.responseHeaders.get('last-modified');
                    if (lastTimeString) {
                        const lastTime = new Date(lastTimeString);
                        if (this.isCacheEnabled()) {
                            this.fileModifiedTimeCache.set(corpusPath, lastTime);
                        }
                        return lastTime;
                    }
                }
            }
        });
    }
    fetchAllFilesAsync(folderCorpusPath) {
        return __awaiter(this, void 0, void 0, function* () {
            const fileMetadatas = yield this.fetchAllFilesMetadataAsync(folderCorpusPath);
            return Array.from(fileMetadatas.keys());
        });
    }
    fetchAllFilesMetadataAsync(folderCorpusPath) {
        const _super = Object.create(null, {
            executeRequest: { get: () => super.executeRequest }
        });
        return __awaiter(this, void 0, void 0, function* () {
            if (folderCorpusPath === undefined || folderCorpusPath === null) {
                return undefined;
            }
            const url = `https://${this.formattedHostnameNoProtocol}/${this.rootBlobContainer}`;
            const escapedFolderCorpusPath = this.escapePath(folderCorpusPath);
            let directory = `${this.escapedRootSubPath}${this.formatCorpusPath(escapedFolderCorpusPath)}`;
            if (directory.startsWith('/')) {
                directory = directory.substring(1);
            }
            let continuationToken = null;
            const result = new Map();
            do {
                let request;
                if (continuationToken == null) {
                    request = yield this.buildRequest(`${url}?directory=${directory}&maxResults=${this.httpMaxResults}&recursive=True&resource=filesystem`, 'GET');
                }
                else {
                    request = yield this.buildRequest(`${url}?continuation=${encodeURIComponent(continuationToken)}&directory=${directory}&maxResults=${this.httpMaxResults}&recursive=True&resource=filesystem`, 'GET');
                }
                const cdmResponse = yield _super.executeRequest.call(this, request);
                if (cdmResponse.statusCode === 200) {
                    continuationToken = cdmResponse.responseHeaders.has(this.httpXmsContinuation) ? cdmResponse.responseHeaders.get(this.httpXmsContinuation) : null;
                    const json = cdmResponse.content;
                    const jObject1 = JSON.parse(json);
                    const jArray = jObject1.paths;
                    for (const jObject of jArray) {
                        const isDirectory = jObject.isDirectory;
                        if (isDirectory === undefined || !isDirectory) {
                            const name = jObject.name;
                            const nameWithoutSubPath = this.unescapedRootSubPath.length > 0 && name.startsWith(this.unescapedRootSubPath) ?
                                name.substring(this.unescapedRootSubPath.length + 1) : name;
                            const path = this.formatCorpusPath(nameWithoutSubPath);
                            result.set(path, { fileSizeBytes: Number(jObject.contentLength) });
                            if (jObject.lastModified && this.isCacheEnabled()) {
                                this.fileModifiedTimeCache.set(path, new Date(jObject.lastModified));
                            }
                        }
                    }
                }
            } while (continuationToken != null);
            return result;
        });
    }
    clearCache() {
        this.fileModifiedTimeCache.clear();
    }
    fetchConfig() {
        const resultConfig = {
            type: this.type
        };
        const configObject = {
            hostname: this.hostname,
            root: this.root
        };
        // Check for clientId auth, we won't write shared key or secrets to JSON.
        if (this.clientId && this.tenant) {
            configObject.tenant = this.tenant;
            configObject.clientId = this.clientId;
        }
        // Try constructing network configs.
        const networkConfigArray = this.fetchNetworkConfig();
        for (const key of Object.keys(networkConfigArray)) {
            configObject[key] = networkConfigArray[key];
        }
        if (this.locationHint) {
            configObject.locationHint = this.locationHint;
        }
        if (this.endpoint !== undefined) {
            configObject.endpoint = azureCloudEndpoint_1.azureCloudEndpoint[this.endpoint];
        }
        resultConfig.config = configObject;
        return JSON.stringify(resultConfig);
    }
    updateConfig(config) {
        if (!config) {
            throw new TypeError('ADLS adapter needs a config.');
        }
        const configJson = JSON.parse(config);
        if (configJson.root) {
            this.root = configJson.root;
        }
        else {
            throw new TypeError('Root has to be set for ADLS adapter.');
        }
        if (configJson.hostname) {
            this.hostname = configJson.hostname;
        }
        else {
            throw new TypeError('Hostname has to be set for ADLS adapter.');
        }
        this.updateNetworkConfig(config);
        if (configJson.tenant && configJson.clientId) {
            this._tenant = configJson.tenant;
            this.clientId = configJson.clientId;
            // To keep backwards compatibility with config files that were generated before the introduction of the `endpoint` property.
            if (!this.endpoint) {
                this.endpoint = azureCloudEndpoint_1.azureCloudEndpoint.AzurePublic;
            }
        }
        if (configJson.locationHint) {
            this.locationHint = configJson.locationHint;
        }
        if (configJson.endpoint) {
            const endpointStr = configJson.endpoint;
            if (Object.values(azureCloudEndpoint_1.azureCloudEndpoint).includes(endpointStr)) {
                this.endpoint = azureCloudEndpoint_1.azureCloudEndpoint[endpointStr];
            }
            else {
                throw new TypeError('Endpoint value should be a string of an enumeration value from the class AzureCloudEndpoint in Pascal case.');
            }
        }
    }
    applySharedKey(sharedKey, url, method, content, contentType) {
        const headers = new Map();
        // Add UTC now time and new version.
        headers.set(this.httpXmsDate, new Date().toUTCString());
        headers.set(this.httpXmsVersion, '2018-06-17');
        let contentLength = 0;
        const uri = new url_1.URL(url);
        if (content) {
            contentLength = Buffer.from(content).length;
        }
        let builder = '';
        builder += `${method}\n`; // verb;
        builder += '\n'; // Content-Encoding
        builder += ('\n'); // Content-Language.
        builder += (contentLength !== 0) ? `${contentLength}\n` : '\n'; // Content length.
        builder += '\n'; // Content-md5.
        builder += contentType ? `${contentType}\n` : '\n'; // Content-type.
        builder += '\n'; // Date.
        builder += '\n'; // If-modified-since.
        builder += '\n'; // If-match.
        builder += '\n'; // If-none-match.
        builder += '\n'; // If-unmodified-since.
        builder += '\n'; // Range.
        for (const header of headers) {
            builder += `${header[0]}:${header[1]}\n`;
        }
        // Append canonicalized resource.
        const accountName = uri.host.split('.')[0];
        builder += '/';
        builder += accountName;
        builder += uri.pathname;
        // Append canonicalized queries.
        if (uri.search) {
            const queryParameters = (uri.search.startsWith('?') ? uri.search.substr(1) : uri.search).split('&');
            for (const parameter of queryParameters) {
                const keyValuePair = parameter.split('=');
                builder += `\n${keyValuePair[0].toLowerCase()}:${decodeURIComponent(keyValuePair[1])}`;
            }
        }
        // hash the payload
        const dataToHash = builder.trimRight();
        const bytes = Buffer.from(sharedKey, 'base64');
        const hmac = crypto.createHmac('sha256', bytes);
        const signedString = `SharedKey ${accountName}:${hmac.update(dataToHash)
            .digest('base64')}`;
        headers.set(this.httpAuthorization, signedString);
        return headers;
    }
    /**
     * Appends SAS token to the given URL.
     * @param url URL to be appended with the SAS token
     * @returns URL with the SAS token appended
     */
    applySasToken(url) {
        return `${url}${url.includes('?') ? '&' : '?'}${this.sasToken}`;
    }
    buildRequest(url, method, content, contentType) {
        return __awaiter(this, void 0, void 0, function* () {
            let request;
            // Check whether we support shared key or clientId/secret auth
            if (this.sharedKey) {
                request = this.setUpCdmRequest(url, this.applySharedKey(this.sharedKey, url, method, content, contentType), method);
            }
            else if (this.sasToken) {
                request = this.setUpCdmRequest(this.applySasToken(url), null, method);
            }
            else if (this.tenant && this.clientId && this.secret) {
                const token = yield this.generateBearerToken();
                request = this.setUpCdmRequest(url, new Map([['authorization', `${token.tokenType} ${token.accessToken}`]]), method);
            }
            else if (this.tokenProvider) {
                request = this.setUpCdmRequest(url, new Map([['authorization', `${this.tokenProvider.getToken()}`]]), method);
            }
            else {
                throw new Error('Adls adapter is not configured with any auth method');
            }
            if (content) {
                request.content = content;
                request.contentType = contentType;
            }
            return request;
        });
    }
    createFormattedAdapterPath(corpusPath) {
        const adapterPath = this.createAdapterPath(corpusPath);
        return adapterPath ? adapterPath.replace(this.hostname, this.formattedHostname) : undefined;
    }
    createFileAtPath(corpusPath, url) {
        const _super = Object.create(null, {
            executeRequest: { get: () => super.executeRequest }
        });
        return __awaiter(this, void 0, void 0, function* () {
            let request;
            let response;
            try {
                request = yield this.buildRequest(`${url}?resource=file`, 'PUT');
                response = yield _super.executeRequest.call(this, request);
            }
            catch (e) {
                throw new StorageAdapterException_1.StorageAdapterException("Could not write ADLS content at path, there was an issue at: " + corpusPath + e);
            }
            if (response.statusCode !== 201) { // Empty file was not created successfully.
                throw new StorageAdapterException_1.StorageAdapterException(`Could not write ADLS content at path, response code: ${response.statusCode}. Reason: ${response.reason}.`);
            }
            return response;
        });
    }
    deleteContentAtPath(corpusPath, url, innerException) {
        const _super = Object.create(null, {
            executeRequest: { get: () => super.executeRequest }
        });
        return __awaiter(this, void 0, void 0, function* () {
            if (this.ctx == null || this.ctx.featureFlags == null || !this.ctx.featureFlags.has("ADLSAdapter_deleteEmptyFile") || this.ctx.featureFlags.get("ADLSAdapter_deleteEmptyFile") === true) {
                try {
                    yield _super.executeRequest.call(this, yield this.buildRequest(url, 'DELETE'));
                    return; // Return on delete success. Throw exception even if delete succeeds since file write operation failed.
                }
                catch (e) { }
            }
            throw new StorageAdapterException_1.StorageAdapterException("Empty file was created but could not write ADLS content at path: " + corpusPath + innerException);
        });
    }
    ensurePath(pathFor) {
        if (pathFor.lastIndexOf('/') === -1) {
            return false;
        }
        // Folders are only of virtual kind in Azure Storage
        return true;
    }
    escapePath(unescapedPath) {
        return encodeURIComponent(unescapedPath)
            .replace(/%2F/g, '/');
    }
    extractRootBlobContainerAndSubPath(root) {
        // No root value was set
        if (!root) {
            this.rootBlobContainer = '';
            this.updateRootSubPath('');
            return '';
        }
        // Remove leading and trailing /
        let prepRoot = root.startsWith('/') ? root.substring(1) : root;
        prepRoot = prepRoot.endsWith('/') ? prepRoot.substring(0, prepRoot.length - 1) : prepRoot;
        // Root contains only the file-system name, e.g. "fs-name"
        if (prepRoot.indexOf('/') === -1) {
            this.rootBlobContainer = prepRoot;
            this.updateRootSubPath('');
            return `/${this.rootBlobContainer}`;
        }
        // Root contains file-system name and folder, e.g. "fs-name/folder/folder..."
        const prepRootArray = prepRoot.split('/');
        this.rootBlobContainer = prepRootArray[0];
        this.updateRootSubPath(prepRootArray.slice(1)
            .join('/'));
        return `/${this.rootBlobContainer}/${this.unescapedRootSubPath}`;
    }
    formatCorpusPath(corpusPath) {
        const pathTuple = StorageUtils_1.StorageUtils.splitNamespacePath(corpusPath);
        if (!pathTuple) {
            return undefined;
        }
        corpusPath = pathTuple[1];
        if (corpusPath.length > 0 && !corpusPath.startsWith('/')) {
            corpusPath = `/${corpusPath}`;
        }
        return corpusPath;
    }
    formatHostname(hostname) {
        hostname = hostname.replace('.blob.', '.dfs.');
        const port = ':443';
        if (hostname.includes(port)) {
            hostname = hostname.substr(0, hostname.length - port.length);
        }
        return hostname;
    }
    generateBearerToken() {
        return __awaiter(this, void 0, void 0, function* () {
            this.buildContext();
            return new Promise((resolve, reject) => {
                const clientCredentialRequest = {
                    scopes: this.scopes,
                };
                this.context.acquireTokenByClientCredential(clientCredentialRequest).then((response) => {
                    if (response.accessToken && response.accessToken.length !== 0 && response.tokenType) {
                        resolve(response);
                    }
                    reject(Error('Received invalid ADLS Adapter\'s authentication result. The result might be null, or missing access token or/and token type from the authentication result.'));
                }).catch((error) => {
                    reject(Error('There was an error while acquiring ADLS Adapter\'s Token with client ID/secret authentication. Exception:' + JSON.stringify(error)));
                });
            });
        });
    }
    getEscapedRoot() {
        return this.escapedRootSubPath ?
            `/${this.rootBlobContainer}/${this.escapedRootSubPath}`
            : `/${this.rootBlobContainer}`;
    }
    updateRootSubPath(value) {
        this.unescapedRootSubPath = value;
        this.escapedRootSubPath = this.escapePath(this.unescapedRootSubPath);
    }
    // Build context when users make the first call. Also need to ensure client Id, tenant and secret are not null.
    buildContext() {
        if (this.context === undefined) {
            const clientConfig = {
                auth: {
                    clientId: this.clientId,
                    authority: `${azureCloudEndpoint_1.AzureCloudEndpointConvertor.azureCloudEndpointToURL(this.endpoint)}${this.tenant}`,
                    clientSecret: this.secret
                }
            };
            this.context = new msal.ConfidentialClientApplication(clientConfig);
        }
    }
    /**
     * Check if the hostname has a leading protocol.
     * if it doesn't have, return the hostname
     * if the leading protocol is not "https://", throw an error
     * otherwise, return the hostname with no leading protocol.
     * @param {string} hostname The hostname.
     * @return The hostname without the leading protocol "https://" if original hostname has it, otherwise it is same as hostname.
     */
    removeProtocolFromHostname(hostname) {
        if (hostname.indexOf('://') == -1) {
            return hostname;
        }
        try {
            const url = new url_1.URL(hostname);
            if (url.protocol === 'https:') {
                return hostname.substring('https://'.length);
            }
        }
        catch (error) {
            throw new URIError('Please provide a valid hostname.');
        }
        throw new URIError('ADLS Adapter only supports HTTPS, please provide a leading \"https://\" hostname or a non-protocol-relative hostname.');
    }
}
exports.ADLSAdapter = ADLSAdapter;
//# sourceMappingURL=ADLSAdapter.js.map