import { fileURLToPath } from 'node:url'; import { randomBytes } from 'node:crypto'; export type Config = { production: boolean; appUrl: string; databasePath: string; sessionSecret: string; discoverySecret: string; discoveryEnabled: boolean; trustedProxies: string[]; port: number; host: string; guestDays: number; requestSeconds: number; uploadStartHours: number; spaceMinutes: number; spaceMax: number; offlineGraceMs: number; maxUrlBytes: number; maxItems: number; urlRetentionDays: number; sessionRate: number; ipRate: number; pairRate: number; rejectCooldownMs: number; uploadDir: string; maxFileBytes: number; guestQuota: number; totalGuestQuota: number; minFreeBytes: number; fileRetentionDays: number; orphanHours: number; undoSeconds: number; registrationMode: 'open' | 'closed'; setupToken?: string; mailFrom: string; resendApiKey?: string; userQuota: number; emailRequestDays: number; webRoot?: string; }; export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const production = env.NODE_ENV === 'production'; const num = (key: string, fallback: number, min = 1) => { const n = Number(env[key] ?? fallback); if (!Number.isSafeInteger(n) || n < min) throw Error(`Invalid ${key}`); return n; }; const secret = (key: string) => { const v = env[key]; if (production && (!v || v.length < 32 || v.startsWith('replace-'))) throw Error(`${key} must be a random secret (32+ characters)`); return v && v.length >= 32 ? v : randomBytes(32).toString('base64url'); }; const port = num('PORT', 0, 0); if (production && port === 0) throw Error('Set PORT explicitly before deployment'); const appUrl = env.APP_URL ?? 'http://127.0.0.1'; if (production && !appUrl.startsWith('https://')) throw Error('Production APP_URL must use HTTPS'); return { production, appUrl, databasePath: env.DATABASE_PATH ?? fileURLToPath(new URL('../../../.data/hop.sqlite', import.meta.url)), sessionSecret: secret('SESSION_SECRET'), discoverySecret: secret('DISCOVERY_SECRET'), discoveryEnabled: env.SAME_NETWORK_DISCOVERY_ENABLED === undefined ? !production : env.SAME_NETWORK_DISCOVERY_ENABLED === 'true', trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '') .split(',') .map((v) => v.trim()) .filter(Boolean), port, host: env.HOST ?? '127.0.0.1', guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30), requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60), uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24), spaceMinutes: num('SPACE_RETENTION_MINUTES', 10), spaceMax: num('SPACE_MAX_DEVICES', 10), offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0), maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024, maxItems: num('MAX_ITEMS_PER_TRANSFER', 20), urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)), sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5), ipRate: num('IP_REQUESTS_PER_MINUTE', 20), pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2), rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000, uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)), maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576, guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576, totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 1048576, minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576, fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)), orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24), undoSeconds: num('DELETE_UNDO_SECONDS', 5), registrationMode: env.REGISTRATION_MODE === 'open' ? 'open' : 'closed', setupToken: env.SETUP_TOKEN, resendApiKey: env.RESEND_API_KEY?.trim() || undefined, mailFrom: env.MAIL_FROM ?? 'hop. ', userQuota: num('USER_STORAGE_LIMIT_MB', 10240) * 1048576, emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7), webRoot: env.WEB_ROOT, }; }