From 951e39da3d6da6924b46a9d3bd466e9c754a3f15 Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 15 Sep 2026 00:31:47 +0200 Subject: [PATCH] feat(api): add expression source guild routes (#2786) --- .../api/guild/ExpressionSourceGuild.test.ts | 89 +++++++++ .../src/api/guild/ExpressionSourceGuild.ts | 32 ++++ fluxer_api/src/api/guild/GuildModel.ts | 21 +-- .../guild/controllers/GuildEmojiController.ts | 22 +++ .../controllers/GuildStickerController.ts | 22 +++ .../src/api/guild/services/GuildService.ts | 24 ++- ...GuildExpressionMetadataSourceGuild.test.ts | 163 ----------------- .../guild/tests/GuildExpressionSource.test.ts | 167 +++++++++++++++++ fluxer_api/src/api/openapi/openapi.json | 170 +++++++++++++++--- .../GuildRateLimitConfig.ts | 8 + .../CustomStatusDisplay.tsx | 2 + .../src/features/app/constants/Endpoints.ts | 2 + .../components/MessageAttachments.module.css | 4 + .../channel/components/MessageAttachments.tsx | 2 +- .../ExpressionMetadataCommands.test.ts | 53 ------ .../commands/ExpressionMetadataCommands.ts | 20 +-- .../commands/ExpressionSourceCommands.test.ts | 51 ++++++ .../commands/ExpressionSourceCommands.ts | 43 +++++ .../components/ExpressionInfoCard.module.css | 16 +- .../components/ExpressionInfoCard.test.tsx | 74 +++----- .../components/ExpressionInfoCard.tsx | 93 ++++++---- .../components/ExpressionInfoPopout.tsx | 14 +- .../expressions/state/ExpressionMetadata.ts | 8 - .../state/ExpressionSource.test.ts | 67 +++++++ .../expressions/state/ExpressionSource.ts | 52 ++++++ .../utils/ExpressionPreviewConstants.ts | 4 + .../src/features/i18n/locales/ar/messages.po | 35 ++-- .../locales/auto-i18n-reviewed-unchanged.json | 3 + .../src/features/i18n/locales/bg/messages.po | 35 ++-- .../src/features/i18n/locales/cs/messages.po | 35 ++-- .../src/features/i18n/locales/da/messages.po | 35 ++-- .../src/features/i18n/locales/de/messages.po | 35 ++-- .../src/features/i18n/locales/el/messages.po | 35 ++-- .../features/i18n/locales/en-GB/messages.po | 36 ++-- .../features/i18n/locales/en-US/messages.po | 35 ++-- .../features/i18n/locales/es-419/messages.po | 35 ++-- .../features/i18n/locales/es-ES/messages.po | 35 ++-- .../src/features/i18n/locales/fi/messages.po | 35 ++-- .../src/features/i18n/locales/fr/messages.po | 35 ++-- .../src/features/i18n/locales/he/messages.po | 35 ++-- .../src/features/i18n/locales/hi/messages.po | 35 ++-- .../src/features/i18n/locales/hr/messages.po | 35 ++-- .../src/features/i18n/locales/hu/messages.po | 35 ++-- .../src/features/i18n/locales/id/messages.po | 35 ++-- .../src/features/i18n/locales/it/messages.po | 35 ++-- .../src/features/i18n/locales/ja/messages.po | 35 ++-- .../src/features/i18n/locales/ko/messages.po | 35 ++-- .../src/features/i18n/locales/lt/messages.po | 35 ++-- .../src/features/i18n/locales/nl/messages.po | 35 ++-- .../src/features/i18n/locales/no/messages.po | 35 ++-- .../src/features/i18n/locales/pl/messages.po | 35 ++-- .../features/i18n/locales/pt-BR/messages.po | 35 ++-- .../src/features/i18n/locales/ro/messages.po | 35 ++-- .../src/features/i18n/locales/ru/messages.po | 35 ++-- .../features/i18n/locales/sv-SE/messages.po | 35 ++-- .../src/features/i18n/locales/th/messages.po | 35 ++-- .../src/features/i18n/locales/tr/messages.po | 35 ++-- .../src/features/i18n/locales/uk/messages.po | 35 ++-- .../src/features/i18n/locales/vi/messages.po | 35 ++-- .../features/i18n/locales/zh-CN/messages.po | 35 ++-- .../features/i18n/locales/zh-TW/messages.po | 35 ++-- .../composer/nodes/ComposerCustomEmoji.tsx | 7 +- .../composer/nodes/ComposerStandardEmoji.tsx | 7 +- .../markdown/renderers/EmojiRenderer.tsx | 2 + .../src/features/ui/popover/PopoverPopout.tsx | 4 + .../src/content/docs/http-api/expressions.mdx | 82 ++++++--- .../src/domains/guild/GuildEmojiSchemas.ts | 2 - 67 files changed, 1598 insertions(+), 923 deletions(-) create mode 100644 fluxer_api/src/api/guild/ExpressionSourceGuild.test.ts create mode 100644 fluxer_api/src/api/guild/ExpressionSourceGuild.ts delete mode 100644 fluxer_api/src/api/guild/tests/GuildExpressionMetadataSourceGuild.test.ts create mode 100644 fluxer_api/src/api/guild/tests/GuildExpressionSource.test.ts delete mode 100644 fluxer_app/src/features/expressions/commands/ExpressionMetadataCommands.test.ts create mode 100644 fluxer_app/src/features/expressions/commands/ExpressionSourceCommands.test.ts create mode 100644 fluxer_app/src/features/expressions/commands/ExpressionSourceCommands.ts create mode 100644 fluxer_app/src/features/expressions/state/ExpressionSource.test.ts create mode 100644 fluxer_app/src/features/expressions/state/ExpressionSource.ts diff --git a/fluxer_api/src/api/guild/ExpressionSourceGuild.test.ts b/fluxer_api/src/api/guild/ExpressionSourceGuild.test.ts new file mode 100644 index 000000000..1b5cdb1bc --- /dev/null +++ b/fluxer_api/src/api/guild/ExpressionSourceGuild.test.ts @@ -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, 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'); + }); +}); diff --git a/fluxer_api/src/api/guild/ExpressionSourceGuild.ts b/fluxer_api/src/api/guild/ExpressionSourceGuild.ts new file mode 100644 index 000000000..ee9175aa4 --- /dev/null +++ b/fluxer_api/src/api/guild/ExpressionSourceGuild.ts @@ -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; + isMember: () => Promise; +} + +export async function resolveExpressionSourceGuild({ + loadGuild, + isMember, +}: ExpressionSourceGuildLookup): Promise { + 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)), + }; +} diff --git a/fluxer_api/src/api/guild/GuildModel.ts b/fluxer_api/src/api/guild/GuildModel.ts index b9ca64115..d684d9fa2 100644 --- a/fluxer_api/src/api/guild/GuildModel.ts +++ b/fluxer_api/src/api/guild/GuildModel.ts @@ -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?: { diff --git a/fluxer_api/src/api/guild/controllers/GuildEmojiController.ts b/fluxer_api/src/api/guild/controllers/GuildEmojiController.ts index 31bf50032..2aa3a38eb 100644 --- a/fluxer_api/src/api/guild/controllers/GuildEmojiController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildEmojiController.ts @@ -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)); + }, + ); } diff --git a/fluxer_api/src/api/guild/controllers/GuildStickerController.ts b/fluxer_api/src/api/guild/controllers/GuildStickerController.ts index 3c265ac7f..01ade3464 100644 --- a/fluxer_api/src/api/guild/controllers/GuildStickerController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildStickerController.ts @@ -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)); + }, + ); } diff --git a/fluxer_api/src/api/guild/services/GuildService.ts b/fluxer_api/src/api/guild/services/GuildService.ts index 2ee2f067e..f5a141ffd 100644 --- a/fluxer_api/src/api/guild/services/GuildService.ts +++ b/fluxer_api/src/api/guild/services/GuildService.ts @@ -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 { + 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 { + 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 { + 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; diff --git a/fluxer_api/src/api/guild/tests/GuildExpressionMetadataSourceGuild.test.ts b/fluxer_api/src/api/guild/tests/GuildExpressionMetadataSourceGuild.test.ts deleted file mode 100644 index bb2668f3e..000000000 --- a/fluxer_api/src/api/guild/tests/GuildExpressionMetadataSourceGuild.test.ts +++ /dev/null @@ -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 { - const owner = await createTestAccount(harness); - await ensureSessionStarted(harness, owner.token); - const guild = await createGuild(harness, owner.token, name); - const emoji = await createBuilder(harness, owner.token) - .post(`/guilds/${guild.id}/emojis`) - .body({name: 'source_emoji', image: getPngDataUrl()}) - .execute(); - const sticker = await createBuilder(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): Promise { - await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute(); -} - -async function getEmojiMetadata( - harness: ApiTestHarness, - token: string, - emojiId: string, -): Promise { - return createBuilder(harness, token) - .get(`/emojis/${emojiId}/metadata`) - .expect(HTTP_STATUS.OK) - .execute(); -} - -async function getStickerMetadata( - harness: ApiTestHarness, - token: string, - stickerId: string, -): Promise { - return createBuilder(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; - 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']); - }); -}); diff --git a/fluxer_api/src/api/guild/tests/GuildExpressionSource.test.ts b/fluxer_api/src/api/guild/tests/GuildExpressionSource.test.ts new file mode 100644 index 000000000..b71256207 --- /dev/null +++ b/fluxer_api/src/api/guild/tests/GuildExpressionSource.test.ts @@ -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 { + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const guild = await createGuild(harness, owner.token, name); + const emoji = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/emojis`) + .body({name: 'source_emoji', image: getPngDataUrl()}) + .execute(); + const sticker = await createBuilder(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): Promise { + await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute(); +} + +async function joinSource(harness: ApiTestHarness, source: ExpressionSource): Promise { + 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 { + return createBuilder(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(harness, outsider.token) + .get(`/emojis/${source.emoji.id}/metadata`) + .expect(HTTP_STATUS.OK) + .execute(); + const sticker = await createBuilder(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); + }); +}); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 8ab4b3fc0..5f74c1610 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -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": { diff --git a/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts index b1d0b84ec..32f72b0c0 100644 --- a/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts @@ -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; diff --git a/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx b/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx index bdb57d6bc..cd64e05bf 100644 --- a/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx +++ b/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx @@ -10,6 +10,7 @@ import Emoji from '@app/features/emoji/state/Emoji'; import ExpressionInfoCardRollout from '@app/features/expressions/state/ExpressionInfoCardRollout'; import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl'; import {getEmojiURL} from '@app/features/expressions/utils/EmojiUtils'; +import {EXPRESSION_TOOLTIP_DELAY_MS} from '@app/features/expressions/utils/ExpressionPreviewConstants'; import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis'; import Guilds from '@app/features/guild/state/Guilds'; import {usePresenceCustomStatus} from '@app/features/presence/hooks/usePresenceCustomStatus'; @@ -170,6 +171,7 @@ const StatusEmojiNameTooltip = observer( return ( `/guilds/${guildId}/stickers/clone`, EMOJI_METADATA: (emojiId: string) => `/emojis/${emojiId}/metadata`, STICKER_METADATA: (stickerId: string) => `/stickers/${stickerId}/metadata`, + EMOJI_SOURCE: (emojiId: string) => `/emojis/${emojiId}/source`, + STICKER_SOURCE: (stickerId: string) => `/stickers/${stickerId}/source`, GUILD_INVITES: (guildId: string) => `/guilds/${guildId}/invites`, GUILD_VANITY_URL: (guildId: string) => `/guilds/${guildId}/vanity-url`, GUILD_WEBHOOKS: (guildId: string) => `/guilds/${guildId}/webhooks`, diff --git a/fluxer_app/src/features/channel/components/MessageAttachments.module.css b/fluxer_app/src/features/channel/components/MessageAttachments.module.css index 693b8c762..f39beacf2 100644 --- a/fluxer_app/src/features/channel/components/MessageAttachments.module.css +++ b/fluxer_app/src/features/channel/components/MessageAttachments.module.css @@ -145,6 +145,10 @@ div.urlEmbedSpoiler { width: 10rem; } +.stickerWrapperInteractive { + cursor: pointer; +} + .stickerImage { height: 100%; width: 100%; diff --git a/fluxer_app/src/features/channel/components/MessageAttachments.tsx b/fluxer_app/src/features/channel/components/MessageAttachments.tsx index feee5eabb..71c6f6b90 100644 --- a/fluxer_app/src/features/channel/components/MessageAttachments.tsx +++ b/fluxer_app/src/features/channel/components/MessageAttachments.tsx @@ -729,7 +729,7 @@ const StickerItemWithInfoCard = observer(({sticker, message, sourceChannel, hand