diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c5cbc5540..92394b1e4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: matrix: node: [8, 10, 12, 13] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node }} @@ -30,7 +30,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 12 @@ -39,7 +39,7 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 12 @@ -48,7 +48,7 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 13 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9e26e2c..38c44cfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ [1]: https://www.npmjs.com/package/nodejs-storage?activeTab=versions +## [4.7.0](https://www.github.com/googleapis/nodejs-storage/compare/v4.6.0...v4.7.0) (2020-03-26) + + +### Features + +* add remove conditional binding ([#1107](https://www.github.com/googleapis/nodejs-storage/issues/1107)) ([2143705](https://www.github.com/googleapis/nodejs-storage/commit/21437053e9497aa95ef37a865ffbdbaf4134138f)) +* v4 POST with signed policy ([#1102](https://www.github.com/googleapis/nodejs-storage/issues/1102)) ([a3d5b88](https://www.github.com/googleapis/nodejs-storage/commit/a3d5b88b8d3d25b6e16808eb5c1425aa0a8c5ecc)), closes [#1125](https://www.github.com/googleapis/nodejs-storage/issues/1125) + + +### Bug Fixes + +* **deps:** update dependency date-and-time to ^0.13.0 ([#1106](https://www.github.com/googleapis/nodejs-storage/issues/1106)) ([b759605](https://www.github.com/googleapis/nodejs-storage/commit/b7596058e130ee2d82dc2221f24220b83c04fdae)) +* **deps:** update dependency gaxios to v3 ([#1129](https://www.github.com/googleapis/nodejs-storage/issues/1129)) ([5561452](https://www.github.com/googleapis/nodejs-storage/commit/5561452cb0b6e5a1dcabea6973db57799422abb7)) +* **types:** wrap GetSignedUrlResponse ([#1119](https://www.github.com/googleapis/nodejs-storage/issues/1119)) ([0c7ac16](https://www.github.com/googleapis/nodejs-storage/commit/0c7ac161f808201562f60710b9ec7bce4fbf819f)) + ## [4.6.0](https://www.github.com/googleapis/nodejs-storage/compare/v4.5.0...v4.6.0) (2020-03-13) diff --git a/README.md b/README.md index 3d3aec231..4ac36ed62 100644 --- a/README.md +++ b/README.md @@ -61,26 +61,26 @@ npm install @google-cloud/storage ### Using the client library ```javascript - // Imports the Google Cloud client library - const {Storage} = require('@google-cloud/storage'); +// Imports the Google Cloud client library +const {Storage} = require('@google-cloud/storage'); - // Creates a client - const storage = new Storage(); - // Creates a client from a Google service account key. - // const storage = new Storage({keyFilename: "key.json"}); +// Creates a client +const storage = new Storage(); +// Creates a client from a Google service account key. +// const storage = new Storage({keyFilename: "key.json"}); - /** - * TODO(developer): Uncomment these variables before running the sample. - */ - // const bucketName = 'bucket-name'; +/** + * TODO(developer): Uncomment these variables before running the sample. + */ +// const bucketName = 'bucket-name'; - async function createBucket() { - // Creates the new bucket - await storage.createBucket(bucketName); - console.log(`Bucket ${bucketName} created.`); - } +async function createBucket() { + // Creates the new bucket + await storage.createBucket(bucketName); + console.log(`Bucket ${bucketName} created.`); +} - createBucket().catch(console.error); +createBucket().catch(console.error); ``` @@ -147,6 +147,7 @@ has instructions for running the samples. | Quickstart | [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/quickstart.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/quickstart.js,samples/README.md) | | Release Event Based Hold | [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/releaseEventBasedHold.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/releaseEventBasedHold.js,samples/README.md) | | Release Temporary Hold | [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/releaseTemporaryHold.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/releaseTemporaryHold.js,samples/README.md) | +| Remove Bucket Conditional Binding | [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/removeBucketConditionalBinding.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/removeBucketConditionalBinding.js,samples/README.md) | | Remove Bucket Default Owner | [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/removeBucketDefaultOwner.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/removeBucketDefaultOwner.js,samples/README.md) | | Remove Bucket Iam Member | [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/removeBucketIamMember.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/removeBucketIamMember.js,samples/README.md) | | Remove Bucket Owner Acl | [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/removeBucketOwnerAcl.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/removeBucketOwnerAcl.js,samples/README.md) | @@ -167,6 +168,27 @@ has instructions for running the samples. The [Google Cloud Storage Node.js Client API Reference][client-docs] documentation also contains samples. +## Supported Node.js Versions + +Our client libraries follow the [Node.js release schedule](https://nodejs.org/en/about/releases/). +Libraries are compatible with all current _active_ and _maintenance_ versions of +Node.js. + +Client libraries targetting some end-of-life versions of Node.js are available, and +can be installed via npm [dist-tags](https://docs.npmjs.com/cli/dist-tag). +The dist-tags follow the naming convention `legacy-(version)`. + +_Legacy Node.js versions are supported as a best effort:_ + +* Legacy versions will not be tested in continuous integration. +* Some security patches may not be able to be backported. +* Dependencies will not be kept up-to-date, and features will not be backported. + +#### Legacy tags available + +* `legacy-8`: install client libraries from this dist-tag for versions + compatible with Node.js 8. + ## Versioning This library follows [Semantic Versioning](http://semver.org/). @@ -190,6 +212,12 @@ More Information: [Google Cloud Platform Launch Stages][launch_stages] Contributions welcome! See the [Contributing Guide](https://github.com/googleapis/nodejs-storage/blob/master/CONTRIBUTING.md). +Please note that this `README.md`, the `samples/README.md`, +and a variety of configuration files in this repository (including `.nycrc` and `tsconfig.json`) +are generated from a central template. To edit one of these files, make an edit +to its template in this +[directory](https://github.com/googleapis/synthtool/tree/master/synthtool/gcp/templates/node_library). + ## License Apache Version 2.0 diff --git a/bin/benchwrapper.js b/bin/benchwrapper.js index 391d46d90..fa3a06664 100755 --- a/bin/benchwrapper.js +++ b/bin/benchwrapper.js @@ -46,7 +46,7 @@ function read(call, callback) { .bucket(bucketName) .file(objectName) .download({validation: false}) - .then(() => callback(null, null)) + .then(() => callback(null, null)); } function write(call, callback) { diff --git a/conformance-test/test-data/v4SignedUrl.json b/conformance-test/test-data/v4SignedUrl.json index 236986575..aec13259a 100644 --- a/conformance-test/test-data/v4SignedUrl.json +++ b/conformance-test/test-data/v4SignedUrl.json @@ -494,6 +494,34 @@ }, "expectedDecodedPolicy": "{\"conditions\":[{\"success_action_redirect\":\"http://www.google.com/\"},{\"key\":\"test-object\"},{\"x-goog-date\":\"20200123T043530Z\"},{\"x-goog-credential\":\"test-iam-credentials@dummy-project-id.iam.gserviceaccount.com/20200123/auto/storage/goog4_request\"},{\"x-goog-algorithm\":\"GOOG4-RSA-SHA256\"}],\"expiration\":\"2020-01-23T04:35:40Z\"}" } + }, + { + "description": "POST Policy Character Escaping", + "policyInput": { + "scheme": "https", + "bucket": "rsaposttest-1579902671-6ldm6caw4se52vrx", + "object": "$test-object-é", + "expiration": 10, + "timestamp": "2020-01-23T04:35:30Z", + "fields": { + "success_action_redirect": "http://www.google.com/", + "x-goog-meta": "$test-object-é-metadata" + } + }, + "policyOutput": { + "url": "https://storage.googleapis.com/rsaposttest-1579902671-6ldm6caw4se52vrx/", + "fields" : { + "key": "$test-object-é", + "success_action_redirect": "http://www.google.com/", + "x-goog-meta": "$test-object-é-metadata", + "x-goog-algorithm": "GOOG4-RSA-SHA256", + "x-goog-credential": "test-iam-credentials@dummy-project-id.iam.gserviceaccount.com/20200123/auto/storage/goog4_request", + "x-goog-date": "20200123T043530Z", + "x-goog-signature": "05eb19ea4ece513cbb2bc6a92c9bc82de6be46943fb4703df3f7b26e6033f90a194e2444e6c3166e9585ca468b5727702aa2696e5cca54677c047f7734119ea0d635404d6a5e577b737ffd5414059cd1b508aa99cfad592d9228f1bf47d7df3ffd73bcae6af6d8d83f7f50b4ccbf6e6c0798d2d9923a7e18c8888e2519fcf09d174b7015581a7de021964eeb9d27293213686d80d825332819c4e98d4ab2c5237f352840993e22a02a41d827ce6a4a294e84a33bf051519fdcbf982f2ad932f58714608c4b5a1f89d5e322d194f5e29fa4160fce771008320ac4e659adeead36aa07fe26a96e52e809436b7bd169256d6613c135148fdee6926caaef65817dc2", + "policy": "eyJjb25kaXRpb25zIjpbeyJzdWNjZXNzX2FjdGlvbl9yZWRpcmVjdCI6Imh0dHA6Ly93d3cuZ29vZ2xlLmNvbS8ifSx7IngtZ29vZy1tZXRhIjoiJHRlc3Qtb2JqZWN0LVx1MDBlOS1tZXRhZGF0YSJ9LHsia2V5IjoiJHRlc3Qtb2JqZWN0LVx1MDBlOSJ9LHsieC1nb29nLWRhdGUiOiIyMDIwMDEyM1QwNDM1MzBaIn0seyJ4LWdvb2ctY3JlZGVudGlhbCI6InRlc3QtaWFtLWNyZWRlbnRpYWxzQGR1bW15LXByb2plY3QtaWQuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20vMjAyMDAxMjMvYXV0by9zdG9yYWdlL2dvb2c0X3JlcXVlc3QifSx7IngtZ29vZy1hbGdvcml0aG0iOiJHT09HNC1SU0EtU0hBMjU2In1dLCJleHBpcmF0aW9uIjoiMjAyMC0wMS0yM1QwNDozNTo0MFoifQ==" + }, + "expectedDecodedPolicy": "{\"conditions\":[{\"success_action_redirect\":\"http://www.google.com/\"},{\"x-goog-meta\":\"$test-object-\u00e9-metadata\"},{\"key\":\"$test-object-\u00e9\"},{\"x-goog-date\":\"20200123T043530Z\"},{\"x-goog-credential\":\"test-iam-credentials@dummy-project-id.iam.gserviceaccount.com/20200123/auto/storage/goog4_request\"},{\"x-goog-algorithm\":\"GOOG4-RSA-SHA256\"}],\"expiration\":\"2020-01-23T04:35:40Z\"}" + } } ] -} \ No newline at end of file +} diff --git a/conformance-test/v4SignedUrl.ts b/conformance-test/v4SignedUrl.ts index bc08e4086..55453609c 100644 --- a/conformance-test/v4SignedUrl.ts +++ b/conformance-test/v4SignedUrl.ts @@ -21,7 +21,11 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as querystring from 'querystring'; -import {Storage, GetSignedUrlConfig} from '../src/'; +import { + Storage, + GetSignedUrlConfig, + GenerateSignedPostPolicyV4Options, +} from '../src/'; import * as url from 'url'; export enum UrlStyle { @@ -57,6 +61,8 @@ interface PolicyInput { object: string; expiration: number; timestamp: string; + urlStyle?: UrlStyle; + bucketBoundHostname?: string; conditions?: Conditions; fields?: {[key: string]: string}; } @@ -70,6 +76,7 @@ interface Conditions { interface PolicyOutput { url: string; fields: {[key: string]: string}; + expectedDecodedPolicy: string; } interface FileAction { @@ -86,13 +93,10 @@ const testFile = fs.readFileSync( ); // tslint:disable-next-line no-any -const testCases: any[] = JSON.parse(testFile).signingV4Tests; -const v4SignedUrlCases: V4SignedURLTestCase[] = testCases.filter( - testCase => testCase.expectedUrl -); -const v4SignedPolicyCases: V4SignedPolicyTestCase[] = testCases.filter( - testCase => testCase.policyInput -); +const testCases = JSON.parse(testFile); +const v4SignedUrlCases: V4SignedURLTestCase[] = testCases.signingV4Tests; +const v4SignedPolicyCases: V4SignedPolicyTestCase[] = + testCases.postPolicyV4Tests; const SERVICE_ACCOUNT = path.join( __dirname, @@ -101,112 +105,144 @@ const SERVICE_ACCOUNT = path.join( const storage = new Storage({keyFilename: SERVICE_ACCOUNT}); -describe('v4 signed url', () => { - v4SignedUrlCases.forEach(testCase => { - it(testCase.description, async () => { - const NOW = new Date(testCase.timestamp); - - const fakeTimer = sinon.useFakeTimers(NOW); - const bucket = storage.bucket(testCase.bucket); - const expires = NOW.valueOf() + testCase.expiration * 1000; - const version = 'v4' as 'v4'; - const domain = testCase.bucketBoundHostname - ? `${testCase.scheme}://${testCase.bucketBoundHostname}` - : undefined; - const {cname, virtualHostedStyle} = parseUrlStyle( - testCase.urlStyle, - domain - ); - const extensionHeaders = testCase.headers; - const queryParams = testCase.queryParameters; - const baseConfig = { - extensionHeaders, - version, - expires, - cname, - virtualHostedStyle, - queryParams, - }; - let signedUrl: string; - - if (testCase.object) { - const file = bucket.file(testCase.object); - - const action = ({ - GET: 'read', - POST: 'resumable', - PUT: 'write', - DELETE: 'delete', - } as FileAction)[testCase.method]; - - [signedUrl] = await file.getSignedUrl({ - action, - ...baseConfig, - } as GetSignedUrlConfig); - } else { - // bucket operation - const action = ({ - GET: 'list', - } as BucketAction)[testCase.method]; - - [signedUrl] = await bucket.getSignedUrl({ - action, - ...baseConfig, - }); - } - - const expected = new url.URL(testCase.expectedUrl); - const actual = new url.URL(signedUrl); - - assert.strictEqual(actual.origin, expected.origin); - assert.strictEqual(actual.pathname, expected.pathname); - // Order-insensitive comparison of query params - assert.deepStrictEqual( - querystring.parse(actual.search), - querystring.parse(expected.search) - ); - - fakeTimer.restore(); +describe('v4 conformance test', () => { + describe('v4 signed url', () => { + v4SignedUrlCases.forEach(testCase => { + it(testCase.description, async () => { + const NOW = new Date(testCase.timestamp); + + const fakeTimer = sinon.useFakeTimers(NOW); + const bucket = storage.bucket(testCase.bucket); + const expires = NOW.valueOf() + testCase.expiration * 1000; + const version = 'v4' as 'v4'; + const origin = testCase.bucketBoundHostname + ? `${testCase.scheme}://${testCase.bucketBoundHostname}` + : undefined; + const {bucketBoundHostname, virtualHostedStyle} = parseUrlStyle( + testCase.urlStyle, + origin + ); + const extensionHeaders = testCase.headers; + const queryParams = testCase.queryParameters; + const baseConfig = { + extensionHeaders, + version, + expires, + cname: bucketBoundHostname, + virtualHostedStyle, + queryParams, + }; + let signedUrl: string; + + if (testCase.object) { + const file = bucket.file(testCase.object); + + const action = ({ + GET: 'read', + POST: 'resumable', + PUT: 'write', + DELETE: 'delete', + } as FileAction)[testCase.method]; + + [signedUrl] = await file.getSignedUrl({ + action, + ...baseConfig, + } as GetSignedUrlConfig); + } else { + // bucket operation + const action = ({ + GET: 'list', + } as BucketAction)[testCase.method]; + + [signedUrl] = await bucket.getSignedUrl({ + action, + ...baseConfig, + }); + } + + const expected = new url.URL(testCase.expectedUrl); + const actual = new url.URL(signedUrl); + + assert.strictEqual(actual.origin, expected.origin); + assert.strictEqual(actual.pathname, expected.pathname); + // Order-insensitive comparison of query params + assert.deepStrictEqual( + querystring.parse(actual.search), + querystring.parse(expected.search) + ); + + fakeTimer.restore(); + }); }); }); -}); -// tslint:disable-next-line ban -describe.skip('v4 signed policy', () => { - v4SignedPolicyCases.forEach(testCase => { - // TODO: implement parsing v4 signed policy tests - it(testCase.description, async () => { - // const input = testCase.policyInput; - // const NOW = new Date(input.timestamp); - // const fakeTimer = sinon.useFakeTimers(NOW); - // const bucket = storage.bucket(input.bucket); - // const expires = NOW.valueOf() + input.expiration * 1000; - // const options = {}; - // const fields = input.fields || {}; - // // fields that Node.js supports as argument to method. - // const acl = fields.acl; - // delete fields.acl; - // const successActionStatus = fields.success_action_status; - // delete fields.successActionStatus; - // const successActionRedirect = fields.success_action_redirect; - // delete fields.successActionRedirect; - // const conditions = input.conditions || {} as Conditions; - // // conditions that Node.js support as argument to method. - // const startsWith = conditions.startsWith; - // let contentLengthMin - // if (conditions.contentLengthRange) { - // } - // fakeTimer.restore(); + describe('v4 signed policy', () => { + v4SignedPolicyCases.forEach(testCase => { + it(testCase.description, async () => { + const input = testCase.policyInput; + const NOW = new Date(input.timestamp); + const fakeTimer = sinon.useFakeTimers(NOW); + const bucket = storage.bucket(input.bucket); + const expires = NOW.valueOf() + input.expiration * 1000; + const options: GenerateSignedPostPolicyV4Options = { + expires, + }; + + const conditions = []; + if (input.conditions) { + if (input.conditions.startsWith) { + const variable = input.conditions.startsWith[0]; + const prefix = input.conditions.startsWith[1]; + conditions.push(['starts-with', variable, prefix]); + } + + if (input.conditions.contentLengthRange) { + const min = input.conditions.contentLengthRange[0]; + const max = input.conditions.contentLengthRange[1]; + conditions.push(['content-length-range', min, max]); + } + } + + const origin = input.bucketBoundHostname + ? `${input.scheme}://${input.bucketBoundHostname}` + : undefined; + const {bucketBoundHostname, virtualHostedStyle} = parseUrlStyle( + input.urlStyle, + origin + ); + options.virtualHostedStyle = virtualHostedStyle; + options.bucketBoundHostname = bucketBoundHostname; + options.fields = input.fields; + options.conditions = conditions; + + const file = bucket.file(input.object); + const [policy] = await file.generateSignedPostPolicyV4(options); + + assert.strictEqual(policy.url, testCase.policyOutput.url); + const outputFields = testCase.policyOutput.fields; + const decodedPolicy = Buffer.from( + policy.fields.policy, + 'base64' + ).toString(); + assert.deepStrictEqual( + decodedPolicy, + testCase.policyOutput.expectedDecodedPolicy + ); + + assert.deepStrictEqual(outputFields, testCase.policyOutput.fields); + + fakeTimer.restore(); + }); }); }); }); function parseUrlStyle( style?: UrlStyle, - domain?: string -): {cname?: string; virtualHostedStyle?: boolean} { + origin?: string +): {bucketBoundHostname?: string; virtualHostedStyle?: boolean} { if (style === UrlStyle.BUCKET_BOUND_HOSTNAME) { - return {cname: domain}; + return {bucketBoundHostname: origin}; } else if (style === UrlStyle.VIRTUAL_HOSTED_STYLE) { return {virtualHostedStyle: true}; } else { diff --git a/package.json b/package.json index 78c1950b6..860acf52f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@google-cloud/storage", "description": "Cloud Storage Client Library for Node.js", - "version": "4.6.0", + "version": "4.7.0", "license": "Apache-2.0", "author": "Google Inc.", "engines": { @@ -58,10 +58,10 @@ "arrify": "^2.0.0", "compressible": "^2.0.12", "concat-stream": "^2.0.0", - "date-and-time": "^0.12.0", + "date-and-time": "^0.13.0", "duplexify": "^3.5.0", "extend": "^3.0.2", - "gaxios": "^2.0.1", + "gaxios": "^3.0.0", "gcs-resumable-upload": "^2.2.4", "hash-stream-validation": "^0.2.2", "mime": "^2.2.0", @@ -96,11 +96,13 @@ "@types/tmp": "0.1.0", "@types/uuid": "^7.0.0", "@types/xdg-basedir": "^2.0.0", + "c8": "^7.0.0", "codecov": "^3.0.0", "eslint": "^6.0.0", "eslint-config-prettier": "^6.0.0", "eslint-plugin-node": "^11.0.0", "eslint-plugin-prettier": "^3.0.0", + "form-data": "^3.0.0", "grpc": "^1.22.2", "gts": "^1.0.0", "jsdoc": "^3.6.2", @@ -111,7 +113,6 @@ "nock": "~12.0.0", "node-fetch": "^2.2.0", "normalize-newline": "^3.0.0", - "c8": "^7.0.0", "prettier": "^1.7.0", "proxyquire": "^2.1.3", "sinon": "^9.0.0", diff --git a/samples/README.md b/samples/README.md index 657661e94..859ee19d4 100644 --- a/samples/README.md +++ b/samples/README.md @@ -72,6 +72,7 @@ objects to users via direct download. * [Quickstart](#quickstart) * [Release Event Based Hold](#release-event-based-hold) * [Release Temporary Hold](#release-temporary-hold) + * [Remove Bucket Conditional Binding](#remove-bucket-conditional-binding) * [Remove Bucket Default Owner](#remove-bucket-default-owner) * [Remove Bucket Iam Member](#remove-bucket-iam-member) * [Remove Bucket Owner Acl](#remove-bucket-owner-acl) @@ -1036,6 +1037,23 @@ __Usage:__ +### Remove Bucket Conditional Binding + +View the [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/removeBucketConditionalBinding.js). + +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/removeBucketConditionalBinding.js,samples/README.md) + +__Usage:__ + + +`node samples/removeBucketConditionalBinding.js` + + +----- + + + + ### Remove Bucket Default Owner View the [source code](https://github.com/googleapis/nodejs-storage/blob/master/samples/removeBucketDefaultOwner.js). diff --git a/samples/package.json b/samples/package.json index da2bb954a..4992a8821 100644 --- a/samples/package.json +++ b/samples/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@google-cloud/pubsub": "^1.0.0", - "@google-cloud/storage": "^4.6.0", + "@google-cloud/storage": "^4.7.0", "uuid": "^7.0.0", "yargs": "^15.0.0" }, diff --git a/samples/removeBucketConditionalBinding.js b/samples/removeBucketConditionalBinding.js new file mode 100644 index 000000000..81ece9306 --- /dev/null +++ b/samples/removeBucketConditionalBinding.js @@ -0,0 +1,83 @@ +// 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. + +/** + * This application demonstrates how to perform basic operations on bucket and + * file Access Control Lists with the Google Cloud Storage API. + * + * For more information, see the README.md under /storage and the documentation + * at https://cloud.google.com/storage/docs. + */ + +function main( + bucketName = 'my-bucket', + roleName = 'roles/storage.objectViewer', + title = 'match-prefix', + description = 'Applies to objects matching a prefix', + expression = 'resource.name.startsWith("projects/_/buckets/bucket-name/objects/prefix-a-")' +) { + // [START storage_remove_bucket_conditional_iam_binding] + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + // const roleName = 'Role to grant, e.g. roles/storage.objectViewer'; + // const title = 'Condition title.'; + // const description = 'Conditon description.'; + // const expression = 'Condition expression.'; + + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + async function removeBucketConditionalBinding() { + // Get a reference to a Google Cloud Storage bucket + const bucket = storage.bucket(bucketName); + + // Gets and updates the bucket's IAM policy + const [policy] = await bucket.iam.getPolicy({requestedPolicyVersion: 3}); + + // Set the policy's version to 3 to use condition in bindings. + policy.version = 3; + + // Finds and removes the appropriate role-member group with specific condition. + const index = policy.bindings.findIndex( + binding => + binding.role === roleName && + binding.condition && + binding.condition.title === title && + binding.condition.description === description && + binding.condition.expression === expression + ); + + const binding = policy.bindings[index]; + if (binding) { + policy.bindings.splice(index, 1); + + // Updates the bucket's IAM policy + await bucket.iam.setPolicy(policy); + + console.log('Conditional Binding was removed.'); + } else { + // No matching role-member group with specific condition were found + throw new Error('No matching binding group found.'); + } + } + + removeBucketConditionalBinding().catch(console.error); + // [END storage_remove_bucket_conditional_iam_binding] +} +main(...process.argv.slice(2)); diff --git a/samples/system-test/iam.test.js b/samples/system-test/iam.test.js index 49ed11735..054eca818 100644 --- a/samples/system-test/iam.test.js +++ b/samples/system-test/iam.test.js @@ -94,3 +94,10 @@ it('should remove multiple members from a role on a bucket', async () => { ); assert.match(output, new RegExp(`user:${userEmail}`)); }); + +it('should remove conditional binding to a bucket', async () => { + const output = execSync( + `node removeBucketConditionalBinding.js ${bucketName} ${roleName} '${title}' '${description}' '${expression}'` + ); + assert.include(output, `Conditional Binding was removed.`); +}); diff --git a/src/file.ts b/src/file.ts index d7d573aff..b49aa709b 100644 --- a/src/file.ts +++ b/src/file.ts @@ -26,6 +26,7 @@ import {promisifyAll} from '@google-cloud/promisify'; import compressible = require('compressible'); import concat = require('concat-stream'); import * as crypto from 'crypto'; +import * as dateFormat from 'date-and-time'; import * as extend from 'extend'; import * as fs from 'fs'; const hashStreamValidation = require('hash-stream-validation'); @@ -93,6 +94,37 @@ export interface GetSignedPolicyOptions { contentLengthRange?: {min?: number; max?: number}; } +export interface GenerateSignedPostPolicyV2Options + extends GetSignedPolicyOptions {} + +export type GenerateSignedPostPolicyV2Response = GetSignedPolicyResponse; + +export interface GenerateSignedPostPolicyV2Callback + extends GetSignedPolicyCallback {} + +export interface PolicyFields { + [key: string]: string; +} + +export interface GenerateSignedPostPolicyV4Options { + expires: string | number | Date; + bucketBoundHostname?: string; + virtualHostedStyle?: boolean; + conditions?: object[]; + fields?: PolicyFields; +} + +export interface GenerateSignedPostPolicyV4Callback { + (err: Error | null, output?: SignedPostPolicyV4Output): void; +} + +export type GenerateSignedPostPolicyV4Response = [SignedPostPolicyV4Output]; + +export interface SignedPostPolicyV4Output { + url: string; + fields: PolicyFields; +} + export interface GetSignedUrlConfig { action: 'read' | 'write' | 'delete' | 'resumable'; version?: 'v2' | 'v4'; @@ -250,6 +282,12 @@ class ResumableUploadError extends Error { const STORAGE_UPLOAD_BASE_URL = 'https://storage.googleapis.com/upload/storage/v1/b'; +/** + * @const {string} + * @private + */ +export const STORAGE_POST_POLICY_BASE_URL = 'https://storage.googleapis.com'; + /** * @const {RegExp} * @private @@ -345,6 +383,8 @@ class RequestError extends Error { errors?: Error[]; } +const SEVEN_DAYS = 7 * 24 * 60 * 60; + /** * A File object is created from your {@link Bucket} object using * {@link Bucket#file}. @@ -2099,7 +2139,7 @@ class File extends ServiceObject { * @param {object} policy The document policy. */ /** - * Get a signed policy document to allow a user to upload data with a POST + * Get a v2 signed policy document to allow a user to upload data with a POST * request. * * In Google Cloud Platform environments, such as Cloud Functions and App @@ -2114,6 +2154,12 @@ class File extends ServiceObject { * * @see [Policy Document Reference]{@link https://cloud.google.com/storage/docs/xml-api/post-object#policydocument} * + * @deprecated `getSignedPolicy()` is deprecated in favor of + * `generateSignedPostPolicyV2()` and `generateSignedPostPolicyV4()`. + * Currently, this method is an alias to `getSignedPolicyV2()`, + * and will be removed in a future major release. + * We recommend signing new policies using v4. + * * @throws {Error} If an expiration timestamp from the past is given. * @throws {Error} If options.equals has an array with less or more than two * members. @@ -2181,10 +2227,128 @@ class File extends ServiceObject { optionsOrCallback?: GetSignedPolicyOptions | GetSignedPolicyCallback, cb?: GetSignedPolicyCallback ): void | Promise { - const args = normalize(optionsOrCallback, cb); + const args = normalize( + optionsOrCallback, + cb + ); + const options = args.options; + const callback = args.callback; + this.generateSignedPostPolicyV2(options, callback); + } + + generateSignedPostPolicyV2( + options: GenerateSignedPostPolicyV2Options + ): Promise; + generateSignedPostPolicyV2( + options: GenerateSignedPostPolicyV2Options, + callback: GenerateSignedPostPolicyV2Callback + ): void; + generateSignedPostPolicyV2( + callback: GenerateSignedPostPolicyV2Callback + ): void; + /** + * @typedef {array} GenerateSignedPostPolicyV2Response + * @property {object} 0 The document policy. + */ + /** + * @callback GenerateSignedPostPolicyV2Callback + * @param {?Error} err Request error, if any. + * @param {object} policy The document policy. + */ + /** + * Get a signed policy document to allow a user to upload data with a POST + * request. + * + * In Google Cloud Platform environments, such as Cloud Functions and App + * Engine, you usually don't provide a `keyFilename` or `credentials` during + * instantiation. In those environments, we call the + * [signBlob + * API](https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob#authorization-scopes) + * to create a signed policy. That API requires either the + * `https://www.googleapis.com/auth/iam` or + * `https://www.googleapis.com/auth/cloud-platform` scope, so be sure they are + * enabled. + * + * @see [POST Object with the V2 signing process]{@link https://cloud.google.com/storage/docs/xml-api/post-object-v2} + * + * @throws {Error} If an expiration timestamp from the past is given. + * @throws {Error} If options.equals has an array with less or more than two + * members. + * @throws {Error} If options.startsWith has an array with less or more than two + * members. + * + * @param {object} options Configuration options. + * @param {array|array[]} [options.equals] Array of request parameters and + * their expected value (e.g. [['$', '']]). Values are + * translated into equality constraints in the conditions field of the + * policy document (e.g. ['eq', '$', '']). If only one + * equality condition is to be specified, options.equals can be a one- + * dimensional array (e.g. ['$', '']). + * @param {*} options.expires - A timestamp when this policy will expire. Any + * value given is passed to `new Date()`. + * @param {array|array[]} [options.startsWith] Array of request parameters and + * their expected prefixes (e.g. [['$', '']). Values are + * translated into starts-with constraints in the conditions field of the + * policy document (e.g. ['starts-with', '$', '']). If only + * one prefix condition is to be specified, options.startsWith can be a + * one- dimensional array (e.g. ['$', '']). + * @param {string} [options.acl] ACL for the object from possibly predefined + * ACLs. + * @param {string} [options.successRedirect] The URL to which the user client + * is redirected if the upload is successful. + * @param {string} [options.successStatus] - The status of the Google Storage + * response if the upload is successful (must be string). + * @param {object} [options.contentLengthRange] + * @param {number} [options.contentLengthRange.min] Minimum value for the + * request's content length. + * @param {number} [options.contentLengthRange.max] Maximum value for the + * request's content length. + * @param {GenerateSignedPostPolicyV2Callback} [callback] Callback function. + * @returns {Promise} + * + * @example + * const {Storage} = require('@google-cloud/storage'); + * const storage = new Storage(); + * const myBucket = storage.bucket('my-bucket'); + * + * const file = myBucket.file('my-file'); + * const options = { + * equals: ['$Content-Type', 'image/jpeg'], + * expires: '10-25-2022', + * contentLengthRange: { + * min: 0, + * max: 1024 + * } + * }; + * + * file.generateSignedPostPolicyV2(options, function(err, policy) { + * // policy.string: the policy document in plain text. + * // policy.base64: the policy document in base64. + * // policy.signature: the policy signature in base64. + * }); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * file.generateSignedPostPolicyV2(options).then(function(data) { + * const policy = data[0]; + * }); + */ + generateSignedPostPolicyV2( + optionsOrCallback?: + | GenerateSignedPostPolicyV2Options + | GenerateSignedPostPolicyV2Callback, + cb?: GenerateSignedPostPolicyV2Callback + ): void | Promise { + const args = normalize( + optionsOrCallback, + cb + ); let options = args.options; const callback = args.callback; - const expires = new Date((options as GetSignedPolicyOptions).expires); + const expires = new Date( + (options as GenerateSignedPostPolicyV2Options).expires + ); if (isNaN(expires.getTime())) { throw new Error('The expiration date provided was invalid.'); @@ -2280,6 +2444,201 @@ class File extends ServiceObject { ); } + generateSignedPostPolicyV4( + options: GenerateSignedPostPolicyV4Options + ): Promise; + generateSignedPostPolicyV4( + options: GenerateSignedPostPolicyV4Options, + callback: GenerateSignedPostPolicyV4Callback + ): void; + generateSignedPostPolicyV4( + callback: GenerateSignedPostPolicyV4Callback + ): void; + /** + * @typedef {object} SignedPostPolicyV4Output + * @property {string} url The request URL. + * @property {object} fields The form fields to include in the POST request. + */ + /** + * @typedef {array} GenerateSignedPostPolicyV4Response + * @property {SignedPostPolicyV4Output} 0 An object containing the request URL and form fields. + */ + /** + * @callback GenerateSignedPostPolicyV4Callback + * @param {?Error} err Request error, if any. + * @param {SignedPostPolicyV4Output} output An object containing the request URL and form fields. + */ + /** + * Get a v4 signed policy document to allow a user to upload data with a POST + * request. + * + * In Google Cloud Platform environments, such as Cloud Functions and App + * Engine, you usually don't provide a `keyFilename` or `credentials` during + * instantiation. In those environments, we call the + * [signBlob + * API](https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob#authorization-scopes) + * to create a signed policy. That API requires either the + * `https://www.googleapis.com/auth/iam` or + * `https://www.googleapis.com/auth/cloud-platform` scope, so be sure they are + * enabled. + * + * @see [Policy Document Reference]{@link https://cloud.google.com/storage/docs/xml-api/post-object#policydocument} + * + * @param {object} options Configuration options. + * @param {Date|number|string} options.expires - A timestamp when this policy will expire. Any + * value given is passed to `new Date()`. + * @param {boolean} [config.virtualHostedStyle=false] Use virtual hosted-style + * URLs ('https://mybucket.storage.googleapis.com/...') instead of path-style + * ('https://storage.googleapis.com/mybucket/...'). Virtual hosted-style URLs + * should generally be preferred instaed of path-style URL. + * Currently defaults to `false` for path-style, although this may change in a + * future major-version release. + * @param {string} [config.bucketBoundHostname] The bucket-bound hostname to return in + * the result, e.g. "https://cdn.example.com". + * @param {object} [config.fields] [Form fields]{@link https://cloud.google.com/storage/docs/xml-api/post-object#policydocument} + * to include in the signed policy. Any fields with key beginning with 'x-ignore-' + * will not be included in the policy to be signed. + * @param {object[]} [config.conditions] [Conditions]{@link https://cloud.google.com/storage/docs/authentication/signatures#policy-document} + * to include in the signed policy. All fields given in `config.fields` are + * automatically included in the conditions array, adding the same entry + * in both `fields` and `conditions` will result in duplicate entries. + * + * @param {GenerateSignedPostPolicyV4Callback} [callback] Callback function. + * @returns {Promise} + * + * @example + * const {Storage} = require('@google-cloud/storage'); + * const storage = new Storage(); + * const myBucket = storage.bucket('my-bucket'); + * + * const file = myBucket.file('my-file'); + * const options = { + * expires: '10-25-2022', + * conditions: [ + * ['eq', '$Content-Type', 'image/jpeg'], + * ['content-length-range', 0, 1024], + * ], + * fields: { + * acl: 'public-read', + * 'x-goog-meta-foo': 'bar', + * 'x-ignore-mykey': 'data' + * } + * }; + * + * file.generateSignedPostPolicyV4(options, function(err, response) { + * // response.url The request URL + * // response.fields The form fields (including the signature) to include + * // to be used to upload objects by HTML forms. + * }); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * file.generateSignedPostPolicyV4(options).then(function(data) { + * const response = data[0]; + * // response.url The request URL + * // response.fields The form fields (including the signature) to include + * // to be used to upload objects by HTML forms. + * }); + */ + generateSignedPostPolicyV4( + optionsOrCallback?: + | GenerateSignedPostPolicyV4Options + | GenerateSignedPostPolicyV4Callback, + cb?: GenerateSignedPostPolicyV4Callback + ): void | Promise { + const args = normalize< + GenerateSignedPostPolicyV4Options, + GenerateSignedPostPolicyV4Callback + >(optionsOrCallback, cb); + let options = args.options; + const callback = args.callback; + const expires = new Date( + (options as GenerateSignedPostPolicyV4Options).expires + ); + + if (isNaN(expires.getTime())) { + throw new Error('The expiration date provided was invalid.'); + } + + if (expires.valueOf() < Date.now()) { + throw new Error('An expiration date cannot be in the past.'); + } + + if (expires.valueOf() - Date.now() > SEVEN_DAYS * 1000) { + throw new Error( + `Max allowed expiration is seven days (${SEVEN_DAYS} seconds).` + ); + } + + options = Object.assign({}, options); + let fields = Object.assign({}, options.fields); + + const now = new Date(); + const nowISO = dateFormat.format(now, 'YYYYMMDD[T]HHmmss[Z]', true); + const todayISO = dateFormat.format(now, 'YYYYMMDD', true); + + const sign = async () => { + const {client_email} = await this.storage.authClient.getCredentials(); + const credential = `${client_email}/${todayISO}/auto/storage/goog4_request`; + + fields = { + ...fields, + key: this.name, + 'x-goog-date': nowISO, + 'x-goog-credential': credential, + 'x-goog-algorithm': 'GOOG4-RSA-SHA256', + }; + + const conditions = options.conditions || []; + + Object.entries(fields).forEach(([key, value]) => { + if (!key.startsWith('x-ignore-')) { + conditions.push({[key]: value}); + } + }); + + const expiration = dateFormat.format( + expires, + 'YYYY-MM-DD[T]HH:mm:ss[Z]', + true + ); + + const policy = { + conditions, + expiration, + }; + + const policyString = JSON.stringify(policy); + const policyBase64 = Buffer.from(policyString).toString('base64'); + + try { + const signature = await this.storage.authClient.sign(policyBase64); + const signatureHex = Buffer.from(signature, 'base64').toString('hex'); + fields['policy'] = policyBase64; + fields['x-goog-signature'] = signatureHex; + + let url: string; + if (options.virtualHostedStyle) { + url = `https://${this.bucket.name}.storage.googleapis.com/`; + } else if (options.bucketBoundHostname) { + url = `${options.bucketBoundHostname}/`; + } else { + url = `${STORAGE_POST_POLICY_BASE_URL}/${this.bucket.name}/`; + } + + return { + url, + fields, + }; + } catch (err) { + throw new SigningError(err.message); + } + }; + + sign().then(res => callback!(null, res), callback!); + } + getSignedUrl(cfg: GetSignedUrlConfig): Promise; getSignedUrl(cfg: GetSignedUrlConfig, callback: GetSignedUrlCallback): void; /** diff --git a/src/index.ts b/src/index.ts index 75667a73b..7ff735e4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,6 +155,12 @@ export { GetSignedPolicyCallback, GetSignedPolicyOptions, GetSignedPolicyResponse, + GenerateSignedPostPolicyV2Callback, + GenerateSignedPostPolicyV2Options, + GenerateSignedPostPolicyV2Response, + GenerateSignedPostPolicyV4Callback, + GenerateSignedPostPolicyV4Options, + GenerateSignedPostPolicyV4Response, GetSignedUrlConfig, MakeFilePrivateCallback, MakeFilePrivateOptions, @@ -165,6 +171,7 @@ export { MoveOptions, MoveResponse, PolicyDocument, + PolicyFields, PredefinedAcl, RotateEncryptionKeyCallback, RotateEncryptionKeyOptions, @@ -177,6 +184,7 @@ export { SetStorageClassCallback, SetStorageClassOptions, SetStorageClassResponse, + SignedPostPolicyV4Output, } from './file'; export { HmacKey, diff --git a/src/signer.ts b/src/signer.ts index c7143ce50..fa7271d5f 100644 --- a/src/signer.ts +++ b/src/signer.ts @@ -87,7 +87,9 @@ export interface SignerGetSignedUrlConfig { contentType?: string; } -export type GetSignedUrlResponse = string; +export type SignerGetSignedUrlResponse = string; + +export type GetSignedUrlResponse = [SignerGetSignedUrlResponse]; export interface GetSignedUrlCallback { (err: Error | null, url?: string): void; @@ -119,7 +121,9 @@ export class URLSigner { this.authClient = authClient; } - getSignedUrl(cfg: SignerGetSignedUrlConfig): Promise { + getSignedUrl( + cfg: SignerGetSignedUrlConfig + ): Promise { const expiresInSeconds = this.parseExpires(cfg.expires); const method = cfg.method; diff --git a/synth.metadata b/synth.metadata index 0bd43d7e1..34f3f5950 100644 --- a/synth.metadata +++ b/synth.metadata @@ -1,11 +1,11 @@ { - "updateTime": "2020-01-31T12:36:26.549093Z", + "updateTime": "2020-03-22T11:49:19.238679Z", "sources": [ { - "template": { - "name": "node_library", - "origin": "synthtool.gcp", - "version": "2019.10.17" + "git": { + "name": "synthtool", + "remote": "https://github.com/googleapis/synthtool.git", + "sha": "7e98e1609c91082f4eeb63b530c6468aefd18cfd" } } ] diff --git a/system-test/storage.ts b/system-test/storage.ts index 0210f246a..337518657 100644 --- a/system-test/storage.ts +++ b/system-test/storage.ts @@ -16,7 +16,8 @@ import * as assert from 'assert'; import {describe, it} from 'mocha'; import * as crypto from 'crypto'; import * as fs from 'fs'; -import fetch from 'node-fetch'; +import fetch, {Request} from 'node-fetch'; +import * as FormData from 'form-data'; const normalizeNewline = require('normalize-newline'); import pLimit from 'p-limit'; import {promisify} from 'util'; @@ -241,10 +242,15 @@ describe('storage', () => { }); it('should not upload a file', async () => { - await assert.rejects( - () => file.save('new data'), - /Could not load the default credentials/ - ); + try { + await file.save('new data'); + } catch (e) { + const allowedErrorMessages = [ + /Could not load the default credentials/, + /does not have storage\.objects\.create access/, + ]; + assert(allowedErrorMessages.some(msg => msg.test(e.message))); + } }); }); }); @@ -3382,12 +3388,8 @@ describe('storage', () => { describe('sign policy', () => { let file: File; - before(done => { + before(() => { file = bucket.file('LogoToSign.jpg'); - fs.createReadStream(FILES.logo.path) - .pipe(file.createWriteStream()) - .on('error', done) - .on('finish', done.bind(null, null)); }); beforeEach(function() { @@ -3396,8 +3398,8 @@ describe('storage', () => { } }); - it('should create a policy', done => { - const expires = new Date('10-25-2022'); + it('should create a V2 policy', async () => { + const expires = Date.now() + 60 * 1000; // one minute const expectedExpiration = new Date(expires).toISOString(); const options = { @@ -3409,21 +3411,37 @@ describe('storage', () => { }, }; - file.getSignedPolicy(options, (err, policy) => { - assert.ifError(err); + const [policy] = await file.generateSignedPostPolicyV2(options); - let policyJson; + const policyJson = JSON.parse(policy!.string); + assert.strictEqual(policyJson.expiration, expectedExpiration); + }); - try { - policyJson = JSON.parse(policy!.string); - } catch (e) { - done(e); - return; - } + it('should create a V4 policy', async () => { + const expires = Date.now() + 60 * 1000; // one minute + const options = { + expires, + contentLengthRange: { + min: 0, + max: 50000, + }, + fields: {'x-goog-meta-test': 'data'}, + }; - assert.strictEqual(policyJson.expiration, expectedExpiration); - done(); - }); + const [policy] = await file.generateSignedPostPolicyV4(options); + const form = new FormData(); + for (const [key, value] of Object.entries(policy.fields)) { + form.append(key, value); + } + + const CONTENT = 'my-content'; + + form.append('file', CONTENT); + const res = await fetch(policy.url, {method: 'POST', body: form}); + assert.strictEqual(res.status, 204); + + const [buf] = await file.download(); + assert.strictEqual(buf.toString(), CONTENT); }); }); diff --git a/test/file.ts b/test/file.ts index 2ca3e53b2..cf0f5ad99 100644 --- a/test/file.ts +++ b/test/file.ts @@ -23,6 +23,7 @@ import {PromisifyAllOptions} from '@google-cloud/promisify'; import * as assert from 'assert'; import {describe, it} from 'mocha'; import * as crypto from 'crypto'; +import * as dateFormat from 'date-and-time'; import * as duplexify from 'duplexify'; import * as extend from 'extend'; import * as fs from 'fs'; @@ -45,7 +46,15 @@ import { SetFileMetadataOptions, GetSignedUrlConfig, GetSignedPolicyOptions, + GenerateSignedPostPolicyV2Options, + GenerateSignedPostPolicyV2Callback, } from '../src'; +import { + SignedPostPolicyV4Output, + GenerateSignedPostPolicyV4Options, + STORAGE_POST_POLICY_BASE_URL, +} from '../src/file'; +import {fixedEncodeURIComponent} from '../src/util'; let promisified = false; let makeWritableStreamOverride: Function | null; @@ -2418,7 +2427,27 @@ describe('File', () => { }); describe('getSignedPolicy', () => { - let CONFIG: GetSignedPolicyOptions; + it('should alias to generateSignedPostPolicyV2', done => { + const options = { + expires: Date.now() + 2000, + }; + const callback = () => {}; + + file.generateSignedPostPolicyV2 = ( + argOpts: GenerateSignedPostPolicyV2Options, + argCb: GenerateSignedPostPolicyV2Callback + ) => { + assert.strictEqual(argOpts, options); + assert.strictEqual(argCb, callback); + done(); + }; + + file.getSignedPolicy(options, callback); + }); + }); + + describe('generateSignedPostPolicyV2', () => { + let CONFIG: GenerateSignedPostPolicyV2Options; beforeEach(() => { CONFIG = { @@ -2440,19 +2469,22 @@ describe('File', () => { }; // tslint:disable-next-line no-any - file.getSignedPolicy(CONFIG, (err: Error, signedPolicy: any) => { - assert.ifError(err); - assert.strictEqual(typeof signedPolicy.string, 'string'); - assert.strictEqual(typeof signedPolicy.base64, 'string'); - assert.strictEqual(typeof signedPolicy.signature, 'string'); - done(); - }); + file.generateSignedPostPolicyV2( + CONFIG, + (err: Error, signedPolicy: PolicyDocument) => { + assert.ifError(err); + assert.strictEqual(typeof signedPolicy.string, 'string'); + assert.strictEqual(typeof signedPolicy.base64, 'string'); + assert.strictEqual(typeof signedPolicy.signature, 'string'); + done(); + } + ); }); it('should not modify the configuration object', done => { const originalConfig = Object.assign({}, CONFIG); - file.getSignedPolicy(CONFIG, (err: Error) => { + file.generateSignedPostPolicyV2(CONFIG, (err: Error) => { assert.ifError(err); assert.deepStrictEqual(CONFIG, originalConfig); done(); @@ -2466,7 +2498,7 @@ describe('File', () => { return Promise.reject(error); }; - file.getSignedPolicy(CONFIG, (err: Error) => { + file.generateSignedPostPolicyV2(CONFIG, (err: Error) => { assert.strictEqual(err.name, 'SigningError'); assert.strictEqual(err.message, error.message); done(); @@ -2474,7 +2506,7 @@ describe('File', () => { }); it('should add key equality condition', done => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( CONFIG, (err: Error, signedPolicy: PolicyDocument) => { const conditionString = '["eq","$key","' + file.name + '"]'; @@ -2486,7 +2518,7 @@ describe('File', () => { }); it('should add ACL condtion', done => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, acl: '', @@ -2503,7 +2535,7 @@ describe('File', () => { it('should add success redirect', done => { const redirectUrl = 'http://redirect'; - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, successRedirect: redirectUrl, @@ -2528,7 +2560,7 @@ describe('File', () => { it('should add success status', done => { const successStatus = '200'; - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, successStatus, @@ -2554,7 +2586,7 @@ describe('File', () => { it('should accept Date objects', done => { const expires = new Date(Date.now() + 1000 * 60); - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires, }, @@ -2570,7 +2602,7 @@ describe('File', () => { it('should accept numbers', done => { const expires = Date.now() + 1000 * 60; - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires, }, @@ -2586,7 +2618,7 @@ describe('File', () => { it('should accept strings', done => { const expires = '12-12-2099'; - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires, }, @@ -2603,7 +2635,7 @@ describe('File', () => { const expires = new Date('31-12-2019'); assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires, }, @@ -2616,7 +2648,7 @@ describe('File', () => { const expires = Date.now() - 5; assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires, }, @@ -2628,7 +2660,7 @@ describe('File', () => { describe('equality condition', () => { it('should add equality conditions (array of arrays)', done => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, equals: [['$', '']], @@ -2643,7 +2675,7 @@ describe('File', () => { }); it('should add equality condition (array)', done => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, equals: ['$', ''], @@ -2659,7 +2691,7 @@ describe('File', () => { it('should throw if equal condition is not an array', () => { assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, equals: [{}], @@ -2671,7 +2703,7 @@ describe('File', () => { it('should throw if equal condition length is not 2', () => { assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, equals: [['1', '2', '3']], @@ -2684,7 +2716,7 @@ describe('File', () => { describe('prefix conditions', () => { it('should add prefix conditions (array of arrays)', done => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, startsWith: [['$', '']], @@ -2699,7 +2731,7 @@ describe('File', () => { }); it('should add prefix condition (array)', done => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, startsWith: ['$', ''], @@ -2715,7 +2747,7 @@ describe('File', () => { it('should throw if prexif condition is not an array', () => { assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, startsWith: [{}], @@ -2727,7 +2759,7 @@ describe('File', () => { it('should throw if prefix condition length is not 2', () => { assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, startsWith: [['1', '2', '3']], @@ -2740,7 +2772,7 @@ describe('File', () => { describe('content length', () => { it('should add content length condition', done => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, contentLengthRange: {min: 0, max: 1}, @@ -2756,7 +2788,7 @@ describe('File', () => { it('should throw if content length has no min', () => { assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, contentLengthRange: [{max: 1}], @@ -2768,7 +2800,7 @@ describe('File', () => { it('should throw if content length has no max', () => { assert.throws(() => { - file.getSignedPolicy( + file.generateSignedPostPolicyV2( { expires: Date.now() + 2000, contentLengthRange: [{min: 0}], @@ -2780,6 +2812,354 @@ describe('File', () => { }); }); + describe('generateSignedPostPolicyV4', () => { + let CONFIG: GenerateSignedPostPolicyV4Options; + + const NOW = new Date('2020-01-01'); + const CLIENT_EMAIL = 'test@domain.com'; + const SIGNATURE = 'signature'; + + let fakeTimer: sinon.SinonFakeTimers; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + fakeTimer = sinon.useFakeTimers(NOW); + CONFIG = { + expires: NOW.valueOf() + 2000, + }; + + BUCKET.storage.authClient = { + sign: sandbox.stub().resolves(SIGNATURE), + getCredentials: sandbox.stub().resolves({client_email: CLIENT_EMAIL}), + }; + }); + + afterEach(() => { + sandbox.restore(); + fakeTimer.restore(); + }); + + it('should create a signed policy', done => { + CONFIG.fields = { + 'x-goog-meta-foo': 'bar', + }; + + const fields = { + ...CONFIG.fields, + key: file.name, + 'x-goog-date': '20200101T000000Z', + 'x-goog-credential': `${CLIENT_EMAIL}/20200101/auto/storage/goog4_request`, + 'x-goog-algorithm': 'GOOG4-RSA-SHA256', + }; + + const policy = { + conditions: Object.entries(fields).map(([key, value]) => ({ + [key]: value, + })), + expiration: dateFormat.format( + new Date(CONFIG.expires), + 'YYYY-MM-DD[T]HH:mm:ss[Z]', + true + ), + }; + + const policyString = JSON.stringify(policy); + const EXPECTED_POLICY = Buffer.from(policyString).toString('base64'); + const EXPECTED_SIGNATURE = Buffer.from(SIGNATURE, 'base64').toString( + 'hex' + ); + + // tslint:disable-next-line no-any + file.generateSignedPostPolicyV4( + CONFIG, + (err: Error, res: SignedPostPolicyV4Output) => { + assert.ifError(err); + assert(res.url, `${STORAGE_POST_POLICY_BASE_URL}/${BUCKET.name}`); + + assert.deepStrictEqual(res.fields, { + ...fields, + 'x-goog-signature': EXPECTED_SIGNATURE, + policy: EXPECTED_POLICY, + }); + + const signStub = BUCKET.storage.authClient.sign; + assert.deepStrictEqual( + Buffer.from(signStub.getCall(0).args[0], 'base64').toString(), + policyString + ); + + done(); + } + ); + }); + + it('should not modify the configuration object', done => { + const originalConfig = Object.assign({}, CONFIG); + + file.generateSignedPostPolicyV4(CONFIG, (err: Error) => { + assert.ifError(err); + assert.deepStrictEqual(CONFIG, originalConfig); + done(); + }); + }); + + it('should return an error if signBlob errors', done => { + const error = new Error('Error.'); + + BUCKET.storage.authClient.sign.rejects(error); + + file.generateSignedPostPolicyV4(CONFIG, (err: Error) => { + assert.strictEqual(err.name, 'SigningError'); + assert.strictEqual(err.message, error.message); + done(); + }); + }); + + it('should add key condition', done => { + file.generateSignedPostPolicyV4( + CONFIG, + (err: Error, res: SignedPostPolicyV4Output) => { + assert.ifError(err); + + assert.strictEqual(res.fields['key'], file.name); + const EXPECTED_POLICY_ELEMENT = `{"key":"${file.name}"}`; + assert( + Buffer.from(res.fields.policy, 'base64') + .toString('utf-8') + .includes(EXPECTED_POLICY_ELEMENT) + ); + done(); + } + ); + }); + + it('should include fields in conditions', done => { + CONFIG = { + fields: { + 'x-goog-meta-foo': 'bar', + }, + ...CONFIG, + }; + + file.generateSignedPostPolicyV4( + CONFIG, + (err: Error, res: SignedPostPolicyV4Output) => { + assert.ifError(err); + + const expectedConditionString = JSON.stringify(CONFIG.fields); + assert.strictEqual(res.fields['x-goog-meta-foo'], 'bar'); + const decodedPolicy = Buffer.from( + res.fields.policy, + 'base64' + ).toString('utf-8'); + assert(decodedPolicy.includes(expectedConditionString)); + done(); + } + ); + }); + + it('should not include fields with x-ignore- prefix in conditions', done => { + CONFIG = { + fields: { + 'x-ignore-foo': 'bar', + }, + ...CONFIG, + }; + + file.generateSignedPostPolicyV4( + CONFIG, + (err: Error, res: SignedPostPolicyV4Output) => { + assert.ifError(err); + + const expectedConditionString = JSON.stringify(CONFIG.fields); + assert.strictEqual(res.fields['x-ignore-foo'], 'bar'); + const decodedPolicy = Buffer.from( + res.fields.policy, + 'base64' + ).toString('utf-8'); + assert(!decodedPolicy.includes(expectedConditionString)); + + const signStub = BUCKET.storage.authClient.sign; + assert(!signStub.getCall(0).args[0].includes('x-ignore-foo')); + done(); + } + ); + }); + + it('should accept conditions', done => { + CONFIG = { + conditions: [['starts-with', '$key', 'prefix-']], + ...CONFIG, + }; + + file.generateSignedPostPolicyV4( + CONFIG, + (err: Error, res: SignedPostPolicyV4Output) => { + assert.ifError(err); + + const expectedConditionString = JSON.stringify(CONFIG.conditions); + const decodedPolicy = Buffer.from( + res.fields.policy, + 'base64' + ).toString('utf-8'); + assert(decodedPolicy.includes(expectedConditionString)); + + const signStub = BUCKET.storage.authClient.sign; + assert( + !signStub.getCall(0).args[0].includes(expectedConditionString) + ); + done(); + } + ); + }); + + it('should output url with cname', done => { + CONFIG.bucketBoundHostname = 'http://domain.tld'; + + file.generateSignedPostPolicyV4( + CONFIG, + (err: Error, res: SignedPostPolicyV4Output) => { + assert.ifError(err); + assert(res.url, CONFIG.bucketBoundHostname); + done(); + } + ); + }); + + it('should output a virtualHostedStyle url', done => { + CONFIG.virtualHostedStyle = true; + + file.generateSignedPostPolicyV4( + CONFIG, + (err: Error, res: SignedPostPolicyV4Output) => { + assert.ifError(err); + assert(res.url, `https://${BUCKET.name}.storage.googleapis.com/`); + done(); + } + ); + }); + + describe('expires', () => { + it('should accept Date objects', done => { + const expires = new Date(Date.now() + 1000 * 60); + + file.generateSignedPostPolicyV4( + { + expires, + }, + (err: Error, response: SignedPostPolicyV4Output) => { + assert.ifError(err); + const policy = JSON.parse( + Buffer.from(response.fields.policy, 'base64').toString() + ); + assert.strictEqual( + policy.expiration, + dateFormat.format(expires, 'YYYY-MM-DD[T]HH:mm:ss[Z]', true) + ); + done(); + } + ); + }); + + it('should accept numbers', done => { + const expires = Date.now() + 1000 * 60; + + file.generateSignedPostPolicyV4( + { + expires, + }, + (err: Error, response: SignedPostPolicyV4Output) => { + assert.ifError(err); + const policy = JSON.parse( + Buffer.from(response.fields.policy, 'base64').toString() + ); + assert.strictEqual( + policy.expiration, + dateFormat.format( + new Date(expires), + 'YYYY-MM-DD[T]HH:mm:ss[Z]', + true + ) + ); + done(); + } + ); + }); + + it('should accept strings', done => { + const expires = dateFormat.format( + new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), + 'YYYY-MM-DD', + true + ); + + file.generateSignedPostPolicyV4( + { + expires, + }, + (err: Error, response: SignedPostPolicyV4Output) => { + assert.ifError(err); + const policy = JSON.parse( + Buffer.from(response.fields.policy, 'base64').toString() + ); + assert.strictEqual( + policy.expiration, + dateFormat.format( + new Date(expires), + 'YYYY-MM-DD[T]HH:mm:ss[Z]', + true + ) + ); + done(); + } + ); + }); + + it('should throw if a date is invalid', () => { + const expires = new Date('31-12-2019'); + + assert.throws(() => { + file.generateSignedPostPolicyV4( + { + expires, + }, + () => {} + ); + }, /The expiration date provided was invalid\./); + }); + + it('should throw if a date from the past is given', () => { + const expires = Date.now() - 5; + + assert.throws(() => { + file.generateSignedPostPolicyV4( + { + expires, + }, + () => {} + ); + }, /An expiration date cannot be in the past\./); + }); + + it('should throw if a date beyond 7 days is given', () => { + const expires = Date.now() + 7.1 * 24 * 60 * 60 * 1000; + + assert.throws( + () => { + file.generateSignedPostPolicyV4( + { + expires, + }, + () => {} + ); + }, + {message: `Max allowed expiration is seven days (604800 seconds).`} + ); + }); + }); + }); + describe('getSignedUrl', () => { const EXPECTED_SIGNED_URL = 'signed-url'; const CNAME = 'https://www.example.com';