import * as fs from 'fs'; import { join, dirname, isAbsolute, resolve, sep } from 'path'; import { fileURLToPath } from 'url'; import * as crypto from 'crypto'; import request from './request.js'; import { Logger, LoggerService } from './logger.js'; import { ApplicationConfig, SelectedNode } from '../models/config.model.js'; export class CommonService { public logger: LoggerService = Logger; public nodes: SelectedNode[] = []; public selectedNode: SelectedNode = null; public ssoInit = { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }; public appConfig: ApplicationConfig = { defaultNodeIndex: 0, selectedNodeIndex: 0, rtlConfFilePath: '', dbDirectoryPath: join(dirname(fileURLToPath(import.meta.url)), '..', '..'), rtlPass: '', allowPasswordUpdate: true, enable2FA: false, secret2FA: '', SSO: this.ssoInit, nodes: [] }; public port = 3000; public host = ''; public secret_key = crypto.randomBytes(64).toString('hex'); public read_dummy_data = false; public baseHref = '/rtl'; private dummy_data_array_from_file = []; private MONTHS = [ { name: 'JAN', days: 31 }, { name: 'FEB', days: 28 }, { name: 'MAR', days: 31 }, { name: 'APR', days: 30 }, { name: 'MAY', days: 31 }, { name: 'JUN', days: 30 }, { name: 'JUL', days: 31 }, { name: 'AUG', days: 31 }, { name: 'SEP', days: 30 }, { name: 'OCT', days: 31 }, { name: 'NOV', days: 30 }, { name: 'DEC', days: 31 } ]; constructor() {} public maskPasswords = (obj) => { // Clone up front: masking a live config object must not blank the credentials LN // requests authenticate with (mirrors removeSecureData). const masked = JSON.parse(JSON.stringify(obj)); const maskRecursive = (current) => { const keys = Object.keys(current); const length = keys.length; if (length !== 0) { for (let i = 0; i < length; i++) { // Header maps always carry credentials in this codebase (macaroon, rune, basic // auth). Key-substring matching cannot catch them without also hiding the *Path // fields the settings UI legitimately shows, so mask the whole map. if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') { Object.keys(current[keys[i]]).forEach((headerKey) => { current[keys[i]][headerKey] = '*'.repeat(20); }); } else if (current[keys[i]] && typeof current[keys[i]] === 'object') { // Truthiness guard: null is 'object' too and must not reach Object.keys. maskRecursive(current[keys[i]]); } if (typeof keys[i] === 'string' && ((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') || keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') || keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('rpcauth') || keys[i].toLowerCase().includes('secret2fa') || keys[i].toLowerCase().includes('cookievalue') || keys[i].toLowerCase().includes('rtlpass') || keys[i].toLowerCase().includes('runevalue')) ) { current[keys[i]] = '*'.repeat(20); } } } return current; }; return maskRecursive(masked); }; public removeAuthSecureData = (node: SelectedNode) => { if (node.authentication) { delete node.authentication.macaroonPath; delete node.authentication.runePath; delete node.authentication.runeValue; delete node.authentication.lnApiPassword; delete node.authentication.options; } return node; }; public removeSecureData = (config: ApplicationConfig) => { // Clone before deleting: cookieValue is runtime-only, so mutating a caller's live // appConfig would destroy SSO state with no way to restore it. const sanitized = JSON.parse(JSON.stringify(config)); delete sanitized.rtlConfFilePath; delete sanitized.rtlPass; delete sanitized.multiPass; delete sanitized.multiPassHashed; delete sanitized.secret2FA; // The SSO cookie is a live bearer credential; it must never leave the server. if (sanitized.SSO) { delete sanitized.SSO.cookieValue; } sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node)); return sanitized; }; public addSecureData = (config: ApplicationConfig) => { config.rtlConfFilePath = this.appConfig.rtlConfFilePath; config.rtlPass = this.appConfig.rtlPass; // Pin the hash only when the server holds one: on a default install's first boot the // file already has multiPassHashed but the in-memory config does not, and pinning // undefined would erase the only password from the file on save, bricking the boot. if (this.appConfig.multiPassHashed) { config.multiPassHashed = this.appConfig.multiPassHashed; } else { delete config.multiPassHashed; } // Deployment-level switches are pinned to server-held values: the settings API must // not flip the authentication mode (disableAuth, SSO) or move SSO fields, the // password policy, or the database location; no UI flow writes them. Pinning the // whole SSO object also means a trimmed or missing SSO object can never wipe server // state. config.disableAuth = this.appConfig.disableAuth; config.allowPasswordUpdate = this.appConfig.allowPasswordUpdate; config.dbDirectoryPath = this.appConfig.dbDirectoryPath; config.SSO = JSON.parse(JSON.stringify(this.appConfig.SSO || {})); if (this.appConfig.multiPass) { config.multiPass = this.appConfig.multiPass; } // Restore the TOTP seed when the client omits it — and when it sends an empty seed // while still claiming 2FA is on (an inconsistent pair no honest flow produces). // The settings UI's enable flow sends a non-empty seed; its disable flow sends an // empty seed with enable2FA false. Both are honored. if (config.secret2FA === undefined || (config.secret2FA === '' && config.enable2FA)) { config.secret2FA = this.appConfig.secret2FA; } // enable2FA derives from the seed, matching the boot-time derivation in config.ts, // so the two fields can never diverge after a save. config.enable2FA = !!config.secret2FA; const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []); config.nodes?.forEach((node) => { const appConfigNode = appConfigNodes.get(node.index); if (appConfigNode?.authentication) { node.authentication = node.authentication || {}; if (appConfigNode.authentication.macaroonPath) { node.authentication.macaroonPath = appConfigNode.authentication.macaroonPath; } if (appConfigNode.authentication.runePath) { node.authentication.runePath = appConfigNode.authentication.runePath; } if (appConfigNode.authentication.lnApiPassword) { node.authentication.lnApiPassword = appConfigNode.authentication.lnApiPassword; } } }); return config; }; public setSwapServerOptions = (req) => { const swapOptions = { baseUrl: req.session.selectedNode.settings.swapServerUrl, uri: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': '' } }; if (req.session.selectedNode.authentication.swapMacaroonPath) { try { swapOptions.headers = { 'Grpc-Metadata-macaroon': fs.readFileSync(join(req.session.selectedNode.authentication.swapMacaroonPath, 'loop.macaroon')).toString('hex') }; } catch (err) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err }); } } this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options Set' }); return swapOptions; }; public getBoltzServerOptions = (req) => { const boltzOptions = { url: req.session.selectedNode.settings.boltzServerUrl, rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': '' } }; if (req.session.selectedNode.authentication.boltzMacaroonPath) { try { boltzOptions.headers = { 'Grpc-Metadata-macaroon': fs.readFileSync(join(req.session.selectedNode.authentication.boltzMacaroonPath, 'admin.macaroon')).toString('hex') }; } catch (err) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err }); } } this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options Set' }); return boltzOptions; }; public getOptions = (req) => { if (req.session.selectedNode && req.session.selectedNode.authentication.options) { req.session.selectedNode.authentication.options.method = (req.session.selectedNode.lnImplementation && req.session.selectedNode.lnImplementation.toUpperCase() === 'LND') ? 'GET' : 'POST'; delete req.session.selectedNode.authentication.options.form; delete req.session.selectedNode.authentication.options.body; req.session.selectedNode.authentication.options.qs = {}; return req.session.selectedNode.authentication.options; } return this.handleError({ statusCode: 401, message: 'Session expired after a day\'s inactivity' }, 'Session Expired', 'Session Expiry Error', this.selectedNode); }; public updateSelectedNodeOptions = (req) => { if (!req.session.selectedNode) { req.session.selectedNode = {}; } req.session.selectedNode.authentication.options = { url: '', rejectUnauthorized: false, json: true, form: null }; try { if (req.session.selectedNode && req.session.selectedNode.lnImplementation) { switch (req.session.selectedNode.lnImplementation.toUpperCase()) { case 'CLN': try { if (!req.session.selectedNode.authentication.runeValue) { req.session.selectedNode.authentication.runeValue = this.getRuneValue(req.session.selectedNode.authentication.runePath); } req.session.selectedNode.authentication.options.headers = { rune: req.session.selectedNode.authentication.runeValue }; } catch (err) { throw new Error(err); } break; case 'ECL': req.session.selectedNode.authentication.options.headers = { authorization: 'Basic ' + Buffer.from(':' + req.session.selectedNode.authentication.lnApiPassword).toString('base64') }; break; default: req.session.selectedNode.authentication.options.headers = { 'Grpc-Metadata-macaroon': fs.readFileSync(join(req.session.selectedNode.authentication.macaroonPath, 'admin.macaroon')).toString('hex') }; break; } } if (req.session.selectedNode) { this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode }); } return { status: 200, message: 'Updated Successfully' }; } catch (err) { req.session.selectedNode.authentication.options = { url: '', rejectUnauthorized: false, json: true, form: null }; this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Update Selected Node Options Error', error: err }); return { status: 502, message: err }; } }; public getRuneValue = (rune_path) => { const data = fs.readFileSync(rune_path, 'utf8'); const pattern = /LIGHTNING_RUNE="(?[^"]+)"/; const match = data.match(pattern); if (match.groups.runeValue) { return match.groups.runeValue; } else { throw new Error('Rune not found in the file.'); } }; public setOptions = (req) => { if (this.nodes && this.nodes.length > 0) { this.nodes.forEach((node) => { if (node.authentication.options && node.authentication.options.headers) { return; } node.authentication.options = { url: '', rejectUnauthorized: false, json: true, form: null }; try { if (node.lnImplementation) { switch (node.lnImplementation.toUpperCase()) { case 'CLN': try { if (!node.authentication.runeValue) { node.authentication.runeValue = this.getRuneValue(node.authentication.runePath); } node.authentication.options.headers = { rune: node.authentication.runeValue }; } catch (err) { throw new Error(err); } break; case 'ECL': node.authentication.options.headers = { authorization: 'Basic ' + Buffer.from(':' + node.authentication.lnApiPassword).toString('base64') }; break; default: node.authentication.options.headers = { 'Grpc-Metadata-macaroon': fs.readFileSync(join(node.authentication.macaroonPath, 'admin.macaroon')).toString('hex') }; break; } } } catch (err) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Common Set Options Error', error: err }); node.authentication.options = { url: '', rejectUnauthorized: false, json: true, form: '' }; } this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode }); }); this.updateSelectedNodeOptions(req); } }; public findNode = (selNodeIndex) => this.nodes.find((node) => node.index === selNodeIndex); public replaceNode = (req, newNode) => { const foundIndex = this.nodes.findIndex((node) => node.index === req.session.selectedNode.index); this.nodes.splice(foundIndex, 1, newNode); req.session.selectedNode = this.findNode(req.session.selectedNode.index); }; public convertTimeToEpoch = (timeToConvert: Date) => Math.floor(timeToConvert.getTime() / 1000); public convertTimestampToTime = (num) => { const myDate = new Date(+num * 1000); let days = myDate.getDate().toString(); days = +days < 10 ? '0' + days : days; let hours = myDate.getHours().toString(); hours = +hours < 10 ? '0' + hours : hours; let minutes = myDate.getMinutes().toString(); minutes = +minutes < 10 ? '0' + minutes : minutes; let seconds = myDate.getSeconds().toString(); seconds = +seconds < 10 ? '0' + seconds : seconds; return days + '/' + this.MONTHS[myDate.getMonth()].name + '/' + myDate.getFullYear() + ' ' + hours + ':' + minutes + ':' + seconds; }; public sortAscByKey = (array, key) => array.sort((a, b) => { const x = +a[key]; const y = +b[key]; return ((x < y) ? -1 : ((x > y) ? 1 : 0)); }); public sortAscByStrKey = (array, key) => array.sort((a, b) => { const x = a[key] ? a[key].toUpperCase() : ''; const y = b[key] ? b[key].toUpperCase() : ''; return ((x < y) ? -1 : ((x > y) ? 1 : 0)); }); public sortDescByKey = (array, key) => { const temp = array.sort((a, b) => { const x = +a[key] ? +a[key] : 0; const y = +b[key] ? +b[key] : 0; return (x > y) ? -1 : ((x < y) ? 1 : 0); }); return temp; }; public sortDescByStrKey = (array, key) => { const temp = array.sort((a, b) => { const x = a[key] ? a[key].toUpperCase() : ''; const y = b[key] ? b[key].toUpperCase() : ''; return (x > y) ? -1 : ((x < y) ? 1 : 0); }); return temp; }; public newestOnTop = (array, key, value) => { const newlyAddedRecord = array.splice(array.findIndex((item) => item[key] === value), 1); array?.unshift(newlyAddedRecord[0]); return array; }; public camelCase = (str: string) => str?.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => (word.toUpperCase()))?.replace(/\s+/g, '')?.replace(/-/g, ' '); public titleCase = (str: string) => { if (str.indexOf('!\n') > 0 || str.indexOf('.\n') > 0) { return str.split('\n')?.reduce((accumulator, currentStr) => accumulator + currentStr.charAt(0).toUpperCase() + currentStr.substring(1).toLowerCase() + '\n', ''); } else { if (str.indexOf(' ') > 0) { return str.split(' ')?.reduce((accumulator, currentStr) => accumulator + currentStr.charAt(0).toUpperCase() + currentStr.substring(1).toLowerCase() + ' ', ''); } else { return str.charAt(0).toUpperCase() + str.substring(1).toLowerCase(); } } }; public handleError = (errRes, fileName, errMsg, selectedNode: SelectedNode) => { const err = JSON.parse(JSON.stringify(errRes)); if (!selectedNode) { selectedNode = this.selectedNode; } switch (selectedNode.lnImplementation) { case 'LND': if (err.options && err.options.headers && err.options.headers['Grpc-Metadata-macaroon']) { delete err.options.headers['Grpc-Metadata-macaroon']; } if (err.response && err.response.request && err.response.request.headers && err.response.request.headers['Grpc-Metadata-macaroon']) { delete err.response.request.headers['Grpc-Metadata-macaroon']; } break; case 'CLN': if (err.options && err.options.headers && err.options.headers.rune) { delete err.options.headers.rune; } if (err.response && err.response.request && err.response.request.headers && err.response.request.headers.rune) { delete err.response.request.headers.rune; } break; case 'ECL': if (err.options && err.options.headers && err.options.headers.authorization) { delete err.options.headers.authorization; } if (err.response && err.response.request && err.response.request.headers && err.response.request.headers.authorization) { delete err.response.request.headers.authorization; } break; default: if (err.options && err.options.headers) { delete err.options.headers; } break; } this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) }); let newErrorObj = { statusCode: 500, message: '', error: '' }; if (err.code && err.code === 'ENOENT') { // The absolute path stays in the server log above but is not echoed to clients. newErrorObj = { statusCode: 500, message: 'No such file or directory', error: 'No such file or directory' }; } else { newErrorObj = { statusCode: err.statusCode ? err.statusCode : err.status ? err.status : (err.error && err.error.code && err.error.code === 'ECONNREFUSED') ? 503 : 500, message: (err.error && err.error.message) ? err.error.message : err.message ? err.message : errMsg, error: ( (err.error && err.error.error && err.error.error.error && typeof err.error.error.error === 'string') ? err.error.error.error : (err.error && err.error.error && typeof err.error.error === 'string') ? err.error.error : (err.error && err.error.error && err.error.error.message && typeof err.error.error.message === 'string') ? err.error.error.message : (err.error && err.error.message && typeof err.error.message === 'string') ? err.error.message : (err.error && typeof err.error === 'string') ? err.error : (err.message && typeof err.message === 'string') ? err.message : (typeof err === 'string') ? err : 'Unknown Error' ) }; } if (selectedNode.lnImplementation === 'ECL' && err.message && err.message.indexOf('Authentication Error') < 0 && err.name && err.name === 'StatusCodeError') { newErrorObj.statusCode = 500; } return newErrorObj; }; public getRequestIP = (req) => ((typeof req.headers['x-forwarded-for'] === 'string' && req.headers['x-forwarded-for'].split(',').shift()) || req.ip || req.connection.remoteAddress || req.socket.remoteAddress || (req.connection.socket ? req.connection.socket.remoteAddress : null)); public getDummyData = (dataKey, lnImplementation) => { const dummyDataFile = this.appConfig.rtlConfFilePath + sep + 'ECLDummyData.log'; return new Promise((resolve, reject) => { if (this.dummy_data_array_from_file.length === 0) { fs.readFile(dummyDataFile, 'utf8', (err, data) => { if (err) { if (err.code === 'ENOENT') { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Dummy data file does not exist' }); } else { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Getting dummy data failed' }); } } else { this.dummy_data_array_from_file = data.split('\n'); resolve(this.filterData(dataKey, lnImplementation)); } }); } else { resolve(this.filterData(dataKey, lnImplementation)); } }); }; public readCookie = (config) => { this.appConfig.SSO = config.SSO; const exists = fs.existsSync(this.appConfig.SSO.rtlCookiePath); if (exists) { try { this.appConfig.SSO.cookieValue = fs.readFileSync(this.appConfig.SSO.rtlCookiePath, 'utf-8'); } catch (err) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Something went wrong while reading cookie: \n' + err }); throw new Error(err); } } else { try { const directoryName = dirname(this.appConfig.SSO.rtlCookiePath); this.createDirectory(directoryName); fs.writeFileSync(this.appConfig.SSO.rtlCookiePath, crypto.randomBytes(64).toString('hex')); this.appConfig.SSO.cookieValue = fs.readFileSync(this.appConfig.SSO.rtlCookiePath, 'utf-8'); } catch (err) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Something went wrong while reading the cookie: \n' + err }); throw new Error(err); } } }; public refreshCookie = () => { try { fs.writeFileSync(this.appConfig.SSO.rtlCookiePath, crypto.randomBytes(64).toString('hex')); this.appConfig.SSO.cookieValue = fs.readFileSync(this.appConfig.SSO.rtlCookiePath, 'utf-8'); } catch (err) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Something went wrong while refreshing cookie', error: err }); throw new Error(err); } }; public createDirectory = (directoryName) => { const initDir = isAbsolute(directoryName) ? sep : ''; directoryName.split(sep)?.reduce((parentDir, childDir) => { const curDir = resolve(parentDir, childDir); try { if (!fs.existsSync(curDir)) { fs.mkdirSync(curDir); } } catch (err) { if (err.code !== 'EEXIST') { if (err.code === 'ENOENT') { throw new Error(`ENOENT: No such file or directory, mkdir '${directoryName}'. Ensure that the path separator is '${sep}'`); } else { throw err; } } } return curDir; }, initDir); }; public replacePasswordWithHash = (multiPassHashed) => { this.appConfig.rtlConfFilePath = process.env.RTL_CONFIG_PATH ? process.env.RTL_CONFIG_PATH : join(dirname(fileURLToPath(import.meta.url)), '../..'); try { const RTLConfFile = this.appConfig.rtlConfFilePath + sep + 'RTL-Config.json'; const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); config.multiPassHashed = multiPassHashed; delete config.multiPass; fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Please note that, RTL has encrypted the plaintext password into its corresponding hash' }); return config.multiPassHashed; } catch (err) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Password hashing failed', error: err }); } }; public getAllNodeAllChannelBackup = (node: SelectedNode) => { const channel_backup_file = node.settings.channelBackupPath + sep + 'channel-all.bak'; const options = { url: node.settings.lnServerUrl + '/v1/channels/backup', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': fs.readFileSync(node.authentication.macaroonPath + '/admin.macaroon').toString('hex') } }; this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Getting Channel Backup for Node ' + node.lnNode + '..' }); request(options).then((body) => { fs.writeFile(channel_backup_file, JSON.stringify(body), (err) => { if (err) { if (node.lnNode) { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Error in Channel Backup for Node ' + node.lnNode, error: err }); } else { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Error in Channel Backup for File ' + channel_backup_file, error: err }); } } else { if (node.lnNode) { this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Successful in Channel Backup for Node ' + node.lnNode, data: body }); } else { this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Successful in Channel Backup for File ' + channel_backup_file, data: body }); } } }); }, (err) => { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Error in Channel Backup for Node ' + node.lnNode, error: err }); fs.writeFile(channel_backup_file, '', () => { }); }); }; public isVersionCompatible = (currentVersion, checkVersion) => { if (currentVersion && currentVersion !== '') { // eslint-disable-next-line prefer-named-capture-group const pattern = /v?(\d+(\.\d+)*)/; const match = currentVersion.match(pattern); if (match && match.length && match.length > 1) { this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Global Version ' + match[1] }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Checking Compatiblility with Version ' + checkVersion }); const currentVersionArr = match[1].split('.') || []; currentVersionArr[1] = currentVersionArr[1].substring(0, 2); const checkVersionsArr = checkVersion.split('.'); checkVersionsArr[1] = checkVersionsArr[1].substring(0, 2); return (+currentVersionArr[0] > +checkVersionsArr[0]) || (+currentVersionArr[0] === +checkVersionsArr[0] && +currentVersionArr[1] > +checkVersionsArr[1]) || (+currentVersionArr[0] === +checkVersionsArr[0] && +currentVersionArr[1] === +checkVersionsArr[1] && +currentVersionArr[2] >= +checkVersionsArr[2]); } else { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Invalid Version String ' + currentVersion }); return false; } } return false; }; public getMonthDays = (selMonth, selYear) => ((selMonth === 1 && selYear % 4 === 0) ? (this.MONTHS[selMonth].days + 1) : this.MONTHS[selMonth].days); public logEnvVariables = (req) => { const selNode = req.session.selectedNode; if (selNode && selNode.index) { this.logger.log({ selectedNode: selNode, level: 'INFO', fileName: 'Config Setup:', msg: JSON.stringify(this.removeSecureData(JSON.parse(JSON.stringify(this.appConfig)))) }); this.logger.log({ selectedNode: selNode, level: 'INFO', fileName: 'Config Setup Variable', msg: 'SSO: ' + this.appConfig.SSO.rtlSSO }); } }; public filterData = (dataKey, lnImplementation) => { let search_string = ''; if (lnImplementation === 'ECL') { switch (dataKey) { case 'GetInfo': search_string = 'INFO: GetInfo => Get Info Response: '; break; case 'Fees': search_string = 'INFO: Fees => Fee Response: '; break; case 'Payments': search_string = 'INFO: Fees => Payments Response: '; break; case 'Invoices': search_string = 'INFO: Invoice => Invoices List Received: '; break; case 'OnChainBalance': search_string = 'INFO: Onchain => Balance Received: '; break; case 'Peers': search_string = 'INFO: Peers => Peers with Alias: '; break; case 'Channels': search_string = 'INFO: Channels => Simplified Channels with Alias: '; break; default: search_string = 'Random Line'; break; } } else if (lnImplementation === 'CLN') { switch (dataKey) { case 'GetInfo': search_string = 'DEBUG: GetInfo => Node Information. '; break; case 'Fees': search_string = 'DEBUG: Fees => Fee Received. '; break; case 'Payments': search_string = 'DEBUG: Payments => Payment List Received: '; break; case 'Invoices': search_string = 'DEBUG: Invoice => Invoices List Received. '; break; case 'ChannelBalance': search_string = 'DEBUG: Channels => Local Remote Balance. '; break; case 'Peers': search_string = 'DEBUG: Peers => Peers with Alias: '; break; case 'Channels': search_string = 'DEBUG: Channels => List Channels: '; break; case 'Balance': search_string = 'DEBUG: Balance => Balance Received. '; break; case 'ForwardingHistory': search_string = 'DEBUG: Channels => Forwarding History Received: '; break; case 'UTXOs': search_string = 'DEBUG: OnChain => List Funds Received. '; break; case 'FeeRateperkb': search_string = 'DEBUG: Network => Network Fee Rates Received for perkb. '; break; case 'FeeRateperkw': search_string = 'DEBUG: Network => Network Fee Rates Received for perkw. '; break; default: search_string = 'Random Line'; break; } } const foundDataLine = this.dummy_data_array_from_file.find((dataItem) => dataItem.includes(search_string)); const dataStr = foundDataLine ? foundDataLine.substring((foundDataLine.indexOf(search_string)) + search_string.length) : '{}'; return JSON.parse(dataStr); }; public runWithConcurrencyLimit = (tasks, limit, done) => { const results = new Array(tasks?.length || 0); // 'done' must fire exactly once. Guard it: multiple runNext() completions (e.g. several // non-function task elements draining synchronously) must not send the response twice. let finished = false; const finish = () => { if (finished) { return; } finished = true; done(results); }; // No tasks: the start loop below never runs, so 'done' would never fire and the // response would hang. Resolve immediately for empty lists (e.g. a node with no peers). if (!tasks || tasks.length === 0) { return finish(); } let nextIndex = 0; let activeCount = 0; const runNext = () => { if (nextIndex >= tasks.length) { if (activeCount === 0) { finish(); // all tasks are finished } return; } const currentIndex = nextIndex++; activeCount++; const task = tasks[currentIndex]; if (typeof task !== 'function') { results[currentIndex] = { error: new Error('Invalid task at index ' + currentIndex) }; activeCount--; runNext(); return; } Promise.resolve().then(() => task()).then((result) => { results[currentIndex] = result; }).catch((err) => { results[currentIndex] = { error: err }; }).finally(() => { activeCount--; runNext(); }); }; // Normalize to at least 1: a non-positive limit would start no tasks, so 'done' (only // reached from a task's finally) would never fire and the response would hang. const startCount = Math.max(1, limit); for (let i = 0; i < startCount && i < tasks.length; i++) { runNext(); } }; } export const Common = new CommonService();