From 9f9d67b8aa200b620d13ef7664d948462e8e3a30 Mon Sep 17 00:00:00 2001 From: Hampus Kraft Date: Wed, 18 Feb 2026 21:42:43 +0000 Subject: [PATCH] feat: add lowercase fallback to invites --- packages/api/src/invite/InviteService.tsx | 48 +++++++++----- .../tests/InviteSecurityChecks.test.tsx | 63 +++++++++++++++++++ 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/packages/api/src/invite/InviteService.tsx b/packages/api/src/invite/InviteService.tsx index fb83fd08..9e67dfcd 100644 --- a/packages/api/src/invite/InviteService.tsx +++ b/packages/api/src/invite/InviteService.tsx @@ -137,9 +137,11 @@ export class InviteService { ) {} async getInvite(inviteCode: InviteCode): Promise { - const invite = await this.inviteRepository.findUnique(inviteCode); - if (!invite) throw new UnknownInviteError(); - return invite; + const invite = await this.findInviteWithLowercaseFallback(inviteCode); + if (invite) { + return invite; + } + throw new UnknownInviteError(); } async getChannelInvites({userId, channelId}: GetChannelInvitesParams): Promise> { @@ -350,7 +352,7 @@ export class InviteService { } private async performAcceptInvite({userId, inviteCode, requestCache}: AcceptInviteParams): Promise { - const invite = await this.inviteRepository.findUnique(inviteCode); + const invite = await this.findInviteWithLowercaseFallback(inviteCode); if (!invite) throw new UnknownInviteError(); if (invite.maxUses > 0 && invite.uses >= invite.maxUses) { @@ -358,10 +360,10 @@ export class InviteService { const guild = await this.guildService.getGuildSystem(invite.guildId); const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null; if (invite.code !== vanityCode) { - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); } } else if (invite.type === InviteTypes.GROUP_DM) { - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); } throw new UnknownInviteError(); @@ -390,9 +392,9 @@ export class InviteService { }); const newUses = invite.uses + 1; - await this.inviteRepository.updateInviteUses(inviteCode, newUses); + await this.inviteRepository.updateInviteUses(invite.code, newUses); if (invite.maxUses > 0 && newUses >= invite.maxUses) { - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); } return this.cloneInviteWithUses(invite, newUses); @@ -403,9 +405,9 @@ export class InviteService { await this.packService.installPack(userId, invite.guildId); const newUses = invite.uses + 1; - await this.inviteRepository.updateInviteUses(inviteCode, newUses); + await this.inviteRepository.updateInviteUses(invite.code, newUses); if (invite.maxUses > 0 && newUses >= invite.maxUses) { - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); } return this.cloneInviteWithUses(invite, newUses); @@ -459,15 +461,29 @@ export class InviteService { } const newUses = invite.uses + 1; - await this.inviteRepository.updateInviteUses(inviteCode, newUses); + await this.inviteRepository.updateInviteUses(invite.code, newUses); if (!isVanityInvite && invite.maxUses > 0 && newUses >= invite.maxUses) { - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); } return this.cloneInviteWithUses(invite, newUses); } + private async findInviteWithLowercaseFallback(inviteCode: InviteCode): Promise { + const invite = await this.inviteRepository.findUnique(inviteCode); + if (invite) { + return invite; + } + + const lowercaseInviteCode = createInviteCode(inviteCode.toLowerCase()); + if (lowercaseInviteCode === inviteCode) { + return null; + } + + return this.inviteRepository.findUnique(lowercaseInviteCode); + } + private cloneInviteWithUses(invite: Invite, uses: number): Invite { const row = invite.toRow(); return new Invite({ @@ -483,7 +499,7 @@ export class InviteService { } async deleteInvite({userId, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise { - const invite = await this.inviteRepository.findUnique(inviteCode); + const invite = await this.findInviteWithLowercaseFallback(inviteCode); if (!invite) throw new UnknownInviteError(); if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) { @@ -497,7 +513,7 @@ export class InviteService { throw new PackAccessDeniedError(); } - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); return; } @@ -517,7 +533,7 @@ export class InviteService { throw new MissingPermissionsError(); } - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); return; } @@ -537,7 +553,7 @@ export class InviteService { await checkPermission(Permissions.MANAGE_GUILD); } - await this.inviteRepository.delete(inviteCode); + await this.inviteRepository.delete(invite.code); await this.logGuildInviteAction({ invite, userId, diff --git a/packages/api/src/invite/tests/InviteSecurityChecks.test.tsx b/packages/api/src/invite/tests/InviteSecurityChecks.test.tsx index d08effa6..f62673a5 100644 --- a/packages/api/src/invite/tests/InviteSecurityChecks.test.tsx +++ b/packages/api/src/invite/tests/InviteSecurityChecks.test.tsx @@ -18,20 +18,43 @@ */ import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createInviteCode, createUserID} from '@fluxer/api/src/BrandedTypes'; import { acceptInvite, createChannelInvite, createGuild, getChannel, } from '@fluxer/api/src/channel/tests/ChannelTestUtils'; +import {InviteRepository} from '@fluxer/api/src/invite/InviteRepository'; import {deleteInvite} from '@fluxer/api/src/invite/tests/InviteTestUtils'; import {banUser} from '@fluxer/api/src/moderation/tests/ModerationTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness'; import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants'; import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder'; +import {InviteTypes} from '@fluxer/constants/src/ChannelConstants'; import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas'; import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; +async function createGuildInviteWithCodeForTesting( + code: string, + guildId: string, + channelId: string, + inviterId: string, +): Promise { + const inviteRepository = new InviteRepository(); + await inviteRepository.create({ + code: createInviteCode(code), + type: InviteTypes.GUILD, + guild_id: createGuildID(BigInt(guildId)), + channel_id: createChannelID(BigInt(channelId)), + inviter_id: createUserID(BigInt(inviterId)), + uses: 0, + max_uses: 0, + max_age: 0, + temporary: false, + }); +} + describe('Invite Security Checks', () => { let harness: ApiTestHarness; @@ -110,6 +133,46 @@ describe('Invite Security Checks', () => { .execute(); }); + test('invite lookup falls back to lowercase when casing differs', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Vanity Lookup Guild'); + const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!); + const inviteCode = 'vanitycase'; + + await createGuildInviteWithCodeForTesting(inviteCode, guild.id, systemChannel.id, owner.userId); + + const inviteResponse = await createBuilder<{code: string}>(harness, owner.token) + .get(`/invites/${inviteCode.toUpperCase()}`) + .expect(HTTP_STATUS.OK) + .execute(); + + expect(inviteResponse.code).toBe(inviteCode); + + await deleteInvite(harness, owner.token, inviteCode); + }); + + test('invite accept falls back to lowercase and updates uses', async () => { + const owner = await createTestAccount(harness); + const joiner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Invite Accept Case Guild'); + const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!); + const inviteCode = 'acceptcase'; + + await createGuildInviteWithCodeForTesting(inviteCode, guild.id, systemChannel.id, owner.userId); + + const accepted = await acceptInvite(harness, joiner.token, inviteCode.toUpperCase()); + expect(accepted.guild.id).toBe(guild.id); + + const invitesList = await createBuilder>(harness, owner.token) + .get(`/guilds/${guild.id}/invites`) + .execute(); + const updatedInvite = invitesList.find((invite) => invite.code === inviteCode); + expect(updatedInvite).toBeDefined(); + expect(updatedInvite!.uses).toBe(1); + + await deleteInvite(harness, owner.token, inviteCode); + }); + test('invite with max_uses limit becomes invalid after exhaustion', async () => { const owner = await createTestAccount(harness); const joiner1 = await createTestAccount(harness);