Add an endpoint to check if your backup-id can be rotated

Co-authored-by: Katherine <katherine@signal.org>
This commit is contained in:
ravi-signal
2025-09-12 16:39:01 -05:00
committed by GitHub
parent e0d39212ec
commit 1770558d5e
4 changed files with 104 additions and 0 deletions

View File

@@ -11,6 +11,8 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -31,6 +33,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@@ -134,6 +137,32 @@ public class BackupAuthManager {
.toCompletableFuture();
}
public record BackupIdRotationLimit(boolean hasPermitsRemaining, Duration nextPermitAvailable) {}
public CompletionStage<BackupIdRotationLimit> checkBackupIdRotationLimit(final Account account) {
final RateLimiter messagesLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID);
final RateLimiter mediaLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID);
final boolean isPaid = hasActiveVoucher(account);
final CompletionStage<Boolean> hasSetMessagesPermits =
messagesLimiter.hasAvailablePermitsAsync(account.getUuid(), 1);
final CompletionStage<Boolean> hasSetMediaPermits = isPaid
? mediaLimiter.hasAvailablePermitsAsync(account.getUuid(), 1)
: CompletableFuture.completedFuture(true);
return hasSetMessagesPermits.thenCombine(hasSetMediaPermits, (hasMessage, hasMedia) -> {
if (hasMedia && hasMessage) {
return new BackupIdRotationLimit(true, Duration.ZERO);
} else {
final Duration timeToNextPermit = Collections.max(Arrays.asList(
messagesLimiter.config().permitRegenerationDuration(),
isPaid ? mediaLimiter.config().permitRegenerationDuration() : Duration.ZERO));
return new BackupIdRotationLimit(false, timeToNextPermit);
}
});
}
public record Credential(BackupAuthCredentialResponse credential, Instant redemptionTime) {}
/**

View File

@@ -163,6 +163,34 @@ public class ArchiveController {
});
}
public record BackupIdLimitResponse(
@Schema(description = "If true, a call to PUT /v1/archive/backupid may succeed without waiting")
boolean hasPermitsRemaining,
@Schema(description = "How long to wait before a permit becomes available, in seconds")
long retryAfterSeconds) {}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/backupid/limits")
@Operation(
summary = "Retrieve limits",
description = """
Determine whether the backup-id can currently be rotated
""")
@ApiResponse(responseCode = "200", description = "Successfully retrieved backup-id rotation limits", useReturnTypeSchema = true)
@ApiResponse(responseCode = "403", description = "Invalid account authentication")
public CompletionStage<BackupIdLimitResponse> checkLimits(@Auth final AuthenticatedDevice authenticatedDevice) {
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return backupAuthManager.checkBackupIdRotationLimit(account).thenApply(limit ->
new BackupIdLimitResponse(limit.hasPermitsRemaining(), limit.nextPermitAvailable().getSeconds()));
});
}
public record RedeemBackupReceiptRequest(
@Schema(description = "Presentation of a ZK receipt encoded in standard padded base64", implementation = String.class)
@JsonDeserialize(using = Deserializer.class)