import { dirname } from 'node:path'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readFile } from 'fs/promises'; import { createHash } from 'crypto'; import { DEFAULT_CONFIG } from './defaultConfig.js'; import fs, { readFileSync } from 'fs'; import logger from './services/logger.js'; import { packageUp } from 'package-up'; const RE_GT = />/g; const RE_WEBP = /\/format\/webp/gi; const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i; const HTTPS_PREFIX = 'https://'; const providersDirectoryPath = `${getDirName()}/provider`; /** * Lazily load all provider modules from the provider directory. * Caches the resolved array to avoid re-importing on subsequent calls. * * @returns {Promise} A list of loaded provider modules. */ let cachedProvidersPromise = null; export function getProviders() { if (!cachedProvidersPromise) { /** @type {string[]} */ const providerFileNames = fs.readdirSync(providersDirectoryPath).filter((fileName) => fileName.endsWith('.js')); cachedProvidersPromise = Promise.all( providerFileNames.map((fileName) => import(pathToFileURL(path.join(providersDirectoryPath, fileName)).href)), ); } return cachedProvidersPromise; } /** * Safely stringify a value to JSON for storage. * - Returns null when the input is null or undefined. * - Uses JSON.stringify directly otherwise. * * @template T * @param {T} v - Any JSON-serializable value. * @returns {string|null} JSON string or null. */ const toJson = (v) => (v == null ? null : JSON.stringify(v)); /** * Safely parse JSON text coming from storage. * - Returns the provided fallback when input is null/undefined. * - Returns the fallback when parsing fails. * * @template T * @param {string|null|undefined} txt - JSON text from DB/storage. * @param {T} fallback - Value to return when txt is null/invalid. * @returns {T} Parsed value or fallback. */ const fromJson = (txt, fallback) => { if (txt == null) return fallback; try { return JSON.parse(txt); } catch { return fallback; } }; /** * Determine if the current process runs in development mode. * Returns true when NODE_ENV is not 'production'. * @returns {boolean} */ function inDevMode() { return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production'; } /** * Check if a word contains any of the strings in the given array (case-insensitive, substring match). * @param {string} word * @param {string[]} arr * @returns {boolean} */ function isOneOf(word, arr) { if (!arr || arr.length === 0 || word == null) return false; const lowerWord = word.toLowerCase(); return arr.some((item) => lowerWord.indexOf(item.toLowerCase()) !== -1); } /** * Check if a value is null or an empty string/array. * @param {any} val * @returns {boolean} */ function nullOrEmpty(val) { return val == null || val.length === 0; } /** * Convert a day time string (HH:mm) to epoch milliseconds for the given reference date. * @param {string} timeString - Format HH:mm * @param {number} now - Epoch ms used as the date basis * @returns {number} */ function timeStringToMs(timeString, now) { const d = new Date(now); const parts = timeString.split(':'); d.setHours(parts[0]); d.setMinutes(parts[1]); d.setSeconds(0); return d.getTime(); } /** * Determine whether the given timestamp is within the configured working hours, or return true when the window is not set. * - If workingHours is missing or either 'from' or 'to' is empty/null, returns true. * - Supports windows that cross midnight (e.g., from '23:00' to '06:00'). * * Time parsing is based on the local timezone of the running process. * * @param {{workingHours?: {from?: string|null, to?: string|null}}} config - Configuration object containing working hours in 'HH:mm' format. * @param {number} now - Epoch milliseconds to evaluate. * @returns {boolean} True when execution is allowed at 'now'. * @example * // Same-day window * duringWorkingHoursOrNotSet({ workingHours: { from: '08:00', to: '17:00' } }, someTime); * @example * // Window crossing midnight * // For { from: '05:00', to: '00:30' } → 23:00 => true, 01:00 => false, 06:00 => true * duringWorkingHoursOrNotSet({ workingHours: { from: '05:00', to: '00:30' } }, Date.now()); */ function duringWorkingHoursOrNotSet(config, now) { const { workingHours } = config; if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) { return true; } const toDate = timeStringToMs(workingHours.to, now); const fromDate = timeStringToMs(workingHours.from, now); // If parsing fails (e.g., malformed time), be lenient and allow. if (isNaN(toDate) || isNaN(fromDate)) { return true; } if (toDate >= fromDate) { // Same-day window (e.g., 08:00 - 17:00) return now >= fromDate && now <= toDate; } // Window crosses midnight (e.g., 05:00 -> 00:30 next day) // Accept if we are after 'from' today OR before 'to' today (which represents next day's cutoff). return now >= fromDate || now <= toDate; } /** * Return the directory name of the current module (ESM equivalent of __dirname). * @returns {string} */ function getDirName() { return dirname(fileURLToPath(import.meta.url)); } /** * Build a sha256 hash string from the provided inputs (ignores null/empty strings). * Returns null if there are no valid inputs. * @param {...(string|null|undefined)} inputs * @returns {string|null} */ function buildHash(...inputs) { if (inputs == null) { return null; } const cleaned = inputs.filter((i) => i != null && i.length > 0); if (cleaned.length === 0) { return null; } return createHash('sha256').update(cleaned.join(',')).digest('hex'); } /** * The in-memory configuration object. Call refreshConfig() to populate/update. * @type {any} */ let config = {}; /** * If the config exists, but cannot be accessed, we quit Fredy as something is fishy here. * @returns {Promise} */ export async function checkIfConfigIsAccessible() { const path = new URL('../conf/config.json', import.meta.url); try { if (!fs.existsSync(path)) { return true; } fs.accessSync(path, fs.constants.R_OK); return true; } catch { return false; } } /** * Read config JSON from disk (conf/config.json) and parse it. * @returns {Promise} Parsed configuration object. */ export async function readConfigFromStorage() { return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url))); } /** * Refresh the in-memory config, ensuring the file exists and setting backward-compatible defaults. * Populates defaults for analyticsEnabled, demoMode, sqlitepath when missing. * @returns {Promise} */ export async function refreshConfig() { checkIfConfigExistsAndWriteIfNot(); try { config = await readConfigFromStorage(); config.sqlitepath ??= '/db'; } catch (error) { config = { ...DEFAULT_CONFIG }; logger.info('Error reading config file.', error); } } /** * If the config file does not exist, create it with DEFAULT_CONFIG. * @returns {void} */ const checkIfConfigExistsAndWriteIfNot = () => { if (!fs.existsSync(`${getDirName()}/../conf/config.json`)) { logger.info('Could not find config file. Will create one with default values now'); fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...DEFAULT_CONFIG })); } }; /** * Normalize image URLs: * - Trim, remove stray '>' characters. * - Convert '/format/webp' segments to '/format/jpg'. * - Enforce HTTPS and ensure a valid image extension (jpg/png/gif). If URL contains '.jpg' without query, cut trailing parts. * - Return null for invalid inputs. * @param {string} url * @returns {string|null} */ const normalizeImageUrl = (url) => { if (typeof url !== 'string' || url.length === 0) return null; let u = url.trim().replace(RE_GT, ''); if (RE_WEBP.test(u)) u = u.replace(RE_WEBP, '/format/jpg'); if (!u.startsWith(HTTPS_PREFIX)) return null; if (!RE_EXT.test(u)) { const jpgIdx = u.toLowerCase().lastIndexOf('.jpg'); if (jpgIdx > -1) u = u.slice(0, jpgIdx + 4); } return u; }; /** * returns Fredy's version * @returns {Promise<*|string>} */ async function getPackageVersion() { try { const packagePath = await packageUp(); const packageJson = readFileSync(packagePath, 'utf8'); const json = JSON.parse(packageJson); return json.version; } catch (error) { logger.error('Error reading version from package.json', error); } return 'N/A'; } /** * Sleep helper * @param {number} ms milliseconds to wait * @returns {Promise} */ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Return a random integer between min and max (inclusive). * @param {number} min - Minimum integer value. * @param {number} max - Maximum integer value. * @returns {number} A random integer N where min <= N <= max. */ function randomBetween(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } // Call refreshConfig() from the application entrypoint during startup to populate config. await refreshConfig(); export { isOneOf, normalizeImageUrl, inDevMode, nullOrEmpty, duringWorkingHoursOrNotSet, getDirName, sleep, randomBetween, buildHash, getPackageVersion, toJson, fromJson, };