mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 03:38:07 +01:00
Define ProfileController protobufs and setProfile endpoint
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import io.grpc.Status;
|
||||
import org.whispersystems.textsecuregcm.entities.AvatarChange;
|
||||
|
||||
public class AvatarChangeUtil {
|
||||
public static AvatarChange fromGrpcAvatarChange(final org.signal.chat.profile.SetProfileRequest.AvatarChange avatarChangeType) {
|
||||
return switch (avatarChangeType) {
|
||||
case AVATAR_CHANGE_UNCHANGED -> AvatarChange.AVATAR_CHANGE_UNCHANGED;
|
||||
case AVATAR_CHANGE_CLEAR -> AvatarChange.AVATAR_CHANGE_CLEAR;
|
||||
case AVATAR_CHANGE_UPDATE -> AvatarChange.AVATAR_CHANGE_UPDATE;
|
||||
case UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.withDescription("Invalid avatar change value").asRuntimeException();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import org.signal.chat.profile.SetProfileRequest.AvatarChange;
|
||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
|
||||
import org.signal.chat.profile.ReactorProfileGrpc;
|
||||
import org.signal.chat.profile.SetProfileRequest;
|
||||
import org.signal.chat.profile.SetProfileResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
|
||||
private final Clock clock;
|
||||
|
||||
private final ProfilesManager profilesManager;
|
||||
private final AccountsManager accountsManager;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
|
||||
private final PolicySigner policySigner;
|
||||
private final PostPolicyGenerator policyGenerator;
|
||||
|
||||
private final S3AsyncClient asyncS3client;
|
||||
private final String bucket;
|
||||
|
||||
private record AvatarData(Optional<String> currentAvatar,
|
||||
Optional<String> finalAvatar,
|
||||
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
|
||||
|
||||
public ProfileGrpcService(
|
||||
Clock clock,
|
||||
AccountsManager accountsManager,
|
||||
ProfilesManager profilesManager,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
BadgesConfiguration badgesConfiguration,
|
||||
S3AsyncClient asyncS3client,
|
||||
PostPolicyGenerator policyGenerator,
|
||||
PolicySigner policySigner,
|
||||
String bucket) {
|
||||
this.clock = clock;
|
||||
this.accountsManager = accountsManager;
|
||||
this.profilesManager = profilesManager;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
|
||||
BadgeConfiguration::getId, Function.identity()));
|
||||
this.bucket = bucket;
|
||||
this.asyncS3client = asyncS3client;
|
||||
this.policyGenerator = policyGenerator;
|
||||
this.policySigner = policySigner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetProfileResponse> setProfile(SetProfileRequest request) {
|
||||
validateRequest(request);
|
||||
return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)
|
||||
.flatMap(authenticatedDevice -> Mono.zip(
|
||||
Mono.fromFuture(accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)),
|
||||
Mono.fromFuture(profilesManager.getAsync(authenticatedDevice.accountIdentifier(), request.getVersion()))
|
||||
))
|
||||
.doOnNext(accountAndMaybeProfile -> {
|
||||
if (!request.getPaymentAddress().isEmpty()) {
|
||||
final boolean hasDisallowedPrefix =
|
||||
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
|
||||
.anyMatch(prefix -> accountAndMaybeProfile.getT1().getNumber().startsWith(prefix));
|
||||
if (hasDisallowedPrefix && accountAndMaybeProfile.getT2().map(VersionedProfile::getPaymentAddress).isEmpty()) {
|
||||
throw Status.PERMISSION_DENIED.asRuntimeException();
|
||||
}
|
||||
}
|
||||
})
|
||||
.flatMap(accountAndMaybeProfile -> {
|
||||
final Account account = accountAndMaybeProfile.getT1();
|
||||
final Optional<String> currentAvatar = accountAndMaybeProfile.getT2().map(VersionedProfile::getAvatar)
|
||||
.filter(avatar -> avatar.startsWith("profiles/"));
|
||||
final AvatarData avatarData = switch (AvatarChangeUtil.fromGrpcAvatarChange(request.getAvatarChange())) {
|
||||
case AVATAR_CHANGE_UNCHANGED -> new AvatarData(currentAvatar, currentAvatar, Optional.empty());
|
||||
case AVATAR_CHANGE_CLEAR -> new AvatarData(currentAvatar, Optional.empty(), Optional.empty());
|
||||
case AVATAR_CHANGE_UPDATE -> {
|
||||
final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
|
||||
yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),
|
||||
Optional.of(ProfileHelper.generateAvatarUploadFormGrpc(policyGenerator, policySigner, updateAvatarObjectName)));
|
||||
}
|
||||
};
|
||||
|
||||
final Mono<Void> profileSetMono = Mono.fromFuture(profilesManager.setAsync(account.getUuid(),
|
||||
new VersionedProfile(
|
||||
request.getVersion(),
|
||||
encodeToBase64(request.getName().toByteArray()),
|
||||
avatarData.finalAvatar().orElse(null),
|
||||
encodeToBase64(request.getAboutEmoji().toByteArray()),
|
||||
encodeToBase64(request.getAbout().toByteArray()),
|
||||
encodeToBase64(request.getPaymentAddress().toByteArray()),
|
||||
request.getCommitment().toByteArray())));
|
||||
|
||||
final List<Mono<?>> updates = new ArrayList<>(2);
|
||||
final List<AccountBadge> updatedBadges = Optional.of(request.getBadgeIdsList())
|
||||
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, account.getBadges()))
|
||||
.orElseGet(account::getBadges);
|
||||
|
||||
updates.add(Mono.fromFuture(accountsManager.updateAsync(account, a -> {
|
||||
a.setBadges(clock, updatedBadges);
|
||||
a.setCurrentProfileVersion(request.getVersion());
|
||||
})));
|
||||
if (request.getAvatarChange() != AvatarChange.AVATAR_CHANGE_UNCHANGED && avatarData.currentAvatar().isPresent()) {
|
||||
updates.add(Mono.fromFuture(asyncS3client.deleteObject(DeleteObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(avatarData.currentAvatar().get())
|
||||
.build())));
|
||||
}
|
||||
return profileSetMono.thenMany(Flux.merge(updates)).then(Mono.just(avatarData));
|
||||
})
|
||||
.map(avatarData -> avatarData.uploadAttributes()
|
||||
.map(avatarUploadAttributes -> SetProfileResponse.newBuilder().setAttributes(avatarUploadAttributes).build())
|
||||
.orElse(SetProfileResponse.newBuilder().build())
|
||||
);
|
||||
}
|
||||
|
||||
private void validateRequest(SetProfileRequest request) {
|
||||
if (request.getVersion().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Missing version").asRuntimeException();
|
||||
}
|
||||
|
||||
if (request.getCommitment().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Missing profile commitment").asRuntimeException();
|
||||
}
|
||||
|
||||
checkByteStringLength(request.getName(), "Invalid name length", List.of(81, 285));
|
||||
checkByteStringLength(request.getAboutEmoji(), "Invalid about emoji length", List.of(0, 60));
|
||||
checkByteStringLength(request.getAbout(), "Invalid about length", List.of(0, 156, 282, 540));
|
||||
checkByteStringLength(request.getPaymentAddress(), "Invalid mobile coin address length", List.of(0, 582));
|
||||
}
|
||||
|
||||
private static void checkByteStringLength(final ByteString byteString, final String errorMessage, final List<Integer> allowedLengths) {
|
||||
final int byteStringLength = byteString.toByteArray().length;
|
||||
|
||||
for (int allowedLength : allowedLengths) {
|
||||
if (byteStringLength == allowedLength) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw Status.INVALID_ARGUMENT.withDescription(errorMessage).asRuntimeException();
|
||||
}
|
||||
|
||||
private static String encodeToBase64(byte[] input) {
|
||||
return Base64.getEncoder().encodeToString(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
public class ProfileHelper {
|
||||
public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
|
||||
final Clock clock,
|
||||
final Map<String, BadgeConfiguration> badgeConfigurationMap,
|
||||
final List<String> badgeIds,
|
||||
final List<AccountBadge> accountBadges) {
|
||||
LinkedHashMap<String, AccountBadge> existingBadges = new LinkedHashMap<>(accountBadges.size());
|
||||
for (final AccountBadge accountBadge : accountBadges) {
|
||||
existingBadges.putIfAbsent(accountBadge.getId(), accountBadge);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, AccountBadge> result = new LinkedHashMap<>(accountBadges.size());
|
||||
for (final String badgeId : badgeIds) {
|
||||
|
||||
// duplicate in the list, ignore it
|
||||
if (result.containsKey(badgeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is for testing badges and allows them to be added to an account at any time with an expiration of 1 day
|
||||
// in the future.
|
||||
BadgeConfiguration badgeConfiguration = badgeConfigurationMap.get(badgeId);
|
||||
if (badgeConfiguration != null && badgeConfiguration.isTestBadge()) {
|
||||
result.put(badgeId, new AccountBadge(badgeId, clock.instant().plus(Duration.ofDays(1)), true));
|
||||
continue;
|
||||
}
|
||||
|
||||
// reordering or making visible existing badges
|
||||
if (existingBadges.containsKey(badgeId)) {
|
||||
AccountBadge accountBadge = existingBadges.get(badgeId).withVisibility(true);
|
||||
result.put(badgeId, accountBadge);
|
||||
}
|
||||
}
|
||||
|
||||
// take any remaining account badges and make them invisible
|
||||
for (final Map.Entry<String, AccountBadge> entry : existingBadges.entrySet()) {
|
||||
if (!result.containsKey(entry.getKey())) {
|
||||
AccountBadge accountBadge = entry.getValue().withVisibility(false);
|
||||
result.put(accountBadge.getId(), accountBadge);
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(result.values());
|
||||
}
|
||||
|
||||
public static String generateAvatarObjectName() {
|
||||
byte[] object = new byte[16];
|
||||
new SecureRandom().nextBytes(object);
|
||||
|
||||
return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
|
||||
}
|
||||
|
||||
public static org.signal.chat.profile.ProfileAvatarUploadAttributes generateAvatarUploadFormGrpc(
|
||||
final PostPolicyGenerator policyGenerator,
|
||||
final PolicySigner policySigner,
|
||||
final String objectName) {
|
||||
final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
|
||||
final String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return org.signal.chat.profile.ProfileAvatarUploadAttributes.newBuilder()
|
||||
.setPath(objectName)
|
||||
.setCredential(policy.first())
|
||||
.setAcl("private")
|
||||
.setAlgorithm("AWS4-HMAC-SHA256")
|
||||
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
|
||||
.setPolicy(policy.second())
|
||||
.setSignature(ByteString.copyFrom(signature.getBytes()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes generateAvatarUploadForm(
|
||||
final PostPolicyGenerator policyGenerator,
|
||||
final PolicySigner policySigner,
|
||||
final String objectName) {
|
||||
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
|
||||
String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return new org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
|
||||
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user