feat(expressions): remove the unfinished packs feature (#2250)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-31 16:34:22 +02:00
1 parent 0eef611b6d
commit b8f8d8d859
159 files changed
+42 -6863

No files matched your search

-2
View File
@@ -10,7 +10,6 @@ import {IntegrationRateLimitConfigs} from './rate_limit_configs/IntegrationRateL
import {InviteRateLimitConfigs} from './rate_limit_configs/InviteRateLimitConfig';
import {MiscRateLimitConfigs} from './rate_limit_configs/MiscRateLimitConfig';
import {OAuthRateLimitConfigs} from './rate_limit_configs/OAuthRateLimitConfig';
import {PackRateLimitConfigs} from './rate_limit_configs/PackRateLimitConfig';
import type {RateLimitSection} from './rate_limit_configs/RateLimitHelpers';
import {mergeRateLimitSections} from './rate_limit_configs/RateLimitHelpers';
import {UserRateLimitConfigs} from './rate_limit_configs/UserRateLimitConfig';
@@ -29,6 +28,5 @@ const rateLimitSections = [
IntegrationRateLimitConfigs,
AdminRateLimitConfigs,
MiscRateLimitConfigs,
PackRateLimitConfigs,
] satisfies ReadonlyArray<RateLimitSection>;
export const RateLimitConfigs = mergeRateLimitSections(...rateLimitSections);
-22
View File
@@ -290,13 +290,10 @@ import {
type SuspiciousIpRow,
} from './database/types/RiskTypes';
import {
EXPRESSION_PACK_COLUMNS,
type ExpressionPackRow,
FAVORITE_MEME_COLUMNS,
type FavoriteMemeRow,
NOTE_COLUMNS,
type NoteRow,
type PackInstallationRow,
PUSH_SUBSCRIPTION_COLUMNS,
type PushSubscriptionRow,
RECENT_MENTION_COLUMNS,
@@ -1004,25 +1001,6 @@ export const FavoriteMemesByMemeId = defineTable<FavoriteMemesByMemeIdRow, 'meme
columns: FAVORITE_MEMES_BY_MEME_ID_COLUMNS,
primaryKey: ['meme_id', 'user_id'],
});
export const ExpressionPacks = defineTable<ExpressionPackRow, 'pack_id'>({
name: 'expression_packs',
columns: EXPRESSION_PACK_COLUMNS,
primaryKey: ['pack_id'],
});
export const ExpressionPacksByCreator = defineTable<ExpressionPackRow, 'creator_id' | 'pack_id'>({
name: 'expression_packs_by_creator',
columns: EXPRESSION_PACK_COLUMNS,
primaryKey: ['creator_id', 'pack_id'],
partitionKey: ['creator_id'],
});
const PACK_INSTALLATION_COLUMNS = ['user_id', 'pack_id', 'pack_type', 'installed_at'] as const satisfies ReadonlyArray<
keyof PackInstallationRow
>;
export const PackInstallations = defineTable<PackInstallationRow, 'user_id' | 'pack_id'>({
name: 'pack_installations',
columns: PACK_INSTALLATION_COLUMNS,
primaryKey: ['user_id', 'pack_id'],
});
interface InvitesByChannelRow {
channel_id: ChannelID;
@@ -24,7 +24,6 @@ import {getCacheService} from '../middleware/ServiceSingletons';
import {OAuth2ApplicationsController} from '../oauth/OAuth2ApplicationsController';
import {OAuth2Controller} from '../oauth/OAuth2Controller';
import {OpenAPIController} from '../openapi/OpenAPIController';
import {registerPackControllers} from '../pack/controllers/index';
import {PremiumController} from '../premium/PremiumController';
import {ReadStateController} from '../read_state/ReadStateController';
import {ReportController} from '../report/ReportController';
@@ -54,7 +53,6 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
FavoriteGifController(routes);
FavoriteMemeController(routes);
InviteController(routes);
registerPackControllers(routes);
ReadStateController(routes);
ReportController(routes);
GuildController(routes);
@@ -18,7 +18,6 @@ import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {IInviteRepository} from '../../invite/IInviteRepository';
import type {LimitConfigService} from '../../limits/LimitConfigService';
import type {User} from '../../models/User';
import type {PackService} from '../../pack/PackService';
import type {ReadStateService} from '../../read_state/ReadStateService';
import type {IUserRepository} from '../../user/IUserRepository';
import {createDirectMessageSpamMitigationService} from '../../user/services/DirectMessageSpamMitigationService';
@@ -57,7 +56,6 @@ export class ChannelService {
channelRepository: IChannelRepository,
userRepository: IUserRepository,
guildRepository: IGuildRepositoryAggregate,
packService: PackService,
userCacheService: UserCacheService,
embedService: EmbedService,
readStateService: ReadStateService,
@@ -94,7 +92,6 @@ export class ChannelService {
channelRepository,
userRepository,
guildRepository,
packService,
embedService,
storageService,
attachmentUploadTraceRepository,
@@ -9,7 +9,6 @@ import type {GuildID, UserID, WebhookID} from '../../../BrandedTypes';
import type {IGuildRepositoryAggregate} from '../../../guild/repositories/IGuildRepositoryAggregate';
import type {LimitConfigService} from '../../../limits/LimitConfigService';
import type {Channel} from '../../../models/Channel';
import type {PackService} from '../../../pack/PackService';
import type {IUserRepository} from '../../../user/IUserRepository';
import * as EmojiUtils from '../../../utils/EmojiUtils';
@@ -22,7 +21,6 @@ export class MessageContentService {
constructor(
private userRepository: IUserRepository,
private guildRepository: IGuildRepositoryAggregate,
private packService: PackService,
private limitConfigService: LimitConfigService,
) {}
@@ -33,15 +31,10 @@ export class MessageContentService {
guildId: GuildID | null;
hasPermission?: (permission: bigint) => Promise<boolean>;
}): Promise<string> {
const packResolver = await this.packService.createPackExpressionAccessResolver({
userId: params.userId,
type: 'emoji',
});
return await EmojiUtils.sanitizeCustomEmojis({
...params,
userRepository: this.userRepository,
guildRepository: this.guildRepository,
packResolver,
limitConfigService: this.limitConfigService,
});
}
@@ -33,7 +33,6 @@ import type {Channel} from '../../../models/Channel';
import type {Message} from '../../../models/Message';
import type {MessageSnapshot} from '../../../models/MessageSnapshot';
import type {User} from '../../../models/User';
import type {PackService} from '../../../pack/PackService';
import type {ReadStateService} from '../../../read_state/ReadStateService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {hasVisibleContent} from '../../../utils/StringUtils';
@@ -119,7 +118,6 @@ export class MessagePersistenceService {
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
private guildRepository: IGuildRepositoryAggregate,
private packService: PackService,
private embedService: EmbedService,
storageService: IStorageService,
attachmentUploadTraceRepository: AttachmentUploadTraceRepository,
@@ -136,18 +134,8 @@ export class MessagePersistenceService {
virusScanService,
snowflakeService,
);
this.contentService = new MessageContentService(
this.userRepository,
guildRepository,
this.packService,
limitConfigService,
);
this.stickerService = new MessageStickerService(
this.userRepository,
guildRepository,
this.packService,
limitConfigService,
);
this.contentService = new MessageContentService(this.userRepository, guildRepository, limitConfigService);
this.stickerService = new MessageStickerService(this.userRepository, guildRepository, limitConfigService);
this.embedAttachmentResolver = new MessageEmbedAttachmentResolver();
this.attachmentDecayService = new AttachmentDecayService();
}
@@ -11,14 +11,12 @@ import type {IGuildRepositoryAggregate} from '../../../guild/repositories/IGuild
import type {LimitConfigService} from '../../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
import type {PackService} from '../../../pack/PackService';
import type {IUserRepository} from '../../../user/IUserRepository';
export class MessageStickerService {
constructor(
private userRepository: IUserRepository,
private guildRepository: IGuildRepositoryAggregate,
private packService: PackService,
private readonly limitConfigService: LimitConfigService,
) {}
@@ -30,10 +28,6 @@ export class MessageStickerService {
isNSFWAllowed?: boolean;
}): Promise<Array<MessageStickerItem>> {
const {stickerIds, userId, guildId, hasPermission, isNSFWAllowed = true} = params;
const packResolver = await this.packService.createPackExpressionAccessResolver({
userId,
type: 'sticker',
});
let hasGlobalExpressions = 0;
if (userId) {
const user = await this.userRepository.findUnique(userId);
@@ -55,10 +49,6 @@ export class MessageStickerService {
if (!stickerFromAnyGuild) {
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
}
const packAccess = await packResolver.resolve(stickerFromAnyGuild.guildId);
if (packAccess === 'not-accessible') {
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
}
if (!isNSFWAllowed && stickerFromAnyGuild.isNsfw) {
throw new NsfwEmojiStickerBlockedError();
}
@@ -97,10 +87,6 @@ export class MessageStickerService {
throw new MissingPermissionsError();
}
}
const packAccess = await packResolver.resolve(stickerFromOtherGuild.guildId);
if (packAccess === 'not-accessible') {
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
}
if (!isNSFWAllowed && stickerFromOtherGuild.isNsfw) {
throw new NsfwEmojiStickerBlockedError();
}
@@ -311,24 +311,6 @@ export interface UserGuildSettingsRow {
version: number;
}
export interface ExpressionPackRow {
pack_id: GuildID;
pack_type: string;
creator_id: UserID;
name: string;
description: Nullish<string>;
created_at: Date;
updated_at: Date;
version: number;
}
export interface PackInstallationRow {
user_id: UserID;
pack_id: GuildID;
pack_type: string;
installed_at: Date;
}
export interface SavedMessageRow {
user_id: UserID;
channel_id: ChannelID;
@@ -515,16 +497,6 @@ export const USER_SETTINGS_COLUMNS = [
'default_share_voice_activity',
'version',
] as const satisfies ReadonlyArray<keyof UserSettingsRow>;
export const EXPRESSION_PACK_COLUMNS = [
'pack_id',
'pack_type',
'creator_id',
'name',
'description',
'created_at',
'updated_at',
'version',
] as const satisfies ReadonlyArray<keyof ExpressionPackRow>;
export const USER_GUILD_SETTINGS_COLUMNS = [
'user_id',
'guild_id',
+4 -66
View File
@@ -1,16 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
ChannelIdParam,
GuildIdParam,
InviteCodeParam,
PackIdParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ChannelIdParam, GuildIdParam, InviteCodeParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ChannelInviteCreateRequest,
InviteMetadataResponseSchema,
InviteResponseSchema,
PackInviteCreateRequest,
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import {z} from 'zod';
import {createChannelID, createGuildID, createInviteCode} from '../BrandedTypes';
@@ -30,7 +24,7 @@ export function InviteController(app: HonoApp) {
operationId: 'get_invite',
summary: 'Get invite information',
description:
'Fetches detailed information about an invite using its code, including the guild, channel, or pack it belongs to and metadata such as expiration and usage limits. This endpoint does not require authentication and does not consume the invite.',
'Fetches detailed information about an invite using its code, including the guild or channel it belongs to and metadata such as expiration and usage limits. This endpoint does not require authentication and does not consume the invite.',
responseSchema: InviteResponseSchema,
statusCode: 200,
security: [],
@@ -53,7 +47,7 @@ export function InviteController(app: HonoApp) {
operationId: 'accept_invite',
summary: 'Accept invite',
description:
'Accepts an invite using its code, adding the authenticated user to the corresponding guild, pack, or other entity. The invite usage count is incremented, and if it reaches its maximum usage limit or expiration, the invite is automatically revoked. Returns the accepted invite details.',
'Accepts an invite using its code, adding the authenticated user to the corresponding guild or other entity. The invite usage count is incremented, and if it reaches its maximum usage limit or expiration, the invite is automatically revoked. Returns the accepted invite details.',
responseSchema: InviteResponseSchema,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
@@ -77,7 +71,7 @@ export function InviteController(app: HonoApp) {
operationId: 'delete_invite',
summary: 'Delete invite',
description:
'Permanently deletes an invite by its code, preventing any further usage. The authenticated user must have permission to manage invites for the guild, channel, or pack associated with the invite. This action can be logged in the audit log if an X-Audit-Log-Reason header is provided.',
'Permanently deletes an invite by its code, preventing any further usage. The authenticated user must have permission to manage invites for the guild or channel associated with the invite. This action can be logged in the audit log if an X-Audit-Log-Reason header is provided.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
@@ -171,60 +165,4 @@ export function InviteController(app: HonoApp) {
return ctx.json(await inviteRequestService.listGuildInvites({userId, guildId, requestCache}));
},
);
app.get(
'/packs/:pack_id/invites',
RateLimitMiddleware(RateLimitConfigs.PACKS_INVITES_LIST),
LoginRequired,
DefaultUserOnly,
Validator('param', PackIdParam),
OpenAPI({
operationId: 'list_pack_invites',
summary: 'List pack invites',
description:
'Retrieves all currently active invites for the specified pack, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the pack and must be a default (non-bot) user. Returns an array of invite metadata objects.',
responseSchema: z.array(InviteMetadataResponseSchema),
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Invites'],
}),
async (ctx) => {
const userId = ctx.get('user').id;
const packId = createGuildID(ctx.req.valid('param').pack_id);
const inviteRequestService = ctx.get('inviteRequestService');
const requestCache = ctx.get('requestCache');
return ctx.json(await inviteRequestService.listPackInvites({userId, packId, requestCache}));
},
);
app.post(
'/packs/:pack_id/invites',
RateLimitMiddleware(RateLimitConfigs.PACKS_INVITES_CREATE),
LoginRequired,
DefaultUserOnly,
Validator('param', PackIdParam),
Validator('json', PackInviteCreateRequest),
OpenAPI({
operationId: 'create_pack_invite',
summary: 'Create pack invite',
description:
'Creates a new invite for the specified pack with optional parameters such as maximum age and maximum uses. The authenticated user must have permission to create invites for the pack and must be a default (non-bot) user. Returns the created invite with full metadata including usage statistics.',
responseSchema: InviteMetadataResponseSchema,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Invites'],
}),
async (ctx) => {
const userId = ctx.get('user').id;
const packId = createGuildID(ctx.req.valid('param').pack_id);
const inviteRequestService = ctx.get('inviteRequestService');
const requestCache = ctx.get('requestCache');
return ctx.json(
await inviteRequestService.createPackInvite({
inviterId: userId,
packId,
requestCache,
data: ctx.req.valid('json'),
}),
);
},
);
}
-69
View File
@@ -2,7 +2,6 @@
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
import {UnknownInviteError} from '@fluxer/errors/src/domains/invite/UnknownInviteError';
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
import type {ChannelPartialResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildPartialResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {
@@ -10,8 +9,6 @@ import type {
GroupDmInviteResponse,
GuildInviteMetadataResponse,
GuildInviteResponse,
PackInviteMetadataResponse,
PackInviteResponse,
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import type {z} from 'zod';
import type {ChannelID, GuildID} from '../BrandedTypes';
@@ -20,8 +17,6 @@ import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import type {Channel} from '../models/Channel';
import type {Invite} from '../models/Invite';
import {mapPackToSummary} from '../pack/PackModel';
import type {PackRepository} from '../pack/PackRepository';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '../user/UserCacheHelpers';
interface MapInviteToGuildInviteResponseParams {
@@ -216,67 +211,3 @@ export async function mapInviteToGroupDmInviteMetadataResponse({
max_uses: invite.maxUses,
};
}
interface MapInviteToPackInviteResponseParams {
invite: Invite;
userCacheService: UserCacheService;
requestCache: RequestCache;
packRepository: PackRepository;
}
const buildPackInviteBase = async ({
invite,
userCacheService,
requestCache,
packRepository,
}: MapInviteToPackInviteResponseParams): Promise<z.infer<typeof PackInviteResponse>> => {
if (!invite.guildId) {
throw new UnknownPackError();
}
const pack = await packRepository.getPack(invite.guildId);
if (!pack) {
throw new UnknownPackError();
}
const creator = await getCachedUserPartialResponse({
userId: pack.creatorId,
userCacheService,
requestCache,
});
const inviter = invite.inviterId
? await getCachedUserPartialResponse({
userId: invite.inviterId,
userCacheService,
requestCache,
})
: null;
const expiresAt = invite.maxAge > 0 ? new Date(invite.createdAt.getTime() + invite.maxAge * 1000) : null;
return {
code: invite.code,
type: invite.type as typeof InviteTypes.EMOJI_PACK | typeof InviteTypes.STICKER_PACK,
pack: {
...mapPackToSummary(pack),
creator,
},
inviter,
expires_at: expiresAt?.toISOString() ?? null,
temporary: invite.temporary,
};
};
export async function mapInviteToPackInviteResponse(
params: MapInviteToPackInviteResponseParams,
): Promise<z.infer<typeof PackInviteResponse>> {
return buildPackInviteBase(params);
}
export async function mapInviteToPackInviteMetadataResponse(
params: MapInviteToPackInviteResponseParams,
): Promise<z.infer<typeof PackInviteMetadataResponse>> {
const baseResponse = await buildPackInviteBase(params);
return {
...baseResponse,
created_at: params.invite.createdAt.toISOString(),
uses: params.invite.uses,
max_uses: params.invite.maxUses,
};
}
@@ -1,14 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
import type {ChannelPartialResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildPartialResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {
ChannelInviteCreateRequest,
InviteMetadataResponseSchema,
InviteResponseSchema,
PackInviteCreateRequest,
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import type {ChannelID, GuildID, InviteCode, UserID} from '../BrandedTypes';
import {mapChannelToPartialResponse} from '../channel/ChannelMappers';
@@ -19,14 +17,11 @@ import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import type {Channel} from '../models/Channel';
import type {Invite} from '../models/Invite';
import type {PackRepository} from '../pack/PackRepository';
import {
mapInviteToGroupDmInviteMetadataResponse,
mapInviteToGroupDmInviteResponse,
mapInviteToGuildInviteMetadataResponse,
mapInviteToGuildInviteResponse,
mapInviteToPackInviteMetadataResponse,
mapInviteToPackInviteResponse,
} from './InviteModel';
import type {InviteService} from './InviteService';
@@ -41,7 +36,6 @@ interface MappingHelpers {
memberCount: number;
presenceCount: number;
}>;
packRepository: PackRepository;
gatewayService: IGatewayService;
}
@@ -51,7 +45,6 @@ export class InviteRequestService {
private readonly channelService: ChannelService,
private readonly guildService: GuildService,
private readonly gatewayService: IGatewayService,
private readonly packRepository: PackRepository,
private readonly userCacheService: UserCacheService,
) {}
@@ -127,43 +120,6 @@ export class InviteRequestService {
return this.mapInviteList(invites, params.requestCache);
}
async listPackInvites(params: {
userId: UserID;
packId: GuildID;
requestCache: RequestCache;
}): Promise<Array<InviteMetadataResponseSchema>> {
const invites = await this.inviteService.getPackInvitesSorted({
userId: params.userId,
packId: params.packId,
});
return this.mapInviteList(invites, params.requestCache);
}
async createPackInvite(params: {
inviterId: UserID;
packId: GuildID;
requestCache: RequestCache;
data: PackInviteCreateRequest;
}): Promise<InviteMetadataResponseSchema> {
const pack = await this.packRepository.getPack(params.packId);
if (!pack) {
throw new UnknownPackError();
}
const {invite, isNew} = await this.inviteService.createPackInvite({
inviterId: params.inviterId,
packId: params.packId,
packType: pack.type,
maxUses: params.data.max_uses ?? 0,
maxAge: params.data.max_age ?? 0,
unique: params.data.unique ?? false,
});
const inviteData = await this.mapInviteMetadataResponse(invite, params.requestCache);
if (isNew) {
await this.inviteService.dispatchInviteCreate(invite, inviteData);
}
return inviteData;
}
private createMappingHelpers(requestCache: RequestCache): MappingHelpers {
return {
userCacheService: this.userCacheService,
@@ -176,7 +132,6 @@ export class InviteRequestService {
await this.channelService.channelData.operations.getChannelMemberCount(channelId),
getGuildResponse: async (guildId: GuildID) => await this.guildService.data.getPublicGuildData(guildId),
getGuildCounts: async (guildId: GuildID) => await this.gatewayService.getGuildCounts(guildId),
packRepository: this.packRepository,
gatewayService: this.gatewayService,
};
}
@@ -186,9 +141,6 @@ export class InviteRequestService {
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});
}
@@ -200,9 +152,6 @@ export class InviteRequestService {
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});
}
+1 -100
View File
@@ -12,12 +12,9 @@ import {MaxGuildInvitesError} from '@fluxer/errors/src/domains/guild/MaxGuildInv
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
import {TemporaryInviteRequiresPresenceError} from '@fluxer/errors/src/domains/invite/TemporaryInviteRequiresPresenceError';
import {UnknownInviteError} from '@fluxer/errors/src/domains/invite/UnknownInviteError';
import {PackAccessDeniedError} from '@fluxer/errors/src/domains/pack/PackAccessDeniedError';
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
import type {
GroupDmInviteMetadataResponse,
GuildInviteMetadataResponse,
PackInviteMetadataResponse,
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import type {ApiContext} from '../ApiContext';
import type {ChannelID, GuildID, InviteCode, UserID} from '../BrandedTypes';
@@ -32,8 +29,6 @@ import {createLimitMatchContext} from '../limits/LimitMatchContextBuilder';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import type {Channel} from '../models/Channel';
import {Invite} from '../models/Invite';
import type {PackRepository, PackType} from '../pack/PackRepository';
import type {PackService} from '../pack/PackService';
import * as RandomUtils from '../utils/RandomUtils';
import type {IInviteRepository} from './IInviteRepository';
@@ -56,15 +51,6 @@ interface CreateInviteParams {
temporary?: boolean;
}
interface CreatePackInviteParams {
inviterId: UserID;
packId: GuildID;
packType: PackType;
maxUses: number;
maxAge: number;
unique: boolean;
}
interface AcceptInviteParams {
userId: UserID;
inviteCode: InviteCode;
@@ -108,11 +94,6 @@ interface ReusableInviteCriteria {
type?: number;
}
const PACK_TYPE_TO_INVITE_TYPE: Record<PackType, number> = {
emoji: InviteTypes.EMOJI_PACK,
sticker: InviteTypes.STICKER_PACK,
};
export class InviteService {
constructor(
private readonly apiContext: ApiContext,
@@ -120,8 +101,6 @@ export class InviteService {
private guildService: GuildService,
private channelService: ChannelService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly packRepository: PackRepository,
private readonly packService: PackService,
private readonly limitConfigService: LimitConfigService,
) {}
@@ -255,51 +234,6 @@ export class InviteService {
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 = this.findReusableInvite(allInvites, {
inviterId,
maxUses,
maxAge,
type: inviteType,
});
if (existingInvite) {
return {invite: existingInvite, isNew: false};
}
}
const packInviteLimit = this.resolveInviteLimit(null);
if (allInvites.length >= packInviteLimit) {
throw new MaxGuildInvitesError(packInviteLimit);
}
const newInvite = await this.inviteRepository.create({
code: this.createRandomInviteCode(),
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.findInviteWithLowercaseFallback(inviteCode);
if (!invite) throw new UnknownInviteError();
@@ -334,11 +268,6 @@ export class InviteService {
});
return this.incrementInviteUses(invite, {deleteWhenExhausted: true});
}
if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) {
if (!invite.guildId) throw new UnknownInviteError();
await this.packService.installPack(userId, invite.guildId);
return this.incrementInviteUses(invite, {deleteWhenExhausted: true});
}
if (!invite.guildId) throw new UnknownInviteError();
const guild = await this.guildService.data.getGuildSystem(invite.guildId);
if ((guild.disabledOperations & GuildOperations.INSTANT_INVITES) !== 0) {
@@ -433,18 +362,6 @@ export class InviteService {
async deleteInvite({userId, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise<void> {
const invite = await this.findInviteWithLowercaseFallback(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(invite.code);
return;
}
if (invite.type === InviteTypes.GROUP_DM) {
if (!invite.channelId) throw new UnknownInviteError();
const channel = await this.channelService.channelData.operations.getChannel({
@@ -497,25 +414,9 @@ export class InviteService {
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,
inviteData: GuildInviteMetadataResponse | GroupDmInviteMetadataResponse,
): Promise<void> {
if (invite.guildId && invite.type === InviteTypes.GUILD) {
await this.apiContext.services.gateway.dispatchGuild({
@@ -9,7 +9,6 @@ import {ChannelService} from '../channel/services/ChannelService';
import type {IFavoriteMemeRepository} from '../favorite_meme/IFavoriteMemeRepository';
import type {GuildAuditLogService} from '../guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '../guild/repositories/IGuildRepositoryAggregate';
import type {ExpressionAssetPurger} from '../guild/services/content/ExpressionAssetPurger';
import {GuildService} from '../guild/services/GuildService';
import type {AvatarService} from '../infrastructure/AvatarService';
import type {IPurgeQueue} from '../infrastructure/BunnyPurgeQueue';
@@ -23,8 +22,6 @@ import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {InviteRepository} from '../invite/InviteRepository';
import {InviteService} from '../invite/InviteService';
import type {LimitConfigService} from '../limits/LimitConfigService';
import type {PackRepository} from '../pack/PackRepository';
import {PackService} from '../pack/PackService';
import type {ReadStateService} from '../read_state/ReadStateService';
import type {IUserRepository} from '../user/IUserRepository';
import type {VoiceAvailabilityService} from '../voice/VoiceAvailabilityService';
@@ -32,7 +29,6 @@ import type {IWebhookRepository} from '../webhook/IWebhookRepository';
interface GuildStackServiceFactoryDependencies {
apiContext: ApiContext;
packRepository: PackRepository;
channelRepository: IChannelRepository;
userRepository: IUserRepository;
guildRepository: IGuildRepositoryAggregate;
@@ -42,7 +38,6 @@ interface GuildStackServiceFactoryDependencies {
avatarService: AvatarService;
entityAssetService: EntityAssetService;
assetDeletionQueue: IAssetDeletionQueue;
expressionAssetPurger: ExpressionAssetPurger;
userCacheService: UserCacheService;
limitConfigService: LimitConfigService;
embedService: EmbedService;
@@ -59,40 +54,24 @@ interface GuildStackServiceFactoryDependencies {
}
export interface GuildStackServices {
packService: PackService;
channelService: ChannelService;
guildService: GuildService;
inviteService: InviteService;
}
class LazyGuildStackServices implements GuildStackServices {
private cachedPackService: PackService | undefined;
private cachedChannelService: ChannelService | undefined;
private cachedGuildService: GuildService | undefined;
private cachedInviteService: InviteService | undefined;
constructor(private readonly dependencies: GuildStackServiceFactoryDependencies) {}
get packService(): PackService {
this.cachedPackService ??= new PackService(
this.dependencies.apiContext,
this.dependencies.packRepository,
this.dependencies.guildRepository,
this.dependencies.avatarService,
this.dependencies.expressionAssetPurger,
this.dependencies.userCacheService,
this.dependencies.limitConfigService,
);
return this.cachedPackService;
}
get channelService(): ChannelService {
this.cachedChannelService ??= new ChannelService(
this.dependencies.apiContext,
this.dependencies.channelRepository,
this.dependencies.userRepository,
this.dependencies.guildRepository,
this.packService,
this.dependencies.userCacheService,
this.dependencies.embedService,
this.dependencies.readStateService,
@@ -139,8 +118,6 @@ class LazyGuildStackServices implements GuildStackServices {
this.guildService,
this.channelService,
this.dependencies.guildAuditLogService,
this.dependencies.packRepository,
this.packService,
this.dependencies.limitConfigService,
);
return this.cachedInviteService;
@@ -124,7 +124,6 @@ import {
getEntranceSoundPlayService,
getEntranceSoundService,
getErrorI18nService,
getExpressionAssetPurger,
getFavoriteMemeRepository,
getGatewayRequestService,
getGifService,
@@ -139,7 +138,6 @@ import {
getLimitConfigService,
getNcmecSubmissionService,
getOAuth2TokenRepository,
getPackRepository,
getPasswordChangeRepository,
getPremiumStateReconciliationQueueService,
getPurgeQueue,
@@ -444,7 +442,6 @@ class RequestServices implements RequestScopedServices {
private get guildStack(): GuildStackServices {
this.cachedGuildStack ??= createGuildStackServices({
apiContext: this.context,
packRepository: getPackRepository(),
channelRepository: this.channelRepository,
userRepository: getUserRepository(),
guildRepository: this.requestGuildRepository,
@@ -454,7 +451,6 @@ class RequestServices implements RequestScopedServices {
avatarService: getAvatarService(),
entityAssetService: getEntityAssetService(),
assetDeletionQueue: getAssetDeletionQueue(),
expressionAssetPurger: getExpressionAssetPurger(),
userCacheService: getUserCacheService(),
limitConfigService: getLimitConfigService(),
embedService: getEmbedService(),
@@ -472,10 +468,6 @@ class RequestServices implements RequestScopedServices {
return this.cachedGuildStack;
}
get packService() {
return this.guildStack.packService;
}
get channelService() {
return this.guildStack.channelService;
}
@@ -583,10 +575,6 @@ class RequestServices implements RequestScopedServices {
return getOAuth2TokenRepository();
}
get packRepository() {
return getPackRepository();
}
get rateLimitService() {
return getRateLimitService();
}
@@ -822,7 +810,6 @@ class RequestServices implements RequestScopedServices {
this.channelService,
this.guildService,
this.gatewayService,
getPackRepository(),
getUserCacheService(),
);
return this.cachedInviteRequestService;
@@ -43,7 +43,6 @@ import {createNatsGifProvider} from '../gif/NatsGifProvider';
import {GuildAuditLogService} from '../guild/GuildAuditLogService';
import {GuildDiscoveryRepository} from '../guild/repositories/GuildDiscoveryRepository';
import {GuildRepository} from '../guild/repositories/GuildRepository';
import {ExpressionAssetPurger} from '../guild/services/content/ExpressionAssetPurger';
import {GuildDiscoveryService} from '../guild/services/GuildDiscoveryService';
import {AssetDeletionQueue} from '../infrastructure/AssetDeletionQueue';
import {AvatarService} from '../infrastructure/AvatarService';
@@ -75,7 +74,6 @@ import {BotAuthService} from '../oauth/BotAuthService';
import {BotMfaMirrorService} from '../oauth/BotMfaMirrorService';
import {ApplicationRepository} from '../oauth/repositories/ApplicationRepository';
import {OAuth2TokenRepository} from '../oauth/repositories/OAuth2TokenRepository';
import {PackRepository} from '../pack/PackRepository';
import {ReadStateRepository} from '../read_state/ReadStateRepository';
import {ReadStateRequestService} from '../read_state/ReadStateRequestService';
import {ReadStateService} from '../read_state/ReadStateService';
@@ -119,7 +117,6 @@ export const getAdminArchiveRepository = singleton(() => new AdminArchiveReposit
export const getVoiceRepository = singleton(() => new VoiceRepository());
export const getApplicationRepository = singleton(() => new ApplicationRepository());
export const getOAuth2TokenRepository = singleton(() => new OAuth2TokenRepository());
export const getPackRepository = singleton(() => new PackRepository());
export const getGuildDiscoveryRepository = singleton(() => new GuildDiscoveryRepository());
export const getEmailChangeRepository = singleton(() => new EmailChangeRepository());
export const getPasswordChangeRepository = singleton(() => new PasswordChangeRepository());
@@ -398,7 +395,6 @@ export const getGifService = singleton(() => {
createNatsGifProvider(async () => (await instanceConfigRepository.getEffectiveGifConfig()).klipy_api_key),
);
});
export const getExpressionAssetPurger = singleton(() => new ExpressionAssetPurger(getAssetDeletionQueue()));
export const getGuildAuditLogService = singleton(
() => new GuildAuditLogService(getGuildRepository(), getSnowflakeService(), getWorkerService(), getGatewayService()),
);
@@ -54,8 +54,6 @@ const REQUEST_SERVICE_VARIABLES: ReadonlyArray<keyof HonoEnv['Variables']> = [
'oauth2RequestService',
'oauth2Service',
'oauth2TokenRepository',
'packRepository',
'packService',
'passwordChangeService',
'rateLimitService',
'readStateRequestService',
@@ -1,41 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '../BrandedTypes';
import type {ExpressionPackRow} from '../database/types/UserTypes';
type ExpressionPackType = 'emoji' | 'sticker';
export class ExpressionPack {
readonly id: GuildID;
readonly type: ExpressionPackType;
readonly creatorId: UserID;
readonly name: string;
readonly description: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly version: number;
constructor(row: ExpressionPackRow) {
this.id = row.pack_id;
this.type = row.pack_type as ExpressionPackType;
this.creatorId = row.creator_id;
this.name = row.name;
this.description = row.description ?? null;
this.createdAt = row.created_at;
this.updatedAt = row.updated_at;
this.version = row.version;
}
toRow(): ExpressionPackRow {
return {
pack_id: this.id,
pack_type: this.type,
creator_id: this.creatorId,
name: this.name,
description: this.description,
created_at: this.createdAt,
updated_at: this.updatedAt,
version: this.version,
};
}
}
File diff suppressed because it is too large. Load diff
@@ -1,15 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '../BrandedTypes';
import type {PackType} from './PackRepository';
export type PackExpressionAccessResolution = 'accessible' | 'not-accessible' | 'not-pack';
export interface PackExpressionAccessResolver {
resolve(packId: GuildID): Promise<PackExpressionAccessResolution>;
}
export interface PackExpressionAccessResolverParams {
userId: UserID | null;
type: PackType;
}
-20
View File
@@ -1,20 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PackSummaryResponse} from '@fluxer/schema/src/domains/pack/PackSchemas';
import type {ExpressionPack} from '../models/ExpressionPack';
export function mapPackToSummary(pack: ExpressionPack, installedAt?: Date | null): PackSummaryResponse {
const summary: PackSummaryResponse = {
id: pack.id.toString(),
name: pack.name,
description: pack.description,
type: pack.type,
creator_id: pack.creatorId.toString(),
created_at: pack.createdAt.toISOString(),
updated_at: pack.updatedAt.toISOString(),
};
if (installedAt) {
summary.installed_at = installedAt.toISOString();
}
return summary;
}
-116
View File
@@ -1,116 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '../BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import {nextVersion} from '../database/CassandraTypes';
import {buildPatchFromData, executeVersionedUpdate} from '../database/CassandraVersionedUpdate';
import type {ExpressionPackRow, PackInstallationRow} from '../database/types/UserTypes';
import {EXPRESSION_PACK_COLUMNS} from '../database/types/UserTypes';
import {ExpressionPack} from '../models/ExpressionPack';
import {ExpressionPacks, ExpressionPacksByCreator, PackInstallations} from '../Tables';
export type PackType = ExpressionPack['type'];
const FETCH_EXPRESSION_PACK_BY_ID_QUERY = ExpressionPacks.select({
where: ExpressionPacks.where.eq('pack_id'),
limit: 1,
});
const FETCH_EXPRESSION_PACKS_BY_CREATOR_QUERY = ExpressionPacksByCreator.select({
where: ExpressionPacksByCreator.where.eq('creator_id'),
});
const FETCH_PACK_INSTALLATIONS_BY_USER_QUERY = PackInstallations.select({
where: PackInstallations.where.eq('user_id'),
});
const FETCH_PACK_INSTALLATION_QUERY = PackInstallations.select({
where: [PackInstallations.where.eq('user_id'), PackInstallations.where.eq('pack_id')],
limit: 1,
});
export class PackRepository {
async getPack(packId: GuildID): Promise<ExpressionPack | null> {
const row = await fetchOne<ExpressionPackRow>(FETCH_EXPRESSION_PACK_BY_ID_QUERY.bind({pack_id: packId}));
return row ? new ExpressionPack(row) : null;
}
async listPacksByCreator(creatorId: UserID, packType?: PackType): Promise<Array<ExpressionPack>> {
const rows = await fetchMany<ExpressionPackRow>(
FETCH_EXPRESSION_PACKS_BY_CREATOR_QUERY.bind({creator_id: creatorId}),
);
return rows.filter((row) => (packType ? row.pack_type === packType : true)).map((row) => new ExpressionPack(row));
}
async countPacksByCreator(creatorId: UserID, packType: PackType): Promise<number> {
const rows = await fetchMany<ExpressionPackRow>(
FETCH_EXPRESSION_PACKS_BY_CREATOR_QUERY.bind({creator_id: creatorId}),
);
return rows.filter((row) => row.pack_type === packType).length;
}
async upsertPack(data: ExpressionPackRow): Promise<ExpressionPack> {
const packId = data.pack_id;
const previousPack = await this.getPack(packId);
const result = await executeVersionedUpdate<ExpressionPackRow, 'pack_id'>(
async () => {
const existing = await fetchOne<ExpressionPackRow>(FETCH_EXPRESSION_PACK_BY_ID_QUERY.bind({pack_id: packId}));
return existing ?? null;
},
(current) => ({
pk: {pack_id: packId},
patch: buildPatchFromData(data, current, EXPRESSION_PACK_COLUMNS, ['pack_id']),
}),
ExpressionPacks,
);
const batch = new BatchBuilder();
if (previousPack && previousPack.creatorId !== data.creator_id) {
batch.addPrepared(
ExpressionPacksByCreator.deleteByPk({
creator_id: previousPack.creatorId,
pack_id: packId,
}),
);
}
const finalPack = new ExpressionPack({...data, version: result.finalVersion ?? nextVersion(previousPack?.version)});
batch.addPrepared(ExpressionPacksByCreator.insert(finalPack.toRow()));
await batch.execute();
return finalPack;
}
async deletePack(packId: GuildID): Promise<void> {
const pack = await this.getPack(packId);
const batch = new BatchBuilder().addPrepared(ExpressionPacks.deleteByPk({pack_id: packId}));
if (pack) {
batch.addPrepared(
ExpressionPacksByCreator.deleteByPk({
creator_id: pack.creatorId,
pack_id: packId,
}),
);
}
await batch.execute();
}
async listInstallations(userId: UserID): Promise<Array<PackInstallationRow>> {
return await fetchMany<PackInstallationRow>(FETCH_PACK_INSTALLATIONS_BY_USER_QUERY.bind({user_id: userId}));
}
async addInstallation(data: PackInstallationRow): Promise<void> {
await upsertOne(PackInstallations.insert(data));
}
async removeInstallation(userId: UserID, packId: GuildID): Promise<void> {
await PackInstallations.deleteByPk({
user_id: userId,
pack_id: packId,
});
}
async hasInstallation(userId: UserID, packId: GuildID): Promise<boolean> {
const row = await fetchOne<PackInstallationRow>(
FETCH_PACK_INSTALLATION_QUERY.bind({
user_id: userId,
pack_id: packId,
}),
);
return row !== null;
}
}
-642
View File
@@ -1,642 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {
MAX_CREATED_PACKS_NON_PREMIUM,
MAX_INSTALLED_PACKS_NON_PREMIUM,
MAX_PACK_EXPRESSIONS,
} from '@fluxer/constants/src/LimitConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {FeatureAccessError} from '@fluxer/errors/src/domains/core/FeatureAccessError';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
import {InvalidPackTypeError} from '@fluxer/errors/src/domains/pack/InvalidPackTypeError';
import {MaxPackExpressionsError} from '@fluxer/errors/src/domains/pack/MaxPackExpressionsError';
import {MaxPackLimitError} from '@fluxer/errors/src/domains/pack/MaxPackLimitError';
import {PackAccessDeniedError} from '@fluxer/errors/src/domains/pack/PackAccessDeniedError';
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
import {FluxerError} from '@fluxer/errors/src/FluxerError';
import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n';
import type {
GuildEmojiResponse,
GuildEmojiWithUserResponse,
GuildStickerResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {PackDashboardResponse, PackDashboardSectionResponse} from '@fluxer/schema/src/domains/pack/PackSchemas';
import type {ApiContext} from '../ApiContext';
import type {EmojiID, GuildID, StickerID, UserID} from '../BrandedTypes';
import {createEmojiID, createGuildID, createStickerID} from '../BrandedTypes';
import {
mapGuildEmojisWithUsersToResponse,
mapGuildEmojiToResponse,
mapGuildStickersWithUsersToResponse,
mapGuildStickerToResponse,
} from '../guild/GuildModel';
import type {IGuildRepositoryAggregate} from '../guild/repositories/IGuildRepositoryAggregate';
import type {ExpressionAssetPurger} from '../guild/services/content/ExpressionAssetPurger';
import type {AvatarService} from '../infrastructure/AvatarService';
import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {LimitConfigService} from '../limits/LimitConfigService';
import {resolveLimitSafe} from '../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../limits/LimitMatchContextBuilder';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import {ExpressionPack} from '../models/ExpressionPack';
import type {User} from '../models/User';
import type {
PackExpressionAccessResolution,
PackExpressionAccessResolver,
PackExpressionAccessResolverParams,
} from './PackExpressionAccessResolver';
import {mapPackToSummary} from './PackModel';
import type {PackRepository, PackType} from './PackRepository';
export class PackService {
constructor(
private readonly apiContext: ApiContext,
private readonly packRepository: PackRepository,
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly avatarService: AvatarService,
private readonly assetPurger: ExpressionAssetPurger,
private readonly userCacheService: UserCacheService,
private readonly limitConfigService: LimitConfigService,
) {}
private getLocalizedPackExpressionLimitMessage(locale: string | null | undefined, count: number): string {
return getErrorMessageUnsafe(APIErrorCodes.MAX_PACK_EXPRESSIONS, locale, {count});
}
private getLocalizedBulkErrorMessage(error: unknown, locale: string | null | undefined): string {
if (error instanceof FluxerError) {
return getErrorMessageUnsafe(error.code, locale, error.messageVariables, error.message);
}
return getErrorMessageUnsafe(APIErrorCodes.GENERAL_ERROR, locale);
}
private async requireExpressionPackAccess(userId: UserID): Promise<void> {
const user = await this.apiContext.services.users.findUnique(userId);
if (!user || (user.flags & UserFlags.STAFF) === 0n) {
throw new FeatureTemporarilyDisabledError();
}
}
private async hasFeatureAccess(userId: UserID, limitKey: LimitKey): Promise<boolean> {
const user = await this.apiContext.services.users.findUnique(userId);
if (!user) {
return false;
}
const ctx = createLimitMatchContext({user});
const value = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, limitKey, 0);
return value > 0;
}
private async ensurePackOwner(userId: UserID, packId: GuildID): Promise<ExpressionPack> {
const pack = await this.packRepository.getPack(packId);
if (!pack) {
throw new UnknownPackError();
}
if (pack.creatorId !== userId) {
throw new PackAccessDeniedError();
}
return pack;
}
private async collectInstalledPacks(userId: UserID): Promise<
Array<{
pack: ExpressionPack;
installedAt: Date;
}>
> {
const installations = await this.packRepository.listInstallations(userId);
const results: Array<{
pack: ExpressionPack;
installedAt: Date;
}> = [];
for (const row of installations) {
const pack = await this.packRepository.getPack(row.pack_id);
if (!pack) {
await this.packRepository.removeInstallation(userId, row.pack_id);
continue;
}
results.push({pack, installedAt: row.installed_at});
}
return results;
}
private async requireFeature(userId: UserID, limitKey: LimitKey): Promise<void> {
if (!(await this.hasFeatureAccess(userId, limitKey))) {
throw new FeatureAccessError();
}
}
private async getInstalledPackIdsByType(userId: UserID, packType: PackType): Promise<Set<GuildID>> {
const installations = await this.packRepository.listInstallations(userId);
return new Set(installations.filter((row) => row.pack_type === packType).map((row) => row.pack_id));
}
private buildPackExpressionAccessResolution(
userId: UserID,
packType: PackType,
pack: ExpressionPack | null,
): PackExpressionAccessResolution {
if (!pack) {
return 'not-pack';
}
if (pack.type !== packType) {
return 'not-pack';
}
return pack.creatorId === userId ? 'accessible' : 'not-accessible';
}
async createPackExpressionAccessResolver(
params: PackExpressionAccessResolverParams,
): Promise<PackExpressionAccessResolver> {
const {userId, type} = params;
if (!userId) {
return {
resolve: async () => 'not-pack',
};
}
const installedPackIds = await this.getInstalledPackIdsByType(userId, type);
const resolutionCache = new Map<GuildID, PackExpressionAccessResolution>();
return {
resolve: async (packId: GuildID) => {
if (installedPackIds.has(packId)) {
resolutionCache.set(packId, 'accessible');
return 'accessible';
}
const cached = resolutionCache.get(packId);
if (cached) {
return cached;
}
const pack = await this.packRepository.getPack(packId);
const resolution = this.buildPackExpressionAccessResolution(userId, type, pack);
resolutionCache.set(packId, resolution);
return resolution;
},
};
}
async listUserPacks(userId: UserID): Promise<PackDashboardResponse> {
await this.requireExpressionPackAccess(userId);
const user = await this.apiContext.services.users.findUnique(userId);
const ctx = createLimitMatchContext({user});
resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'feature_global_expressions', 0);
const createdEmoji = await this.packRepository.listPacksByCreator(userId, 'emoji');
const createdSticker = await this.packRepository.listPacksByCreator(userId, 'sticker');
const installations = await this.collectInstalledPacks(userId);
const installedEmoji = installations
.filter((entry) => entry.pack.type === 'emoji')
.map((entry) => mapPackToSummary(entry.pack, entry.installedAt));
const installedSticker = installations
.filter((entry) => entry.pack.type === 'sticker')
.map((entry) => mapPackToSummary(entry.pack, entry.installedAt));
const fallbackCreatedLimit = MAX_CREATED_PACKS_NON_PREMIUM;
const fallbackInstalledLimit = MAX_INSTALLED_PACKS_NON_PREMIUM;
const createdLimit = this.resolveLimitForUser(user ?? null, 'max_created_packs', fallbackCreatedLimit);
const installedLimit = this.resolveLimitForUser(user ?? null, 'max_installed_packs', fallbackInstalledLimit);
const emojiSection: PackDashboardSectionResponse = {
installed_limit: installedLimit,
created_limit: createdLimit,
installed: installedEmoji,
created: createdEmoji.map((pack) => mapPackToSummary(pack)),
};
const stickerSection: PackDashboardSectionResponse = {
installed_limit: installedLimit,
created_limit: createdLimit,
installed: installedSticker,
created: createdSticker.map((pack) => mapPackToSummary(pack)),
};
return {
emoji: emojiSection,
sticker: stickerSection,
};
}
async createPack(params: {
user: User;
type: PackType;
name: string;
description?: string | null;
}): Promise<ExpressionPack> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const createdCount = await this.packRepository.countPacksByCreator(params.user.id, params.type);
const fallbackLimit = MAX_CREATED_PACKS_NON_PREMIUM;
const limit = this.resolveLimitForUser(params.user, 'max_created_packs', fallbackLimit);
if (createdCount >= limit) {
throw new MaxPackLimitError(params.type, limit, 'create');
}
const now = new Date();
const packId = createGuildID(await this.apiContext.services.snowflake.generate());
return await this.packRepository.upsertPack({
pack_id: packId,
pack_type: params.type,
creator_id: params.user.id,
name: params.name,
description: params.description ?? null,
created_at: now,
updated_at: now,
version: 1,
});
}
async updatePack(params: {
userId: UserID;
packId: GuildID;
name?: string;
description?: string | null;
}): Promise<ExpressionPack> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
const now = new Date();
const updatedPack = new ExpressionPack({
...pack.toRow(),
name: params.name ?? pack.name,
description: params.description === undefined ? pack.description : params.description,
updated_at: now,
});
return await this.packRepository.upsertPack(updatedPack.toRow());
}
async deletePack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
await this.ensurePackOwner(userId, packId);
await this.packRepository.deletePack(packId);
}
async installPack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
const pack = await this.packRepository.getPack(packId);
if (!pack) {
throw new UnknownPackError();
}
const alreadyInstalled = await this.packRepository.hasInstallation(userId, packId);
if (alreadyInstalled) {
return;
}
await this.requireFeature(userId, 'feature_global_expressions');
const user = await this.apiContext.services.users.findUnique(userId);
const fallbackLimit = MAX_INSTALLED_PACKS_NON_PREMIUM;
const limit = this.resolveLimitForUser(user ?? null, 'max_installed_packs', fallbackLimit);
const installations = await this.collectInstalledPacks(userId);
const typeCount = installations.filter((entry) => entry.pack.type === pack.type).length;
if (typeCount >= limit) {
throw new MaxPackLimitError(pack.type, limit, 'install');
}
await this.packRepository.addInstallation({
user_id: userId,
pack_id: packId,
pack_type: pack.type,
installed_at: new Date(),
});
}
async uninstallPack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
await this.packRepository.removeInstallation(userId, packId);
}
async getInstalledPackIds(userId: UserID): Promise<Set<GuildID>> {
await this.requireExpressionPackAccess(userId);
const installations = await this.collectInstalledPacks(userId);
return new Set(installations.map((entry) => entry.pack.id));
}
async getPackEmojis(params: {
userId: UserID;
packId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildEmojiWithUserResponse>> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojis = await this.guildRepository.listEmojis(pack.id);
return await mapGuildEmojisWithUsersToResponse(emojis, this.userCacheService, params.requestCache);
}
async getPackStickers(params: {
userId: UserID;
packId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildStickerWithUserResponse>> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickers = await this.guildRepository.listStickers(pack.id);
return await mapGuildStickersWithUsersToResponse(stickers, this.userCacheService, params.requestCache);
}
async createPackEmoji(params: {
user: User;
packId: GuildID;
name: string;
image: string;
}): Promise<GuildEmojiResponse> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojiCount = await this.guildRepository.countEmojis(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
if (emojiCount >= expressionLimit) {
throw new MaxPackExpressionsError(expressionLimit);
}
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
errorPath: 'image',
base64Image: params.image,
});
const emojiId = createEmojiID(await this.apiContext.services.snowflake.generate());
await this.avatarService.uploadEmoji({
prefix: 'emojis',
emojiId,
imageBuffer,
contentType,
});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: pack.id,
emoji_id: emojiId,
name: params.name,
creator_id: params.user.id,
animated,
nsfw: null,
version: 1,
});
return mapGuildEmojiToResponse(emoji);
}
async bulkCreatePackEmojis(params: {
user: User;
packId: GuildID;
emojis: Array<{
name: string;
image: string;
}>;
}): Promise<{
success: Array<GuildEmojiResponse>;
failed: Array<{
name: string;
error: string;
}>;
}> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
let emojiCount = await this.guildRepository.countEmojis(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
const success: Array<GuildEmojiResponse> = [];
const failed: Array<{
name: string;
error: string;
}> = [];
for (const emojiData of params.emojis) {
if (emojiCount >= expressionLimit) {
failed.push({
name: emojiData.name,
error: this.getLocalizedPackExpressionLimitMessage(params.user.locale, expressionLimit),
});
continue;
}
try {
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
errorPath: `emojis[${success.length + failed.length}].image`,
base64Image: emojiData.image,
});
const emojiId = createEmojiID(await this.apiContext.services.snowflake.generate());
await this.avatarService.uploadEmoji({
prefix: 'emojis',
emojiId,
imageBuffer,
contentType,
});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: pack.id,
emoji_id: emojiId,
name: emojiData.name,
creator_id: params.user.id,
animated,
nsfw: null,
version: 1,
});
success.push(mapGuildEmojiToResponse(emoji));
emojiCount += 1;
} catch (error) {
failed.push({
name: emojiData.name,
error: this.getLocalizedBulkErrorMessage(error, params.user.locale),
});
}
}
return {success, failed};
}
async updatePackEmoji(params: {
userId: UserID;
packId: GuildID;
emojiId: EmojiID;
name: string;
}): Promise<GuildEmojiResponse> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emoji = await this.guildRepository.getEmoji(params.emojiId, pack.id);
if (!emoji) {
throw new UnknownGuildEmojiError();
}
const updatedEmoji = await this.guildRepository.upsertEmoji({
...emoji.toRow(),
name: params.name,
});
return mapGuildEmojiToResponse(updatedEmoji);
}
async deletePackEmoji(params: {userId: UserID; packId: GuildID; emojiId: EmojiID; purge?: boolean}): Promise<void> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emoji = await this.guildRepository.getEmoji(params.emojiId, pack.id);
if (!emoji) {
throw new UnknownGuildEmojiError();
}
await this.guildRepository.deleteEmoji(pack.id, params.emojiId);
if (params.purge) {
await this.assetPurger.purgeEmoji(emoji.id.toString());
}
}
async createPackSticker(params: {
user: User;
packId: GuildID;
name: string;
description?: string | null;
tags: Array<string>;
image: string;
}): Promise<GuildStickerResponse> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickerCount = await this.guildRepository.countStickers(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
if (stickerCount >= expressionLimit) {
throw new MaxPackExpressionsError(expressionLimit);
}
const {animated, imageBuffer} = await this.avatarService.processSticker({
errorPath: 'image',
base64Image: params.image,
});
const stickerId = createStickerID(await this.apiContext.services.snowflake.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: pack.id,
sticker_id: stickerId,
name: params.name,
description: params.description ?? null,
tags: params.tags,
animated,
nsfw: null,
creator_id: params.user.id,
version: 1,
});
const response = mapGuildStickerToResponse(sticker);
return response;
}
async bulkCreatePackStickers(params: {
user: User;
packId: GuildID;
stickers: Array<{
name: string;
description?: string | null;
tags: Array<string>;
image: string;
}>;
}): Promise<{
success: Array<GuildStickerResponse>;
failed: Array<{
name: string;
error: string;
}>;
}> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
let stickerCount = await this.guildRepository.countStickers(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
const success: Array<GuildStickerResponse> = [];
const failed: Array<{
name: string;
error: string;
}> = [];
for (const stickerData of params.stickers) {
if (stickerCount >= expressionLimit) {
failed.push({
name: stickerData.name,
error: this.getLocalizedPackExpressionLimitMessage(params.user.locale, expressionLimit),
});
continue;
}
try {
const {animated, imageBuffer} = await this.avatarService.processSticker({
errorPath: `stickers[${success.length + failed.length}].image`,
base64Image: stickerData.image,
});
const stickerId = createStickerID(await this.apiContext.services.snowflake.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: pack.id,
sticker_id: stickerId,
name: stickerData.name,
description: stickerData.description ?? null,
tags: stickerData.tags,
animated,
nsfw: null,
creator_id: params.user.id,
version: 1,
});
success.push(mapGuildStickerToResponse(sticker));
stickerCount += 1;
} catch (error) {
failed.push({
name: stickerData.name,
error: this.getLocalizedBulkErrorMessage(error, params.user.locale),
});
}
}
for (const _ of success) {
}
return {success, failed};
}
async updatePackSticker(params: {
userId: UserID;
packId: GuildID;
stickerId: StickerID;
name: string;
description?: string | null;
tags: Array<string>;
}): Promise<GuildStickerResponse> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const sticker = await this.guildRepository.getSticker(params.stickerId, pack.id);
if (!sticker) {
throw new UnknownGuildStickerError();
}
const updatedSticker = await this.guildRepository.upsertSticker({
...sticker.toRow(),
name: params.name,
description: params.description ?? null,
tags: params.tags,
});
return mapGuildStickerToResponse(updatedSticker);
}
async deletePackSticker(params: {
userId: UserID;
packId: GuildID;
stickerId: StickerID;
purge?: boolean;
}): Promise<void> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const sticker = await this.guildRepository.getSticker(params.stickerId, pack.id);
if (!sticker) {
throw new UnknownGuildStickerError();
}
await this.guildRepository.deleteSticker(pack.id, params.stickerId);
if (params.purge) {
await this.assetPurger.purgeSticker(sticker.id.toString());
}
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
}
@@ -1,161 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PackIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
PackCreateRequest,
PackDashboardResponse,
PackSummaryResponse,
PackTypeParam,
PackUpdateRequest,
} from '@fluxer/schema/src/domains/pack/PackSchemas';
import {createGuildID} from '../../BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
import {mapPackToSummary} from '../PackModel';
export function PackController(app: HonoApp) {
app.get(
'/packs',
RateLimitMiddleware(RateLimitConfigs.PACKS_LIST),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_user_packs',
summary: 'List user packs',
description:
'Returns a dashboard view containing all emoji and sticker packs created by or owned by the authenticated user. This includes pack metadata such as name, description, type, and cover image.',
responseSchema: PackDashboardResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const response = await ctx.get('packService').listUserPacks(ctx.get('user').id);
return ctx.json(response);
},
);
app.post(
'/packs/:pack_type',
RateLimitMiddleware(RateLimitConfigs.PACKS_CREATE),
LoginRequired,
DefaultUserOnly,
Validator('param', PackTypeParam),
Validator('json', PackCreateRequest),
OpenAPI({
operationId: 'create_pack',
summary: 'Create pack',
description:
'Creates a new emoji or sticker pack owned by the authenticated user. The pack type is specified in the path parameter and can be either "emoji" or "sticker". Returns the newly created pack with its metadata.',
responseSchema: PackSummaryResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const user = ctx.get('user');
const data = ctx.req.valid('json');
const pack = await ctx.get('packService').createPack({
user,
type: ctx.req.valid('param').pack_type as 'emoji' | 'sticker',
name: data.name,
description: data.description ?? null,
});
return ctx.json(mapPackToSummary(pack));
},
);
app.patch(
'/packs/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', PackIdParam),
Validator('json', PackUpdateRequest),
OpenAPI({
operationId: 'update_pack',
summary: 'Update pack',
description:
'Updates the metadata for an existing pack owned by the authenticated user. Allowed modifications include name, description, and cover image. Returns the updated pack with all current metadata.',
responseSchema: PackSummaryResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const data = ctx.req.valid('json');
const updated = await ctx.get('packService').updatePack({
userId: ctx.get('user').id,
packId: createGuildID(ctx.req.valid('param').pack_id),
name: data.name,
description: data.description,
});
return ctx.json(mapPackToSummary(updated));
},
);
app.delete(
'/packs/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', PackIdParam),
OpenAPI({
operationId: 'delete_pack',
summary: 'Delete pack',
description:
'Permanently deletes a pack owned by the authenticated user along with all emojis or stickers contained within it. This action cannot be undone and will remove all associated assets.',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
await ctx.get('packService').deletePack(ctx.get('user').id, createGuildID(ctx.req.valid('param').pack_id));
return ctx.body(null, 204);
},
);
app.post(
'/packs/:pack_id/install',
RateLimitMiddleware(RateLimitConfigs.PACKS_INSTALL),
LoginRequired,
DefaultUserOnly,
Validator('param', PackIdParam),
OpenAPI({
operationId: 'install_pack',
summary: 'Install pack',
description:
"Installs a pack to the authenticated user's collection, making its emojis or stickers available for use. The pack must be publicly accessible or owned by the user.",
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
await ctx.get('packService').installPack(ctx.get('user').id, createGuildID(ctx.req.valid('param').pack_id));
return ctx.body(null, 204);
},
);
app.delete(
'/packs/:pack_id/install',
RateLimitMiddleware(RateLimitConfigs.PACKS_INSTALL),
LoginRequired,
DefaultUserOnly,
Validator('param', PackIdParam),
OpenAPI({
operationId: 'uninstall_pack',
summary: 'Uninstall pack',
description:
"Uninstalls a pack from the authenticated user's collection, removing access to its emojis or stickers. This does not delete the pack itself, only removes it from the user's installed list.",
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
await ctx.get('packService').uninstallPack(ctx.get('user').id, createGuildID(ctx.req.valid('param').pack_id));
return ctx.body(null, 204);
},
);
}
@@ -1,148 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PackIdEmojiIdParam, PackIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {PurgeQuery} from '@fluxer/schema/src/domains/common/CommonQuerySchemas';
import {
GuildEmojiBulkCreateResponse,
GuildEmojiResponse,
GuildEmojiWithUserListResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {
GuildEmojiBulkCreateRequest,
GuildEmojiCreateRequest,
GuildEmojiUpdateRequest,
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {createEmojiID, createGuildID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function PackEmojiController(app: HonoApp) {
app.post(
'/packs/emojis/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_CREATE),
LoginRequired,
Validator('param', PackIdParam),
Validator('json', GuildEmojiCreateRequest),
OpenAPI({
operationId: 'create_pack_emoji',
summary: 'Create pack emoji',
description:
'Creates a new emoji within the specified pack. Requires the pack ID in the path and emoji metadata (name and image data) in the request body. Returns the newly created emoji with its generated ID.',
responseSchema: GuildEmojiResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {name, image} = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(await ctx.get('packService').createPackEmoji({user, packId, name, image}));
},
);
app.post(
'/packs/emojis/:pack_id/bulk',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_BULK_CREATE),
LoginRequired,
Validator('param', PackIdParam),
Validator('json', GuildEmojiBulkCreateRequest),
OpenAPI({
operationId: 'bulk_create_pack_emojis',
summary: 'Bulk create pack emojis',
description:
'Creates multiple emojis within the specified pack in a single bulk operation. Accepts an array of emoji definitions, each containing name and image data. Returns a response containing all successfully created emojis.',
responseSchema: GuildEmojiBulkCreateResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {emojis} = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(await ctx.get('packService').bulkCreatePackEmojis({user, packId, emojis}));
},
);
app.get(
'/packs/emojis/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJIS_LIST),
LoginRequired,
Validator('param', PackIdParam),
OpenAPI({
operationId: 'list_pack_emojis',
summary: 'List pack emojis',
description:
'Returns a list of all emojis contained within the specified pack, including emoji metadata and creator information. Results include emoji ID, name, image URL, and the user who created each emoji.',
responseSchema: GuildEmojiWithUserListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const userId = ctx.get('user').id;
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('packService').getPackEmojis({userId, packId, requestCache}));
},
);
app.patch(
'/packs/emojis/:pack_id/:emoji_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_UPDATE),
LoginRequired,
Validator('param', PackIdEmojiIdParam),
Validator('json', GuildEmojiUpdateRequest),
OpenAPI({
operationId: 'update_pack_emoji',
summary: 'Update pack emoji',
description:
'Updates the name of an existing emoji within the specified pack. Requires both pack ID and emoji ID in the path parameters. Returns the updated emoji with its new name and all existing metadata.',
responseSchema: GuildEmojiResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const {pack_id, emoji_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const emojiId = createEmojiID(emoji_id);
const {name} = ctx.req.valid('json');
return ctx.json(
await ctx.get('packService').updatePackEmoji({userId: ctx.get('user').id, packId, emojiId, name}),
);
},
);
app.delete(
'/packs/emojis/:pack_id/:emoji_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_DELETE),
LoginRequired,
Validator('param', PackIdEmojiIdParam),
Validator('query', PurgeQuery),
OpenAPI({
operationId: 'delete_pack_emoji',
summary: 'Delete pack emoji',
description:
'Permanently deletes an emoji from the specified pack. Requires both pack ID and emoji ID in the path parameters. Accepts an optional "purge" query parameter to control whether associated assets are immediately deleted.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const {pack_id, emoji_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const emojiId = createEmojiID(emoji_id);
const purge = ctx.req.valid('query').purge ?? false;
await ctx.get('packService').deletePackEmoji({
userId: ctx.get('user').id,
packId,
emojiId,
purge,
});
return ctx.body(null, 204);
},
);
}
@@ -1,152 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PackIdParam, PackIdStickerIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {PurgeQuery} from '@fluxer/schema/src/domains/common/CommonQuerySchemas';
import {
GuildStickerBulkCreateResponse,
GuildStickerResponse,
GuildStickerWithUserListResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {
GuildStickerBulkCreateRequest,
GuildStickerCreateRequest,
GuildStickerUpdateRequest,
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {createGuildID, createStickerID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function PackStickerController(app: HonoApp) {
app.post(
'/packs/stickers/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_CREATE),
LoginRequired,
Validator('param', PackIdParam),
Validator('json', GuildStickerCreateRequest),
OpenAPI({
operationId: 'create_pack_sticker',
summary: 'Create pack sticker',
description:
'Creates a new sticker within the specified pack. Requires the pack ID in the path and sticker metadata (name, description, tags, and image data) in the request body. Returns the newly created sticker with its generated ID.',
responseSchema: GuildStickerResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {name, description, tags, image} = ctx.req.valid('json');
const user = ctx.get('user');
const sticker = await ctx.get('packService').createPackSticker({user, packId, name, description, tags, image});
return ctx.json(sticker);
},
);
app.post(
'/packs/stickers/:pack_id/bulk',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_BULK_CREATE),
LoginRequired,
Validator('param', PackIdParam),
Validator('json', GuildStickerBulkCreateRequest),
OpenAPI({
operationId: 'bulk_create_pack_stickers',
summary: 'Bulk create pack stickers',
description:
'Creates multiple stickers within the specified pack in a single bulk operation. Accepts an array of sticker definitions, each containing name, description, tags, and image data. Returns a response containing all successfully created stickers.',
responseSchema: GuildStickerBulkCreateResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {stickers} = ctx.req.valid('json');
const user = ctx.get('user');
const result = await ctx.get('packService').bulkCreatePackStickers({user, packId, stickers});
return ctx.json(result);
},
);
app.get(
'/packs/stickers/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKERS_LIST),
LoginRequired,
Validator('param', PackIdParam),
OpenAPI({
operationId: 'list_pack_stickers',
summary: 'List pack stickers',
description:
'Returns a list of all stickers contained within the specified pack, including sticker metadata and creator information. Results include sticker ID, name, description, tags, image URL, and the user who created each sticker.',
responseSchema: GuildStickerWithUserListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const userId = ctx.get('user').id;
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('packService').getPackStickers({userId, packId, requestCache}));
},
);
app.patch(
'/packs/stickers/:pack_id/:sticker_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_UPDATE),
LoginRequired,
Validator('param', PackIdStickerIdParam),
Validator('json', GuildStickerUpdateRequest),
OpenAPI({
operationId: 'update_pack_sticker',
summary: 'Update pack sticker',
description:
'Updates the name, description, or tags of an existing sticker within the specified pack. Requires both pack ID and sticker ID in the path parameters. Returns the updated sticker with its new metadata and all existing fields.',
responseSchema: GuildStickerResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const {pack_id, sticker_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const stickerId = createStickerID(sticker_id);
const {name, description, tags} = ctx.req.valid('json');
return ctx.json(
await ctx
.get('packService')
.updatePackSticker({userId: ctx.get('user').id, packId, stickerId, name, description, tags}),
);
},
);
app.delete(
'/packs/stickers/:pack_id/:sticker_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_DELETE),
LoginRequired,
Validator('param', PackIdStickerIdParam),
Validator('query', PurgeQuery),
OpenAPI({
operationId: 'delete_pack_sticker',
summary: 'Delete pack sticker',
description:
'Permanently deletes a sticker from the specified pack. Requires both pack ID and sticker ID in the path parameters. Accepts an optional "purge" query parameter to control whether associated assets are immediately deleted.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Packs'],
}),
async (ctx) => {
const {pack_id, sticker_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const stickerId = createStickerID(sticker_id);
const {purge = false} = ctx.req.valid('query');
await ctx.get('packService').deletePackSticker({
userId: ctx.get('user').id,
packId,
stickerId,
purge,
});
return ctx.body(null, 204);
},
);
}
@@ -1,12 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {HonoApp} from '../../types/HonoEnv';
import {PackController} from './PackController';
import {PackEmojiController} from './PackEmojiController';
import {PackStickerController} from './PackStickerController';
export function registerPackControllers(app: HonoApp) {
PackController(app);
PackEmojiController(app);
PackStickerController(app);
}
@@ -1,199 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {
createPack,
createPackEmoji,
createPackSticker,
deletePack,
deletePackEmoji,
deletePackSticker,
getPackEmojis,
getPackStickers,
installPack,
listPacks,
setupPackTestAccount,
uninstallPack,
updatePack,
} from './PackTestUtils';
describe('Pack Invite Flow', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('user can create and list emoji pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {
name: 'My Emoji Pack',
description: 'A collection of emojis',
});
const dashboard = await listPacks(harness, account.token);
const createdPack = dashboard.emoji.created.find((p) => p.id === pack.id);
expect(createdPack).toBeTruthy();
expect(createdPack?.name).toBe('My Emoji Pack');
});
test('user can update pack name and description', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'Original Name'});
const updated = await updatePack(harness, account.token, pack.id, {
name: 'Updated Name',
description: 'New description',
});
expect(updated.name).toBe('Updated Name');
});
test('user can delete own pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'To Delete'});
await deletePack(harness, account.token, pack.id);
const dashboard = await listPacks(harness, account.token);
const deletedPack = dashboard.emoji.created.find((p) => p.id === pack.id);
expect(deletedPack).toBeUndefined();
});
test('user cannot update another users pack', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Owners Pack'});
const {account: other} = await setupPackTestAccount(harness);
await createBuilder(harness, other.token)
.patch(`/packs/${pack.id}`)
.body({name: 'Stolen Name'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('user cannot delete another users pack', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Protected Pack'});
const {account: other} = await setupPackTestAccount(harness);
await createBuilder(harness, other.token).delete(`/packs/${pack.id}`).expect(HTTP_STATUS.FORBIDDEN).execute();
});
test('user can install pack from another user', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Shareable Pack'});
const {account: installer} = await setupPackTestAccount(harness);
await installPack(harness, installer.token, pack.id);
const dashboard = await listPacks(harness, installer.token);
expect(dashboard.emoji.installed.some((p) => p.id === pack.id)).toBe(true);
});
test('uninstall pack returns success', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Uninstall Test Pack'});
const {account: installer} = await setupPackTestAccount(harness);
await installPack(harness, installer.token, pack.id);
await uninstallPack(harness, installer.token, pack.id);
});
test('installing pack is idempotent', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Idempotent Pack'});
const {account: installer} = await setupPackTestAccount(harness);
await installPack(harness, installer.token, pack.id);
await installPack(harness, installer.token, pack.id);
const dashboard = await listPacks(harness, installer.token);
const installedCount = dashboard.emoji.installed.filter((p) => p.id === pack.id).length;
expect(installedCount).toBe(1);
});
test('cannot install non-existent pack', async () => {
const {account} = await setupPackTestAccount(harness);
await createBuilder(harness, account.token)
.post('/packs/999999999999999999/install')
.body({})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('can add emoji to pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'Emoji Pack'});
const emoji = await createPackEmoji(harness, account.token, pack.id, 'test_emoji');
expect(emoji.id).toBeTruthy();
expect(emoji.name).toBe('test_emoji');
const emojis = await getPackEmojis(harness, account.token, pack.id);
expect(emojis.some((e) => e.id === emoji.id)).toBe(true);
});
test('can add sticker to pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'sticker', {name: 'Sticker Pack'});
const sticker = await createPackSticker(harness, account.token, pack.id, 'test_sticker', ['happy', 'fun']);
expect(sticker.id).toBeTruthy();
expect(sticker.name).toBe('test_sticker');
const stickers = await getPackStickers(harness, account.token, pack.id);
expect(stickers.some((s) => s.id === sticker.id)).toBe(true);
});
test('cannot add emoji to sticker pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'sticker', {name: 'Wrong Type Pack'});
await createBuilder(harness, account.token)
.post(`/packs/emojis/${pack.id}`)
.body({name: 'wrong_emoji', image: 'data:image/png;base64,iVBORw0KGgo='})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('cannot add sticker to emoji pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'Wrong Type Pack'});
await createBuilder(harness, account.token)
.post(`/packs/stickers/${pack.id}`)
.body({name: 'wrong_sticker', tags: ['test'], image: 'data:image/png;base64,iVBORw0KGgo='})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('can delete emoji from pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'Delete Emoji Pack'});
const emoji = await createPackEmoji(harness, account.token, pack.id, 'to_delete');
await deletePackEmoji(harness, account.token, pack.id, emoji.id);
const emojis = await getPackEmojis(harness, account.token, pack.id);
expect(emojis.some((e) => e.id === emoji.id)).toBe(false);
});
test('can delete sticker from pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'sticker', {name: 'Delete Sticker Pack'});
const sticker = await createPackSticker(harness, account.token, pack.id, 'to_delete', ['bye']);
await deletePackSticker(harness, account.token, pack.id, sticker.id);
const stickers = await getPackStickers(harness, account.token, pack.id);
expect(stickers.some((s) => s.id === sticker.id)).toBe(false);
});
test('cannot add emoji to another users pack', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Protected Emoji Pack'});
const {account: other} = await setupPackTestAccount(harness);
await createBuilder(harness, other.token)
.post(`/packs/emojis/${pack.id}`)
.body({name: 'unauthorized', image: 'data:image/png;base64,iVBORw0KGgo='})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('pack creator shows in created packs list', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'My Created Pack'});
const dashboard = await listPacks(harness, account.token);
expect(dashboard.emoji.created.some((p) => p.id === pack.id)).toBe(true);
expect(dashboard.emoji.installed.some((p) => p.id === pack.id)).toBe(false);
});
test('multiple packs can be created and managed', async () => {
const {account} = await setupPackTestAccount(harness);
const emojiPack1 = await createPack(harness, account.token, 'emoji', {name: 'Emoji Pack 1'});
const emojiPack2 = await createPack(harness, account.token, 'emoji', {name: 'Emoji Pack 2'});
const stickerPack = await createPack(harness, account.token, 'sticker', {name: 'Sticker Pack 1'});
const dashboard = await listPacks(harness, account.token);
expect(dashboard.emoji.created.length).toBe(2);
expect(dashboard.sticker.created.length).toBe(1);
expect(dashboard.emoji.created.some((p) => p.id === emojiPack1.id)).toBe(true);
expect(dashboard.emoji.created.some((p) => p.id === emojiPack2.id)).toBe(true);
expect(dashboard.sticker.created.some((p) => p.id === stickerPack.id)).toBe(true);
});
test('installed pack shows installed_at timestamp', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Timestamped Pack'});
const {account: installer} = await setupPackTestAccount(harness);
await installPack(harness, installer.token, pack.id);
const dashboard = await listPacks(harness, installer.token);
const installedPack = dashboard.emoji.installed.find((p) => p.id === pack.id);
expect(installedPack).toBeTruthy();
expect(installedPack?.installed_at).toBeTruthy();
});
});
@@ -1,113 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {
createPack,
grantPremium,
grantStaffAccess,
installPack,
listPacks,
revokePremium,
setupNonPremiumPackTestAccount,
setupPackTestAccount,
} from './PackTestUtils';
describe('Pack Premium Requirements', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('user without staff flag cannot list packs', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token).get('/packs').expect(HTTP_STATUS.FORBIDDEN).execute();
});
test('user with staff flag but no premium cannot create pack', async () => {
const {account} = await setupNonPremiumPackTestAccount(harness);
await createBuilder(harness, account.token)
.post('/packs/emoji')
.body({name: 'Test Pack'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('premium user with staff flag can create emoji pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'My Emoji Pack'});
expect(pack.id).toBeTruthy();
expect(pack.name).toBe('My Emoji Pack');
expect(pack.type).toBe('emoji');
});
test('premium user with staff flag can create sticker pack', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'sticker', {name: 'My Sticker Pack'});
expect(pack.id).toBeTruthy();
expect(pack.name).toBe('My Sticker Pack');
expect(pack.type).toBe('sticker');
});
test('non-premium user cannot create pack emoji', async () => {
const {account} = await setupPackTestAccount(harness);
const pack = await createPack(harness, account.token, 'emoji', {name: 'Emoji Pack'});
await revokePremium(harness, account.userId);
await createBuilder(harness, account.token)
.post(`/packs/emojis/${pack.id}`)
.body({name: 'emoji1', image: 'data:image/png;base64,iVBORw0KGgo='})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('non-premium user cannot install pack', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Shared Pack'});
const {account: installer} = await setupNonPremiumPackTestAccount(harness);
await createBuilder(harness, installer.token)
.post(`/packs/${pack.id}/install`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('premium user can install pack', async () => {
const {account: owner} = await setupPackTestAccount(harness);
const pack = await createPack(harness, owner.token, 'emoji', {name: 'Installable Pack'});
const {account: installer} = await setupPackTestAccount(harness);
await installPack(harness, installer.token, pack.id);
const dashboard = await listPacks(harness, installer.token);
const installed = dashboard.emoji.installed.find((p) => p.id === pack.id);
expect(installed).toBeTruthy();
});
test('user without staff flag cannot access pack endpoints', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token).get('/packs').expect(HTTP_STATUS.FORBIDDEN).execute();
await createBuilder(harness, account.token)
.post('/packs/emoji')
.body({name: 'Unauthorized Pack'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('user gains staff flag and can access expression packs', async () => {
const owner = await createTestAccount(harness);
await grantStaffAccess(harness, owner.userId);
await grantPremium(harness, owner.userId);
const dashboard = await listPacks(harness, owner.token);
expect(dashboard.emoji).toBeTruthy();
expect(dashboard.sticker).toBeTruthy();
});
test('pack limits show correct values for premium user', async () => {
const {account} = await setupPackTestAccount(harness);
const dashboard = await listPacks(harness, account.token);
expect(dashboard.emoji.created_limit).toBeGreaterThan(0);
expect(dashboard.emoji.installed_limit).toBeGreaterThan(0);
expect(dashboard.sticker.created_limit).toBeGreaterThan(0);
expect(dashboard.sticker.installed_limit).toBeGreaterThan(0);
});
test('pack limits show zero for non-premium user with staff flag', async () => {
const {account} = await setupNonPremiumPackTestAccount(harness);
const dashboard = await listPacks(harness, account.token);
expect(dashboard.emoji.created_limit).toBe(0);
expect(dashboard.emoji.installed_limit).toBe(0);
});
});
@@ -1,206 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {join} from 'node:path';
import type {
GuildEmojiResponse,
GuildEmojiWithUserResponse,
GuildStickerResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {PackDashboardResponse, PackSummaryResponse} from '@fluxer/schema/src/domains/pack/PackSchemas';
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
interface PackCreateRequest {
name: string;
description?: string | null;
}
interface PackUpdateRequest {
name?: string;
description?: string | null;
}
type PackType = 'emoji' | 'sticker';
function loadPackFixture(filename: string): Buffer {
const fixturesPath = join(import.meta.dirname, '..', '..', 'test', 'fixtures', filename);
return readFileSync(fixturesPath);
}
async function createGuild(harness: ApiTestHarness, token: string, name: string): Promise<GuildResponse> {
return createBuilder<GuildResponse>(harness, token).post('/guilds').body({name}).expect(HTTP_STATUS.OK).execute();
}
export async function grantStaffAccess(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilderWithoutAuth(harness).patch(`/test/users/${userId}/flags`).body({flags: 1}).execute();
}
export async function grantPremium(harness: ApiTestHarness, userId: string): Promise<void> {
const premiumUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/premium`)
.body({
premium_type: 2,
premium_until: premiumUntil,
})
.execute();
}
export async function revokePremium(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/premium`)
.body({
premium_type: null,
premium_until: null,
})
.execute();
}
export async function setupPackTestAccount(harness: ApiTestHarness): Promise<{
account: TestAccount;
guild: GuildResponse;
}> {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Pack Test Guild');
await grantStaffAccess(harness, account.userId);
await grantPremium(harness, account.userId);
return {account, guild};
}
export async function setupNonPremiumPackTestAccount(harness: ApiTestHarness): Promise<{
account: TestAccount;
guild: GuildResponse;
}> {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Pack Test Guild');
await grantStaffAccess(harness, account.userId);
return {account, guild};
}
export async function listPacks(harness: ApiTestHarness, token: string): Promise<PackDashboardResponse> {
return createBuilder<PackDashboardResponse>(harness, token).get('/packs').expect(HTTP_STATUS.OK).execute();
}
export async function createPack(
harness: ApiTestHarness,
token: string,
packType: PackType,
data: PackCreateRequest,
): Promise<PackSummaryResponse> {
return createBuilder<PackSummaryResponse>(harness, token)
.post(`/packs/${packType}`)
.body(data)
.expect(HTTP_STATUS.OK)
.execute();
}
export async function updatePack(
harness: ApiTestHarness,
token: string,
packId: string,
data: PackUpdateRequest,
): Promise<PackSummaryResponse> {
return createBuilder<PackSummaryResponse>(harness, token)
.patch(`/packs/${packId}`)
.body(data)
.expect(HTTP_STATUS.OK)
.execute();
}
export async function deletePack(harness: ApiTestHarness, token: string, packId: string): Promise<void> {
await createBuilder<void>(harness, token).delete(`/packs/${packId}`).expect(HTTP_STATUS.NO_CONTENT).execute();
}
export async function installPack(harness: ApiTestHarness, token: string, packId: string): Promise<void> {
await createBuilder<void>(harness, token)
.post(`/packs/${packId}/install`)
.body({})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
export async function uninstallPack(harness: ApiTestHarness, token: string, packId: string): Promise<void> {
await createBuilder<void>(harness, token).delete(`/packs/${packId}/install`).expect(HTTP_STATUS.NO_CONTENT).execute();
}
export async function getPackEmojis(
harness: ApiTestHarness,
token: string,
packId: string,
): Promise<Array<GuildEmojiWithUserResponse>> {
return createBuilder<Array<GuildEmojiWithUserResponse>>(harness, token)
.get(`/packs/emojis/${packId}`)
.expect(HTTP_STATUS.OK)
.execute();
}
export async function getPackStickers(
harness: ApiTestHarness,
token: string,
packId: string,
): Promise<Array<GuildStickerWithUserResponse>> {
return createBuilder<Array<GuildStickerWithUserResponse>>(harness, token)
.get(`/packs/stickers/${packId}`)
.expect(HTTP_STATUS.OK)
.execute();
}
export async function createPackEmoji(
harness: ApiTestHarness,
token: string,
packId: string,
name: string,
imageBase64?: string,
): Promise<GuildEmojiResponse> {
const image = imageBase64 ?? loadPackFixture('yeah.png').toString('base64');
return createBuilder<GuildEmojiResponse>(harness, token)
.post(`/packs/emojis/${packId}`)
.body({name, image: `data:image/png;base64,${image}`})
.expect(HTTP_STATUS.OK)
.execute();
}
export async function deletePackEmoji(
harness: ApiTestHarness,
token: string,
packId: string,
emojiId: string,
): Promise<void> {
await createBuilder<void>(harness, token)
.delete(`/packs/emojis/${packId}/${emojiId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
export async function createPackSticker(
harness: ApiTestHarness,
token: string,
packId: string,
name: string,
tags: Array<string>,
imageBase64?: string,
): Promise<GuildStickerResponse> {
const image = imageBase64 ?? loadPackFixture('sticker.png').toString('base64');
return createBuilder<GuildStickerResponse>(harness, token)
.post(`/packs/stickers/${packId}`)
.body({name, tags, image: `data:image/png;base64,${image}`})
.expect(HTTP_STATUS.OK)
.execute();
}
export async function deletePackSticker(
harness: ApiTestHarness,
token: string,
packId: string,
stickerId: string,
): Promise<void> {
await createBuilder<void>(harness, token)
.delete(`/packs/stickers/${packId}/${stickerId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
@@ -1,75 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ms} from 'itty-time';
import type {RouteRateLimitConfig} from '../middleware/RateLimitMiddleware';
export const PackRateLimitConfigs = {
PACKS_LIST: {
bucket: 'packs:list',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_CREATE: {
bucket: 'packs:create',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_UPDATE: {
bucket: 'packs:update::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_DELETE: {
bucket: 'packs:delete::pack_id',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
PACKS_INSTALL: {
bucket: 'packs:install::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_INVITES_LIST: {
bucket: 'packs:invite:list::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_INVITES_CREATE: {
bucket: 'packs:invite:create::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_EMOJIS_LIST: {
bucket: 'packs:emoji:list::pack_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_EMOJI_CREATE: {
bucket: 'packs:emoji:create::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_EMOJI_BULK_CREATE: {
bucket: 'packs:emoji:bulk_create::pack_id',
config: {limit: 6, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
PACKS_EMOJI_UPDATE: {
bucket: 'packs:emoji:update::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_EMOJI_DELETE: {
bucket: 'packs:emoji:delete::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_STICKERS_LIST: {
bucket: 'packs:sticker:list::pack_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_STICKER_CREATE: {
bucket: 'packs:sticker:create::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_STICKER_BULK_CREATE: {
bucket: 'packs:sticker:bulk_create::pack_id',
config: {limit: 6, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
PACKS_STICKER_UPDATE: {
bucket: 'packs:sticker:update::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
PACKS_STICKER_DELETE: {
bucket: 'packs:sticker:delete::pack_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
} as const;
-4
View File
@@ -56,8 +56,6 @@ import type {OAuth2RequestService} from '../oauth/OAuth2RequestService';
import type {OAuth2Service} from '../oauth/OAuth2Service';
import type {IApplicationRepository} from '../oauth/repositories/IApplicationRepository';
import type {IOAuth2TokenRepository} from '../oauth/repositories/IOAuth2TokenRepository';
import type {PackRepository} from '../pack/PackRepository';
import type {PackService} from '../pack/PackService';
import type {ReadStateRequestService} from '../read_state/ReadStateRequestService';
import type {ReadStateService} from '../read_state/ReadStateService';
import type {ReportRequestService} from '../report/ReportRequestService';
@@ -142,8 +140,6 @@ export interface HonoEnv {
gatewayRequestService: GatewayRequestService;
discoveryService: IGuildDiscoveryService;
guildService: GuildService;
packService: PackService;
packRepository: PackRepository;
inviteService: InviteService;
inviteRequestService: InviteRequestService;
liveKitWebhookService?: LiveKitWebhookService;
@@ -87,7 +87,7 @@ describe('User custom status emoji premium', () => {
expect(settings.custom_status?.emoji_id).toBeUndefined();
expect(settings.custom_status?.emoji_animated).toBe(false);
});
it('allows custom emoji status for premium users without guild or installed pack access', async () => {
it('allows custom emoji status for premium users without guild access', async () => {
const owner = await createTestAccount(harness);
const premiumAccount = await createTestAccount(harness);
await grantPremium(harness, premiumAccount.userId, UserPremiumTypes.SUBSCRIPTION);
+3 -30
View File
@@ -8,7 +8,6 @@ import type {LimitConfigService} from '../limits/LimitConfigService';
import {resolveLimitSafe} from '../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../limits/LimitMatchContextBuilder';
import type {GuildEmoji} from '../models/GuildEmoji';
import type {PackExpressionAccessResolution, PackExpressionAccessResolver} from '../pack/PackExpressionAccessResolver';
import type {IUserAccountRepository} from '../user/repositories/IUserAccountRepository';
type EmojiGuildRepository = Pick<IGuildRepositoryAggregate, 'getEmoji' | 'getEmojiById'>;
@@ -26,7 +25,6 @@ interface SanitizeCustomEmojisParams {
guildRepository: EmojiGuildRepository;
limitConfigService: LimitConfigService;
hasPermission?: (permission: bigint) => Promise<boolean>;
packResolver?: PackExpressionAccessResolver;
}
interface EmojiMatch {
@@ -43,17 +41,8 @@ interface CodeBlock {
}
export async function sanitizeCustomEmojis(params: SanitizeCustomEmojisParams): Promise<string> {
const {
content,
userId,
webhookId,
guildId,
userRepository,
guildRepository,
limitConfigService,
hasPermission,
packResolver,
} = params;
const {content, userId, webhookId, guildId, userRepository, guildRepository, limitConfigService, hasPermission} =
params;
const escapedContexts = parseEscapedContexts(content);
const isInEscapedContext = (index: number): boolean =>
escapedContexts.some((ctx) => index >= ctx.start && index < ctx.end);
@@ -80,7 +69,6 @@ export async function sanitizeCustomEmojis(params: SanitizeCustomEmojisParams):
isWebhook,
hasGlobalExpressions,
canUseExternalEmojis,
packResolver,
});
return applyReplacements(content, replacements);
}
@@ -171,7 +159,6 @@ async function determineReplacements(params: {
isWebhook: boolean;
hasGlobalExpressions: number;
canUseExternalEmojis: boolean | null;
packResolver?: PackExpressionAccessResolver;
}): Promise<Array<Replacement>> {
const {emojiMatches, emojiLookups, guildId, isWebhook, hasGlobalExpressions, canUseExternalEmojis} = params;
const replacements: Array<Replacement> = [];
@@ -184,7 +171,6 @@ async function determineReplacements(params: {
isWebhook,
hasGlobalExpressions,
canUseExternalEmojis,
packResolver: params.packResolver,
});
if (shouldReplace) {
replacements.push({
@@ -203,9 +189,8 @@ async function shouldReplaceEmoji(params: {
isWebhook: boolean;
hasGlobalExpressions: number;
canUseExternalEmojis: boolean | null;
packResolver?: PackExpressionAccessResolver;
}): Promise<boolean> {
const {lookup, guildId, isWebhook, hasGlobalExpressions, canUseExternalEmojis, packResolver} = params;
const {lookup, guildId, isWebhook, hasGlobalExpressions, canUseExternalEmojis} = params;
if (!guildId) {
if (!lookup.globalEmoji) return true;
if (!isWebhook && hasGlobalExpressions === 0) return true;
@@ -215,23 +200,11 @@ async function shouldReplaceEmoji(params: {
return false;
}
if (!lookup.globalEmoji) return true;
const packAccess = await resolvePackAccessStatus(lookup.globalEmoji.guildId, packResolver);
if (packAccess === 'not-accessible') {
return true;
}
if (!isWebhook && hasGlobalExpressions === 0) return true;
if (hasGlobalExpressions > 0 && canUseExternalEmojis === false) return true;
return false;
}
async function resolvePackAccessStatus(
packId: GuildID,
packResolver?: PackExpressionAccessResolver,
): Promise<PackExpressionAccessResolution> {
if (!packResolver) return 'not-pack';
return await packResolver.resolve(packId);
}
function applyReplacements(content: string, replacements: Array<Replacement>): string {
if (replacements.length === 0) return content;
const sorted = [...replacements].sort((a, b) => b.start - a.start);
@@ -77,7 +77,6 @@ import {
getEmailService,
getEmbedService,
getEntityAssetService,
getExpressionAssetPurger,
getFavoriteMemeRepository,
getGuildAuditLogService,
getGuildRepository,
@@ -89,7 +88,6 @@ import {
getLimitConfigService,
getNcmecSubmissionService,
getOAuth2TokenRepository,
getPackRepository,
getPremiumStateReconciliationQueueService,
getPurgeQueue,
getRateLimitService,
@@ -215,7 +213,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
await ensureVirusScanInitialized();
const virusScanService = getVirusScanServiceInstance();
const rateLimitService = getRateLimitService();
const packRepository = getPackRepository();
const emailService = getEmailService();
const workerService = getWorkerService();
const guildAuditLogService = getGuildAuditLogService();
@@ -261,7 +258,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
const apiContext = createApiContext();
const {channelService, guildService, inviteService} = createGuildStackServices({
apiContext,
packRepository,
channelRepository,
userRepository,
guildRepository,
@@ -271,7 +267,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
avatarService,
entityAssetService,
assetDeletionQueue,
expressionAssetPurger: getExpressionAssetPurger(),
userCacheService,
limitConfigService,
embedService,