import { resolve } from 'node:path'; const allowedFields = ['username', 'display_name', 'language_code', 'guild_id', 'created_at']; export function loadConfig(env = process.env) { const integer = (key, fallback, min, max) => { const value = env[key] ?? String(fallback); if (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max) { throw new Error(`${key} must be an integer from ${min} to ${max}`); } return Number(value); }; const origin = (key, fallback) => { let url; try { url = new URL(env[key] || fallback); } catch { throw new Error(`${key} must be a valid HTTP(S) origin`); } if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { throw new Error(`${key} must be an HTTP(S) origin without credentials, path, query or fragment`); } return url; }; const ids = (key) => { const values = [...new Set((env[key] || '').split(',').map(v => v.trim()).filter(Boolean))]; if (values.some(value => !/^[1-9]\d{0,24}$/.test(value))) throw new Error(`${key} must contain comma-separated numeric user IDs`); return values; }; const publicUrl = origin('PUBLIC_URL', 'http://localhost:8080'); const upstreamUrl = origin('UPSTREAM_URL', 'http://127.0.0.1:3000'); if (publicUrl.origin === upstreamUrl.origin) throw new Error('UPSTREAM_URL cannot point to PUBLIC_URL'); if (env.NODE_ENV === 'production' && publicUrl.protocol !== 'https:') throw new Error('PUBLIC_URL must use HTTPS in production'); const secret = env.SESSION_SECRET || ''; if (secret.length < 32 || /replace-with|change.?me/i.test(secret)) throw new Error('Set SESSION_SECRET to a random secret of at least 32 characters'); if (env.TRUST_PROXY && !['true', 'false'].includes(env.TRUST_PROXY)) throw new Error('TRUST_PROXY must be true or false'); const telegram = { token: env.TELEGRAM_BOT_TOKEN || '', username: env.TELEGRAM_BOT_USERNAME || '', admins: ids('TELEGRAM_ADMIN_IDS') }; if (telegram.token && (!/^\d+:[A-Za-z0-9_-]+$/.test(telegram.token) || !/^[A-Za-z0-9_]{5,32}$/.test(telegram.username) || !telegram.admins.length)) { throw new Error('Telegram requires TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME (without @), and TELEGRAM_ADMIN_IDS'); } const discord = { token: env.DISCORD_BOT_TOKEN || '', applicationId: env.DISCORD_APPLICATION_ID || '', publicKey: env.DISCORD_PUBLIC_KEY || '', admins: ids('DISCORD_ADMIN_IDS'), accessUrl: env.DISCORD_ACCESS_URL || '', guildId: env.DISCORD_GUILD_ID || '', }; if (discord.token) { if (!/^\d{1,25}$/.test(discord.applicationId) || !/^[a-f\d]{64}$/i.test(discord.publicKey) || !discord.admins.length) { throw new Error('Discord requires DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY and DISCORD_ADMIN_IDS'); } let link; try { link = new URL(discord.accessUrl); } catch { throw new Error('Set DISCORD_ACCESS_URL to your Discord channel or invite'); } if (link.protocol !== 'https:' || !['discord.com', 'discord.gg'].includes(link.hostname) || link.username || link.password) { throw new Error('DISCORD_ACCESS_URL must be an HTTPS discord.com or discord.gg link'); } if (discord.guildId && !/^\d{1,25}$/.test(discord.guildId)) throw new Error('DISCORD_GUILD_ID must be numeric'); } if (env.NODE_ENV === 'production' && !telegram.token && !discord.token) throw new Error('Enable at least one bot in production'); const fields = (env.ADMIN_REQUEST_FIELDS ?? allowedFields.join(',')).split(',').map(v => v.trim()).filter(Boolean); if (fields.some(field => !allowedFields.includes(field))) throw new Error(`ADMIN_REQUEST_FIELDS may contain: ${allowedFields.join(', ')}`); return { publicUrl, upstreamUrl, secret, telegram, discord, adminFields: fields, host: env.HOST || '0.0.0.0', port: integer('PORT', 8080, 1, 65535), dataFile: resolve(env.DATA_FILE || './data/access.json'), secureCookie: publicUrl.protocol === 'https:', trustProxy: env.TRUST_PROXY === 'true', codeLength: integer('CODE_LENGTH', 10, 8, 12), codeTtlMs: integer('CODE_TTL_DAYS', 15, 1, 90) * 86400_000, sessionTtlMs: integer('SESSION_TTL_HOURS', 360, 1, 8760) * 3_600_000, requestTtlMs: integer('REQUEST_TTL_HOURS', 24, 1, 168) * 3_600_000, requestCooldownMs: integer('REQUEST_COOLDOWN_SECONDS', 300, 1, 86400) * 1000, loginAttempts: integer('LOGIN_ATTEMPTS', 5, 1, 100), globalLoginAttempts: integer('GLOBAL_LOGIN_ATTEMPTS', 100, 1, 10000), loginWindowMs: integer('LOGIN_WINDOW_SECONDS', 900, 1, 86400) * 1000, }; }