fix(api): premium sanitisation recursion issue (#25)

This commit is contained in:
hampus-fluxer 2026-01-05 03:05:15 +01:00 committed by GitHub
parent c4be1d424c
commit 11ec2e63b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 39 additions and 42 deletions

View File

@ -43,7 +43,6 @@ import {
} from '~/Constants'; } from '~/Constants';
import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel'; import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository'; import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {UserRow} from '~/database/types/UserTypes';
import {RateLimitError, UnauthorizedError, UnknownGuildError} from '~/Errors'; import {RateLimitError, UnauthorizedError, UnknownGuildError} from '~/Errors';
import {mapFavoriteMemeToResponse} from '~/favorite_meme/FavoriteMemeModel'; import {mapFavoriteMemeToResponse} from '~/favorite_meme/FavoriteMemeModel';
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository'; import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
@ -88,7 +87,7 @@ import type {RpcRequest, RpcResponse, RpcResponseGuildData, RpcResponseSessionDa
import type {IUserRepository} from '~/user/IUserRepository'; import type {IUserRepository} from '~/user/IUserRepository';
import {CustomStatusValidator} from '~/user/services/CustomStatusValidator'; import {CustomStatusValidator} from '~/user/services/CustomStatusValidator';
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers'; import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
import {mapExpiredPremiumFields, shouldStripExpiredPremium} from '~/user/UserHelpers'; import {createPremiumClearPatch, shouldStripExpiredPremium} from '~/user/UserHelpers';
import { import {
mapRelationshipToResponse, mapRelationshipToResponse,
mapUserGuildSettingsToResponse, mapUserGuildSettingsToResponse,
@ -574,10 +573,7 @@ export class RpcService {
if (needsPremiumStrip) { if (needsPremiumStrip) {
try { try {
const strippedUser = await this.userRepository.patchUpsert( const strippedUser = await this.userRepository.patchUpsert(user.id, createPremiumClearPatch());
user.id,
mapExpiredPremiumFields(() => null) as Partial<UserRow>,
);
if (strippedUser) { if (strippedUser) {
user = strippedUser; user = strippedUser;
userData.user = strippedUser; userData.user = strippedUser;

View File

@ -19,6 +19,7 @@
import {Config} from '~/Config'; import {Config} from '~/Config';
import {UserFlags} from '~/Constants'; import {UserFlags} from '~/Constants';
import type {UserRow} from '~/database/types/UserTypes';
interface PremiumCheckable { interface PremiumCheckable {
premiumType: number | null; premiumType: number | null;
@ -81,3 +82,7 @@ export function mapExpiredPremiumFields<T>(mapper: (field: PremiumClearField) =>
} }
return result; return result;
} }
export function createPremiumClearPatch(): Partial<UserRow> {
return mapExpiredPremiumFields(() => null) as Partial<UserRow>;
}

View File

@ -47,6 +47,7 @@ import {
z, z,
} from '~/Schema'; } from '~/Schema';
import {getCachedUserPartialResponse, mapUserToPartialResponseWithCache} from '~/user/UserCacheHelpers'; import {getCachedUserPartialResponse, mapUserToPartialResponseWithCache} from '~/user/UserCacheHelpers';
import {createPremiumClearPatch, shouldStripExpiredPremium} from '~/user/UserHelpers';
import { import {
mapGuildMemberToProfileResponse, mapGuildMemberToProfileResponse,
mapUserGuildSettingsToResponse, mapUserGuildSettingsToResponse,
@ -406,7 +407,32 @@ export const UserAccountController = (app: HonoApp) => {
requestCache: ctx.get('requestCache'), requestCache: ctx.get('requestCache'),
}); });
const userProfile = mapUserToProfileResponse(profile.user); let profileUser = profile.user;
let premiumType = profile.premiumType;
let premiumSince = profile.premiumSince;
let premiumLifetimeSequence = profile.premiumLifetimeSequence;
if (shouldStripExpiredPremium(profileUser)) {
try {
const sanitizedUser = await ctx
.get('userRepository')
.patchUpsert(profileUser.id, createPremiumClearPatch(), profileUser.toRow());
if (sanitizedUser) {
profileUser = sanitizedUser;
profile.user = sanitizedUser;
premiumType = undefined;
premiumSince = undefined;
premiumLifetimeSequence = undefined;
}
} catch (error) {
Logger.warn(
{userId: profileUser.id.toString(), error},
'Failed to sanitize expired premium fields before returning profile',
);
}
}
const userProfile = mapUserToProfileResponse(profileUser);
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null); const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null);
const mutualFriends = profile.mutualFriends const mutualFriends = profile.mutualFriends
@ -423,16 +449,16 @@ export const UserAccountController = (app: HonoApp) => {
return ctx.json({ return ctx.json({
user: await mapUserToPartialResponseWithCache({ user: await mapUserToPartialResponseWithCache({
user: profile.user, user: profileUser,
userCacheService: ctx.get('userCacheService'), userCacheService: ctx.get('userCacheService'),
requestCache: ctx.get('requestCache'), requestCache: ctx.get('requestCache'),
}), }),
user_profile: userProfile, user_profile: userProfile,
guild_member: profile.guildMember ?? undefined, guild_member: profile.guildMember ?? undefined,
guild_member_profile: guildMemberProfile ?? undefined, guild_member_profile: guildMemberProfile ?? undefined,
premium_type: profile.premiumType, premium_type: premiumType,
premium_since: profile.premiumSince?.toISOString(), premium_since: premiumSince?.toISOString(),
premium_lifetime_sequence: profile.premiumLifetimeSequence, premium_lifetime_sequence: premiumLifetimeSequence,
mutual_friends: mutualFriends, mutual_friends: mutualFriends,
mutual_guilds: profile.mutualGuilds, mutual_guilds: profile.mutualGuilds,
}); });

View File

@ -20,10 +20,8 @@
import {createUserID, type UserID} from '~/BrandedTypes'; import {createUserID, type UserID} from '~/BrandedTypes';
import {buildPatchFromData, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra'; import {buildPatchFromData, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {EMPTY_USER_ROW, USER_COLUMNS, type UserRow} from '~/database/CassandraTypes'; import {EMPTY_USER_ROW, USER_COLUMNS, type UserRow} from '~/database/CassandraTypes';
import {Logger} from '~/Logger';
import {User} from '~/Models'; import {User} from '~/Models';
import {Users} from '~/Tables'; import {Users} from '~/Tables';
import {shouldStripExpiredPremium} from '~/user/UserHelpers';
const FETCH_USERS_BY_IDS_CQL = Users.selectCql({ const FETCH_USERS_BY_IDS_CQL = Users.selectCql({
where: Users.where.in('user_id', 'user_ids'), where: Users.where.in('user_id', 'user_ids'),
@ -75,21 +73,7 @@ export class UserDataRepository {
return null; return null;
} }
const user = new User(userRow); return new User(userRow);
if (shouldStripExpiredPremium(user)) {
try {
await this.readRepairExpiredPremium(user);
const repairedRow = await fetchOne<UserRow>(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<User> { async findUniqueAssert(userId: UserID): Promise<User> {
@ -168,18 +152,4 @@ export class UserDataRepository {
await this.patchUser(userId, patch); await this.patchUser(userId, patch);
} }
private async readRepairExpiredPremium(user: User): Promise<void> {
await this.patchUser(user.id, createPremiumClearPatch());
}
}
function createPremiumClearPatch(): UserPatch {
return {
premium_type: Db.clear<number | null>(),
premium_since: Db.clear<Date | null>(),
premium_until: Db.clear<Date | null>(),
premium_will_cancel: Db.clear<boolean | null>(),
premium_billing_cycle: Db.clear<string | null>(),
};
} }