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
+503
-151
No files matched your search
@@ -112,9 +112,9 @@ fn render_user_detail(
|
|||||||
@if let Some(banner) = banner_url {
|
@if let Some(banner) = banner_url {
|
||||||
div class="mb-4 space-y-2" {
|
div class="mb-4 space-y-2" {
|
||||||
a href=(banner) target="_blank" rel="noreferrer noopener"
|
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"}
|
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";
|
loading="lazy";
|
||||||
}
|
}
|
||||||
@if let Some(hash) = &user.banner {
|
@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
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {getSsoRequestUrlPolicy} from '@app/api/instance/SsoConfigValidation';
|
||||||
import {Logger} from '@app/api/Logger';
|
import {Logger} from '@app/api/Logger';
|
||||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
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> {
|
export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<DiscoveredOidcProviderMetadata | null> {
|
||||||
try {
|
try {
|
||||||
const url = buildDiscoveryUrl(issuer);
|
const url = buildDiscoveryUrl(issuer);
|
||||||
const resp = await FetchUtils.sendRequest({
|
const resp = await FetchUtils.sendRequest(
|
||||||
|
{
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {Accept: 'application/json'},
|
headers: {Accept: 'application/json'},
|
||||||
timeout: ms('10 seconds'),
|
timeout: ms('10 seconds'),
|
||||||
serviceName: 'sso_oidc_discovery',
|
serviceName: 'sso_oidc_discovery',
|
||||||
});
|
},
|
||||||
|
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||||
|
);
|
||||||
if (resp.status < 200 || resp.status >= 300) {
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
FetchUtils.discardResponseBody(resp.stream, resp.status);
|
FetchUtils.discardResponseBody(resp.stream, resp.status);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {createWebhookID} from '@app/api/BrandedTypes';
|
||||||
import {resetApiServicesForTesting} from '@app/api/CreateApiContext';
|
import {resetApiServicesForTesting} from '@app/api/CreateApiContext';
|
||||||
import {
|
import {
|
||||||
@@ -36,11 +36,8 @@ import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies';
|
|||||||
import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage';
|
import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage';
|
||||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||||
import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
||||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
|
||||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||||
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
|
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 {FollowedChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
|
||||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||||
@@ -212,30 +209,6 @@ export async function setGuildFeatures(
|
|||||||
.execute();
|
.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> = {
|
const crosspostTaskHandlers: Record<string, WorkerTaskHandler> = {
|
||||||
crosspostMessage,
|
crosspostMessage,
|
||||||
crosspostMessageChunk,
|
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
|
// 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 {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
|
||||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||||
import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings';
|
import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings';
|
||||||
@@ -112,6 +112,7 @@ export class GuildChannelService {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
guildId: params.guildId,
|
guildId: params.guildId,
|
||||||
permission: Permissions.MANAGE_CHANNELS,
|
permission: Permissions.MANAGE_CHANNELS,
|
||||||
|
channelId: params.data.parent_id ? createChannelID(params.data.parent_id) : undefined,
|
||||||
});
|
});
|
||||||
const response = await this.channelOps.createChannel(params, auditLogReason);
|
const response = await this.channelOps.createChannel(params, auditLogReason);
|
||||||
const [masked] = await maskChannelResponseThreadBits(params.guildId, params.viewer, [response]);
|
const [masked] = await maskChannelResponseThreadBits(params.guildId, params.viewer, [response]);
|
||||||
@@ -155,11 +156,17 @@ export class GuildChannelService {
|
|||||||
await this.channelOps.sanitizeTextChannelNames(params);
|
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({
|
const hasPermission = await this.gatewayService.checkPermission({
|
||||||
guildId: params.guildId,
|
guildId: params.guildId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
permission: params.permission,
|
permission: params.permission,
|
||||||
|
channelId: params.channelId,
|
||||||
});
|
});
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
if (!hasPermission) throw new MissingPermissionsError();
|
||||||
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export class ChannelOperationsService {
|
|||||||
guildId: params.guildId,
|
guildId: params.guildId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
permission: Permissions.MANAGE_ROLES,
|
permission: Permissions.MANAGE_ROLES,
|
||||||
|
channelId: parentId ?? undefined,
|
||||||
});
|
});
|
||||||
if (!canManageRoles) throw new MissingPermissionsError();
|
if (!canManageRoles) throw new MissingPermissionsError();
|
||||||
const basePermissions = await this.gatewayService.getUserPermissions({
|
const basePermissions = await this.gatewayService.getUserPermissions({
|
||||||
@@ -736,6 +737,7 @@ export class ChannelOperationsService {
|
|||||||
ctx,
|
ctx,
|
||||||
'max_channels_per_category',
|
'max_channels_per_category',
|
||||||
maxChannels,
|
maxChannels,
|
||||||
|
'guild',
|
||||||
);
|
);
|
||||||
if (count >= maxChannels) {
|
if (count >= maxChannels) {
|
||||||
throw new MaxCategoryChannelsError(maxChannels);
|
throw new MaxCategoryChannelsError(maxChannels);
|
||||||
@@ -747,7 +749,13 @@ export class ChannelOperationsService {
|
|||||||
let maxChannels = MAX_GUILD_CHANNELS;
|
let maxChannels = MAX_GUILD_CHANNELS;
|
||||||
const guild = await this.guildRepository.findUnique(guildId);
|
const guild = await this.guildRepository.findUnique(guildId);
|
||||||
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
|
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) {
|
if (count >= maxChannels) {
|
||||||
throw new MaxGuildChannelsError(maxChannels);
|
throw new MaxGuildChannelsError(maxChannels);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,7 +527,6 @@ class RequestServices implements RequestScopedServices {
|
|||||||
get applicationService(): ApplicationService {
|
get applicationService(): ApplicationService {
|
||||||
this.cachedApplicationService ??= new ApplicationService(this.context, {
|
this.cachedApplicationService ??= new ApplicationService(this.context, {
|
||||||
applicationRepository: getApplicationRepository(),
|
applicationRepository: getApplicationRepository(),
|
||||||
channelRepository: getChannelRepository(),
|
|
||||||
userCacheService: getUserCacheService(),
|
userCacheService: getUserCacheService(),
|
||||||
entityAssetService: getEntityAssetService(),
|
entityAssetService: getEntityAssetService(),
|
||||||
discriminatorService: getDiscriminatorService(),
|
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 {ApiContext} from '@app/api/ApiContext';
|
||||||
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
|
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
|
||||||
import {applicationIdToUserId} 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 {ApplicationRow} from '@app/api/database/types/OAuth2Types';
|
||||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||||
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
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 {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||||
import type {Application} from '@app/api/models/Application';
|
import type {Application} from '@app/api/models/Application';
|
||||||
import type {User} from '@app/api/models/User';
|
import type {User} from '@app/api/models/User';
|
||||||
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
|
|
||||||
import type {BotAuthService} from '@app/api/oauth/BotAuthService';
|
import type {BotAuthService} from '@app/api/oauth/BotAuthService';
|
||||||
import {generateOAuthTokenSecret} from '@app/api/oauth/OAuthTokenSecret';
|
import {generateOAuthTokenSecret} from '@app/api/oauth/OAuthTokenSecret';
|
||||||
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
|
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 {
|
interface ApplicationServiceDeps {
|
||||||
discriminatorService: DiscriminatorService;
|
discriminatorService: DiscriminatorService;
|
||||||
channelRepository: ChannelRepository;
|
|
||||||
applicationRepository: ApplicationRepository;
|
applicationRepository: ApplicationRepository;
|
||||||
botAuthService: BotAuthService;
|
botAuthService: BotAuthService;
|
||||||
entityAssetService: EntityAssetService;
|
entityAssetService: EntityAssetService;
|
||||||
@@ -307,12 +304,6 @@ export class ApplicationService {
|
|||||||
const application = await this.verifyOwnership(userId, applicationId);
|
const application = await this.verifyOwnership(userId, applicationId);
|
||||||
if (application.hasBotUser()) {
|
if (application.hasBotUser()) {
|
||||||
const botUserId = application.getBotUserId()!;
|
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);
|
const guildIds = await this.apiContext.services.users.getUserGuildIds(botUserId);
|
||||||
await this.apiContext.services.users.deleteUserSecondaryIndices(botUserId);
|
await this.apiContext.services.users.deleteUserSecondaryIndices(botUserId);
|
||||||
await this.apiContext.services.users.removeFromAllGuilds(botUserId);
|
await this.apiContext.services.users.removeFromAllGuilds(botUserId);
|
||||||
@@ -372,14 +363,10 @@ export class ApplicationService {
|
|||||||
botUser.toRow(),
|
botUser.toRow(),
|
||||||
);
|
);
|
||||||
await this.deps.userCacheService.invalidateUserCache(botUserId);
|
await this.deps.userCacheService.invalidateUserCache(botUserId);
|
||||||
if (replacementAuthorId) {
|
|
||||||
await this.deps.userCacheService.invalidateUserCache(replacementAuthorId);
|
|
||||||
}
|
|
||||||
Logger.info(
|
Logger.info(
|
||||||
{
|
{
|
||||||
applicationId: applicationId.toString(),
|
applicationId: applicationId.toString(),
|
||||||
botUserId: botUserId.toString(),
|
botUserId: botUserId.toString(),
|
||||||
replacementAuthorId: replacementAuthorId?.toString() ?? null,
|
|
||||||
},
|
},
|
||||||
'Anonymized bot user associated with application',
|
'Anonymized bot user associated with application',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,25 +94,17 @@ describe('OAuth2 Application Delete', () => {
|
|||||||
.execute();
|
.execute();
|
||||||
const foundMessage = messages.find((message) => message.id === botMessageId);
|
const foundMessage = messages.find((message) => message.id === botMessageId);
|
||||||
expect(foundMessage).toBeDefined();
|
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.username).toBe('DeletedUser');
|
||||||
expect(foundMessage?.author.discriminator).toBe('0000');
|
expect(foundMessage?.author.discriminator).toBe('0000');
|
||||||
const previousAuthorCount = await createBuilderWithoutAuth<{
|
const authorCount = await createBuilderWithoutAuth<{
|
||||||
count: number;
|
count: number;
|
||||||
}>(harness)
|
}>(harness)
|
||||||
.get(`/test/users/${createResult.botUserId}/messages/count`)
|
.get(`/test/users/${createResult.botUserId}/messages/count`)
|
||||||
.expect(HTTP_STATUS.OK)
|
.expect(HTTP_STATUS.OK)
|
||||||
.execute();
|
.execute();
|
||||||
expect(previousAuthorCount.count).toBe(0);
|
expect(authorCount.count).toBe(1);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
test('enforces access control', async () => {
|
test('enforces access control', async () => {
|
||||||
const owner = await createTestAccount(harness);
|
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,
|
follow,
|
||||||
followRequest,
|
followRequest,
|
||||||
setGuildFeatures,
|
setGuildFeatures,
|
||||||
setLimitOverride,
|
|
||||||
} from '@app/api/channel/tests/AnnouncementTestUtils';
|
} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||||
import {createGuild, createPermissionOverwrite, updateGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
import {createGuild, createPermissionOverwrite, updateGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||||
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||||
|
import {setLimitOverride} from '@app/api/test/LimitTestUtils';
|
||||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
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 {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||||
import {Logger} from '@app/api/Logger';
|
import {Logger} from '@app/api/Logger';
|
||||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||||
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
|
|
||||||
import {chunkArray} from '@app/api/utils/ArrayUtils';
|
import {chunkArray} from '@app/api/utils/ArrayUtils';
|
||||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||||
import {
|
import {
|
||||||
@@ -25,15 +24,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
|||||||
helpers.logger.debug({payload: validated}, 'Processing applicationProcessDeletion task');
|
helpers.logger.debug({payload: validated}, 'Processing applicationProcessDeletion task');
|
||||||
const applicationId = createApplicationID(BigInt(validated.applicationId));
|
const applicationId = createApplicationID(BigInt(validated.applicationId));
|
||||||
const botUserId = applicationIdToUserId(applicationId);
|
const botUserId = applicationIdToUserId(applicationId);
|
||||||
const {
|
const {userRepository, guildRepository, applicationRepository, userCacheService, gatewayService} =
|
||||||
userRepository,
|
getWorkerDependencies();
|
||||||
guildRepository,
|
|
||||||
channelRepository,
|
|
||||||
applicationRepository,
|
|
||||||
userCacheService,
|
|
||||||
gatewayService,
|
|
||||||
snowflakeService,
|
|
||||||
} = getWorkerDependencies();
|
|
||||||
Logger.debug({applicationId, botUserId}, 'Starting application deletion');
|
Logger.debug({applicationId, botUserId}, 'Starting application deletion');
|
||||||
try {
|
try {
|
||||||
const application = await applicationRepository.getApplication(applicationId);
|
const application = await applicationRepository.getApplication(applicationId);
|
||||||
@@ -42,21 +34,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const botUser = await userRepository.findUniqueAssert(botUserId);
|
const botUser = await userRepository.findUniqueAssert(botUserId);
|
||||||
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
|
|
||||||
originalAuthorId: botUserId,
|
|
||||||
channelRepository,
|
|
||||||
userRepository,
|
|
||||||
snowflakeService,
|
|
||||||
});
|
|
||||||
if (botUser.flags & UserFlags.DELETED) {
|
if (botUser.flags & UserFlags.DELETED) {
|
||||||
Logger.info(
|
Logger.info({applicationId, botUserId}, 'Bot user already marked as deleted, skipping profile update');
|
||||||
{
|
|
||||||
applicationId,
|
|
||||||
botUserId,
|
|
||||||
replacementAuthorId: replacementAuthorId?.toString() ?? null,
|
|
||||||
},
|
|
||||||
'Bot user already marked as deleted, skipping profile update',
|
|
||||||
);
|
|
||||||
await applicationRepository.deleteApplication(applicationId);
|
await applicationRepository.deleteApplication(applicationId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,6 +45,11 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
|
|||||||
username: DELETED_USER_USERNAME,
|
username: DELETED_USER_USERNAME,
|
||||||
global_name: DELETED_USER_GLOBAL_NAME,
|
global_name: DELETED_USER_GLOBAL_NAME,
|
||||||
discriminator: DELETED_USER_DISCRIMINATOR,
|
discriminator: DELETED_USER_DISCRIMINATOR,
|
||||||
|
avatar_hash: null,
|
||||||
|
banner_hash: null,
|
||||||
|
bio: null,
|
||||||
|
pronouns: null,
|
||||||
|
accent_color: null,
|
||||||
},
|
},
|
||||||
botUser.toRow(),
|
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 />
|
<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
|
### 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.
|
`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 |
|
| 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 |
|
| 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` |
|
| 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`
|
<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.
|
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
|
#### 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`:
|
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