mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 00:30:21 +01:00
implement /v2/config API (#2764)
This commit is contained in:
committed by
GitHub
parent
6116830da9
commit
5c21aa2ad4
@@ -122,6 +122,7 @@ import org.whispersystems.textsecuregcm.controllers.ProfileController;
|
||||
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
|
||||
import org.whispersystems.textsecuregcm.controllers.RegistrationController;
|
||||
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
|
||||
import org.whispersystems.textsecuregcm.controllers.RemoteConfigControllerV1;
|
||||
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
|
||||
import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;
|
||||
import org.whispersystems.textsecuregcm.controllers.SecureValueRecoveryBController;
|
||||
@@ -1131,6 +1132,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new ProvisioningController(rateLimiters, provisioningManager),
|
||||
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
|
||||
rateLimiters),
|
||||
new RemoteConfigControllerV1(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
|
||||
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
|
||||
new SecureStorageController(storageCredentialsGenerator),
|
||||
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -8,38 +8,48 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.headers.Header;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.EntityTag;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
|
||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
||||
import org.whispersystems.textsecuregcm.entities.RemoteConfigurationResponse;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfig;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@Path("/v1/config")
|
||||
@Path("/v2/config")
|
||||
@Tag(name = "Remote Config")
|
||||
public class RemoteConfigController {
|
||||
|
||||
private final RemoteConfigsManager remoteConfigsManager;
|
||||
private final Map<String, String> globalConfig;
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
private static final String GLOBAL_CONFIG_PREFIX = "global.";
|
||||
|
||||
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager,
|
||||
@@ -47,36 +57,60 @@ public class RemoteConfigController {
|
||||
final Clock clock) {
|
||||
this.remoteConfigsManager = remoteConfigsManager;
|
||||
this.globalConfig = globalConfig;
|
||||
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(
|
||||
summary = "Fetch remote configuration",
|
||||
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.
|
||||
"""
|
||||
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."
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Remote configuration values for the authenticated user", useReturnTypeSchema = true)
|
||||
public UserRemoteConfigList getAll(@Auth AuthenticatedDevice auth) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA1");
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Remote configuration values for the authenticated user",
|
||||
content = @Content(schema = @Schema(implementation = RemoteConfigurationResponse.class)),
|
||||
headers = @Header(name = "ETag", description = "A hash of the configuration content which can be supplied in an If-None-Match header on future requests"))
|
||||
@ApiResponse(responseCode = "304", description = "There is no change since the last fetch", content = {})
|
||||
@ApiResponse(responseCode = "401", description = "This request requires authentication", content = {})
|
||||
|
||||
final Stream<UserRemoteConfig> globalConfigStream = globalConfig.entrySet().stream()
|
||||
.map(entry -> new UserRemoteConfig(GLOBAL_CONFIG_PREFIX + entry.getKey(), true, entry.getValue()));
|
||||
return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> {
|
||||
final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8)
|
||||
: config.getName().getBytes(StandardCharsets.UTF_8);
|
||||
boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(),
|
||||
config.getUuids());
|
||||
return new UserRemoteConfig(config.getName(), inBucket,
|
||||
inBucket ? config.getValue() : config.getDefaultValue());
|
||||
}), globalConfigStream).collect(Collectors.toList()), clock.instant());
|
||||
public Response getAll(
|
||||
@Auth AuthenticatedDevice auth,
|
||||
|
||||
@Parameter(description = "The ETag header supplied with a previous response from this endpoint. Optional.")
|
||||
@HeaderParam(HttpHeaders.IF_NONE_MATCH)
|
||||
@Nullable EntityTag eTag,
|
||||
|
||||
@Parameter(description = "The user agent in standard form.")
|
||||
@HeaderParam(HttpHeaders.USER_AGENT)
|
||||
String userAgent
|
||||
) {
|
||||
try {
|
||||
final List<RemoteConfig> remoteConfigs = remoteConfigsManager.getAll();
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
final Map<String, String> configs = Stream.concat(
|
||||
remoteConfigs.stream()
|
||||
.map(
|
||||
config -> {
|
||||
final byte[] hashKey = config.getHashKey() != null
|
||||
? config.getHashKey().getBytes(StandardCharsets.UTF_8)
|
||||
: config.getName().getBytes(StandardCharsets.UTF_8);
|
||||
boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(), config.getUuids());
|
||||
final String value = inBucket ? config.getValue() : config.getDefaultValue();
|
||||
return Pair.of(config.getName(), value == null ? String.valueOf(inBucket) : value);
|
||||
}),
|
||||
globalConfig.entrySet().stream()
|
||||
.map(e -> Pair.of(GLOBAL_CONFIG_PREFIX + e.getKey(), e.getValue())))
|
||||
.collect(Collectors.toMap(Pair::getLeft, Pair::getRight));
|
||||
|
||||
final EntityTag newETag = new EntityTag(HexFormat.of().toHexDigits(configs.hashCode()));
|
||||
if (newETag.equals(eTag)) {
|
||||
return Response.notModified(eTag).build();
|
||||
}
|
||||
|
||||
return Response.ok(new RemoteConfigurationResponse(configs))
|
||||
.tag(newETag)
|
||||
.build();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
@@ -89,7 +123,7 @@ public class RemoteConfigController {
|
||||
return true;
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
|
||||
ByteBuffer bb = ByteBuffer.allocate(16);
|
||||
bb.putLong(uid.getMostSignificantBits());
|
||||
bb.putLong(uid.getLeastSignificantBits());
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
|
||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@Path("/v1/config")
|
||||
@Tag(name = "Remote Config")
|
||||
public class RemoteConfigControllerV1 {
|
||||
|
||||
private final RemoteConfigsManager remoteConfigsManager;
|
||||
private final Map<String, String> globalConfig;
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
private static final String GLOBAL_CONFIG_PREFIX = "global.";
|
||||
|
||||
public RemoteConfigControllerV1(RemoteConfigsManager remoteConfigsManager,
|
||||
Map<String, String> globalConfig,
|
||||
final Clock clock) {
|
||||
this.remoteConfigsManager = remoteConfigsManager;
|
||||
this.globalConfig = globalConfig;
|
||||
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Deprecated
|
||||
@Operation(
|
||||
summary = "Fetch remote configuration (deprecated)",
|
||||
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.
|
||||
|
||||
This endpoint is deprecated; use GET /v2/config instead
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Remote configuration values for the authenticated user", useReturnTypeSchema = true)
|
||||
public UserRemoteConfigList getAll(@Auth AuthenticatedDevice auth) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
final Stream<UserRemoteConfig> globalConfigStream = globalConfig.entrySet().stream()
|
||||
.map(entry -> new UserRemoteConfig(GLOBAL_CONFIG_PREFIX + entry.getKey(), true, entry.getValue()));
|
||||
return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> {
|
||||
final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8)
|
||||
: config.getName().getBytes(StandardCharsets.UTF_8);
|
||||
boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(),
|
||||
config.getUuids());
|
||||
return new UserRemoteConfig(config.getName(), inBucket,
|
||||
inBucket ? config.getValue() : config.getDefaultValue());
|
||||
}), globalConfigStream).collect(Collectors.toList()), clock.instant());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage,
|
||||
Set<UUID> uuidsInBucket) {
|
||||
if (uuidsInBucket.contains(uid)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
|
||||
bb.putLong(uid.getMostSignificantBits());
|
||||
bb.putLong(uid.getLeastSignificantBits());
|
||||
|
||||
digest.update(bb.array());
|
||||
|
||||
byte[] hash = digest.digest(hashKey);
|
||||
int bucket = (int) (Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100);
|
||||
|
||||
return bucket < configPercentage;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.Map;
|
||||
|
||||
public record RemoteConfigurationResponse(
|
||||
@JsonProperty
|
||||
@Schema(description = "Remote configurations applicable to the user and client")
|
||||
Map<String, String> config) {
|
||||
}
|
||||
Reference in New Issue
Block a user