mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
|
|
import {deriveEndpointsFromDomain} from '@fluxer/config/src/EndpointDerivation';
|
|
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
|
|
|
type ConfigObject = Record<string, unknown>;
|
|
|
|
let cachedConfig: MasterConfig | null = null;
|
|
|
|
const DEFAULT_PASSKEY_ORIGINS = [
|
|
'https://fluxer.app',
|
|
'https://web.fluxer.app',
|
|
'https://web.canary.fluxer.app',
|
|
'android:apk-key-hash:keSY4bimyLqZQV7bKXgpa2xYuqXi0qZJzsYtp6gpx7w',
|
|
'android:apk-key-hash:zRmCKDKo3uCX2GDZISjJx8Rzo3J-Y3Gbp7s7mAaUH28',
|
|
];
|
|
|
|
function defaultConfig(): MasterConfig {
|
|
return {
|
|
env: 'development',
|
|
domain: {
|
|
base_domain: '',
|
|
public_scheme: 'http',
|
|
internal_scheme: 'http',
|
|
public_port: 8088,
|
|
internal_port: 8088,
|
|
static_cdn_domain: '',
|
|
invite_domain: '',
|
|
gift_domain: '',
|
|
},
|
|
endpoints: {
|
|
api: '',
|
|
api_client: '',
|
|
app: '',
|
|
gateway: '',
|
|
media: '',
|
|
static_cdn: '',
|
|
admin: '',
|
|
marketing: '',
|
|
invite: '',
|
|
gift: '',
|
|
},
|
|
internal: {
|
|
kv: 'redis://localhost:6379/0',
|
|
kv_provider: 'redis',
|
|
kv_mode: 'standalone',
|
|
kv_cluster_nodes: [],
|
|
kv_cluster_nat_map: {},
|
|
api: 'http://127.0.0.1:8080',
|
|
media_proxy: 'http://127.0.0.1:8082',
|
|
},
|
|
database: {
|
|
backend: 'postgres',
|
|
cassandra: {
|
|
hosts: ['127.0.0.1'],
|
|
port: 9042,
|
|
keyspace: 'fluxer',
|
|
local_dc: 'datacenter1',
|
|
username: '',
|
|
password: '',
|
|
},
|
|
postgres: {
|
|
url: '',
|
|
host: '127.0.0.1',
|
|
port: 5432,
|
|
database: 'fluxer',
|
|
username: 'fluxer',
|
|
password: 'fluxer',
|
|
ssl: false,
|
|
ssl_ca: '',
|
|
max_connections: 20,
|
|
kv_table: 'fluxer_kv',
|
|
},
|
|
},
|
|
s3: {
|
|
endpoint: 'http://localhost:3900',
|
|
force_path_style: false,
|
|
region: 'local',
|
|
access_key_id: '',
|
|
secret_access_key: '',
|
|
buckets: {
|
|
cdn: 'fluxer',
|
|
uploads: 'fluxer-uploads',
|
|
downloads: 'fluxer-downloads',
|
|
reports: 'fluxer-reports',
|
|
harvests: 'fluxer-harvests',
|
|
static: 'fluxer-static',
|
|
},
|
|
},
|
|
services: {
|
|
api: {
|
|
port: 8080,
|
|
ip_ban_exempt_ips: [],
|
|
presigned_attachment_uploads_enabled: false,
|
|
presigned_downloads_enabled: false,
|
|
presigned_harvest_downloads_enabled: true,
|
|
unfurl_ignored_hosts: [],
|
|
embeds: {
|
|
oembed_html_enabled: false,
|
|
oembed_html_allow_untrusted_on_self_hosted: false,
|
|
oembed_html_allowed_hosts: [],
|
|
cache_default_ttl_seconds: 86_400,
|
|
cache_max_ttl_seconds: 604_800,
|
|
cache_min_ttl_seconds: 300,
|
|
cache_respect_remote_ttl: true,
|
|
},
|
|
content_moderation: {
|
|
nsfw_threshold: 0.7,
|
|
},
|
|
},
|
|
nats: {
|
|
core_url: 'nats://127.0.0.1:4222',
|
|
jetstream_url: 'nats://127.0.0.1:4222',
|
|
auth_token: '',
|
|
},
|
|
media_proxy: {
|
|
host: '0.0.0.0',
|
|
port: 8082,
|
|
secret_key: '',
|
|
mode: 'upload',
|
|
upload_relay: {
|
|
endpoint: 'http://localhost:8088/media',
|
|
max_body_bytes: 268_435_456,
|
|
token_ttl_secs: 900,
|
|
keep_direct_countries: [],
|
|
},
|
|
},
|
|
gateway: {
|
|
port: 8771,
|
|
push_enabled: false,
|
|
rpc_auth_token: '',
|
|
},
|
|
admin: {
|
|
port: 3020,
|
|
base_path: '/admin',
|
|
secret_key_base: '',
|
|
oauth_client_secret: '',
|
|
},
|
|
marketing: {
|
|
port: 3010,
|
|
host: '0.0.0.0',
|
|
base_path: '/marketing',
|
|
secret_key_base: '',
|
|
},
|
|
app_proxy: {
|
|
port: 8773,
|
|
assets_dir: 'fluxer_app/dist',
|
|
},
|
|
},
|
|
auth: {
|
|
sudo_mode_secret: '',
|
|
connection_initiation_secret: '',
|
|
sso_allow_private_addresses: false,
|
|
passkeys: {
|
|
rp_name: 'Fluxer',
|
|
rp_id: 'fluxer.app',
|
|
additional_allowed_origins: DEFAULT_PASSKEY_ORIGINS,
|
|
},
|
|
vapid: {
|
|
public_key: '',
|
|
private_key: '',
|
|
email: '',
|
|
},
|
|
bluesky: {
|
|
enabled: true,
|
|
client_name: 'Fluxer',
|
|
client_uri: '',
|
|
logo_uri: '',
|
|
tos_uri: 'https://fluxer.app/terms',
|
|
policy_uri: 'https://fluxer.app/privacy',
|
|
keys: [],
|
|
},
|
|
},
|
|
cookie: {
|
|
domain: '',
|
|
secure: false,
|
|
},
|
|
integrations: {
|
|
email: {
|
|
enabled: false,
|
|
provider: 'none',
|
|
from_email: '',
|
|
from_name: 'Fluxer',
|
|
},
|
|
sms: {
|
|
enabled: false,
|
|
},
|
|
captcha: {
|
|
enabled: false,
|
|
provider: 'none',
|
|
},
|
|
voice: {
|
|
enabled: false,
|
|
api_key: '',
|
|
api_secret: '',
|
|
url: '',
|
|
internal_url: '',
|
|
webhook_url: '',
|
|
},
|
|
search: {
|
|
engine: 'elasticsearch',
|
|
url: 'http://127.0.0.1:9200',
|
|
api_key: '',
|
|
username: '',
|
|
password: '',
|
|
tls_reject_unauthorized: true,
|
|
},
|
|
stripe: {
|
|
enabled: false,
|
|
secret_key: '',
|
|
webhook_secret: '',
|
|
prices: {},
|
|
},
|
|
ncmec: {
|
|
enabled: false,
|
|
base_url: '',
|
|
username: '',
|
|
password: '',
|
|
},
|
|
clamav: {
|
|
enabled: false,
|
|
host: '127.0.0.1',
|
|
port: 3310,
|
|
fail_open: false,
|
|
},
|
|
klipy: {
|
|
api_key: '',
|
|
},
|
|
youtube: {
|
|
api_key: '',
|
|
},
|
|
bunny: {
|
|
purge_enabled: false,
|
|
api_key: '',
|
|
pull_zone_id: 0,
|
|
},
|
|
blocklist_feeds: {},
|
|
risk_integration: {
|
|
enabled: false,
|
|
ipinfo_api_key: '',
|
|
account_policy_dsl: undefined,
|
|
tor: {
|
|
block_all_relays: false,
|
|
reverse_dns_heuristic: false,
|
|
reverse_dns_timeout_ms: 750,
|
|
},
|
|
},
|
|
push: {
|
|
apns: {
|
|
enabled: false,
|
|
apps: [],
|
|
},
|
|
fcm: {
|
|
enabled: false,
|
|
apps: [],
|
|
},
|
|
},
|
|
},
|
|
instance: {
|
|
self_hosted: false,
|
|
branding: {
|
|
product_name: 'Fluxer',
|
|
},
|
|
setup: {
|
|
configured: false,
|
|
},
|
|
abuse_policy: {
|
|
inbound_phone_country_codes: [],
|
|
phone_verification: {
|
|
inbound_required_prefixes: [],
|
|
},
|
|
direct_contact_spam: {
|
|
enabled: false,
|
|
country_codes: [],
|
|
distinct_target_threshold: 25,
|
|
target_window_ms: 2 * 60 * 60 * 1000,
|
|
action: 'flag_spammer',
|
|
},
|
|
},
|
|
},
|
|
dev: {
|
|
relax_registration_rate_limits: false,
|
|
disable_rate_limits: false,
|
|
test_mode_enabled: false,
|
|
},
|
|
geoip: {
|
|
maxmind_db_path: '',
|
|
},
|
|
proxy: {
|
|
trust_client_ip_header: false,
|
|
client_ip_header: 'x-forwarded-for',
|
|
},
|
|
discovery: {
|
|
enabled: true,
|
|
min_member_count: 1,
|
|
},
|
|
attachment_decay_enabled: true,
|
|
deletion_grace_period_hours: 336,
|
|
inactivity_deletion_threshold_days: 365,
|
|
};
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is ConfigObject {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function mergeConfig<T>(base: T, overrides: unknown): T {
|
|
if (!isPlainObject(base) || !isPlainObject(overrides)) {
|
|
return overrides === undefined ? base : (overrides as T);
|
|
}
|
|
const out: ConfigObject = {...base};
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
const current = out[key];
|
|
out[key] = isPlainObject(current) && isPlainObject(value) ? mergeConfig(current, value) : value;
|
|
}
|
|
return out as T;
|
|
}
|
|
|
|
function assertOneOf<T extends string>(value: string, allowed: ReadonlyArray<T>, path: string): asserts value is T {
|
|
if (!allowed.includes(value as T)) {
|
|
throw new Error(`Invalid ${path}: ${value}`);
|
|
}
|
|
}
|
|
|
|
function requireString(value: string | undefined, envName: string): void {
|
|
if (!value || value.trim().length === 0) {
|
|
throw new Error(`${envName} is required`);
|
|
}
|
|
}
|
|
|
|
function assertBoolean(value: unknown, envName: string): asserts value is boolean {
|
|
if (typeof value !== 'boolean') {
|
|
throw new Error(`${envName} must be true or false`);
|
|
}
|
|
}
|
|
|
|
function assertIntegerInRange(value: unknown, envName: string, min: number, max: number): asserts value is number {
|
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
|
|
throw new Error(`${envName} must be an integer between ${min} and ${max}`);
|
|
}
|
|
}
|
|
|
|
function assertIdentifier(value: string, envName: string): void {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) {
|
|
throw new Error(`${envName} must be a safe Postgres identifier`);
|
|
}
|
|
}
|
|
|
|
function validatePostgresConfig(config: MasterConfig): void {
|
|
const postgres = config.database.postgres;
|
|
assertIntegerInRange(postgres.port, 'FLUXER_POSTGRES_PORT', 1, 65535);
|
|
assertIntegerInRange(postgres.max_connections, 'FLUXER_POSTGRES_MAX_CONNECTIONS', 1, 1000);
|
|
assertBoolean(postgres.ssl, 'FLUXER_POSTGRES_SSL');
|
|
assertIdentifier(postgres.kv_table, 'FLUXER_POSTGRES_KV_TABLE');
|
|
if (config.env !== 'production' || config.database.backend !== 'postgres') {
|
|
return;
|
|
}
|
|
if (!postgres.url) {
|
|
requireString(postgres.host, 'FLUXER_POSTGRES_HOST');
|
|
requireString(postgres.database, 'FLUXER_POSTGRES_DATABASE');
|
|
requireString(postgres.username, 'FLUXER_POSTGRES_USERNAME');
|
|
requireString(postgres.password, 'FLUXER_POSTGRES_PASSWORD');
|
|
if (['127.0.0.1', 'localhost'].includes(postgres.host.trim().toLowerCase())) {
|
|
throw new Error('FLUXER_POSTGRES_HOST must be explicitly configured for production');
|
|
}
|
|
if (postgres.password === 'fluxer') {
|
|
throw new Error('FLUXER_POSTGRES_PASSWORD must not use the development default in production');
|
|
}
|
|
}
|
|
if (!postgres.ssl && !config.instance.self_hosted) {
|
|
throw new Error('FLUXER_POSTGRES_SSL must be true in production');
|
|
}
|
|
}
|
|
|
|
function validateApiWorkerConfig(config: MasterConfig): void {
|
|
const worker = config.services.api?.worker;
|
|
if (!worker) {
|
|
return;
|
|
}
|
|
if (worker.mode !== undefined) {
|
|
assertOneOf(worker.mode, ['all_lanes', 'single_lane', 'single_task'], 'FLUXER_API_WORKER_MODE');
|
|
}
|
|
if (worker.lane !== undefined) {
|
|
assertOneOf(worker.lane, ['realtime', 'unfurl', 'lifecycle', 'batch'], 'FLUXER_API_WORKER_LANE');
|
|
}
|
|
if (worker.mode === 'single_task') {
|
|
requireString(worker.task, 'FLUXER_API_WORKER_TASK');
|
|
}
|
|
}
|
|
|
|
function normalizeConfig(config: MasterConfig): MasterConfig {
|
|
assertOneOf(config.env, ['development', 'production', 'test'], 'FLUXER_ENV');
|
|
assertOneOf(config.domain.public_scheme, ['http', 'https'], 'FLUXER_PUBLIC_SCHEME');
|
|
assertOneOf(config.domain.internal_scheme, ['http', 'https'], 'FLUXER_INTERNAL_SCHEME');
|
|
assertOneOf(config.database.backend, ['postgres', 'cassandra'], 'FLUXER_DATABASE_BACKEND');
|
|
assertOneOf(config.internal.kv_provider, ['redis'], 'FLUXER_KV_PROVIDER');
|
|
assertOneOf(config.internal.kv_mode, ['standalone', 'cluster'], 'FLUXER_KV_MODE');
|
|
assertOneOf(config.integrations.email.provider, ['smtp', 'none'], 'FLUXER_EMAIL_PROVIDER');
|
|
assertOneOf(config.integrations.captcha.provider, ['hcaptcha', 'turnstile', 'none'], 'FLUXER_CAPTCHA_PROVIDER');
|
|
assertOneOf(config.integrations.search.engine, ['elasticsearch', 'meilisearch'], 'FLUXER_SEARCH_ENGINE');
|
|
assertOneOf(
|
|
config.instance.abuse_policy.direct_contact_spam.action,
|
|
['flag_spammer', 'suppress_delivery'],
|
|
'FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ACTION',
|
|
);
|
|
validatePostgresConfig(config);
|
|
validateApiWorkerConfig(config);
|
|
requireString(config.domain.base_domain, 'FLUXER_BASE_DOMAIN');
|
|
requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET');
|
|
requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET');
|
|
requireString(config.auth.vapid.public_key, 'FLUXER_VAPID_PUBLIC_KEY');
|
|
requireString(config.auth.vapid.private_key, 'FLUXER_VAPID_PRIVATE_KEY');
|
|
requireString(config.s3?.access_key_id, 'FLUXER_S3_ACCESS_KEY_ID');
|
|
requireString(config.s3?.secret_access_key, 'FLUXER_S3_SECRET_ACCESS_KEY');
|
|
requireString(config.services.media_proxy.secret_key, 'FLUXER_MEDIA_PROXY_SECRET_KEY');
|
|
requireString(config.services.admin.secret_key_base, 'FLUXER_ADMIN_SECRET_KEY_BASE');
|
|
requireString(config.services.admin.oauth_client_secret, 'FLUXER_ADMIN_OAUTH_CLIENT_SECRET');
|
|
if (!config.instance.self_hosted) {
|
|
requireString(config.services.marketing.secret_key_base, 'FLUXER_MARKETING_SECRET_KEY_BASE');
|
|
}
|
|
requireString(config.services.gateway.rpc_auth_token, 'FLUXER_GATEWAY_RPC_AUTH_TOKEN');
|
|
return config;
|
|
}
|
|
|
|
export async function loadConfig(): Promise<MasterConfig> {
|
|
if (cachedConfig) {
|
|
return cachedConfig;
|
|
}
|
|
const merged = mergeConfig(defaultConfig(), buildNamedFluxerEnvOverrides(process.env));
|
|
const normalized = normalizeConfig(merged);
|
|
const derived = deriveEndpointsFromDomain(normalized.domain);
|
|
const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})};
|
|
cachedConfig = {...normalized, endpoints};
|
|
return cachedConfig;
|
|
}
|
|
|
|
export function getConfig(): MasterConfig {
|
|
if (!cachedConfig) {
|
|
throw new Error('Config not loaded. Call loadConfig() first.');
|
|
}
|
|
return cachedConfig;
|
|
}
|
|
|
|
export function resetConfig(): void {
|
|
cachedConfig = null;
|
|
}
|