initial commit
This commit is contained in:
40
fluxer_api/src/invite/IInviteRepository.ts
Normal file
40
fluxer_api/src/invite/IInviteRepository.ts
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 '~/BrandedTypes';
|
||||
import type {Invite} from '~/Models';
|
||||
|
||||
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>;
|
||||
}
|
||||
252
fluxer_api/src/invite/InviteController.ts
Normal file
252
fluxer_api/src/invite/InviteController.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* 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 {Context} from 'hono';
|
||||
import type {HonoApp, HonoEnv} from '~/App';
|
||||
import type {ChannelID, GuildID} from '~/BrandedTypes';
|
||||
import {createChannelID, createGuildID, createInviteCode} from '~/BrandedTypes';
|
||||
import {InviteTypes} from '~/Constants';
|
||||
import {PackAccessDeniedError} from '~/errors/PackAccessDeniedError';
|
||||
import {UnknownPackError} from '~/errors/UnknownPackError';
|
||||
import {
|
||||
mapInviteToGroupDmInviteMetadataResponse,
|
||||
mapInviteToGroupDmInviteResponse,
|
||||
mapInviteToGuildInviteMetadataResponse,
|
||||
mapInviteToGuildInviteResponse,
|
||||
mapInviteToPackInviteMetadataResponse,
|
||||
mapInviteToPackInviteResponse,
|
||||
} from '~/invite/InviteModel';
|
||||
import type {Invite} from '~/Models';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const inviteCodeParamSchema = z.object({invite_code: createStringType()});
|
||||
const channelIdParamSchema = z.object({channel_id: Int64Type});
|
||||
const guildIdParamSchema = z.object({guild_id: Int64Type});
|
||||
const packIdParamSchema = z.object({pack_id: Int64Type});
|
||||
const packInviteBodySchema = z.object({
|
||||
max_uses: z.number().int().min(0).max(100).nullish().default(0),
|
||||
max_age: z.number().int().min(0).max(604800).nullish().default(0),
|
||||
unique: z.boolean().nullish().default(false),
|
||||
});
|
||||
|
||||
const createMappingHelpers = (ctx: Context<HonoEnv>) => ({
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
getChannelResponse: async (channelId: ChannelID) => await ctx.get('channelService').getPublicChannelData(channelId),
|
||||
getChannelSystem: async (channelId: ChannelID) => await ctx.get('channelService').getChannelSystem(channelId),
|
||||
getChannelMemberCount: async (channelId: ChannelID) =>
|
||||
await ctx.get('channelService').getChannelMemberCount(channelId),
|
||||
getGuildResponse: async (guildId: GuildID) => await ctx.get('guildService').getPublicGuildData(guildId),
|
||||
getGuildCounts: async (guildId: GuildID) => await ctx.get('gatewayService').getGuildCounts(guildId),
|
||||
packRepository: ctx.get('packRepository'),
|
||||
gatewayService: ctx.get('gatewayService'),
|
||||
});
|
||||
|
||||
const mapInviteResponse = async (invite: Invite, ctx: Context<HonoEnv>) => {
|
||||
const helpers = createMappingHelpers(ctx);
|
||||
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});
|
||||
};
|
||||
|
||||
const mapInviteMetadataResponse = async (invite: Invite, ctx: Context<HonoEnv>) => {
|
||||
const helpers = createMappingHelpers(ctx);
|
||||
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});
|
||||
};
|
||||
|
||||
const mapInviteList = async (invites: Array<Invite>, ctx: Context<HonoEnv>) => {
|
||||
return Promise.all(invites.map((invite) => mapInviteMetadataResponse(invite, ctx)));
|
||||
};
|
||||
|
||||
export const InviteController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/invites/:invite_code',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_GET),
|
||||
Validator('param', inviteCodeParamSchema),
|
||||
async (ctx) => {
|
||||
const inviteCode = createInviteCode(ctx.req.valid('param').invite_code);
|
||||
const invite = await ctx.get('inviteService').getInvite(inviteCode);
|
||||
return ctx.json(await mapInviteResponse(invite, ctx));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/invites/:invite_code',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_ACCEPT),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', inviteCodeParamSchema),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const inviteCode = createInviteCode(ctx.req.valid('param').invite_code);
|
||||
const helpers = createMappingHelpers(ctx);
|
||||
await ctx.get('inviteService').acceptInvite({userId, inviteCode, requestCache: helpers.requestCache});
|
||||
const invite = await ctx.get('inviteService').getInvite(inviteCode);
|
||||
return ctx.json(await mapInviteResponse(invite, ctx));
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/invites/:invite_code',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', inviteCodeParamSchema),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const inviteCode = createInviteCode(ctx.req.valid('param').invite_code);
|
||||
const inviteService = ctx.get('inviteService');
|
||||
const invite = await inviteService.getInvite(inviteCode);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await inviteService.deleteInvite({userId, inviteCode}, auditLogReason);
|
||||
await inviteService.dispatchInviteDelete(invite);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', channelIdParamSchema),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
max_uses: z.number().int().min(0).max(100).nullish().default(0),
|
||||
max_age: z.number().int().min(0).max(604800).nullish().default(0),
|
||||
unique: z.boolean().nullish().default(false),
|
||||
temporary: z.boolean().nullish().default(false),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const {max_uses: maxUses, max_age: maxAge, unique, temporary} = ctx.req.valid('json');
|
||||
const inviteService = ctx.get('inviteService');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const {invite, isNew} = await inviteService.createInvite(
|
||||
{
|
||||
inviterId: userId,
|
||||
channelId,
|
||||
maxUses: maxUses ?? 0,
|
||||
maxAge: maxAge ?? 0,
|
||||
unique: unique ?? false,
|
||||
temporary: temporary ?? false,
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
const inviteData = await mapInviteMetadataResponse(invite, ctx);
|
||||
if (isNew) {
|
||||
await inviteService.dispatchInviteCreate(invite, inviteData);
|
||||
}
|
||||
return ctx.json(inviteData);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_LIST_CHANNEL),
|
||||
LoginRequired,
|
||||
Validator('param', channelIdParamSchema),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const invites = await ctx.get('inviteService').getChannelInvitesSorted({userId, channelId});
|
||||
return ctx.json(await mapInviteList(invites, ctx));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.INVITE_LIST_GUILD),
|
||||
LoginRequired,
|
||||
Validator('param', guildIdParamSchema),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const invites = await ctx.get('inviteService').getGuildInvitesSorted({userId, guildId});
|
||||
return ctx.json(await mapInviteList(invites, ctx));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/packs/:pack_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.PACKS_INVITES_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', packIdParamSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const packId = createGuildID(ctx.req.valid('param').pack_id);
|
||||
const invites = await ctx.get('inviteService').getPackInvitesSorted({
|
||||
userId: user.id,
|
||||
packId,
|
||||
});
|
||||
return ctx.json(await mapInviteList(invites, ctx));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/packs/:pack_id/invites',
|
||||
RateLimitMiddleware(RateLimitConfigs.PACKS_INVITES_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', packIdParamSchema),
|
||||
Validator('json', packInviteBodySchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const packId = createGuildID(ctx.req.valid('param').pack_id);
|
||||
const pack = await ctx.get('packRepository').getPack(packId);
|
||||
if (!pack) {
|
||||
throw new UnknownPackError();
|
||||
}
|
||||
if (pack.creatorId !== user.id) {
|
||||
throw new PackAccessDeniedError();
|
||||
}
|
||||
|
||||
const {max_uses, max_age, unique} = ctx.req.valid('json');
|
||||
const {invite, isNew} = await ctx.get('inviteService').createPackInvite({
|
||||
inviterId: user.id,
|
||||
packId,
|
||||
packType: pack.type,
|
||||
maxUses: max_uses ?? 0,
|
||||
maxAge: max_age ?? 0,
|
||||
unique: unique ?? false,
|
||||
});
|
||||
const inviteData = await mapInviteMetadataResponse(invite, ctx);
|
||||
if (isNew) {
|
||||
await ctx.get('inviteService').dispatchInviteCreate(invite, inviteData);
|
||||
}
|
||||
return ctx.json(inviteData);
|
||||
},
|
||||
);
|
||||
};
|
||||
370
fluxer_api/src/invite/InviteModel.ts
Normal file
370
fluxer_api/src/invite/InviteModel.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* 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 '~/BrandedTypes';
|
||||
import {InviteTypes} from '~/Constants';
|
||||
import {ChannelPartialResponse} from '~/channel/ChannelModel';
|
||||
import {UnknownInviteError} from '~/Errors';
|
||||
import {UnknownPackError} from '~/errors/UnknownPackError';
|
||||
import {GuildPartialResponse} from '~/guild/GuildModel';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Invite} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {mapPackToSummary} from '~/pack/PackModel';
|
||||
import type {PackRepository} from '~/pack/PackRepository';
|
||||
import {z} from '~/Schema';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '~/user/UserCacheHelpers';
|
||||
import {UserPartialResponse} from '~/user/UserModel';
|
||||
|
||||
export const GuildInviteResponse = z.object({
|
||||
code: z.string(),
|
||||
type: z.literal(InviteTypes.GUILD),
|
||||
guild: GuildPartialResponse,
|
||||
channel: z.lazy(() => ChannelPartialResponse),
|
||||
inviter: z.lazy(() => UserPartialResponse).nullish(),
|
||||
member_count: z.number().int(),
|
||||
presence_count: z.number().int(),
|
||||
expires_at: z.iso.datetime().nullish(),
|
||||
temporary: z.boolean(),
|
||||
});
|
||||
export type GuildInviteResponse = z.infer<typeof GuildInviteResponse>;
|
||||
|
||||
export const GroupDmInviteResponse = z.object({
|
||||
code: z.string(),
|
||||
type: z.literal(InviteTypes.GROUP_DM),
|
||||
channel: z.lazy(() => ChannelPartialResponse),
|
||||
inviter: z.lazy(() => UserPartialResponse).nullish(),
|
||||
member_count: z.number().int(),
|
||||
expires_at: z.iso.datetime().nullish(),
|
||||
temporary: z.boolean(),
|
||||
});
|
||||
export type GroupDmInviteResponse = z.infer<typeof GroupDmInviteResponse>;
|
||||
|
||||
const PackInfoResponse = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullish(),
|
||||
type: z.enum(['emoji', 'sticker']),
|
||||
creator_id: z.string(),
|
||||
created_at: z.iso.datetime(),
|
||||
updated_at: z.iso.datetime(),
|
||||
creator: z.lazy(() => UserPartialResponse),
|
||||
});
|
||||
|
||||
export const PackInviteResponse = z.object({
|
||||
code: z.string(),
|
||||
type: z.union([z.literal(InviteTypes.EMOJI_PACK), z.literal(InviteTypes.STICKER_PACK)]),
|
||||
pack: PackInfoResponse,
|
||||
inviter: z.lazy(() => UserPartialResponse).nullish(),
|
||||
expires_at: z.iso.datetime().nullish(),
|
||||
temporary: z.boolean(),
|
||||
});
|
||||
export type PackInviteResponse = z.infer<typeof PackInviteResponse>;
|
||||
|
||||
export const PackInviteMetadataResponse = z.object({
|
||||
...PackInviteResponse.shape,
|
||||
created_at: z.iso.datetime(),
|
||||
uses: z.number().int(),
|
||||
max_uses: z.number().int(),
|
||||
});
|
||||
export type PackInviteMetadataResponse = z.infer<typeof PackInviteMetadataResponse>;
|
||||
|
||||
export const GuildInviteMetadataResponse = z.object({
|
||||
...GuildInviteResponse.shape,
|
||||
created_at: z.iso.datetime(),
|
||||
uses: z.number().int(),
|
||||
max_uses: z.number().int(),
|
||||
});
|
||||
export type GuildInviteMetadataResponse = z.infer<typeof GuildInviteMetadataResponse>;
|
||||
|
||||
export const GroupDmInviteMetadataResponse = z.object({
|
||||
...GroupDmInviteResponse.shape,
|
||||
created_at: z.iso.datetime(),
|
||||
uses: z.number().int(),
|
||||
max_uses: z.number().int(),
|
||||
});
|
||||
export type GroupDmInviteMetadataResponse = z.infer<typeof GroupDmInviteMetadataResponse>;
|
||||
|
||||
interface MapInviteToGuildInviteResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<GuildPartialResponse>;
|
||||
getGuildCounts: (guildId: GuildID) => Promise<{memberCount: number; presenceCount: number}>;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
interface MapInviteToGuildInviteMetadataResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<GuildPartialResponse>;
|
||||
getGuildCounts: (guildId: GuildID) => Promise<{memberCount: number; presenceCount: number}>;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
interface MapInviteToGroupDmInviteResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
|
||||
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
|
||||
}
|
||||
|
||||
interface MapInviteToGroupDmInviteMetadataResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
|
||||
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
|
||||
}
|
||||
|
||||
export const mapInviteToGuildInviteResponse = async ({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getGuildResponse,
|
||||
getGuildCounts,
|
||||
gatewayService,
|
||||
}: MapInviteToGuildInviteResponseParams): Promise<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 const mapInviteToGuildInviteMetadataResponse = async ({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getGuildResponse,
|
||||
getGuildCounts,
|
||||
gatewayService,
|
||||
}: MapInviteToGuildInviteMetadataResponseParams): Promise<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,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapInviteToGroupDmInviteResponse = async ({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getChannelSystem,
|
||||
getChannelMemberCount,
|
||||
}: MapInviteToGroupDmInviteResponseParams): Promise<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 const mapInviteToGroupDmInviteMetadataResponse = async ({
|
||||
invite,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
getChannelResponse,
|
||||
getChannelSystem,
|
||||
getChannelMemberCount,
|
||||
}: MapInviteToGroupDmInviteMetadataResponseParams): Promise<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<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 const mapInviteToPackInviteResponse = async (
|
||||
params: MapInviteToPackInviteResponseParams,
|
||||
): Promise<PackInviteResponse> => {
|
||||
return buildPackInviteBase(params);
|
||||
};
|
||||
|
||||
export const mapInviteToPackInviteMetadataResponse = async (
|
||||
params: MapInviteToPackInviteResponseParams,
|
||||
): Promise<PackInviteMetadataResponse> => {
|
||||
const baseResponse = await buildPackInviteBase(params);
|
||||
|
||||
return {
|
||||
...baseResponse,
|
||||
created_at: params.invite.createdAt.toISOString(),
|
||||
uses: params.invite.uses,
|
||||
max_uses: params.invite.maxUses,
|
||||
};
|
||||
};
|
||||
165
fluxer_api/src/invite/InviteRepository.ts
Normal file
165
fluxer_api/src/invite/InviteRepository.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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, createInviteCode, type GuildID, type InviteCode, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {InviteRow} from '~/database/CassandraTypes';
|
||||
import {Invite} from '~/Models';
|
||||
import {Invites, InvitesByChannel, InvitesByGuild} from '~/Tables';
|
||||
import {IInviteRepository} from './IInviteRepository';
|
||||
|
||||
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();
|
||||
|
||||
batch.addPrepared(Invites.insertWithTtlParam(inviteRow, 'max_age'));
|
||||
|
||||
if (inviteRow.guild_id) {
|
||||
batch.addPrepared(
|
||||
InvitesByGuild.insertWithTtl(
|
||||
{
|
||||
guild_id: inviteRow.guild_id,
|
||||
code: inviteRow.code,
|
||||
},
|
||||
inviteRow.max_age,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (inviteRow.channel_id) {
|
||||
batch.addPrepared(
|
||||
InvitesByChannel.insertWithTtl(
|
||||
{
|
||||
channel_id: inviteRow.channel_id,
|
||||
code: inviteRow.code,
|
||||
},
|
||||
inviteRow.max_age,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
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();
|
||||
}
|
||||
}
|
||||
675
fluxer_api/src/invite/InviteService.ts
Normal file
675
fluxer_api/src/invite/InviteService.ts
Normal file
@@ -0,0 +1,675 @@
|
||||
/*
|
||||
* 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,
|
||||
createInviteCode,
|
||||
type GuildID,
|
||||
type InviteCode,
|
||||
type UserID,
|
||||
vanityCodeToInviteCode,
|
||||
} from '~/BrandedTypes';
|
||||
import {ChannelTypes, GuildFeatures, GuildOperations, InviteTypes, MAX_GUILD_INVITES, Permissions} from '~/Constants';
|
||||
import type {ChannelService} from '~/channel/services/ChannelService';
|
||||
import {AuditLogActionType} from '~/constants/AuditLogActionType';
|
||||
import {
|
||||
FeatureTemporarilyDisabledError,
|
||||
GuildDisallowsUnclaimedAccountsError,
|
||||
InvitesDisabledError,
|
||||
MaxGuildInvitesError,
|
||||
MaxGuildMembersError,
|
||||
MissingPermissionsError,
|
||||
TemporaryInviteRequiresPresenceError,
|
||||
UnclaimedAccountRestrictedError,
|
||||
UnknownChannelError,
|
||||
UnknownInviteError,
|
||||
} from '~/Errors';
|
||||
import {PackAccessDeniedError} from '~/errors/PackAccessDeniedError';
|
||||
import {UnknownPackError} from '~/errors/UnknownPackError';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {
|
||||
GroupDmInviteMetadataResponse,
|
||||
GuildInviteMetadataResponse,
|
||||
PackInviteMetadataResponse,
|
||||
} from '~/invite/InviteModel';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {Channel, Invite} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {PackRepository, PackType} from '~/pack/PackRepository';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
import type {IInviteRepository} from './IInviteRepository';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
) {}
|
||||
|
||||
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};
|
||||
}
|
||||
}
|
||||
|
||||
if (existingInvites.length >= MAX_GUILD_INVITES) {
|
||||
throw new MaxGuildInvitesError();
|
||||
}
|
||||
|
||||
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};
|
||||
}
|
||||
}
|
||||
|
||||
if (allInvites.length >= MAX_GUILD_INVITES) {
|
||||
throw new MaxGuildInvitesError();
|
||||
}
|
||||
|
||||
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> {
|
||||
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 && !user.passwordHash) {
|
||||
throw new UnclaimedAccountRestrictedError('join group DMs');
|
||||
}
|
||||
|
||||
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 invite;
|
||||
}
|
||||
|
||||
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 invite;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (guild.features.has(GuildFeatures.DISALLOW_UNCLAIMED_ACCOUNTS)) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (user && !user.passwordHash) {
|
||||
throw new GuildDisallowsUnclaimedAccountsError();
|
||||
}
|
||||
}
|
||||
|
||||
const existingMember = await this.gatewayService.hasGuildMember({
|
||||
guildId: invite.guildId,
|
||||
userId,
|
||||
});
|
||||
if (existingMember) {
|
||||
return invite;
|
||||
}
|
||||
|
||||
await this.guildService.checkUserBanStatus({userId, guildId: invite.guildId});
|
||||
|
||||
const {memberCount} = await this.gatewayService.getGuildCounts(invite.guildId);
|
||||
if (memberCount >= 1000) {
|
||||
throw new MaxGuildMembersError(1000);
|
||||
}
|
||||
|
||||
if (invite.temporary) {
|
||||
const hasPresence = await this.gatewayService.hasActivePresence(userId);
|
||||
if (!hasPresence) {
|
||||
throw new TemporaryInviteRequiresPresenceError();
|
||||
}
|
||||
}
|
||||
|
||||
await this.guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId: invite.guildId,
|
||||
sendJoinMessage: true,
|
||||
requestCache,
|
||||
isTemporary: invite.temporary,
|
||||
});
|
||||
|
||||
if (invite.temporary) {
|
||||
await this.gatewayService.addTemporaryGuild({userId, guildId: invite.guildId});
|
||||
}
|
||||
|
||||
const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null;
|
||||
const isVanityInvite = invite.code === vanityCode;
|
||||
|
||||
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 invite;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await checkPermission(Permissions.MANAGE_GUILD);
|
||||
|
||||
if (invite.code === guildData.vanity_url_code) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
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): Record<string, unknown> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user