"use strict";
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
Object.defineProperty(exports, "__esModule", { value: true });
const crypto = require("crypto");
const dateFormat = require("date-and-time");
const url = require("url");
const util_1 = require("./util");
/*
 * Default signing version for getSignedUrl is 'v2'.
 */
const DEFAULT_SIGNING_VERSION = 'v2';
const SEVEN_DAYS = 604800;
/**
 * @const {string}
 * @private
 */
exports.PATH_STYLED_HOST = 'https://storage.googleapis.com';
class URLSigner {
    constructor(authClient, bucket, file) {
        this.bucket = bucket;
        this.file = file;
        this.authClient = authClient;
    }
    getSignedUrl(cfg) {
        const expiresInSeconds = this.parseExpires(cfg.expires);
        const method = cfg.method;
        let customHost;
        // Default style is `path`.
        const isVirtualHostedStyle = cfg.virtualHostedStyle || false;
        if (cfg.cname) {
            customHost = cfg.cname;
        }
        else if (isVirtualHostedStyle) {
            customHost = `https://${this.bucket.name}.storage.googleapis.com`;
        }
        const config = Object.assign({}, cfg, {
            method,
            expiration: expiresInSeconds,
            bucket: this.bucket.name,
            file: this.file ? util_1.encodeURI(this.file.name, false) : undefined,
        });
        if (customHost) {
            config.cname = customHost;
        }
        const version = cfg.version || DEFAULT_SIGNING_VERSION;
        let promise;
        if (version === 'v2') {
            promise = this.getSignedUrlV2(config);
        }
        else if (version === 'v4') {
            promise = this.getSignedUrlV4(config);
        }
        else {
            throw new Error(`Invalid signed URL version: ${version}. Supported versions are 'v2' and 'v4'.`);
        }
        return promise.then(query => {
            query = Object.assign(query, cfg.queryParams);
            const signedUrl = new url.URL(config.cname || exports.PATH_STYLED_HOST);
            signedUrl.pathname = this.getResourcePath(!!config.cname, this.bucket.name, config.file);
            // tslint:disable-next-line:no-any
            signedUrl.search = util_1.qsStringify(query);
            return signedUrl.href;
        });
    }
    getSignedUrlV2(config) {
        const canonicalHeadersString = this.getCanonicalHeaders(config.extensionHeaders || {});
        const resourcePath = this.getResourcePath(false, config.bucket, config.file);
        const blobToSign = [
            config.method,
            config.contentMd5 || '',
            config.contentType || '',
            config.expiration,
            canonicalHeadersString + resourcePath,
        ].join('\n');
        const sign = async () => {
            const authClient = this.authClient;
            try {
                const signature = await authClient.sign(blobToSign);
                const credentials = await authClient.getCredentials();
                return {
                    GoogleAccessId: credentials.client_email,
                    Expires: config.expiration,
                    Signature: signature,
                };
            }
            catch (err) {
                const signingErr = new SigningError(err.message);
                signingErr.stack = err.stack;
                throw signingErr;
            }
        };
        return sign();
    }
    getSignedUrlV4(config) {
        const now = new Date();
        const nowInSeconds = Math.floor(now.valueOf() / 1000);
        const expiresPeriodInSeconds = config.expiration - nowInSeconds;
        // v4 limit expiration to be 7 days maximum
        if (expiresPeriodInSeconds > SEVEN_DAYS) {
            throw new Error(`Max allowed expiration is seven days (${SEVEN_DAYS} seconds).`);
        }
        const extensionHeaders = Object.assign({}, config.extensionHeaders);
        const fqdn = new url.URL(config.cname || exports.PATH_STYLED_HOST);
        extensionHeaders.host = fqdn.host;
        if (config.contentMd5) {
            extensionHeaders['content-md5'] = config.contentMd5;
        }
        if (config.contentType) {
            extensionHeaders['content-type'] = config.contentType;
        }
        let contentSha256;
        const sha256Header = extensionHeaders['x-goog-content-sha256'];
        if (sha256Header) {
            if (typeof sha256Header !== 'string' ||
                !/[A-Fa-f0-9]{40}/.test(sha256Header)) {
                throw new Error('The header X-Goog-Content-SHA256 must be a hexadecimal string.');
            }
            contentSha256 = sha256Header;
        }
        const signedHeaders = Object.keys(extensionHeaders)
            .map(header => header.toLowerCase())
            .sort()
            .join(';');
        const extensionHeadersString = this.getCanonicalHeaders(extensionHeaders);
        const datestamp = dateFormat.format(now, 'YYYYMMDD', true);
        const credentialScope = `${datestamp}/auto/storage/goog4_request`;
        const sign = async () => {
            const credentials = await this.authClient.getCredentials();
            const credential = `${credentials.client_email}/${credentialScope}`;
            const dateISO = dateFormat.format(now, 'YYYYMMDD[T]HHmmss[Z]', true);
            const queryParams = Object.assign({ 'X-Goog-Algorithm': 'GOOG4-RSA-SHA256', 'X-Goog-Credential': credential, 'X-Goog-Date': dateISO, 'X-Goog-Expires': expiresPeriodInSeconds.toString(10), 'X-Goog-SignedHeaders': signedHeaders }, (config.queryParams || {}));
            // tslint:disable-next-line:no-any
            const canonicalQueryParams = this.getCanonicalQueryParams(queryParams);
            const canonicalRequest = this.getCanonicalRequest(config.method, this.getResourcePath(!!config.cname, config.bucket, config.file), canonicalQueryParams, extensionHeadersString, signedHeaders, contentSha256);
            const hash = crypto
                .createHash('sha256')
                .update(canonicalRequest)
                .digest('hex');
            const blobToSign = [
                'GOOG4-RSA-SHA256',
                dateISO,
                credentialScope,
                hash,
            ].join('\n');
            try {
                const signature = await this.authClient.sign(blobToSign);
                const signatureHex = Buffer.from(signature, 'base64').toString('hex');
                const signedQuery = Object.assign({}, queryParams, {
                    'X-Goog-Signature': signatureHex,
                });
                return signedQuery;
            }
            catch (err) {
                const signingErr = new SigningError(err.message);
                signingErr.stack = err.stack;
                throw signingErr;
            }
        };
        return sign();
    }
    /**
     * Create canonical headers for signing v4 url.
     *
     * The canonical headers for v4-signing a request demands header names are
     * first lowercased, followed by sorting the header names.
     * Then, construct the canonical headers part of the request:
     *  <lowercasedHeaderName> + ":" + Trim(<value>) + "\n"
     *  ..
     *  <lowercasedHeaderName> + ":" + Trim(<value>) + "\n"
     *
     * @param headers
     * @private
     */
    getCanonicalHeaders(headers) {
        // Sort headers by their lowercased names
        const sortedHeaders = util_1.objectEntries(headers)
            // Convert header names to lowercase
            .map(([headerName, value]) => [
            headerName.toLowerCase(),
            value,
        ])
            .sort((a, b) => a[0].localeCompare(b[0]));
        return sortedHeaders
            .filter(([_, value]) => value !== undefined)
            .map(([headerName, value]) => {
            // - Convert Array (multi-valued header) into string, delimited by
            //      ',' (no space).
            // - Trim leading and trailing spaces.
            // - Convert sequential (2+) spaces into a single space
            const canonicalValue = `${value}`.trim().replace(/\s{2,}/g, ' ');
            return `${headerName}:${canonicalValue}\n`;
        })
            .join('');
    }
    getCanonicalRequest(method, path, query, headers, signedHeaders, contentSha256) {
        return [
            method,
            path,
            query,
            headers,
            signedHeaders,
            contentSha256 || 'UNSIGNED-PAYLOAD',
        ].join('\n');
    }
    getCanonicalQueryParams(query) {
        return util_1.objectEntries(query)
            .map(([key, value]) => [util_1.encodeURI(key, true), util_1.encodeURI(value, true)])
            .sort((a, b) => (a[0] < b[0] ? -1 : 1))
            .map(([key, value]) => `${key}=${value}`)
            .join('&');
    }
    getResourcePath(cname, bucket, file) {
        if (cname) {
            return '/' + (file || '');
        }
        else if (file) {
            return `/${bucket}/${file}`;
        }
        else {
            return `/${bucket}`;
        }
    }
    parseExpires(expires, current = new Date()) {
        const expiresInMSeconds = new Date(expires).valueOf();
        if (isNaN(expiresInMSeconds)) {
            throw new Error('The expiration date provided was invalid.');
        }
        if (expiresInMSeconds < current.valueOf()) {
            throw new Error('An expiration date cannot be in the past.');
        }
        return Math.round(expiresInMSeconds / 1000); // The API expects seconds.
    }
}
exports.URLSigner = URLSigner;
/**
 * Custom error type for errors related to getting signed errors and policies.
 *
 * @private
 */
class SigningError extends Error {
    constructor() {
        super(...arguments);
        this.name = 'SigningError';
    }
}
exports.SigningError = SigningError;
//# sourceMappingURL=signer.js.map