feat: add lowercase fallback to invites

This commit is contained in:
Hampus Kraft 2026-02-18 21:42:43 +00:00
parent f1bfd080e2
commit 9f9d67b8aa
No known key found for this signature in database
GPG Key ID: 6090864C465A454D
2 changed files with 95 additions and 16 deletions

View File

@ -137,9 +137,11 @@ export class InviteService {
) {}
async getInvite(inviteCode: InviteCode): Promise<Invite> {
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<Array<Invite>> {
@ -350,7 +352,7 @@ export class InviteService {
}
private async performAcceptInvite({userId, inviteCode, requestCache}: AcceptInviteParams): Promise<Invite> {
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<Invite | null> {
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<void> {
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,

View File

@ -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<void> {
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<Array<GuildInviteMetadataResponse>>(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);