diff --git a/fluxer_api/src/api/user/services/UserAccountSecurityService.ts b/fluxer_api/src/api/user/services/UserAccountSecurityService.ts index 35e17561c..cb91564ae 100644 --- a/fluxer_api/src/api/user/services/UserAccountSecurityService.ts +++ b/fluxer_api/src/api/user/services/UserAccountSecurityService.ts @@ -8,6 +8,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas'; import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; import type {ApiContext} from '../../ApiContext'; +import {Config} from '../../Config'; import * as AuthPassword from '../../auth/AuthPassword'; import * as AuthSession from '../../auth/AuthSession'; import type {SudoVerificationResult} from '../../auth/services/SudoVerificationService'; @@ -227,7 +228,7 @@ export class UserAccountSecurityService { }; } const discriminatorToUse = normalizedRequestedDiscriminator ?? user.discriminator; - if (discriminatorToUse === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) { + if (this.requiresVisionaryForDiscriminator0000(user, discriminatorToUse)) { throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR); } const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({ @@ -263,7 +264,7 @@ export class UserAccountSecurityService { ValidationErrorCodes.CHANGING_DISCRIMINATOR_REQUIRES_PREMIUM, ); } - if (discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) { + if (this.requiresVisionaryForDiscriminator0000(user, discriminator)) { throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR); } const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({ @@ -277,6 +278,13 @@ export class UserAccountSecurityService { return discriminator; } + private requiresVisionaryForDiscriminator0000(user: User, discriminator: number): boolean { + if (Config.instance.selfHosted) { + return false; + } + return discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME; + } + private async enforceFluxerTagChangeRateLimit(params: { user: User; nextUsername: string; diff --git a/fluxer_api/src/api/user/tests/UserDiscriminator0000.test.ts b/fluxer_api/src/api/user/tests/UserDiscriminator0000.test.ts new file mode 100644 index 000000000..d37b07123 --- /dev/null +++ b/fluxer_api/src/api/user/tests/UserDiscriminator0000.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {getConfig} from '../../Config'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; +import {grantPremium, updateUserProfile} from './UserTestUtils'; + +async function withSelfHostedInstance(callback: () => Promise): Promise { + const config = getConfig(); + const originalSelfHosted = config.instance.selfHosted; + try { + config.instance.selfHosted = true; + await callback(); + } finally { + config.instance.selfHosted = originalSelfHosted; + } +} + +describe('User discriminator #0000', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + afterEach(async () => { + await harness?.shutdown(); + }); + test('blocks #0000 for non-visionary premium users on the main instance', async () => { + const account = await createTestAccount(harness); + await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION); + const {response, text} = await createBuilder(harness, account.token) + .patch('/users/@me') + .body({discriminator: '0000', password: account.password}) + .executeRaw(); + expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST); + expect(text).toContain(ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR); + }); + test('allows #0000 on self-hosted instances', async () => { + const account = await createTestAccount(harness); + await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION); + await withSelfHostedInstance(async () => { + const updated = await updateUserProfile(harness, account.token, { + discriminator: '0000', + password: account.password, + }); + expect(updated.json.discriminator).toBe('0000'); + }); + }); +}); diff --git a/fluxer_app/src/features/user/components/modals/FluxerTagChangeModal.tsx b/fluxer_app/src/features/user/components/modals/FluxerTagChangeModal.tsx index 193e2a4c8..fee7498a4 100644 --- a/fluxer_app/src/features/user/components/modals/FluxerTagChangeModal.tsx +++ b/fluxer_app/src/features/user/components/modals/FluxerTagChangeModal.tsx @@ -29,6 +29,7 @@ import * as UserCommands from '@app/features/user/commands/UserCommands'; import styles from '@app/features/user/components/modals/FluxerTagChangeModal.module.css'; import type {User} from '@app/features/user/models/User'; import {getFormattedDateTime} from '@app/features/user/utils/DateFormatting'; +import {isVisionaryDiscriminator0000Blocked} from '@app/features/user/utils/FluxerTagDiscriminatorUtils'; import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; @@ -315,7 +316,11 @@ export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps) data-flx="user.fluxer-tag-change-modal.input.set-value.text--3" {...form.register('discriminator', { validate: (value) => - !isVisionary && value === '0000' + isVisionaryDiscriminator0000Blocked({ + showPremium, + isVisionary, + discriminator: value, + }) ? i18n._(THE_0000_TAG_IS_RESERVED_FOR_VISIONARY_SUBSCRIBERS_DESCRIPTOR) : true, })} diff --git a/fluxer_app/src/features/user/utils/FluxerTagDiscriminatorUtils.test.ts b/fluxer_app/src/features/user/utils/FluxerTagDiscriminatorUtils.test.ts new file mode 100644 index 000000000..67eb45269 --- /dev/null +++ b/fluxer_app/src/features/user/utils/FluxerTagDiscriminatorUtils.test.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import {isVisionaryDiscriminator0000Blocked} from './FluxerTagDiscriminatorUtils'; + +describe('isVisionaryDiscriminator0000Blocked', () => { + it('allows #0000 on self-hosted instances', () => { + expect( + isVisionaryDiscriminator0000Blocked({ + showPremium: false, + isVisionary: false, + discriminator: '0000', + }), + ).toBe(false); + }); + + it('blocks #0000 for non-visionary users on the main instance', () => { + expect( + isVisionaryDiscriminator0000Blocked({ + showPremium: true, + isVisionary: false, + discriminator: '0000', + }), + ).toBe(true); + }); + + it('allows #0000 for visionary users on the main instance', () => { + expect( + isVisionaryDiscriminator0000Blocked({ + showPremium: true, + isVisionary: true, + discriminator: '0000', + }), + ).toBe(false); + }); + + it('allows other discriminators on the main instance', () => { + expect( + isVisionaryDiscriminator0000Blocked({ + showPremium: true, + isVisionary: false, + discriminator: '1337', + }), + ).toBe(false); + }); +}); diff --git a/fluxer_app/src/features/user/utils/FluxerTagDiscriminatorUtils.ts b/fluxer_app/src/features/user/utils/FluxerTagDiscriminatorUtils.ts new file mode 100644 index 000000000..7f5ce8e43 --- /dev/null +++ b/fluxer_app/src/features/user/utils/FluxerTagDiscriminatorUtils.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export function isVisionaryDiscriminator0000Blocked(options: { + showPremium: boolean; + isVisionary: boolean; + discriminator: string; +}): boolean { + const {showPremium, isVisionary, discriminator} = options; + return showPremium && !isVisionary && discriminator === '0000'; +}