UNPKG

@iobroker/js-controller-common-db

Version:

The Library contains the common utils for the ioBroker controller which can be used by db classes too, as they do not rely on the db (circular dependencies).

1,340 lines 124 kB
import fs from 'fs-extra'; import path from 'node:path'; import semver from 'semver'; import os from 'node:os'; import forge from 'node-forge'; import deepClone from 'deep-clone'; import { exec as cpExecAsync } from 'promisify-child-process'; import { createInterface } from 'node:readline'; import { PassThrough } from 'node:stream'; import { detectPackageManager, packageManagers } from '@alcalzone/pak'; import { EXIT_CODES } from '../../lib/common/exitCodes.js'; import zlib from 'node:zlib'; import { password } from '../../lib/common/password.js'; import jwt from 'jsonwebtoken'; import axios from 'axios'; import crypto from 'node:crypto'; import { exec } from 'node:child_process'; import { URLSearchParams } from 'node:url'; import events from 'node:events'; import { maybeCallbackWithError } from '../../lib/common/maybeCallback.js'; // @ts-expect-error has no types import extend from 'node.extend'; import { setDefaultResultOrder } from 'node:dns'; import { applyAliasAutoScaling, applyAliasConvenienceConversion, applyAliasTransformer, } from '../../lib/common/aliasProcessing.js'; import * as url from 'node:url'; import { createRequire } from 'node:module'; // eslint-disable-next-line unicorn/prefer-module const thisDir = url.fileURLToPath(new URL('.', import.meta.url || `file://${__filename}`)); // eslint-disable-next-line unicorn/prefer-module const require = createRequire(import.meta.url || `file://${__filename}`); export var ERRORS; (function (ERRORS) { ERRORS["ERROR_NOT_FOUND"] = "Not exists"; ERRORS["ERROR_EMPTY_OBJECT"] = "null object"; ERRORS["ERROR_NO_OBJECT"] = "no object"; ERRORS["ERROR_DB_CLOSED"] = "DB closed"; })(ERRORS || (ERRORS = {})); events.EventEmitter.prototype.setMaxListeners(100); let npmVersion; let diskusage; const randomID = Math.round(Math.random() * 10_000_000_000_000); // Used for creation of User-Agent const VENDOR_FILE = '/etc/iob-vendor.json'; /** This file contains the version string in an official docker image */ const OFFICIAL_DOCKER_FILE = '/opt/scripts/.docker_config/.thisisdocker'; /** URL to fetch information of the latest docker image */ const DOCKER_INFO_URL = 'https://hub.docker.com/v2/namespaces/iobroker/repositories/iobroker/tags?page_size=1'; /** Time the image approx. needs to be built and published to DockerHub */ const DOCKER_HUB_BUILD_TIME_MS = 6 * 60 * 60 * 1_000; /** Version of official Docker image which started to support UI upgrade */ const DOCKER_VERSION_UI_UPGRADE = '8.1.0'; let lastCalculationOfIps; let ownIpArr = []; // Here we define all characters that are forbidden in IDs. Since we want to allow multiple // unicode character classes, we do that by OR-ing the character classes and negating the result. // Also, we can easily whitelist characters this way. // // We allow: // · Ll = lowercase letters // · Lu = uppercase letters // · Nd = numbers // · ".", "_", "-" (common in IDs) // · "/" (required for designs) // · " :!#$%&()+=@^{}|~" (for legacy reasons) // /** All characters that may not appear in an object ID. */ export const FORBIDDEN_CHARS = /[^._\-/ :!#$%&()+=@^{}|~\p{Ll}\p{Lu}\p{Nd}]+/gu; /** * recursively copy values from an old object to new one * * @param oldObj source object * @param newObj destination object * @param originalObj optional object for read __no_change__ values * @param isNonEdit optional indicator if copy is in nonEdit part */ export function copyAttributes(oldObj, newObj, originalObj, isNonEdit) { for (const attr of Object.keys(oldObj)) { if (oldObj[attr] === undefined || oldObj[attr] === null || typeof oldObj[attr] !== 'object' || oldObj[attr] instanceof Array) { if (oldObj[attr] === '__no_change__' && originalObj && !isNonEdit) { if (originalObj[attr] !== undefined) { newObj[attr] = deepClone(originalObj[attr]); } else { console.log(`Attribute ${attr} ignored by copying`); } } else if (oldObj[attr] === '__delete__' && !isNonEdit) { if (newObj[attr] !== undefined) { delete newObj[attr]; } } else { newObj[attr] = oldObj[attr]; } } else { newObj[attr] = newObj[attr] || {}; copyAttributes(oldObj[attr], newObj[attr], originalObj && originalObj[attr], isNonEdit || attr === 'nonEdit'); } } } /** * Checks the flag nonEdit and restores non-changeable values if required * * @param oldObject source object * @param newObject destination object */ export function checkNonEditable(oldObject, newObject) { if (!oldObject) { return true; } if (!oldObject.nonEdit && !newObject.nonEdit) { return true; } // if nonEdit is protected with password if (oldObject.nonEdit?.passHash) { // If new Object wants to update the nonEdit information if (newObject.nonEdit?.password) { const hash = crypto.createHash('sha256').update(newObject.nonEdit.password.toString()).digest('base64'); if (oldObject.nonEdit.passHash !== hash) { delete newObject.nonEdit; return false; } oldObject.nonEdit = deepClone(newObject.nonEdit); delete oldObject.nonEdit.password; delete newObject.nonEdit.password; oldObject.nonEdit.passHash = hash; newObject.nonEdit.passHash = hash; copyAttributes(newObject.nonEdit, newObject, newObject); if (newObject.nonEdit.passHash) { delete newObject.nonEdit.passHash; } if (newObject.nonEdit?.password) { delete newObject.nonEdit.password; } return true; } newObject.nonEdit = oldObject.nonEdit; } else if (newObject.nonEdit) { oldObject.nonEdit = deepClone(newObject.nonEdit); if (newObject.nonEdit.password) { const hash = crypto.createHash('sha256').update(newObject.nonEdit.password.toString()).digest('base64'); delete oldObject.nonEdit.password; delete newObject.nonEdit.password; oldObject.nonEdit.passHash = hash; newObject.nonEdit.passHash = hash; } } // restore settings copyAttributes(oldObject.nonEdit, newObject, oldObject); if (newObject.nonEdit?.passHash) { delete newObject.nonEdit.passHash; } if (newObject.nonEdit?.password) { delete newObject.nonEdit.password; } return true; } /** * Checks if a version is up-to-date, throws error on invalid version strings * * @param repoVersion version in repository * @param installedVersion the current installed version */ export function upToDate(repoVersion, installedVersion) { // Check if the installed version is at least the repo version return semver.gte(installedVersion, repoVersion); } // TODO: this is only here for backward compatibility, if MULTIHOST password was still setup with old decryption export function decryptPhrase(password, data, callback) { const decipher = crypto.createDecipher('aes192', password); try { let decrypted = ''; decipher.on('readable', () => { const data = decipher.read(); if (data) { decrypted += data.toString('utf8'); } }); decipher.on('error', error => { console.error(`Cannot decode secret: ${error.message}`); callback(null); }); decipher.on('end', () => callback(decrypted)); decipher.write(data, 'hex'); decipher.end(); } catch (e) { console.error(`Cannot decode secret: ${e.message}`); callback(null); } } /** * Checks if multiple host objects exists, without using object views * * @param objects the objects db * @returns true if only one host object exists */ export async function isSingleHost(objects) { const res = await objects.getObjectList({ startkey: 'system.host.', endkey: 'system.host.\u9999', }); const hostObjs = res.rows.filter(obj => obj.value && obj.value.type === 'host'); return hostObjs.length <= 1; // on setup no host object is there yet } /** * Checks if at least one host is running in a Multihost environment * * @param objects the objects db * @param states the states db * @returns true if one or more hosts running else false */ export async function isHostRunning(objects, states) { // do it without an object view for now, TODO: can be reverted if no one downgrades to < 4 (redis-sets) // const res = await objects.getObjectViewAsync('system', 'host', { startkey: '', endkey: '\u9999' }); const res = await objects.getObjectList({ startkey: 'system.host.', endkey: 'system.host.\u9999', }); // TODO: this check should be redundant as soon as we go back to the object view approach res.rows = res.rows.filter(obj => obj.value?.type === 'host'); for (const hostObj of res.rows) { const state = await states.getState(`${hostObj.id}.alive`); if (state && state.val) { return true; } } return false; } /** * Checks if ioBroker is installed in a dev environment */ export function isDevInstallation() { return fs.pathExistsSync(`${getControllerDir()}/../../packages/controller`); } /** * Get the app name either for prod or for dev installation */ function getAppName() { if (isDevInstallation()) { // dev install - GitHub folder is uppercase return 'ioBroker'; } return 'iobroker'; } export const appNameLowerCase = 'iobroker'; export const appName = getAppName(); export function findIPs() { if (!lastCalculationOfIps || Date.now() - lastCalculationOfIps > 10000) { lastCalculationOfIps = Date.now(); ownIpArr = []; try { const ifaces = os.networkInterfaces(); for (const iface of Object.values(ifaces)) { iface?.forEach(details => !details.internal && ownIpArr.push(details.address)); } } catch (e) { console.error(`Can not find local IPs: ${e.message}`); } } return ownIpArr; } function findPath(path, url) { if (!url) { return ''; } if (url.startsWith('http://') || url.startsWith('https://')) { return url; } if (path.startsWith('http://') || path.startsWith('https://')) { return (path + url).replace(/\/\//g, '/').replace('http:/', 'http://').replace('https:/', 'https://'); } if (url[0] === '/') { return `${thisDir}/..${url}`; } return `${thisDir}/../${path}${url}`; } /** * Get MAC address of this host */ async function getMac() { const macRegex = /(?:[a-z0-9]{2}[:-]){5}[a-z0-9]{2}/gi; const zeroRegex = /(?:[0]{2}[:-]){5}[0]{2}/; const command = process.platform.indexOf('win') === 0 ? 'getmac' : 'ifconfig || ip link'; const { stdout, stderr } = await execAsync(command); if (typeof stderr === 'string') { throw new Error(stderr); } if (typeof stdout !== 'string') { throw new Error(`Unexpected stdout: ${stdout?.toString()}`); } let macAddress; let match; let result = null; while (true) { match = macRegex.exec(stdout); if (!match) { break; } macAddress = match[0]; if (!zeroRegex.test(macAddress) && !result) { result = macAddress; } } if (result === null) { throw new Error(`Could not determine the mac address from:\n${stdout}`); } return result.replace(/-/g, ':').toLowerCase(); } /** * Fetch the image information of the newest available (official) ioBroker Docker image from DockerHub */ export async function getNewestDockerImageVersion() { const res = await axios.get(DOCKER_INFO_URL); const dockerResult = res.data.results[0]; const isNew = new Date(dockerResult.last_updated).getTime() > new Date(process.env.BUILD).getTime() + DOCKER_HUB_BUILD_TIME_MS; return { version: dockerResult.name, lastUpdated: dockerResult.last_updated, isNew }; } /** * Get information of a Docker installation */ export function getDockerInformation() { try { const versionString = fs.readFileSync(OFFICIAL_DOCKER_FILE, { encoding: 'utf-8' }).trim(); return { isDocker: true, isOfficial: true, officialVersion: versionString }; } catch { // ignore error } return { isDocker: isDocker(), isOfficial: false }; } /** * Controller UI upgrade is not supported on Windows and MacOS */ export function isControllerUiUpgradeSupported() { const dockerInfo = getDockerInformation(); if (dockerInfo.isDocker) { if (!dockerInfo.isOfficial) { return false; } if (!semver.valid(dockerInfo.officialVersion) || semver.lt(dockerInfo.officialVersion, DOCKER_VERSION_UI_UPGRADE)) { return false; } return true; } return !['win32', 'darwin'].includes(os.platform()); } /** * Checks if we are running inside a docker container */ export function isDocker() { try { // deprecated, works only with docker daemon fs.statSync('/.dockerenv'); return true; } catch { // ignore error } try { // ioBroker docker image specific, will be created during a build process fs.statSync(OFFICIAL_DOCKER_FILE); return true; } catch { // ignore error } try { // check a docker group, works in most cases, but not on arm return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker'); } catch { return false; } } /** * Build unique uuid based on MAC address if possible * * @param givenMac the given MAC address */ async function uuid(givenMac) { givenMac = givenMac ?? ''; const _isDocker = isDocker(); // return constant UUID for all CI environments to keep the statistics clean if (require('ci-info').isCI) { return '55travis-pipe-line-cior-githubaction'; } let mac = givenMac !== null ? givenMac || '' : null; let u; if (!_isDocker && mac === '') { const ifaces = os.networkInterfaces(); // Find first not empty MAC for (const iface of Object.values(ifaces)) { if (iface) { for (const entry of iface) { if (entry.mac !== '00:00:00:00:00:00') { mac = entry.mac; break; } } } if (mac) { break; } } } if (!_isDocker && mac === '') { const mac = await getMac(); return uuid(mac); } if (!_isDocker && mac) { const md5sum = crypto.createHash('md5'); md5sum.update(mac); mac = md5sum.digest('hex'); u = `${mac.substring(0, 8)}-${mac.substring(8, 12)}-${mac.substring(12, 16)}-${mac.substring(16, 20)}-${mac.substring(20)}`; } else { // Returns a RFC4122 compliant v4 UUID https://gist.github.com/LeverOne/1308368 (DO WTF YOU WANT TO PUBLIC LICENSE) let a; let b; b = a = ''; while (a++ < 36) { b += (a * 51) & 52 ? (a ^ 15 ? 8 ^ (Math.random() * (a ^ 20 ? 16 : 4)) : 4).toString(16) : '-'; } u = b; } return u; } /** * Update the installation UUID * * @param newUuid the new UUID to set * @param _objects the objects DB instance */ async function updateUuid(newUuid, _objects) { let _uuid = await uuid(''); _uuid = newUuid || _uuid; // Add vendor prefix to UUID if (fs.existsSync(VENDOR_FILE)) { try { const vendor = await fs.readJSON(VENDOR_FILE); if (vendor.vendor?.uuidPrefix?.length === 2 && !_uuid.startsWith(vendor.vendor.uuidPrefix)) { _uuid = vendor.vendor.uuidPrefix + _uuid; } } catch { console.error(`Cannot parse ${VENDOR_FILE}`); } } try { await _objects.setObject('system.meta.uuid', { type: 'meta', common: { name: 'uuid', type: 'uuid', }, ts: new Date().getTime(), from: `system.host.${getHostName()}.tools`, native: { uuid: _uuid, }, }); } catch (e) { throw new Error(`Object system.meta.uuid cannot be updated: ${e.message}`); } const obj = await _objects.getObject('system.meta.uuid'); if (obj.native.uuid !== _uuid) { console.error('object system.meta.uuid cannot be updated: write protected'); } else { console.log(`object system.meta.uuid created: ${_uuid}`); } return _uuid; } /** * Generates a new uuid if non-existing * * @param objects - objects DB * @returns uuid if successfully created/updated */ export async function createUuid(objects) { const userObj = await objects.getObject('system.user.admin'); if (!userObj) { await new Promise(resolve => { // Default Password for user 'admin' is application name in lower case password(appName).hash(null, null, async (err, res) => { err && console.error(err); // Create user here and not in io-package.js because of hash password await objects.setObject('system.user.admin', { type: 'user', common: { name: 'admin', password: res, dontDelete: true, enabled: true, }, ts: new Date().getTime(), from: `system.host.${getHostName()}.tools`, native: {}, }); console.log('object system.user.admin created'); resolve(); }); }); } const obj = await objects.getObject('system.meta.uuid'); if (!obj?.native?.uuid) { // generate new UUID return updateUuid('', objects); } const PROBLEM_UUIDS = [ 'ab265f4a-67f9-a46a-c0b2-61e4b95cefe5', '7abd3182-d399-f7bd-da19-9550d8babede', 'deb6f2a8-fe69-5491-0a50-a9f9b8f3419c', 'ec66c85e-fc36-f6f9-f1c9-f5a2882d23c7', 'e6203b03-f5f4-253a-e4f6-b295fc543ab7', 'd659fa3d-7ef9-202a-ea23-acd0aff67b24', ]; // check if COMMON invalid docker uuid if (!PROBLEM_UUIDS.includes(obj.native.uuid)) { return; } // Read vis license const licObj = objects.getObject('system.adapter.vis.0'); if (!licObj || !licObj.native || !licObj.native.license) { return updateUuid('', objects); } // decode obj.native.license let data; try { data = jwt.decode(licObj.native.license); } catch { data = null; } if (!data || typeof data === 'string' || !data.uuid) { // generate new UUID return updateUuid('', objects); } if (data.uuid !== obj.native.uuid) { return updateUuid(data.correct ? data.uuid : '', objects); } // Show error console.warn(`Your iobroker.vis license must be updated. Please contact info@iobroker.net to get a new license!`); console.warn(`Provide following information in email: ${data.email}, invoice: ${data.invoice}`); } /** * Download file to tmp or return file name directly * * @param urlOrPath * @param fileName * @param callback */ export async function getFile(urlOrPath, fileName, callback) { // If object was read if (urlOrPath.substring(0, 'http://'.length) === 'http://' || urlOrPath.substring(0, 'https://'.length) === 'https://') { const tmpFile = `${thisDir}/../tmp/${fileName || `${Math.floor(Math.random() * 0xffffffe)}.zip`}`; try { // Add some information to user-agent, like chrome, IE and Firefox do const res = await axios.get(urlOrPath, { responseType: 'stream', headers: { 'User-Agent': `${appName}, RND: ${randomID}, N: ${process.version}`, 'Accept-Encoding': 'gzip', }, }); res.data.pipe(fs.createWriteStream(tmpFile)).on('close', () => { console.log(`downloaded ${tmpFile}`); callback && callback(tmpFile); }); } catch (e) { console.log(`Cannot download "${tmpFile}": ${e.message}`); callback && callback(tmpFile); } } else { try { if (fs.existsSync(urlOrPath)) { callback && callback(urlOrPath); } else if (fs.existsSync(`${thisDir}/../${urlOrPath}`)) { callback && callback(`${thisDir}/../${urlOrPath}`); } else if (fs.existsSync(`${thisDir}/../tmp/${urlOrPath}`)) { callback && callback(`${thisDir}/../tmp/${urlOrPath}`); } else { console.log(`File not found: ${urlOrPath}`); process.exit(EXIT_CODES.FILE_NOT_FOUND); } } catch (err) { console.log(`File "${urlOrPath}" could no be read: ${err.message}`); process.exit(EXIT_CODES.FILE_NOT_FOUND); } } } // Return content of the json file. Download it or read directly export async function getJson(urlOrPath, agent, callback) { if (typeof agent === 'function') { callback = agent; agent = ''; } agent = agent || ''; let sources = {}; // If object was read if (urlOrPath && typeof urlOrPath === 'object') { if (callback) { callback(urlOrPath); } } else if (!urlOrPath) { console.log('Empty url!'); if (callback) { callback(null); } } else { if (urlOrPath.substring(0, 'http://'.length) === 'http://' || urlOrPath.substring(0, 'https://'.length) === 'https://') { try { const res = await axios.get(urlOrPath, { headers: { 'Accept-Encoding': 'gzip', timeout: 10000, 'User-Agent': agent }, }); if (res.status !== 200 || !res.data) { throw new Error(`Invalid response, body: ${res.data}, status code: ${res.status}`); } sources = res.data; if (callback) { callback(sources, urlOrPath); } } catch (e) { console.warn(`Cannot download json from ${urlOrPath}. Error: ${e.message}`); if (callback) { callback(null, urlOrPath); } return; } } else { if (fs.existsSync(urlOrPath)) { try { sources = fs.readJSONSync(urlOrPath); } catch (e) { console.log(`Cannot parse json file from ${urlOrPath}. Error: ${e.message}`); if (callback) { callback(null, urlOrPath); } return; } if (callback) { callback(sources, urlOrPath); } } else if (fs.existsSync(`${thisDir}/../${urlOrPath}`)) { try { sources = fs.readJSONSync(`${thisDir}/../${urlOrPath}`); } catch (e) { console.log(`Cannot parse json file from ${thisDir}/../${urlOrPath}. Error: ${e.message}`); if (callback) { callback(null, urlOrPath); } return; } if (callback) { callback(sources, urlOrPath); } } else if (fs.existsSync(`${thisDir}/../tmp/${urlOrPath}`)) { try { sources = fs.readJSONSync(`${thisDir}/../tmp/${urlOrPath}`); } catch (e) { console.log(`Cannot parse json file from ${thisDir}/../tmp/${urlOrPath}. Error: ${e.message}`); if (callback) { callback(null, urlOrPath); } return; } if (callback) { callback(sources, urlOrPath); } } else { if (callback) { callback(null, urlOrPath); } } } } } /** * Return content of the json file. Download it or read directly * * @param urlOrPath URL where the json file could be found * @param agent optional agent identifier like "Windows Chrome 12.56" * @returns json object */ export async function getJsonAsync(urlOrPath, agent) { agent = agent || ''; let sources = {}; // If object was read if (urlOrPath && typeof urlOrPath === 'object') { return urlOrPath; } else if (!urlOrPath) { console.log('Empty url!'); return null; } if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) { try { const result = await axios(urlOrPath, { timeout: 10000, headers: { 'User-Agent': agent }, validateStatus: status => status !== 200, }); return result.data; } catch (e) { console.warn(`Cannot download json from ${urlOrPath}. Error: ${e.message}`); return null; } } else { if (fs.existsSync(urlOrPath)) { try { sources = fs.readJSONSync(urlOrPath); } catch (e) { console.warn(`Cannot parse json file from ${urlOrPath}. Error: ${e.message}`); return null; } return sources; } else if (fs.existsSync(`${thisDir}/../${urlOrPath}`)) { try { sources = fs.readJSONSync(`${thisDir}/../${urlOrPath}`); } catch (e) { console.warn(`Cannot parse json file from ${thisDir}/../${urlOrPath}. Error: ${e.message}`); return null; } return sources; } else if (fs.existsSync(`${thisDir}/../tmp/${urlOrPath}`)) { try { sources = fs.readJSONSync(`${thisDir}/../tmp/${urlOrPath}`); } catch (e) { console.log(`Cannot parse json file from ${thisDir}/../tmp/${urlOrPath}. Error: ${e.message}`); return null; } return sources; } return null; } } /** * Scans directory for adapters and adds information to list * * @param dirName name of the directory to scan * @param list prefilled list to extend adapters too * @param regExp regexp to check matching files */ function scanDirectory(dirName, list, regExp) { if (fs.existsSync(dirName)) { let dirs; try { dirs = fs.readdirSync(dirName); } catch (e) { console.log(`Cannot read or parse ${dirName}: ${e.message}`); return; } for (let i = 0; i < dirs.length; i++) { try { const fullPath = path.join(dirName, dirs[i]); const fileIoName = path.join(fullPath, 'io-package.json'); const fileName = path.join(fullPath, 'package.json'); if (regExp.test(dirs[i]) && fs.existsSync(fileIoName)) { const ioPackage = fs.readJSONSync(fileIoName); const package_ = fs.existsSync(fileName) ? fs.readJSONSync(fileName) : {}; const localIcon = ioPackage.common.icon ? `/adapter/${dirs[i].substring(appName.length + 1)}/${ioPackage.common.icon}` : ''; list[ioPackage.common.name] = { controller: ioPackage.common.controller || false, version: ioPackage.common.version, icon: ioPackage.common.extIcon || localIcon, localIcon, title: ioPackage.common.title, // deprecated 2021.04.18 BF titleLang: ioPackage.common.titleLang, desc: ioPackage.common.desc, platform: ioPackage.common.platform, keywords: ioPackage.common.keywords, readme: ioPackage.common.readme, type: ioPackage.common.type, license: ioPackage.common.license ? ioPackage.common.license : package_.licenses && package_.licenses.length ? package_.licenses[0].type : '', licenseUrl: package_.licenses && package_.licenses.length ? package_.licenses[0].url : '', }; } } catch (e) { console.log(`Cannot read or parse ${thisDir}/../node_modules/${dirs[i]}/io-package.json: ${e.message}`); } } } } /** * Get a list of all installed adapters and controller version on this host * * @param hostRunningVersion Version of the running js-controller, will be included in the returned information if provided * @returns object containing information about installed host */ export function getInstalledInfo(hostRunningVersion) { const result = {}; const fullPath = getControllerDir(); if (!fullPath) { console.error('Could not determine controller directory on getInstalledInfo.'); return result; } // Get info about host let ioPackage; try { ioPackage = fs.readJSONSync(path.join(fullPath, 'io-package.json')); } catch (e) { console.error(`Cannot get installed host information: ${e.message}`); } const package_ = fs.existsSync(path.join(fullPath, 'package.json')) ? fs.readJSONSync(path.join(fullPath, 'package.json')) : {}; const regExp = new RegExp(`^${appName}\\.`, 'i'); if (ioPackage) { // Add controller information result[ioPackage.common.name] = { controller: true, type: ioPackage.common.type, version: ioPackage.common.version, icon: ioPackage.common.extIcon || ioPackage.common.icon, title: ioPackage.common.title, // deprecated 2021.04.18 BF titleLang: ioPackage.common.titleLang, desc: ioPackage.common.desc, platform: ioPackage.common.platform, keywords: ioPackage.common.keywords, readme: ioPackage.common.readme, runningVersion: hostRunningVersion, license: ioPackage.common.license ? ioPackage.common.license : package_.licenses && package_.licenses.length ? package_.licenses[0].type : '', licenseUrl: package_.licenses && package_.licenses.length ? package_.licenses[0].url : '', }; } // collect adapter information scanDirectory(path.join(fullPath, 'node_modules'), result, regExp); scanDirectory(path.join(fullPath, '..'), result, regExp); return result; } /** * Reads an adapter's npm version * * @param adapter The adapter to read the npm version from. Null for the root ioBroker packet * @param callback */ function getNpmVersion(adapter, callback) { adapter = adapter ? `${appName}.${adapter}` : appName; adapter = adapter.toLowerCase(); const cliCommand = `npm view ${adapter}@latest version`; exec(cliCommand, { timeout: 2000, windowsHide: true }, (error, stdout, _stderr) => { let version; if (error) { // command failed if (typeof callback === 'function') { callback(error); return; } } else if (stdout) { version = semver.valid(stdout.trim()); } if (typeof callback === 'function') { callback(null, version); } }); } function getIoPack(sources, name, callback) { getJson(sources[name].meta, '', ioPack => { const packUrl = sources[name].meta.replace('io-package.json', 'package.json'); if (!ioPack) { if (sources._helper) { sources._helper.failCounter.push(name); } if (callback) { callback(sources, name); } } else { setImmediate(() => { getJson(packUrl, '', pack => { const version = sources[name].version; const type = sources[name].type; // If installed from git or something else, // js-controller is exception, because can be installed from npm and from git if (sources[name].url && name !== 'js-controller') { if (ioPack && ioPack.common) { sources[name] = extend(true, sources[name], ioPack.common); // overwrite type of adapter from repository if (type) { sources[name].type = type; } if (pack && pack.licenses && pack.licenses.length) { if (!sources[name].license) { sources[name].license = pack.licenses[0].type; } if (!sources[name].licenseUrl) { sources[name].licenseUrl = pack.licenses[0].url; } } } if (callback) { callback(sources, name); } } else { if (ioPack && ioPack.common) { sources[name] = extend(true, sources[name], ioPack.common); if (pack && pack.licenses && pack.licenses.length) { if (!sources[name].license) { sources[name].license = pack.licenses[0].type; } if (!sources[name].licenseUrl) { sources[name].licenseUrl = pack.licenses[0].url; } } } // overwrite type of adapter from repository if (type) { sources[name].type = type; } if (version) { sources[name].version = version; if (callback) { callback(sources, name); } } else { if (sources[name].meta.substring(0, 'http://'.length) === 'http://' || sources[name].meta.substring(0, 'https://'.length) === 'https://') { //installed from npm getNpmVersion(name, (_err, version) => { if (version) { sources[name].version = version; } else { sources[name].version = 'npm error'; } if (callback) { callback(sources, name); } }); } else { if (callback) { callback(sources, name); } } } } }); }); } }); } function _getRepositoryFile(sources, path, callback) { if (!sources._helper) { let count = 0; for (const _name in sources) { if (!Object.prototype.hasOwnProperty.call(sources, _name)) { continue; } count++; } sources._helper = { failCounter: [] }; sources._helper.timeout = setTimeout(() => { if (sources._helper) { delete sources._helper; for (const __name of Object.keys(sources)) { if (sources[__name].processed !== undefined) { delete sources[__name].processed; } } if (callback) { callback(new Error(`Timeout by read all package.json (${count}) seconds`), sources); } callback = undefined; } }, count * 1000); } for (const name of Object.keys(sources)) { if (sources[name].processed || name === '_helper') { continue; } sources[name].processed = true; if (sources[name].url) { sources[name].url = findPath(path, sources[name].url); } if (sources[name].meta) { sources[name].meta = findPath(path, sources[name].meta); } if (sources[name].icon) { sources[name].icon = findPath(path, sources[name].icon); } if (!sources[name].name && sources[name].meta) { getIoPack(sources, name, _ignore => { if (sources._helper) { if (sources._helper.failCounter.length > 10) { clearTimeout(sources._helper.timeout); delete sources._helper; for (const _name of Object.keys(sources)) { if (sources[_name].processed !== undefined) { delete sources[_name].processed; } } if (callback) { callback(new Error('Looks like there is no internet.'), sources); } callback = undefined; } else { // process next setImmediate(() => _getRepositoryFile(sources, path, callback)); } } }); return; } } // all packages are processed if (sources._helper) { let err; if (sources._helper.failCounter.length) { err = new Error(`Following packages cannot be read: ${sources._helper.failCounter.join(', ')}`); } clearTimeout(sources._helper.timeout); delete sources._helper; for (const __name of Object.keys(sources)) { if (sources[__name].processed !== undefined) { delete sources[__name].processed; } } if (callback) { callback(err, sources); } callback = undefined; } } async function _checkRepositoryFileHash(urlOrPath, additionalInfo, callback) { // read hash of file if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) { urlOrPath = urlOrPath.replace(/\.json$/, '-hash.json'); let json = null; try { const res = await axios.get(urlOrPath, { headers: { 'Accept-Encoding': 'gzip', timeout: 10000 } }); if (!res.data || res.status !== 200) { throw new Error(`Invalid response, body: ${res.data}, status code: ${res.status}`); } json = res.data; } catch (e) { console.warn(`Cannot download json from ${urlOrPath}. Error: ${e.message}`); } if (json && json.hash) { // The hash download was successful if (additionalInfo && additionalInfo.sources && json.hash === additionalInfo.hash) { // The hash is the same as for the cached sources console.log('hash unchanged, use cached sources'); callback(null, additionalInfo.sources, json.hash); } else { // Either we have no sources cached or the hash changed // => force download of new sources console.log('hash changed or no sources cached => force download of new sources'); callback(null, null, json.hash); } } else { // Could not download new sources, use the old ones console.log('failed to download new sources, use cached sources'); callback(null, additionalInfo.sources, ''); } } else { // it is a file and file has not hash callback(null, null, 0); } } /** * Get a list of all adapters and controller in some repository file or in /conf/source-dist.json * * @param urlOrPath URL starting with http:// or https:// or local file link * @param additionalInfo destination object * @param callback function (err, sources, actualHash) { } */ export function getRepositoryFile(urlOrPath, additionalInfo, callback) { let sources = {}; let _path = ''; if (typeof additionalInfo === 'function') { // @ts-expect-error: fix all fun calls then remove callback = additionalInfo; additionalInfo = {}; } additionalInfo = additionalInfo || {}; if (urlOrPath) { const parts = urlOrPath.split('/'); _path = `${parts.splice(0, parts.length - 1).join('/')}/`; } // If an object was read if (urlOrPath && typeof urlOrPath === 'object') { if (typeof callback === 'function') { callback(null, urlOrPath); } } else if (!urlOrPath) { try { const controllerDir = getControllerDir(); if (controllerDir) { sources = fs.readJSONSync(path.join(controllerDir, getDefaultDataDir(), 'sources.json')); } } catch { sources = {}; } try { const controllerDir = getControllerDir(); if (controllerDir) { const sourcesDist = fs.readJSONSync(path.join(controllerDir, 'conf', 'sources-dist.json')); sources = extend(true, sourcesDist, sources); } } catch { // continue regardless of error } for (const s of Object.keys(sources)) { if (additionalInfo[s] && additionalInfo[s].published) { sources[s].published = additionalInfo[s].published; } } _getRepositoryFile(sources, _path, err => { if (err) { console.error(`[${new Date().toString()}] ${err.message}`); } if (typeof callback === 'function') { callback(err, sources); } }); } else { let agent = ''; if (additionalInfo) { // Add some information to user-agent, like chrome, IE and Firefox do agent = `${additionalInfo.name}, RND: ${additionalInfo.randomID || randomID}, Node:${additionalInfo.node}, V:${additionalInfo.controller}`; } // load hash of file first to not load the whole 1MB of sources _checkRepositoryFileHash(urlOrPath, additionalInfo, (err, sources, actualSourcesHash) => { if (!err && sources) { // Source file was not changed typeof callback === 'function' && callback(err, sources, actualSourcesHash); } else { getJson(urlOrPath, agent, sources => { if (sources) { for (const s of Object.keys(sources)) { if (additionalInfo[s] && additionalInfo[s].published) { sources[s].published = additionalInfo[s].published; } } setImmediate(() => _getRepositoryFile(sources, _path, err => { err && console.error(`[${new Date().toString()}] ${err.message}`); typeof callback === 'function' && callback(err, sources, actualSourcesHash); })); } else { // return cached sources, because no sources found console.log(`failed to download new sources, ${additionalInfo.sources ? 'use cached sources' : 'no cached sources available'}`); return maybeCallbackWithError(callback, `Cannot read "${urlOrPath}"`, additionalInfo.sources, ''); } }); } }); } } /** * Read on repository * * @param url URL starting with http:// or https:// or local file link * @param hash actual hash * @param force Force repository update despite on hash * @param _actualRepo Actual repository JSON content */ export async function getRepositoryFileAsync(url, hash, force, _actualRepo) { let _hash; let data; if (url.startsWith('http://') || url.startsWith('https://')) { try { _hash = await axios({ url: url.replace(/\.json$/, '-hash.json'), timeout: 10_000 }); } catch { // ignore missing hash file } if (_actualRepo && !force && hash && _hash?.data && _hash.data.hash === hash) { data = _actualRepo; } else { const agent = `${appName}, RND: ${randomID}, Node:${process.version}, V:${require('@iobroker/js-controller-common/package.json').version}`; try { data = await axios({ url, timeout: 10_000, headers: { 'User-Agent': agent }, }); data = data.data; } catch (e) { throw new Error(`Cannot download repository file from "${url}": ${e.message}`); } } } else { if (fs.existsSync(url)) { try { data = JSON.parse(fs.readFileSync(url).toString('utf8')); } catch (e) { throw new Error(`Error: Cannot read or parse file "${url}": ${e}`); } } else { throw new Error(`Error: Cannot find file "${url}"`); } } return { json: data, changed: _hash?.data ? hash !== _hash.data.hash : true, hash: _hash && _hash.data ? _hash.data.hash : '', }; } /** * Sends the given object to the diagnosis server * * @param obj - diagnosis object */ export async function sendDiagInfo(obj) { const objStr = JSON.stringify(obj); console.log(`Send diag info: ${objStr}`); const params = new URLSearchParams(); params.append('data', objStr); const config = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 4000, }; try { await axios.post(`http://download.${appName}.net/diag.php`, params, config); } catch (e) { console.log(`Cannot send diag info: ${e.message}`); } } /** * Finds the adapter directory of a given adapter * * @param adapter name of the adapter, e.g., hm-rpc * @returns path to adapter directory or null if no directory found */ export function getAdapterDir(adapter) { // snip off 'iobroker.' if (adapter.toLowerCase().startsWith(`${appNameLowerCase}.`)) { adapter = adapter.substring(appName.length + 1); } // snip off instance id if (/\.\d+$/.test(adapter)) { adapter = adapter.slice(0, adapter.lastIndexOf('.')); } const possibilities = [`${appName.toLowerCase()}.${adapter}/package.json`, `${appName}.${adapter}/package.json`]; let adapterPath; for (const possibility of possibilities) { // special case to not read adapters from js-controller/node_module/adapter and check first in parent directory if (fs.existsSync(path.join(getControllerDir(), '..', possibility))) { adapterPath = path.join(getControllerDir(), '..', possibility); } else if (fs.existsSync(path.join(getControllerDir(), 'node_modules', possibility))) { adapterPath = path.join(getControllerDir(), 'node_modules', possibility); } else { try { adapterPath = require.resolve(possibility); } catch { // not found } } if (adapterPath) { break; } } if (!adapterPath) { return null; // inactive } const parts = path.normalize(adapterPath).split(/[\\/]/g); parts.pop(); return parts.join('/'); } /** * Returns the hostname of this host */ export function getHostName() { if (process.env.IOB_HOSTNAME) { return process.env.IOB_HOSTNAME; } try { const configName = getConfigFileName(); const c