feat: add lowercase fallback to invites
This commit is contained in:
parent
f1bfd080e2
commit
9f9d67b8aa
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user