From c4be1d424cc91b0a415ad0a6576efebfc3bb14ac Mon Sep 17 00:00:00 2001 From: hampus-fluxer Date: Sun, 4 Jan 2026 22:52:33 +0100 Subject: [PATCH] fix(api): strip expired premium flags using read repair (#24) --- fluxer_api/src/rpc/RpcService.ts | 18 +++++++++ fluxer_api/src/user/UserHelpers.ts | 26 +++++++++++++ .../account/crud/UserDataRepository.ts | 38 ++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/fluxer_api/src/rpc/RpcService.ts b/fluxer_api/src/rpc/RpcService.ts index 257f3fb5..aa52e19b 100644 --- a/fluxer_api/src/rpc/RpcService.ts +++ b/fluxer_api/src/rpc/RpcService.ts @@ -43,6 +43,7 @@ import { } from '~/Constants'; import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel'; import type {IChannelRepository} from '~/channel/IChannelRepository'; +import type {UserRow} from '~/database/types/UserTypes'; import {RateLimitError, UnauthorizedError, UnknownGuildError} from '~/Errors'; import {mapFavoriteMemeToResponse} from '~/favorite_meme/FavoriteMemeModel'; import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository'; @@ -87,6 +88,7 @@ import type {RpcRequest, RpcResponse, RpcResponseGuildData, RpcResponseSessionDa import type {IUserRepository} from '~/user/IUserRepository'; import {CustomStatusValidator} from '~/user/services/CustomStatusValidator'; import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers'; +import {mapExpiredPremiumFields, shouldStripExpiredPremium} from '~/user/UserHelpers'; import { mapRelationshipToResponse, mapUserGuildSettingsToResponse, @@ -567,8 +569,24 @@ export class RpcService { const hadPremium = user.premiumType != null && user.premiumType > 0; const isPremium = user.isPremium(); + const needsPremiumStrip = shouldStripExpiredPremium(user); const hasBeenSanitized = !!(user.flags & UserFlags.PREMIUM_PERKS_SANITIZED); + if (needsPremiumStrip) { + try { + const strippedUser = await this.userRepository.patchUpsert( + user.id, + mapExpiredPremiumFields(() => null) as Partial, + ); + if (strippedUser) { + user = strippedUser; + userData.user = strippedUser; + } + } catch (error) { + Logger.warn({userId: user.id.toString(), error}, 'Failed to strip expired premium on RPC session start'); + } + } + if (hadPremium && !isPremium && !hasBeenSanitized) { if (user.flags & UserFlags.PREMIUM_DISCRIMINATOR) { try { diff --git a/fluxer_api/src/user/UserHelpers.ts b/fluxer_api/src/user/UserHelpers.ts index a98e5c96..685ebe66 100644 --- a/fluxer_api/src/user/UserHelpers.ts +++ b/fluxer_api/src/user/UserHelpers.ts @@ -55,3 +55,29 @@ export function checkIsPremium(user: PremiumCheckable): boolean { return nowMs <= untilMs + GRACE_MS; } + +export const PREMIUM_CLEAR_FIELDS = [ + 'premium_type', + 'premium_since', + 'premium_until', + 'premium_will_cancel', + 'premium_billing_cycle', +] as const; + +export type PremiumClearField = (typeof PREMIUM_CLEAR_FIELDS)[number]; + +export function shouldStripExpiredPremium(user: PremiumCheckable): boolean { + if ((user.premiumType ?? 0) <= 0) { + return false; + } + + return !checkIsPremium(user); +} + +export function mapExpiredPremiumFields(mapper: (field: PremiumClearField) => T): Record { + const result = {} as Record; + for (const field of PREMIUM_CLEAR_FIELDS) { + result[field] = mapper(field); + } + return result; +} diff --git a/fluxer_api/src/user/repositories/account/crud/UserDataRepository.ts b/fluxer_api/src/user/repositories/account/crud/UserDataRepository.ts index f690377c..e00317dc 100644 --- a/fluxer_api/src/user/repositories/account/crud/UserDataRepository.ts +++ b/fluxer_api/src/user/repositories/account/crud/UserDataRepository.ts @@ -20,8 +20,10 @@ import {createUserID, type UserID} from '~/BrandedTypes'; import {buildPatchFromData, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra'; import {EMPTY_USER_ROW, USER_COLUMNS, type UserRow} from '~/database/CassandraTypes'; +import {Logger} from '~/Logger'; import {User} from '~/Models'; import {Users} from '~/Tables'; +import {shouldStripExpiredPremium} from '~/user/UserHelpers'; const FETCH_USERS_BY_IDS_CQL = Users.selectCql({ where: Users.where.in('user_id', 'user_ids'), @@ -68,8 +70,26 @@ export class UserDataRepository { }); } - const user = await fetchOne(FETCH_USER_BY_ID_CQL, {user_id: userId}); - return user ? new User(user) : null; + const userRow = await fetchOne(FETCH_USER_BY_ID_CQL, {user_id: userId}); + if (!userRow) { + return null; + } + + const user = new User(userRow); + + if (shouldStripExpiredPremium(user)) { + try { + await this.readRepairExpiredPremium(user); + const repairedRow = await fetchOne(FETCH_USER_BY_ID_CQL, { + user_id: userId, + }); + return repairedRow ? new User(repairedRow) : null; + } catch (error) { + Logger.warn({userId: user.id.toString(), error}, 'Failed to repair expired premium fields while reading user'); + } + } + + return user; } async findUniqueAssert(userId: UserID): Promise { @@ -148,4 +168,18 @@ export class UserDataRepository { await this.patchUser(userId, patch); } + + private async readRepairExpiredPremium(user: User): Promise { + await this.patchUser(user.id, createPremiumClearPatch()); + } +} + +function createPremiumClearPatch(): UserPatch { + return { + premium_type: Db.clear(), + premium_since: Db.clear(), + premium_until: Db.clear(), + premium_will_cancel: Db.clear(), + premium_billing_cycle: Db.clear(), + }; }