feat(captcha): make ALTCHA the only captcha (#3035)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 17:00:15 +02:00
1 parent d433a039b5
commit 4f968bbc47
209 files changed
+4097 -8692

No files matched your search

@@ -39,6 +39,7 @@ const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const LEGACY_ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
@@ -150,36 +151,54 @@ describe('InstanceConfigRepository', () => {
});
});
it('reports the effective captcha provider as none while the selected pair is incomplete', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
it('turns the captcha on at the default difficulty when no row is stored', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setInstanceIntegrationsConfig({
captcha: {
provider: 'turnstile',
hcaptcha_site_key: 'hcaptcha-site-key',
hcaptcha_secret_key: 'hcaptcha-secret-key',
},
});
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: true, cost: 5000, max_counter: 1000});
});
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
enabled: false,
provider: 'none',
});
it('ignores a legacy altcha captcha row that turned the experiment off', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setInstanceIntegrationsConfig({
captcha: {
turnstile_site_key: 'turnstile-site-key',
turnstile_secret_key: 'turnstile-secret-key',
},
});
await repository.setConfig(
LEGACY_ALTCHA_CAPTCHA_CONFIG_KEY,
JSON.stringify({enabled: false, config_version: 4, cost: 5000, max_counter: 10000}),
);
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
enabled: true,
provider: 'turnstile',
});
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: true, cost: 5000, max_counter: 1000});
});
it('merges a partial captcha update onto the stored config', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.updateCaptchaConfig({cost: 2000});
await repository.updateCaptchaConfig({enabled: false});
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: false, cost: 2000, max_counter: 1000});
});
it('drops a legacy captcha integration, secrets included, on the next integrations write', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setConfig(
INSTANCE_INTEGRATIONS_CONFIG_KEY,
JSON.stringify({
captcha: {provider: 'legacy-provider', site_key: 'legacy-site-key', secret_key: 'legacy-secret-key'},
youtube: {api_key: 'youtube-key'},
}),
);
expect(await repository.getInstanceIntegrationsConfig()).not.toHaveProperty('captcha');
await repository.setInstanceIntegrationsConfig({gif: {klipy_api_key: 'klipy-key'}});
const stored = JSON.parse((await repository.getConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY)) ?? '{}');
expect(stored).not.toHaveProperty('captcha');
expect(stored.youtube.api_key).toBe('youtube-key');
expect(stored.gif.klipy_api_key).toBe('klipy-key');
});
it('keeps the stored setup state when a branding field is invalid', async () => {
@@ -34,9 +34,10 @@ import {
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
type AltchaCaptchaConfig,
AltchaCaptchaConfigSchema,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
type CaptchaConfig,
CaptchaConfigSchema,
type CaptchaConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {
type DomainMigrationConfig,
DomainMigrationConfigSchema,
@@ -64,8 +65,6 @@ import {
type InstanceAppPublic,
InstanceAppPublicSchema,
type InstanceBranding,
type InstanceCaptchaProvider,
InstanceCaptchaProviderSchema,
type InstanceCommunity,
type InstanceRegistration,
InstanceRegistrationSchema,
@@ -79,7 +78,7 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const CAPTCHA_CONFIG_KEY = 'captcha_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -189,14 +188,6 @@ interface InstanceYoutubeIntegrationConfig {
api_key: string | null;
}
interface InstanceCaptchaIntegrationConfig {
provider: InstanceCaptchaProvider | null;
hcaptcha_site_key: string | null;
hcaptcha_secret_key: string | null;
turnstile_site_key: string | null;
turnstile_secret_key: string | null;
}
interface InstanceEmailSmtpIntegrationConfig {
host: string | null;
port: number | null;
@@ -232,7 +223,6 @@ interface InstanceBlueskyIntegrationConfig {
interface InstanceIntegrationsConfig {
gif: InstanceGifIntegrationConfig;
youtube: InstanceYoutubeIntegrationConfig;
captcha: InstanceCaptchaIntegrationConfig;
email: InstanceEmailIntegrationConfig;
bluesky: InstanceBlueskyIntegrationConfig;
}
@@ -243,15 +233,6 @@ interface InstanceGifEffectiveConfig {
available: boolean;
}
export interface InstanceCaptchaEffectiveConfig {
enabled: boolean;
provider: InstanceCaptchaProvider;
hcaptcha_site_key: string | null;
hcaptcha_secret_key: string | null;
turnstile_site_key: string | null;
turnstile_secret_key: string | null;
}
interface InstanceIntegrationsAdminConfig {
gif: {
klipy_api_key_set: boolean;
@@ -261,15 +242,6 @@ interface InstanceIntegrationsAdminConfig {
api_key_set: boolean;
effective_available: boolean;
};
captcha: {
provider: InstanceCaptchaProvider | null;
effective_provider: InstanceCaptchaProvider;
hcaptcha_site_key: string | null;
hcaptcha_secret_key_set: boolean;
turnstile_site_key: string | null;
turnstile_secret_key_set: boolean;
effective_enabled: boolean;
};
email: {
enabled: boolean | null;
effective_enabled: boolean;
@@ -336,7 +308,6 @@ interface InstanceMediaAdminConfig {
interface InstanceIntegrationsConfigPatch {
gif?: Partial<InstanceGifIntegrationConfig>;
youtube?: Partial<InstanceYoutubeIntegrationConfig>;
captcha?: Partial<InstanceCaptchaIntegrationConfig>;
email?: Partial<Omit<InstanceEmailIntegrationConfig, 'smtp'>> & {
smtp?: Partial<InstanceEmailSmtpIntegrationConfig>;
};
@@ -435,7 +406,7 @@ type StoredConfigSection =
| 'gateway rollout'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'captcha'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -596,8 +567,8 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig {
return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
@@ -718,15 +689,6 @@ const StoredBlueskyKeysSchema = z
const StoredInstanceIntegrationsSchema = z.object({
gif: z.object({klipy_api_key: StoredIntegrationStringSchema}).prefault({}),
youtube: z.object({api_key: StoredIntegrationStringSchema}).prefault({}),
captcha: z
.object({
provider: InstanceCaptchaProviderSchema.nullable().default(null),
hcaptcha_site_key: StoredIntegrationStringSchema,
hcaptcha_secret_key: StoredIntegrationStringSchema,
turnstile_site_key: StoredIntegrationStringSchema,
turnstile_secret_key: StoredIntegrationStringSchema,
})
.prefault({}),
email: z
.object({
enabled: StoredNullableBooleanSchema,
@@ -1270,7 +1232,7 @@ export class InstanceConfigRepository {
);
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1384,20 +1346,14 @@ export class InstanceConfigRepository {
);
}
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(raw);
async getCaptchaConfig(): Promise<CaptchaConfig> {
const raw = await this.getConfig(CAPTCHA_CONFIG_KEY);
return parseStoredCaptchaConfig(raw);
}
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
await this.updateAltchaCaptchaConfig(() => config);
}
updateAltchaCaptchaConfig(
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
): Promise<AltchaCaptchaConfig> {
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
updateCaptchaConfig(patch: CaptchaConfigUpdateRequest): Promise<CaptchaConfig> {
return this.updateStoredConfig(CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(CaptchaConfigSchema, {...parseStoredCaptchaConfig(raw), ...patch}, 'captcha'),
);
}
@@ -1545,10 +1501,6 @@ export class InstanceConfigRepository {
...current.youtube,
...(config.youtube ?? {}),
},
captcha: {
...current.captcha,
...(config.captcha ?? {}),
},
email: {
...current.email,
...(config.email ?? {}),
@@ -1642,33 +1594,6 @@ export class InstanceConfigRepository {
return integrations.youtube.api_key ?? normalizeOptionalString(Config.youtube.apiKey);
}
async getEffectiveCaptchaConfig(): Promise<InstanceCaptchaEffectiveConfig> {
const integrations = await this.getInstanceIntegrationsConfig();
const provider = integrations.captcha.provider ?? (Config.captcha.enabled ? Config.captcha.provider : 'none');
const hcaptchaSiteKey =
integrations.captcha.hcaptcha_site_key ?? normalizeOptionalString(Config.captcha.hcaptcha?.siteKey);
const hcaptchaSecretKey =
integrations.captcha.hcaptcha_secret_key ?? normalizeOptionalString(Config.captcha.hcaptcha?.secretKey);
const turnstileSiteKey =
integrations.captcha.turnstile_site_key ?? normalizeOptionalString(Config.captcha.turnstile?.siteKey);
const turnstileSecretKey =
integrations.captcha.turnstile_secret_key ?? normalizeOptionalString(Config.captcha.turnstile?.secretKey);
const providerReady =
provider === 'hcaptcha'
? Boolean(hcaptchaSiteKey && hcaptchaSecretKey)
: provider === 'turnstile'
? Boolean(turnstileSiteKey && turnstileSecretKey)
: false;
return {
enabled: providerReady,
provider: providerReady ? provider : 'none',
hcaptcha_site_key: hcaptchaSiteKey,
hcaptcha_secret_key: hcaptchaSecretKey,
turnstile_site_key: turnstileSiteKey,
turnstile_secret_key: turnstileSecretKey,
};
}
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
const integrations = await this.getInstanceIntegrationsConfig();
const provider = integrations.email.provider ?? Config.email.provider;
@@ -1731,11 +1656,10 @@ export class InstanceConfigRepository {
}
async getInstanceIntegrationsAdminConfig(): Promise<InstanceIntegrationsAdminConfig> {
const [integrations, gif, youtubeApiKey, captcha, email, bluesky] = await Promise.all([
const [integrations, gif, youtubeApiKey, email, bluesky] = await Promise.all([
this.getInstanceIntegrationsConfig(),
this.getEffectiveGifConfig(),
this.getEffectiveYoutubeApiKey(),
this.getEffectiveCaptchaConfig(),
this.getEffectiveEmailConfig(),
this.getEffectiveBlueskyConfig(),
]);
@@ -1748,17 +1672,6 @@ export class InstanceConfigRepository {
api_key_set: secretIsSet(integrations.youtube.api_key) || secretIsSet(Config.youtube.apiKey),
effective_available: Boolean(youtubeApiKey),
},
captcha: {
provider: integrations.captcha.provider,
effective_provider: captcha.provider,
hcaptcha_site_key: captcha.hcaptcha_site_key,
hcaptcha_secret_key_set:
secretIsSet(integrations.captcha.hcaptcha_secret_key) || secretIsSet(Config.captcha.hcaptcha?.secretKey),
turnstile_site_key: captcha.turnstile_site_key,
turnstile_secret_key_set:
secretIsSet(integrations.captcha.turnstile_secret_key) || secretIsSet(Config.captcha.turnstile?.secretKey),
effective_enabled: captcha.enabled,
},
email: {
enabled: integrations.email.enabled,
effective_enabled: email.enabled,
@@ -14,8 +14,6 @@ import {afterEach, describe, expect, it} from 'vitest';
interface DiscoveryCaptcha {
provider: string;
hcaptcha_site_key: string | null;
turnstile_site_key: string | null;
}
describe('InstanceController discovery captcha', () => {
@@ -62,40 +60,15 @@ describe('InstanceController discovery captcha', () => {
return ((await response.json()) as {captcha: DiscoveryCaptcha}).captcha;
}
it('advertises no provider and no site key while the selected pair is incomplete', async () => {
const repository = createRepository();
await repository.setInstanceIntegrationsConfig({
captcha: {
provider: 'turnstile',
hcaptcha_site_key: 'hcaptcha-site-key',
hcaptcha_secret_key: 'hcaptcha-secret-key',
},
});
await expect(readCaptcha(repository)).resolves.toEqual({
provider: 'none',
hcaptcha_site_key: null,
turnstile_site_key: null,
});
it('advertises altcha by default', async () => {
await expect(readCaptcha(createRepository())).resolves.toEqual({provider: 'altcha'});
});
it('advertises only the site key that matches the named provider', async () => {
it('advertises no provider once an admin turns the captcha off', async () => {
const repository = createRepository();
await repository.setInstanceIntegrationsConfig({
captcha: {
provider: 'turnstile',
hcaptcha_site_key: 'hcaptcha-site-key',
hcaptcha_secret_key: 'hcaptcha-secret-key',
turnstile_site_key: 'turnstile-site-key',
turnstile_secret_key: 'turnstile-secret-key',
},
});
await repository.updateCaptchaConfig({enabled: false});
await expect(readCaptcha(repository)).resolves.toEqual({
provider: 'turnstile',
hcaptcha_site_key: null,
turnstile_site_key: 'turnstile-site-key',
});
await expect(readCaptcha(repository)).resolves.toEqual({provider: 'none'});
});
it('publishes the domain migration kill switch and anonymous rollout without the targeting lists', async () => {
@@ -8,7 +8,6 @@ import {
isDiscoveryNotModified,
nextDiscoveryValidators,
} from '@app/api/instance/DiscoveryValidators';
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
@@ -17,6 +16,7 @@ import type {HonoEnv} from '@app/api/types/HonoEnv';
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
import type {CaptchaConfig} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {toDomainMigrationDiscovery} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import type {Hono} from 'hono';
@@ -27,7 +27,7 @@ function buildDiscoveryStaticInput(
gifService: GifService | undefined,
appPublic: InstanceAppPublic,
runtime: {
captcha: InstanceCaptchaEffectiveConfig;
captcha: CaptchaConfig;
emailEnabled: boolean;
},
): DiscoveryStaticInput {
@@ -61,9 +61,7 @@ function buildDiscoveryStaticInput(
webapp: Config.endpoints.webApp,
},
captcha: {
provider: runtime.captcha.provider,
hcaptcha_site_key: runtime.captcha.provider === 'hcaptcha' ? runtime.captcha.hcaptcha_site_key : null,
turnstile_site_key: runtime.captcha.provider === 'turnstile' ? runtime.captcha.turnstile_site_key : null,
provider: runtime.captcha.enabled ? 'altcha' : 'none',
},
features: {
voice_enabled: Config.voice.enabled,
@@ -111,7 +109,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
instanceConfigRepository.getInstanceCommunityPublicConfig(),
instanceConfigRepository.getResolvedServicesConfig(),
instanceConfigRepository.getAppPublicConfig(),
instanceConfigRepository.getEffectiveCaptchaConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getEffectiveEmailConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
]);