diff --git a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 0bcff36df..2a0cad54d 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -131,9 +131,15 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private UnidentifiedDeliveryConfiguration unidentifiedDelivery; + @Valid @NotNull @JsonProperty - private Map hystrix = new HashMap<>(); + private VoiceVerificationConfiguration voiceVerification; + + + public VoiceVerificationConfiguration getVoiceVerificationConfiguration() { + return voiceVerification; + } public WebSocketConfiguration getWebSocketConfiguration() { return webSocket; diff --git a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 8d138d821..b6b715d95 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -38,6 +38,7 @@ import org.whispersystems.textsecuregcm.controllers.KeysController; import org.whispersystems.textsecuregcm.controllers.MessageController; import org.whispersystems.textsecuregcm.controllers.ProfileController; import org.whispersystems.textsecuregcm.controllers.ProvisioningController; +import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle; import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; @@ -213,6 +214,7 @@ public class WhisperServerService extends Application locales; + + public String getUrl() { + return url; + } + + public Set getLocales() { + return new HashSet<>(locales); + } +} diff --git a/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 2aaf5247a..800e3c25d 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -113,6 +113,7 @@ public class AccountController { public Response createAccount(@PathParam("transport") String transport, @PathParam("number") String number, @HeaderParam("X-Forwarded-For") String requester, + @HeaderParam("Accept-Language") Optional locale, @QueryParam("client") Optional client) throws IOException, RateLimitExceededException { @@ -151,7 +152,7 @@ public class AccountController { } else if (transport.equals("sms")) { smsSender.deliverSmsVerification(number, client, verificationCode.getVerificationCodeDisplay()); } else if (transport.equals("voice")) { - smsSender.deliverVoxVerification(number, verificationCode.getVerificationCodeSpeech()); + smsSender.deliverVoxVerification(number, verificationCode.getVerificationCode(), locale); } return Response.ok().build(); @@ -337,8 +338,7 @@ public class AccountController { @Path("/voice/twiml/{code}") @Produces(MediaType.APPLICATION_XML) public Response getTwiml(@PathParam("code") String encodedVerificationText) { - return Response.ok().entity(String.format(TwilioSmsSender.SAY_TWIML, - encodedVerificationText)).build(); + return Response.ok().entity(String.format(TwilioSmsSender.SAY_TWIML, encodedVerificationText)).build(); } private void createAccount(String number, String password, String userAgent, AccountAttributes accountAttributes) { diff --git a/src/main/java/org/whispersystems/textsecuregcm/controllers/VoiceVerificationController.java b/src/main/java/org/whispersystems/textsecuregcm/controllers/VoiceVerificationController.java new file mode 100644 index 000000000..aedc77cf8 --- /dev/null +++ b/src/main/java/org/whispersystems/textsecuregcm/controllers/VoiceVerificationController.java @@ -0,0 +1,108 @@ +package org.whispersystems.textsecuregcm.controllers; + +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.Optional; +import java.util.Set; + +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +@Path("/v1/voice/") +public class VoiceVerificationController { + + private static final String PLAY_TWIML = "\n" + + "\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " \n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " \n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + " %s\n" + + ""; + + + private final String baseUrl; + private final Set supportedLocales; + + public VoiceVerificationController(String baseUrl, Set supportedLocales) { + this.baseUrl = baseUrl; + this.supportedLocales = supportedLocales; + } + + @POST + @Path("/description/{code}") + @Produces(MediaType.APPLICATION_XML) + public Response getDescription(@PathParam("code") String code, @QueryParam("l") String locale) { + code = code.replaceAll("[^0-9]", ""); + + if (code.length() != 6) { + return Response.status(400).build(); + } + + if (locale != null && supportedLocales.contains(locale)) { + return getLocalizedDescription(code, locale); + } + + return getLocalizedDescription(code, "en-US"); + } + + private Response getLocalizedDescription(String code, String locale) { + String path = constructUrlForLocale(baseUrl, locale); + + return Response.ok() + .entity(String.format(PLAY_TWIML, + path + "verification.mp3", + path + code.charAt(0) + "_middle.mp3", + path + code.charAt(1) + "_middle.mp3", + path + code.charAt(2) + "_middle.mp3", + path + code.charAt(3) + "_middle.mp3", + path + code.charAt(4) + "_middle.mp3", + path + code.charAt(5) + "_falling.mp3", + path + "verification.mp3", + path + code.charAt(0) + "_middle.mp3", + path + code.charAt(1) + "_middle.mp3", + path + code.charAt(2) + "_middle.mp3", + path + code.charAt(3) + "_middle.mp3", + path + code.charAt(4) + "_middle.mp3", + path + code.charAt(5) + "_falling.mp3", + path + "verification.mp3", + path + code.charAt(0) + "_middle.mp3", + path + code.charAt(1) + "_middle.mp3", + path + code.charAt(2) + "_middle.mp3", + path + code.charAt(3) + "_middle.mp3", + path + code.charAt(4) + "_middle.mp3", + path + code.charAt(5) + "_falling.mp3")) + .build(); + } + + private String constructUrlForLocale(String baseUrl, String locale) { + if (!baseUrl.endsWith("/")) { + baseUrl += "/"; + } + + return baseUrl + locale + "/"; + } + +} diff --git a/src/main/java/org/whispersystems/textsecuregcm/sms/SmsSender.java b/src/main/java/org/whispersystems/textsecuregcm/sms/SmsSender.java index 8b62e0f53..64ab11ee3 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/sms/SmsSender.java +++ b/src/main/java/org/whispersystems/textsecuregcm/sms/SmsSender.java @@ -55,11 +55,11 @@ public class SmsSender { } } - public void deliverVoxVerification(String destination, String verificationCode) + public void deliverVoxVerification(String destination, String verificationCode, Optional locale) throws IOException { try { - twilioSender.deliverVoxVerification(destination, verificationCode); + twilioSender.deliverVoxVerification(destination, verificationCode, locale); } catch (TwilioRestException e) { logger.info("Twilio Vox Failed: " + e.getErrorMessage()); } diff --git a/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioSmsSender.java b/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioSmsSender.java index 481965d83..406dcb9ea 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioSmsSender.java +++ b/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioSmsSender.java @@ -97,15 +97,21 @@ public class TwilioSmsSender { smsMeter.mark(); } - public void deliverVoxVerification(String destination, String verificationCode) + public void deliverVoxVerification(String destination, String verificationCode, Optional locale) throws IOException, TwilioRestException { + String url = "https://" + localDomain + "/v1/voice/description/" + verificationCode; + + if (locale.isPresent()) { + url += "?l=" + locale.get(); + } + TwilioRestClient client = new TwilioRestClient(accountId, accountToken); CallFactory callFactory = client.getAccount().getCallFactory(); Map callParams = new HashMap<>(); callParams.put("To", destination); callParams.put("From", getRandom(random, numbers)); - callParams.put("Url", "https://" + localDomain + "/v1/accounts/voice/twiml/" + verificationCode); + callParams.put("Url", url); try { callFactory.create(callParams); diff --git a/src/main/java/org/whispersystems/textsecuregcm/util/VerificationCode.java b/src/main/java/org/whispersystems/textsecuregcm/util/VerificationCode.java index b4b559ed3..8489521a6 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/util/VerificationCode.java +++ b/src/main/java/org/whispersystems/textsecuregcm/util/VerificationCode.java @@ -35,9 +35,12 @@ public class VerificationCode { @VisibleForTesting VerificationCode() {} public VerificationCode(int verificationCode) { - this.verificationCode = verificationCode + ""; - this.verificationCodeDisplay = this.verificationCode.substring(0, 3) + "-" + - this.verificationCode.substring(3, 6); + this(verificationCode + ""); + } + + public VerificationCode(String verificationCode) { + this.verificationCode = verificationCode; + this.verificationCodeDisplay = this.verificationCode.substring(0, 3) + "-" + this.verificationCode.substring(3, 6); this.verificationCodeSpeech = delimit(verificationCode + ""); } diff --git a/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/VoiceVerificationControllerTest.java b/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/VoiceVerificationControllerTest.java new file mode 100644 index 000000000..e39a4a381 --- /dev/null +++ b/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/VoiceVerificationControllerTest.java @@ -0,0 +1,96 @@ +package org.whispersystems.textsecuregcm.tests.controllers; + +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.Rule; +import org.junit.Test; +import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController; +import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +import javax.ws.rs.core.Response; +import java.util.Collections; + +import io.dropwizard.auth.AuthValueFactoryProvider; +import io.dropwizard.testing.FixtureHelpers; +import io.dropwizard.testing.junit.ResourceTestRule; +import static org.assertj.core.api.Assertions.assertThat; + +public class VoiceVerificationControllerTest { + + @Rule + public final ResourceTestRule resources = ResourceTestRule.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new AuthValueFactoryProvider.Binder<>(Account.class)) + .addProvider(new RateLimitExceededExceptionMapper()) + .setMapper(SystemMapper.getMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new VoiceVerificationController("https://foo.com/bar", + Collections.singleton("pt-BR"))) + .build(); + + @Test + public void testTwimlLocale() { + Response response = + resources.getJerseyTest() + .target("/v1/voice/description/123456") + .queryParam("l", "pt-BR") + .request() + .post(null); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_pt_br.xml")); + } + + @Test + public void testTwimlUnsupportedLocale() { + Response response = + resources.getJerseyTest() + .target("/v1/voice/description/123456") + .queryParam("l", "es-MX") + .request() + .post(null); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml")); + } + + @Test + public void testTwimlMissingLocale() { + Response response = + resources.getJerseyTest() + .target("/v1/voice/description/123456") + .request() + .post(null); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml")); + } + + + @Test + public void testTwimlMalformedCode() { + Response response = + resources.getJerseyTest() + .target("/v1/voice/description/1234...56") + .request() + .post(null); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml")); + } + + @Test + public void testTwimlBadCodeLength() { + Response response = + resources.getJerseyTest() + .target("/v1/voice/description/1234567") + .request() + .post(null); + + assertThat(response.getStatus()).isEqualTo(400); + } + + +} diff --git a/src/test/resources/fixtures/voice_verification_en_us.xml b/src/test/resources/fixtures/voice_verification_en_us.xml new file mode 100644 index 000000000..5203a4fad --- /dev/null +++ b/src/test/resources/fixtures/voice_verification_en_us.xml @@ -0,0 +1,26 @@ + + + https://foo.com/bar/en-US/verification.mp3 + https://foo.com/bar/en-US/1_middle.mp3 + https://foo.com/bar/en-US/2_middle.mp3 + https://foo.com/bar/en-US/3_middle.mp3 + https://foo.com/bar/en-US/4_middle.mp3 + https://foo.com/bar/en-US/5_middle.mp3 + https://foo.com/bar/en-US/6_falling.mp3 + + https://foo.com/bar/en-US/verification.mp3 + https://foo.com/bar/en-US/1_middle.mp3 + https://foo.com/bar/en-US/2_middle.mp3 + https://foo.com/bar/en-US/3_middle.mp3 + https://foo.com/bar/en-US/4_middle.mp3 + https://foo.com/bar/en-US/5_middle.mp3 + https://foo.com/bar/en-US/6_falling.mp3 + + https://foo.com/bar/en-US/verification.mp3 + https://foo.com/bar/en-US/1_middle.mp3 + https://foo.com/bar/en-US/2_middle.mp3 + https://foo.com/bar/en-US/3_middle.mp3 + https://foo.com/bar/en-US/4_middle.mp3 + https://foo.com/bar/en-US/5_middle.mp3 + https://foo.com/bar/en-US/6_falling.mp3 + diff --git a/src/test/resources/fixtures/voice_verification_pt_br.xml b/src/test/resources/fixtures/voice_verification_pt_br.xml new file mode 100644 index 000000000..fb05662a0 --- /dev/null +++ b/src/test/resources/fixtures/voice_verification_pt_br.xml @@ -0,0 +1,26 @@ + + + https://foo.com/bar/pt-BR/verification.mp3 + https://foo.com/bar/pt-BR/1_middle.mp3 + https://foo.com/bar/pt-BR/2_middle.mp3 + https://foo.com/bar/pt-BR/3_middle.mp3 + https://foo.com/bar/pt-BR/4_middle.mp3 + https://foo.com/bar/pt-BR/5_middle.mp3 + https://foo.com/bar/pt-BR/6_falling.mp3 + + https://foo.com/bar/pt-BR/verification.mp3 + https://foo.com/bar/pt-BR/1_middle.mp3 + https://foo.com/bar/pt-BR/2_middle.mp3 + https://foo.com/bar/pt-BR/3_middle.mp3 + https://foo.com/bar/pt-BR/4_middle.mp3 + https://foo.com/bar/pt-BR/5_middle.mp3 + https://foo.com/bar/pt-BR/6_falling.mp3 + + https://foo.com/bar/pt-BR/verification.mp3 + https://foo.com/bar/pt-BR/1_middle.mp3 + https://foo.com/bar/pt-BR/2_middle.mp3 + https://foo.com/bar/pt-BR/3_middle.mp3 + https://foo.com/bar/pt-BR/4_middle.mp3 + https://foo.com/bar/pt-BR/5_middle.mp3 + https://foo.com/bar/pt-BR/6_falling.mp3 +