diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index fe262b700..bb7f4df06 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -110,6 +110,7 @@ import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.captcha.CaptchaClient; import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager; import org.whispersystems.textsecuregcm.captcha.ShortCodeExpander; +import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.secrets.SecretStore; import org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule; @@ -1105,7 +1106,10 @@ public class WhisperServerService extends Application ServerInterceptors.intercept(bindableService, // Note: interceptors run in the reverse order they are added; the remote deprecation filter // depends on the user-agent context so it has to come first here! diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcService.java index 1aded5662..834b7b831 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcService.java @@ -12,25 +12,49 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.signal.chat.common.Badge; +import org.signal.chat.remoteconfiguration.Badges; import org.signal.chat.remoteconfiguration.Configuration; +import org.signal.chat.remoteconfiguration.GetBadgesRequest; +import org.signal.chat.remoteconfiguration.GetBadgesResponse; import org.signal.chat.remoteconfiguration.GetConfigurationRequest; import org.signal.chat.remoteconfiguration.GetConfigurationResponse; import org.signal.chat.remoteconfiguration.SimpleRemoteConfigurationGrpc; +import org.signal.chat.remoteconfiguration.TaggedBadges; import org.signal.chat.remoteconfiguration.TaggedConfiguration; import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; +import org.whispersystems.textsecuregcm.badges.BadgeTranslator; import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; public class RemoteConfigurationGrpcService extends SimpleRemoteConfigurationGrpc.RemoteConfigurationImplBase { private static final GetConfigurationResponse CONFIGURATION_ETAG_MATCHED = GetConfigurationResponse.newBuilder().setEtagMatched(true).build(); + private static final GetBadgesResponse BADGES_ETAG_MATCHED = + GetBadgesResponse.newBuilder().setEtagMatched(true).build(); private final RemoteConfigsManager remoteConfigsManager; + private final BadgeTranslator badgeTranslator; + private final List badgeIds; - public RemoteConfigurationGrpcService(final RemoteConfigsManager remoteConfigsManager) { + // Badge information varies based on the provided Accept-Language header. Here we cache the etag for the resolved + // language, so if the caller provides a matching etag we don't have to build the full badge response. + private final ConcurrentHashMap localeToBadgesEtag = new ConcurrentHashMap<>(); + + public RemoteConfigurationGrpcService( + final RemoteConfigsManager remoteConfigsManager, + final BadgeTranslator badgeTranslator, + final List badgeIds) { this.remoteConfigsManager = remoteConfigsManager; + this.badgeTranslator = badgeTranslator; + this.badgeIds = badgeIds; } @Override @@ -54,6 +78,32 @@ public class RemoteConfigurationGrpcService extends SimpleRemoteConfigurationGrp .build(); } + @Override + public GetBadgesResponse getBadges(final GetBadgesRequest request) { + final List acceptableLanguages = RequestAttributesUtil.getAvailableAcceptedLocales(); + final Locale locale = badgeTranslator.resolveLocale(acceptableLanguages); + + final ByteString cachedEtag = localeToBadgesEtag.get(locale); + if (cachedEtag != null && cachedEtag.equals(request.getEtag())) { + return BADGES_ETAG_MATCHED; + } + + final Map badgesById = badgeIds.stream().collect(Collectors.toMap( + Function.identity(), + badgeId -> BadgeGrpcHelper.toGrpcBadge(badgeTranslator.translate(acceptableLanguages, badgeId)))); + final Badges badges = Badges.newBuilder().putAllBadges(badgesById).build(); + + final TaggedBadges taggedBadges = TaggedBadges.newBuilder() + .setBadges(badges) + .setEtag(etag(badges)) + .build(); + + // This could race and multiple threads could decide to build-and-cache. That's fine, they should all calculate + // the same etag. + localeToBadgesEtag.put(locale, taggedBadges.getEtag()); + return GetBadgesResponse.newBuilder().setTaggedBadges(taggedBadges).build(); + } + private static ByteString etag(final Message message) { final ByteArrayOutputStream baos = new ByteArrayOutputStream(message.getSerializedSize()); final CodedOutputStream cos = CodedOutputStream.newInstance(baos); diff --git a/service/src/main/proto/org/signal/chat/remote_configuration.proto b/service/src/main/proto/org/signal/chat/remote_configuration.proto index efaf565a0..ffbfbcdca 100644 --- a/service/src/main/proto/org/signal/chat/remote_configuration.proto +++ b/service/src/main/proto/org/signal/chat/remote_configuration.proto @@ -9,6 +9,7 @@ option java_multiple_files = true; package org.signal.chat.remoteconfiguration; +import "org/signal/chat/common.proto"; import "org/signal/chat/require.proto"; import "org/signal/chat/tag.proto"; @@ -20,6 +21,16 @@ service RemoteConfiguration { // values are based on the authenticated account and caller's client platform, // which is derived from the "User-Agent" header. rpc GetConfiguration(GetConfigurationRequest) returns (GetConfigurationResponse) {} + + // Returns detailed information for all configured badges, keyed by badge ID. + // + // Badge descriptions may contain localized strings. Callers should set their + // language preferences via an "Accept-Language" header on the request + // https://datatracker.ietf.org/doc/html/rfc3282#section-3 + // + // Callers may cache a badges result for up to 1 day before checking (via + // etag) if there are any new updates. + rpc GetBadges(GetBadgesRequest) returns (GetBadgesResponse) {} } message GetConfigurationRequest { @@ -54,3 +65,32 @@ message TaggedConfiguration { // changed. bytes etag = 2; } + +message GetBadgesRequest { + // If present, the etag from a prior GetBadgesResponse. If the provided etag + // matches the current badge etag, the server may elide the badge response. + bytes etag = 1; +} + +message GetBadgesResponse { + oneof response { + // The full set of badges and corresponding etag. + TaggedBadges tagged_badges = 1; + + // The etag in the request matched the current badge etag. + bool etag_matched = 2 [(tag.reason) = "etag_match"]; + } +} + +message Badges { + // A map of badge ID to the detailed information for that badge. + map badges = 1; +} + +message TaggedBadges { + Badges badges = 1; + + // An entity tag for `badges`. This may be supplied in a subsequent request + // to optimize bandwidth when the set of badges has not changed. + bytes etag = 2; +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcServiceTest.java index 43956247a..7000c0b47 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/RemoteConfigurationGrpcServiceTest.java @@ -5,8 +5,13 @@ package org.whispersystems.textsecuregcm.grpc; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.common.net.InetAddresses; @@ -14,7 +19,9 @@ import com.google.protobuf.ByteString; import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.TreeMap; import java.util.UUID; import java.util.stream.Collectors; @@ -23,23 +30,39 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; +import org.signal.chat.remoteconfiguration.GetBadgesRequest; +import org.signal.chat.remoteconfiguration.GetBadgesResponse; import org.signal.chat.remoteconfiguration.GetConfigurationRequest; import org.signal.chat.remoteconfiguration.GetConfigurationResponse; import org.signal.chat.remoteconfiguration.RemoteConfigurationGrpc; +import org.whispersystems.textsecuregcm.badges.BadgeTranslator; +import org.whispersystems.textsecuregcm.entities.Badge; +import org.whispersystems.textsecuregcm.entities.BadgeSvg; import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; class RemoteConfigurationGrpcServiceTest extends SimpleBaseGrpcTest { + private static final List BADGE_IDS = List.of("B1", "B2"); + @Mock private RemoteConfigsManager remoteConfigsManager; + @Mock + private BadgeTranslator badgeTranslator; + @Override protected RemoteConfigurationGrpcService createServiceBeforeEachTest() { getMockRequestAttributesInterceptor().setRequestAttributes( new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, null)); - return new RemoteConfigurationGrpcService(remoteConfigsManager); + when(badgeTranslator.resolveLocale(any())).thenReturn(Locale.US); + when(badgeTranslator.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1", + List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd")))); + when(badgeTranslator.translate(any(), eq("B2"))).thenReturn(new Badge("B2", "cat2", "name2", "desc2", + List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd")))); + + return new RemoteConfigurationGrpcService(remoteConfigsManager, badgeTranslator, BADGE_IDS); } @Test @@ -149,4 +172,73 @@ class RemoteConfigurationGrpcServiceTest extends config1.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, _) -> a, () -> new TreeMap(Comparator.reverseOrder())))) ); } + + @Test + void getBadges() { + final GetBadgesResponse response = authenticatedServiceStub() + .getBadges(GetBadgesRequest.newBuilder().build()); + + assertTrue(response.hasTaggedBadges()); + final Map badges = + response.getTaggedBadges().getBadges().getBadgesMap(); + assertEquals(Set.of("B1", "B2"), badges.keySet()); + assertEquals("B1", badges.get("B1").getId()); + assertEquals("cat1", badges.get("B1").getCategory()); + assertEquals("B2", badges.get("B2").getId()); + } + + @Test + void getBadgesEtagMatched() { + final GetBadgesResponse response = authenticatedServiceStub() + .getBadges(GetBadgesRequest.newBuilder().build()); + final ByteString etag = response.getTaggedBadges().getEtag(); + + final GetBadgesResponse cachedResponse = authenticatedServiceStub() + .getBadges(GetBadgesRequest.newBuilder().setEtag(etag).build()); + + assertTrue(cachedResponse.getEtagMatched()); + assertFalse(cachedResponse.hasTaggedBadges()); + } + + @Test + void getBadgesWrongEtag() { + final GetBadgesResponse response = authenticatedServiceStub() + .getBadges(GetBadgesRequest.newBuilder().setEtag(ByteString.copyFrom(new byte[32])).build()); + + assertFalse(response.hasEtagMatched()); + assertTrue(response.hasTaggedBadges()); + } + + @Test + void getBadgesComputesOncePerLocale() { + final RequestAttributes frAttributes = + new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "fr-fr"); + final List frLocales = List.of(Locale.FRANCE); + final RequestAttributes caAttributes = + new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "en-ca"); + final List caLocales = List.of(Locale.CANADA); + + when(badgeTranslator.resolveLocale(frLocales)).thenReturn(Locale.FRANCE); + when(badgeTranslator.resolveLocale(caLocales)).thenReturn(Locale.CANADA); + + // return slightly different values based on the language so the locales configurations have different etags + when(badgeTranslator.translate(eq(frLocales), eq("B1"))) + .thenReturn(new Badge("B1", "cat1", "name1", "desc1", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd")))); + when(badgeTranslator.translate(eq(caLocales), eq("B1"))) + .thenReturn(new Badge("B1", "dog1", "name1", "desc1", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd")))); + + getMockRequestAttributesInterceptor().setRequestAttributes(caAttributes); + final GetBadgesResponse caResponse = authenticatedServiceStub() + .getBadges(GetBadgesRequest.newBuilder().build()); + final GetBadgesResponse caResponseCached = authenticatedServiceStub() + .getBadges(GetBadgesRequest.newBuilder().setEtag(caResponse.getTaggedBadges().getEtag()).build()); + assertTrue(caResponseCached.getEtagMatched()); + verify(badgeTranslator, times(1)).translate(eq(caLocales), eq("B1")); + + getMockRequestAttributesInterceptor().setRequestAttributes(frAttributes); + final GetBadgesResponse frResponse = authenticatedServiceStub() + .getBadges(GetBadgesRequest.newBuilder().setEtag(caResponse.getTaggedBadges().getEtag()).build()); + assertFalse(frResponse.hasEtagMatched()); + verify(badgeTranslator, times(1)).translate(eq(frLocales), eq("B1")); + } }