mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(api)!: correct pagination and locking, drop toggle routes (#2505)
This commit is contained in:
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, {
|
||||
|
||||
Reference in new issue
Block a user