1
1
/*
2
- * Copyright 2013 Signal Messenger, LLC
2
+ * Copyright 2025 Signal Messenger, LLC
3
3
* SPDX-License-Identifier: AGPL-3.0-only
4
4
*/
5
5
8
8
import com .google .common .annotations .VisibleForTesting ;
9
9
import io .dropwizard .auth .Auth ;
10
10
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 ;
11
15
import io .swagger .v3 .oas .annotations .responses .ApiResponse ;
12
16
import io .swagger .v3 .oas .annotations .tags .Tag ;
13
17
import jakarta .ws .rs .GET ;
18
+ import jakarta .ws .rs .HeaderParam ;
14
19
import jakarta .ws .rs .Path ;
15
20
import jakarta .ws .rs .Produces ;
21
+ import jakarta .ws .rs .core .EntityTag ;
22
+ import jakarta .ws .rs .core .HttpHeaders ;
16
23
import jakarta .ws .rs .core .MediaType ;
24
+ import jakarta .ws .rs .core .Response ;
17
25
import java .nio .ByteBuffer ;
18
26
import java .nio .charset .StandardCharsets ;
19
27
import java .security .MessageDigest ;
20
28
import java .security .NoSuchAlgorithmException ;
21
29
import java .time .Clock ;
30
+ import java .util .HexFormat ;
31
+ import java .util .List ;
22
32
import java .util .Map ;
23
33
import java .util .Set ;
24
34
import java .util .UUID ;
25
35
import java .util .stream .Collectors ;
26
36
import java .util .stream .Stream ;
37
+ import javax .annotation .Nullable ;
38
+ import org .apache .commons .lang3 .tuple .Pair ;
27
39
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 ;
30
42
import org .whispersystems .textsecuregcm .storage .RemoteConfigsManager ;
31
43
import org .whispersystems .textsecuregcm .util .Conversions ;
32
44
import org .whispersystems .textsecuregcm .util .Util ;
33
45
34
- @ Path ("/v1 /config" )
46
+ @ Path ("/v2 /config" )
35
47
@ Tag (name = "Remote Config" )
36
48
public class RemoteConfigController {
37
49
38
50
private final RemoteConfigsManager remoteConfigsManager ;
39
51
private final Map <String , String > globalConfig ;
40
52
41
- private final Clock clock ;
42
-
43
53
private static final String GLOBAL_CONFIG_PREFIX = "global." ;
44
54
45
55
public RemoteConfigController (RemoteConfigsManager remoteConfigsManager ,
46
56
Map <String , String > globalConfig ,
47
57
final Clock clock ) {
48
58
this .remoteConfigsManager = remoteConfigsManager ;
49
59
this .globalConfig = globalConfig ;
50
-
51
- this .clock = clock ;
52
60
}
53
61
54
62
@ GET
55
63
@ Produces (MediaType .APPLICATION_JSON )
56
64
@ Operation (
57
65
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."
64
67
)
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
+ ) {
67
87
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 ();
80
114
} catch (NoSuchAlgorithmException e ) {
81
115
throw new AssertionError (e );
82
116
}
@@ -89,7 +123,7 @@ public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey,
89
123
return true ;
90
124
}
91
125
92
- ByteBuffer bb = ByteBuffer .wrap ( new byte [ 16 ] );
126
+ ByteBuffer bb = ByteBuffer .allocate ( 16 );
93
127
bb .putLong (uid .getMostSignificantBits ());
94
128
bb .putLong (uid .getLeastSignificantBits ());
95
129
0 commit comments