mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(guild): treat very high as high without phone verification (#3095)
This commit is contained in:
@@ -160,6 +160,9 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
# api.pwnedpasswords.com.
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
|
||||
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
|
||||
# Phone verification needs your own responder on the rpc.phone.v1 NATS
|
||||
# subjects. Off unless turned on.
|
||||
#FLUXER_PHONE_VERIFICATION_ENABLED=false
|
||||
# A local path, or an s3:// URL read with the S3 credentials of this file.
|
||||
#FLUXER_GEOIP_DB_PATH=
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
|
||||
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
|
||||
FLUXER_PHONE_VERIFICATION_ENABLED: ${FLUXER_PHONE_VERIFICATION_ENABLED:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
|
||||
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
|
||||
|
||||
@@ -211,3 +211,41 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function withPhoneVerification(master: MasterConfig, selfHosted: boolean, enabled?: boolean): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
instance: {
|
||||
...master.instance,
|
||||
self_hosted: selfHosted,
|
||||
phone_verification_enabled: enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster phone verification', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('is on by default when the instance is not self-hosted', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, false)).instance.phoneVerificationEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('is off by default on a self-hosted instance', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true)).instance.phoneVerificationEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a self-hosted operator switch it on', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true, true)).instance.phoneVerificationEnabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets an operator switch it off when the instance is not self-hosted', () => {
|
||||
expect(
|
||||
buildAPIConfigFromMaster(withPhoneVerification(master, false, false)).instance.phoneVerificationEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,6 +367,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
instance: {
|
||||
selfHosted: master.instance.self_hosted,
|
||||
phoneVerificationEnabled: master.instance.phone_verification_enabled ?? !master.instance.self_hosted,
|
||||
autoJoinInviteCode: master.instance.auto_join_invite_code,
|
||||
visionariesGuildId: master.instance.visionaries_guild_id,
|
||||
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
|
||||
|
||||
@@ -262,6 +262,7 @@ export interface APIConfig {
|
||||
};
|
||||
instance: {
|
||||
selfHosted: boolean;
|
||||
phoneVerificationEnabled: boolean;
|
||||
autoJoinInviteCode?: string;
|
||||
visionariesGuildId?: string;
|
||||
visionariesGuildVisionaryRoleId?: string;
|
||||
|
||||
@@ -434,7 +434,11 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
member_count: hit.memberCount,
|
||||
online_count: 0,
|
||||
features: hit.features,
|
||||
verification_level: getEffectiveGuildVerificationLevel(hit.verificationLevel, hit.isDiscoverable),
|
||||
verification_level: getEffectiveGuildVerificationLevel(
|
||||
hit.verificationLevel,
|
||||
hit.isDiscoverable,
|
||||
Config.instance.phoneVerificationEnabled,
|
||||
),
|
||||
}));
|
||||
const total = results.total;
|
||||
if (guilds.length > 0) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {ChannelID, GuildID, RoleID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {
|
||||
type ChannelFollowerRemovalCopyMode,
|
||||
@@ -481,6 +482,17 @@ export class GuildOperationsService {
|
||||
ValidationErrorCodes.DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW,
|
||||
);
|
||||
}
|
||||
if (
|
||||
data.verification_level === GuildVerificationLevel.VERY_HIGH &&
|
||||
data.verification_level !== currentGuild.verificationLevel &&
|
||||
!Config.instance.phoneVerificationEnabled
|
||||
) {
|
||||
throw InputValidationError.fromCode('verification_level', ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE, {
|
||||
name: 'verification_level',
|
||||
minValue: GuildVerificationLevel.NONE,
|
||||
maxValue: GuildVerificationLevel.HIGH,
|
||||
});
|
||||
}
|
||||
const isMfaLevelChange = data.mfa_level !== undefined && data.mfa_level !== currentGuild.mfaLevel;
|
||||
if (isMfaLevelChange) {
|
||||
const isOwner = guildData.owner_id === userId.toString();
|
||||
@@ -1177,7 +1189,12 @@ export class GuildOperationsService {
|
||||
|
||||
private sanitiseTemplateGuildSettings(template?: TemplateSerializedGuild): TemplateGuildSettings {
|
||||
return {
|
||||
verificationLevel: this.clampTemplateSetting(template?.verification_level, 0, 4, 0),
|
||||
verificationLevel: this.clampTemplateSetting(
|
||||
template?.verification_level,
|
||||
GuildVerificationLevel.NONE,
|
||||
Config.instance.phoneVerificationEnabled ? GuildVerificationLevel.VERY_HIGH : GuildVerificationLevel.HIGH,
|
||||
GuildVerificationLevel.NONE,
|
||||
),
|
||||
explicitContentFilter: this.clampTemplateSetting(template?.explicit_content_filter, 0, 2, 0),
|
||||
defaultMessageNotifications: this.clampTemplateSetting(template?.default_message_notifications, 0, 1, 0),
|
||||
systemChannelFlags: (template?.system_channel_flags ?? 0) & SUPPORTED_SYSTEM_CHANNEL_FLAGS,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild verification level without phone verification', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let savedPhoneVerificationEnabled: boolean;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
savedPhoneVerificationEnabled = Config.instance.phoneVerificationEnabled;
|
||||
});
|
||||
afterEach(() => {
|
||||
Config.instance.phoneVerificationEnabled = savedPhoneVerificationEnabled;
|
||||
});
|
||||
|
||||
test('rejects very high when phone verification is unavailable', async () => {
|
||||
Config.instance.phoneVerificationEnabled = false;
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'No Phone Guild');
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({verification_level: GuildVerificationLevel.VERY_HIGH})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
verification_level: GuildVerificationLevel.HIGH,
|
||||
});
|
||||
expect(updated.verification_level).toBe(GuildVerificationLevel.HIGH);
|
||||
});
|
||||
|
||||
test('accepts very high when phone verification is available', async () => {
|
||||
Config.instance.phoneVerificationEnabled = true;
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Phone Guild');
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
verification_level: GuildVerificationLevel.VERY_HIGH,
|
||||
});
|
||||
expect(updated.verification_level).toBe(GuildVerificationLevel.VERY_HIGH);
|
||||
});
|
||||
|
||||
test('accepts an unchanged stored very high when phone verification is unavailable', async () => {
|
||||
Config.instance.phoneVerificationEnabled = true;
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Stored Phone Guild');
|
||||
await updateGuild(harness, account.token, guild.id, {verification_level: GuildVerificationLevel.VERY_HIGH});
|
||||
Config.instance.phoneVerificationEnabled = false;
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
name: 'Renamed Phone Guild',
|
||||
verification_level: GuildVerificationLevel.VERY_HIGH,
|
||||
});
|
||||
expect(updated.name).toBe('Renamed Phone Guild');
|
||||
expect(updated.verification_level).toBe(GuildVerificationLevel.VERY_HIGH);
|
||||
});
|
||||
|
||||
test('clamps a template level to high when phone verification is unavailable', async () => {
|
||||
Config.instance.phoneVerificationEnabled = false;
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Template Guild',
|
||||
template: {
|
||||
name: 'Template Source',
|
||||
description: null,
|
||||
verification_level: GuildVerificationLevel.VERY_HIGH,
|
||||
default_message_notifications: 0,
|
||||
explicit_content_filter: 0,
|
||||
system_channel_id: 1001,
|
||||
afk_timeout: 300,
|
||||
system_channel_flags: 0,
|
||||
roles: [{id: 0, name: '@everyone', permissions: '0'}],
|
||||
channels: [{id: 1001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}],
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
expect(guild.verification_level).toBe(GuildVerificationLevel.HIGH);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@ function buildDiscoveryStaticInput(
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
presigned_attachment_uploads: Config.presignedAttachmentUploadsEnabled,
|
||||
emails_enabled: runtime.emailEnabled,
|
||||
phone_verification_enabled: Config.instance.phoneVerificationEnabled,
|
||||
},
|
||||
gif: {
|
||||
provider: gifProviderName,
|
||||
|
||||
@@ -30849,6 +30849,10 @@
|
||||
"emails_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the instance sends emails (verification, password reset, etc.)"
|
||||
},
|
||||
"phone_verification_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether users can verify a phone number, so the very high guild verification level applies"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -30858,7 +30862,8 @@
|
||||
"stripe_serviceable",
|
||||
"self_hosted",
|
||||
"presigned_attachment_uploads",
|
||||
"emails_enabled"
|
||||
"emails_enabled",
|
||||
"phone_verification_enabled"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"description": "Feature flags for this instance"
|
||||
|
||||
@@ -34,7 +34,7 @@ export function convertToSearchableGuild(guild: Guild, discovery?: GuildDiscover
|
||||
bannerHash: guild.bannerHash,
|
||||
splashHash: guild.splashHash,
|
||||
features: Array.from(guild.features),
|
||||
verificationLevel: getEffectiveGuildVerificationLevel(guild.verificationLevel, isDiscoverable),
|
||||
verificationLevel: getEffectiveGuildVerificationLevel(guild.verificationLevel, isDiscoverable, true),
|
||||
mfaLevel: guild.mfaLevel,
|
||||
nsfwLevel: guild.nsfwLevel,
|
||||
createdAt,
|
||||
|
||||
@@ -76,11 +76,13 @@ export class UserAuthRequestService {
|
||||
if (user.suspiciousActivityFlags !== 0) {
|
||||
return;
|
||||
}
|
||||
const guildIds = await this.userRepository.getUserGuildIds(user.id);
|
||||
if (guildIds.length > 0) {
|
||||
const guilds = await this.guildRepository.listGuilds(guildIds);
|
||||
if (guilds.some((g) => g.verificationLevel >= GuildVerificationLevel.VERY_HIGH)) {
|
||||
return;
|
||||
if (this.apiContext.services.config.instance.phoneVerificationEnabled) {
|
||||
const guildIds = await this.userRepository.getUserGuildIds(user.id);
|
||||
if (guildIds.length > 0) {
|
||||
const guilds = await this.guildRepository.listGuilds(guildIds);
|
||||
if (guilds.some((g) => g.verificationLevel >= GuildVerificationLevel.VERY_HIGH)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new PhoneAddNotEligibleError();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {User} from '@app/api/models/User';
|
||||
import {checkGuildVerificationWithResponse} from '@app/api/utils/GuildVerificationUtils';
|
||||
import {GuildFeatures, GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {GuildEmailVerificationRequiredError} from '@fluxer/errors/src/domains/auth/EmailVerificationRequiredError';
|
||||
import {GuildPhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/GuildPhoneVerificationRequiredError';
|
||||
import {GuildVerificationRequiredError} from '@fluxer/errors/src/domains/guild/GuildVerificationRequiredError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
@@ -178,4 +180,28 @@ describe('GuildVerificationUtils', () => {
|
||||
}),
|
||||
).toThrow(GuildPhoneVerificationRequiredError);
|
||||
});
|
||||
it('treats very high as high when phone verification is unavailable', () => {
|
||||
const saved = Config.instance.phoneVerificationEnabled;
|
||||
Config.instance.phoneVerificationEnabled = false;
|
||||
try {
|
||||
const guild = createGuildResponse([]);
|
||||
guild.verification_level = GuildVerificationLevel.VERY_HIGH;
|
||||
expect(() =>
|
||||
checkGuildVerificationWithResponse({
|
||||
user: createUser({emailVerified: true, hasVerifiedPhone: false}),
|
||||
guild,
|
||||
member,
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
checkGuildVerificationWithResponse({
|
||||
user: createUser({emailVerified: true, hasVerifiedPhone: false}),
|
||||
guild,
|
||||
member: createMemberResponse(new Date().toISOString()),
|
||||
}),
|
||||
).toThrow(GuildVerificationRequiredError);
|
||||
} finally {
|
||||
Config.instance.phoneVerificationEnabled = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createRoleIDSet, createUserID, type RoleID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {Guild} from '@app/api/models/Guild';
|
||||
import type {GuildMember} from '@app/api/models/GuildMember';
|
||||
import type {User} from '@app/api/models/User';
|
||||
@@ -90,6 +91,7 @@ export function checkGuildVerificationWithGuildModel({
|
||||
verificationLevel: getEffectiveGuildVerificationLevel(
|
||||
guild.verificationLevel ?? GuildVerificationLevel.NONE,
|
||||
guild.features.has(GuildFeatures.DISCOVERABLE),
|
||||
Config.instance.phoneVerificationEnabled,
|
||||
),
|
||||
memberJoinedAt: member.joinedAt,
|
||||
memberRoles: member.roleIds,
|
||||
@@ -115,6 +117,7 @@ export function checkGuildVerificationWithResponse({
|
||||
verificationLevel: getEffectiveGuildVerificationLevel(
|
||||
guild.verification_level ?? GuildVerificationLevel.NONE,
|
||||
(guild.features ?? []).includes(GuildFeatures.DISCOVERABLE),
|
||||
Config.instance.phoneVerificationEnabled,
|
||||
),
|
||||
memberJoinedAt: member.joined_at,
|
||||
memberRoles: createRoleIDSet(new Set(member.roles.map((roleId) => BigInt(roleId)))),
|
||||
|
||||
@@ -100,6 +100,7 @@ const DEFAULT_INSTANCE_FEATURES: InstanceFeatures = {
|
||||
self_hosted: false,
|
||||
presigned_attachment_uploads: false,
|
||||
emails_enabled: false,
|
||||
phone_verification_enabled: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_INSTANCE_REGISTRATION: InstanceRegistration = {
|
||||
@@ -516,6 +517,10 @@ class RuntimeConfig {
|
||||
return this.features.emails_enabled;
|
||||
}
|
||||
|
||||
get phoneVerificationEnabled(): boolean {
|
||||
return this.features.phone_verification_enabled;
|
||||
}
|
||||
|
||||
get productName(): string {
|
||||
return this.appPublic.branding.product_name.trim() || DEFAULT_APP_PUBLIC_CONFIG.branding.product_name;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as GuildCommands from '@app/features/guild/commands/GuildCommands';
|
||||
import styles from '@app/features/guild/components/modals/guild_tabs/GuildModerationTab.module.css';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
@@ -176,6 +177,7 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) =>
|
||||
const canManageGuild = Permission.can(Permissions.MANAGE_GUILD, {guildId});
|
||||
const isGuildOwner = guild?.ownerId === currentUser?.id;
|
||||
const isDiscoverable = guild?.features.has(GuildFeatures.DISCOVERABLE) ?? false;
|
||||
const phoneVerificationEnabled = RuntimeConfig.phoneVerificationEnabled;
|
||||
const remoteValues: FormInputs = {
|
||||
verification_level: guild?.verificationLevel ?? GuildVerificationLevel.NONE,
|
||||
mfa_level: guild?.mfaLevel ?? GuildMFALevel.NONE,
|
||||
@@ -258,7 +260,7 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) =>
|
||||
}
|
||||
return;
|
||||
};
|
||||
const verificationLevelOptions: ReadonlyArray<RadioOption<number>> = [
|
||||
const allVerificationLevelOptions: ReadonlyArray<RadioOption<number>> = [
|
||||
{
|
||||
value: GuildVerificationLevel.NONE,
|
||||
name: i18n._(VERIFICATION_LEVEL_NONE_NAME_DESCRIPTOR),
|
||||
@@ -286,6 +288,9 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) =>
|
||||
desc: i18n._(VERIFICATION_LEVEL_VERY_HIGH_DESCRIPTION_DESCRIPTOR),
|
||||
},
|
||||
];
|
||||
const verificationLevelOptions = phoneVerificationEnabled
|
||||
? allVerificationLevelOptions
|
||||
: allVerificationLevelOptions.filter((option) => option.value !== GuildVerificationLevel.VERY_HIGH);
|
||||
const matureContentOptions: ReadonlyArray<ComboboxOption<string>> = [
|
||||
{value: 'on', label: i18n._(MATURE_CONTENT_ON_DESCRIPTOR)},
|
||||
{value: 'off', label: i18n._(MATURE_CONTENT_OFF_DESCRIPTOR)},
|
||||
@@ -324,7 +329,11 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) =>
|
||||
control={form.control}
|
||||
render={({field}) => (
|
||||
<RadioGroup
|
||||
value={getEffectiveGuildVerificationLevel(field.value ?? GuildVerificationLevel.NONE, isDiscoverable)}
|
||||
value={getEffectiveGuildVerificationLevel(
|
||||
field.value ?? GuildVerificationLevel.NONE,
|
||||
isDiscoverable,
|
||||
phoneVerificationEnabled,
|
||||
)}
|
||||
onChange={field.onChange}
|
||||
disabled={!canManageGuild}
|
||||
options={verificationLevelOptions}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
@@ -143,6 +144,7 @@ class GuildVerification {
|
||||
const verificationLevel = getEffectiveGuildVerificationLevel(
|
||||
guild.verificationLevel ?? GuildVerificationLevel.NONE,
|
||||
guild.features.has(GuildFeatures.DISCOVERABLE),
|
||||
RuntimeConfig.phoneVerificationEnabled,
|
||||
);
|
||||
if (verificationLevel === GuildVerificationLevel.NONE) {
|
||||
return {canAccess: true};
|
||||
|
||||
@@ -320,6 +320,7 @@ const BOOTSTRAP_ENDPOINT = 'https://primary.test/api';
|
||||
self_hosted: false,
|
||||
presigned_attachment_uploads: false,
|
||||
emails_enabled: false,
|
||||
phone_verification_enabled: true,
|
||||
},
|
||||
gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false},
|
||||
sso: {enabled: false, enforced: false, display_name: null, redirect_uri: ''},
|
||||
|
||||
@@ -290,7 +290,7 @@ A verification level gates member participation. Fluxer evaluates it when a memb
|
||||
|
||||
<sup>2</sup> The membership age requirement is skipped when the join timestamp cannot be read, so the level behaves as MEDIUM
|
||||
|
||||
<sup>3</sup> A verified phone number is the whole requirement at this level
|
||||
<sup>3</sup> A verified phone number is the whole requirement at this level. A deployment whose [instance features](/http-api/instance/#instance-features-object) report `phone_verification_enabled` as false evaluates a stored VERY_HIGH as HIGH. [Modify guild](#modify-guild) then rejects a change to VERY_HIGH with 400 `INVALID_FORM_BODY` and the field code `VALUE_MUST_BE_INTEGER_IN_RANGE`, and a [guild template](#guild-creation-template-object) value is clamped to HIGH
|
||||
|
||||
The guild owner, a bot, and any member holding at least one role bypass the check at every level.
|
||||
|
||||
@@ -674,7 +674,7 @@ Every field is optional. An omitted field preserves its current value, and a fie
|
||||
|
||||
<sup>5</sup> The channel must exist in this guild and be a voice channel, and is otherwise rejected with `AFK_CHANNEL_MUST_BE_IN_GUILD` or `AFK_CHANNEL_MUST_BE_VOICE`
|
||||
|
||||
<sup>6</sup> A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`
|
||||
<sup>6</sup> A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`. A change to VERY_HIGH is rejected with the field code `VALUE_MUST_BE_INTEGER_IN_RANGE` when phone verification is unavailable, as [Verification levels](#verification-levels) states
|
||||
|
||||
<sup>7</sup> Sending the value the guild already holds needs neither ownership nor sudo mode, and an owner without a configured second factor is rejected with the field code `MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS`
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ Deployment-wide switches a client reads before it offers a feature, plus whether
|
||||
| self_hosted | boolean | Whether this deployment identifies itself as self-hosted |
|
||||
| presigned_attachment_uploads | boolean | Whether a client can request presigned attachment upload URLs |
|
||||
| emails_enabled<sup>1</sup> | boolean | Whether the deployment sends email |
|
||||
| phone_verification_enabled<sup>5</sup> | boolean | Whether accounts can verify a phone number |
|
||||
|
||||
<sup>1</sup> The value is true only when email is switched on and the transport is completely configured
|
||||
|
||||
@@ -147,6 +148,8 @@ Deployment-wide switches a client reads before it offers a feature, plus whether
|
||||
|
||||
<sup>4</sup> On a hosted deployment, true while billing is switched on and a Stripe secret key is set. A self-hosted deployment reports true while a Stripe secret key is set and `premium_enabled` is true, even after billing is switched off
|
||||
|
||||
<sup>5</sup> Defaults to true on a hosted deployment and false on a self-hosted one. While it is false, the `VERY_HIGH` [verification level](/http-api/guilds/#verification-levels) is evaluated as `HIGH`
|
||||
|
||||
A deployment that reports `emails_enabled` as false sends no verification, password recovery, or IP authorisation message, and the flows that depend on one are unusable there. [Deployment availability](/http-api/deployment-availability/) states which routes a self-hosted deployment does not serve at all.
|
||||
|
||||
## GIF provider object
|
||||
|
||||
@@ -17,7 +17,7 @@ Every route here needs a non-bot user session, and each one admits a session wit
|
||||
- The account already holds a verified phone.
|
||||
- A TOTP [authenticator](/http-api/users/#authenticator-types) is enrolled on the account.
|
||||
- The stored suspicious activity bitfield is non-zero.
|
||||
- The account belongs to at least one guild whose [verification level](/http-api/guilds/#verification-levels) is `VERY_HIGH`.
|
||||
- The account belongs to at least one guild whose [verification level](/http-api/guilds/#verification-levels) is `VERY_HIGH`, and the [instance features](/http-api/instance/#instance-features-object) report `phone_verification_enabled` as true.
|
||||
|
||||
An account satisfying none of them is refused with 403 `PHONE_ADD_NOT_ELIGIBLE`.
|
||||
|
||||
|
||||
@@ -994,6 +994,10 @@ Defaults to the inverse of `FLUXER_SELF_HOSTED`. Off by default on a self-hosted
|
||||
|
||||
With feeds off, Fluxer checks none of this data. The first worker start with feeds off removes the URL feed file and every `malware_bazaar` file-SHA ban that no Admin added. File-SHA bans added through the Admin API stay. Turning feeds back on downloads the URLs within six hours and the hashes within twelve. Compose forwards it from `.env`.
|
||||
|
||||
#### `FLUXER_PHONE_VERIFICATION_ENABLED`
|
||||
|
||||
Defaults to the inverse of `FLUXER_SELF_HOSTED`. Phone verification needs an external responder on the `rpc.phone.v1` NATS subjects, and none ships with Fluxer. With it off, the `VERY_HIGH` guild verification level is evaluated as `HIGH`, the guild settings stop offering it, and the API rejects it. Turn it on only when your own responder answers those subjects. Compose forwards it from `.env`.
|
||||
|
||||
#### `FLUXER_BREACHED_PASSWORD_CHECK_ENABLED`
|
||||
|
||||
Defaults to the inverse of `FLUXER_SELF_HOSTED`. Breached password rejection. Sends the first five characters of the password's SHA-1 hash to `api.pwnedpasswords.com`. Off by default on a self-hosted instance. Compose forwards it from `.env`.
|
||||
@@ -1078,7 +1082,7 @@ Default `development`. The runtime mode. `development`, `production`, or `test`.
|
||||
|
||||
#### `FLUXER_SELF_HOSTED`
|
||||
|
||||
Default `false`. The self-host switch. Compose sets `true`. It relaxes the production Postgres SSL requirement, seeds the limit tier, gates registration, donation and discovery controllers, keeps premium billing off until it is set up as [Payments](#payments) describes, and turns blocklist feeds off.
|
||||
Default `false`. The self-host switch. Compose sets `true`. It relaxes the production Postgres SSL requirement, seeds the limit tier, gates registration, donation and discovery controllers, keeps premium billing off until it is set up as [Payments](#payments) describes, and turns blocklist feeds and phone verification off.
|
||||
|
||||
`/_metrics` on `api`, `media-proxy`, `gateway`, and `push`, plus the Gateway's `/_health/ready`, `/_health/drain`, and `/_health/undrain`, are gated to loopback peers, so no proxy reaches them. The probes that work from outside are `/api/_health`, `/gateway/_health`, `/media/_health`, and the edge's own `/_health`.
|
||||
|
||||
|
||||
@@ -286,6 +286,7 @@ export interface MasterConfig {
|
||||
};
|
||||
instance: {
|
||||
self_hosted: boolean;
|
||||
phone_verification_enabled?: boolean;
|
||||
auto_join_invite_code?: string;
|
||||
visionaries_guild_id?: string;
|
||||
visionaries_guild_visionary_role_id?: string;
|
||||
|
||||
@@ -290,6 +290,15 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('maps the phone verification switch onto instance.phone_verification_enabled', () => {
|
||||
expect(buildNamedFluxerEnvOverrides({FLUXER_PHONE_VERIFICATION_ENABLED: 'true'})).toEqual({
|
||||
instance: {phone_verification_enabled: true},
|
||||
});
|
||||
expect(buildNamedFluxerEnvOverrides({FLUXER_PHONE_VERIFICATION_ENABLED: 'false'})).toEqual({
|
||||
instance: {phone_verification_enabled: false},
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a store enabled flag that is not a boolean', () => {
|
||||
expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_ENABLED: 'yes'})).toThrow(
|
||||
'FLUXER_APP_STORE_ENABLED must be true or false',
|
||||
|
||||
@@ -265,6 +265,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
parse: parseBoolean,
|
||||
},
|
||||
FLUXER_SELF_HOSTED: {path: ['instance', 'self_hosted'], parse: parseBoolean},
|
||||
FLUXER_PHONE_VERIFICATION_ENABLED: {path: ['instance', 'phone_verification_enabled'], parse: parseBoolean},
|
||||
FLUXER_AUTO_JOIN_INVITE_CODE: {path: ['instance', 'auto_join_invite_code']},
|
||||
FLUXER_VISIONARIES_GUILD_ID: {path: ['instance', 'visionaries_guild_id']},
|
||||
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: {path: ['instance', 'visionaries_guild_visionary_role_id']},
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {
|
||||
clampVoiceChannelBitrate,
|
||||
GuildFeatures,
|
||||
GuildVerificationLevel,
|
||||
getEffectiveGuildVerificationLevel,
|
||||
getMaxVoiceChannelBitrate,
|
||||
resolveVoiceChannelBitrate,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
@@ -59,3 +61,33 @@ describe('resolveVoiceChannelBitrate', () => {
|
||||
expect(resolveVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveGuildVerificationLevel', () => {
|
||||
it('returns the stored level when phone verification is available', () => {
|
||||
expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.VERY_HIGH, false, true)).toBe(
|
||||
GuildVerificationLevel.VERY_HIGH,
|
||||
);
|
||||
expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.NONE, false, true)).toBe(
|
||||
GuildVerificationLevel.NONE,
|
||||
);
|
||||
});
|
||||
it('treats very high as high when phone verification is unavailable', () => {
|
||||
expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.VERY_HIGH, false, false)).toBe(
|
||||
GuildVerificationLevel.HIGH,
|
||||
);
|
||||
expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.VERY_HIGH, true, false)).toBe(
|
||||
GuildVerificationLevel.HIGH,
|
||||
);
|
||||
expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.MEDIUM, false, false)).toBe(
|
||||
GuildVerificationLevel.MEDIUM,
|
||||
);
|
||||
});
|
||||
it('raises a discoverable guild to at least low', () => {
|
||||
expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.NONE, true, true)).toBe(
|
||||
GuildVerificationLevel.LOW,
|
||||
);
|
||||
expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.NONE, true, false)).toBe(
|
||||
GuildVerificationLevel.LOW,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,11 +20,16 @@ export const GuildVerificationLevel = {
|
||||
|
||||
export type GuildVerificationLevelValue = ValueOf<typeof GuildVerificationLevel>;
|
||||
|
||||
export function getEffectiveGuildVerificationLevel(verificationLevel: number, isDiscoverable: boolean): number {
|
||||
export function getEffectiveGuildVerificationLevel(
|
||||
verificationLevel: number,
|
||||
isDiscoverable: boolean,
|
||||
phoneVerificationEnabled: boolean,
|
||||
): number {
|
||||
const level = phoneVerificationEnabled ? verificationLevel : Math.min(verificationLevel, GuildVerificationLevel.HIGH);
|
||||
if (!isDiscoverable) {
|
||||
return verificationLevel;
|
||||
return level;
|
||||
}
|
||||
return Math.max(verificationLevel, GuildVerificationLevel.LOW);
|
||||
return Math.max(level, GuildVerificationLevel.LOW);
|
||||
}
|
||||
|
||||
export const GuildMFALevel = {
|
||||
|
||||
@@ -113,6 +113,9 @@ export const InstanceFeaturesSchema = z
|
||||
self_hosted: z.boolean().describe('Whether this is a self-hosted instance'),
|
||||
presigned_attachment_uploads: z.boolean().describe('Whether clients can request presigned attachment upload URLs'),
|
||||
emails_enabled: z.boolean().describe('Whether the instance sends emails (verification, password reset, etc.)'),
|
||||
phone_verification_enabled: z
|
||||
.boolean()
|
||||
.describe('Whether users can verify a phone number, so the very high guild verification level applies'),
|
||||
})
|
||||
.describe('Feature flags for this instance');
|
||||
export type InstanceFeatures = z.infer<typeof InstanceFeaturesSchema>;
|
||||
|
||||
Reference in New Issue
Block a user