Skip to content

Commit 5c21aa2

Browse files
authored
implement /v2/config API (#2764)
1 parent 6116830 commit 5c21aa2

File tree

6 files changed

+573
-153
lines changed

6 files changed

+573
-153
lines changed

service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
123123
import org.whispersystems.textsecuregcm.controllers.RegistrationController;
124124
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
125+
import org.whispersystems.textsecuregcm.controllers.RemoteConfigControllerV1;
125126
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
126127
import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;
127128
import org.whispersystems.textsecuregcm.controllers.SecureValueRecoveryBController;
@@ -1131,6 +1132,7 @@ protected void configureServer(final ServerBuilder<?> serverBuilder) {
11311132
new ProvisioningController(rateLimiters, provisioningManager),
11321133
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
11331134
rateLimiters),
1135+
new RemoteConfigControllerV1(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
11341136
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
11351137
new SecureStorageController(storageCredentialsGenerator),
11361138
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager),

service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2013 Signal Messenger, LLC
2+
* Copyright 2025 Signal Messenger, LLC
33
* SPDX-License-Identifier: AGPL-3.0-only
44
*/
55

@@ -8,75 +8,109 @@
88
import com.google.common.annotations.VisibleForTesting;
99
import io.dropwizard.auth.Auth;
1010
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.Parameter;
12+
import io.swagger.v3.oas.annotations.headers.Header;
13+
import io.swagger.v3.oas.annotations.media.Content;
14+
import io.swagger.v3.oas.annotations.media.Schema;
1115
import io.swagger.v3.oas.annotations.responses.ApiResponse;
1216
import io.swagger.v3.oas.annotations.tags.Tag;
1317
import jakarta.ws.rs.GET;
18+
import jakarta.ws.rs.HeaderParam;
1419
import jakarta.ws.rs.Path;
1520
import jakarta.ws.rs.Produces;
21+
import jakarta.ws.rs.core.EntityTag;
22+
import jakarta.ws.rs.core.HttpHeaders;
1623
import jakarta.ws.rs.core.MediaType;
24+
import jakarta.ws.rs.core.Response;
1725
import java.nio.ByteBuffer;
1826
import java.nio.charset.StandardCharsets;
1927
import java.security.MessageDigest;
2028
import java.security.NoSuchAlgorithmException;
2129
import java.time.Clock;
30+
import java.util.HexFormat;
31+
import java.util.List;
2232
import java.util.Map;
2333
import java.util.Set;
2434
import java.util.UUID;
2535
import java.util.stream.Collectors;
2636
import java.util.stream.Stream;
37+
import javax.annotation.Nullable;
38+
import org.apache.commons.lang3.tuple.Pair;
2739
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
28-
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
29-
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
40+
import org.whispersystems.textsecuregcm.entities.RemoteConfigurationResponse;
41+
import org.whispersystems.textsecuregcm.storage.RemoteConfig;
3042
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
3143
import org.whispersystems.textsecuregcm.util.Conversions;
3244
import org.whispersystems.textsecuregcm.util.Util;
3345

34-
@Path("/v1/config")
46+
@Path("/v2/config")
3547
@Tag(name = "Remote Config")
3648
public class RemoteConfigController {
3749

3850
private final RemoteConfigsManager remoteConfigsManager;
3951
private final Map<String, String> globalConfig;
4052

41-
private final Clock clock;
42-
4353
private static final String GLOBAL_CONFIG_PREFIX = "global.";
4454

4555
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager,
4656
Map<String, String> globalConfig,
4757
final Clock clock) {
4858
this.remoteConfigsManager = remoteConfigsManager;
4959
this.globalConfig = globalConfig;
50-
51-
this.clock = clock;
5260
}
5361

5462
@GET
5563
@Produces(MediaType.APPLICATION_JSON)
5664
@Operation(
5765
summary = "Fetch remote configuration",
58-
description = """
59-
Remote configuration is a list of namespaced keys that clients may use for consistent configuration or behavior.
60-
61-
Configuration values change over time, and the list should be refreshed periodically, typically at client
62-
launch and every few hours thereafter.
63-
"""
66+
description = "Remote configuration is a list of namespaced keys that clients may use for consistent configuration or behavior. Configuration values change over time, and the list should be refreshed periodically, typically at client launch and every few hours thereafter. Some values depend on the authenticated user, so the list should be refreshed immediately if the user changes."
6467
)
65-
@ApiResponse(responseCode = "200", description = "Remote configuration values for the authenticated user", useReturnTypeSchema = true)
66-
public UserRemoteConfigList getAll(@Auth AuthenticatedDevice auth) {
68+
@ApiResponse(
69+
responseCode = "200",
70+
description = "Remote configuration values for the authenticated user",
71+
content = @Content(schema = @Schema(implementation = RemoteConfigurationResponse.class)),
72+
headers = @Header(name = "ETag", description = "A hash of the configuration content which can be supplied in an If-None-Match header on future requests"))
73+
@ApiResponse(responseCode = "304", description = "There is no change since the last fetch", content = {})
74+
@ApiResponse(responseCode = "401", description = "This request requires authentication", content = {})
75+
76+
public Response getAll(
77+
@Auth AuthenticatedDevice auth,
78+
79+
@Parameter(description = "The ETag header supplied with a previous response from this endpoint. Optional.")
80+
@HeaderParam(HttpHeaders.IF_NONE_MATCH)
81+
@Nullable EntityTag eTag,
82+
83+
@Parameter(description = "The user agent in standard form.")
84+
@HeaderParam(HttpHeaders.USER_AGENT)
85+
String userAgent
86+
) {
6787
try {
68-
MessageDigest digest = MessageDigest.getInstance("SHA1");
69-
70-
final Stream<UserRemoteConfig> globalConfigStream = globalConfig.entrySet().stream()
71-
.map(entry -> new UserRemoteConfig(GLOBAL_CONFIG_PREFIX + entry.getKey(), true, entry.getValue()));
72-
return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> {
73-
final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8)
74-
: config.getName().getBytes(StandardCharsets.UTF_8);
75-
boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(),
76-
config.getUuids());
77-
return new UserRemoteConfig(config.getName(), inBucket,
78-
inBucket ? config.getValue() : config.getDefaultValue());
79-
}), globalConfigStream).collect(Collectors.toList()), clock.instant());
88+
final List<RemoteConfig> remoteConfigs = remoteConfigsManager.getAll();
89+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
90+
91+
final Map<String, String> configs = Stream.concat(
92+
remoteConfigs.stream()
93+
.map(
94+
config -> {
95+
final byte[] hashKey = config.getHashKey() != null
96+
? config.getHashKey().getBytes(StandardCharsets.UTF_8)
97+
: config.getName().getBytes(StandardCharsets.UTF_8);
98+
boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(), config.getUuids());
99+
final String value = inBucket ? config.getValue() : config.getDefaultValue();
100+
return Pair.of(config.getName(), value == null ? String.valueOf(inBucket) : value);
101+
}),
102+
globalConfig.entrySet().stream()
103+
.map(e -> Pair.of(GLOBAL_CONFIG_PREFIX + e.getKey(), e.getValue())))
104+
.collect(Collectors.toMap(Pair::getLeft, Pair::getRight));
105+
106+
final EntityTag newETag = new EntityTag(HexFormat.of().toHexDigits(configs.hashCode()));
107+
if (newETag.equals(eTag)) {
108+
return Response.notModified(eTag).build();
109+
}
110+
111+
return Response.ok(new RemoteConfigurationResponse(configs))
112+
.tag(newETag)
113+
.build();
80114
} catch (NoSuchAlgorithmException e) {
81115
throw new AssertionError(e);
82116
}
@@ -89,7 +123,7 @@ public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey,
89123
return true;
90124
}
91125

92-
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
126+
ByteBuffer bb = ByteBuffer.allocate(16);
93127
bb.putLong(uid.getMostSignificantBits());
94128
bb.putLong(uid.getLeastSignificantBits());
95129

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright 2013 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.whispersystems.textsecuregcm.controllers;
7+
8+
import com.google.common.annotations.VisibleForTesting;
9+
import io.dropwizard.auth.Auth;
10+
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
13+
import jakarta.ws.rs.GET;
14+
import jakarta.ws.rs.Path;
15+
import jakarta.ws.rs.Produces;
16+
import jakarta.ws.rs.core.MediaType;
17+
import java.nio.ByteBuffer;
18+
import java.nio.charset.StandardCharsets;
19+
import java.security.MessageDigest;
20+
import java.security.NoSuchAlgorithmException;
21+
import java.time.Clock;
22+
import java.util.Map;
23+
import java.util.Set;
24+
import java.util.UUID;
25+
import java.util.stream.Collectors;
26+
import java.util.stream.Stream;
27+
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
28+
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
29+
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
30+
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
31+
import org.whispersystems.textsecuregcm.util.Conversions;
32+
import org.whispersystems.textsecuregcm.util.Util;
33+
34+
@Path("/v1/config")
35+
@Tag(name = "Remote Config")
36+
public class RemoteConfigControllerV1 {
37+
38+
private final RemoteConfigsManager remoteConfigsManager;
39+
private final Map<String, String> globalConfig;
40+
41+
private final Clock clock;
42+
43+
private static final String GLOBAL_CONFIG_PREFIX = "global.";
44+
45+
public RemoteConfigControllerV1(RemoteConfigsManager remoteConfigsManager,
46+
Map<String, String> globalConfig,
47+
final Clock clock) {
48+
this.remoteConfigsManager = remoteConfigsManager;
49+
this.globalConfig = globalConfig;
50+
51+
this.clock = clock;
52+
}
53+
54+
@GET
55+
@Produces(MediaType.APPLICATION_JSON)
56+
@Deprecated
57+
@Operation(
58+
summary = "Fetch remote configuration (deprecated)",
59+
description = """
60+
Remote configuration is a list of namespaced keys that clients may use for consistent configuration or behavior.
61+
62+
Configuration values change over time, and the list should be refreshed periodically, typically at client
63+
launch and every few hours thereafter.
64+
65+
This endpoint is deprecated; use GET /v2/config instead
66+
"""
67+
)
68+
@ApiResponse(responseCode = "200", description = "Remote configuration values for the authenticated user", useReturnTypeSchema = true)
69+
public UserRemoteConfigList getAll(@Auth AuthenticatedDevice auth) {
70+
try {
71+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
72+
73+
final Stream<UserRemoteConfig> globalConfigStream = globalConfig.entrySet().stream()
74+
.map(entry -> new UserRemoteConfig(GLOBAL_CONFIG_PREFIX + entry.getKey(), true, entry.getValue()));
75+
return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> {
76+
final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8)
77+
: config.getName().getBytes(StandardCharsets.UTF_8);
78+
boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(),
79+
config.getUuids());
80+
return new UserRemoteConfig(config.getName(), inBucket,
81+
inBucket ? config.getValue() : config.getDefaultValue());
82+
}), globalConfigStream).collect(Collectors.toList()), clock.instant());
83+
} catch (NoSuchAlgorithmException e) {
84+
throw new AssertionError(e);
85+
}
86+
}
87+
88+
@VisibleForTesting
89+
public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage,
90+
Set<UUID> uuidsInBucket) {
91+
if (uuidsInBucket.contains(uid)) {
92+
return true;
93+
}
94+
95+
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
96+
bb.putLong(uid.getMostSignificantBits());
97+
bb.putLong(uid.getLeastSignificantBits());
98+
99+
digest.update(bb.array());
100+
101+
byte[] hash = digest.digest(hashKey);
102+
int bucket = (int) (Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100);
103+
104+
return bucket < configPercentage;
105+
}
106+
107+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2025 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.whispersystems.textsecuregcm.entities;
7+
8+
import com.fasterxml.jackson.annotation.JsonProperty;
9+
import io.swagger.v3.oas.annotations.media.Schema;
10+
import java.util.Map;
11+
12+
public record RemoteConfigurationResponse(
13+
@JsonProperty
14+
@Schema(description = "Remote configurations applicable to the user and client")
15+
Map<String, String> config) {
16+
}

0 commit comments

Comments
 (0)