mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): add expression source guild routes (#2786)
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function guild(features: Array<string>, icon: string | null = null): GuildResponse {
|
||||
return {id: '20', name: 'Blob Club', icon, features} as unknown as GuildResponse;
|
||||
}
|
||||
|
||||
describe('resolveExpressionSourceGuild', () => {
|
||||
it('hides a community the gateway reports as unavailable without checking membership', async () => {
|
||||
const isMember = vi.fn(() => Promise.resolve(true));
|
||||
await expect(
|
||||
resolveExpressionSourceGuild({loadGuild: () => Promise.reject(new UnknownGuildError()), isMember}),
|
||||
).rejects.toBeInstanceOf(UnknownGuildError);
|
||||
expect(isMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates other gateway failures instead of reporting the community as unavailable', async () => {
|
||||
const failure = new Error('gateway timeout');
|
||||
const isMember = vi.fn(() => Promise.resolve(true));
|
||||
await expect(resolveExpressionSourceGuild({loadGuild: () => Promise.reject(failure), isMember})).rejects.toBe(
|
||||
failure,
|
||||
);
|
||||
expect(isMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals a discoverable community without checking membership', async () => {
|
||||
const isMember = vi.fn(() => Promise.resolve(false));
|
||||
const community = await resolveExpressionSourceGuild({
|
||||
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE])),
|
||||
isMember,
|
||||
});
|
||||
expect(community).toEqual({id: '20', name: 'Blob Club', icon: null, features: [GuildFeatures.DISCOVERABLE]});
|
||||
expect(isMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals a private community to a member', async () => {
|
||||
const community = await resolveExpressionSourceGuild({
|
||||
loadGuild: () => Promise.resolve(guild([])),
|
||||
isMember: () => Promise.resolve(true),
|
||||
});
|
||||
expect(community.id).toBe('20');
|
||||
expect(community.features).toEqual([]);
|
||||
});
|
||||
|
||||
it('hides a private community from a non-member', async () => {
|
||||
await expect(
|
||||
resolveExpressionSourceGuild({
|
||||
loadGuild: () => Promise.resolve(guild([])),
|
||||
isMember: () => Promise.resolve(false),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnknownGuildError);
|
||||
});
|
||||
|
||||
it('limits features to the badge flags', async () => {
|
||||
const community = await resolveExpressionSourceGuild({
|
||||
loadGuild: () =>
|
||||
Promise.resolve(
|
||||
guild([
|
||||
GuildFeatures.DISCOVERABLE,
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.INVITES_DISABLED,
|
||||
GuildFeatures.CLONE_EMOJI_ENABLED,
|
||||
]),
|
||||
),
|
||||
isMember: () => Promise.resolve(false),
|
||||
});
|
||||
expect(community.features).toEqual(expect.arrayContaining([GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]));
|
||||
expect(community.features).not.toContain(GuildFeatures.INVITES_DISABLED);
|
||||
expect(community.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
|
||||
});
|
||||
|
||||
it('strips the animated icon prefix unless the community is entitled to it', async () => {
|
||||
const plain = await resolveExpressionSourceGuild({
|
||||
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE], 'a_icon')),
|
||||
isMember: () => Promise.resolve(false),
|
||||
});
|
||||
const animated = await resolveExpressionSourceGuild({
|
||||
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE, GuildFeatures.ANIMATED_ICON], 'a_icon')),
|
||||
isMember: () => Promise.resolve(false),
|
||||
});
|
||||
expect(plain.icon).toBe('icon');
|
||||
expect(animated.icon).toBe('a_icon');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {stripGuildIconForFeatures} from '@app/api/infrastructure/AssetEntitlementUtils';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {
|
||||
GUILD_EXPRESSION_SOURCE_BADGE_FEATURES,
|
||||
type GuildExpressionSourceGuildResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
interface ExpressionSourceGuildLookup {
|
||||
loadGuild: () => Promise<GuildResponse>;
|
||||
isMember: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function resolveExpressionSourceGuild({
|
||||
loadGuild,
|
||||
isMember,
|
||||
}: ExpressionSourceGuildLookup): Promise<GuildExpressionSourceGuildResponse> {
|
||||
const guild = await loadGuild();
|
||||
const features = new Set(guild.features);
|
||||
if (!features.has(GuildFeatures.DISCOVERABLE) && !(await isMember())) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
return {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: stripGuildIconForFeatures(guild.icon, features),
|
||||
features: GUILD_EXPRESSION_SOURCE_BADGE_FEATURES.filter((feature) => features.has(feature)),
|
||||
};
|
||||
}
|
||||
@@ -15,13 +15,11 @@ import type {GuildMember} from '@app/api/models/GuildMember';
|
||||
import type {GuildRole} from '@app/api/models/GuildRole';
|
||||
import type {GuildSticker} from '@app/api/models/GuildSticker';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@app/api/user/UserCacheHelpers';
|
||||
import {
|
||||
GUILD_EXPRESSION_SOURCE_BADGE_FEATURES,
|
||||
type GuildEmojiResponse,
|
||||
type GuildEmojiWithUserResponse,
|
||||
type GuildExpressionSourceGuildResponse,
|
||||
type GuildStickerResponse,
|
||||
type GuildStickerWithUserResponse,
|
||||
import type {
|
||||
GuildEmojiResponse,
|
||||
GuildEmojiWithUserResponse,
|
||||
GuildStickerResponse,
|
||||
GuildStickerWithUserResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
@@ -52,15 +50,6 @@ export function mapGuildToPartialResponse(guild: Guild): GuildPartialResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildToExpressionSourceGuildResponse(guild: Guild): GuildExpressionSourceGuildResponse {
|
||||
return {
|
||||
id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
icon: stripGuildIconForFeatures(guild.iconHash, guild.features),
|
||||
features: GUILD_EXPRESSION_SOURCE_BADGE_FEATURES.filter((feature) => guild.features.has(feature)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildToGuildResponse(
|
||||
guild: Guild,
|
||||
options?: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GuildEmojiMetadataResponse,
|
||||
GuildEmojiResponse,
|
||||
GuildEmojiWithUserListResponse,
|
||||
GuildExpressionSourceGuildResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import {
|
||||
GuildEmojiBulkCreateRequest,
|
||||
@@ -197,4 +198,25 @@ export function GuildEmojiController(app: HonoApp) {
|
||||
return ctx.json(await ctx.get('guildService').getEmojiMetadata(emojiId));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/emojis/:emoji_id/source',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_SOURCE),
|
||||
LoginRequired,
|
||||
Validator('param', EmojiIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_emoji_source',
|
||||
summary: 'Get emoji source guild',
|
||||
description:
|
||||
'Lookup the public presentation of the guild a custom emoji belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.',
|
||||
responseSchema: GuildExpressionSourceGuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Emojis'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const emojiId = createEmojiID(ctx.req.valid('param').emoji_id);
|
||||
return ctx.json(await ctx.get('guildService').getEmojiSource(emojiId, ctx.get('user').id));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {PurgeQuery} from '@fluxer/schema/src/domains/common/CommonQuerySchemas';
|
||||
import {
|
||||
GuildExpressionSourceGuildResponse,
|
||||
GuildStickerBulkCreateResponse,
|
||||
GuildStickerMetadataResponse,
|
||||
GuildStickerResponse,
|
||||
@@ -210,4 +211,25 @@ export function GuildStickerController(app: HonoApp) {
|
||||
return ctx.json(await ctx.get('guildService').getStickerMetadata(stickerId));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/stickers/:sticker_id/source',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_SOURCE),
|
||||
LoginRequired,
|
||||
Validator('param', StickerIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_sticker_source',
|
||||
summary: 'Get sticker source guild',
|
||||
description:
|
||||
'Lookup the public presentation of the guild a custom sticker belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.',
|
||||
responseSchema: GuildExpressionSourceGuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Stickers'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const stickerId = createStickerID(ctx.req.valid('param').sticker_id);
|
||||
return ctx.json(await ctx.get('guildService').getStickerSource(stickerId, ctx.get('user').id));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '@app/api/Branded
|
||||
import {createWebhookID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild';
|
||||
import {
|
||||
collectGuildAuditLogUserIds,
|
||||
isNoopGuildAuditLog,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
type StoredGuildAuditLogEntryResponse,
|
||||
} from '@app/api/guild/GuildAuditLogEntryMapper';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import {mapGuildToExpressionSourceGuildResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {GuildChannelService} from '@app/api/guild/services/GuildChannelService';
|
||||
import {GuildContentService} from '@app/api/guild/services/GuildContentService';
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
|
||||
import type {
|
||||
GuildEmojiMetadataResponse,
|
||||
GuildExpressionSourceGuildResponse,
|
||||
GuildStickerMetadataResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
@@ -260,7 +261,6 @@ export class GuildService {
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
allow_cloning: guild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED),
|
||||
guild: mapGuildToExpressionSourceGuildResponse(guild),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,10 +274,28 @@ export class GuildService {
|
||||
name: sticker.name,
|
||||
animated: sticker.animated,
|
||||
allow_cloning: guild.features.has(GuildFeatures.CLONE_STICKER_ENABLED),
|
||||
guild: mapGuildToExpressionSourceGuildResponse(guild),
|
||||
};
|
||||
}
|
||||
|
||||
async getEmojiSource(emojiId: EmojiID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
|
||||
const emoji = await this.guildRepository.getEmojiById(emojiId);
|
||||
if (!emoji) throw new UnknownGuildEmojiError();
|
||||
return this.resolveExpressionSourceGuild(emoji.guildId, userId);
|
||||
}
|
||||
|
||||
async getStickerSource(stickerId: StickerID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
|
||||
const sticker = await this.guildRepository.getStickerById(stickerId);
|
||||
if (!sticker) throw new UnknownGuildStickerError();
|
||||
return this.resolveExpressionSourceGuild(sticker.guildId, userId);
|
||||
}
|
||||
|
||||
private resolveExpressionSourceGuild(guildId: GuildID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
|
||||
return resolveExpressionSourceGuild({
|
||||
loadGuild: () => this.gatewayService.getGuildData({guildId, userId, skipMembershipCheck: true}),
|
||||
isMember: async () => (await this.gatewayService.getGuildMember({guildId, userId})).success,
|
||||
});
|
||||
}
|
||||
|
||||
async listGuildAuditLogs(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {
|
||||
GuildEmojiMetadataResponse,
|
||||
GuildEmojiWithUserResponse,
|
||||
GuildStickerMetadataResponse,
|
||||
GuildStickerWithUserResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const SOURCE_GUILD_KEYS = ['features', 'icon', 'id', 'name'];
|
||||
|
||||
interface MetadataSource {
|
||||
owner: TestAccount;
|
||||
guild: GuildResponse;
|
||||
emoji: GuildEmojiWithUserResponse;
|
||||
sticker: GuildStickerWithUserResponse;
|
||||
}
|
||||
|
||||
async function createSource(harness: ApiTestHarness, name: string): Promise<MetadataSource> {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
const emoji = await createBuilder<GuildEmojiWithUserResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/emojis`)
|
||||
.body({name: 'source_emoji', image: getPngDataUrl()})
|
||||
.execute();
|
||||
const sticker = await createBuilder<GuildStickerWithUserResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/stickers`)
|
||||
.body({name: 'source_sticker', description: 'source sticker', tags: [], image: getPngDataUrl()})
|
||||
.execute();
|
||||
return {owner, guild, emoji, sticker};
|
||||
}
|
||||
|
||||
async function grantGuildFeatures(harness: ApiTestHarness, guildId: string, features: Array<string>): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute();
|
||||
}
|
||||
|
||||
async function getEmojiMetadata(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
emojiId: string,
|
||||
): Promise<GuildEmojiMetadataResponse> {
|
||||
return createBuilder<GuildEmojiMetadataResponse>(harness, token)
|
||||
.get(`/emojis/${emojiId}/metadata`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function getStickerMetadata(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
stickerId: string,
|
||||
): Promise<GuildStickerMetadataResponse> {
|
||||
return createBuilder<GuildStickerMetadataResponse>(harness, token)
|
||||
.get(`/stickers/${stickerId}/metadata`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Guild expression metadata source community', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('returns the source community presentation for both kinds to a member and a non-member alike', async () => {
|
||||
const source = await createSource(harness, 'Metadata Presentation Source');
|
||||
const withIcon = await updateGuild(harness, source.owner.token, source.guild.id, {icon: getPngDataUrl()});
|
||||
expect(withIcon.icon).toBeTruthy();
|
||||
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
|
||||
const outsider = await createTestAccount(harness);
|
||||
const responses = [
|
||||
await getEmojiMetadata(harness, source.owner.token, source.emoji.id),
|
||||
await getEmojiMetadata(harness, outsider.token, source.emoji.id),
|
||||
await getStickerMetadata(harness, source.owner.token, source.sticker.id),
|
||||
await getStickerMetadata(harness, outsider.token, source.sticker.id),
|
||||
];
|
||||
for (const response of responses) {
|
||||
expect(response.guild_id).toBe(source.guild.id);
|
||||
expect(response.guild.id).toBe(source.guild.id);
|
||||
expect(response.guild.name).toBe('Metadata Presentation Source');
|
||||
expect(response.guild.icon).toBe(withIcon.icon);
|
||||
expect(response.guild.features).toContain(GuildFeatures.DISCOVERABLE);
|
||||
expect(response.guild.features).toContain(GuildFeatures.VERIFIED);
|
||||
expect(response.allow_cloning).toBe(false);
|
||||
}
|
||||
const [memberEmoji, outsiderEmoji, memberSticker, outsiderSticker] = responses;
|
||||
expect(outsiderEmoji.guild).toEqual(memberEmoji.guild);
|
||||
expect(outsiderSticker.guild).toEqual(memberSticker.guild);
|
||||
expect(memberSticker.guild).toEqual(memberEmoji.guild);
|
||||
});
|
||||
|
||||
test('reports no badge features and no icon for a community that has neither', async () => {
|
||||
const source = await createSource(harness, 'Metadata Plain Source');
|
||||
const outsider = await createTestAccount(harness);
|
||||
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
|
||||
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
|
||||
for (const response of [emoji, sticker]) {
|
||||
expect(response.guild.icon).toBeNull();
|
||||
expect(response.guild.features).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('exposes only id, name, icon and badge features to a non-member for both kinds', async () => {
|
||||
const source = await createSource(harness, 'Metadata Envelope Source');
|
||||
await updateGuild(harness, source.owner.token, source.guild.id, {verification_level: 1});
|
||||
await grantGuildFeatures(harness, source.guild.id, [
|
||||
GuildFeatures.DISCOVERABLE,
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.INVITES_DISABLED,
|
||||
GuildFeatures.CLONE_EMOJI_ENABLED,
|
||||
GuildFeatures.CLONE_STICKER_ENABLED,
|
||||
GuildFeatures.BANNER,
|
||||
GuildFeatures.INVITE_SPLASH,
|
||||
]);
|
||||
const outsider = await createTestAccount(harness);
|
||||
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
|
||||
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
|
||||
for (const response of [emoji, sticker]) {
|
||||
expect(response.allow_cloning).toBe(true);
|
||||
expect(Object.keys(response.guild).sort()).toEqual(SOURCE_GUILD_KEYS);
|
||||
const leaked = response.guild as Record<string, unknown>;
|
||||
for (const key of [
|
||||
'banner',
|
||||
'banner_width',
|
||||
'banner_height',
|
||||
'splash',
|
||||
'splash_width',
|
||||
'splash_height',
|
||||
'embed_splash',
|
||||
'embed_splash_width',
|
||||
'embed_splash_height',
|
||||
'splash_card_alignment',
|
||||
]) {
|
||||
expect(leaked).not.toHaveProperty(key);
|
||||
}
|
||||
expect([...response.guild.features].sort()).toEqual([GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
|
||||
expect(response.guild.features).not.toContain(GuildFeatures.INVITES_DISABLED);
|
||||
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
|
||||
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
|
||||
expect(response.guild.features).not.toContain(GuildFeatures.BANNER);
|
||||
}
|
||||
expect(sticker.guild).toEqual(emoji.guild);
|
||||
expect(Object.keys(emoji).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
|
||||
expect(Object.keys(sticker).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {
|
||||
GuildEmojiMetadataResponse,
|
||||
GuildEmojiWithUserResponse,
|
||||
GuildExpressionSourceGuildResponse,
|
||||
GuildStickerMetadataResponse,
|
||||
GuildStickerWithUserResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const SOURCE_GUILD_KEYS = ['features', 'icon', 'id', 'name'];
|
||||
const METADATA_KEYS = ['allow_cloning', 'animated', 'guild_id', 'id', 'name'];
|
||||
|
||||
type ExpressionPath = 'emojis' | 'stickers';
|
||||
|
||||
interface ExpressionSource {
|
||||
owner: TestAccount;
|
||||
guild: GuildResponse;
|
||||
emoji: GuildEmojiWithUserResponse;
|
||||
sticker: GuildStickerWithUserResponse;
|
||||
}
|
||||
|
||||
async function createSource(harness: ApiTestHarness, name: string): Promise<ExpressionSource> {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
const emoji = await createBuilder<GuildEmojiWithUserResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/emojis`)
|
||||
.body({name: 'source_emoji', image: getPngDataUrl()})
|
||||
.execute();
|
||||
const sticker = await createBuilder<GuildStickerWithUserResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/stickers`)
|
||||
.body({name: 'source_sticker', description: 'source sticker', tags: [], image: getPngDataUrl()})
|
||||
.execute();
|
||||
return {owner, guild, emoji, sticker};
|
||||
}
|
||||
|
||||
async function grantGuildFeatures(harness: ApiTestHarness, guildId: string, features: Array<string>): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute();
|
||||
}
|
||||
|
||||
async function joinSource(harness: ApiTestHarness, source: ExpressionSource): Promise<TestAccount> {
|
||||
const member = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
const invite = await createChannelInvite(harness, source.owner.token, source.guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
return member;
|
||||
}
|
||||
|
||||
function expressionIds(source: ExpressionSource): Array<[ExpressionPath, string]> {
|
||||
return [
|
||||
['emojis', source.emoji.id],
|
||||
['stickers', source.sticker.id],
|
||||
];
|
||||
}
|
||||
|
||||
async function getSource(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
path: ExpressionPath,
|
||||
id: string,
|
||||
): Promise<GuildExpressionSourceGuildResponse> {
|
||||
return createBuilder<GuildExpressionSourceGuildResponse>(harness, token)
|
||||
.get(`/${path}/${id}/source`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function expectHiddenSource(harness: ApiTestHarness, token: string, path: ExpressionPath, id: string) {
|
||||
await createBuilder(harness, token)
|
||||
.get(`/${path}/${id}/source`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_GUILD)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Guild expression source community', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('reveals a discoverable community to a non-member for both kinds', async () => {
|
||||
const source = await createSource(harness, 'Discoverable Source');
|
||||
const withIcon = await updateGuild(harness, source.owner.token, source.guild.id, {icon: getPngDataUrl()});
|
||||
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
|
||||
const outsider = await createTestAccount(harness);
|
||||
for (const [path, id] of expressionIds(source)) {
|
||||
const community = await getSource(harness, outsider.token, path, id);
|
||||
expect(Object.keys(community).sort()).toEqual(SOURCE_GUILD_KEYS);
|
||||
expect(community.id).toBe(source.guild.id);
|
||||
expect(community.name).toBe('Discoverable Source');
|
||||
expect(community.icon).toBe(withIcon.icon);
|
||||
expect(community.features).toContain(GuildFeatures.DISCOVERABLE);
|
||||
expect(community.features).toContain(GuildFeatures.VERIFIED);
|
||||
}
|
||||
});
|
||||
|
||||
test('reveals a private community to its members for both kinds', async () => {
|
||||
const source = await createSource(harness, 'Private Source');
|
||||
const member = await joinSource(harness, source);
|
||||
for (const token of [source.owner.token, member.token]) {
|
||||
for (const [path, id] of expressionIds(source)) {
|
||||
const community = await getSource(harness, token, path, id);
|
||||
expect(community.id).toBe(source.guild.id);
|
||||
expect(community.name).toBe('Private Source');
|
||||
expect(community.features).not.toContain(GuildFeatures.DISCOVERABLE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('hides a private community from non-members for both kinds', async () => {
|
||||
const source = await createSource(harness, 'Hidden Source');
|
||||
const outsider = await createTestAccount(harness);
|
||||
for (const [path, id] of expressionIds(source)) {
|
||||
await expectHiddenSource(harness, outsider.token, path, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('limits the revealed features to the badge flags', async () => {
|
||||
const source = await createSource(harness, 'Feature Source');
|
||||
await grantGuildFeatures(harness, source.guild.id, [
|
||||
GuildFeatures.DISCOVERABLE,
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.INVITES_DISABLED,
|
||||
GuildFeatures.CLONE_EMOJI_ENABLED,
|
||||
]);
|
||||
const outsider = await createTestAccount(harness);
|
||||
for (const [path, id] of expressionIds(source)) {
|
||||
const community = await getSource(harness, outsider.token, path, id);
|
||||
expect(community.features).not.toContain(GuildFeatures.INVITES_DISABLED);
|
||||
expect(community.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the expression metadata contract without the source community', async () => {
|
||||
const source = await createSource(harness, 'Metadata Source');
|
||||
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE]);
|
||||
const outsider = await createTestAccount(harness);
|
||||
const emoji = await createBuilder<GuildEmojiMetadataResponse>(harness, outsider.token)
|
||||
.get(`/emojis/${source.emoji.id}/metadata`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const sticker = await createBuilder<GuildStickerMetadataResponse>(harness, outsider.token)
|
||||
.get(`/stickers/${source.sticker.id}/metadata`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(Object.keys(emoji).sort()).toEqual(METADATA_KEYS);
|
||||
expect(Object.keys(sticker).sort()).toEqual(METADATA_KEYS);
|
||||
});
|
||||
});
|
||||
@@ -5522,6 +5522,70 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/emojis/{emoji_id}/source": {
|
||||
"get": {
|
||||
"operationId": "get_emoji_source",
|
||||
"summary": "Get emoji source guild",
|
||||
"tags": ["Emojis"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Lookup the public presentation of the guild a custom emoji belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.",
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "emoji_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the emoji", "$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "The ID of the emoji"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experiments": {
|
||||
"get": {
|
||||
"operationId": "get_experiments",
|
||||
@@ -12150,6 +12214,70 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/stickers/{sticker_id}/source": {
|
||||
"get": {
|
||||
"operationId": "get_sticker_source",
|
||||
"summary": "Get sticker source guild",
|
||||
"tags": ["Stickers"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Lookup the public presentation of the guild a custom sticker belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.",
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sticker_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the sticker", "$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "The ID of the sticker"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/streams/{stream_key}/preview": {
|
||||
"get": {
|
||||
"operationId": "get_stream_preview",
|
||||
@@ -24834,6 +24962,22 @@
|
||||
"contentEncoding": "binary",
|
||||
"description": "The current stream preview image"
|
||||
},
|
||||
"GuildExpressionSourceGuildResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"description": "The ID of the source guild", "$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"name": {"type": "string", "description": "The name of the source guild"},
|
||||
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "The hash of the source guild icon"},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["VERIFIED", "PARTNERED", "DISCOVERABLE"]},
|
||||
"description": "The badge feature flags of the source guild, limited to VERIFIED, PARTNERED, and DISCOVERABLE"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "icon", "features"],
|
||||
"additionalProperties": false,
|
||||
"description": "Public presentation of the source guild of an expression"
|
||||
},
|
||||
"GuildStickerMetadataResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24850,10 +24994,9 @@
|
||||
"allow_cloning": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the source guild allows non-members to use the in-app clone shortcut"
|
||||
},
|
||||
"guild": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}
|
||||
}
|
||||
},
|
||||
"required": ["id", "guild_id", "name", "animated", "allow_cloning", "guild"],
|
||||
"required": ["id", "guild_id", "name", "animated", "allow_cloning"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GlobalSearchMessagesRequest": {
|
||||
@@ -27485,10 +27628,9 @@
|
||||
"allow_cloning": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the source guild allows non-members to use the in-app clone shortcut"
|
||||
},
|
||||
"guild": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}
|
||||
}
|
||||
},
|
||||
"required": ["id", "guild_id", "name", "animated", "allow_cloning", "guild"],
|
||||
"required": ["id", "guild_id", "name", "animated", "allow_cloning"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationRequestLinkRequest": {
|
||||
@@ -31202,22 +31344,6 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"GuildExpressionSourceGuildResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"description": "The ID of the source guild", "$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"name": {"type": "string", "description": "The name of the source guild"},
|
||||
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "The hash of the source guild icon"},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["VERIFIED", "PARTNERED", "DISCOVERABLE"]},
|
||||
"description": "The badge feature flags of the source guild, limited to VERIFIED, PARTNERED, and DISCOVERABLE"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "icon", "features"],
|
||||
"additionalProperties": false,
|
||||
"description": "Public presentation of the source guild of an expression"
|
||||
},
|
||||
"TypingIndicatorReworkAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -136,6 +136,10 @@ export const GuildRateLimitConfigs = {
|
||||
bucket: 'guild:emoji:metadata::user_id',
|
||||
config: {limit: 60, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_SOURCE: {
|
||||
bucket: 'guild:emoji:source::user_id',
|
||||
config: {limit: 60, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKERS_LIST: {
|
||||
bucket: 'guild:sticker:list::guild_id',
|
||||
config: {limit: 60, windowMs: ms('10 seconds')},
|
||||
@@ -168,4 +172,8 @@ export const GuildRateLimitConfigs = {
|
||||
bucket: 'guild:sticker:metadata::user_id',
|
||||
config: {limit: 60, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_SOURCE: {
|
||||
bucket: 'guild:sticker:source::user_id',
|
||||
config: {limit: 60, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user