fix(api): SSO discovery, channel create and deleted bots (#3390)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 13:31:18 +02:00
1 parent edc913096a
commit 0967bf3136
17 files changed
+503 -151

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);
});
});
+6 -2
View File
@@ -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({
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);
+32
View File
@@ -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`: