refactor progress
This commit is contained in:
40
packages/api/src/invite/IInviteRepository.tsx
Normal file
40
packages/api/src/invite/IInviteRepository.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, InviteCode, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {Invite} from '@fluxer/api/src/models/Invite';
|
||||
|
||||
export abstract class IInviteRepository {
|
||||
abstract findUnique(code: InviteCode): Promise<Invite | null>;
|
||||
abstract listChannelInvites(channelId: ChannelID): Promise<Array<Invite>>;
|
||||
abstract listGuildInvites(guildId: GuildID): Promise<Array<Invite>>;
|
||||
abstract create(data: {
|
||||
code: InviteCode;
|
||||
type: number;
|
||||
guild_id: GuildID | null;
|
||||
channel_id?: ChannelID | null;
|
||||
inviter_id?: UserID | null;
|
||||
uses: number;
|
||||
max_uses: number;
|
||||
max_age: number;
|
||||
temporary?: boolean;
|
||||
}): Promise<Invite>;
|
||||
abstract updateInviteUses(code: InviteCode, uses: number): Promise<void>;
|
||||
abstract delete(code: InviteCode): Promise<void>;
|
||||
}
|
||||
254
packages/api/src/invite/InviteController.tsx
Normal file
254
packages/api/src/invite/InviteController.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createChannelID, createGuildID, createInviteCode} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
ChannelIdParam,
|
||||
GuildIdParam,
|
||||
InviteCodeParam,
|
||||
PackIdParam,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
ChannelInviteCreateRequest,
|
||||
InviteMetadataResponseSchema,
|
||||
InviteResponseSchema,
|
||||
PackInviteCreateRequest,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function InviteController(app: HonoApp) {
|
||||
app.get(
|
||||
'/invites/:invite_code',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_GET),
|
||||
Validator('param', InviteCodeParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_invite',
|
||||
summary: 'Get invite information',
|
||||
description:
|
||||
'Fetches detailed information about an invite using its code, including the guild, channel, or pack it belongs to and metadata such as expiration and usage limits. This endpoint does not require authentication and does not consume the invite.',
|
||||
responseSchema: InviteResponseSchema,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const inviteCode = createInviteCode(ctx.req.valid('param').invite_code);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await inviteRequestService.getInvite({inviteCode, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/invites/:invite_code',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_ACCEPT),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', InviteCodeParam),
|
||||
OpenAPI({
|
||||
operationId: 'accept_invite',
|
||||
summary: 'Accept invite',
|
||||
description:
|
||||
'Accepts an invite using its code, adding the authenticated user to the corresponding guild, pack, or other entity. The invite usage count is incremented, and if it reaches its maximum usage limit or expiration, the invite is automatically revoked. Returns the accepted invite details.',
|
||||
responseSchema: InviteResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const inviteCode = createInviteCode(ctx.req.valid('param').invite_code);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const invite = await inviteRequestService.acceptInvite({userId, inviteCode, requestCache});
|
||||
return ctx.json(invite);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/invites/:invite_code',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', InviteCodeParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_invite',
|
||||
summary: 'Delete invite',
|
||||
description:
|
||||
'Permanently deletes an invite by its code, preventing any further usage. The authenticated user must have permission to manage invites for the guild, channel, or pack associated with the invite. This action can be logged in the audit log if an X-Audit-Log-Reason header is provided.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const inviteCode = createInviteCode(ctx.req.valid('param').invite_code);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await inviteRequestService.deleteInvite({userId, inviteCode, auditLogReason});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('json', ChannelInviteCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_channel_invite',
|
||||
summary: 'Create channel invite',
|
||||
description:
|
||||
'Creates a new invite for the specified channel with optional parameters such as maximum age, maximum uses, and temporary membership settings. The authenticated user must have permission to create invites for the channel. Returns the created invite with full metadata including usage statistics.',
|
||||
responseSchema: InviteMetadataResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(
|
||||
await inviteRequestService.createChannelInvite({
|
||||
inviterId: userId,
|
||||
channelId,
|
||||
requestCache,
|
||||
data: ctx.req.valid('json'),
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_LIST_CHANNEL),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_channel_invites',
|
||||
summary: 'List channel invites',
|
||||
description:
|
||||
'Retrieves all currently active invites for the specified channel, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the channel. Returns an array of invite metadata objects.',
|
||||
responseSchema: z.array(InviteMetadataResponseSchema),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await inviteRequestService.listChannelInvites({userId, channelId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_LIST_GUILD),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_invites',
|
||||
summary: 'List guild invites',
|
||||
description:
|
||||
'Retrieves all currently active invites across all channels in the specified guild, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the guild. Returns an array of invite metadata objects.',
|
||||
responseSchema: z.array(InviteMetadataResponseSchema),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await inviteRequestService.listGuildInvites({userId, guildId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/packs/:pack_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.PACKS_INVITES_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', PackIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_pack_invites',
|
||||
summary: 'List pack invites',
|
||||
description:
|
||||
'Retrieves all currently active invites for the specified pack, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the pack and must be a default (non-bot) user. Returns an array of invite metadata objects.',
|
||||
responseSchema: z.array(InviteMetadataResponseSchema),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const packId = createGuildID(ctx.req.valid('param').pack_id);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await inviteRequestService.listPackInvites({userId, packId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/packs/:pack_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.PACKS_INVITES_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', PackIdParam),
|
||||
Validator('json', PackInviteCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_pack_invite',
|
||||
summary: 'Create pack invite',
|
||||
description:
|
||||
'Creates a new invite for the specified pack with optional parameters such as maximum age and maximum uses. The authenticated user must have permission to create invites for the pack and must be a default (non-bot) user. Returns the created invite with full metadata including usage statistics.',
|
||||
responseSchema: InviteMetadataResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const packId = createGuildID(ctx.req.valid('param').pack_id);
|
||||
const inviteRequestService = ctx.get('inviteRequestService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(
|
||||
await inviteRequestService.createPackInvite({
|
||||
inviterId: userId,
|
||||
packId,
|
||||
requestCache,
|
||||
data: ctx.req.valid('json'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
310
packages/api/src/invite/InviteModel.tsx
Normal file
310
packages/api/src/invite/InviteModel.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Invite} from '@fluxer/api/src/models/Invite';
|
||||
import {mapPackToSummary} from '@fluxer/api/src/pack/PackModel';
|
||||
import type {PackRepository} from '@fluxer/api/src/pack/PackRepository';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UnknownInviteError} from '@fluxer/errors/src/domains/invite/UnknownInviteError';
|
||||
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
|
||||
import type {ChannelPartialResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildPartialResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {
|
||||
GroupDmInviteMetadataResponse,
|
||||
GroupDmInviteResponse,
|
||||
GuildInviteMetadataResponse,
|
||||
GuildInviteResponse,
|
||||
PackInviteMetadataResponse,
|
||||
PackInviteResponse,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import type {z} from 'zod';
|
||||
|
||||
interface MapInviteToGuildInviteResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<z.infer<typeof GuildPartialResponse>>;
|
||||
getGuildCounts: (guildId: GuildID) => Promise<{memberCount: number; presenceCount: number}>;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
interface MapInviteToGuildInviteMetadataResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<z.infer<typeof GuildPartialResponse>>;
|
||||
getGuildCounts: (guildId: GuildID) => Promise<{memberCount: number; presenceCount: number}>;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
interface MapInviteToGroupDmInviteResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
|
||||
}
|
||||
|
||||
interface MapInviteToGroupDmInviteMetadataResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
|
||||
}
|
||||
|
||||
export async function mapInviteToGuildInviteResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getGuildResponse,
|
||||
getGuildCounts,
|
||||
gatewayService,
|
||||
}: MapInviteToGuildInviteResponseParams): Promise<z.infer<typeof GuildInviteResponse>> {
|
||||
if (!invite.guildId) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
let channelId = invite.channelId;
|
||||
if (!channelId) {
|
||||
const resolvedChannelId = await gatewayService.getFirstViewableTextChannel(invite.guildId);
|
||||
if (!resolvedChannelId) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
channelId = resolvedChannelId;
|
||||
}
|
||||
|
||||
const [channel, guild, inviter, counts] = await Promise.all([
|
||||
getChannelResponse(channelId),
|
||||
getGuildResponse(invite.guildId),
|
||||
invite.inviterId
|
||||
? getCachedUserPartialResponse({
|
||||
userId: invite.inviterId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
})
|
||||
: null,
|
||||
getGuildCounts(invite.guildId),
|
||||
]);
|
||||
|
||||
const expiresAt = invite.maxAge > 0 ? new Date(invite.createdAt.getTime() + invite.maxAge * 1000) : null;
|
||||
|
||||
return {
|
||||
code: invite.code,
|
||||
type: InviteTypes.GUILD,
|
||||
guild,
|
||||
channel,
|
||||
inviter,
|
||||
member_count: counts.memberCount,
|
||||
presence_count: counts.presenceCount,
|
||||
expires_at: expiresAt?.toISOString() ?? null,
|
||||
temporary: invite.temporary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapInviteToGuildInviteMetadataResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getGuildResponse,
|
||||
getGuildCounts,
|
||||
gatewayService,
|
||||
}: MapInviteToGuildInviteMetadataResponseParams): Promise<z.infer<typeof GuildInviteMetadataResponse>> {
|
||||
const baseResponse = await mapInviteToGuildInviteResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getGuildResponse,
|
||||
getGuildCounts,
|
||||
gatewayService,
|
||||
});
|
||||
|
||||
return {
|
||||
...baseResponse,
|
||||
created_at: invite.createdAt.toISOString(),
|
||||
uses: invite.uses,
|
||||
max_uses: invite.maxUses,
|
||||
max_age: invite.maxAge,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapInviteToGroupDmInviteResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getChannelSystem,
|
||||
getChannelMemberCount,
|
||||
}: MapInviteToGroupDmInviteResponseParams): Promise<z.infer<typeof GroupDmInviteResponse>> {
|
||||
if (!invite.channelId) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
const [channel, inviter, memberCount, channelSystem] = await Promise.all([
|
||||
getChannelResponse(invite.channelId),
|
||||
invite.inviterId
|
||||
? getCachedUserPartialResponse({
|
||||
userId: invite.inviterId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
})
|
||||
: null,
|
||||
getChannelMemberCount(invite.channelId),
|
||||
getChannelSystem(invite.channelId),
|
||||
]);
|
||||
if (!channelSystem) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
const recipientIds = Array.from(channelSystem.recipientIds);
|
||||
const recipientPartials = await getCachedUserPartialResponses({
|
||||
userIds: recipientIds,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
const recipients = recipientIds.map((recipientId) => {
|
||||
const recipientPartial = recipientPartials.get(recipientId);
|
||||
if (!recipientPartial) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
return {username: recipientPartial.username};
|
||||
});
|
||||
const channelWithRecipients = {...channel, recipients};
|
||||
|
||||
const expiresAt = invite.maxAge > 0 ? new Date(invite.createdAt.getTime() + invite.maxAge * 1000) : null;
|
||||
|
||||
return {
|
||||
code: invite.code,
|
||||
type: InviteTypes.GROUP_DM,
|
||||
channel: channelWithRecipients,
|
||||
inviter,
|
||||
member_count: memberCount,
|
||||
expires_at: expiresAt?.toISOString() ?? null,
|
||||
temporary: invite.temporary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapInviteToGroupDmInviteMetadataResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getChannelSystem,
|
||||
getChannelMemberCount,
|
||||
}: MapInviteToGroupDmInviteMetadataResponseParams): Promise<z.infer<typeof GroupDmInviteMetadataResponse>> {
|
||||
const baseResponse = await mapInviteToGroupDmInviteResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getChannelSystem,
|
||||
getChannelMemberCount,
|
||||
});
|
||||
|
||||
return {
|
||||
...baseResponse,
|
||||
created_at: invite.createdAt.toISOString(),
|
||||
uses: invite.uses,
|
||||
max_uses: invite.maxUses,
|
||||
};
|
||||
}
|
||||
|
||||
interface MapInviteToPackInviteResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
packRepository: PackRepository;
|
||||
}
|
||||
|
||||
const buildPackInviteBase = async ({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
packRepository,
|
||||
}: MapInviteToPackInviteResponseParams): Promise<z.infer<typeof PackInviteResponse>> => {
|
||||
if (!invite.guildId) {
|
||||
throw new UnknownPackError();
|
||||
}
|
||||
|
||||
const pack = await packRepository.getPack(invite.guildId);
|
||||
if (!pack) {
|
||||
throw new UnknownPackError();
|
||||
}
|
||||
|
||||
const creator = await getCachedUserPartialResponse({
|
||||
userId: pack.creatorId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
const inviter = invite.inviterId
|
||||
? await getCachedUserPartialResponse({
|
||||
userId: invite.inviterId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
})
|
||||
: null;
|
||||
|
||||
const expiresAt = invite.maxAge > 0 ? new Date(invite.createdAt.getTime() + invite.maxAge * 1000) : null;
|
||||
|
||||
return {
|
||||
code: invite.code,
|
||||
type: invite.type as typeof InviteTypes.EMOJI_PACK | typeof InviteTypes.STICKER_PACK,
|
||||
pack: {
|
||||
...mapPackToSummary(pack),
|
||||
creator,
|
||||
},
|
||||
inviter,
|
||||
expires_at: expiresAt?.toISOString() ?? null,
|
||||
temporary: invite.temporary,
|
||||
};
|
||||
};
|
||||
|
||||
export async function mapInviteToPackInviteResponse(
|
||||
params: MapInviteToPackInviteResponseParams,
|
||||
): Promise<z.infer<typeof PackInviteResponse>> {
|
||||
return buildPackInviteBase(params);
|
||||
}
|
||||
|
||||
export async function mapInviteToPackInviteMetadataResponse(
|
||||
params: MapInviteToPackInviteResponseParams,
|
||||
): Promise<z.infer<typeof PackInviteMetadataResponse>> {
|
||||
const baseResponse = await buildPackInviteBase(params);
|
||||
|
||||
return {
|
||||
...baseResponse,
|
||||
created_at: params.invite.createdAt.toISOString(),
|
||||
uses: params.invite.uses,
|
||||
max_uses: params.invite.maxUses,
|
||||
};
|
||||
}
|
||||
201
packages/api/src/invite/InviteRepository.tsx
Normal file
201
packages/api/src/invite/InviteRepository.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, InviteCode, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createInviteCode} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, Db, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {InviteRow} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
|
||||
import {Invite} from '@fluxer/api/src/models/Invite';
|
||||
import {Invites, InvitesByChannel, InvitesByGuild} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_INVITE_BY_CODE_CQL = Invites.selectCql({
|
||||
where: Invites.where.eq('code'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_INVITES_BY_CHANNEL_CQL = InvitesByChannel.selectCql({
|
||||
columns: ['code'],
|
||||
where: InvitesByChannel.where.eq('channel_id'),
|
||||
});
|
||||
|
||||
const FETCH_INVITES_BY_GUILD_CQL = InvitesByGuild.selectCql({
|
||||
columns: ['code'],
|
||||
where: InvitesByGuild.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
interface CreateInviteParams {
|
||||
code: InviteCode;
|
||||
type: number;
|
||||
guild_id: GuildID;
|
||||
channel_id?: ChannelID | null;
|
||||
inviter_id?: UserID | null;
|
||||
uses: number;
|
||||
max_uses: number;
|
||||
max_age: number;
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
export class InviteRepository extends IInviteRepository {
|
||||
async findUnique(code: InviteCode): Promise<Invite | null> {
|
||||
const invite = await fetchOne<InviteRow>(FETCH_INVITE_BY_CODE_CQL, {code});
|
||||
return invite ? new Invite(invite) : null;
|
||||
}
|
||||
|
||||
async listChannelInvites(channelId: ChannelID): Promise<Array<Invite>> {
|
||||
const inviteCodes = await fetchMany<{code: string}>(FETCH_INVITES_BY_CHANNEL_CQL, {channel_id: channelId});
|
||||
|
||||
if (inviteCodes.length === 0) return [];
|
||||
|
||||
const invites: Array<Invite> = [];
|
||||
for (const {code} of inviteCodes) {
|
||||
const invite = await this.findUnique(createInviteCode(code));
|
||||
if (invite) invites.push(invite);
|
||||
}
|
||||
|
||||
return invites;
|
||||
}
|
||||
|
||||
async listGuildInvites(guildId: GuildID): Promise<Array<Invite>> {
|
||||
const inviteCodes = await fetchMany<{code: string}>(FETCH_INVITES_BY_GUILD_CQL, {guild_id: guildId});
|
||||
|
||||
if (inviteCodes.length === 0) return [];
|
||||
|
||||
const invites: Array<Invite> = [];
|
||||
for (const {code} of inviteCodes) {
|
||||
const invite = await this.findUnique(createInviteCode(code));
|
||||
if (invite) invites.push(invite);
|
||||
}
|
||||
|
||||
return invites;
|
||||
}
|
||||
|
||||
async create(data: CreateInviteParams): Promise<Invite> {
|
||||
const inviteRow: InviteRow = {
|
||||
code: data.code,
|
||||
type: data.type,
|
||||
guild_id: data.guild_id,
|
||||
channel_id: data.channel_id ?? null,
|
||||
inviter_id: data.inviter_id ?? null,
|
||||
created_at: new Date(),
|
||||
uses: data.uses,
|
||||
max_uses: data.max_uses,
|
||||
max_age: data.max_age,
|
||||
temporary: data.temporary ?? false,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const hasExpiry = inviteRow.max_age > 0;
|
||||
|
||||
if (hasExpiry) {
|
||||
batch.addPrepared(Invites.insertWithTtlParam(inviteRow, 'max_age'));
|
||||
} else {
|
||||
batch.addPrepared(Invites.insert(inviteRow));
|
||||
}
|
||||
|
||||
if (inviteRow.guild_id) {
|
||||
batch.addPrepared(
|
||||
hasExpiry
|
||||
? InvitesByGuild.insertWithTtl(
|
||||
{
|
||||
guild_id: inviteRow.guild_id,
|
||||
code: inviteRow.code,
|
||||
},
|
||||
inviteRow.max_age,
|
||||
)
|
||||
: InvitesByGuild.insert({
|
||||
guild_id: inviteRow.guild_id,
|
||||
code: inviteRow.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (inviteRow.channel_id) {
|
||||
batch.addPrepared(
|
||||
hasExpiry
|
||||
? InvitesByChannel.insertWithTtl(
|
||||
{
|
||||
channel_id: inviteRow.channel_id,
|
||||
code: inviteRow.code,
|
||||
},
|
||||
inviteRow.max_age,
|
||||
)
|
||||
: InvitesByChannel.insert({
|
||||
channel_id: inviteRow.channel_id,
|
||||
code: inviteRow.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
|
||||
await upsertOne(Invites.upsertAll(inviteRow));
|
||||
if (inviteRow.guild_id) {
|
||||
await upsertOne(
|
||||
InvitesByGuild.upsertAll({
|
||||
guild_id: inviteRow.guild_id,
|
||||
code: inviteRow.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (inviteRow.channel_id) {
|
||||
await upsertOne(
|
||||
InvitesByChannel.upsertAll({
|
||||
channel_id: inviteRow.channel_id,
|
||||
code: inviteRow.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return new Invite(inviteRow);
|
||||
}
|
||||
|
||||
async updateInviteUses(code: InviteCode, uses: number): Promise<void> {
|
||||
await fetchOne(
|
||||
Invites.patchByPk(
|
||||
{code},
|
||||
{
|
||||
uses: Db.set(uses),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async delete(code: InviteCode): Promise<void> {
|
||||
const invite = await this.findUnique(code);
|
||||
if (!invite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(Invites.deleteByPk({code}));
|
||||
|
||||
if (invite.guildId) {
|
||||
batch.addPrepared(InvitesByGuild.deleteByPk({guild_id: invite.guildId, code}));
|
||||
}
|
||||
|
||||
if (invite.channelId) {
|
||||
batch.addPrepared(InvitesByChannel.deleteByPk({channel_id: invite.channelId, code}));
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
262
packages/api/src/invite/InviteRequestService.tsx
Normal file
262
packages/api/src/invite/InviteRequestService.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, InviteCode, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {
|
||||
mapInviteToGroupDmInviteMetadataResponse,
|
||||
mapInviteToGroupDmInviteResponse,
|
||||
mapInviteToGuildInviteMetadataResponse,
|
||||
mapInviteToGuildInviteResponse,
|
||||
mapInviteToPackInviteMetadataResponse,
|
||||
mapInviteToPackInviteResponse,
|
||||
} from '@fluxer/api/src/invite/InviteModel';
|
||||
import type {InviteService} from '@fluxer/api/src/invite/InviteService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Invite} from '@fluxer/api/src/models/Invite';
|
||||
import type {PackRepository} from '@fluxer/api/src/pack/PackRepository';
|
||||
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
|
||||
import type {ChannelPartialResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildPartialResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {
|
||||
ChannelInviteCreateRequest,
|
||||
InviteMetadataResponseSchema,
|
||||
InviteResponseSchema,
|
||||
PackInviteCreateRequest,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
interface MappingHelpers {
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
|
||||
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<GuildPartialResponse>;
|
||||
getGuildCounts: (guildId: GuildID) => Promise<{memberCount: number; presenceCount: number}>;
|
||||
packRepository: PackRepository;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
type InviteMetricAction = 'accepted' | 'created' | 'deleted';
|
||||
|
||||
function mapInviteType(inviteType: number): string {
|
||||
switch (inviteType) {
|
||||
case InviteTypes.GUILD:
|
||||
return 'guild';
|
||||
case InviteTypes.GROUP_DM:
|
||||
return 'group_dm';
|
||||
case InviteTypes.EMOJI_PACK:
|
||||
return 'emoji_pack';
|
||||
case InviteTypes.STICKER_PACK:
|
||||
return 'sticker_pack';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export class InviteRequestService {
|
||||
constructor(
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly guildService: GuildService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly packRepository: PackRepository,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
) {}
|
||||
|
||||
async getInvite(params: {inviteCode: InviteCode; requestCache: RequestCache}): Promise<InviteResponseSchema> {
|
||||
const invite = await this.inviteService.getInvite(params.inviteCode);
|
||||
return this.mapInviteResponse(invite, params.requestCache);
|
||||
}
|
||||
|
||||
async acceptInvite(params: {
|
||||
userId: UserID;
|
||||
inviteCode: InviteCode;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<InviteResponseSchema> {
|
||||
const invite = await this.inviteService.acceptInvite(params);
|
||||
this.recordInviteMetric('accepted', invite);
|
||||
return this.mapInviteResponse(invite, params.requestCache);
|
||||
}
|
||||
|
||||
async deleteInvite(params: {userId: UserID; inviteCode: InviteCode; auditLogReason?: string | null}): Promise<void> {
|
||||
const invite = await this.inviteService.getInvite(params.inviteCode);
|
||||
await this.inviteService.deleteInvite(
|
||||
{userId: params.userId, inviteCode: params.inviteCode},
|
||||
params.auditLogReason,
|
||||
);
|
||||
await this.inviteService.dispatchInviteDelete(invite);
|
||||
this.recordInviteMetric('deleted', invite);
|
||||
}
|
||||
|
||||
async createChannelInvite(params: {
|
||||
inviterId: UserID;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
data: ChannelInviteCreateRequest;
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<InviteMetadataResponseSchema> {
|
||||
const {invite, isNew} = await this.inviteService.createInvite(
|
||||
{
|
||||
inviterId: params.inviterId,
|
||||
channelId: params.channelId,
|
||||
maxUses: params.data.max_uses ?? 0,
|
||||
maxAge: params.data.max_age ?? 0,
|
||||
unique: params.data.unique ?? false,
|
||||
temporary: params.data.temporary ?? false,
|
||||
},
|
||||
params.auditLogReason,
|
||||
);
|
||||
const inviteData = await this.mapInviteMetadataResponse(invite, params.requestCache);
|
||||
if (isNew) {
|
||||
await this.inviteService.dispatchInviteCreate(invite, inviteData);
|
||||
}
|
||||
this.recordInviteMetric('created', invite, {is_new: isNew ? 'true' : 'false'});
|
||||
return inviteData;
|
||||
}
|
||||
|
||||
async listChannelInvites(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<InviteMetadataResponseSchema>> {
|
||||
const invites = await this.inviteService.getChannelInvitesSorted({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
return this.mapInviteList(invites, params.requestCache);
|
||||
}
|
||||
|
||||
async listGuildInvites(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<InviteMetadataResponseSchema>> {
|
||||
const invites = await this.inviteService.getGuildInvitesSorted({
|
||||
userId: params.userId,
|
||||
guildId: params.guildId,
|
||||
});
|
||||
return this.mapInviteList(invites, params.requestCache);
|
||||
}
|
||||
|
||||
async listPackInvites(params: {
|
||||
userId: UserID;
|
||||
packId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<InviteMetadataResponseSchema>> {
|
||||
const invites = await this.inviteService.getPackInvitesSorted({
|
||||
userId: params.userId,
|
||||
packId: params.packId,
|
||||
});
|
||||
return this.mapInviteList(invites, params.requestCache);
|
||||
}
|
||||
|
||||
async createPackInvite(params: {
|
||||
inviterId: UserID;
|
||||
packId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
data: PackInviteCreateRequest;
|
||||
}): Promise<InviteMetadataResponseSchema> {
|
||||
const pack = await this.packRepository.getPack(params.packId);
|
||||
if (!pack) {
|
||||
throw new UnknownPackError();
|
||||
}
|
||||
|
||||
const {invite, isNew} = await this.inviteService.createPackInvite({
|
||||
inviterId: params.inviterId,
|
||||
packId: params.packId,
|
||||
packType: pack.type,
|
||||
maxUses: params.data.max_uses ?? 0,
|
||||
maxAge: params.data.max_age ?? 0,
|
||||
unique: params.data.unique ?? false,
|
||||
});
|
||||
const inviteData = await this.mapInviteMetadataResponse(invite, params.requestCache);
|
||||
if (isNew) {
|
||||
await this.inviteService.dispatchInviteCreate(invite, inviteData);
|
||||
}
|
||||
this.recordInviteMetric('created', invite, {is_new: isNew ? 'true' : 'false'});
|
||||
return inviteData;
|
||||
}
|
||||
|
||||
private createMappingHelpers(requestCache: RequestCache): MappingHelpers {
|
||||
return {
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse: async (channelId: ChannelID) => await this.channelService.getPublicChannelData(channelId),
|
||||
getChannelSystem: async (channelId: ChannelID) => await this.channelService.getChannelSystem(channelId),
|
||||
getChannelMemberCount: async (channelId: ChannelID) => await this.channelService.getChannelMemberCount(channelId),
|
||||
getGuildResponse: async (guildId: GuildID) => await this.guildService.getPublicGuildData(guildId),
|
||||
getGuildCounts: async (guildId: GuildID) => await this.gatewayService.getGuildCounts(guildId),
|
||||
packRepository: this.packRepository,
|
||||
gatewayService: this.gatewayService,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapInviteResponse(invite: Invite, requestCache: RequestCache): Promise<InviteResponseSchema> {
|
||||
const helpers = this.createMappingHelpers(requestCache);
|
||||
if (invite.type === InviteTypes.GROUP_DM) {
|
||||
return mapInviteToGroupDmInviteResponse({invite, ...helpers});
|
||||
}
|
||||
if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) {
|
||||
return mapInviteToPackInviteResponse({invite, ...helpers});
|
||||
}
|
||||
return mapInviteToGuildInviteResponse({invite, ...helpers});
|
||||
}
|
||||
|
||||
private async mapInviteMetadataResponse(
|
||||
invite: Invite,
|
||||
requestCache: RequestCache,
|
||||
): Promise<InviteMetadataResponseSchema> {
|
||||
const helpers = this.createMappingHelpers(requestCache);
|
||||
if (invite.type === InviteTypes.GROUP_DM) {
|
||||
return mapInviteToGroupDmInviteMetadataResponse({invite, ...helpers});
|
||||
}
|
||||
if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) {
|
||||
return mapInviteToPackInviteMetadataResponse({invite, ...helpers});
|
||||
}
|
||||
return mapInviteToGuildInviteMetadataResponse({invite, ...helpers});
|
||||
}
|
||||
|
||||
private async mapInviteList(
|
||||
invites: Array<Invite>,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Array<InviteMetadataResponseSchema>> {
|
||||
return Promise.all(invites.map((invite) => this.mapInviteMetadataResponse(invite, requestCache)));
|
||||
}
|
||||
|
||||
private recordInviteMetric(
|
||||
action: InviteMetricAction,
|
||||
invite: Invite,
|
||||
extraDimensions: Record<string, string> = {},
|
||||
): void {
|
||||
getMetricsService().counter({
|
||||
name: `fluxer.invites.${action}`,
|
||||
dimensions: {
|
||||
invite_type: mapInviteType(invite.type),
|
||||
...extraDimensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
702
packages/api/src/invite/InviteService.tsx
Normal file
702
packages/api/src/invite/InviteService.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, InviteCode, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createInviteCode, vanityCodeToInviteCode} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {Invite} from '@fluxer/api/src/models/Invite';
|
||||
import type {PackRepository, PackType} from '@fluxer/api/src/pack/PackRepository';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import * as RandomUtils from '@fluxer/api/src/utils/RandomUtils';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ChannelTypes, InviteTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures, GuildOperations, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_GUILD_INVITES} from '@fluxer/constants/src/LimitConstants';
|
||||
import {UnclaimedAccountCannotJoinGroupDmsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotJoinGroupDmsError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {MaxGuildInvitesError} from '@fluxer/errors/src/domains/guild/MaxGuildInvitesError';
|
||||
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
|
||||
import {TemporaryInviteRequiresPresenceError} from '@fluxer/errors/src/domains/invite/TemporaryInviteRequiresPresenceError';
|
||||
import {UnknownInviteError} from '@fluxer/errors/src/domains/invite/UnknownInviteError';
|
||||
import {PackAccessDeniedError} from '@fluxer/errors/src/domains/pack/PackAccessDeniedError';
|
||||
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
|
||||
import type {
|
||||
GroupDmInviteMetadataResponse,
|
||||
GuildInviteMetadataResponse,
|
||||
PackInviteMetadataResponse,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
interface GetChannelInvitesParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}
|
||||
|
||||
interface GetGuildInvitesParams {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
}
|
||||
|
||||
interface CreateInviteParams {
|
||||
inviterId: UserID;
|
||||
channelId: ChannelID;
|
||||
maxUses: number;
|
||||
maxAge: number;
|
||||
unique: boolean;
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
interface CreatePackInviteParams {
|
||||
inviterId: UserID;
|
||||
packId: GuildID;
|
||||
packType: PackType;
|
||||
maxUses: number;
|
||||
maxAge: number;
|
||||
unique: boolean;
|
||||
}
|
||||
|
||||
interface AcceptInviteParams {
|
||||
userId: UserID;
|
||||
inviteCode: InviteCode;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface DeleteInviteParams {
|
||||
userId: UserID;
|
||||
inviteCode: InviteCode;
|
||||
}
|
||||
|
||||
interface GetChannelInvitesSortedParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}
|
||||
|
||||
interface GetGuildInvitesSortedParams {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
}
|
||||
|
||||
interface SerializedInviteForAudit {
|
||||
[key: string]: string | number | boolean | null;
|
||||
code: string;
|
||||
channel_id: string | null;
|
||||
guild_id: string | null;
|
||||
inviter_id: string | null;
|
||||
uses: number;
|
||||
max_uses: number;
|
||||
max_age: number;
|
||||
temporary: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PACK_TYPE_TO_INVITE_TYPE: Record<PackType, number> = {
|
||||
emoji: InviteTypes.EMOJI_PACK,
|
||||
sticker: InviteTypes.STICKER_PACK,
|
||||
};
|
||||
|
||||
export class InviteService {
|
||||
constructor(
|
||||
private inviteRepository: IInviteRepository,
|
||||
private guildService: GuildService,
|
||||
private channelService: ChannelService,
|
||||
private gatewayService: IGatewayService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private userRepository: IUserRepository,
|
||||
private readonly packRepository: PackRepository,
|
||||
private readonly packService: PackService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
async getInvite(inviteCode: InviteCode): Promise<Invite> {
|
||||
const invite = await this.inviteRepository.findUnique(inviteCode);
|
||||
if (!invite) throw new UnknownInviteError();
|
||||
return invite;
|
||||
}
|
||||
|
||||
async getChannelInvites({userId, channelId}: GetChannelInvitesParams): Promise<Array<Invite>> {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
|
||||
if (!channel.guildId) {
|
||||
if (channel.type !== ChannelTypes.GROUP_DM) throw new UnknownChannelError();
|
||||
|
||||
if (channel.ownerId !== userId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
return await this.inviteRepository.listChannelInvites(channelId);
|
||||
}
|
||||
|
||||
const {checkPermission, guildData} = await this.guildService.getGuildAuthenticated({
|
||||
userId,
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
|
||||
await checkPermission(Permissions.MANAGE_CHANNELS);
|
||||
|
||||
const invites = await this.inviteRepository.listChannelInvites(channelId);
|
||||
return invites.filter((invite) => invite.code !== guildData.vanity_url_code);
|
||||
}
|
||||
|
||||
async getGuildInvites({userId, guildId}: GetGuildInvitesParams): Promise<Array<Invite>> {
|
||||
const {checkPermission, guildData} = await this.guildService.getGuildAuthenticated({
|
||||
userId,
|
||||
guildId,
|
||||
});
|
||||
|
||||
await checkPermission(Permissions.MANAGE_GUILD);
|
||||
|
||||
const invites = await this.inviteRepository.listGuildInvites(guildId);
|
||||
return invites.filter((invite) => invite.code !== guildData.vanity_url_code);
|
||||
}
|
||||
|
||||
async createInvite(
|
||||
{inviterId, channelId, maxUses, maxAge, unique, temporary = false}: CreateInviteParams,
|
||||
auditLogReason?: string | null,
|
||||
): Promise<{invite: Invite; isNew: boolean}> {
|
||||
const channel = await this.channelService.getChannel({
|
||||
userId: inviterId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (!channel.guildId) {
|
||||
if (!unique) {
|
||||
const existingInvite = await this.inviteRepository
|
||||
.listChannelInvites(channelId)
|
||||
.then((invites) =>
|
||||
invites.find(
|
||||
(invite) =>
|
||||
invite.channelId === channelId &&
|
||||
invite.inviterId === inviterId &&
|
||||
invite.maxUses === maxUses &&
|
||||
invite.maxAge === maxAge &&
|
||||
invite.temporary === temporary &&
|
||||
invite.type === InviteTypes.GROUP_DM,
|
||||
),
|
||||
);
|
||||
|
||||
if (existingInvite) {
|
||||
return {invite: existingInvite, isNew: false};
|
||||
}
|
||||
}
|
||||
|
||||
const newInvite = await this.inviteRepository.create({
|
||||
code: createInviteCode(RandomUtils.randomString(8)),
|
||||
type: InviteTypes.GROUP_DM,
|
||||
guild_id: null,
|
||||
channel_id: channelId,
|
||||
inviter_id: inviterId,
|
||||
uses: 0,
|
||||
max_uses: maxUses,
|
||||
max_age: maxAge,
|
||||
temporary,
|
||||
});
|
||||
return {invite: newInvite, isNew: true};
|
||||
}
|
||||
|
||||
const {guildData} = await this.guildService.getGuildAuthenticated({
|
||||
userId: inviterId,
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
|
||||
if ((guildData.disabled_operations & GuildOperations.INSTANT_INVITES) !== 0) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: inviterId,
|
||||
permission: Permissions.CREATE_INSTANT_INVITE,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const existingInvites = await this.inviteRepository.listGuildInvites(channel.guildId);
|
||||
|
||||
if (!unique) {
|
||||
const existingInvite = existingInvites.find(
|
||||
(invite) =>
|
||||
invite.channelId === channelId &&
|
||||
invite.inviterId === inviterId &&
|
||||
invite.maxUses === maxUses &&
|
||||
invite.maxAge === maxAge &&
|
||||
invite.temporary === temporary,
|
||||
);
|
||||
|
||||
if (existingInvite) {
|
||||
return {invite: existingInvite, isNew: false};
|
||||
}
|
||||
}
|
||||
|
||||
const inviteLimit = this.resolveInviteLimit(guildData.features);
|
||||
if (existingInvites.length >= inviteLimit) {
|
||||
throw new MaxGuildInvitesError(inviteLimit);
|
||||
}
|
||||
|
||||
const newInvite = await this.inviteRepository.create({
|
||||
code: createInviteCode(RandomUtils.randomString(8)),
|
||||
type: InviteTypes.GUILD,
|
||||
guild_id: channel.guildId,
|
||||
channel_id: channelId,
|
||||
inviter_id: inviterId,
|
||||
uses: 0,
|
||||
max_uses: maxUses,
|
||||
max_age: maxAge,
|
||||
temporary,
|
||||
});
|
||||
if (newInvite.guildId) {
|
||||
await this.logGuildInviteAction({
|
||||
invite: newInvite,
|
||||
userId: inviterId,
|
||||
action: 'create',
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
return {invite: newInvite, isNew: true};
|
||||
}
|
||||
|
||||
async createPackInvite({
|
||||
inviterId,
|
||||
packId,
|
||||
packType,
|
||||
maxUses,
|
||||
maxAge,
|
||||
unique,
|
||||
}: CreatePackInviteParams): Promise<{invite: Invite; isNew: boolean}> {
|
||||
const pack = await this.packRepository.getPack(packId);
|
||||
if (!pack) {
|
||||
throw new UnknownPackError();
|
||||
}
|
||||
|
||||
if (pack.creatorId !== inviterId) {
|
||||
throw new PackAccessDeniedError();
|
||||
}
|
||||
|
||||
if (pack.type !== packType) {
|
||||
throw new PackAccessDeniedError();
|
||||
}
|
||||
|
||||
const allInvites = await this.inviteRepository.listGuildInvites(packId);
|
||||
const inviteType = PACK_TYPE_TO_INVITE_TYPE[packType];
|
||||
|
||||
if (!unique) {
|
||||
const existingInvite = allInvites.find(
|
||||
(invite) =>
|
||||
invite.inviterId === inviterId &&
|
||||
invite.maxUses === maxUses &&
|
||||
invite.maxAge === maxAge &&
|
||||
invite.type === inviteType,
|
||||
);
|
||||
if (existingInvite) {
|
||||
return {invite: existingInvite, isNew: false};
|
||||
}
|
||||
}
|
||||
|
||||
const packInviteLimit = this.resolveInviteLimit(null);
|
||||
if (allInvites.length >= packInviteLimit) {
|
||||
throw new MaxGuildInvitesError(packInviteLimit);
|
||||
}
|
||||
|
||||
const newInvite = await this.inviteRepository.create({
|
||||
code: createInviteCode(RandomUtils.randomString(8)),
|
||||
type: inviteType,
|
||||
guild_id: packId,
|
||||
channel_id: null,
|
||||
inviter_id: inviterId,
|
||||
uses: 0,
|
||||
max_uses: maxUses,
|
||||
max_age: maxAge,
|
||||
temporary: false,
|
||||
});
|
||||
|
||||
return {invite: newInvite, isNew: true};
|
||||
}
|
||||
|
||||
async acceptInvite({userId, inviteCode, requestCache}: AcceptInviteParams): Promise<Invite> {
|
||||
return await withBusinessSpan('fluxer.guild.join', 'fluxer.guilds.joined', {}, () =>
|
||||
this.performAcceptInvite({userId, inviteCode, requestCache}),
|
||||
);
|
||||
}
|
||||
|
||||
private async performAcceptInvite({userId, inviteCode, requestCache}: AcceptInviteParams): Promise<Invite> {
|
||||
const invite = await this.inviteRepository.findUnique(inviteCode);
|
||||
if (!invite) throw new UnknownInviteError();
|
||||
|
||||
if (invite.maxUses > 0 && invite.uses >= invite.maxUses) {
|
||||
if (invite.type === InviteTypes.GUILD && invite.guildId) {
|
||||
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);
|
||||
}
|
||||
} else if (invite.type === InviteTypes.GROUP_DM) {
|
||||
await this.inviteRepository.delete(inviteCode);
|
||||
}
|
||||
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
if (invite.type === InviteTypes.GROUP_DM) {
|
||||
if (!invite.channelId) throw new UnknownInviteError();
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (user?.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotJoinGroupDmsError();
|
||||
}
|
||||
|
||||
const channel = await this.channelService.getChannelSystem(invite.channelId);
|
||||
if (!channel) throw new UnknownInviteError();
|
||||
|
||||
if (channel.recipientIds.has(userId)) {
|
||||
return invite;
|
||||
}
|
||||
|
||||
await this.channelService.groupDms.addRecipientViaInvite({
|
||||
channelId: invite.channelId,
|
||||
recipientId: userId,
|
||||
inviterId: invite.inviterId,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
const newUses = invite.uses + 1;
|
||||
await this.inviteRepository.updateInviteUses(inviteCode, newUses);
|
||||
if (invite.maxUses > 0 && newUses >= invite.maxUses) {
|
||||
await this.inviteRepository.delete(inviteCode);
|
||||
}
|
||||
|
||||
return this.cloneInviteWithUses(invite, newUses);
|
||||
}
|
||||
|
||||
if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) {
|
||||
if (!invite.guildId) throw new UnknownInviteError();
|
||||
await this.packService.installPack(userId, invite.guildId);
|
||||
|
||||
const newUses = invite.uses + 1;
|
||||
await this.inviteRepository.updateInviteUses(inviteCode, newUses);
|
||||
if (invite.maxUses > 0 && newUses >= invite.maxUses) {
|
||||
await this.inviteRepository.delete(inviteCode);
|
||||
}
|
||||
|
||||
return this.cloneInviteWithUses(invite, newUses);
|
||||
}
|
||||
|
||||
if (!invite.guildId) throw new UnknownInviteError();
|
||||
|
||||
const guild = await this.guildService.getGuildSystem(invite.guildId);
|
||||
|
||||
if ((guild.disabledOperations & GuildOperations.INSTANT_INVITES) !== 0) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
if (guild.features.has(GuildFeatures.INVITES_DISABLED)) {
|
||||
throw new InvitesDisabledError();
|
||||
}
|
||||
|
||||
const existingMember = await this.gatewayService.hasGuildMember({
|
||||
guildId: invite.guildId,
|
||||
userId,
|
||||
});
|
||||
if (existingMember) {
|
||||
return invite;
|
||||
}
|
||||
|
||||
await this.guildService.checkUserBanStatus({userId, guildId: invite.guildId});
|
||||
|
||||
if (invite.temporary) {
|
||||
const hasPresence = await this.gatewayService.hasActivePresence(userId);
|
||||
if (!hasPresence) {
|
||||
throw new TemporaryInviteRequiresPresenceError();
|
||||
}
|
||||
}
|
||||
|
||||
const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null;
|
||||
const isVanityInvite = invite.code === vanityCode;
|
||||
|
||||
await this.guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId: invite.guildId,
|
||||
sendJoinMessage: true,
|
||||
requestCache,
|
||||
isTemporary: invite.temporary,
|
||||
joinSourceType: isVanityInvite ? JoinSourceTypes.VANITY_URL : JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode: isVanityInvite ? undefined : invite.code,
|
||||
inviterId: isVanityInvite ? undefined : (invite.inviterId ?? undefined),
|
||||
});
|
||||
|
||||
if (invite.temporary) {
|
||||
await this.gatewayService.addTemporaryGuild({userId, guildId: invite.guildId});
|
||||
}
|
||||
|
||||
const newUses = invite.uses + 1;
|
||||
await this.inviteRepository.updateInviteUses(inviteCode, newUses);
|
||||
|
||||
if (!isVanityInvite && invite.maxUses > 0 && newUses >= invite.maxUses) {
|
||||
await this.inviteRepository.delete(inviteCode);
|
||||
}
|
||||
|
||||
return this.cloneInviteWithUses(invite, newUses);
|
||||
}
|
||||
|
||||
private cloneInviteWithUses(invite: Invite, uses: number): Invite {
|
||||
const row = invite.toRow();
|
||||
return new Invite({
|
||||
...row,
|
||||
uses,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInviteLimit(guildFeatures?: Iterable<string> | null): number {
|
||||
const limit = MAX_GUILD_INVITES;
|
||||
const ctx = createLimitMatchContext({guildFeatures});
|
||||
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guild_invites', limit);
|
||||
}
|
||||
|
||||
async deleteInvite({userId, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise<void> {
|
||||
const invite = await this.inviteRepository.findUnique(inviteCode);
|
||||
if (!invite) throw new UnknownInviteError();
|
||||
|
||||
if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) {
|
||||
if (!invite.guildId) throw new UnknownInviteError();
|
||||
const pack = await this.packRepository.getPack(invite.guildId);
|
||||
if (!pack) {
|
||||
throw new UnknownPackError();
|
||||
}
|
||||
|
||||
if (pack.creatorId !== userId) {
|
||||
throw new PackAccessDeniedError();
|
||||
}
|
||||
|
||||
await this.inviteRepository.delete(inviteCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (invite.type === InviteTypes.GROUP_DM) {
|
||||
if (!invite.channelId) throw new UnknownInviteError();
|
||||
|
||||
const channel = await this.channelService.getChannel({
|
||||
userId,
|
||||
channelId: invite.channelId,
|
||||
});
|
||||
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
if (channel.ownerId !== userId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
await this.inviteRepository.delete(inviteCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invite.guildId) throw new UnknownInviteError();
|
||||
|
||||
const {checkPermission, guildData} = await this.guildService.getGuildAuthenticated({
|
||||
userId,
|
||||
guildId: invite.guildId,
|
||||
});
|
||||
|
||||
if (invite.code === guildData.vanity_url_code) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
const isInviteCreator = invite.inviterId === userId;
|
||||
if (!isInviteCreator) {
|
||||
await checkPermission(Permissions.MANAGE_GUILD);
|
||||
}
|
||||
|
||||
await this.inviteRepository.delete(inviteCode);
|
||||
await this.logGuildInviteAction({
|
||||
invite,
|
||||
userId,
|
||||
action: 'delete',
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveVanityUrlChannel(guildId: GuildID): Promise<Channel | null> {
|
||||
const channelId = await this.gatewayService.getVanityUrlChannel(guildId);
|
||||
if (!channelId) return null;
|
||||
|
||||
return await this.channelService.getChannelSystem(channelId);
|
||||
}
|
||||
|
||||
async getChannelInvitesSorted({userId, channelId}: GetChannelInvitesSortedParams): Promise<Array<Invite>> {
|
||||
const invites = await this.getChannelInvites({userId, channelId});
|
||||
return invites.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async getGuildInvitesSorted({userId, guildId}: GetGuildInvitesSortedParams): Promise<Array<Invite>> {
|
||||
const invites = await this.getGuildInvites({userId, guildId});
|
||||
return invites.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async getPackInvitesSorted(params: {userId: UserID; packId: GuildID}): Promise<Array<Invite>> {
|
||||
const {userId, packId} = params;
|
||||
const pack = await this.packRepository.getPack(packId);
|
||||
if (!pack) {
|
||||
throw new UnknownPackError();
|
||||
}
|
||||
|
||||
if (pack.creatorId !== userId) {
|
||||
throw new PackAccessDeniedError();
|
||||
}
|
||||
|
||||
const invites = await this.inviteRepository.listGuildInvites(packId);
|
||||
const inviteType = PACK_TYPE_TO_INVITE_TYPE[pack.type];
|
||||
return invites
|
||||
.filter((invite) => invite.type === inviteType)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async dispatchInviteCreate(
|
||||
invite: Invite,
|
||||
inviteData: GuildInviteMetadataResponse | GroupDmInviteMetadataResponse | PackInviteMetadataResponse,
|
||||
): Promise<void> {
|
||||
if (invite.guildId && invite.type === InviteTypes.GUILD) {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId: invite.guildId,
|
||||
event: 'INVITE_CREATE',
|
||||
data: inviteData,
|
||||
});
|
||||
} else if (invite.channelId) {
|
||||
const channel = await this.channelService.getChannelSystem(invite.channelId);
|
||||
if (channel) {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'INVITE_CREATE',
|
||||
data: inviteData,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchInviteDelete(invite: Invite): Promise<void> {
|
||||
const data = {
|
||||
code: invite.code,
|
||||
channel_id: invite.channelId?.toString(),
|
||||
guild_id: invite.guildId?.toString(),
|
||||
};
|
||||
|
||||
if (invite.guildId && invite.type === InviteTypes.GUILD) {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId: invite.guildId,
|
||||
event: 'INVITE_DELETE',
|
||||
data,
|
||||
});
|
||||
} else if (invite.channelId) {
|
||||
const channel = await this.channelService.getChannelSystem(invite.channelId);
|
||||
if (channel) {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'INVITE_DELETE',
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async logGuildInviteAction(params: {
|
||||
invite: Invite;
|
||||
userId: UserID;
|
||||
action: 'create' | 'delete';
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!params.invite.guildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
max_uses: params.invite.maxUses.toString(),
|
||||
max_age: params.invite.maxAge.toString(),
|
||||
temporary: params.invite.temporary ? 'true' : 'false',
|
||||
};
|
||||
|
||||
if (params.invite.channelId) {
|
||||
metadata['channel_id'] = params.invite.channelId.toString();
|
||||
}
|
||||
if (params.invite.inviterId) {
|
||||
metadata['inviter_id'] = params.invite.inviterId.toString();
|
||||
}
|
||||
|
||||
const snapshot = this.serializeInviteForAudit(params.invite);
|
||||
const changes =
|
||||
params.action === 'create'
|
||||
? this.guildAuditLogService.computeChanges(null, snapshot)
|
||||
: this.guildAuditLogService.computeChanges(snapshot, null);
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(params.invite.guildId, params.userId)
|
||||
.withReason(params.auditLogReason ?? null)
|
||||
.withMetadata(metadata)
|
||||
.withChanges(changes ?? null)
|
||||
.withAction(
|
||||
params.action === 'create' ? AuditLogActionType.INVITE_CREATE : AuditLogActionType.INVITE_DELETE,
|
||||
params.invite.code,
|
||||
);
|
||||
|
||||
try {
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.invite.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: params.action === 'create' ? 'guild_invite_create' : 'guild_invite_delete',
|
||||
targetId: params.invite.code,
|
||||
},
|
||||
'Failed to record guild invite audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private serializeInviteForAudit(invite: Invite): SerializedInviteForAudit {
|
||||
return {
|
||||
code: invite.code,
|
||||
channel_id: invite.channelId?.toString() ?? null,
|
||||
guild_id: invite.guildId?.toString() ?? null,
|
||||
inviter_id: invite.inviterId?.toString() ?? null,
|
||||
uses: invite.uses,
|
||||
max_uses: invite.maxUses,
|
||||
max_age: invite.maxAge,
|
||||
temporary: invite.temporary,
|
||||
created_at: invite.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannelInvite,
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface GroupDmInviteChannelResponse {
|
||||
id: string;
|
||||
type: number;
|
||||
recipients?: Array<{username: string}>;
|
||||
}
|
||||
|
||||
interface GroupDmInviteWithChannelResponse {
|
||||
code: string;
|
||||
channel: GroupDmInviteChannelResponse;
|
||||
}
|
||||
|
||||
describe('Group DM Invite Recipients Serialization', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
test('group DM invite recipients contain only username field', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, recipient);
|
||||
|
||||
const groupChannel = await createGroupDmChannel(harness, owner.token, [member.userId, recipient.userId]);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, groupChannel.id);
|
||||
|
||||
const invitePayload = await createBuilder<GroupDmInviteWithChannelResponse>(harness, owner.token)
|
||||
.get(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invitePayload.channel).toBeDefined();
|
||||
expect(invitePayload.channel.recipients).toBeDefined();
|
||||
expect(Array.isArray(invitePayload.channel.recipients)).toBe(true);
|
||||
expect(invitePayload.channel.recipients!.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (const recipientEntry of invitePayload.channel.recipients!) {
|
||||
const keys = Object.keys(recipientEntry);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0]).toBe('username');
|
||||
expect(typeof recipientEntry.username).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
126
packages/api/src/invite/tests/InviteGuildMemberLimit.test.tsx
Normal file
126
packages/api/src/invite/tests/InviteGuildMemberLimit.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_GUILD_MEMBERS, MAX_GUILD_MEMBERS_VERY_LARGE_GUILD} from '@fluxer/constants/src/LimitConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface InviteResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface InviteAcceptResponse {
|
||||
guild: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function createGuildInvite(harness: ApiTestHarness, token: string, channelId: string): Promise<string> {
|
||||
const invite = await createBuilder<InviteResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/invites`)
|
||||
.body({})
|
||||
.execute();
|
||||
return invite.code;
|
||||
}
|
||||
|
||||
async function addGuildFeatureForTesting(harness: ApiTestHarness, guildId: string, feature: string): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: [feature]})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function setGuildMemberCountForTesting(
|
||||
harness: ApiTestHarness,
|
||||
guildId: string,
|
||||
memberCount: number,
|
||||
): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/member-count`)
|
||||
.body({member_count: memberCount})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Invite guild member limit', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('rejects joining when guild reaches the default cap', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const joiner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Default cap guild');
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
const inviteCode = await createGuildInvite(harness, owner.token, guild.system_channel_id);
|
||||
|
||||
await setGuildMemberCountForTesting(harness, guild.id, MAX_GUILD_MEMBERS);
|
||||
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/invites/${inviteCode}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_MEMBERS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('raises guild member cap to 10000 when VERY_LARGE_GUILD is enabled', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const firstJoiner = await createTestAccount(harness);
|
||||
const secondJoiner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Very large guild');
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
const inviteCode = await createGuildInvite(harness, owner.token, guild.system_channel_id);
|
||||
|
||||
await addGuildFeatureForTesting(harness, guild.id, GuildFeatures.VERY_LARGE_GUILD);
|
||||
await setGuildMemberCountForTesting(harness, guild.id, MAX_GUILD_MEMBERS);
|
||||
|
||||
const accepted = await createBuilder<InviteAcceptResponse>(harness, firstJoiner.token)
|
||||
.post(`/invites/${inviteCode}`)
|
||||
.body(null)
|
||||
.execute();
|
||||
expect(accepted.guild.id).toBe(guild.id);
|
||||
|
||||
await setGuildMemberCountForTesting(harness, guild.id, MAX_GUILD_MEMBERS_VERY_LARGE_GUILD);
|
||||
|
||||
await createBuilder(harness, secondJoiner.token)
|
||||
.post(`/invites/${inviteCode}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_MEMBERS)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
201
packages/api/src/invite/tests/InvitePermissions.test.tsx
Normal file
201
packages/api/src/invite/tests/InvitePermissions.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
getChannel,
|
||||
updateRole,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {deleteInvite} from '@fluxer/api/src/invite/tests/InviteTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, test} from 'vitest';
|
||||
|
||||
const BASIC_PERMISSIONS =
|
||||
Permissions.VIEW_CHANNEL |
|
||||
Permissions.SEND_MESSAGES |
|
||||
Permissions.READ_MESSAGE_HISTORY |
|
||||
Permissions.CONNECT |
|
||||
Permissions.SPEAK;
|
||||
|
||||
describe('Invite Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('member without CREATE_INSTANT_INVITE cannot create invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Permission Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, guild.id, {
|
||||
permissions: BASIC_PERMISSIONS.toString(),
|
||||
});
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member without MANAGE_CHANNELS cannot delete invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Permission Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const ownerInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, guild.id, {
|
||||
permissions: BASIC_PERMISSIONS.toString(),
|
||||
});
|
||||
|
||||
const joinInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, joinInvite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${ownerInvite.code}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member without MANAGE_CHANNELS cannot list channel invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Permission Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, guild.id, {
|
||||
permissions: BASIC_PERMISSIONS.toString(),
|
||||
});
|
||||
|
||||
const joinInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, joinInvite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/channels/${systemChannel.id}/invites`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member without MANAGE_GUILD cannot list guild invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Permission Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, guild.id, {
|
||||
permissions: BASIC_PERMISSIONS.toString(),
|
||||
});
|
||||
|
||||
const joinInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, joinInvite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/invites`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('owner can delete invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Permission Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${invite.code}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
|
||||
test('full permission flow: stripped permissions block all invite operations for member', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Full Permission Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const ownerInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, guild.id, {
|
||||
permissions: BASIC_PERMISSIONS.toString(),
|
||||
});
|
||||
|
||||
const joinInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, joinInvite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${ownerInvite.code}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/channels/${systemChannel.id}/invites`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/invites`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, ownerInvite.code);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/invites/${ownerInvite.code}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
271
packages/api/src/invite/tests/InviteSecurityChecks.test.tsx
Normal file
271
packages/api/src/invite/tests/InviteSecurityChecks.test.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
getChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
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 type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Invite Security Checks', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
test('non-member cannot delete invite they did not create', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Invite Security Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, attacker.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_GUILD')
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('owner can delete invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Invite Security Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${invite.code}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
|
||||
test('deleted invite cannot be retrieved', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Invite Security Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${invite.code}`).expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${invite.code}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
|
||||
test('deleted invite cannot be accepted', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const joiner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Invite Security Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('invite with max_uses limit becomes invalid after exhaustion', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const joiner1 = await createTestAccount(harness);
|
||||
const joiner2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Max Uses Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({max_uses: 1})
|
||||
.execute();
|
||||
|
||||
expect(invite.max_uses).toBe(1);
|
||||
|
||||
await acceptInvite(harness, joiner1.token, invite.code);
|
||||
|
||||
await createBuilder(harness, joiner2.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('banned user cannot use invite to rejoin guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const bannedUser = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Ban Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const joinInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, bannedUser.token, joinInvite.code);
|
||||
|
||||
await banUser(harness, owner.token, guild.id, bannedUser.userId, 0);
|
||||
|
||||
const newInvite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, bannedUser.token)
|
||||
.post(`/invites/${newInvite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, newInvite.code);
|
||||
});
|
||||
|
||||
test('non-member can view public invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Public Invite Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const inviteData = await createBuilder<{code: string}>(harness, nonMember.token)
|
||||
.get(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(inviteData.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('unauthenticated request can view public invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Public Invite Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const inviteData = await createBuilderWithoutAuth<{code: string}>(harness)
|
||||
.get(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(inviteData.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('unauthenticated request cannot accept invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Auth Required Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('existing member using invite returns success without incrementing uses', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Existing Member Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const invitesList = await createBuilder<Array<GuildInviteMetadataResponse>>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/invites`)
|
||||
.execute();
|
||||
|
||||
const updatedInvite = invitesList.find((i) => i.code === invite.code);
|
||||
expect(updatedInvite).toBeDefined();
|
||||
expect(updatedInvite!.uses).toBe(1);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('invite with max_uses 0 allows unlimited uses', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const joiner1 = await createTestAccount(harness);
|
||||
const joiner2 = await createTestAccount(harness);
|
||||
const joiner3 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Unlimited Uses Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({max_uses: 0})
|
||||
.execute();
|
||||
|
||||
expect(invite.max_uses).toBe(0);
|
||||
|
||||
await acceptInvite(harness, joiner1.token, invite.code);
|
||||
await acceptInvite(harness, joiner2.token, invite.code);
|
||||
await acceptInvite(harness, joiner3.token, invite.code);
|
||||
|
||||
const invitesList = await createBuilder<Array<GuildInviteMetadataResponse>>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/invites`)
|
||||
.execute();
|
||||
|
||||
const updatedInvite = invitesList.find((i) => i.code === invite.code);
|
||||
expect(updatedInvite).toBeDefined();
|
||||
expect(updatedInvite!.uses).toBe(3);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
});
|
||||
48
packages/api/src/invite/tests/InviteTestUtils.tsx
Normal file
48
packages/api/src/invite/tests/InviteTestUtils.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {GuildInviteMetadataResponse, GuildInviteResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
export async function getInvite(harness: ApiTestHarness, token: string, code: string): Promise<GuildInviteResponse> {
|
||||
return createBuilder<GuildInviteResponse>(harness, token).get(`/invites/${code}`).execute();
|
||||
}
|
||||
|
||||
export async function deleteInvite(harness: ApiTestHarness, token: string, code: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/invites/${code}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function listChannelInvites(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
): Promise<Array<GuildInviteMetadataResponse>> {
|
||||
return createBuilder<Array<GuildInviteMetadataResponse>>(harness, token)
|
||||
.get(`/channels/${channelId}/invites`)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function listGuildInvites(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
): Promise<Array<GuildInviteMetadataResponse>> {
|
||||
return createBuilder<Array<GuildInviteMetadataResponse>>(harness, token).get(`/guilds/${guildId}/invites`).execute();
|
||||
}
|
||||
97
packages/api/src/invite/tests/InviteValidation.test.tsx
Normal file
97
packages/api/src/invite/tests/InviteValidation.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Invite Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject getting nonexistent invite', async () => {
|
||||
await createBuilder(harness, '').get('/invites/nonexistent_code').expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
|
||||
it('should reject accepting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/invites/nonexistent_code')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject deleting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete('/invites/nonexistent_code')
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject invalid max_uses value when creating invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({max_uses: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject invalid max_age value when creating invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({max_age: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should accept valid max_uses and max_age values', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({max_uses: 10, max_age: 3600})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user