@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
JavaScript
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