fluxer/packages/api/src/Config.tsx
2026-02-21 16:41:56 +00:00

439 lines
14 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {APIConfig, BlueskyOAuthConfig} from '@fluxer/api/src/config/APIConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterZodSchema.generated';
function extractHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
throw new Error(`Invalid URL: ${url}`);
}
}
interface CsamIntegrationRaw {
enabled?: boolean;
provider?: 'photo_dna' | 'arachnid_shield';
photo_dna?: {
hash_service_url?: string;
hash_service_timeout_ms?: number;
match_endpoint?: string;
subscription_key?: string;
match_enhance?: boolean;
rate_limit_rps?: number;
};
arachnid_shield?: {
endpoint?: string;
username?: string;
password?: string;
timeout_ms?: number;
max_retries?: number;
retry_backoff_ms?: number;
};
}
interface CsamIntegrationInput {
photo_dna: MasterConfig['integrations']['photo_dna'];
csam_integration?: CsamIntegrationRaw;
}
function buildCsamIntegrationConfig(integrations: CsamIntegrationInput): {
enabled: boolean;
provider: 'photo_dna' | 'arachnid_shield';
photoDna: {
hashServiceUrl: string;
hashServiceTimeoutMs: number;
matchEndpoint: string;
subscriptionKey: string;
matchEnhance: boolean;
rateLimitRps: number;
};
arachnidShield: {
endpoint: string;
username: string;
password: string;
timeoutMs: number;
maxRetries: number;
retryBackoffMs: number;
};
} {
const csam = integrations.csam_integration;
const photoDnaLegacy = integrations.photo_dna;
return {
enabled: csam?.enabled ?? photoDnaLegacy.enabled,
provider: csam?.provider ?? 'photo_dna',
photoDna: {
hashServiceUrl: csam?.photo_dna?.hash_service_url ?? photoDnaLegacy.hash_service_url,
hashServiceTimeoutMs: csam?.photo_dna?.hash_service_timeout_ms ?? photoDnaLegacy.hash_service_timeout_ms,
matchEndpoint: csam?.photo_dna?.match_endpoint ?? photoDnaLegacy.match_endpoint,
subscriptionKey: csam?.photo_dna?.subscription_key ?? photoDnaLegacy.subscription_key,
matchEnhance: csam?.photo_dna?.match_enhance ?? photoDnaLegacy.match_enhance,
rateLimitRps: csam?.photo_dna?.rate_limit_rps ?? photoDnaLegacy.rate_limit_rps,
},
arachnidShield: {
endpoint: csam?.arachnid_shield?.endpoint ?? 'https://shield.projectarachnid.com/v1/media',
username: csam?.arachnid_shield?.username ?? '',
password: csam?.arachnid_shield?.password ?? '',
timeoutMs: csam?.arachnid_shield?.timeout_ms ?? 30000,
maxRetries: csam?.arachnid_shield?.max_retries ?? 3,
retryBackoffMs: csam?.arachnid_shield?.retry_backoff_ms ?? 1000,
},
};
}
export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (!master.internal) {
throw new Error('internal configuration is required for the API');
}
const cassandraSource = master.database.cassandra;
const s3Config = master.s3;
if (!s3Config) {
throw new Error('S3 configuration is required for the API');
}
const s3Buckets = s3Config.buckets ?? {
cdn: '',
uploads: '',
downloads: '',
reports: '',
harvests: '',
static: '',
};
if (master.database.backend === 'cassandra' && !cassandraSource) {
throw new Error('Cassandra configuration is required when database.backend is "cassandra".');
}
return {
nodeEnv: master.env === 'test' ? 'development' : master.env,
port: master.services.api.port,
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
keyspace: cassandraSource?.keyspace ?? '',
localDc: cassandraSource?.local_dc ?? '',
username: cassandraSource?.username ?? '',
password: cassandraSource?.password ?? '',
},
database: {
backend: master.database.backend,
sqlitePath: master.database.sqlite_path,
},
kv: {
url: master.internal.kv,
mode: ((master.internal as {kv_mode?: string}).kv_mode ?? 'standalone') as 'standalone' | 'cluster',
clusterNodes:
(master.internal as {kv_cluster_nodes?: Array<{host: string; port: number}>}).kv_cluster_nodes ?? [],
clusterNatMap:
(master.internal as {kv_cluster_nat_map?: Record<string, {host: string; port: number}>}).kv_cluster_nat_map ??
{},
},
nats: {
coreUrl: master.services.nats?.core_url ?? 'nats://127.0.0.1:4222',
jetStreamUrl: master.services.nats?.jetstream_url ?? 'nats://127.0.0.1:4223',
authToken: master.services.nats?.auth_token ?? '',
},
mediaProxy: {
host: extractHostname(master.internal.media_proxy),
port: new URL(master.internal.media_proxy).port
? Number.parseInt(new URL(master.internal.media_proxy).port, 10)
: 80,
secretKey: master.services.media_proxy.secret_key,
},
geoip: {
maxmindDbPath: master.geoip.maxmind_db_path,
},
proxy: {
trust_cf_connecting_ip: master.proxy.trust_cf_connecting_ip,
},
endpoints: {
apiPublic: master.endpoints.api,
apiClient: master.endpoints.api_client,
webApp: master.endpoints.app,
gateway: master.endpoints.gateway,
media: master.endpoints.media,
marketing: master.endpoints.marketing,
admin: master.endpoints.admin,
invite: master.endpoints.invite,
gift: master.endpoints.gift,
staticCdn: master.endpoints.static_cdn,
},
hosts: {
invite: extractHostname(master.endpoints.invite),
gift: extractHostname(master.endpoints.gift),
marketing: extractHostname(master.endpoints.marketing),
unfurlIgnored: master.services.api.unfurl_ignored_hosts,
},
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
region: s3Config.region,
accessKeyId: s3Config.access_key_id,
secretAccessKey: s3Config.secret_access_key,
buckets: s3Buckets,
},
email: {
enabled: master.integrations.email.enabled,
provider: master.integrations.email.provider,
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
fromEmail: master.integrations.email.from_email,
fromName: master.integrations.email.from_name,
smtp: master.integrations.email.smtp
? {
host: master.integrations.email.smtp.host,
port: master.integrations.email.smtp.port,
username: master.integrations.email.smtp.username,
password: master.integrations.email.smtp.password,
secure: master.integrations.email.smtp.secure ?? true,
}
: undefined,
},
sms: {
enabled: master.integrations.sms.enabled,
accountSid: master.integrations.sms.account_sid,
authToken: master.integrations.sms.auth_token,
verifyServiceSid: master.integrations.sms.verify_service_sid,
},
captcha: {
enabled: master.integrations.captcha.enabled,
provider: master.integrations.captcha.provider,
hcaptcha: master.integrations.captcha.hcaptcha
? {
siteKey: master.integrations.captcha.hcaptcha.site_key,
secretKey: master.integrations.captcha.hcaptcha.secret_key,
}
: undefined,
turnstile: master.integrations.captcha.turnstile
? {
siteKey: master.integrations.captcha.turnstile.site_key,
secretKey: master.integrations.captcha.turnstile.secret_key,
}
: undefined,
},
voice: {
enabled: master.integrations.voice.enabled,
apiKey: master.integrations.voice.api_key,
apiSecret: master.integrations.voice.api_secret,
webhookUrl: master.integrations.voice.webhook_url,
url: master.integrations.voice.url,
defaultRegion: master.integrations.voice.default_region,
},
search: {
engine: ((master.integrations.search as {engine?: string}).engine ?? 'meilisearch') as
| 'meilisearch'
| 'elasticsearch',
url: master.integrations.search.url,
apiKey: master.integrations.search.api_key,
username: (master.integrations.search as {username?: string}).username ?? '',
password: (master.integrations.search as {password?: string}).password ?? '',
},
stripe: {
enabled: master.integrations.stripe.enabled,
secretKey: master.integrations.stripe.secret_key,
webhookSecret: master.integrations.stripe.webhook_secret,
prices: master.integrations.stripe.prices
? {
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
monthlyEur: master.integrations.stripe.prices.monthly_eur,
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
yearlyEur: master.integrations.stripe.prices.yearly_eur,
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
gift1YearUsd: master.integrations.stripe.prices.gift_1_year_usd,
gift1YearEur: master.integrations.stripe.prices.gift_1_year_eur,
}
: undefined,
},
cloudflare: {
purgeEnabled: master.integrations.cloudflare.purge_enabled,
zoneId: master.integrations.cloudflare.zone_id,
apiToken: master.integrations.cloudflare.api_token,
},
clamav: {
enabled: master.integrations.clamav.enabled,
host: master.integrations.clamav.host,
port: master.integrations.clamav.port,
failOpen: master.integrations.clamav.fail_open,
},
photoDna: {
enabled: master.integrations.photo_dna.enabled,
hashService: {
url: master.integrations.photo_dna.hash_service_url,
timeoutMs: master.integrations.photo_dna.hash_service_timeout_ms,
},
api: {
endpoint: master.integrations.photo_dna.match_endpoint,
subscriptionKey: master.integrations.photo_dna.subscription_key,
enhance: master.integrations.photo_dna.match_enhance,
},
rateLimit: {
requestsPerSecond: master.integrations.photo_dna.rate_limit_rps,
},
},
csamIntegration: buildCsamIntegrationConfig(master.integrations as CsamIntegrationInput),
ncmec: {
enabled: master.integrations.ncmec.enabled,
baseUrl: master.integrations.ncmec.base_url,
username: master.integrations.ncmec.username,
password: master.integrations.ncmec.password,
},
alerts: {
webhookUrl: master.alerts?.webhook_url,
},
admin: {
basePath: master.services.admin.base_path,
oauthClientSecret: master.services.admin.oauth_client_secret,
},
appPublic: {
sentryDsn: master.app_public.sentry_dsn,
},
auth: {
sudoModeSecret: master.auth.sudo_mode_secret,
connectionInitiationSecret: master.auth.connection_initiation_secret,
passkeys: {
rpName: master.auth.passkeys.rp_name,
rpId: master.auth.passkeys.rp_id,
allowedOrigins: master.auth.passkeys.additional_allowed_origins,
},
vapid: {
publicKey: master.auth.vapid.public_key,
privateKey: master.auth.vapid.private_key,
email: master.auth.vapid.email,
},
bluesky: master.auth.bluesky as BlueskyOAuthConfig,
},
cookie: master.cookie,
gif: {
provider: master.integrations.gif.provider,
},
klipy: {
apiKey: master.integrations.klipy.api_key,
},
tenor: {
apiKey: master.integrations.tenor.api_key,
},
youtube: {
apiKey: master.integrations.youtube.api_key,
},
instance: {
selfHosted: master.instance.self_hosted,
autoJoinInviteCode: master.instance.auto_join_invite_code,
visionariesGuildId: master.instance.visionaries_guild_id,
operatorsGuildId: master.instance.operators_guild_id,
privateKeyPath: master.instance.private_key_path,
},
domain: {
baseDomain: master.domain.base_domain,
},
federation: master.federation?.enabled
? {
enabled: master.federation.enabled,
}
: undefined,
discovery: {
enabled: master.discovery.enabled,
minMemberCount: master.discovery.min_member_count,
},
dev: {
relaxRegistrationRateLimits: master.dev.relax_registration_rate_limits,
disableRateLimits: master.dev.disable_rate_limits,
testModeEnabled: master.dev.test_mode_enabled,
testHarnessToken: master.dev.test_harness_token,
},
csam: {
evidenceRetentionDays: master.csam.evidence_retention_days,
jobRetentionDays: master.csam.job_retention_days,
cleanupBatchSize: master.csam.cleanup_batch_size,
queue: {
timeoutMs: master.csam.queue?.timeout_ms ?? 30000,
maxEntriesPerBatch: master.csam.queue?.max_entries_per_batch ?? 5,
consumerLockTtlSeconds: master.csam.queue?.consumer_lock_ttl_seconds ?? 5,
},
},
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
push: {
publicVapidKey: master.auth.vapid.public_key,
},
queue: {
baseUrl: 'queue' in master.internal ? String(master.internal.queue) : 'http://localhost:8088/queue',
authSecret: 'queue' in master.services ? (master.services.queue as {secret?: string}).secret : undefined,
},
};
}
let _config: APIConfig | null = null;
export function initializeConfig(config: APIConfig): void {
if (_config !== null) {
return;
}
_config = config;
}
export function getConfig(): APIConfig {
if (_config === null) {
throw new Error('Config has not been initialized. Call initializeConfig() first.');
}
return _config;
}
export function resetConfig(): void {
_config = null;
}
export const Config: APIConfig = new Proxy({} as APIConfig, {
get(_target, prop: keyof APIConfig | symbol) {
if (_config === null) {
throw new Error('Config has not been initialized. Call initializeConfig() first.');
}
return _config[prop as keyof APIConfig];
},
set() {
throw new Error('Cannot modify Config directly. Use initializeConfig() instead.');
},
});