fix(api)!: correct pagination and locking, drop toggle routes (#2505)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:31:13 +02:00
1 parent 632f4c7b6c
commit d07f520b13
10 files changed
+302 -626

No files matched your search

@@ -1,126 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {
DisabledToggleRequest,
EnabledToggleRequest,
GuildIdParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {createGuildID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
type ToggleMode = 'enabled' | 'disabled';
type GuildFeatureToggleConfig = {
path: string;
operationId: string;
summary: string;
description: string;
feature: string;
mode: ToggleMode;
};
const TOGGLES: ReadonlyArray<GuildFeatureToggleConfig> = [
{
path: '/guilds/:guild_id/text-channel-flexible-names',
operationId: 'toggle_text_channel_flexible_names',
summary: 'Toggle text channel flexible names',
description: 'Requires manage_guild permission. Allows or disables flexible naming for text channels.',
feature: GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES,
mode: 'enabled',
},
{
path: '/guilds/:guild_id/detached-banner',
operationId: 'toggle_detached_banner',
summary: 'Toggle detached banner',
description: 'Requires manage_guild permission. Enables or disables independent banner display configuration.',
feature: GuildFeatures.DETACHED_BANNER,
mode: 'enabled',
},
{
path: '/guilds/:guild_id/invites-disabled',
operationId: 'toggle_invites_disabled',
summary: 'Toggle invites disabled',
description: 'Requires manage_guild permission. Pauses or resumes invite-link joins for this guild.',
feature: GuildFeatures.INVITES_DISABLED,
mode: 'disabled',
},
{
path: '/guilds/:guild_id/clone-emoji-disabled',
operationId: 'toggle_clone_emoji_disabled',
summary: 'Toggle emoji cloning disabled',
description:
"Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's emojis. Note that this does not prevent users from saving and re-uploading the image manually.",
feature: GuildFeatures.CLONE_EMOJI_DISABLED,
mode: 'disabled',
},
{
path: '/guilds/:guild_id/hide-owner-crown',
operationId: 'toggle_hide_owner_crown',
summary: 'Toggle hide community owner crown',
description:
'Requires manage_guild permission. When enabled, the community owner crown icon is hidden across the UI for this guild.',
feature: GuildFeatures.HIDE_OWNER_CROWN,
mode: 'enabled',
},
{
path: '/guilds/:guild_id/clone-sticker-disabled',
operationId: 'toggle_clone_sticker_disabled',
summary: 'Toggle sticker cloning disabled',
description:
"Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's stickers. Note that this does not prevent users from saving and re-uploading the image manually.",
feature: GuildFeatures.CLONE_STICKER_DISABLED,
mode: 'disabled',
},
];
function registerGuildFeatureToggle(app: HonoApp, config: GuildFeatureToggleConfig) {
const bodySchema = config.mode === 'enabled' ? EnabledToggleRequest : DisabledToggleRequest;
app.patch(
config.path,
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
LoginRequired,
Validator('param', GuildIdParam),
Validator('json', bodySchema),
OpenAPI({
operationId: config.operationId,
summary: config.summary,
description: config.description,
responseSchema: GuildResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Guilds'],
}),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const body = ctx.req.valid('json');
const present =
config.mode === 'enabled' ? (body as EnabledToggleRequest).enabled : (body as DisabledToggleRequest).disabled;
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const guildService = ctx.get('guildService');
const currentFeatures = await guildService.getGuildFeaturesForToggle(guildId);
const features = new Set(currentFeatures);
if (present) {
features.add(config.feature);
} else {
features.delete(config.feature);
}
const data: GuildUpdateRequest = {features: Array.from(features)};
return ctx.json(await guildService.updateGuild({userId, guildId, data, requestCache}, auditLogReason));
},
);
}
export function GuildFeatureToggleController(app: HonoApp) {
for (const config of TOGGLES) {
registerGuildFeatureToggle(app, config);
}
}
@@ -6,7 +6,6 @@ import {GuildBaseController} from './GuildBaseController';
import {GuildChannelController} from './GuildChannelController';
import {GuildDiscoveryController} from './GuildDiscoveryController';
import {GuildEmojiController} from './GuildEmojiController';
import {GuildFeatureToggleController} from './GuildFeatureToggleController';
import {GuildMemberController} from './GuildMemberController';
import {GuildMemberSearchController} from './GuildMemberSearchController';
import {GuildRoleController} from './GuildRoleController';
@@ -14,7 +13,6 @@ import {GuildStickerController} from './GuildStickerController';
export function registerGuildControllers(app: HonoApp) {
GuildBaseController(app);
GuildFeatureToggleController(app);
GuildMemberController(app);
GuildMemberSearchController(app);
GuildRoleController(app);
@@ -7,6 +7,7 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLockedError';
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
@@ -17,6 +18,7 @@ import type {
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {ApiContext} from '../../ApiContext';
import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '../../BrandedTypes';
@@ -95,6 +97,10 @@ interface GuildAuth {
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
const GUILD_UPDATE_LOCK_TTL_SECONDS = 10;
const GUILD_UPDATE_LOCK_RETRY_DELAY_MS = 50;
const GUILD_UPDATE_LOCK_MAX_WAIT_MS = 5000;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -104,6 +110,7 @@ export class GuildService {
public readonly channels: GuildChannelService;
public readonly search: GuildSearchService;
private readonly guildRepository: IGuildRepositoryAggregate;
private readonly cacheService: ICacheService;
private readonly userCacheService: UserCacheService;
private readonly webhookRepository: IWebhookRepository;
private readonly guildAuditLogService: GuildAuditLogService;
@@ -135,6 +142,7 @@ export class GuildService {
} = apiContext.services;
this.gatewayService = gatewayService;
this.guildRepository = guildRepository;
this.cacheService = cacheService;
this.userCacheService = userCacheService;
this.webhookRepository = webhookRepository;
this.guildAuditLogService = guildAuditLogService;
@@ -212,12 +220,6 @@ export class GuildService {
);
}
async getGuildFeaturesForToggle(guildId: GuildID): Promise<Set<string>> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) throw new UnknownGuildError();
return new Set(guild.features);
}
async updateGuild(
params: {
userId: UserID;
@@ -228,7 +230,9 @@ export class GuildService {
auditLogReason?: string | null,
): Promise<GuildResponse> {
const {guildId, requestCache} = params;
const {guild, previousFeatures, updatedFeatures} = await this.data.updateGuild(params, auditLogReason);
const {guild, previousFeatures, updatedFeatures} = await this.withGuildUpdateLock(guildId, requestCache, () =>
this.data.updateGuild(params, auditLogReason),
);
if (
previousFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) &&
!updatedFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES)
@@ -238,6 +242,32 @@ export class GuildService {
return guild;
}
private async withGuildUpdateLock<T>(guildId: GuildID, requestCache: RequestCache, fn: () => Promise<T>): Promise<T> {
const lockKey = `guild:${guildId}:update`;
const lockToken = await this.acquireGuildUpdateLock(lockKey);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
requestCache.guilds.delete(guildId);
return await fn();
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
private async acquireGuildUpdateLock(lockKey: string): Promise<string | null> {
const startTime = Date.now();
while (Date.now() - startTime < GUILD_UPDATE_LOCK_MAX_WAIT_MS) {
const token = await this.cacheService.acquireLock(lockKey, GUILD_UPDATE_LOCK_TTL_SECONDS);
if (token) {
return token;
}
await new Promise((resolve) => setTimeout(resolve, GUILD_UPDATE_LOCK_RETRY_DELAY_MS));
}
return null;
}
async getEmojiMetadata(emojiId: EmojiID): Promise<GuildEmojiMetadataResponse> {
const emoji = await this.guildRepository.getEmojiById(emojiId);
if (!emoji) throw new UnknownGuildEmojiError();
@@ -318,10 +348,7 @@ export class GuildService {
let processedLogs: Array<GuildAuditLog> = [];
let currentBeforeLogId = beforeLogId;
let currentAfterLogId = afterLogId;
const maxIterations = 5;
let iterations = 0;
while (processedLogs.length < effectiveLimit && iterations < maxIterations) {
iterations++;
while (processedLogs.length < effectiveLimit) {
const fetchLimit = Math.min(effectiveLimit * 2, 200);
const logs = await this.guildRepository.listAuditLogs({
guildId,
@@ -5,6 +5,7 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {authorizeBot, createTestBotAccount} from '../../bot/tests/BotTestUtils';
import {deleteMessage, sendMessage} from '../../message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
@@ -218,6 +219,27 @@ describe('Guild audit log endpoint', () => {
expect(userIds).toContain(owner.userId);
expect(userIds).toContain(member.userId);
});
test('fills a page from behind a long run of batched message deletes', async () => {
const {owner, guild, channels} = await setupTestGuildWithMembers(harness, 0);
const channel = channels[0];
await createRole(harness, owner.token, guild.id, {
name: 'Older Role',
permissions: Permissions.VIEW_CHANNEL.toString(),
});
for (let index = 0; index < 60; index++) {
const message = await sendMessage(harness, owner.token, channel.id, `delete me ${index}`);
await deleteMessage(harness, owner.token, channel.id, message.id);
}
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/audit-logs?limit=6`)
.expect(HTTP_STATUS.OK)
.execute();
expect(response.audit_log_entries).toHaveLength(6);
expect(response.audit_log_entries.slice(0, 5).map((entry) => entry.action_type)).toEqual(
Array.from({length: 5}, () => AuditLogActionType.MESSAGE_BULK_DELETE),
);
expect(response.audit_log_entries[5]?.action_type).toBe(AuditLogActionType.ROLE_CREATE);
});
test('rejects specifying before and after together', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
await createRole(harness, owner.token, guild.id, {