Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions fluxer_api/src/api/Tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ import {
type GuildStickerRow,
} from './database/types/GuildTypes';
import {INSTANCE_CONFIGURATION_COLUMNS, type InstanceConfigurationRow} from './database/types/InstanceConfigTypes';
import { GUILD_INVITE_BUNDLE_COLUMNS, type GuildInviteBundleRow } from './database/types/InviteBundleTypes';
import {
JOB_ACTIVE_COLUMNS,
JOB_BY_DAY_BUCKET_COLUMNS,
Expand Down Expand Up @@ -1064,6 +1065,11 @@ export const InvitesByGuild = defineTable<InvitesByGuildRow, 'guild_id' | 'code'
columns: INVITES_BY_GUILD_COLUMNS,
primaryKey: ['guild_id', 'code'],
});
export const GuildInviteBundles = defineTable<GuildInviteBundleRow, 'code'>({
name: 'guild_invite_bundles',
columns: GUILD_INVITE_BUNDLE_COLUMNS,
primaryKey: ['code'],
});
const APPLICATIONS_BY_OWNER_COLUMNS = ['owner_user_id', 'application_id'] as const satisfies ReadonlyArray<
keyof ApplicationByOwnerRow
>;
Expand Down
25 changes: 25 additions & 0 deletions fluxer_api/src/api/database/types/InviteBundleTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {ChannelID, GuildID, InviteCode, UserID} from '../../BrandedTypes';

export const GUILD_INVITE_BUNDLE_COLUMNS = [
'code',
'invites',
'inviter_id',
'created_at',
'uses',
'max_uses',
'max_age',
] as const satisfies ReadonlyArray<keyof GuildInviteBundleRow>;

export interface GuildInviteBundleRow {
code: InviteCode;
invites: Array<{
guild_id: GuildID;
channel_id: ChannelID;
code: InviteCode;
}>;
inviter_id: UserID;
created_at: Date;
uses: number;
max_uses: number;
max_age: number;
}
16 changes: 16 additions & 0 deletions fluxer_api/src/api/invite/IInviteRepository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

import type {ChannelID, GuildID, InviteCode, UserID} from '../BrandedTypes';
import type {GuildInviteBundle} from '../models/GuildInviteBundle';
import type {Invite} from '../models/Invite';

export abstract class IInviteRepository {
Expand All @@ -25,4 +26,19 @@ export abstract class IInviteRepository {
abstract updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise<void>;

abstract delete(code: InviteCode): Promise<void>;

abstract createGuildBundle(data: {
code: InviteCode;
max_uses: number;
max_age: number;
uses: number;
invites: Array<{
guild_id: GuildID;
channel_id: ChannelID;
code: InviteCode;
}>;
inviter_id: UserID;
}): Promise<GuildInviteBundle>;

abstract findUniqueGuildBundle(code: InviteCode): Promise<GuildInviteBundle | null>;
}
65 changes: 65 additions & 0 deletions fluxer_api/src/api/invite/InviteController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ChannelInviteCreateRequest,
GuildInviteBundleAcceptRequest,
GuildInviteBundleCreateRequest,
GuildInviteBundleMetadataResponse,
GuildInviteBundleResponse,
InviteMetadataResponseSchema,
InviteResponseSchema,
PackInviteCreateRequest,
Expand Down Expand Up @@ -227,4 +231,65 @@ export function InviteController(app: HonoApp) {
);
},
);
app.post(
'/guild-bundles',
RateLimitMiddleware(RateLimitConfigs.GUILD_INVITE_BUNDLE_CREATE),
LoginRequired,
DefaultUserOnly,
Validator('json', GuildInviteBundleCreateRequest),
OpenAPI({
operationId: 'create_guild_invite_bundle',
summary: 'Create guild invite bundle',
description:
'Creates a new invite bundle for the specified guild channels, with expiration and maximum uses parameters. The authenticated user must have permission to create invites for all of the guild channels and must be a default (non-bot) user. Returns the created invite metadata including the code.',
responseSchema: GuildInviteBundleMetadataResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Invites'],
}),
async (ctx) => {
const userId = ctx.get('user').id;
const inviteRequestService = ctx.get('inviteRequestService');
const requestCache = ctx.get('requestCache');
return ctx.json(
await inviteRequestService.createGuildInviteBundle({
inviterId: userId,
data: ctx.req.valid('json'),
requestCache,
}),
);
},
);
app.post(
'/guild-bundles/:invite_code',
RateLimitMiddleware(RateLimitConfigs.GUILD_INVITE_BUNDLE_ACCEPT),
LoginRequired,
DefaultUserOnly,
Validator('json', GuildInviteBundleAcceptRequest),
Validator('param', InviteCodeParam),
OpenAPI({
operationId: 'accept_guild_invite_bundle',
summary: 'Aceppt guild invite bundle',
description:
'Accepts a guild invite bundle using its code, adding the authenticated user to the guilds specified in the request. The usage count for the invite bundle as well as all invites linked to this invite bundle for which the guild ID was specified in the request is increased. If the usage count for the bundle or child invites reaches the maximum usage limit or expiration, those invites are automatically revoked.',
responseSchema: GuildInviteBundleResponse,
statusCode: 200,
security: ['sessionToken'],
tags: ['Invites'],
}),
async (ctx) => {
const userId = ctx.get('user').id;
const inviteCode = createInviteCode(ctx.req.valid('param').invite_code);
const inviteRequestService = ctx.get('inviteRequestService');
const requestCache = ctx.get('requestCache');
return ctx.json(
await inviteRequestService.acceptGuildInviteBundle({
userId,
inviteCode,
guildIds: ctx.req.valid('json').guild_ids.map((guildId) => createGuildID(BigInt(guildId))),
requestCache,
}),
);
},
);
}
39 changes: 38 additions & 1 deletion fluxer_api/src/api/invite/InviteRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {createInviteCode} from '../BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import {Db} from '../database/CassandraTypes';
import type {InviteRow} from '../database/types/ChannelTypes';
import type {GuildInviteBundleRow} from '../database/types/InviteBundleTypes';
import {GuildInviteBundle} from '../models/GuildInviteBundle';
import {Invite} from '../models/Invite';
import {Invites, InvitesByChannel, InvitesByGuild} from '../Tables';
import {GuildInviteBundles, Invites, InvitesByChannel, InvitesByGuild} from '../Tables';
import {IInviteRepository} from './IInviteRepository';

const FETCH_INVITE_BY_CODE_CQL = Invites.selectCql({
Expand Down Expand Up @@ -208,4 +210,39 @@ export class InviteRepository extends IInviteRepository {
}
await batch.execute();
}

override async createGuildBundle(data: {
code: InviteCode;
max_uses: number;
max_age: number;
uses: number;
invites: Array<{guild_id: GuildID; channel_id: ChannelID; code: InviteCode}>;
inviter_id: UserID;
}): Promise<GuildInviteBundle> {
const guildInviteBundleRow: GuildInviteBundleRow = {
code: data.code,
max_uses: data.max_uses,
max_age: data.max_age,
uses: data.uses,
invites: data.invites,
inviter_id: data.inviter_id,
created_at: new Date(),
};

const batch = new BatchBuilder();
const hasExpiry = guildInviteBundleRow.max_age > 0;
if (hasExpiry) {
batch.addPrepared(GuildInviteBundles.insertWithTtlParam(guildInviteBundleRow, 'max_age'));
} else {
batch.addPrepared(GuildInviteBundles.insert(guildInviteBundleRow));
}
await batch.execute();
if (hasExpiry) {
await upsertOne(GuildInviteBundles.upsertAllWithTtl(guildInviteBundleRow, guildInviteBundleRow.max_age));
} else {
await upsertOne(GuildInviteBundles.upsertAll(guildInviteBundleRow));
}

return new GuildInviteBundle(guildInviteBundleRow);
}
}
86 changes: 86 additions & 0 deletions fluxer_api/src/api/invite/InviteRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type {ChannelPartialResponse} from '@fluxer/schema/src/domains/channel/Ch
import type {GuildPartialResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {
ChannelInviteCreateRequest,
GuildInviteBundleCreateRequest,
GuildInviteBundleMetadataResponse,
GuildInviteBundleResponse,
InviteMetadataResponseSchema,
InviteResponseSchema,
PackInviteCreateRequest,
Expand All @@ -29,6 +32,8 @@ import {
mapInviteToPackInviteResponse,
} from './InviteModel';
import type {InviteService} from './InviteService';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {Guild} from '../models/Guild';

interface MappingHelpers {
userCacheService: UserCacheService;
Expand Down Expand Up @@ -164,6 +169,87 @@ export class InviteRequestService {
return inviteData;
}

async createGuildInviteBundle(params: {
inviterId: UserID;
data: GuildInviteBundleCreateRequest;
requestCache: RequestCache;
}): Promise<GuildInviteBundleMetadataResponse> {
const {bundle, invites} = await this.inviteService.createInviteBundle(params.inviterId, params.data);
for (const invite of invites) {
const inviteData = await this.mapInviteMetadataResponse(invite, params.requestCache);
await this.inviteService.dispatchInviteCreate(invite, inviteData);
}
return bundle;
}

async acceptGuildInviteBundle(params: {
userId: UserID;
inviteCode: InviteCode;
guildIds: Array<GuildID>;
requestCache: RequestCache;
}): Promise<GuildInviteBundleResponse> {
const {bundle, num_expired, invitesDisabledOrFeatureTemporarilyDisabled} =
await this.inviteService.acceptGuildInviteBundle(
params.userId,
params.inviteCode,
params.guildIds,
params.requestCache,
);
const guilds: Array<{
guild: GuildPartialResponse;
channel?: ChannelPartialResponse | null;
}> = [];
for (const inviteData of bundle.invites) {
let guild: Guild;
try {
guild = await this.guildService.data.getGuildSystem(inviteData.guild_id);
} catch (error) {
if (error instanceof UnknownGuildError) {
continue;
}
throw error;
}
const channel = await this.channelService.channelData.operations.getChannelSystem(inviteData.channel_id);
guilds.push({
guild: {
id: guild.id.toString(),
name: guild.name,
icon: guild.iconHash,
} as GuildPartialResponse,
channel: channel
? ({
id: channel.id.toString(),
name: channel.name,
type: channel.type,
} as ChannelPartialResponse)
: undefined,
});
}
const invites_disabled = [];
for (const guildId of invitesDisabledOrFeatureTemporarilyDisabled) {
let guild: Guild;
try {
guild = await this.guildService.data.getGuildSystem(guildId);
} catch (error) {
if (error instanceof UnknownGuildError) {
continue;
}
throw error;
}
invites_disabled.push({
id: guild.id.toString(),
name: guild.name,
icon: guild.iconHash,
} as GuildPartialResponse);
}
return {
code: bundle.code,
guilds,
no_invite_available_count: num_expired,
invites_disabled,
};
}

private createMappingHelpers(requestCache: RequestCache): MappingHelpers {
return {
userCacheService: this.userCacheService,
Expand Down
Loading
Loading