From 705fb93e45a5d96770f8f9bc821415a002211d4b Mon Sep 17 00:00:00 2001 From: ravi-signal <99042880+ravi-signal@users.noreply.github.com> Date: Fri, 21 Jul 2023 12:09:45 -0500 Subject: [PATCH] Add v4 attachment controller Add AttachmentControllerV4 which can be configured to generate upload forms for a TUS based CDN --- service/config/sample-secrets-bundle.yml | 2 + service/config/sample.yml | 4 + .../WhisperServerConfiguration.java | 10 ++ .../textsecuregcm/WhisperServerService.java | 14 ++- .../attachments/AttachmentGenerator.java | 15 +++ .../attachments/GcsAttachmentGenerator.java | 54 ++++++++++ .../attachments/TusAttachmentGenerator.java | 47 +++++++++ .../attachments/TusConfiguration.java | 15 +++ .../controllers/AttachmentControllerV3.java | 34 ++----- .../controllers/AttachmentControllerV4.java | 99 +++++++++++++++++++ .../entities/AttachmentDescriptorV3.java | 17 +++- .../controllers/AttachmentControllerTest.java | 66 ++++++++++++- 12 files changed, 344 insertions(+), 33 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/attachments/AttachmentGenerator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/attachments/GcsAttachmentGenerator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusAttachmentGenerator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV4.java diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index b80b7a4a4..7f83c009a 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -11,6 +11,8 @@ directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789 svr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users svr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users +tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= + awsAttachments.accessKey: test awsAttachments.accessSecret: test diff --git a/service/config/sample.yml b/service/config/sample.yml index cb4a21d14..bfe046b2f 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -202,6 +202,10 @@ gcpAttachments: # GCP Storage configuration pathPrefix: rsaSigningKey: secret://gcpAttachments.rsaSigningKey +tus: + uploadUri: https://example.org/upload + userAuthenticationTokenSharedSecret: secret://tus.userAuthenticationTokenSharedSecret + accountDatabaseCrawler: chunkSize: 10 # accounts per run diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 1e4599027..ab5e733fe 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.Set; import javax.validation.Valid; import javax.validation.constraints.NotNull; +import org.whispersystems.textsecuregcm.attachments.TusConfiguration; import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration; import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration; import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; @@ -270,6 +271,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private TurnSecretConfiguration turn; + @Valid + @NotNull + @JsonProperty + private TusConfiguration tus; + @Valid @NotNull @JsonProperty @@ -454,6 +460,10 @@ public class WhisperServerConfiguration extends Configuration { return turn; } + public TusConfiguration getTus() { + return tus; + } + public int getGrpcPort() { return grpcPort; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 2939bab4c..72f44ebd0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -63,6 +63,8 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator; +import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator; import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator; @@ -89,6 +91,7 @@ import org.whispersystems.textsecuregcm.controllers.AccountControllerV2; import org.whispersystems.textsecuregcm.controllers.ArtController; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; +import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4; import org.whispersystems.textsecuregcm.controllers.CallLinkController; import org.whispersystems.textsecuregcm.controllers.CertificateController; import org.whispersystems.textsecuregcm.controllers.ChallengeController; @@ -596,6 +599,14 @@ public class WhisperServerService extends Application headers, String signedUploadLocation) {} + + Descriptor generateAttachment(final String key); + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/attachments/GcsAttachmentGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/attachments/GcsAttachmentGenerator.java new file mode 100644 index 000000000..177a5e03b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/attachments/GcsAttachmentGenerator.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.attachments; + +import org.whispersystems.textsecuregcm.gcp.CanonicalRequest; +import org.whispersystems.textsecuregcm.gcp.CanonicalRequestGenerator; +import org.whispersystems.textsecuregcm.gcp.CanonicalRequestSigner; +import javax.annotation.Nonnull; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.spec.InvalidKeySpecException; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Map; + +public class GcsAttachmentGenerator implements AttachmentGenerator { + @Nonnull + private final CanonicalRequestGenerator canonicalRequestGenerator; + + @Nonnull + private final CanonicalRequestSigner canonicalRequestSigner; + + public GcsAttachmentGenerator(@Nonnull String domain, @Nonnull String email, + int maxSizeInBytes, @Nonnull String pathPrefix, @Nonnull String rsaSigningKey) + throws IOException, InvalidKeyException, InvalidKeySpecException { + this.canonicalRequestGenerator = new CanonicalRequestGenerator(domain, email, maxSizeInBytes, pathPrefix); + this.canonicalRequestSigner = new CanonicalRequestSigner(rsaSigningKey); + } + + @Override + public Descriptor generateAttachment(final String key) { + final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + final CanonicalRequest canonicalRequest = canonicalRequestGenerator.createFor(key, now); + return new Descriptor(getHeaderMap(canonicalRequest), getSignedUploadLocation(canonicalRequest)); + } + + private String getSignedUploadLocation(@Nonnull CanonicalRequest canonicalRequest) { + return "https://" + canonicalRequest.getDomain() + canonicalRequest.getResourcePath() + + '?' + canonicalRequest.getCanonicalQuery() + + "&X-Goog-Signature=" + canonicalRequestSigner.sign(canonicalRequest); + } + + private static Map getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) { + return Map.of( + "host", canonicalRequest.getDomain(), + "x-goog-content-length-range", "1," + canonicalRequest.getMaxSizeInBytes(), + "x-goog-resumable", "start"); + } + + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusAttachmentGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusAttachmentGenerator.java new file mode 100644 index 000000000..9fc7a71c2 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusAttachmentGenerator.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.attachments; + +import org.apache.http.HttpHeaders; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.util.HeaderUtils; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Base64; +import java.util.Map; + +public class TusAttachmentGenerator implements AttachmentGenerator { + + private static final String ATTACHMENTS = "attachments"; + + final ExternalServiceCredentialsGenerator credentialsGenerator; + final String tusUri; + + public TusAttachmentGenerator(final TusConfiguration cfg) { + this.tusUri = cfg.uploadUri(); + this.credentialsGenerator = credentialsGenerator(Clock.systemUTC(), cfg); + } + + private static ExternalServiceCredentialsGenerator credentialsGenerator(final Clock clock, final TusConfiguration cfg) { + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .prependUsername(false) + .withClock(clock) + .build(); + } + + @Override + public Descriptor generateAttachment(final String key) { + final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(ATTACHMENTS + "/" + key); + final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8)); + final Map headers = Map.of( + HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials), + "Upload-Metadata", String.format("filename %s", b64Key) + ); + return new Descriptor(headers, tusUri + "/" + ATTACHMENTS); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusConfiguration.java new file mode 100644 index 000000000..5991c8ab3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusConfiguration.java @@ -0,0 +1,15 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.attachments; + +import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes; +import org.whispersystems.textsecuregcm.util.ExactlySize; +import javax.validation.constraints.NotEmpty; + +public record TusConfiguration( + @ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret, + @NotEmpty String uploadUri +){} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java index 23a59c480..8aa3cba69 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java @@ -21,6 +21,8 @@ import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; +import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator; +import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3; import org.whispersystems.textsecuregcm.gcp.CanonicalRequest; @@ -37,20 +39,15 @@ public class AttachmentControllerV3 { private final RateLimiter rateLimiter; @Nonnull - private final CanonicalRequestGenerator canonicalRequestGenerator; - - @Nonnull - private final CanonicalRequestSigner canonicalRequestSigner; + private final GcsAttachmentGenerator gcsAttachmentGenerator; @Nonnull private final SecureRandom secureRandom; - public AttachmentControllerV3(@Nonnull RateLimiters rateLimiters, @Nonnull String domain, @Nonnull String email, - int maxSizeInBytes, @Nonnull String pathPrefix, @Nonnull String rsaSigningKey) + public AttachmentControllerV3(@Nonnull RateLimiters rateLimiters, @Nonnull GcsAttachmentGenerator gcsAttachmentGenerator) throws IOException, InvalidKeyException, InvalidKeySpecException { this.rateLimiter = rateLimiters.getAttachmentLimiter(); - this.canonicalRequestGenerator = new CanonicalRequestGenerator(domain, email, maxSizeInBytes, pathPrefix); - this.canonicalRequestSigner = new CanonicalRequestSigner(rsaSigningKey); + this.gcsAttachmentGenerator = gcsAttachmentGenerator; this.secureRandom = new SecureRandom(); } @@ -61,26 +58,9 @@ public class AttachmentControllerV3 { public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedAccount auth) throws RateLimitExceededException { rateLimiter.validate(auth.getAccount().getUuid()); - - final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); final String key = generateAttachmentKey(); - final CanonicalRequest canonicalRequest = canonicalRequestGenerator.createFor(key, now); - - return new AttachmentDescriptorV3(2, key, getHeaderMap(canonicalRequest), - getSignedUploadLocation(canonicalRequest)); - } - - private String getSignedUploadLocation(@Nonnull CanonicalRequest canonicalRequest) { - return "https://" + canonicalRequest.getDomain() + canonicalRequest.getResourcePath() - + '?' + canonicalRequest.getCanonicalQuery() - + "&X-Goog-Signature=" + canonicalRequestSigner.sign(canonicalRequest); - } - - private static Map getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) { - return Map.of( - "host", canonicalRequest.getDomain(), - "x-goog-content-length-range", "1," + canonicalRequest.getMaxSizeInBytes(), - "x-goog-resumable", "start"); + final AttachmentGenerator.Descriptor descriptor = this.gcsAttachmentGenerator.generateAttachment(key); + return new AttachmentDescriptorV3(2, key, descriptor.headers(), descriptor.signedUploadLocation()); } private String generateAttachmentKey() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV4.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV4.java new file mode 100644 index 000000000..06028d146 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV4.java @@ -0,0 +1,99 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + +import com.codahale.metrics.annotation.Timed; +import io.dropwizard.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; +import javax.annotation.Nonnull; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator; +import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator; +import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; + + +/** + * The V4 API is identical to the {@link AttachmentControllerV3} API, but supports an additional TUS based cdn type (cdn3) + */ +@Path("/v4/attachments") +@Tag(name = "Attachments") +public class AttachmentControllerV4 { + + public static final String CDN3_EXPERIMENT_NAME = "cdn3"; + + private final ExperimentEnrollmentManager experimentEnrollmentManager; + private final RateLimiter rateLimiter; + + private final Map attachmentGenerators; + + @Nonnull + private final SecureRandom secureRandom; + + public AttachmentControllerV4( + final RateLimiters rateLimiters, + final GcsAttachmentGenerator gcsAttachmentGenerator, + final TusAttachmentGenerator tusAttachmentGenerator, + final ExperimentEnrollmentManager experimentEnrollmentManager) { + this.rateLimiter = rateLimiters.getAttachmentLimiter(); + this.experimentEnrollmentManager = experimentEnrollmentManager; + this.secureRandom = new SecureRandom(); + this.attachmentGenerators = Map.of( + 2, gcsAttachmentGenerator, + 3, tusAttachmentGenerator + ); + } + + @Timed + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/form/upload") + @Operation( + summary = "Get an upload form", + description = """ + Retrieve an upload form that can be used to perform a resumable upload. The response will include a cdn number + indicating what protocol should be used to perform the upload. + """ + ) + @ApiResponse(responseCode = "200", description = "Success, response body includes upload form", useReturnTypeSchema = true) + @ApiResponse(responseCode = "413", description = "Too many attempts", headers = @Header( + name = "Retry-After", + description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed")) + @ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header( + name = "Retry-After", + description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed")) + public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedAccount auth) + throws RateLimitExceededException { + rateLimiter.validate(auth.getAccount().getUuid()); + final String key = generateAttachmentKey(); + final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.getAccount().getUuid(), CDN3_EXPERIMENT_NAME); + int cdn = useCdn3 ? 3 : 2; + final AttachmentGenerator.Descriptor descriptor = this.attachmentGenerators.get(cdn).generateAttachment(key); + return new AttachmentDescriptorV3(cdn, key, descriptor.headers(), descriptor.signedUploadLocation()); + } + + private String generateAttachmentKey() { + final byte[] bytes = new byte[15]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().encodeToString(bytes); + } +} + diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java index a9a1f4ef2..c0aec7388 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java @@ -5,7 +5,22 @@ package org.whispersystems.textsecuregcm.entities; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.Map; -public record AttachmentDescriptorV3(int cdn, String key, Map headers, String signedUploadLocation) { +public record AttachmentDescriptorV3( + @Schema(description = """ + Indicates the CDN type. 2 in the v3 API, 2 or 3 in the v4 API. + 2 indicates resumable uploads using GCS, + 3 indicates resumable uploads using TUS + """) + int cdn, + @Schema(description = "The location within the specified cdn where the finished upload can be found") + String key, + @Schema(description = "A map of headers to include with all upload requests. Potentially contains time-limited upload credentials") + Map headers, + + @Schema(description = "The URL to upload to with the appropriate protocol") + String signedUploadLocation) { + } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerTest.java index 68f980215..3092d86af 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerTest.java @@ -26,6 +26,7 @@ import java.security.spec.InvalidKeySpecException; import java.util.Base64; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import javax.ws.rs.core.Response; import org.assertj.core.api.Assertions; import org.assertj.core.api.Condition; @@ -33,10 +34,15 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator; +import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator; +import org.whispersystems.textsecuregcm.attachments.TusConfiguration; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; +import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes; import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV2; import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; @@ -51,6 +57,17 @@ class AttachmentControllerTest { private static final RateLimiters RATE_LIMITERS = MockUtils.buildMock(RateLimiters.class, rateLimiters -> when(rateLimiters.getAttachmentLimiter()).thenReturn(RATE_LIMITER)); + + private static String CDN3_ENABLED_CREDS = AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD); + private static String CDN3_DISABLED_CREDS = AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO); + private static final ExperimentEnrollmentManager EXPERIMENT_MANAGER = MockUtils.buildMock(ExperimentEnrollmentManager.class, mgr -> { + when(mgr.isEnrolled(AuthHelper.VALID_UUID, AttachmentControllerV4.CDN3_EXPERIMENT_NAME)).thenReturn(true); + when(mgr.isEnrolled(AuthHelper.VALID_UUID_TWO, AttachmentControllerV4.CDN3_EXPERIMENT_NAME)).thenReturn(false); + }); + + private static final byte[] TUS_SECRET = getRandomBytes(32); + private static final String TUS_URL = "https://example.com/uploads"; + public static final String RSA_PRIVATE_KEY_PEM; static { @@ -67,10 +84,13 @@ class AttachmentControllerTest { } } + private static final ResourceExtension resources; static { try { + final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator("some-cdn.signal.org", + "signal@example.com", 1000, "/attach-here", RSA_PRIVATE_KEY_PEM); resources = ResourceExtension.builder() .addProvider(AuthHelper.getAuthFilter()) .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( @@ -78,13 +98,43 @@ class AttachmentControllerTest { .setMapper(SystemMapper.jsonMapper()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .addResource(new AttachmentControllerV2(RATE_LIMITERS, "accessKey", "accessSecret", "us-east-1", "attachmentv2-bucket")) - .addResource(new AttachmentControllerV3(RATE_LIMITERS, "some-cdn.signal.org", "signal@example.com", 1000, "/attach-here", RSA_PRIVATE_KEY_PEM)) - .build(); + .addResource(new AttachmentControllerV3(RATE_LIMITERS, gcsAttachmentGenerator)) + .addProvider(new AttachmentControllerV4(RATE_LIMITERS, + gcsAttachmentGenerator, + new TusAttachmentGenerator(new TusConfiguration( new SecretBytes(TUS_SECRET), TUS_URL)), + EXPERIMENT_MANAGER)) + .build(); } catch (IOException | InvalidKeyException | InvalidKeySpecException e) { throw new AssertionError(e); } } + @Test + void testV4TusForm() { + AttachmentDescriptorV3 descriptor = resources.getJerseyTest() + .target("/v4/attachments/form/upload") + .request() + .header("Authorization", CDN3_ENABLED_CREDS) + .get(AttachmentDescriptorV3.class); + assertThat(descriptor.cdn()).isEqualTo(3); + assertThat(descriptor.key()).isNotBlank(); + assertThat(descriptor.signedUploadLocation()).isEqualTo(TUS_URL + "/" + "attachments"); + final String filenameb64 = descriptor.headers().get("Upload-Metadata").split(" ")[1]; + final String filename = new String(Base64.getDecoder().decode(filenameb64)); + assertThat(descriptor.key()).isEqualTo(filename); + } + + @Test + void testV4GcsForm() { + AttachmentDescriptorV3 descriptor = resources.getJerseyTest() + .target("/v4/attachments/form/upload") + .request() + .header("Authorization", CDN3_DISABLED_CREDS) + .get(AttachmentDescriptorV3.class); + assertThat(descriptor.cdn()).isEqualTo(2); + assertValidCdn2Response(descriptor); + } + @Test void testV3Form() { AttachmentDescriptorV3 descriptor = resources.getJerseyTest() @@ -92,7 +142,10 @@ class AttachmentControllerTest { .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .get(AttachmentDescriptorV3.class); + assertValidCdn2Response(descriptor); + } + private static void assertValidCdn2Response(final AttachmentDescriptorV3 descriptor) { assertThat(descriptor.key()).isNotBlank(); assertThat(descriptor.cdn()).isEqualTo(2); assertThat(descriptor.headers()).hasSize(3); @@ -123,8 +176,8 @@ class AttachmentControllerTest { for (final String queryTerm : queryTerms) { final String[] keyValueArray = queryTerm.split("=", 2); queryParamMap.put( - URLDecoder.decode(keyValueArray[0], StandardCharsets.UTF_8), - URLDecoder.decode(keyValueArray[1], StandardCharsets.UTF_8)); + URLDecoder.decode(keyValueArray[0], StandardCharsets.UTF_8), + URLDecoder.decode(keyValueArray[1], StandardCharsets.UTF_8)); } assertThat(queryParamMap).extractingByKey("X-Goog-Algorithm").isEqualTo("GOOG4-RSA-SHA256"); @@ -191,4 +244,9 @@ class AttachmentControllerTest { assertThat(response.getStatus()).isEqualTo(401); } + private static byte[] getRandomBytes(int length) { + byte[] result = new byte[length]; + ThreadLocalRandom.current().nextBytes(result); + return result; + } }