diff --git a/README.md b/README.md index d75bcf217..878c7674b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ On Linux, prefer a repository over a single file so Fluxer updates with the rest ## Linux package repositories -Every repository serves both channels. The package is `fluxer` for stable, `fluxer-canary` for canary. +The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository. ### Flatpak @@ -59,7 +59,7 @@ Stable is on [Flathub][flathub], the easiest route on most desktops: flatpak install flathub app.fluxer.Fluxer ``` -Flathub has stable only. For canary, or to use Fluxer's own repository, open [this reference file][flatpak-ref] and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar. +Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar. From a terminal: @@ -76,6 +76,15 @@ sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.co sudo apt update && sudo apt install fluxer ``` +For canary, use the canary entry file and package. + +```sh +sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources +sudo apt update && sudo apt install fluxer-canary +``` + +A `.deb` installed from a download only updates once its channel's entry is added. + ### Fedora and RHEL ```sh @@ -83,6 +92,13 @@ sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/flux sudo dnf install fluxer ``` +For canary, use the canary entry file and package. + +```sh +sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo +sudo dnf install fluxer-canary +``` + RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not. ### Arch Linux @@ -150,6 +166,7 @@ endorsement rights. [linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz [linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz [flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref +[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref [flathub]: https://flathub.org/apps/app.fluxer.Fluxer [android-apk]: https://github.com/fluxerapp/flutter_client/releases [obtainium]: https://obtainium.imranr.dev/ diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index aa7851252..1d9b6a02e 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -476,6 +476,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { }, abusePolicy: { inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes, + phoneFlagging: { + enabled: master.instance.abuse_policy.phone_flagging.enabled, + exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes, + }, phoneVerification: { inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes, }, diff --git a/fluxer_api/src/api/auth/AuthRegistration.ts b/fluxer_api/src/api/auth/AuthRegistration.ts index 5525260f7..9f519fddf 100644 --- a/fluxer_api/src/api/auth/AuthRegistration.ts +++ b/fluxer_api/src/api/auth/AuthRegistration.ts @@ -23,7 +23,7 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {User} from '@app/api/models/User'; import {UserSettings} from '@app/api/models/UserSettings'; -import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy'; +import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy'; import { type IAccountPolicyEvaluator, isAssessmentThresholdAuditEvent, @@ -362,7 +362,9 @@ export async function register( action: riskResult.recommendedAction, }, }); - const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits); + const combinedFlags = await deferPhoneFlagsUntilCommunityJoin( + await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode), + ); const createdAt = new Date(); const riskContext = deriveLatestRiskContext({ userId: userId.toString(), diff --git a/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts b/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts index 45332e3c6..6d3f6efa7 100644 --- a/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts +++ b/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts @@ -9,6 +9,7 @@ import { loginAccount, registerUser, } from '@app/api/auth/tests/AuthTestUtils'; +import {Config} from '@app/api/Config'; import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware'; import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; import { @@ -33,7 +34,7 @@ import { SuspiciousActivityFlags, } from '@fluxer/constants/src/UserConstants'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; -import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator { return { @@ -241,6 +242,59 @@ describe('Deferred phone verification gate', () => { expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0); }); + describe('with phone flagging disabled', () => { + const originalPhoneFlagging = {...Config.abusePolicy.phoneFlagging}; + afterEach(() => { + Config.abusePolicy.phoneFlagging = originalPhoneFlagging; + }); + + it('sets no phone requirement and no deferral at registration', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true}); + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70)); + const registration = await registerUser(harness, { + email: createUniqueEmail('flagging-off'), + username: createUniqueUsername('flagging_off'), + global_name: 'Flagging Off', + password: 'StrongPassword!123', + date_of_birth: '2000-01-01', + consent: true, + }); + const flags = await readFlags(registration.user_id); + expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).toBe(0); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0); + }); + + it('keeps an existing deferral dormant on a qualifying join', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({ + deferred_phone_gate_enabled: true, + deferred_phone_gate_member_threshold: 1, + deferred_phone_gate_window_hours: 24, + }); + const {inviteCode} = await createGuildWithInvite(harness); + const filler = await createTestAccount(harness); + await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute(); + setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70)); + const registration = await registerUser(harness, { + email: createUniqueEmail('flagging-off-join'), + username: createUniqueUsername('flagging_off_join'), + global_name: 'Flagging Off Join', + password: 'StrongPassword!123', + date_of_birth: '2000-01-01', + consent: true, + }); + setInjectedRegistrationRiskEvaluator(undefined); + expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0); + + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute(); + + const flags = await readFlags(registration.user_id); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0); + expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0); + }); + }); + describe('phone gate escape', () => { async function configurePhoneGate( overrides: { diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index ad7b248cc..e2a93f835 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -336,6 +336,10 @@ export interface APIConfig { }; abusePolicy: { inboundPhoneCountryCodes: Array; + phoneFlagging: { + enabled: boolean; + exemptCountryCodes: Array; + }; phoneVerification: { inboundRequiredPrefixes: Array; }; diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts index 72c2e77ad..a019b883b 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts @@ -3,6 +3,7 @@ import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils'; import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes'; import {createChannelID, createRoleID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import {assertMutableUserId} from '@app/api/constants/Core'; import type {GuildMemberRow} from '@app/api/database/types/GuildTypes'; @@ -421,6 +422,13 @@ export class GuildMemberOperationsService { memberCount: guild.memberCount, accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(), }; + if ( + !Config.abusePolicy.phoneFlagging.enabled && + (getEffectiveSuspiciousFlags(user) & PHONE_REQUIREMENT_FLAGS) === 0 + ) { + Logger.info(logContext, 'deferred_phone_gate.skipped_phone_flagging_disabled'); + return; + } if (status !== 'ok') { const undeferredFlags = getEffectiveSuspiciousFlags({ ...user, diff --git a/fluxer_api/src/api/risk/AbusePolicy.ts b/fluxer_api/src/api/risk/AbusePolicy.ts index 269fb5a7b..751998ec2 100644 --- a/fluxer_api/src/api/risk/AbusePolicy.ts +++ b/fluxer_api/src/api/risk/AbusePolicy.ts @@ -1,6 +1,29 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Config} from '@app/api/Config'; +import {PHONE_REQUIREMENT_FLAGS, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; + +const EMAIL_ONLY_EQUIVALENTS: ReadonlyArray = [ + [SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL], + [SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL], + [ + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE, + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL, + ], + [ + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE, + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL, + ], +]; + +const PHONE_OFFERING_FLAGS = EMAIL_ONLY_EQUIVALENTS.reduce((mask, [either]) => mask | either, PHONE_REQUIREMENT_FLAGS); + +function withoutPhoneOfferingFlags(flagBits: number): number { + return EMAIL_ONLY_EQUIVALENTS.reduce( + (next, [either, emailOnly]) => ((flagBits & either) !== 0 ? next | emailOnly : next), + flagBits & ~PHONE_OFFERING_FLAGS, + ); +} function normalizeCountryCode(countryCode: string | null | undefined): string | null { const trimmed = countryCode?.trim(); @@ -17,6 +40,25 @@ export function countryRequiresInboundPhoneVerification(countryCode: string | nu return configuredCountrySet(Config.abusePolicy.inboundPhoneCountryCodes).has(normalized); } +export function phoneFlaggingAllowedForCountry(countryCode: string | null | undefined): boolean { + const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging; + if (!enabled) return false; + const normalized = normalizeCountryCode(countryCode); + if (!normalized) return true; + return !configuredCountrySet(exemptCountryCodes).has(normalized); +} + +export async function stripDisallowedPhoneFlags( + flagBits: number, + resolveCountryCode: () => Promise, +): Promise { + if ((flagBits & PHONE_OFFERING_FLAGS) === 0) return flagBits; + const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging; + if (enabled && exemptCountryCodes.length === 0) return flagBits; + if (enabled && phoneFlaggingAllowedForCountry(await resolveCountryCode())) return flagBits; + return withoutPhoneOfferingFlags(flagBits); +} + export function phoneRequiresInboundVerification( phone: string, prefixes: ReadonlyArray = Config.abusePolicy.phoneVerification.inboundRequiredPrefixes, diff --git a/fluxer_api/src/api/risk/__tests__/AbusePolicy.test.ts b/fluxer_api/src/api/risk/__tests__/AbusePolicy.test.ts new file mode 100644 index 000000000..5fa6c8788 --- /dev/null +++ b/fluxer_api/src/api/risk/__tests__/AbusePolicy.test.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {phoneFlaggingAllowedForCountry, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy'; +import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const PHONE_AND_EMAIL = + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | + SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | + SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION; + +describe('phone flagging policy', () => { + const original = {...Config.abusePolicy.phoneFlagging}; + + beforeEach(() => { + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: []}; + }); + + afterEach(() => { + Config.abusePolicy.phoneFlagging = original; + }); + + it('keeps phone flags by default without resolving the country', async () => { + const resolveCountryCode = vi.fn(async () => 'NG'); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(PHONE_AND_EMAIL); + expect(resolveCountryCode).not.toHaveBeenCalled(); + expect(phoneFlaggingAllowedForCountry('NG')).toBe(true); + }); + + it('strips only phone flags when disabled', async () => { + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + const resolveCountryCode = vi.fn(async () => 'NG'); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, + ); + expect(resolveCountryCode).not.toHaveBeenCalled(); + expect(phoneFlaggingAllowedForCountry('NG')).toBe(false); + expect(phoneFlaggingAllowedForCountry(null)).toBe(false); + }); + + it('strips phone flags for exempt countries only', async () => { + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: [' br', 'PT']}; + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'BR')).toBe( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, + ); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'ng')).toBe(PHONE_AND_EMAIL); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => null)).toBe(PHONE_AND_EMAIL); + expect(phoneFlaggingAllowedForCountry('pt')).toBe(false); + expect(phoneFlaggingAllowedForCountry('NG')).toBe(true); + }); + + it('replaces email or phone flags with their email only equivalent', async () => { + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + expect( + await stripDisallowedPhoneFlags( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE | + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, + async () => null, + ), + ).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL); + expect( + await stripDisallowedPhoneFlags( + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE | + SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION, + async () => null, + ), + ).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL); + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']}; + expect( + await stripDisallowedPhoneFlags( + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE, + async () => 'BR', + ), + ).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL); + }); + + it('skips the country lookup when no phone flags are present', async () => { + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']}; + const resolveCountryCode = vi.fn(async () => 'BR'); + expect(await stripDisallowedPhoneFlags(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, resolveCountryCode)).toBe( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, + ); + expect(resolveCountryCode).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_api/src/api/rpc/RpcSessionStartService.ts b/fluxer_api/src/api/rpc/RpcSessionStartService.ts index 98bf14069..7e70314d9 100644 --- a/fluxer_api/src/api/rpc/RpcSessionStartService.ts +++ b/fluxer_api/src/api/rpc/RpcSessionStartService.ts @@ -10,7 +10,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {User} from '@app/api/models/User'; -import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy'; +import {countryRequiresInboundPhoneVerification, phoneFlaggingAllowedForCountry} from '@app/api/risk/AbusePolicy'; import { createRpcTimingNode, RpcTimingRecorder, @@ -311,6 +311,17 @@ export class RpcSessionStartService { ) { return null; } + if ( + !timeRpcStepSync(timingSteps, 'check_phone_flagging_allowed', () => + phoneFlaggingAllowedForCountry(geoipCountryIso), + ) + ) { + Logger.info( + {userId: user.id.toString(), countryIso: geoipCountryIso}, + 'Skipping configured-country inbound phone requirement: phone flagging disabled for this country', + ); + return null; + } if ( timeRpcStepSync(timingSteps, 'check_not_suspicious_flag', () => (user.flags & UserFlags.NOT_SUSPICIOUS) !== 0n) ) { diff --git a/fluxer_api/src/api/user/UserHelpers.test.ts b/fluxer_api/src/api/user/UserHelpers.test.ts index 80dc9ae82..66de2226d 100644 --- a/fluxer_api/src/api/user/UserHelpers.test.ts +++ b/fluxer_api/src/api/user/UserHelpers.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {Config} from '@app/api/Config'; import type {User} from '@app/api/models/User'; import {setInjectedAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyService'; import {setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache'; @@ -52,6 +53,20 @@ describe('deferred phone gate marker', () => { }); expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']); }); + it('keeps a deferral suppressed when the gate reads off but phone flagging is disabled', () => { + setCachedDeferredPhoneGateEnabled(false); + const original = {...Config.abusePolicy.phoneFlagging}; + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + try { + const user = createUser({ + suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN, + }); + expect(getRequiredActions(user)).toEqual([]); + expect(getEffectiveSuspiciousFlags(user)).toBe(0); + } finally { + Config.abusePolicy.phoneFlagging = original; + } + }); it('suppresses a deferred phone requirement so the account is not locked out', () => { const user = createUser({ suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN, diff --git a/fluxer_api/src/api/user/UserHelpers.ts b/fluxer_api/src/api/user/UserHelpers.ts index 37b2fe17a..9f503798b 100644 --- a/fluxer_api/src/api/user/UserHelpers.ts +++ b/fluxer_api/src/api/user/UserHelpers.ts @@ -134,7 +134,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number { if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) { return rawFlags; } - if (getCachedDeferredPhoneGateEnabled() === false) { + if (getCachedDeferredPhoneGateEnabled() === false && Config.abusePolicy.phoneFlagging.enabled) { return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN; } return rawFlags & ~DEFERRABLE_PHONE_FLAGS; diff --git a/fluxer_api/src/api/user/services/UserAccountRequestService.ts b/fluxer_api/src/api/user/services/UserAccountRequestService.ts index dfb1a86bf..e5dcc5c1f 100644 --- a/fluxer_api/src/api/user/services/UserAccountRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAccountRequestService.ts @@ -12,6 +12,7 @@ import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {AuthSession} from '@app/api/models/AuthSession'; import type {User} from '@app/api/models/User'; +import {stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy'; import {createAccountPolicyContactContext, type IAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyEvaluator'; import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter'; import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository'; @@ -42,6 +43,7 @@ import { mapUserToPrivateResponse, mapUserToProfileResponse, } from '@app/api/user/UserMappers'; +import {lookupGeoip} from '@app/api/utils/IpUtils'; import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils'; @@ -302,7 +304,11 @@ export class UserAccountRequestService { action: emailSetRecommendedAction, }, }); - nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits); + const policyFlagBits = await stripDisallowedPhoneFlags( + policyDecision.flagBits, + async () => (await lookupGeoip(request)).countryCode, + ); + nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyFlagBits); if (nextSuspiciousFlags !== currentSuspiciousFlags) { user = await this.userRepository.patchUpsert( user.id, diff --git a/fluxer_desktop/electron-builder.config.cjs b/fluxer_desktop/electron-builder.config.cjs index 173407aa2..e2350c51a 100644 --- a/fluxer_desktop/electron-builder.config.cjs +++ b/fluxer_desktop/electron-builder.config.cjs @@ -1680,6 +1680,7 @@ module.exports = { }, deb: { packageCategory: 'net', + synopsis: 'Instant messaging and VoIP', desktop: { entry: linuxDesktopEntryWithActions, desktopActions: linuxDesktopActions, diff --git a/fluxer_docs/src/content/docs/downloads/linux-repositories.md b/fluxer_docs/src/content/docs/downloads/linux-repositories.md index 6f0076724..6edf9cd75 100644 --- a/fluxer_docs/src/content/docs/downloads/linux-repositories.md +++ b/fluxer_docs/src/content/docs/downloads/linux-repositories.md @@ -19,6 +19,16 @@ sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources \ sudo apt update && sudo apt install fluxer ``` +For the canary channel, add `fluxer-canary.sources` and install `fluxer-canary`. + +``` +sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources \ + https://pkgs.fluxer.com/deb/fluxer-canary.sources +sudo apt update && sudo apt install fluxer-canary +``` + +The stable entry does not list `fluxer-canary`. A canary `.deb` installed from a download stays on its version until the canary entry is added. + The `.sources` entry uses `Signed-By` rather than `Trusted: yes`, so `apt update` verifies the repository and prints nothing. ## dnf @@ -34,6 +44,14 @@ sudo dnf install fluxer The `.repo` file names the signing key by URL, so dnf fetches it rather than needing the keyring step the apt entry has. Without the `rpm --import` line dnf asks to import twice on a first install, once for the repository metadata and once for the package. With it dnf asks once, for the metadata, which dnf keeps in its own key store. Both prompts print the fingerprint, which reads `09D01339EE128925F75E675C855C5BDE34D205D2`. +For the canary channel, add `fluxer-canary.repo` and install `fluxer-canary`. + +``` +sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo \ + https://pkgs.fluxer.com/rpm/fluxer-canary.repo +sudo dnf install fluxer-canary +``` + Metadata expires after six hours, so a freshly published build becomes visible within that window, or immediately with `dnf --refresh upgrade`. :::caution[RHEL, Rocky, Alma and CentOS Stream need EPEL] diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 5aeef8e61..35140c40e 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -991,6 +991,14 @@ No default. The account risk policy. JSON. Malformed JSON fails startup, and an Default empty. Allowed inbound phone countries. Comma separated, passed through unvalidated. +#### `FLUXER_ABUSE_PHONE_FLAGGING_ENABLED` + +Default `true`. Automatic phone requirements. With this off, registration, setting an email and gateway session start never add a requirement that offers phone verification. An email or phone requirement becomes its email only form. A deferred phone requirement stays dormant on a community join. Requirements already on an account and flags set by an Admin are untouched. + +#### `FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES` + +Default empty. Countries that never get an automatic phone requirement, matched against the request's GeoIP country. An email or phone requirement becomes its email only form. Comma separated, unvalidated. + #### `FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES` Default empty. Required inbound prefixes. Comma separated. diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 91f8867fd..05dd14612 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -277,6 +277,10 @@ function defaultConfig(): MasterConfig { }, abuse_policy: { inbound_phone_country_codes: [], + phone_flagging: { + enabled: true, + exempt_country_codes: [], + }, phone_verification: { inbound_required_prefixes: [], }, diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 629eabf3e..7a007e56a 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -341,6 +341,10 @@ export interface MasterConfig { }; abuse_policy: { inbound_phone_country_codes: Array; + phone_flagging: { + enabled: boolean; + exempt_country_codes: Array; + }; phone_verification: { inbound_required_prefixes: Array; }; diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 17d8e188f..aac4eb5a7 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -464,6 +464,8 @@ describe('ConfigLoader', () => { FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: 'https://status.example/history', FLUXER_INSTANCE_SETUP_CONFIGURED: 'true', FLUXER_ABUSE_INBOUND_PHONE_COUNTRY_CODES: 'AA,BB', + FLUXER_ABUSE_PHONE_FLAGGING_ENABLED: 'false', + FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES: 'CC,DD', FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES: '+101,+202', FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ENABLED: 'true', FLUXER_ABUSE_DIRECT_CONTACT_SPAM_COUNTRY_CODES: 'AA,BB', @@ -492,6 +494,10 @@ describe('ConfigLoader', () => { expect(config.instance.setup.configured).toBe(true); expect(config.instance.abuse_policy).toEqual({ inbound_phone_country_codes: ['AA', 'BB'], + phone_flagging: { + enabled: false, + exempt_country_codes: ['CC', 'DD'], + }, phone_verification: { inbound_required_prefixes: ['+101', '+202'], }, diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 21b46b0f1..94f15a651 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -369,6 +369,14 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { path: ['instance', 'abuse_policy', 'inbound_phone_country_codes'], parse: parseCsv, }, + FLUXER_ABUSE_PHONE_FLAGGING_ENABLED: { + path: ['instance', 'abuse_policy', 'phone_flagging', 'enabled'], + parse: parseBoolean, + }, + FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES: { + path: ['instance', 'abuse_policy', 'phone_flagging', 'exempt_country_codes'], + parse: parseCsv, + }, FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES: { path: ['instance', 'abuse_policy', 'phone_verification', 'inbound_required_prefixes'], parse: parseCsv,