From 32d8fface1a994cb5ac928f08c0467edc3c9aab1 Mon Sep 17 00:00:00 2001 From: JesseLovelace <43148100+JesseLovelace@users.noreply.github.com> Date: Thu, 30 Apr 2020 11:36:56 -0700 Subject: [PATCH] feat: V4 POST policy (#177) * Update V4 signature to pass conformance tests * unrevert InternalExtensionOnly * revert accidental deletion of comment updates * revert fix part 2 * revert fix part 3 Co-authored-by: Frank Natividad --- .../clirr-ignored-differences.xml | 24 ++ google-cloud-storage/pom.xml | 15 +- .../google/cloud/storage/PostPolicyV4.java | 389 ++++++++++++++++++ .../com/google/cloud/storage/Storage.java | 227 +++++++++- .../com/google/cloud/storage/StorageImpl.java | 140 +++++++ .../google/cloud/storage/StorageImplTest.java | 76 ++++ .../cloud/storage/V4PostPolicyTest.java | 246 +++++++++++ .../cloud/storage/it/ITStorageTest.java | 35 ++ pom.xml | 23 ++ 9 files changed, 1158 insertions(+), 17 deletions(-) create mode 100644 google-cloud-storage/clirr-ignored-differences.xml create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java create mode 100644 google-cloud-storage/src/test/java/com/google/cloud/storage/V4PostPolicyTest.java diff --git a/google-cloud-storage/clirr-ignored-differences.xml b/google-cloud-storage/clirr-ignored-differences.xml new file mode 100644 index 0000000000..ae8d781dbe --- /dev/null +++ b/google-cloud-storage/clirr-ignored-differences.xml @@ -0,0 +1,24 @@ + + + + + com/google/cloud/storage/Storage + com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.PostPolicyV4$PostFieldsV4, com.google.cloud.storage.PostPolicyV4$PostConditionsV4, com.google.cloud.storage.Storage$PostPolicyV4Option[]) + 7012 + + + com/google/cloud/storage/Storage + com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.PostPolicyV4$PostFieldsV4, com.google.cloud.storage.Storage$PostPolicyV4Option[]) + 7012 + + + com/google/cloud/storage/Storage + com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.PostPolicyV4$PostConditionsV4, com.google.cloud.storage.Storage$PostPolicyV4Option[]) + 7012 + + + com/google/cloud/storage/Storage + com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.Storage$PostPolicyV4Option[]) + 7012 + + \ No newline at end of file diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml index 1a52a00329..67da3521e2 100644 --- a/google-cloud-storage/pom.xml +++ b/google-cloud-storage/pom.xml @@ -40,6 +40,10 @@ com.google.apis google-api-services-storage + + com.google.code.gson + gson + com.google.cloud google-cloud-core @@ -174,7 +178,16 @@ org.apache.httpcomponents httpclient - 4.5.12 + test + + + org.apache.httpcomponents + httpmime + test + + + org.apache.httpcomponents + httpcore test diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java new file mode 100644 index 0000000000..df2936dc9a --- /dev/null +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java @@ -0,0 +1,389 @@ +/* + * 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. + */ + +package com.google.cloud.storage; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.text.SimpleDateFormat; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Presigned V4 post policy. + * + * @see POST Object + */ +public final class PostPolicyV4 { + private String url; + private Map fields; + + private PostPolicyV4(String url, Map fields) { + this.url = url; + this.fields = fields; + } + + public static PostPolicyV4 of(String url, Map fields) { + return new PostPolicyV4(url, fields); + } + + public String getUrl() { + return url; + } + + public Map getFields() { + return fields; + } + + /** + * Class representing which fields to specify in a V4 POST request. + * + * @see POST + * Object Form fields + */ + public static final class PostFieldsV4 { + private Map fieldsMap; + + private PostFieldsV4(Builder builder) { + this.fieldsMap = builder.fieldsMap; + } + + private PostFieldsV4(Map fields) { + this.fieldsMap = fields; + } + + public static PostFieldsV4 of(Map fields) { + return new PostFieldsV4(fields); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Map getFieldsMap() { + return fieldsMap; + } + + public static class Builder { + private Map fieldsMap; + + private Builder() { + fieldsMap = new HashMap<>(); + } + + public PostFieldsV4 build() { + return new PostFieldsV4(this); + } + + public Builder setAcl(String acl) { + fieldsMap.put("acl", acl); + return this; + } + + public Builder setCacheControl(String cacheControl) { + fieldsMap.put("cache-control", cacheControl); + return this; + } + + public Builder setContentDisposition(String contentDisposition) { + fieldsMap.put("content-disposition", contentDisposition); + return this; + } + + public Builder setContentEncoding(String contentEncoding) { + fieldsMap.put("content-encoding", contentEncoding); + return this; + } + + public Builder setContentLength(int contentLength) { + fieldsMap.put("content-length", "" + contentLength); + return this; + } + + public Builder setContentType(String contentType) { + fieldsMap.put("content-type", contentType); + return this; + } + + public Builder Expires(String expires) { + fieldsMap.put("expires", expires); + return this; + } + + public Builder setSuccessActionRedirect(String successActionRedirect) { + fieldsMap.put("success_action_redirect", successActionRedirect); + return this; + } + + public Builder setSuccessActionStatus(int successActionStatus) { + fieldsMap.put("success_action_status", "" + successActionStatus); + return this; + } + + public Builder AddCustomMetadataField(String field, String value) { + fieldsMap.put("x-goog-meta-" + field, value); + return this; + } + } + } + + /** + * Class for specifying conditions in a V4 POST Policy document. + * + * @see + * Policy document + */ + public static final class PostConditionsV4 { + private Set conditions; + + private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + + public PostConditionsV4(Builder builder) { + this.conditions = builder.conditions; + } + + public Builder toBuilder() { + return new Builder(conditions); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Set getConditions() { + return conditions; + } + + public static class Builder { + Set conditions; + + private Builder() { + this.conditions = new LinkedHashSet<>(); + } + + private Builder(Set conditions) { + this.conditions = conditions; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public PostConditionsV4 build() { + return new PostConditionsV4(this); + } + + public Builder addAclCondition(ConditionV4Type type, String acl) { + conditions.add(new ConditionV4(type, "acl", acl)); + return this; + } + + public Builder addBucketCondition(ConditionV4Type type, String bucket) { + conditions.add(new ConditionV4(type, "bucket", bucket)); + return this; + } + + public Builder addCacheControlCondition(ConditionV4Type type, String cacheControl) { + conditions.add(new ConditionV4(type, "cache-control", cacheControl)); + return this; + } + + public Builder addContentDispositionCondition( + ConditionV4Type type, String contentDisposition) { + conditions.add(new ConditionV4(type, "content-disposition", contentDisposition)); + return this; + } + + public Builder addContentEncodingCondition(ConditionV4Type type, String contentEncoding) { + conditions.add(new ConditionV4(type, "content-encoding", contentEncoding)); + return this; + } + + public Builder addContentLengthCondition(ConditionV4Type type, int contentLength) { + conditions.add(new ConditionV4(type, "content-length", "" + contentLength)); + return this; + } + + public Builder addContentTypeCondition(ConditionV4Type type, String contentType) { + conditions.add(new ConditionV4(type, "content-type", contentType)); + return this; + } + + public Builder addExpiresCondition(ConditionV4Type type, long expires) { + conditions.add(new ConditionV4(type, "expires", dateFormat.format(expires))); + return this; + } + + public Builder addExpiresCondition(ConditionV4Type type, String expires) { + conditions.add(new ConditionV4(type, "expires", expires)); + return this; + } + + public Builder addKeyCondition(ConditionV4Type type, String key) { + conditions.add(new ConditionV4(type, "key", key)); + return this; + } + + public Builder addSuccessActionRedirectUrlCondition( + ConditionV4Type type, String successActionRedirectUrl) { + conditions.add(new ConditionV4(type, "success_action_redirect", successActionRedirectUrl)); + return this; + } + + public Builder addSuccessActionStatusCondition(ConditionV4Type type, int status) { + conditions.add(new ConditionV4(type, "success_action_status", "" + status)); + return this; + } + + public Builder addContentLengthRangeCondition(int min, int max) { + conditions.add(new ConditionV4(ConditionV4Type.CONTENT_LENGTH_RANGE, "" + min, "" + max)); + return this; + } + + Builder addCustomCondition(ConditionV4Type type, String field, String value) { + conditions.add(new ConditionV4(type, field, value)); + return this; + } + } + } + + /** + * Class for a V4 POST Policy document. + * + * @see + * Policy document + */ + public static final class PostPolicyV4Document { + private String expiration; + private PostConditionsV4 conditions; + + private PostPolicyV4Document(String expiration, PostConditionsV4 conditions) { + this.expiration = expiration; + this.conditions = conditions; + } + + public static PostPolicyV4Document of(String expiration, PostConditionsV4 conditions) { + return new PostPolicyV4Document(expiration, conditions); + } + + public String toJson() { + JsonObject object = new JsonObject(); + JsonArray conditions = new JsonArray(); + for (ConditionV4 condition : this.conditions.conditions) { + switch (condition.type) { + case MATCHES: + JsonObject match = new JsonObject(); + match.addProperty(condition.operand1, condition.operand2); + conditions.add(match); + break; + case STARTS_WITH: + JsonArray startsWith = new JsonArray(); + startsWith.add("starts-with"); + startsWith.add("$" + condition.operand1); + startsWith.add(condition.operand2); + conditions.add(startsWith); + break; + case CONTENT_LENGTH_RANGE: + JsonArray contentLengthRange = new JsonArray(); + contentLengthRange.add("content-length-range"); + contentLengthRange.add(Integer.parseInt(condition.operand1)); + contentLengthRange.add(Integer.parseInt(condition.operand2)); + conditions.add(contentLengthRange); + break; + } + } + object.add("conditions", conditions); + object.addProperty("expiration", expiration); + + String json = object.toString(); + StringBuilder escapedJson = new StringBuilder(); + + // Certain characters in a policy must be escaped + for (char c : json.toCharArray()) { + if (c >= 128) { // is a unicode character + escapedJson.append(String.format("\\u%04x", (int) c)); + } else { + switch (c) { + case '\\': + escapedJson.append("\\\\"); + break; + case '\b': + escapedJson.append("\\b"); + break; + case '\f': + escapedJson.append("\\f"); + break; + case '\n': + escapedJson.append("\\n"); + break; + case '\r': + escapedJson.append("\\r"); + break; + case '\t': + escapedJson.append("\\t"); + break; + case '\u000b': + escapedJson.append("\\v"); + break; + default: + escapedJson.append(c); + } + } + } + return escapedJson.toString(); + } + } + + public enum ConditionV4Type { + MATCHES, + STARTS_WITH, + CONTENT_LENGTH_RANGE + } + + /** + * Class for a specific POST policy document condition. + * + * @see + * Policy document + */ + static final class ConditionV4 { + ConditionV4Type type; + String operand1; + String operand2; + + private ConditionV4(ConditionV4Type type, String operand1, String operand2) { + this.type = type; + this.operand1 = operand1; + this.operand2 = operand2; + } + + @Override + public boolean equals(Object other) { + ConditionV4 condition = (ConditionV4) other; + return this.type == condition.type + && this.operand1.equals(condition.operand1) + && this.operand2.equals(condition.operand2); + } + + @Override + public int hashCode() { + return Objects.hash(type, operand1, operand2); + } + } +} diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java index eb22eddc11..38794cd4ba 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java @@ -32,6 +32,8 @@ import com.google.cloud.WriteChannel; import com.google.cloud.storage.Acl.Entity; import com.google.cloud.storage.HmacKey.HmacKeyMetadata; +import com.google.cloud.storage.PostPolicyV4.PostConditionsV4; +import com.google.cloud.storage.PostPolicyV4.PostFieldsV4; import com.google.cloud.storage.spi.v1.StorageRpc; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; @@ -1041,7 +1043,7 @@ public static BlobListOption userProject(String userProject) { /** * If set to {@code true}, lists all versions of a blob. The default is {@code false}. * - * @see Object Versioning + * @see Object Versioning */ public static BlobListOption versions(boolean versions) { return new BlobListOption(StorageRpc.Option.VERSIONS, versions); @@ -1060,6 +1062,107 @@ public static BlobListOption fields(BlobField... fields) { } } + /** Class for specifying Post Policy V4 options. * */ + class PostPolicyV4Option implements Serializable { + private static final long serialVersionUID = 8150867146534084543L; + private final PostPolicyV4Option.Option option; + private final Object value; + + enum Option { + PATH_STYLE, + VIRTUAL_HOSTED_STYLE, + BUCKET_BOUND_HOST_NAME, + SERVICE_ACCOUNT_CRED + } + + private PostPolicyV4Option(Option option, Object value) { + this.option = option; + this.value = value; + } + + PostPolicyV4Option.Option getOption() { + return option; + } + + Object getValue() { + return value; + } + + /** + * Provides a service account signer to sign the policy. If not provided an attempt is made to + * get it from the environment. + * + * @see Service + * Accounts + */ + public static PostPolicyV4Option signWith(ServiceAccountSigner signer) { + return new PostPolicyV4Option(PostPolicyV4Option.Option.SERVICE_ACCOUNT_CRED, signer); + } + + /** + * Use a virtual hosted-style hostname, which adds the bucket into the host portion of the URI + * rather than the path, e.g. 'https://mybucket.storage.googleapis.com/...'. The bucket name is + * obtained from the resource passed in. + * + * @see Request Endpoints + */ + public static PostPolicyV4Option withVirtualHostedStyle() { + return new PostPolicyV4Option(PostPolicyV4Option.Option.VIRTUAL_HOSTED_STYLE, ""); + } + + /** + * Generates a path-style URL, which places the bucket name in the path portion of the URL + * instead of in the hostname, e.g 'https://storage.googleapis.com/mybucket/...'. Note that this + * cannot be used alongside {@code withVirtualHostedStyle()}. Virtual hosted-style URLs, which + * can be used via the {@code withVirtualHostedStyle()} method, should generally be preferred + * instead of path-style URLs. + * + * @see Request Endpoints + */ + public static PostPolicyV4Option withPathStyle() { + return new PostPolicyV4Option(PostPolicyV4Option.Option.PATH_STYLE, ""); + } + + /** + * Use a bucket-bound hostname, which replaces the storage.googleapis.com host with the name of + * a CNAME bucket, e.g. a bucket named 'gcs-subdomain.my.domain.tld', or a Google Cloud Load + * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. Note that this + * cannot be used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. This + * method signature uses HTTP for the URI scheme, and is equivalent to calling {@code + * withBucketBoundHostname("...", UriScheme.HTTP).} + * + * @see CNAME + * Redirects + * @see + * GCLB Redirects + */ + public static PostPolicyV4Option withBucketBoundHostname(String bucketBoundHostname) { + return withBucketBoundHostname(bucketBoundHostname, Storage.UriScheme.HTTP); + } + + /** + * Use a bucket-bound hostname, which replaces the storage.googleapis.com host with the name of + * a CNAME bucket, e.g. a bucket named 'gcs-subdomain.my.domain.tld', or a Google Cloud Load + * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. Note that this + * cannot be used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. The + * bucket name itself should not include the URI scheme (http or https), so it is specified via + * a local enum. + * + * @see CNAME + * Redirects + * @see + * GCLB Redirects + */ + public static PostPolicyV4Option withBucketBoundHostname( + String bucketBoundHostname, Storage.UriScheme uriScheme) { + return new PostPolicyV4Option( + PostPolicyV4Option.Option.BUCKET_BOUND_HOST_NAME, + uriScheme.getScheme() + "://" + bucketBoundHostname); + } + } + /** Class for specifying signed URL options. */ class SignUrlOption implements Serializable { @@ -1154,8 +1257,8 @@ public static SignUrlOption withV4Signature() { } /** - * Provides a service account signer to sign the URL. If not provided an attempt will be made to - * get it from the environment. + * Provides a service account signer to sign the URL. If not provided an attempt is made to get + * it from the environment. * * @see Service * Accounts @@ -1167,7 +1270,7 @@ public static SignUrlOption signWith(ServiceAccountSigner signer) { /** * Use a different host name than the default host name 'storage.googleapis.com'. This option is * particularly useful for developers to point requests to an alternate endpoint (e.g. a staging - * environment or sending requests through VPC). Note that if using this with the {@code + * environment or sending requests through VPC). If using this with the {@code * withVirtualHostedStyle()} method, you should omit the bucket name from the hostname, as it * automatically gets prepended to the hostname for virtual hosted-style URLs. */ @@ -1177,10 +1280,10 @@ public static SignUrlOption withHostName(String hostName) { /** * Use a virtual hosted-style hostname, which adds the bucket into the host portion of the URI - * rather than the path, e.g. 'https://mybucket.storage.googleapis.com/...'. The bucket name - * will be obtained from the resource passed in. For V4 signing, this also sets the "host" - * header in the canonicalized extension headers to the virtual hosted-style host, unless that - * header is supplied via the {@code withExtHeaders()} method. + * rather than the path, e.g. 'https://mybucket.storage.googleapis.com/...'. The bucket name is + * obtained from the resource passed in. For V4 signing, this also sets the "host" header in the + * canonicalized extension headers to the virtual hosted-style host, unless that header is + * supplied via the {@code withExtHeaders()} method. * * @see Request Endpoints */ @@ -1189,11 +1292,11 @@ public static SignUrlOption withVirtualHostedStyle() { } /** - * Generate a path-style URL, which places the bucket name in the path portion of the URL - * instead of in the hostname, e.g 'https://storage.googleapis.com/mybucket/...'. Note that this - * cannot be used alongside {@code withVirtualHostedStyle()}. Virtual hosted-style URLs, which - * can be used via the {@code withVirtualHostedStyle()} method, should generally be preferred - * instead of path-style URLs. + * Generates a path-style URL, which places the bucket name in the path portion of the URL + * instead of in the hostname, e.g 'https://storage.googleapis.com/mybucket/...'. This cannot be + * used alongside {@code withVirtualHostedStyle()}. Virtual hosted-style URLs, which can be used + * via the {@code withVirtualHostedStyle()} method, should generally be preferred instead of + * path-style URLs. * * @see Request Endpoints */ @@ -1204,9 +1307,9 @@ public static SignUrlOption withPathStyle() { /** * Use a bucket-bound hostname, which replaces the storage.googleapis.com host with the name of * a CNAME bucket, e.g. a bucket named 'gcs-subdomain.my.domain.tld', or a Google Cloud Load - * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. Note that this - * cannot be used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. This - * method signature uses HTTP for the URI scheme, and is equivalent to calling {@code + * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. This cannot be + * used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. This method + * signature uses HTTP for the URI scheme, and is equivalent to calling {@code * withBucketBoundHostname("...", UriScheme.HTTP).} * * @see CNAME @@ -1896,6 +1999,7 @@ Blob create( * only if supplied Decrpytion Key decrypts the blob successfully, otherwise a {@link * StorageException} is thrown. For more information review * + * @throws StorageException upon failure * @see Encrypted * Elements @@ -2547,6 +2651,96 @@ Blob create( */ URL signUrl(BlobInfo blobInfo, long duration, TimeUnit unit, SignUrlOption... options); + /** + * Generates a URL and a map of fields that can be specified in an HTML form to submit a POST + * request. The returned map includes a signature which must be provided with the request. + * Generating a presigned POST policy requires a service account signer. If an instance of {@link + * com.google.auth.ServiceAccountSigner} was passed to {@link StorageOptions}' builder via {@code + * setCredentials(Credentials)} or the default credentials are being used and the environment + * variable {@code GOOGLE_APPLICATION_CREDENTIALS} is set, generatPresignedPostPolicyV4 will use + * that credentials to sign the URL. If the credentials passed to {@link StorageOptions} do not + * implement {@link ServiceAccountSigner} (this is the case, for instance, for Google Cloud SDK + * credentials) then {@code signUrl} will throw an {@link IllegalStateException} unless an + * implementation of {@link ServiceAccountSigner} is passed using the {@link + * PostPolicyV4Option#signWith(ServiceAccountSigner)} option. + * + *

Example of generating a presigned post policy which has the condition that only jpeg images + * can be uploaded, and applies the public read acl to each image uploaded, and making the POST + * request: + * + *

{@code
+   * PostFieldsV4 fields = PostFieldsV4.newBuilder().setAcl("public-read").build();
+   * PostConditionsV4 conditions = PostConditionsV4.newBuilder().addContentTypeCondition(ConditionV4Type.MATCHES, "image/jpeg").build();
+   *
+   * PostPolicyV4 policy = storage.generateSignedPostPolicyV4(
+   *     BlobInfo.newBuilder("my-bucket", "my-object").build(),
+   *     7, TimeUnit.DAYS, fields, conditions);
+   *
+   * HttpClient client = HttpClientBuilder.create().build();
+   * HttpPost request = new HttpPost(policy.getUrl());
+   * MultipartEntityBuilder builder = MultipartEntityBuilder.create();
+   *
+   * for (Map.Entry entry : policy.getFields().entrySet()) {
+   *     builder.addTextBody(entry.getKey(), entry.getValue());
+   * }
+   * File file = new File("path/to/your/file/to/upload");
+   * builder.addBinaryBody("file", new FileInputStream(file), ContentType.APPLICATION_OCTET_STREAM, file.getName());
+   * request.setEntity(builder.build());
+   * client.execute(request);
+   * }
+ * + * @param blobInfo the blob uploaded in the form + * @param fields the fields specified in the form + * @param conditions which conditions every upload must satisfy + * @param duration how long until the form expires, in milliseconds + * @param options optional post policy options + * @see POST + * Object + */ + PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, + long duration, + TimeUnit unit, + PostFieldsV4 fields, + PostConditionsV4 conditions, + PostPolicyV4Option... options); + + /** + * Generates a presigned post policy without any conditions. Automatically creates required + * conditions. See full documentation for generateSignedPostPolicyV4( BlobInfo blobInfo, long + * duration, TimeUnit unit, PostFieldsV4 fields, PostConditionsV4 conditions, + * PostPolicyV4Option... options) above. + */ + PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, + long duration, + TimeUnit unit, + PostFieldsV4 fields, + PostPolicyV4Option... options); + + /** + * Generates a presigned post policy without any fields. Automatically creates required fields. + * See full documentation for generateSignedPostPolicyV4( BlobInfo blobInfo, long duration, + * TimeUnit unit, PostFieldsV4 fields, PostConditionsV4 conditions, PostPolicyV4Option... options) + * above. + */ + PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, + long duration, + TimeUnit unit, + PostConditionsV4 conditions, + PostPolicyV4Option... options); + + /** + * Generates a presigned post policy without any fields or conditions. Automatically creates + * required fields and conditions. See full documentation for generateSignedPostPolicyV4( BlobInfo + * blobInfo, long duration, TimeUnit unit, PostFieldsV4 fields, PostConditionsV4 conditions, + * PostPolicyV4Option... options) above. + */ + PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, long duration, TimeUnit unit, PostPolicyV4Option... options); + /** * Gets the requested blobs. A batch request is used to perform this call. * @@ -3161,6 +3355,7 @@ HmacKeyMetadata updateHmacKeyState( final HmacKeyMetadata hmacKeyMetadata, final HmacKey.HmacKeyState state, UpdateHmacKeyOption... options); + /** * Gets the IAM policy for the provided bucket. * diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java index 0f38e38272..0e24521eb6 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java @@ -50,6 +50,10 @@ import com.google.cloud.Tuple; import com.google.cloud.storage.Acl.Entity; import com.google.cloud.storage.HmacKey.HmacKeyMetadata; +import com.google.cloud.storage.PostPolicyV4.ConditionV4Type; +import com.google.cloud.storage.PostPolicyV4.PostConditionsV4; +import com.google.cloud.storage.PostPolicyV4.PostFieldsV4; +import com.google.cloud.storage.PostPolicyV4.PostPolicyV4Document; import com.google.cloud.storage.spi.v1.StorageRpc; import com.google.cloud.storage.spi.v1.StorageRpc.RewriteResponse; import com.google.common.base.CharMatcher; @@ -72,12 +76,15 @@ import java.net.URI; import java.net.URL; import java.net.URLEncoder; +import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Collections; import java.util.EnumMap; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TimeZone; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; @@ -733,6 +740,139 @@ public URL signUrl(BlobInfo blobInfo, long duration, TimeUnit unit, SignUrlOptio } } + @Override + public PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, + long duration, + TimeUnit unit, + PostFieldsV4 fields, + PostConditionsV4 conditions, + PostPolicyV4Option... options) { + EnumMap optionMap = Maps.newEnumMap(SignUrlOption.Option.class); + // Convert to a map of SignUrlOptions so we can re-use some utility methods + for (PostPolicyV4Option option : options) { + optionMap.put(SignUrlOption.Option.valueOf(option.getOption().name()), option.getValue()); + } + + optionMap.put(SignUrlOption.Option.SIGNATURE_VERSION, SignUrlOption.SignatureVersion.V4); + + ServiceAccountSigner credentials = + (ServiceAccountSigner) optionMap.get(SignUrlOption.Option.SERVICE_ACCOUNT_CRED); + if (credentials == null) { + checkState( + this.getOptions().getCredentials() instanceof ServiceAccountSigner, + "Signing key was not provided and could not be derived"); + credentials = (ServiceAccountSigner) this.getOptions().getCredentials(); + } + + checkArgument( + !(optionMap.containsKey(SignUrlOption.Option.VIRTUAL_HOSTED_STYLE) + && optionMap.containsKey(SignUrlOption.Option.PATH_STYLE) + && optionMap.containsKey(SignUrlOption.Option.BUCKET_BOUND_HOST_NAME)), + "Only one of VIRTUAL_HOSTED_STYLE, PATH_STYLE, or BUCKET_BOUND_HOST_NAME SignUrlOptions can be" + + " specified."); + + String bucketName = slashlessBucketNameFromBlobInfo(blobInfo); + + boolean usePathStyle = shouldUsePathStyleForSignedUrl(optionMap); + + String url; + + if (usePathStyle) { + url = STORAGE_XML_URI_SCHEME + "://" + STORAGE_XML_URI_HOST_NAME + "/" + bucketName + "/"; + } else { + url = STORAGE_XML_URI_SCHEME + "://" + bucketName + "." + STORAGE_XML_URI_HOST_NAME + "/"; + } + + if (optionMap.containsKey(SignUrlOption.Option.BUCKET_BOUND_HOST_NAME)) { + url = optionMap.get(SignUrlOption.Option.BUCKET_BOUND_HOST_NAME) + "/"; + } + + SimpleDateFormat googDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'"); + SimpleDateFormat yearMonthDayFormat = new SimpleDateFormat("yyyyMMdd"); + SimpleDateFormat expirationFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + googDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + yearMonthDayFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + expirationFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + + long timestamp = getOptions().getClock().millisTime(); + String date = googDateFormat.format(timestamp); + String signingCredential = + credentials.getAccount() + + "/" + + yearMonthDayFormat.format(timestamp) + + "/auto/storage/goog4_request"; + + Map policyFields = new HashMap<>(); + + PostConditionsV4.Builder conditionsBuilder = conditions.toBuilder(); + + for (Map.Entry entry : fields.getFieldsMap().entrySet()) { + // Every field needs a corresponding policy condition, so add them if they're missing + conditionsBuilder.addCustomCondition( + ConditionV4Type.MATCHES, entry.getKey(), entry.getValue()); + + policyFields.put(entry.getKey(), entry.getValue()); + } + + PostConditionsV4 v4Conditions = + conditionsBuilder + .addBucketCondition(ConditionV4Type.MATCHES, blobInfo.getBucket()) + .addKeyCondition(ConditionV4Type.MATCHES, blobInfo.getName()) + .addCustomCondition(ConditionV4Type.MATCHES, "x-goog-date", date) + .addCustomCondition(ConditionV4Type.MATCHES, "x-goog-credential", signingCredential) + .addCustomCondition(ConditionV4Type.MATCHES, "x-goog-algorithm", "GOOG4-RSA-SHA256") + .build(); + PostPolicyV4Document document = + PostPolicyV4Document.of( + expirationFormat.format(timestamp + unit.toMillis(duration)), v4Conditions); + String policy = BaseEncoding.base64().encode(document.toJson().getBytes()); + String signature = + BaseEncoding.base16().encode(credentials.sign(policy.getBytes())).toLowerCase(); + + for (PostPolicyV4.ConditionV4 condition : v4Conditions.getConditions()) { + if (condition.type == ConditionV4Type.MATCHES) { + policyFields.put(condition.operand1, condition.operand2); + } + } + policyFields.put("key", blobInfo.getName()); + policyFields.put("x-goog-credential", signingCredential); + policyFields.put("x-goog-algorithm", "GOOG4-RSA-SHA256"); + policyFields.put("x-goog-date", date); + policyFields.put("x-goog-signature", signature); + policyFields.put("policy", policy); + + policyFields.remove("bucket"); + + return PostPolicyV4.of(url, policyFields); + } + + public PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, + long duration, + TimeUnit unit, + PostFieldsV4 fields, + PostPolicyV4Option... options) { + return generateSignedPostPolicyV4( + blobInfo, duration, unit, fields, PostConditionsV4.newBuilder().build(), options); + } + + public PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, + long duration, + TimeUnit unit, + PostConditionsV4 conditions, + PostPolicyV4Option... options) { + return generateSignedPostPolicyV4( + blobInfo, duration, unit, PostFieldsV4.newBuilder().build(), conditions, options); + } + + public PostPolicyV4 generateSignedPostPolicyV4( + BlobInfo blobInfo, long duration, TimeUnit unit, PostPolicyV4Option... options) { + return generateSignedPostPolicyV4( + blobInfo, duration, unit, PostFieldsV4.newBuilder().build(), options); + } + private String constructResourceUriPath( String slashlessBucketName, String escapedBlobName, diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java index 55295a9d94..3d590e0071 100644 --- a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java @@ -3044,4 +3044,80 @@ public void testWriterWithSignedURL() throws MalformedURLException { assertNotNull(writer); assertTrue(writer.isOpen()); } + + @Test + public void testV4PostPolicy() { + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(ACCOUNT) + .setPrivateKey(privateKey) + .build(); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + + PostPolicyV4.PostFieldsV4 fields = + PostPolicyV4.PostFieldsV4.newBuilder().setAcl("public-read").build(); + PostPolicyV4.PostConditionsV4 conditions = + PostPolicyV4.PostConditionsV4.newBuilder() + .addContentTypeCondition(PostPolicyV4.ConditionV4Type.MATCHES, "image/jpeg") + .build(); + + // test fields and conditions + PostPolicyV4 policy = + storage.generateSignedPostPolicyV4( + BlobInfo.newBuilder("my-bucket", "my-object").build(), + 7, + TimeUnit.DAYS, + fields, + conditions); + + Map outputFields = policy.getFields(); + + assertTrue(outputFields.containsKey("x-goog-date")); + assertTrue(outputFields.containsKey("x-goog-credential")); + assertTrue(outputFields.containsKey("x-goog-signature")); + assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256"); + assertEquals(outputFields.get("content-type"), "image/jpeg"); + assertEquals(outputFields.get("acl"), "public-read"); + assertEquals(outputFields.get("key"), "my-object"); + assertEquals("https://storage.googleapis.com/my-bucket/", policy.getUrl()); + + // test fields, no conditions + policy = + storage.generateSignedPostPolicyV4( + BlobInfo.newBuilder("my-bucket", "my-object").build(), 7, TimeUnit.DAYS, conditions); + outputFields = policy.getFields(); + + assertTrue(outputFields.containsKey("x-goog-date")); + assertTrue(outputFields.containsKey("x-goog-credential")); + assertTrue(outputFields.containsKey("x-goog-signature")); + assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256"); + assertEquals(outputFields.get("content-type"), "image/jpeg"); + assertEquals(outputFields.get("key"), "my-object"); + assertEquals("https://storage.googleapis.com/my-bucket/", policy.getUrl()); + + // test conditions, no fields + policy = + storage.generateSignedPostPolicyV4( + BlobInfo.newBuilder("my-bucket", "my-object").build(), 7, TimeUnit.DAYS, fields); + outputFields = policy.getFields(); + assertTrue(outputFields.containsKey("x-goog-date")); + assertTrue(outputFields.containsKey("x-goog-credential")); + assertTrue(outputFields.containsKey("x-goog-signature")); + assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256"); + assertEquals(outputFields.get("acl"), "public-read"); + assertEquals(outputFields.get("key"), "my-object"); + + // test no conditions no fields + policy = + storage.generateSignedPostPolicyV4( + BlobInfo.newBuilder("my-bucket", "my-object").build(), 7, TimeUnit.DAYS); + outputFields = policy.getFields(); + assertTrue(outputFields.containsKey("x-goog-date")); + assertTrue(outputFields.containsKey("x-goog-credential")); + assertTrue(outputFields.containsKey("x-goog-signature")); + assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256"); + assertEquals(outputFields.get("key"), "my-object"); + assertEquals("https://storage.googleapis.com/my-bucket/", policy.getUrl()); + } } diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/V4PostPolicyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/V4PostPolicyTest.java new file mode 100644 index 0000000000..161fb36404 --- /dev/null +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/V4PostPolicyTest.java @@ -0,0 +1,246 @@ +/* + * 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. + */ + +package com.google.cloud.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import com.google.api.core.ApiClock; +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.cloud.conformance.storage.v1.PolicyConditions; +import com.google.cloud.conformance.storage.v1.PolicyInput; +import com.google.cloud.conformance.storage.v1.PostPolicyV4Test; +import com.google.cloud.conformance.storage.v1.TestFile; +import com.google.cloud.conformance.storage.v1.UrlStyle; +import com.google.cloud.storage.testing.RemoteStorageHelper; +import com.google.common.base.Charsets; +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; +import com.google.protobuf.Timestamp; +import com.google.protobuf.util.JsonFormat; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public class V4PostPolicyTest { + + private static final String SERVICE_ACCOUNT_JSON_RESOURCE = + "com/google/cloud/conformance/storage/v1/test_service_account.not-a-test.json"; + private static final String TEST_DATA_JSON_RESOURCE = + "com/google/cloud/conformance/storage/v1/v4_signatures.json"; + + private static class FakeClock implements ApiClock { + private final AtomicLong currentNanoTime; + + public FakeClock(Timestamp timestamp) { + this.currentNanoTime = + new AtomicLong( + TimeUnit.NANOSECONDS.convert(timestamp.getSeconds(), TimeUnit.SECONDS) + + timestamp.getNanos()); + } + + public long nanoTime() { + return this.currentNanoTime.get(); + } + + public long millisTime() { + return TimeUnit.MILLISECONDS.convert(this.nanoTime(), TimeUnit.NANOSECONDS); + } + } + + @Rule public TestName testName = new TestName(); + + private final PostPolicyV4Test testData; + private final ServiceAccountCredentials serviceAccountCredentials; + + /** + * @param testData the serialized test data representing the test case. + * @param serviceAccountCredentials The credentials to use in this test. + * @param description Not used by the test, but used by the parameterized test runner as the name + * of the test. + */ + public V4PostPolicyTest( + PostPolicyV4Test testData, + ServiceAccountCredentials serviceAccountCredentials, + @SuppressWarnings("unused") String description) { + this.testData = testData; + this.serviceAccountCredentials = serviceAccountCredentials; + } + + @Test + public void test() { + Storage storage = + RemoteStorageHelper.create() + .getOptions() + .toBuilder() + .setCredentials(serviceAccountCredentials) + .setClock(new FakeClock(testData.getPolicyInput().getTimestamp())) + .build() + .getService(); + + BlobInfo blob = + BlobInfo.newBuilder( + testData.getPolicyInput().getBucket(), testData.getPolicyInput().getObject()) + .build(); + + PolicyInput policyInput = testData.getPolicyInput(); + PostPolicyV4.PostConditionsV4.Builder builder = PostPolicyV4.PostConditionsV4.newBuilder(); + + Map fields = policyInput.getFieldsMap(); + + PolicyConditions conditions = policyInput.getConditions(); + + if (!Strings.isNullOrEmpty(fields.get("success_action_redirect"))) { + builder.addSuccessActionRedirectUrlCondition( + PostPolicyV4.ConditionV4Type.MATCHES, fields.get("success_action_redirect")); + } + + if (!Strings.isNullOrEmpty(fields.get("success_action_status"))) { + builder.addSuccessActionStatusCondition( + PostPolicyV4.ConditionV4Type.MATCHES, + Integer.parseInt(fields.get("success_action_status"))); + } + + if (conditions != null) { + if (!conditions.getStartsWithList().isEmpty()) { + builder.addCustomCondition( + PostPolicyV4.ConditionV4Type.STARTS_WITH, + conditions.getStartsWith(0).replace("$", ""), + conditions.getStartsWith(1)); + } + if (!conditions.getContentLengthRangeList().isEmpty()) { + builder.addContentLengthRangeCondition( + conditions.getContentLengthRange(0), conditions.getContentLengthRange(1)); + } + } + + PostPolicyV4.PostFieldsV4 v4Fields = PostPolicyV4.PostFieldsV4.of(fields); + + Storage.PostPolicyV4Option style = Storage.PostPolicyV4Option.withPathStyle(); + + if (policyInput.getUrlStyle().equals(UrlStyle.VIRTUAL_HOSTED_STYLE)) { + style = Storage.PostPolicyV4Option.withVirtualHostedStyle(); + } else if (policyInput.getUrlStyle().equals(UrlStyle.PATH_STYLE)) { + style = Storage.PostPolicyV4Option.withPathStyle(); + } else if (policyInput.getUrlStyle().equals(UrlStyle.BUCKET_BOUND_HOSTNAME)) { + style = + Storage.PostPolicyV4Option.withBucketBoundHostname( + policyInput.getBucketBoundHostname(), + Storage.UriScheme.valueOf(policyInput.getScheme().toUpperCase())); + } + + PostPolicyV4 policy = + storage.generateSignedPostPolicyV4( + blob, + testData.getPolicyInput().getExpiration(), + TimeUnit.SECONDS, + v4Fields, + builder.build(), + style); + + String expectedPolicy = testData.getPolicyOutput().getExpectedDecodedPolicy(); + StringBuilder escapedPolicy = new StringBuilder(); + + // Java automatically unescapes the unicode escapes in the conformance tests, so we need to + // manually re-escape them + for (char c : expectedPolicy.toCharArray()) { + if (c >= 128) { + escapedPolicy.append(String.format("\\u%04x", (int) c)); + } else { + switch (c) { + case '\\': + escapedPolicy.append("\\\\"); + break; + case '\b': + escapedPolicy.append("\\b"); + break; + case '\f': + escapedPolicy.append("\\f"); + break; + case '\n': + escapedPolicy.append("\\n"); + break; + case '\r': + escapedPolicy.append("\\r"); + break; + case '\t': + escapedPolicy.append("\\t"); + break; + case '\u000b': + escapedPolicy.append("\\v"); + break; + default: + escapedPolicy.append(c); + } + } + } + assertEquals(testData.getPolicyOutput().getFieldsMap(), policy.getFields()); + assertEquals( + escapedPolicy.toString(), + new String(BaseEncoding.base64().decode(policy.getFields().get("policy")))); + assertEquals(testData.getPolicyOutput().getUrl(), policy.getUrl()); + } + + /** + * Loads all of the tests and return a {@code Collection} representing the set of tests. + * Each entry in the returned collection is the set of parameters to the constructor of this test + * class. + * + *

The results of this method will then be run by JUnit's Parameterized test runner + */ + @Parameters(name = "{2}") + public static Collection testCases() throws IOException { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + + InputStream credentialsStream = cl.getResourceAsStream(SERVICE_ACCOUNT_JSON_RESOURCE); + assertNotNull( + String.format("Unable to load service account json: %s", SERVICE_ACCOUNT_JSON_RESOURCE), + credentialsStream); + + InputStream dataJson = cl.getResourceAsStream(TEST_DATA_JSON_RESOURCE); + assertNotNull( + String.format("Unable to load test definition: %s", TEST_DATA_JSON_RESOURCE), dataJson); + + ServiceAccountCredentials serviceAccountCredentials = + ServiceAccountCredentials.fromStream(credentialsStream); + + InputStreamReader reader = new InputStreamReader(dataJson, Charsets.UTF_8); + TestFile.Builder testBuilder = TestFile.newBuilder(); + JsonFormat.parser().merge(reader, testBuilder); + TestFile testFile = testBuilder.build(); + + List tests = testFile.getPostPolicyV4TestsList(); + ArrayList data = new ArrayList<>(tests.size()); + for (PostPolicyV4Test test : tests) { + data.add(new Object[] {test, serviceAccountCredentials, test.getDescription()}); + } + return data; + } +} diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java index 52a6acd7e4..e1fb526031 100644 --- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java @@ -67,6 +67,8 @@ import com.google.cloud.storage.CopyWriter; import com.google.cloud.storage.HmacKey; import com.google.cloud.storage.HttpMethod; +import com.google.cloud.storage.PostPolicyV4; +import com.google.cloud.storage.PostPolicyV4.PostFieldsV4; import com.google.cloud.storage.ServiceAccount; import com.google.cloud.storage.Storage; import com.google.cloud.storage.Storage.BlobField; @@ -98,11 +100,14 @@ import io.grpc.stub.MetadataUtils; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.net.URLConnection; import java.nio.ByteBuffer; +import java.nio.file.Files; import java.security.Key; import java.util.ArrayList; import java.util.Arrays; @@ -120,6 +125,11 @@ import java.util.logging.Logger; import java.util.zip.GZIPInputStream; import javax.crypto.spec.SecretKeySpec; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.junit.AfterClass; @@ -3208,4 +3218,29 @@ public void testBucketLogging() throws ExecutionException, InterruptedException RemoteStorageHelper.forceDelete(storage, loggingBucket, 5, TimeUnit.SECONDS); } } + + @Test + public void testSignedPostPolicyV4() throws Exception { + PostFieldsV4 fields = PostFieldsV4.newBuilder().setAcl("public-read").build(); + + PostPolicyV4 policy = + storage.generateSignedPostPolicyV4( + BlobInfo.newBuilder(BUCKET, "my-object").build(), 7, TimeUnit.DAYS, fields); + + HttpClient client = HttpClientBuilder.create().build(); + HttpPost request = new HttpPost(policy.getUrl()); + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); + + for (Map.Entry entry : policy.getFields().entrySet()) { + builder.addTextBody(entry.getKey(), entry.getValue()); + } + File file = File.createTempFile("temp", "file"); + Files.write(file.toPath(), "hello world".getBytes()); + builder.addBinaryBody( + "file", new FileInputStream(file), ContentType.APPLICATION_OCTET_STREAM, file.getName()); + request.setEntity(builder.build()); + client.execute(request); + + assertEquals("hello world", new String(storage.get(BUCKET, "my-object").getContent())); + } } diff --git a/pom.xml b/pom.xml index 618b345868..08faa8cfc5 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,11 @@ pom import + + com.google.code.gson + gson + 2.8.6 + com.google.cloud google-cloud-core-bom @@ -221,6 +226,24 @@ 0.0.10 test + + org.apache.httpcomponents + httpclient + 4.5.12 + test + + + org.apache.httpcomponents + httpmime + 4.5.12 + test + + + org.apache.httpcomponents + httpcore + 4.4.13 + test +