Skip to content

Commit

Permalink
Return backup info at /v1/subscription/configuration
Browse files Browse the repository at this point in the history
- Return the free tier media duration and storage allowance for backups
- Add openapi annotations
- Update default media storage allowance
  • Loading branch information
ravi-signal committed Aug 2, 2024
1 parent 65b2892 commit 10d559b
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 45 deletions.
2 changes: 2 additions & 0 deletions service/config/sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,8 @@ badges:
subscription: # configuration for Stripe subscriptions
badgeExpiration: P30D
badgeGracePeriod: P15D
backupExpiration: P30D
backupFreeTierMediaDuration: P30D
levels:
500:
badge: EXAMPLE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package org.whispersystems.textsecuregcm.backup;

import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.util.DataSize;
import io.grpc.Status;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
Expand Down Expand Up @@ -47,8 +48,8 @@ public class BackupManager {
private static final Logger logger = LoggerFactory.getLogger(BackupManager.class);

static final String MESSAGE_BACKUP_NAME = "messageBackup";
static final long MAX_TOTAL_BACKUP_MEDIA_BYTES = 1024L * 1024L * 1024L * 50L;
static final long MAX_MEDIA_OBJECT_SIZE = 1024L * 1024L * 101L;
public static final long MAX_TOTAL_BACKUP_MEDIA_BYTES = DataSize.gibibytes(100).toBytes();
static final long MAX_MEDIA_OBJECT_SIZE = DataSize.mebibytes(101).toBytes();

// If the last media usage recalculation is over MAX_QUOTA_STALENESS, force a recalculation before quota enforcement.
static final Duration MAX_QUOTA_STALENESS = Duration.ofDays(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class SubscriptionConfiguration {
private final Duration badgeExpiration;

private final Duration backupExpiration;
private final Duration backupFreeTierMediaDuration;
private final Map<Long, SubscriptionLevelConfiguration.Donation> donationLevels;
private final Map<Long, SubscriptionLevelConfiguration.Backup> backupLevels;

Expand All @@ -37,10 +38,12 @@ public SubscriptionConfiguration(
@JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod,
@JsonProperty("badgeExpiration") @Valid Duration badgeExpiration,
@JsonProperty("backupExpiration") @Valid Duration backupExpiration,
@JsonProperty("backupFreeTierMediaDuration") @Valid Duration backupFreeTierMediaDuration,
@JsonProperty("levels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Donation> donationLevels,
@JsonProperty("backupLevels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Backup> backupLevels) {
this.badgeGracePeriod = badgeGracePeriod;
this.badgeExpiration = badgeExpiration;
this.backupFreeTierMediaDuration = backupFreeTierMediaDuration;
this.donationLevels = donationLevels;
this.backupExpiration = backupExpiration;
this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels;
Expand Down Expand Up @@ -107,6 +110,10 @@ public boolean isCurrencyListSameAcrossAllLevels() {
return subscriptionLevels.values().stream().allMatch(level -> currencies.equals(level.prices().keySet()));
}

public Duration getbackupFreeTierMediaDuration() {
return backupFreeTierMediaDuration;
}

private static boolean isValidBackupLevel(final long receiptLevel) {
try {
BackupLevelUtil.fromReceiptLevel(receiptLevel);
Expand All @@ -115,4 +122,5 @@ private static boolean isValidBackupLevel(final long receiptLevel) {
return false;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.Operation;
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 java.math.BigDecimal;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
Expand Down Expand Up @@ -74,6 +78,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.badges.LevelTranslator;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
Expand Down Expand Up @@ -200,18 +205,18 @@ private Map<String, CurrencyConfiguration> buildCurrencyConfiguration() {
@VisibleForTesting
GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(
final List<Locale> acceptableLanguages) {
final Map<String, LevelConfiguration> levels = new HashMap<>();
final Map<String, LevelConfiguration> donationLevels = new HashMap<>();

subscriptionConfiguration.getDonationLevels().forEach((levelId, levelConfig) -> {
final LevelConfiguration levelConfiguration = new LevelConfiguration(
levelTranslator.translate(acceptableLanguages, levelConfig.badge()),
badgeTranslator.translate(acceptableLanguages, levelConfig.badge()));
levels.put(String.valueOf(levelId), levelConfiguration);
donationLevels.put(String.valueOf(levelId), levelConfiguration);
});

final Badge boostBadge = badgeTranslator.translate(acceptableLanguages,
oneTimeDonationConfiguration.boost().badge());
levels.put(String.valueOf(oneTimeDonationConfiguration.boost().level()),
donationLevels.put(String.valueOf(oneTimeDonationConfiguration.boost().level()),
new LevelConfiguration(
boostBadge.getName(),
// NB: the one-time badges are PurchasableBadge, which has a `duration` field
Expand All @@ -220,14 +225,22 @@ GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(
oneTimeDonationConfiguration.boost().expiration())));

final Badge giftBadge = badgeTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.gift().badge());
levels.put(String.valueOf(oneTimeDonationConfiguration.gift().level()),
donationLevels.put(String.valueOf(oneTimeDonationConfiguration.gift().level()),
new LevelConfiguration(
giftBadge.getName(),
new PurchasableBadge(
giftBadge,
oneTimeDonationConfiguration.gift().expiration())));

return new GetSubscriptionConfigurationResponse(buildCurrencyConfiguration(), levels, oneTimeDonationConfiguration.sepaMaximumEuros());
final Map<String, BackupLevelConfiguration> backupLevels = subscriptionConfiguration.getBackupLevels()
.entrySet().stream()
.collect(Collectors.toMap(
e -> String.valueOf(e.getKey()),
ignored -> new BackupLevelConfiguration(BackupManager.MAX_TOTAL_BACKUP_MEDIA_BYTES)));

return new GetSubscriptionConfigurationResponse(buildCurrencyConfiguration(), donationLevels,
new BackupConfiguration(backupLevels, subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()),
oneTimeDonationConfiguration.sepaMaximumEuros());
}

@DELETE
Expand Down Expand Up @@ -542,48 +555,61 @@ public boolean subscriptionsAreSameType(long level1, long level2) {
== subscriptionConfiguration.getSubscriptionLevel(level2).type();
}

/**
* Comprehensive configuration for subscriptions and one-time donations
*
* @param currencies map of lower-cased ISO 3 currency codes to minimums and level-specific scalar amounts
* @param levels map of numeric level IDs to level-specific configuration
*/
public record GetSubscriptionConfigurationResponse(Map<String, CurrencyConfiguration> currencies,
Map<String, LevelConfiguration> levels,
BigDecimal sepaMaximumEuros) {

}

/**
* Configuration for a currency - use to present appropriate client interfaces
*
* @param minimum the minimum amount that may be submitted for a one-time donation in the currency
* @param oneTime map of numeric one-time donation level IDs to the list of default amounts to be
* presented
* @param subscription map of numeric subscription level IDs to the amount charged for that level
* @param backupSubscription map of numeric backup level IDs to the amount charged for that level
* @param supportedPaymentMethods the payment methods that support the given currency
*/
public record CurrencyConfiguration(BigDecimal minimum, Map<String, List<BigDecimal>> oneTime,
Map<String, BigDecimal> subscription,
Map<String, BigDecimal> backupSubscription,
List<String> supportedPaymentMethods) {

}

/**
* Configuration for a donation level - use to present appropriate client interfaces
*
* @param name the localized name for the level
* @param badge the displayable badge associated with the level
*/
public record LevelConfiguration(String name, Badge badge) {

}
@Schema(description = """
Comprehensive configuration for donation subscriptions, backup subscriptions, gift subscriptions, and one-time
donations pricing information for all levels are included in currencies. All levels that have an associated
badge are included in levels. All levels that correspond to a backup payment tier are included in
backupLevels.""")
public record GetSubscriptionConfigurationResponse(
@Schema(description = "A map of lower-cased ISO 3 currency codes to minimums and level-specific scalar amounts")
Map<String, CurrencyConfiguration> currencies,
@Schema(description = "A map of numeric donation level IDs to level-specific badge configuration")
Map<String, LevelConfiguration> levels,
@Schema(description = "Backup specific configuration")
BackupConfiguration backup,
@Schema(description = "The maximum value of a one-time donation SEPA transaction")
BigDecimal sepaMaximumEuros) {}

@Schema(description = "Configuration for a currency - use to present appropriate client interfaces")
public record CurrencyConfiguration(
@Schema(description = "The minimum amount that may be submitted for a one-time donation in the currency")
BigDecimal minimum,
@Schema(description = "A map of numeric one-time donation level IDs to the list of default amounts to be presented")
Map<String, List<BigDecimal>> oneTime,
@Schema(description = "A map of numeric subscription level IDs to the amount charged for that level")
Map<String, BigDecimal> subscription,
@Schema(description = "A map of numeric backup level IDs to the amount charged for that level")
Map<String, BigDecimal> backupSubscription,
@Schema(description = "The payment methods that support the given currency")
List<String> supportedPaymentMethods) {}

@Schema(description = "Configuration for a donation level - use to present appropriate client interfaces")
public record LevelConfiguration(
@Schema(description = "The localized name for the level")
String name,
@Schema(description = "The displayable badge associated with the level")
Badge badge) {}

public record BackupConfiguration(
@Schema(description = "A map of numeric backup level IDs to level-specific backup configuration")
Map<String, BackupLevelConfiguration> levels,
@Schema(description = "The number of days of media a free tier backup user gets")
long backupFreeTierMediaDays) {}

@Schema(description = "Configuration for a backup level - use to present appropriate client interfaces")
public record BackupLevelConfiguration(
@Schema(description = "The amount of media storage in bytes that a paying subscriber may store")
long storageAllowanceBytes) {}

@GET
@Path("/configuration")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Subscription configuration ",
description = """
Returns all configuration for badges, donation subscriptions, backup subscriptions, and one-time donation (
"boost" and "gift") minimum and suggested amounts.""")
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = GetSubscriptionConfigurationResponse.class)))
public CompletableFuture<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.badges.LevelTranslator;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
Expand Down Expand Up @@ -1017,6 +1018,11 @@ void getSubscriptionConfiguration() {
});
});

assertThat(response.backup().levels()).containsOnlyKeys("201").extractingByKey("201").satisfies(configuration -> {
assertThat(configuration.storageAllowanceBytes()).isEqualTo(BackupManager.MAX_TOTAL_BACKUP_MEDIA_BYTES);
});
assertThat(response.backup().backupFreeTierMediaDays()).isEqualTo(30);

// check the badge vs purchasable badge fields
// subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration`
Map<String, Object> genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration")
Expand Down Expand Up @@ -1068,6 +1074,7 @@ private static <T> T readValue(String yaml, Class<T> type) {
badgeExpiration: P30D
badgeGracePeriod: P15D
backupExpiration: P13D
backupFreeTierMediaDuration: P30D
backupLevels:
201:
prices:
Expand Down
2 changes: 2 additions & 0 deletions service/src/test/resources/config/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ badges:
subscription: # configuration for Stripe subscriptions
badgeExpiration: P30D
badgeGracePeriod: P15D
backupExpiration: P30D
backupFreeTierMediaDuration: P30D
levels:
500:
badge: EXAMPLE
Expand Down

0 comments on commit 10d559b

Please sign in to comment.