mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(api): SSO discovery, channel create and deleted bots (#3390)
This commit is contained in:
1 parent
edc913096a
commit
0967bf3136
17 files changed
+508
-156
No files matched your search
@@ -112,9 +112,9 @@ fn render_user_detail(
|
||||
@if let Some(banner) = banner_url {
|
||||
div class="mb-4 space-y-2" {
|
||||
a href=(banner) target="_blank" rel="noreferrer noopener"
|
||||
class="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
class="block max-w-2xl rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
img src=(banner) alt={(user.username) "'s banner"}
|
||||
class="h-32 w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover sm:h-48"
|
||||
class="aspect-[17/6] w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover"
|
||||
loading="lazy";
|
||||
}
|
||||
@if let Some(hash) = &user.banner {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {tryDiscoverOidcProviderMetadata} from '@app/api/auth/services/SsoUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {resetSsoRequestUrlPolicyForTesting} from '@app/api/instance/SsoConfigValidation';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const PRIVATE_ISSUER = 'http://10.20.30.40:5556/dex';
|
||||
|
||||
describe('tryDiscoverOidcProviderMetadata', () => {
|
||||
const previousAllowPrivateAddresses = Config.auth.ssoAllowPrivateAddresses;
|
||||
let discoveryRequests = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
discoveryRequests = 0;
|
||||
server.use(
|
||||
http.get(`${PRIVATE_ISSUER}/.well-known/openid-configuration`, () => {
|
||||
discoveryRequests++;
|
||||
return HttpResponse.json({
|
||||
issuer: PRIVATE_ISSUER,
|
||||
authorization_endpoint: `${PRIVATE_ISSUER}/auth`,
|
||||
token_endpoint: `${PRIVATE_ISSUER}/token`,
|
||||
userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`,
|
||||
jwks_uri: `${PRIVATE_ISSUER}/keys`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.auth.ssoAllowPrivateAddresses = previousAllowPrivateAddresses;
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
});
|
||||
|
||||
it('discovers endpoints on a private issuer when private addresses are allowed', async () => {
|
||||
Config.auth.ssoAllowPrivateAddresses = true;
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toEqual({
|
||||
issuer: PRIVATE_ISSUER,
|
||||
authorization_endpoint: `${PRIVATE_ISSUER}/auth`,
|
||||
token_endpoint: `${PRIVATE_ISSUER}/token`,
|
||||
userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`,
|
||||
jwks_uri: `${PRIVATE_ISSUER}/keys`,
|
||||
});
|
||||
expect(discoveryRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses a private issuer when private addresses are not allowed', async () => {
|
||||
Config.auth.ssoAllowPrivateAddresses = false;
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toBeNull();
|
||||
expect(discoveryRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getSsoRequestUrlPolicy} from '@app/api/instance/SsoConfigValidation';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
@@ -68,13 +69,16 @@ function normalizeIssuerForCompare(value: string): string {
|
||||
export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<DiscoveredOidcProviderMetadata | null> {
|
||||
try {
|
||||
const url = buildDiscoveryUrl(issuer);
|
||||
const resp = await FetchUtils.sendRequest({
|
||||
url: url.toString(),
|
||||
method: 'GET',
|
||||
headers: {Accept: 'application/json'},
|
||||
timeout: ms('10 seconds'),
|
||||
serviceName: 'sso_oidc_discovery',
|
||||
});
|
||||
const resp = await FetchUtils.sendRequest(
|
||||
{
|
||||
url: url.toString(),
|
||||
method: 'GET',
|
||||
headers: {Accept: 'application/json'},
|
||||
timeout: ms('10 seconds'),
|
||||
serviceName: 'sso_oidc_discovery',
|
||||
},
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
FetchUtils.discardResponseBody(resp.stream, resp.status);
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createWebhookID} from '@app/api/BrandedTypes';
|
||||
import {resetApiServicesForTesting} from '@app/api/CreateApiContext';
|
||||
import {
|
||||
@@ -36,11 +36,8 @@ import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies';
|
||||
import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import type {FollowedChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
@@ -212,30 +209,6 @@ export async function setGuildFeatures(
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function setLimitOverride(
|
||||
harness: ApiTestHarness,
|
||||
limits: Partial<Record<LimitKey, number>>,
|
||||
): Promise<() => Promise<void>> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||
]);
|
||||
const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.execute();
|
||||
const writeConfig = async (rules: LimitConfigSnapshot['rules']) => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/limit-config')
|
||||
.body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}})
|
||||
.execute();
|
||||
};
|
||||
await writeConfig([...current.limit_config.rules, {id: 'announcement_test_override', limits}]);
|
||||
return async () => {
|
||||
await writeConfig(current.limit_config.rules);
|
||||
};
|
||||
}
|
||||
|
||||
const crosspostTaskHandlers: Record<string, WorkerTaskHandler> = {
|
||||
crosspostMessage,
|
||||
crosspostMessageChunk,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
addMemberRole,
|
||||
createChannel,
|
||||
createPermissionOverwrite,
|
||||
createRole,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {setLimitOverride} from '@app/api/test/LimitTestUtils';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('Channel create in a category', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function setupCategoryManager(extraAllow = 0n) {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'managed', ChannelTypes.GUILD_CATEGORY);
|
||||
const otherCategory = await createChannel(harness, owner.token, guild.id, 'other', ChannelTypes.GUILD_CATEGORY);
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'creators'});
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
await createPermissionOverwrite(harness, owner.token, category.id, role.id, {
|
||||
type: 0,
|
||||
allow: (Permissions.VIEW_CHANNEL | Permissions.MANAGE_CHANNELS | extraAllow).toString(),
|
||||
deny: '0',
|
||||
});
|
||||
return {owner, member, guild, role, category, otherCategory};
|
||||
}
|
||||
|
||||
it('lets a category overwrite grant MANAGE_CHANNELS for that category only', async () => {
|
||||
const {member, guild, category, otherCategory} = await setupCategoryManager();
|
||||
const created = await createBuilder<ChannelResponse>(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'member-made', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.execute();
|
||||
expect(created.parent_id).toBe(category.id);
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'root-made', type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'elsewhere', type: ChannelTypes.GUILD_TEXT, parent_id: otherCategory.id})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('honors a category deny of MANAGE_CHANNELS for a guild-wide manager', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'locked', ChannelTypes.GUILD_CATEGORY);
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'managers',
|
||||
permissions: Permissions.MANAGE_CHANNELS.toString(),
|
||||
});
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
await createPermissionOverwrite(harness, owner.token, category.id, role.id, {
|
||||
type: 0,
|
||||
allow: '0',
|
||||
deny: Permissions.MANAGE_CHANNELS.toString(),
|
||||
});
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'denied', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('lets a category overwrite grant MANAGE_ROLES for requested overwrites within its own permissions', async () => {
|
||||
const {member, guild, role, category} = await setupCategoryManager(Permissions.MANAGE_ROLES);
|
||||
await createBuilder<ChannelResponse>(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({
|
||||
name: 'with-overwrites',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
parent_id: category.id,
|
||||
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}],
|
||||
})
|
||||
.execute();
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({
|
||||
name: 'escalate',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
parent_id: category.id,
|
||||
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.ADMINISTRATOR.toString(), deny: '0'}],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects requested overwrites without MANAGE_ROLES on the category', async () => {
|
||||
const {member, guild, role, category} = await setupCategoryManager();
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({
|
||||
name: 'no-roles',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
parent_id: category.id,
|
||||
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('applies a configured max_channels_per_category on create', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'full', ChannelTypes.GUILD_CATEGORY);
|
||||
const restore = await setLimitOverride(harness, {max_channels_per_category: 0});
|
||||
try {
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'overflow', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS)
|
||||
.execute();
|
||||
await createChannel(harness, owner.token, guild.id, 'at-root');
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a configured max_guild_channels on create', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const restore = await setLimitOverride(harness, {max_guild_channels: 0});
|
||||
try {
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'overflow', type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS)
|
||||
.execute();
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a raised max_channels_per_category admit a channel past the default', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'busy', ChannelTypes.GUILD_CATEGORY);
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50);
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS)
|
||||
.execute();
|
||||
const restore = await setLimitOverride(harness, {max_channels_per_category: 500});
|
||||
try {
|
||||
const created = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
|
||||
.execute();
|
||||
expect(created.parent_id).toBe(category.id);
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a raised max_guild_channels admit a channel past the default', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getChannelCount').mockResolvedValue(500);
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'one-more', type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS)
|
||||
.execute();
|
||||
const restore = await setLimitOverride(harness, {max_guild_channels: 1000});
|
||||
try {
|
||||
await createChannel(harness, owner.token, guild.id, 'one-more');
|
||||
} finally {
|
||||
await restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a configured max_channels_per_category when a channel is moved into a category', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'target', ChannelTypes.GUILD_CATEGORY);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'mover');
|
||||
const move = () =>
|
||||
createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: channel.id, parent_id: category.id}]);
|
||||
const restoreLowered = await setLimitOverride(harness, {max_channels_per_category: 0});
|
||||
try {
|
||||
await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute();
|
||||
} finally {
|
||||
await restoreLowered();
|
||||
}
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50);
|
||||
await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute();
|
||||
const restoreRaised = await setLimitOverride(harness, {max_channels_per_category: 500});
|
||||
try {
|
||||
await move().expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
} finally {
|
||||
await restoreRaised();
|
||||
}
|
||||
const moved = await createBuilder<ChannelResponse>(harness, owner.token).get(`/channels/${channel.id}`).execute();
|
||||
expect(moved.parent_id).toBe(category.id);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {type ChannelID, createChannelID, type GuildID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings';
|
||||
@@ -112,6 +112,7 @@ export class GuildChannelService {
|
||||
userId: params.userId,
|
||||
guildId: params.guildId,
|
||||
permission: Permissions.MANAGE_CHANNELS,
|
||||
channelId: params.data.parent_id ? createChannelID(params.data.parent_id) : undefined,
|
||||
});
|
||||
const response = await this.channelOps.createChannel(params, auditLogReason);
|
||||
const [masked] = await maskChannelResponseThreadBits(params.guildId, params.viewer, [response]);
|
||||
@@ -155,11 +156,17 @@ export class GuildChannelService {
|
||||
await this.channelOps.sanitizeTextChannelNames(params);
|
||||
}
|
||||
|
||||
private async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}): Promise<void> {
|
||||
private async checkPermission(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
permission: bigint;
|
||||
channelId?: ChannelID;
|
||||
}): Promise<void> {
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
permission: params.permission,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||
|
||||
@@ -104,6 +104,7 @@ export class ChannelOperationsService {
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
channelId: parentId ?? undefined,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
const basePermissions = await this.gatewayService.getUserPermissions({
|
||||
@@ -736,6 +737,7 @@ export class ChannelOperationsService {
|
||||
ctx,
|
||||
'max_channels_per_category',
|
||||
maxChannels,
|
||||
'guild',
|
||||
);
|
||||
if (count >= maxChannels) {
|
||||
throw new MaxCategoryChannelsError(maxChannels);
|
||||
@@ -747,7 +749,13 @@ export class ChannelOperationsService {
|
||||
let maxChannels = MAX_GUILD_CHANNELS;
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
|
||||
maxChannels = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guild_channels', maxChannels);
|
||||
maxChannels = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_guild_channels',
|
||||
maxChannels,
|
||||
'guild',
|
||||
);
|
||||
if (count >= maxChannels) {
|
||||
throw new MaxGuildChannelsError(maxChannels);
|
||||
}
|
||||
|
||||
@@ -527,7 +527,6 @@ class RequestServices implements RequestScopedServices {
|
||||
get applicationService(): ApplicationService {
|
||||
this.cachedApplicationService ??= new ApplicationService(this.context, {
|
||||
applicationRepository: getApplicationRepository(),
|
||||
channelRepository: getChannelRepository(),
|
||||
userCacheService: getUserCacheService(),
|
||||
entityAssetService: getEntityAssetService(),
|
||||
discriminatorService: getDiscriminatorService(),
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import {MessageAnonymizationService} from '@app/api/channel/services/message/MessageAnonymizationService';
|
||||
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {
|
||||
DELETED_USER_DISCRIMINATOR,
|
||||
DELETED_USER_GLOBAL_NAME,
|
||||
DELETED_USER_USERNAME,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
interface RemapAuthorMessagesToDeletedUserParams {
|
||||
originalAuthorId: UserID;
|
||||
channelRepository: ChannelRepository;
|
||||
userRepository: UserRepository;
|
||||
snowflakeService: ISnowflakeService;
|
||||
}
|
||||
|
||||
async function createDeletedMessageAuthorUser(params: {
|
||||
userRepository: UserRepository;
|
||||
snowflakeService: ISnowflakeService;
|
||||
}): Promise<UserID> {
|
||||
const deletedUserId = createUserID(await params.snowflakeService.generate());
|
||||
await params.userRepository.create({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: deletedUserId,
|
||||
username: DELETED_USER_USERNAME,
|
||||
discriminator: DELETED_USER_DISCRIMINATOR,
|
||||
global_name: DELETED_USER_GLOBAL_NAME,
|
||||
bot: false,
|
||||
system: false,
|
||||
flags: UserFlags.DELETED,
|
||||
});
|
||||
await params.userRepository.deleteUserSecondaryIndices(deletedUserId);
|
||||
return deletedUserId;
|
||||
}
|
||||
|
||||
export async function remapAuthorMessagesToDeletedUser(
|
||||
params: RemapAuthorMessagesToDeletedUserParams,
|
||||
): Promise<UserID | null> {
|
||||
const {originalAuthorId, channelRepository, userRepository, snowflakeService} = params;
|
||||
const hasMessages = await channelRepository.messages.listMessagesByAuthor(originalAuthorId, 1);
|
||||
if (hasMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const replacementAuthorId = await createDeletedMessageAuthorUser({
|
||||
userRepository,
|
||||
snowflakeService,
|
||||
});
|
||||
const anonymizationService = new MessageAnonymizationService(channelRepository);
|
||||
await anonymizationService.anonymizeMessagesByAuthor(originalAuthorId, replacementAuthorId);
|
||||
Logger.info(
|
||||
{originalAuthorId: originalAuthorId.toString(), replacementAuthorId: replacementAuthorId.toString()},
|
||||
'Remapped authored messages to deleted user id',
|
||||
);
|
||||
return replacementAuthorId;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
|
||||
import {applicationIdToUserId} from '@app/api/BrandedTypes';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import type {ApplicationRow} from '@app/api/database/types/OAuth2Types';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
||||
@@ -14,7 +13,6 @@ import {Logger} from '@app/api/Logger';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import type {Application} from '@app/api/models/Application';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
|
||||
import type {BotAuthService} from '@app/api/oauth/BotAuthService';
|
||||
import {generateOAuthTokenSecret} from '@app/api/oauth/OAuthTokenSecret';
|
||||
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
|
||||
@@ -43,7 +41,6 @@ import type {BotProfileUpdateRequest} from '@fluxer/schema/src/domains/oauth/OAu
|
||||
|
||||
interface ApplicationServiceDeps {
|
||||
discriminatorService: DiscriminatorService;
|
||||
channelRepository: ChannelRepository;
|
||||
applicationRepository: ApplicationRepository;
|
||||
botAuthService: BotAuthService;
|
||||
entityAssetService: EntityAssetService;
|
||||
@@ -307,12 +304,6 @@ export class ApplicationService {
|
||||
const application = await this.verifyOwnership(userId, applicationId);
|
||||
if (application.hasBotUser()) {
|
||||
const botUserId = application.getBotUserId()!;
|
||||
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
|
||||
originalAuthorId: botUserId,
|
||||
channelRepository: this.deps.channelRepository,
|
||||
userRepository: this.apiContext.services.users,
|
||||
snowflakeService: this.apiContext.services.snowflake,
|
||||
});
|
||||
const guildIds = await this.apiContext.services.users.getUserGuildIds(botUserId);
|
||||
await this.apiContext.services.users.deleteUserSecondaryIndices(botUserId);
|
||||
await this.apiContext.services.users.removeFromAllGuilds(botUserId);
|
||||
@@ -372,14 +363,10 @@ export class ApplicationService {
|
||||
botUser.toRow(),
|
||||
);
|
||||
await this.deps.userCacheService.invalidateUserCache(botUserId);
|
||||
if (replacementAuthorId) {
|
||||
await this.deps.userCacheService.invalidateUserCache(replacementAuthorId);
|
||||
}
|
||||
Logger.info(
|
||||
{
|
||||
applicationId: applicationId.toString(),
|
||||
botUserId: botUserId.toString(),
|
||||
replacementAuthorId: replacementAuthorId?.toString() ?? null,
|
||||
},
|
||||
'Anonymized bot user associated with application',
|
||||
);
|
||||
|
||||
@@ -94,25 +94,17 @@ describe('OAuth2 Application Delete', () => {
|
||||
.execute();
|
||||
const foundMessage = messages.find((message) => message.id === botMessageId);
|
||||
expect(foundMessage).toBeDefined();
|
||||
expect(foundMessage?.author.id).not.toBe(createResult.botUserId);
|
||||
expect(foundMessage?.author.id).toBe(createResult.botUserId);
|
||||
expect(foundMessage?.author.bot).toBe(true);
|
||||
expect(foundMessage?.author.username).toBe('DeletedUser');
|
||||
expect(foundMessage?.author.discriminator).toBe('0000');
|
||||
const previousAuthorCount = await createBuilderWithoutAuth<{
|
||||
const authorCount = await createBuilderWithoutAuth<{
|
||||
count: number;
|
||||
}>(harness)
|
||||
.get(`/test/users/${createResult.botUserId}/messages/count`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(previousAuthorCount.count).toBe(0);
|
||||
const replacementAuthorId = foundMessage?.author.id;
|
||||
expect(replacementAuthorId).toBeTruthy();
|
||||
const replacementAuthorCount = await createBuilderWithoutAuth<{
|
||||
count: number;
|
||||
}>(harness)
|
||||
.get(`/test/users/${replacementAuthorId}/messages/count`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(replacementAuthorCount.count).toBe(1);
|
||||
expect(authorCount.count).toBe(1);
|
||||
});
|
||||
test('enforces access control', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
|
||||
export async function setLimitOverride(
|
||||
harness: ApiTestHarness,
|
||||
limits: Partial<Record<LimitKey, number>>,
|
||||
): Promise<() => Promise<void>> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||
]);
|
||||
const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.execute();
|
||||
const writeConfig = async (rules: LimitConfigSnapshot['rules']) => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/limit-config')
|
||||
.body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}})
|
||||
.execute();
|
||||
};
|
||||
await writeConfig([...current.limit_config.rules, {id: 'test_limit_override', limits}]);
|
||||
return async () => {
|
||||
await writeConfig(current.limit_config.rules);
|
||||
};
|
||||
}
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
follow,
|
||||
followRequest,
|
||||
setGuildFeatures,
|
||||
setLimitOverride,
|
||||
} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||
import {createGuild, createPermissionOverwrite, updateGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {setLimitOverride} from '@app/api/test/LimitTestUtils';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
|
||||
@@ -4,7 +4,6 @@ import {applicationIdToUserId, createApplicationID, type GuildID} from '@app/api
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
|
||||
import {chunkArray} from '@app/api/utils/ArrayUtils';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {
|
||||
@@ -25,15 +24,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
||||
helpers.logger.debug({payload: validated}, 'Processing applicationProcessDeletion task');
|
||||
const applicationId = createApplicationID(BigInt(validated.applicationId));
|
||||
const botUserId = applicationIdToUserId(applicationId);
|
||||
const {
|
||||
userRepository,
|
||||
guildRepository,
|
||||
channelRepository,
|
||||
applicationRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
snowflakeService,
|
||||
} = getWorkerDependencies();
|
||||
const {userRepository, guildRepository, applicationRepository, userCacheService, gatewayService} =
|
||||
getWorkerDependencies();
|
||||
Logger.debug({applicationId, botUserId}, 'Starting application deletion');
|
||||
try {
|
||||
const application = await applicationRepository.getApplication(applicationId);
|
||||
@@ -42,21 +34,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
||||
return;
|
||||
}
|
||||
const botUser = await userRepository.findUniqueAssert(botUserId);
|
||||
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
|
||||
originalAuthorId: botUserId,
|
||||
channelRepository,
|
||||
userRepository,
|
||||
snowflakeService,
|
||||
});
|
||||
if (botUser.flags & UserFlags.DELETED) {
|
||||
Logger.info(
|
||||
{
|
||||
applicationId,
|
||||
botUserId,
|
||||
replacementAuthorId: replacementAuthorId?.toString() ?? null,
|
||||
},
|
||||
'Bot user already marked as deleted, skipping profile update',
|
||||
);
|
||||
Logger.info({applicationId, botUserId}, 'Bot user already marked as deleted, skipping profile update');
|
||||
await applicationRepository.deleteApplication(applicationId);
|
||||
return;
|
||||
}
|
||||
@@ -66,6 +45,11 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
||||
username: DELETED_USER_USERNAME,
|
||||
global_name: DELETED_USER_GLOBAL_NAME,
|
||||
discriminator: DELETED_USER_DISCRIMINATOR,
|
||||
avatar_hash: null,
|
||||
banner_hash: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
},
|
||||
botUser.toRow(),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createApplicationID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {getGatewayService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
createUserCacheService,
|
||||
getApplicationRepository,
|
||||
getGuildRepository,
|
||||
getUserRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {createOAuth2Application, createUniqueApplicationName} from '@app/api/oauth/tests/OAuth2TestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import applicationProcessDeletion from '@app/api/worker/tasks/ApplicationProcessDeletion';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 7200000000000000000n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('applicationProcessDeletion task', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
setWorkerDependenciesForTest({
|
||||
userRepository: getUserRepository(),
|
||||
guildRepository: getGuildRepository(),
|
||||
applicationRepository: getApplicationRepository(),
|
||||
userCacheService: createUserCacheService(),
|
||||
gatewayService: getGatewayService(),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
clearWorkerDependencies();
|
||||
});
|
||||
|
||||
test('keeps the bot as author of its messages and clears its profile', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const createResult = await createOAuth2Application(harness, account.token, {
|
||||
name: createUniqueApplicationName(),
|
||||
});
|
||||
const botUserId = createUserID(BigInt(createResult.botUserId));
|
||||
const userRepository = getUserRepository();
|
||||
const botUser = await userRepository.findUniqueAssert(botUserId);
|
||||
await userRepository.patchUpsert(
|
||||
botUserId,
|
||||
{
|
||||
avatar_hash: 'a'.repeat(32),
|
||||
banner_hash: 'b'.repeat(32),
|
||||
bio: 'bot bio',
|
||||
pronouns: 'it/its',
|
||||
accent_color: 0x4641d9,
|
||||
},
|
||||
botUser.toRow(),
|
||||
);
|
||||
const guild = await createBuilder<{
|
||||
id: string;
|
||||
system_channel_id?: string;
|
||||
}>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({name: `Bot Message Retention ${Date.now()}`})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const channelId = guild.system_channel_id;
|
||||
if (!channelId) {
|
||||
throw new Error('Guild response missing system channel');
|
||||
}
|
||||
const seeded = await createBuilder<{
|
||||
messages: Array<{
|
||||
message_id: string;
|
||||
}>;
|
||||
}>(harness, '')
|
||||
.post('/test/messages/seed')
|
||||
.body({
|
||||
channel_id: channelId,
|
||||
author_id: createResult.botUserId,
|
||||
clear_existing: true,
|
||||
messages: [{content: 'bot message before deletion'}],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const botMessageId = seeded.messages[0]?.message_id;
|
||||
if (!botMessageId) {
|
||||
throw new Error('Seeded message response missing message id');
|
||||
}
|
||||
|
||||
await applicationProcessDeletion({applicationId: createResult.application.id}, createHelpers());
|
||||
|
||||
expect(
|
||||
await getApplicationRepository().getApplication(createApplicationID(BigInt(createResult.application.id))),
|
||||
).toBeNull();
|
||||
const deletedBot = await userRepository.findUniqueAssert(botUserId);
|
||||
expect(deletedBot.isBot).toBe(true);
|
||||
expect(deletedBot.flags & UserFlags.DELETED).toBe(UserFlags.DELETED);
|
||||
expect(deletedBot.username).toBe('DeletedUser');
|
||||
expect(deletedBot.avatarHash).toBeNull();
|
||||
expect(deletedBot.bannerHash).toBeNull();
|
||||
expect(deletedBot.bio).toBeNull();
|
||||
expect(deletedBot.pronouns).toBeNull();
|
||||
expect(deletedBot.accentColor).toBeNull();
|
||||
const messages = await createBuilder<Array<MessageResponse>>(harness, account.token)
|
||||
.get(`/channels/${channelId}/messages`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const foundMessage = messages.find((message) => message.id === botMessageId);
|
||||
expect(foundMessage?.author.id).toBe(createResult.botUserId);
|
||||
expect(foundMessage?.author.bot).toBe(true);
|
||||
expect(foundMessage?.author.username).toBe('DeletedUser');
|
||||
expect(foundMessage?.author.discriminator).toBe('0000');
|
||||
expect(foundMessage?.author.avatar).toBeNull();
|
||||
const authorCount = await createBuilderWithoutAuth<{
|
||||
count: number;
|
||||
}>(harness)
|
||||
.get(`/test/users/${createResult.botUserId}/messages/count`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(authorCount.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -137,11 +137,12 @@ A guild holds at most the instance-configured `max_guild_channels` [limit](/http
|
||||
|
||||
<RouteHeader method="POST" path="/v1/guilds/{guild_id}/channels" bot auditReason mfa />
|
||||
|
||||
Creates a guild channel and returns its [channel object](/http-api/channels/#channel-object) with status 200. Requires [MANAGE_CHANNELS](/http-api/permissions/) at guild level. Emits a [Channel Create](/gateway/events/#channel-create) Gateway event.
|
||||
Creates a guild channel and returns its [channel object](/http-api/channels/#channel-object) with status 200. Requires [MANAGE_CHANNELS](/http-api/permissions/) in the parent category when `parent_id` is supplied, and at guild level otherwise. Emits a [Channel Create](/gateway/events/#channel-create) Gateway event.
|
||||
|
||||
### Limitations
|
||||
|
||||
- Supplying `permission_overwrites` also requires [MANAGE_ROLES](/http-api/permissions/) at guild level.
|
||||
- Supplying `permission_overwrites` also requires [MANAGE_ROLES](/http-api/permissions/) in the same context.
|
||||
- Both permissions resolve through the parent category's overwrites. A category overwrite that allows one grants it for that category only, and a category overwrite that denies one blocks a caller who holds it at guild level.
|
||||
|
||||
`MANAGE_CHANNELS` is an [elevated permission](/http-api/permissions/#elevated-permissions). It also requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner.
|
||||
|
||||
@@ -216,7 +217,7 @@ Only the allowed mask is compared, so a supplied deny is stored without any auth
|
||||
| --- | --- | --- |
|
||||
| 200 | [channel](/http-api/channels/#channel-object) object | Channel was created |
|
||||
| 400<sup>1</sup><sup>2</sup> | [error response](/http-api/#error-response) | Body, type, name, parent, URL, voice setting, or content warning text is invalid, a channel limit is reached, or the caller has no enrolled authenticator in an elevated-MFA guild |
|
||||
| 403 | [error response](/http-api/#error-response) | Caller lacks `MANAGE_CHANNELS`, is not a member of the guild, supplied overwrites without `MANAGE_ROLES`, or allowed a permission they do not hold in the parent context, each returning `MISSING_PERMISSIONS` |
|
||||
| 403 | [error response](/http-api/#error-response) | Caller is not a member of the guild, lacks `MANAGE_CHANNELS` in the parent category or in the guild when no parent is supplied, supplied overwrites without `MANAGE_ROLES` in that context, or allowed a permission they do not hold there, each returning `MISSING_PERMISSIONS` |
|
||||
| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
|
||||
|
||||
<sup>1</sup> A schema or parent failure returns `INVALID_FORM_BODY` and names the field with `CATEGORIES_CANNOT_HAVE_PARENTS`, `INVALID_PARENT_CHANNEL`, or `PARENT_MUST_BE_CATEGORY`. A capacity failure returns the top-level `MAX_GUILD_CHANNELS` or `MAX_CATEGORY_CHANNELS`
|
||||
|
||||
@@ -58,6 +58,22 @@ Forward all paths unchanged to the edge. No internal routing changes are needed.
|
||||
|
||||
With `FLUXER_PUBLIC_SCHEME=https` the admin CSRF cookie is `__Host-csrf_token`, which browsers accept only over HTTPS.
|
||||
|
||||
#### Keep TLS 1.2 enabled
|
||||
|
||||
The Android and iOS apps play video through a media library whose TLS stack offers only TLS 1.2. The rest of each app uses the system TLS stack. Behind a proxy that accepts only TLS 1.3 the apps sign in and load messages and images, but every video fails with "Could not play this video."
|
||||
|
||||
Accept TLS 1.2 alongside TLS 1.3. TLS 1.2 can stay limited to ECDHE key exchange with AES-GCM or ChaCha20-Poly1305, which the apps support.
|
||||
|
||||
| Proxy | Setting |
|
||||
| --- | --- |
|
||||
| nginx | `ssl_protocols TLSv1.2 TLSv1.3;` |
|
||||
| Caddy | `protocols tls1.2 tls1.3` |
|
||||
| Traefik | `minVersion: VersionTLS12` |
|
||||
| HAProxy | `ssl-min-ver TLSv1.2` |
|
||||
| Apache httpd | `SSLProtocol -all +TLSv1.2 +TLSv1.3` |
|
||||
|
||||
Caddy and Traefik accept both versions by default, so their rows apply only where a `tls` block or a TLS option already sets a floor. The HAProxy keyword goes on the `bind` line. On Cloudflare, keep Minimum TLS Version at 1.2 or lower. The bundled edge accepts both versions.
|
||||
|
||||
#### Forward WebSocket upgrades
|
||||
|
||||
The client connection is on `/gateway` and voice signalling is on `/livekit/*`. Both open as WebSocket upgrades. Check that the handshake completes through whatever is in front of the instance, with your own hostname in place of `chat.example.com`:
|
||||
|
||||
Reference in new issue
Block a user