parent
8eed2329bc
commit
19a4c7253a
|
@ -25,6 +25,7 @@ import org.whispersystems.textsecuregcm.configuration.RedPhoneConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.S3Configuration;
|
import org.whispersystems.textsecuregcm.configuration.S3Configuration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.TurnConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.WebsocketConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.WebsocketConfiguration;
|
||||||
|
|
||||||
|
@ -105,6 +106,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private JerseyClientConfiguration httpClient = new JerseyClientConfiguration();
|
private JerseyClientConfiguration httpClient = new JerseyClientConfiguration();
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotNull
|
||||||
|
@JsonProperty
|
||||||
|
private TurnConfiguration turn;
|
||||||
|
|
||||||
|
|
||||||
public WebsocketConfiguration getWebsocketConfiguration() {
|
public WebsocketConfiguration getWebsocketConfiguration() {
|
||||||
return websocket;
|
return websocket;
|
||||||
|
@ -158,6 +164,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
return redphone;
|
return redphone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TurnConfiguration getTurnConfiguration() {
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Integer> getTestDevices() {
|
public Map<String, Integer> getTestDevices() {
|
||||||
Map<String, Integer> results = new HashMap<>();
|
Map<String, Integer> results = new HashMap<>();
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
package org.whispersystems.textsecuregcm;
|
package org.whispersystems.textsecuregcm;
|
||||||
|
|
||||||
import com.codahale.metrics.SharedMetricRegistries;
|
import com.codahale.metrics.SharedMetricRegistries;
|
||||||
import com.codahale.metrics.graphite.GraphiteReporter;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
@ -33,6 +32,7 @@ import org.whispersystems.dropwizard.simpleauth.AuthValueFactoryProvider;
|
||||||
import org.whispersystems.dropwizard.simpleauth.BasicCredentialAuthFilter;
|
import org.whispersystems.dropwizard.simpleauth.BasicCredentialAuthFilter;
|
||||||
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
||||||
import org.whispersystems.textsecuregcm.auth.FederatedPeerAuthenticator;
|
import org.whispersystems.textsecuregcm.auth.FederatedPeerAuthenticator;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentController;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.DeviceController;
|
import org.whispersystems.textsecuregcm.controllers.DeviceController;
|
||||||
|
@ -100,14 +100,12 @@ import javax.servlet.ServletRegistration;
|
||||||
import javax.ws.rs.client.Client;
|
import javax.ws.rs.client.Client;
|
||||||
import java.security.Security;
|
import java.security.Security;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import static com.codahale.metrics.MetricRegistry.name;
|
import static com.codahale.metrics.MetricRegistry.name;
|
||||||
import io.dropwizard.Application;
|
import io.dropwizard.Application;
|
||||||
import io.dropwizard.client.JerseyClientBuilder;
|
import io.dropwizard.client.JerseyClientBuilder;
|
||||||
import io.dropwizard.db.DataSourceFactory;
|
import io.dropwizard.db.DataSourceFactory;
|
||||||
import io.dropwizard.jdbi.DBIFactory;
|
import io.dropwizard.jdbi.DBIFactory;
|
||||||
import io.dropwizard.metrics.graphite.GraphiteReporterFactory;
|
|
||||||
import io.dropwizard.setup.Bootstrap;
|
import io.dropwizard.setup.Bootstrap;
|
||||||
import io.dropwizard.setup.Environment;
|
import io.dropwizard.setup.Environment;
|
||||||
import redis.clients.jedis.JedisPool;
|
import redis.clients.jedis.JedisPool;
|
||||||
|
@ -190,6 +188,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
PushSender pushSender = new PushSender(apnFallbackManager, pushServiceClient, websocketSender, config.getPushConfiguration().getQueueSize());
|
PushSender pushSender = new PushSender(apnFallbackManager, pushServiceClient, websocketSender, config.getPushConfiguration().getQueueSize());
|
||||||
ReceiptSender receiptSender = new ReceiptSender(accountsManager, pushSender, federatedClientManager);
|
ReceiptSender receiptSender = new ReceiptSender(accountsManager, pushSender, federatedClientManager);
|
||||||
FeedbackHandler feedbackHandler = new FeedbackHandler(pushServiceClient, accountsManager);
|
FeedbackHandler feedbackHandler = new FeedbackHandler(pushServiceClient, accountsManager);
|
||||||
|
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(config.getTurnConfiguration());
|
||||||
Optional<byte[]> authorizationKey = config.getRedphoneConfiguration().getAuthorizationKey();
|
Optional<byte[]> authorizationKey = config.getRedphoneConfiguration().getAuthorizationKey();
|
||||||
|
|
||||||
environment.lifecycle().manage(apnFallbackManager);
|
environment.lifecycle().manage(apnFallbackManager);
|
||||||
|
@ -212,7 +211,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
.buildAuthFilter()));
|
.buildAuthFilter()));
|
||||||
environment.jersey().register(new AuthValueFactoryProvider.Binder());
|
environment.jersey().register(new AuthValueFactoryProvider.Binder());
|
||||||
|
|
||||||
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, rateLimiters, smsSender, messagesManager, new TimeProvider(), authorizationKey, config.getTestDevices()));
|
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, rateLimiters, smsSender, messagesManager, new TimeProvider(), authorizationKey, turnTokenGenerator, config.getTestDevices()));
|
||||||
environment.jersey().register(new DeviceController(pendingDevicesManager, accountsManager, messagesManager, rateLimiters));
|
environment.jersey().register(new DeviceController(pendingDevicesManager, accountsManager, messagesManager, rateLimiters));
|
||||||
environment.jersey().register(new DirectoryController(rateLimiters, directory));
|
environment.jersey().register(new DirectoryController(rateLimiters, directory));
|
||||||
environment.jersey().register(new FederationControllerV1(accountsManager, attachmentController, messageController, keysControllerV1));
|
environment.jersey().register(new FederationControllerV1(accountsManager, attachmentController, messageController, keysControllerV1));
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TurnToken {
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private List<String> urls;
|
||||||
|
|
||||||
|
public TurnToken(String username, String password, List<String> urls) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.urls = urls;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.TurnConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.util.Base64;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
public class TurnTokenGenerator {
|
||||||
|
|
||||||
|
private final byte[] key;
|
||||||
|
private final List<String> urls;
|
||||||
|
|
||||||
|
public TurnTokenGenerator(TurnConfiguration configuration) {
|
||||||
|
this.key = configuration.getSecret().getBytes();
|
||||||
|
this.urls = configuration.getUris();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TurnToken generate() {
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA1");
|
||||||
|
long validUntilSeconds = (System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)) / 1000;
|
||||||
|
long user = Math.abs(SecureRandom.getInstance("SHA1PRNG").nextInt());
|
||||||
|
String userTime = validUntilSeconds + ":" + user;
|
||||||
|
|
||||||
|
mac.init(new SecretKeySpec(key, "HmacSHA1"));
|
||||||
|
String password = Base64.encodeBytes(mac.doFinal(userTime.getBytes()));
|
||||||
|
|
||||||
|
return new TurnToken(userTime, password, urls);
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||||
|
throw new AssertionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,6 +50,9 @@ public class RateLimitsConfiguration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private RateLimitConfiguration verifyDevice = new RateLimitConfiguration(2, 2);
|
private RateLimitConfiguration verifyDevice = new RateLimitConfiguration(2, 2);
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private RateLimitConfiguration turnAllocations = new RateLimitConfiguration(60, 60);
|
||||||
|
|
||||||
public RateLimitConfiguration getAllocateDevice() {
|
public RateLimitConfiguration getAllocateDevice() {
|
||||||
return allocateDevice;
|
return allocateDevice;
|
||||||
}
|
}
|
||||||
|
@ -90,6 +93,10 @@ public class RateLimitsConfiguration {
|
||||||
return verifyNumber;
|
return verifyNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RateLimitConfiguration getTurnAllocations() {
|
||||||
|
return turnAllocations;
|
||||||
|
}
|
||||||
|
|
||||||
public static class RateLimitConfiguration {
|
public static class RateLimitConfiguration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private int bucketSize;
|
private int bucketSize;
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import org.hibernate.validator.constraints.NotEmpty;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TurnConfiguration {
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@NotEmpty
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@NotNull
|
||||||
|
private List<String> uris;
|
||||||
|
|
||||||
|
public List<String> getUris() {
|
||||||
|
return uris;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecret() {
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,7 +19,6 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||||
import com.codahale.metrics.Meter;
|
import com.codahale.metrics.Meter;
|
||||||
import com.codahale.metrics.MetricRegistry;
|
import com.codahale.metrics.MetricRegistry;
|
||||||
import com.codahale.metrics.SharedMetricRegistries;
|
import com.codahale.metrics.SharedMetricRegistries;
|
||||||
import com.codahale.metrics.Timer;
|
|
||||||
import com.codahale.metrics.annotation.Timed;
|
import com.codahale.metrics.annotation.Timed;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import com.google.common.base.Optional;
|
import com.google.common.base.Optional;
|
||||||
|
@ -30,6 +29,8 @@ import org.whispersystems.textsecuregcm.auth.AuthorizationHeader;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthorizationToken;
|
import org.whispersystems.textsecuregcm.auth.AuthorizationToken;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthorizationTokenGenerator;
|
import org.whispersystems.textsecuregcm.auth.AuthorizationTokenGenerator;
|
||||||
import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException;
|
import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||||
|
@ -45,7 +46,6 @@ import org.whispersystems.textsecuregcm.storage.PendingAccountsManager;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.Util;
|
import org.whispersystems.textsecuregcm.util.Util;
|
||||||
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
||||||
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
|
||||||
|
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
|
@ -83,6 +83,7 @@ public class AccountController {
|
||||||
private final MessagesManager messagesManager;
|
private final MessagesManager messagesManager;
|
||||||
private final TimeProvider timeProvider;
|
private final TimeProvider timeProvider;
|
||||||
private final Optional<AuthorizationTokenGenerator> tokenGenerator;
|
private final Optional<AuthorizationTokenGenerator> tokenGenerator;
|
||||||
|
private final TurnTokenGenerator turnTokenGenerator;
|
||||||
private final Map<String, Integer> testDevices;
|
private final Map<String, Integer> testDevices;
|
||||||
|
|
||||||
public AccountController(PendingAccountsManager pendingAccounts,
|
public AccountController(PendingAccountsManager pendingAccounts,
|
||||||
|
@ -92,15 +93,17 @@ public class AccountController {
|
||||||
MessagesManager messagesManager,
|
MessagesManager messagesManager,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
Optional<byte[]> authorizationKey,
|
Optional<byte[]> authorizationKey,
|
||||||
|
TurnTokenGenerator turnTokenGenerator,
|
||||||
Map<String, Integer> testDevices)
|
Map<String, Integer> testDevices)
|
||||||
{
|
{
|
||||||
this.pendingAccounts = pendingAccounts;
|
this.pendingAccounts = pendingAccounts;
|
||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
this.rateLimiters = rateLimiters;
|
this.rateLimiters = rateLimiters;
|
||||||
this.smsSender = smsSenderFactory;
|
this.smsSender = smsSenderFactory;
|
||||||
this.messagesManager = messagesManager;
|
this.messagesManager = messagesManager;
|
||||||
this.timeProvider = timeProvider;
|
this.timeProvider = timeProvider;
|
||||||
this.testDevices = testDevices;
|
this.testDevices = testDevices;
|
||||||
|
this.turnTokenGenerator = turnTokenGenerator;
|
||||||
|
|
||||||
if (authorizationKey.isPresent()) {
|
if (authorizationKey.isPresent()) {
|
||||||
tokenGenerator = Optional.of(new AuthorizationTokenGenerator(authorizationKey.get()));
|
tokenGenerator = Optional.of(new AuthorizationTokenGenerator(authorizationKey.get()));
|
||||||
|
@ -232,6 +235,15 @@ public class AccountController {
|
||||||
return tokenGenerator.get().generateFor(account.getNumber());
|
return tokenGenerator.get().generateFor(account.getNumber());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Timed
|
||||||
|
@GET
|
||||||
|
@Path("/turn/")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public TurnToken getTurnToken(@Auth Account account) throws RateLimitExceededException {
|
||||||
|
rateLimiters.getTurnLimiter().validate(account.getNumber());
|
||||||
|
return turnTokenGenerator.generate();
|
||||||
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/gcm/")
|
@Path("/gcm/")
|
||||||
|
|
|
@ -36,6 +36,8 @@ public class RateLimiters {
|
||||||
private final RateLimiter allocateDeviceLimiter;
|
private final RateLimiter allocateDeviceLimiter;
|
||||||
private final RateLimiter verifyDeviceLimiter;
|
private final RateLimiter verifyDeviceLimiter;
|
||||||
|
|
||||||
|
private final RateLimiter turnLimiter;
|
||||||
|
|
||||||
public RateLimiters(RateLimitsConfiguration config, JedisPool cacheClient) {
|
public RateLimiters(RateLimitsConfiguration config, JedisPool cacheClient) {
|
||||||
this.smsDestinationLimiter = new RateLimiter(cacheClient, "smsDestination",
|
this.smsDestinationLimiter = new RateLimiter(cacheClient, "smsDestination",
|
||||||
config.getSmsDestination().getBucketSize(),
|
config.getSmsDestination().getBucketSize(),
|
||||||
|
@ -77,6 +79,9 @@ public class RateLimiters {
|
||||||
config.getVerifyDevice().getBucketSize(),
|
config.getVerifyDevice().getBucketSize(),
|
||||||
config.getVerifyDevice().getLeakRatePerMinute());
|
config.getVerifyDevice().getLeakRatePerMinute());
|
||||||
|
|
||||||
|
this.turnLimiter = new RateLimiter(cacheClient, "turnAllocate",
|
||||||
|
config.getTurnAllocations().getBucketSize(),
|
||||||
|
config.getTurnAllocations().getLeakRatePerMinute());
|
||||||
}
|
}
|
||||||
|
|
||||||
public RateLimiter getAllocateDeviceLimiter() {
|
public RateLimiter getAllocateDeviceLimiter() {
|
||||||
|
@ -119,4 +124,8 @@ public class RateLimiters {
|
||||||
return verifyLimiter;
|
return verifyLimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RateLimiter getTurnLimiter() {
|
||||||
|
return turnLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import org.junit.Before;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.whispersystems.dropwizard.simpleauth.AuthValueFactoryProvider;
|
import org.whispersystems.dropwizard.simpleauth.AuthValueFactoryProvider;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||||
|
@ -42,6 +43,7 @@ public class AccountControllerTest {
|
||||||
private SmsSender smsSender = mock(SmsSender.class );
|
private SmsSender smsSender = mock(SmsSender.class );
|
||||||
private MessagesManager storedMessages = mock(MessagesManager.class );
|
private MessagesManager storedMessages = mock(MessagesManager.class );
|
||||||
private TimeProvider timeProvider = mock(TimeProvider.class );
|
private TimeProvider timeProvider = mock(TimeProvider.class );
|
||||||
|
private TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
|
||||||
private static byte[] authorizationKey = decodeHex("3a078586eea8971155f5c1ebd73c8c923cbec1c3ed22a54722e4e88321dc749f");
|
private static byte[] authorizationKey = decodeHex("3a078586eea8971155f5c1ebd73c8c923cbec1c3ed22a54722e4e88321dc749f");
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
|
@ -57,6 +59,7 @@ public class AccountControllerTest {
|
||||||
storedMessages,
|
storedMessages,
|
||||||
timeProvider,
|
timeProvider,
|
||||||
Optional.of(authorizationKey),
|
Optional.of(authorizationKey),
|
||||||
|
turnTokenGenerator,
|
||||||
new HashMap<String, Integer>()))
|
new HashMap<String, Integer>()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue