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> { async getInvite(inviteCode: InviteCode): Promise<Invite> {
const invite = await this.inviteRepository.findUnique(inviteCode); const invite = await this.findInviteWithLowercaseFallback(inviteCode);
if (!invite) throw new UnknownInviteError(); if (invite) {
return invite; return invite;
}
throw new UnknownInviteError();
} }
async getChannelInvites({userId, channelId}: GetChannelInvitesParams): Promise<Array<Invite>> { async getChannelInvites({userId, channelId}: GetChannelInvitesParams): Promise<Array<Invite>> {
@ -350,7 +352,7 @@ export class InviteService {
} }
private async performAcceptInvite({userId, inviteCode, requestCache}: AcceptInviteParams): Promise<Invite> { 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) throw new UnknownInviteError();
if (invite.maxUses > 0 && invite.uses >= invite.maxUses) { if (invite.maxUses > 0 && invite.uses >= invite.maxUses) {
@ -358,10 +360,10 @@ export class InviteService {
const guild = await this.guildService.getGuildSystem(invite.guildId); const guild = await this.guildService.getGuildSystem(invite.guildId);
const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null; const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null;
if (invite.code !== vanityCode) { if (invite.code !== vanityCode) {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
} }
} else if (invite.type === InviteTypes.GROUP_DM) { } else if (invite.type === InviteTypes.GROUP_DM) {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
} }
throw new UnknownInviteError(); throw new UnknownInviteError();
@ -390,9 +392,9 @@ export class InviteService {
}); });
const newUses = invite.uses + 1; 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) { if (invite.maxUses > 0 && newUses >= invite.maxUses) {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
} }
return this.cloneInviteWithUses(invite, newUses); return this.cloneInviteWithUses(invite, newUses);
@ -403,9 +405,9 @@ export class InviteService {
await this.packService.installPack(userId, invite.guildId); await this.packService.installPack(userId, invite.guildId);
const newUses = invite.uses + 1; 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) { if (invite.maxUses > 0 && newUses >= invite.maxUses) {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
} }
return this.cloneInviteWithUses(invite, newUses); return this.cloneInviteWithUses(invite, newUses);
@ -459,15 +461,29 @@ export class InviteService {
} }
const newUses = invite.uses + 1; 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) { if (!isVanityInvite && invite.maxUses > 0 && newUses >= invite.maxUses) {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
} }
return this.cloneInviteWithUses(invite, newUses); 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 { private cloneInviteWithUses(invite: Invite, uses: number): Invite {
const row = invite.toRow(); const row = invite.toRow();
return new Invite({ return new Invite({
@ -483,7 +499,7 @@ export class InviteService {
} }
async deleteInvite({userId, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise<void> { 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) throw new UnknownInviteError();
if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) { if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) {
@ -497,7 +513,7 @@ export class InviteService {
throw new PackAccessDeniedError(); throw new PackAccessDeniedError();
} }
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
return; return;
} }
@ -517,7 +533,7 @@ export class InviteService {
throw new MissingPermissionsError(); throw new MissingPermissionsError();
} }
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
return; return;
} }
@ -537,7 +553,7 @@ export class InviteService {
await checkPermission(Permissions.MANAGE_GUILD); await checkPermission(Permissions.MANAGE_GUILD);
} }
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(invite.code);
await this.logGuildInviteAction({ await this.logGuildInviteAction({
invite, invite,
userId, userId,

View File

@ -18,20 +18,43 @@
*/ */
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils'; import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createChannelID, createGuildID, createInviteCode, createUserID} from '@fluxer/api/src/BrandedTypes';
import { import {
acceptInvite, acceptInvite,
createChannelInvite, createChannelInvite,
createGuild, createGuild,
getChannel, getChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils'; } 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 {deleteInvite} from '@fluxer/api/src/invite/tests/InviteTestUtils';
import {banUser} from '@fluxer/api/src/moderation/tests/ModerationTestUtils'; import {banUser} from '@fluxer/api/src/moderation/tests/ModerationTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness'; import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants'; import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder'; 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 type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; 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', () => { describe('Invite Security Checks', () => {
let harness: ApiTestHarness; let harness: ApiTestHarness;
@ -110,6 +133,46 @@ describe('Invite Security Checks', () => {
.execute(); .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 () => { test('invite with max_uses limit becomes invalid after exhaustion', async () => {
const owner = await createTestAccount(harness); const owner = await createTestAccount(harness);
const joiner1 = await createTestAccount(harness); const joiner1 = await createTestAccount(harness);