feat(guild): require opt-in for emoji and sticker cloning (#2712)

This commit is contained in:
Hampus
2026-09-12 00:33:23 +02:00
committed by GitHub
parent 7b15e5be0f
commit 3affd295e8
63 changed files with 4189 additions and 1752 deletions
@@ -259,7 +259,7 @@ export class GuildService {
guild_id: guild.id.toString(),
name: emoji.name,
animated: emoji.isAnimated,
allow_cloning: !guild.features.has(GuildFeatures.CLONE_EMOJI_DISABLED),
allow_cloning: guild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED),
};
}
@@ -272,7 +272,7 @@ export class GuildService {
guild_id: guild.id.toString(),
name: sticker.name,
animated: sticker.animated,
allow_cloning: !guild.features.has(GuildFeatures.CLONE_STICKER_DISABLED),
allow_cloning: guild.features.has(GuildFeatures.CLONE_STICKER_ENABLED),
};
}
@@ -157,7 +157,7 @@ export class EmojiService {
const sourceEmoji = await this.guildRepository.getEmojiById(sourceEmojiId);
if (!sourceEmoji) throw new UnknownGuildEmojiError();
const sourceGuild = await this.guildRepository.findUnique(sourceEmoji.guildId);
if (!sourceGuild || sourceGuild.features.has(GuildFeatures.CLONE_EMOJI_DISABLED)) {
if (!sourceGuild || !sourceGuild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED)) {
throw new MissingAccessError();
}
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
@@ -171,7 +171,7 @@ export class StickerService {
const sourceSticker = await this.guildRepository.getStickerById(sourceStickerId);
if (!sourceSticker) throw new UnknownGuildStickerError();
const sourceGuild = await this.guildRepository.findUnique(sourceSticker.guildId);
if (!sourceGuild || sourceGuild.features.has(GuildFeatures.CLONE_STICKER_DISABLED)) {
if (!sourceGuild || !sourceGuild.features.has(GuildFeatures.CLONE_STICKER_ENABLED)) {
throw new MissingAccessError();
}
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
@@ -106,8 +106,8 @@ const USER_TOGGLEABLE_GUILD_FEATURES: ReadonlySet<string> = new Set([
GuildFeatures.INVITES_DISABLED,
GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES,
GuildFeatures.DETACHED_BANNER,
GuildFeatures.CLONE_EMOJI_DISABLED,
GuildFeatures.CLONE_STICKER_DISABLED,
GuildFeatures.CLONE_EMOJI_ENABLED,
GuildFeatures.CLONE_STICKER_ENABLED,
GuildFeatures.HIDE_OWNER_CROWN,
]);
const SUPPORTED_SYSTEM_CHANNEL_FLAGS = SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS;
@@ -0,0 +1,237 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
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, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs, type TestAccount} from '../../auth/tests/AuthTestUtils';
import {getPngDataUrl} from '../../emoji/tests/EmojiTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {createGuild, getGuild, updateGuild} from './GuildTestUtils';
interface CloneSource {
owner: TestAccount;
guild: GuildResponse;
emoji: GuildEmojiWithUserResponse;
sticker: GuildStickerWithUserResponse;
}
async function createSource(harness: ApiTestHarness, name: string): Promise<CloneSource> {
const owner = await createTestAccount(harness);
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 addDeprecatedFeatures(
harness: ApiTestHarness,
source: CloneSource,
features: Array<string>,
): Promise<void> {
const admin = await setUserACLs(harness, source.owner, ['admin:authenticate', 'guild:update:features']);
source.owner = admin;
await createBuilder(harness, admin.token)
.patch(`/admin/guilds/${source.guild.id}`)
.body({add_features: features})
.expect(HTTP_STATUS.OK)
.execute();
source.guild = await getGuild(harness, admin.token, source.guild.id);
}
async function optIn(harness: ApiTestHarness, source: CloneSource, features: Array<string>): Promise<void> {
source.guild = await updateGuild(harness, source.owner.token, source.guild.id, {
features: [...source.guild.features, ...features],
});
}
async function createTarget(
harness: ApiTestHarness,
name: string,
): Promise<{account: TestAccount; guild: GuildResponse}> {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, name);
return {account, guild};
}
describe('Guild expression clone opt-in', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness?.shutdown();
});
async function expectEmojiCloneRejected(source: CloneSource, label: string): Promise<void> {
const target = await createTarget(harness, `${label} Emoji Target`);
await createBuilder(harness, target.account.token)
.post(`/guilds/${target.guild.id}/emojis/clone`)
.body({source_emoji_id: source.emoji.id})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
.execute();
}
async function expectStickerCloneRejected(source: CloneSource, label: string): Promise<void> {
const target = await createTarget(harness, `${label} Sticker Target`);
await createBuilder(harness, target.account.token)
.post(`/guilds/${target.guild.id}/stickers/clone`)
.body({source_sticker_id: source.sticker.id})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
.execute();
}
async function expectEmojiCloneAllowed(source: CloneSource, label: string): Promise<void> {
const target = await createTarget(harness, `${label} Emoji Target`);
const cloned = await createBuilder<GuildEmojiWithUserResponse>(harness, target.account.token)
.post(`/guilds/${target.guild.id}/emojis/clone`)
.body({source_emoji_id: source.emoji.id})
.expect(HTTP_STATUS.OK)
.execute();
expect(cloned.id).not.toBe(source.emoji.id);
expect(cloned.name).toBe(source.emoji.name);
}
async function expectStickerCloneAllowed(source: CloneSource, label: string): Promise<void> {
const target = await createTarget(harness, `${label} Sticker Target`);
const cloned = await createBuilder<GuildStickerWithUserResponse>(harness, target.account.token)
.post(`/guilds/${target.guild.id}/stickers/clone`)
.body({source_sticker_id: source.sticker.id})
.expect(HTTP_STATUS.OK)
.execute();
expect(cloned.id).not.toBe(source.sticker.id);
expect(cloned.name).toBe(source.sticker.name);
}
test('rejects both emoji and sticker cloning when the source guild carries no clone features', async () => {
const source = await createSource(harness, 'No Clone Features Source');
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
await expectEmojiCloneRejected(source, 'No Features');
await expectStickerCloneRejected(source, 'No Features');
});
test('permits emoji cloning and still rejects sticker cloning with only CLONE_EMOJI_ENABLED', async () => {
const source = await createSource(harness, 'Emoji Opt In Source');
await optIn(harness, source, [GuildFeatures.CLONE_EMOJI_ENABLED]);
expect(source.guild.features).toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
await expectEmojiCloneAllowed(source, 'Emoji Opt In');
await expectStickerCloneRejected(source, 'Emoji Opt In');
});
test('permits sticker cloning and still rejects emoji cloning with only CLONE_STICKER_ENABLED', async () => {
const source = await createSource(harness, 'Sticker Opt In Source');
await optIn(harness, source, [GuildFeatures.CLONE_STICKER_ENABLED]);
expect(source.guild.features).toContain(GuildFeatures.CLONE_STICKER_ENABLED);
await expectStickerCloneAllowed(source, 'Sticker Opt In');
await expectEmojiCloneRejected(source, 'Sticker Opt In');
});
test('permits cloning when the deprecated disabled features sit alongside the enabled ones', async () => {
const source = await createSource(harness, 'Deprecated Plus Enabled Source');
await addDeprecatedFeatures(harness, source, [
GuildFeatures.CLONE_EMOJI_DISABLED,
GuildFeatures.CLONE_STICKER_DISABLED,
]);
await optIn(harness, source, [GuildFeatures.CLONE_EMOJI_ENABLED, GuildFeatures.CLONE_STICKER_ENABLED]);
expect(source.guild.features).toContain(GuildFeatures.CLONE_EMOJI_DISABLED);
expect(source.guild.features).toContain(GuildFeatures.CLONE_STICKER_DISABLED);
await expectEmojiCloneAllowed(source, 'Deprecated Plus Enabled');
await expectStickerCloneAllowed(source, 'Deprecated Plus Enabled');
});
test('rejects cloning when the source guild carries only the deprecated disabled features', async () => {
const source = await createSource(harness, 'Deprecated Only Source');
await addDeprecatedFeatures(harness, source, [
GuildFeatures.CLONE_EMOJI_DISABLED,
GuildFeatures.CLONE_STICKER_DISABLED,
]);
await expectEmojiCloneRejected(source, 'Deprecated Only');
await expectStickerCloneRejected(source, 'Deprecated Only');
});
test('reports allow_cloning false until the source guild opts in', async () => {
const source = await createSource(harness, 'Metadata Opt In Source');
const viewer = await createTestAccount(harness);
const emojiBefore = await createBuilder<GuildEmojiMetadataResponse>(harness, viewer.token)
.get(`/emojis/${source.emoji.id}/metadata`)
.execute();
const stickerBefore = await createBuilder<GuildStickerMetadataResponse>(harness, viewer.token)
.get(`/stickers/${source.sticker.id}/metadata`)
.execute();
expect(emojiBefore.allow_cloning).toBe(false);
expect(stickerBefore.allow_cloning).toBe(false);
await optIn(harness, source, [GuildFeatures.CLONE_EMOJI_ENABLED, GuildFeatures.CLONE_STICKER_ENABLED]);
const emojiAfter = await createBuilder<GuildEmojiMetadataResponse>(harness, viewer.token)
.get(`/emojis/${source.emoji.id}/metadata`)
.execute();
const stickerAfter = await createBuilder<GuildStickerMetadataResponse>(harness, viewer.token)
.get(`/stickers/${source.sticker.id}/metadata`)
.execute();
expect(emojiAfter.allow_cloning).toBe(true);
expect(stickerAfter.allow_cloning).toBe(true);
});
test('reports allow_cloning false for a guild carrying only the deprecated disabled features', async () => {
const source = await createSource(harness, 'Metadata Deprecated Source');
await addDeprecatedFeatures(harness, source, [
GuildFeatures.CLONE_EMOJI_DISABLED,
GuildFeatures.CLONE_STICKER_DISABLED,
]);
const viewer = await createTestAccount(harness);
const emoji = await createBuilder<GuildEmojiMetadataResponse>(harness, viewer.token)
.get(`/emojis/${source.emoji.id}/metadata`)
.execute();
const sticker = await createBuilder<GuildStickerMetadataResponse>(harness, viewer.token)
.get(`/stickers/${source.sticker.id}/metadata`)
.execute();
expect(emoji.allow_cloning).toBe(false);
expect(sticker.allow_cloning).toBe(false);
});
test('reports allow_cloning true when the deprecated disabled feature sits alongside the enabled one', async () => {
const source = await createSource(harness, 'Metadata Mixed Source');
await addDeprecatedFeatures(harness, source, [
GuildFeatures.CLONE_EMOJI_DISABLED,
GuildFeatures.CLONE_STICKER_DISABLED,
]);
await optIn(harness, source, [GuildFeatures.CLONE_EMOJI_ENABLED, GuildFeatures.CLONE_STICKER_ENABLED]);
const viewer = await createTestAccount(harness);
const emoji = await createBuilder<GuildEmojiMetadataResponse>(harness, viewer.token)
.get(`/emojis/${source.emoji.id}/metadata`)
.execute();
const sticker = await createBuilder<GuildStickerMetadataResponse>(harness, viewer.token)
.get(`/stickers/${source.sticker.id}/metadata`)
.execute();
expect(emoji.allow_cloning).toBe(true);
expect(sticker.allow_cloning).toBe(true);
});
test('stops permitting cloning once the source guild opts back out', async () => {
const source = await createSource(harness, 'Opt Out Again Source');
await optIn(harness, source, [GuildFeatures.CLONE_EMOJI_ENABLED, GuildFeatures.CLONE_STICKER_ENABLED]);
await expectEmojiCloneAllowed(source, 'Opt Out Again');
source.guild = await updateGuild(harness, source.owner.token, source.guild.id, {
features: source.guild.features.filter(
(feature) => feature !== GuildFeatures.CLONE_EMOJI_ENABLED && feature !== GuildFeatures.CLONE_STICKER_ENABLED,
),
});
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
await expectEmojiCloneRejected(source, 'Opt Out Again');
await expectStickerCloneRejected(source, 'Opt Out Again');
});
});
@@ -198,6 +198,41 @@ describe('Guild Features', () => {
expect(updatedGuild.features).toContain(GuildFeatures.BANNER);
expect(updatedGuild.features).toContain(GuildFeatures.INVITES_DISABLED);
});
test('should allow toggling CLONE_EMOJI_ENABLED and CLONE_STICKER_ENABLED features', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Clone Opt In Test');
const updatedGuild = await updateGuild(harness, account.token, guild.id, {
features: [...guild.features, GuildFeatures.CLONE_EMOJI_ENABLED, GuildFeatures.CLONE_STICKER_ENABLED],
});
expect(updatedGuild.features).toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(updatedGuild.features).toContain(GuildFeatures.CLONE_STICKER_ENABLED);
const optedOut = await updateGuild(harness, account.token, guild.id, {
features: updatedGuild.features.filter(
(feature: string) =>
feature !== GuildFeatures.CLONE_EMOJI_ENABLED && feature !== GuildFeatures.CLONE_STICKER_ENABLED,
),
});
expect(optedOut.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(optedOut.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
});
test('should reject toggling the deprecated CLONE_EMOJI_DISABLED feature', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Deprecated Clone Emoji Test');
await createBuilder(harness, account.token)
.patch(`/guilds/${guild.id}`)
.body({features: [...guild.features, GuildFeatures.CLONE_EMOJI_DISABLED]})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject toggling the deprecated CLONE_STICKER_DISABLED feature', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Deprecated Clone Sticker Test');
await createBuilder(harness, account.token)
.patch(`/guilds/${guild.id}`)
.body({features: [...guild.features, GuildFeatures.CLONE_STICKER_DISABLED]})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject toggling non-toggleable features', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Non Toggleable Feature Test');
+7 -3
View File
@@ -30941,7 +30941,7 @@
"additionalProperties": false
},
"GuildFeatureSchema": {
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)",
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_EMOJI_ENABLED, CLONE_STICKER_DISABLED, CLONE_STICKER_ENABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)",
"x-enumNames": [
"ANIMATED_ICON",
"ANIMATED_BANNER",
@@ -30950,7 +30950,9 @@
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
@@ -30982,8 +30984,10 @@
"Guild can set a voice channel bitrate of up to 256 kbps",
"Guild can set a voice channel bitrate of up to 384 kbps",
"Guild can have a banner",
"Guild has the in-app one-click emoji clone shortcut disabled for non-members",
"Guild has the in-app one-click sticker clone shortcut disabled for non-members",
"Deprecated and no longer enforced: emoji cloning is now opt-in through CLONE_EMOJI_ENABLED",
"Guild allows non-members to use the in-app one-click emoji clone shortcut",
"Deprecated and no longer enforced: sticker cloning is now opt-in through CLONE_STICKER_ENABLED",
"Guild allows non-members to use the in-app one-click sticker clone shortcut",
"Guild banner is detached from splash",
"Guild can have an invite splash",
"Guild has invites disabled",