fluxer/fluxer_api/src/Config.ts
2026-01-05 23:19:05 +01:00

537 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 process from 'node:process';
import {z} from '~/Schema';
function required(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
function optional(key: string): string | undefined {
return process.env[key] || undefined;
}
function optionalInt(key: string, defaultValue: number): number {
const value = process.env[key];
if (!value) return defaultValue;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
function optionalBool(key: string, defaultValue = false): boolean {
const value = process.env[key];
if (!value) return defaultValue;
return value.toLowerCase() === 'true' || value === '1';
}
function extractHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
throw new Error(`Invalid URL: ${url}`);
}
}
function trimTrailingSlash(value: string): string {
if (value.length > 1 && value.endsWith('/')) {
return trimTrailingSlash(value.slice(0, -1));
}
return value;
}
function normalizePath(path: string): string {
const trimmed = path.trim();
if (trimmed === '' || trimmed === '/') return '';
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return trimTrailingSlash(withLeadingSlash);
}
function appendPath(endpoint: string, path: string): string {
const cleanEndpoint = trimTrailingSlash(endpoint);
const normalizedPath = normalizePath(path);
return normalizedPath ? `${cleanEndpoint}${normalizedPath}` : cleanEndpoint;
}
function parseCommaSeparated(value: string): Array<string> {
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
const ConfigSchema = z.object({
nodeEnv: z.enum(['development', 'production']),
port: z.number(),
postgres: z.object({
url: z.string(),
}),
cassandra: z.object({
hosts: z.string(),
keyspace: z.string(),
localDc: z.string(),
username: z.string(),
password: z.string(),
}),
redis: z.object({
url: z.string(),
}),
gateway: z.object({
rpcHost: z.string(),
rpcPort: z.number(),
rpcSecret: z.string(),
}),
mediaProxy: z.object({
host: z.string(),
port: z.number(),
secretKey: z.string(),
}),
geoip: z.object({
maxmindDbPath: z.string().optional(),
}),
endpoints: z.object({
apiPublic: z.string(),
apiClient: z.string(),
webApp: z.string(),
gateway: z.string(),
media: z.string(),
cdn: z.string(),
marketing: z.string(),
admin: z.string(),
invite: z.string(),
gift: z.string(),
}),
hosts: z.object({
invite: z.string(),
gift: z.string(),
marketing: z.string(),
unfurlIgnored: z.array(z.string()),
}),
s3: z.object({
endpoint: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
buckets: z.object({
cdn: z.string(),
uploads: z.string(),
reports: z.string(),
harvests: z.string(),
downloads: z.string(),
}),
}),
email: z.object({
enabled: z.boolean(),
apiKey: z.string().optional(),
webhookPublicKey: z.string().optional(),
fromEmail: z.string(),
fromName: z.string(),
}),
sms: z.object({
enabled: z.boolean(),
accountSid: z.string().optional(),
authToken: z.string().optional(),
verifyServiceSid: z.string().optional(),
}),
captcha: z.object({
enabled: z.boolean(),
provider: z.enum(['hcaptcha', 'turnstile', 'none']),
hcaptcha: z
.object({
siteKey: z.string(),
secretKey: z.string(),
})
.optional(),
turnstile: z
.object({
siteKey: z.string(),
secretKey: z.string(),
})
.optional(),
}),
voice: z.object({
enabled: z.boolean(),
apiKey: z.string().optional(),
apiSecret: z.string().optional(),
webhookUrl: z.string().optional(),
url: z.string().optional(),
autoCreateDummyData: z.boolean(),
}),
search: z.object({
enabled: z.boolean(),
url: z.string().optional(),
apiKey: z.string().optional(),
}),
stripe: z.object({
enabled: z.boolean(),
secretKey: z.string().optional(),
webhookSecret: z.string().optional(),
prices: z
.object({
monthlyUsd: z.string().optional(),
monthlyEur: z.string().optional(),
yearlyUsd: z.string().optional(),
yearlyEur: z.string().optional(),
visionaryUsd: z.string().optional(),
visionaryEur: z.string().optional(),
giftVisionaryUsd: z.string().optional(),
giftVisionaryEur: z.string().optional(),
gift1MonthUsd: z.string().optional(),
gift1MonthEur: z.string().optional(),
gift1YearUsd: z.string().optional(),
gift1YearEur: z.string().optional(),
})
.optional(),
}),
cloudflare: z.object({
purgeEnabled: z.boolean(),
zoneId: z.string().optional(),
apiToken: z.string().optional(),
}),
alerts: z.object({
webhookUrl: z.string().url().optional(),
}),
clamav: z.object({
enabled: z.boolean(),
host: z.string(),
port: z.number(),
failOpen: z.boolean(),
}),
adminOauth2: z.object({
clientId: z.string().optional(),
clientSecret: z.string().optional(),
redirectUri: z.string(),
autoCreate: z.boolean(),
}),
auth: z.object({
sudoModeSecret: z.string(),
passkeys: z.object({
rpName: z.string(),
rpId: z.string(),
allowedOrigins: z.array(z.string()),
}),
}),
cookie: z.object({
domain: z.string(),
secure: z.boolean(),
}),
tenor: z.object({
apiKey: z.string().optional(),
}),
youtube: z.object({
apiKey: z.string().optional(),
}),
instance: z.object({
selfHosted: z.boolean(),
autoJoinInviteCode: z.string().optional(),
visionariesGuildId: z.string().optional(),
operatorsGuildId: z.string().optional(),
}),
dev: z.object({
relaxRegistrationRateLimits: z.boolean(),
disableRateLimits: z.boolean(),
testModeEnabled: z.boolean(),
testHarnessToken: z.string().optional(),
}),
attachmentDecayEnabled: z.boolean(),
deletionGracePeriodHours: z.number(),
inactivityDeletionThresholdDays: z.number().optional(),
push: z.object({
publicVapidKey: z.string().optional(),
}),
metrics: z.object({
host: z.string().optional(),
}),
});
function loadConfig() {
const apiPublicEndpoint = required('FLUXER_API_PUBLIC_ENDPOINT');
const apiClientEndpoint = optional('FLUXER_API_CLIENT_ENDPOINT') || apiPublicEndpoint;
const webAppEndpoint = required('FLUXER_APP_ENDPOINT');
const gatewayEndpoint = required('FLUXER_GATEWAY_ENDPOINT');
const mediaEndpoint = required('FLUXER_MEDIA_ENDPOINT');
const cdnEndpoint = required('FLUXER_CDN_ENDPOINT');
const marketingEndpoint = appendPath(required('FLUXER_MARKETING_ENDPOINT'), required('FLUXER_PATH_MARKETING'));
const adminEndpoint = appendPath(required('FLUXER_ADMIN_ENDPOINT'), required('FLUXER_PATH_ADMIN'));
const inviteEndpoint = required('FLUXER_INVITE_ENDPOINT');
const giftEndpoint = required('FLUXER_GIFT_ENDPOINT');
const passkeyOriginsEnv = optional('PASSKEY_ALLOWED_ORIGINS');
const passkeyAllowedOrigins = passkeyOriginsEnv
? parseCommaSeparated(passkeyOriginsEnv)
: Array.from(new Set([apiPublicEndpoint, webAppEndpoint, apiClientEndpoint]));
const testModeEnabled = optionalBool('FLUXER_TEST_MODE');
const maxmindDbPath = optional('MAXMIND_DB_PATH');
return ConfigSchema.parse({
nodeEnv: optional('NODE_ENV') || 'development',
port: optionalInt('FLUXER_API_PORT', 8080),
postgres: {
url: required('DATABASE_URL'),
},
cassandra: {
hosts: required('CASSANDRA_HOSTS'),
keyspace: required('CASSANDRA_KEYSPACE'),
localDc: optional('CASSANDRA_LOCAL_DC'),
username: required('CASSANDRA_USERNAME'),
password: required('CASSANDRA_PASSWORD'),
},
redis: {
url: required('REDIS_URL'),
},
gateway: {
rpcHost: optional('FLUXER_GATEWAY_RPC_HOST') || 'gateway',
rpcPort: optionalInt('FLUXER_GATEWAY_RPC_PORT', 8081),
rpcSecret: required('GATEWAY_RPC_SECRET'),
},
mediaProxy: {
host: optional('FLUXER_MEDIA_PROXY_HOST') || 'media',
port: optionalInt('FLUXER_MEDIA_PROXY_PORT', 8080),
secretKey: required('MEDIA_PROXY_SECRET_KEY'),
},
geoip: {
maxmindDbPath,
},
endpoints: {
apiPublic: apiPublicEndpoint,
apiClient: apiClientEndpoint,
webApp: webAppEndpoint,
gateway: gatewayEndpoint,
media: mediaEndpoint,
cdn: cdnEndpoint,
marketing: marketingEndpoint,
admin: adminEndpoint,
invite: inviteEndpoint,
gift: giftEndpoint,
},
hosts: {
invite: extractHostname(inviteEndpoint),
gift: extractHostname(giftEndpoint),
marketing: extractHostname(marketingEndpoint),
unfurlIgnored: parseCommaSeparated(optional('FLUXER_UNFURL_IGNORED_HOSTS') || '').concat([
'web.fluxer.app',
'web.canary.fluxer.app',
]),
},
s3: {
endpoint: required('AWS_S3_ENDPOINT'),
accessKeyId: required('AWS_ACCESS_KEY_ID'),
secretAccessKey: required('AWS_SECRET_ACCESS_KEY'),
buckets: {
cdn: required('AWS_S3_BUCKET_CDN'),
uploads: required('AWS_S3_BUCKET_UPLOADS'),
reports: required('AWS_S3_BUCKET_REPORTS'),
harvests: required('AWS_S3_BUCKET_HARVESTS'),
downloads: required('AWS_S3_BUCKET_DOWNLOADS'),
},
},
email: {
enabled: optionalBool('EMAIL_ENABLED'),
apiKey: optional('SENDGRID_API_KEY'),
webhookPublicKey: optional('SENDGRID_WEBHOOK_PUBLIC_KEY'),
fromEmail: optional('SENDGRID_FROM_EMAIL') || 'noreply@fluxer.app',
fromName: optional('SENDGRID_FROM_NAME') || 'Fluxer',
},
sms: {
enabled: optionalBool('SMS_ENABLED'),
accountSid: optional('TWILIO_ACCOUNT_SID'),
authToken: optional('TWILIO_AUTH_TOKEN'),
verifyServiceSid: optional('TWILIO_VERIFY_SERVICE_SID'),
},
captcha: {
enabled: optionalBool('CAPTCHA_ENABLED'),
provider: (optional('CAPTCHA_PRIMARY_PROVIDER') as 'hcaptcha' | 'turnstile' | 'none') || 'none',
hcaptcha:
optional('HCAPTCHA_SITE_KEY') && optional('HCAPTCHA_SECRET_KEY')
? {
siteKey: required('HCAPTCHA_SITE_KEY'),
secretKey: required('HCAPTCHA_SECRET_KEY'),
}
: undefined,
turnstile:
optional('TURNSTILE_SITE_KEY') && optional('TURNSTILE_SECRET_KEY')
? {
siteKey: required('TURNSTILE_SITE_KEY'),
secretKey: required('TURNSTILE_SECRET_KEY'),
}
: undefined,
},
voice: {
enabled: optionalBool('VOICE_ENABLED'),
apiKey: optional('LIVEKIT_API_KEY'),
apiSecret: optional('LIVEKIT_API_SECRET'),
webhookUrl: optional('LIVEKIT_WEBHOOK_URL'),
url: optional('LIVEKIT_URL'),
autoCreateDummyData: optionalBool('LIVEKIT_AUTO_CREATE_DUMMY_DATA'),
},
search: {
enabled: optionalBool('SEARCH_ENABLED'),
url: optional('MEILISEARCH_URL'),
apiKey: optional('MEILISEARCH_API_KEY'),
},
stripe: {
enabled: optionalBool('STRIPE_ENABLED'),
secretKey: optional('STRIPE_SECRET_KEY'),
webhookSecret: optional('STRIPE_WEBHOOK_SECRET'),
prices: optionalBool('STRIPE_ENABLED')
? {
monthlyUsd: optional('STRIPE_PRICE_ID_MONTHLY_USD'),
monthlyEur: optional('STRIPE_PRICE_ID_MONTHLY_EUR'),
yearlyUsd: optional('STRIPE_PRICE_ID_YEARLY_USD'),
yearlyEur: optional('STRIPE_PRICE_ID_YEARLY_EUR'),
visionaryUsd: optional('STRIPE_PRICE_ID_VISIONARY_USD'),
visionaryEur: optional('STRIPE_PRICE_ID_VISIONARY_EUR'),
giftVisionaryUsd: optional('STRIPE_PRICE_ID_GIFT_VISIONARY_USD'),
giftVisionaryEur: optional('STRIPE_PRICE_ID_GIFT_VISIONARY_EUR'),
gift1MonthUsd: optional('STRIPE_PRICE_ID_GIFT_1_MONTH_USD'),
gift1MonthEur: optional('STRIPE_PRICE_ID_GIFT_1_MONTH_EUR'),
gift1YearUsd: optional('STRIPE_PRICE_ID_GIFT_1_YEAR_USD'),
gift1YearEur: optional('STRIPE_PRICE_ID_GIFT_1_YEAR_EUR'),
}
: undefined,
},
cloudflare: {
purgeEnabled: optionalBool('CLOUDFLARE_PURGE_ENABLED'),
zoneId: optional('CLOUDFLARE_ZONE_ID'),
apiToken: optional('CLOUDFLARE_API_TOKEN'),
},
alerts: {
webhookUrl: optional('ALERT_WEBHOOK_URL'),
},
clamav: {
enabled: optionalBool('CLAMAV_ENABLED'),
host: optional('CLAMAV_HOST') || 'clamav',
port: optionalInt('CLAMAV_PORT', 3310),
failOpen: optionalBool('CLAMAV_FAIL_OPEN', true),
},
adminOauth2: {
clientId: optional('ADMIN_OAUTH2_CLIENT_ID'),
clientSecret: optional('ADMIN_OAUTH2_CLIENT_SECRET'),
redirectUri: `${adminEndpoint}/oauth2_callback`,
autoCreate: optionalBool('ADMIN_OAUTH2_AUTO_CREATE'),
},
auth: {
sudoModeSecret: required('SUDO_MODE_SECRET'),
passkeys: {
rpName: optional('PASSKEY_RP_NAME') || 'Fluxer',
rpId: optional('PASSKEY_RP_ID') || extractHostname(webAppEndpoint),
allowedOrigins: passkeyAllowedOrigins,
},
},
cookie: {
domain: optional('FLUXER_COOKIE_DOMAIN') || '',
secure: optionalBool('FLUXER_COOKIE_SECURE', true),
},
tenor: {
apiKey: optional('TENOR_API_KEY'),
},
youtube: {
apiKey: optional('YOUTUBE_API_KEY'),
},
instance: {
selfHosted: optionalBool('SELF_HOSTED'),
autoJoinInviteCode: optional('AUTO_JOIN_INVITE_CODE'),
visionariesGuildId: optional('FLUXER_VISIONARIES_GUILD_ID'),
operatorsGuildId: optional('FLUXER_OPERATORS_GUILD_ID'),
},
dev: {
relaxRegistrationRateLimits: optionalBool('RELAX_REGISTRATION_RATE_LIMITS'),
disableRateLimits: optionalBool('DISABLE_RATE_LIMITS'),
testModeEnabled,
testHarnessToken: optional('FLUXER_TEST_TOKEN'),
},
attachmentDecayEnabled: optionalBool('ATTACHMENT_DECAY_ENABLED', true),
deletionGracePeriodHours: testModeEnabled ? 0.01 : 336,
inactivityDeletionThresholdDays: optionalInt('INACTIVITY_DELETION_THRESHOLD_DAYS', 365 * 2),
push: {
publicVapidKey: optional('VAPID_PUBLIC_KEY'),
},
metrics: {
host: optional('FLUXER_METRICS_HOST'),
},
});
}
export const Config = loadConfig();
export type Config = z.infer<typeof ConfigSchema>;