From 10d559bbb5115b2d92b04d6af446bf310d97bcec Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Thu, 1 Aug 2024 17:16:40 -0500 Subject: [PATCH] Return backup info at `/v1/subscription/configuration` - Return the free tier media duration and storage allowance for backups - Add openapi annotations - Update default media storage allowance --- service/config/sample.yml | 2 + .../textsecuregcm/backup/BackupManager.java | 5 +- .../SubscriptionConfiguration.java | 8 ++ .../controllers/SubscriptionController.java | 104 +++++++++++------- .../SubscriptionControllerTest.java | 7 ++ service/src/test/resources/config/test.yml | 2 + 6 files changed, 87 insertions(+), 41 deletions(-) diff --git a/service/config/sample.yml b/service/config/sample.yml index 2ee21cecc..c2b70e204 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -366,6 +366,8 @@ badges: subscription: # configuration for Stripe subscriptions badgeExpiration: P30D badgeGracePeriod: P15D + backupExpiration: P30D + backupFreeTierMediaDuration: P30D levels: 500: badge: EXAMPLE diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java index 3889acd1f..87068fb27 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java @@ -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; @@ -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); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java index a1bf5be7b..e978d60fa 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java @@ -29,6 +29,7 @@ public class SubscriptionConfiguration { private final Duration badgeExpiration; private final Duration backupExpiration; + private final Duration backupFreeTierMediaDuration; private final Map donationLevels; private final Map backupLevels; @@ -37,10 +38,12 @@ public class 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; @@ -107,6 +110,10 @@ public class SubscriptionConfiguration { 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); @@ -115,4 +122,5 @@ public class SubscriptionConfiguration { return false; } } + } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index d93006960..55633c7d5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -14,6 +14,10 @@ import io.dropwizard.auth.Auth; 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; @@ -74,6 +78,7 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; 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; @@ -200,18 +205,18 @@ public class SubscriptionController { @VisibleForTesting GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse( final List acceptableLanguages) { - final Map levels = new HashMap<>(); + final Map 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 @@ -220,14 +225,22 @@ public class SubscriptionController { 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 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 @@ -542,48 +555,61 @@ public class SubscriptionController { == 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 currencies, - Map levels, - BigDecimal sepaMaximumEuros) { + @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 currencies, + @Schema(description = "A map of numeric donation level IDs to level-specific badge configuration") + Map 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> oneTime, + @Schema(description = "A map of numeric subscription level IDs to the amount charged for that level") + Map subscription, + @Schema(description = "A map of numeric backup level IDs to the amount charged for that level") + Map backupSubscription, + @Schema(description = "The payment methods that support the given currency") + List supportedPaymentMethods) {} - /** - * 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> oneTime, - Map subscription, - Map backupSubscription, - List 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 levels, + @Schema(description = "The number of days of media a free tier backup user gets") + long backupFreeTierMediaDays) {} - /** - * 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 = "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 getConfiguration(@Context ContainerRequestContext containerRequestContext) { return CompletableFuture.supplyAsync(() -> { List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index 0c294601b..cf1c10280 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -64,6 +64,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; 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; @@ -1017,6 +1018,11 @@ class SubscriptionControllerTest { }); }); + 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 genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration") @@ -1068,6 +1074,7 @@ class SubscriptionControllerTest { badgeExpiration: P30D badgeGracePeriod: P15D backupExpiration: P13D + backupFreeTierMediaDuration: P30D backupLevels: 201: prices: diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 691ec2bf6..0454a7f1d 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -361,6 +361,8 @@ badges: subscription: # configuration for Stripe subscriptions badgeExpiration: P30D badgeGracePeriod: P15D + backupExpiration: P30D + backupFreeTierMediaDuration: P30D levels: 500: badge: EXAMPLE