initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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>;
}

View 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);
},
);
};

View 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,
};
};

View 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();
}
}

View 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(),
};
}
}