diff --git a/Common/config/default.json b/Common/config/default.json index 37dbd07e..b0b314e2 100644 --- a/Common/config/default.json +++ b/Common/config/default.json @@ -43,7 +43,7 @@ ], "template": { "title": "License", - "body": "license expires after %s!!!" + "body": "license expires in %s!!!" }, "policies": { "repeatInterval": "1d" @@ -425,7 +425,8 @@ "license" : { "license_file": "", "warning_limit_percents": 70, - "packageType": 0 + "packageType": 0, + "startNotifyFrom": "30d" }, "FileConverter": { "converter": { diff --git a/Common/sources/license.js b/Common/sources/license.js index d838ecf8..fa318669 100644 --- a/Common/sources/license.js +++ b/Common/sources/license.js @@ -37,7 +37,7 @@ const constants = require('./constants'); const buildDate = '6/29/2016'; const oBuildDate = new Date(buildDate); -exports.readLicense = function*() { +exports.readLicense = async function () { const c_LR = constants.LICENSE_RESULT; var now = new Date(); var startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));//first day of current month diff --git a/Common/sources/mailService.js b/Common/sources/mailService.js index 18ec8362..f8afe4e4 100644 --- a/Common/sources/mailService.js +++ b/Common/sources/mailService.js @@ -35,11 +35,8 @@ const config = require('config'); const nodemailer = require('nodemailer'); -const operationContext = require('./operationContext'); - const cfgConnection = config.get('email.connectionConfiguration'); -const ctx = new operationContext.Context(); const connectionDefaultSettings = { pool: true, socketTimeout: 1000 * 60 * 2, @@ -50,7 +47,7 @@ const connectionDefaultSettings = { const settings = Object.assign(connectionDefaultSettings, cfgConnection); const smtpTransporters = new Map(); -function createTransporter(host, port, auth, messageCommonParameters = {}) { +function createTransporter(ctx, host, port, auth, messageCommonParameters = {}) { const server = { host, port, @@ -80,7 +77,7 @@ async function send(host, userLogin, mailObject) { return transporter.sendMail(mailObject); } -function deleteTransporter(host, userLogin) { +function deleteTransporter(ctx, host, userLogin) { const transporter = smtpTransporters.get(`${host}:${userLogin}`); if (!transporter) { ctx.logger.error(`MailService: no transporter exists for host "${host}" and user "${userLogin}"`); @@ -96,10 +93,6 @@ function transportersRelease() { smtpTransporters.clear(); } -function isCreated(host, user) { - return smtpTransporters -} - module.exports = { createTransporter, send, diff --git a/Common/sources/notificationService.js b/Common/sources/notificationService.js index 92e4c6bf..9508223c 100644 --- a/Common/sources/notificationService.js +++ b/Common/sources/notificationService.js @@ -56,23 +56,30 @@ class MailTransport extends TransportInterface { host = cfgMailServer.host; port = cfgMailServer.port; auth = cfgMailServer.auth; + isTemplateRecipientData = false; constructor() { super(); mailService.createTransporter(this.host, this.port, this.auth, cfgMailMessageDefaults); + this.isTemplateRecipientData = cfgMailMessageDefaults.from === 'from.mail@server.com' || cfgMailMessageDefaults.to === 'to.mail@server.com'; } async send(ctx, message) { ctx.logger.info('Notification service: MailTransport send %j', message); - return mailService.send(this.host, this.auth.user, message); + if (this.isTemplateRecipientData) { + + ctx.logger.warn('Notification service: Recipient or sender e-mail address is a template address, message will not be sent.'); + return; + } + + return mailService.send(ctx, this.host, this.auth.user, message); } contentGeneration(template, messageParams) { - let text = util.format(template.body, ...messageParams); return { subject: template.title, - text: text + text: util.format(template.body, ...messageParams) }; } } @@ -105,21 +112,23 @@ class Transport { async function notify(ctx, notificationType, messageParams) { ctx.logger.debug('Notification service: notify "%s"', notificationType); - let tenRule; - tenRule = ctx.getCfg('notification.rules.' + notificationType, config.get('notification.rules.' + notificationType)); + const tenRule = ctx.getCfg(`notification.rules.${notificationType}`, config.get(`notification.rules.${notificationType}`)); if (tenRule && checkRulePolicies(ctx, notificationType, tenRule)) { await notifyRule(ctx, tenRule, messageParams); } } + function checkRulePolicies(ctx, notificationType, tenRule) { - const {repeatInterval} = tenRule.policies; + const { repeatInterval } = tenRule.policies; const intervalMilliseconds = ms(repeatInterval) ?? defaultRepeatInterval; - let expired = repeatIntervalsExpired.get(notificationType); + const expired = repeatIntervalsExpired.get(notificationType); + if (!expired || expired <= Date.now()) { repeatIntervalsExpired.set(notificationType, Date.now() + intervalMilliseconds); return true; } + ctx.logger.debug(`Notification service: skip rule "%s" due to repeat interval %s`, notificationType, repeatInterval); return false; } diff --git a/Common/sources/tenantManager.js b/Common/sources/tenantManager.js index 41400f09..802020af 100644 --- a/Common/sources/tenantManager.js +++ b/Common/sources/tenantManager.js @@ -39,7 +39,7 @@ const license = require('./../../Common/sources/license'); const constants = require('./../../Common/sources/constants'); const commonDefines = require('./../../Common/sources/commondefines'); const utils = require('./../../Common/sources/utils'); -const { readFile } = require('fs/promises'); +const { readFile, readdir } = require('fs/promises'); const path = require('path'); const cfgTenantsBaseDomain = config.get('tenants.baseDomain'); @@ -79,6 +79,17 @@ function getTenant(ctx, domain) { } return tenant; } +async function getAllTenants(ctx) { + try { + const entitiesList = await readdir(cfgTenantsBaseDir, { withFileTypes: true }); + const dirList = entitiesList.filter(direntObj => direntObj.isDirectory()).map(directory => directory.name); + + return dirList; + } catch (error) { + ctx.logger.error('getAllTenants error: ', error.stack); + return []; + } +} function getTenantByConnection(ctx, conn) { return isMultitenantMode(ctx) ? getTenant(ctx, utils.getDomainByConnection(ctx, conn)) : getDefautTenant(); } @@ -395,6 +406,7 @@ async function readLicenseTenant(ctx, licenseFile, baseVerifiedLicense) { return [res, oLicense]; } +exports.getAllTenants = getAllTenants; exports.getDefautTenant = getDefautTenant; exports.getTenantByConnection = getTenantByConnection; exports.getTenantByRequest = getTenantByRequest; diff --git a/DocService/sources/DocsCoServer.js b/DocService/sources/DocsCoServer.js index e9370e34..065b06d8 100644 --- a/DocService/sources/DocsCoServer.js +++ b/DocService/sources/DocsCoServer.js @@ -87,6 +87,7 @@ const bytes = require('bytes'); const storage = require('./../../Common/sources/storage-base'); const constants = require('./../../Common/sources/constants'); const utils = require('./../../Common/sources/utils'); +const utilsDocService = require('./utilsDocService'); const commonDefines = require('./../../Common/sources/commondefines'); const statsDClient = require('./../../Common/sources/statsdclient'); const config = require('config'); @@ -101,7 +102,7 @@ const wopiClient = require('./wopiClient'); const queueService = require('./../../Common/sources/taskqueueRabbitMQ'); const operationContext = require('./../../Common/sources/operationContext'); const tenantManager = require('./../../Common/sources/tenantManager'); -const notificationService = require('../../Common/sources/notificationService'); +const { notificationTypes, ...notificationService } = require('../../Common/sources/notificationService'); const cfgEditorDataStorage = config.get('services.CoAuthoring.server.editorDataStorage'); const cfgEditorStatStorage = config.get('services.CoAuthoring.server.editorStatStorage'); @@ -3527,7 +3528,7 @@ exports.install = function(server, callbackFunction) { if (notificationPrefix) { //todo with yield service could throw error - notificationService.notify(ctx, notificationService.notificationTypes.LICENSE_LIMIT, [notificationPrefix]); + notificationService.notify(ctx, notificationTypes.LICENSE_LIMIT, [notificationPrefix]); } return licenseType; } @@ -3944,8 +3945,46 @@ exports.install = function(server, callbackFunction) { }); }); }; -exports.setLicenseInfo = function(data, original ) { +exports.setLicenseInfo = async function(globalCtx, data, original) { + const asyncIOHandler = async function(asyncOperations, errorMessage) { + const settledPromises = await Promise.allSettled(asyncOperations); + + const filtered = settledPromises.filter(promise => { + if (promise.status === 'rejected') { + globalCtx.logger.error(errorMessage, promise.reason); + return false; + } + + return true; + }); + + return filtered.map(result => result.value); + }; + + const tenantsList = await tenantManager.getAllTenants(globalCtx); + const cacheInitProcess = tenantsList.map(async tenant => { + const ctx = new operationContext.Context(); + ctx.init(tenant); + await ctx.initTenantCache(); + + return ctx; + }); + + const tenantContexts = await asyncIOHandler(cacheInitProcess, 'setLicenseInfo error while initializing context: '); + const pendingLicenses = tenantContexts.map(async ctx => { + const license = await tenantManager.getTenantLicense(ctx); + return [ctx, license]; + }); + const licenses = await asyncIOHandler(pendingLicenses, 'setLicenseInfo error while reading license: '); + tenantManager.setDefLicense(data, original); + utilsDocService.notifyLicenseExpiration(globalCtx, data.endDate); + for (const licenseInfo of licenses) { + const ctx = licenseInfo[0]; + const endDate = licenseInfo[1].endDate; + + utilsDocService.notifyLicenseExpiration(ctx, endDate); + } }; exports.healthCheck = function(req, res) { return co(function*() { diff --git a/DocService/sources/server.js b/DocService/sources/server.js index 27a21d86..9fb46a99 100644 --- a/DocService/sources/server.js +++ b/DocService/sources/server.js @@ -105,19 +105,17 @@ const updatePlugins = (eventType, filename) => { updatePluginsTime = new Date(); pluginsLoaded = false; }; -const readLicense = function*() { - [licenseInfo, licenseOriginal] = yield* license.readLicense(cfgLicenseFile); +const readLicense = async function () { + [licenseInfo, licenseOriginal] = await license.readLicense(cfgLicenseFile); }; -const updateLicense = () => { - return co(function*() { - try { - yield* readLicense(); - docsCoServer.setLicenseInfo(licenseInfo, licenseOriginal); - operationContext.global.logger.info('End updateLicense'); - } catch (err) { - operationContext.global.logger.error('updateLicense error: %s', err.stack); - } - }); +const updateLicense = async () => { + try { + await readLicense(); + await docsCoServer.setLicenseInfo(operationContext.global, licenseInfo, licenseOriginal); + operationContext.global.logger.info('End updateLicense'); + } catch (err) { + operationContext.global.logger.error('updateLicense error: %s', err.stack); + } }; operationContext.global.logger.warn('Express server starting...'); diff --git a/DocService/sources/utilsDocService.js b/DocService/sources/utilsDocService.js index 370ad3dd..036aff4e 100644 --- a/DocService/sources/utilsDocService.js +++ b/DocService/sources/utilsDocService.js @@ -32,9 +32,15 @@ 'use strict'; -const exifParser = require("exif-parser"); -const Jimp = require("jimp"); +const config = require('config'); +const exifParser = require('exif-parser'); +const Jimp = require('jimp'); const locale = require('windows-locale'); +const ms = require('ms'); + +const { notificationTypes, ...notificationService } = require('../../Common/sources/notificationService'); + +const cfgStartNotifyFrom = ms(config.get('license.startNotifyFrom')); async function fixImageExifRotation(ctx, buffer) { if (!buffer) { @@ -70,7 +76,62 @@ function localeToLCID(lang) { return elem && elem.id; } +function humanFriendlyExpirationTime(endTime) { + const timeWithPostfix = (timeName, value) => `${value} ${timeName}${value > 1 ? 's' : ''}`; + const currentTime = new Date(); + const monthDiff = getMonthDiff(currentTime, endTime); + + if (monthDiff > 0) { + return timeWithPostfix('month', monthDiff); + } + + const daysDiff = endTime.getUTCDate() - currentTime.getUTCDate(); + if (daysDiff > 0) { + return timeWithPostfix('day', daysDiff); + } + + const hoursDiff = endTime.getHours() - currentTime.getHours(); + const minutesDiff = endTime.getMinutes() - currentTime.getMinutes(); + + let timeString = ''; + if (hoursDiff > 0) { + timeString += timeWithPostfix('hour', hoursDiff); + } + + if (minutesDiff > 0) { + if (timeString.length !== 0) { + timeString += ' '; + } + + timeString += timeWithPostfix('minute', minutesDiff); + } + + return timeString; +} + +/** + * Notify server user about license expiration via configured notification transports. + * @param {string} ctx Context. + * @param {date} endDate Date of expiration. + * @returns {undefined} + */ +function notifyLicenseExpiration(ctx, endDate) { + if (!endDate) { + ctx.logger.warn('notifyLicenseExpiration(): endDate is not defined'); + return; + } + + const currentDate = new Date(); + const licenseEndTime = new Date(endDate); + + if (currentDate.getTime() >= licenseEndTime.getTime() - cfgStartNotifyFrom) { + const formattedTimeRemaining = humanFriendlyExpirationTime(licenseEndTime); + notificationService.notify(ctx, notificationTypes.LICENSE_EXPIRED, [formattedTimeRemaining]); + } +} + module.exports = { fixImageExifRotation, - localeToLCID -}; \ No newline at end of file + localeToLCID, + notifyLicenseExpiration +}; diff --git a/FileConverter/sources/convertermaster.js b/FileConverter/sources/convertermaster.js index 08d4d9fc..2209e8c9 100644 --- a/FileConverter/sources/convertermaster.js +++ b/FileConverter/sources/convertermaster.js @@ -47,12 +47,12 @@ if (cluster.isMaster) { const cfgMaxProcessCount = config.get('FileConverter.converter.maxprocesscount'); var workersCount = 0; - const readLicense = function* () { + const readLicense = async function () { const numCPUs = os.cpus().length; const availableParallelism = os.availableParallelism?.(); operationContext.global.logger.warn('num of CPUs: %d; availableParallelism: %s', numCPUs, availableParallelism); workersCount = Math.ceil((availableParallelism || numCPUs) * cfgMaxProcessCount); - let [licenseInfo] = yield* license.readLicense(cfgLicenseFile); + let [licenseInfo] = await license.readLicense(cfgLicenseFile); workersCount = Math.min(licenseInfo.count, workersCount); //todo send license to workers for multi-tenancy }; @@ -73,16 +73,14 @@ if (cluster.isMaster) { } } }; - const updateLicense = () => { - return co(function*() { - try { - yield* readLicense(); - operationContext.global.logger.warn('update cluster with %s workers', workersCount); - updateWorkers(); - } catch (err) { - operationContext.global.logger.error('updateLicense error: %s', err.stack); - } - }); + const updateLicense = async () => { + try { + await readLicense(); + operationContext.global.logger.warn('update cluster with %s workers', workersCount); + updateWorkers(); + } catch (err) { + operationContext.global.logger.error('updateLicense error: %s', err.stack); + } }; cluster.on('exit', (worker, code, signal) => { diff --git a/tests/unit/mailService.tests.js b/tests/unit/mailService.tests.js index 6daa42b0..8c2c8392 100644 --- a/tests/unit/mailService.tests.js +++ b/tests/unit/mailService.tests.js @@ -1,7 +1,10 @@ const { describe, test, expect, afterAll } = require('@jest/globals'); const nodemailer = require('../../Common/node_modules/nodemailer'); +const operationContext = require('../../Common/sources/operationContext'); const mailService = require('../../Common/sources/mailService'); + +const ctx = new operationContext.Context(); const defaultTestSMTPServer = { host: 'smtp.ethereal.email', port: 587 @@ -13,7 +16,6 @@ afterAll(function () { }) describe('Mail service', function () { - console.log('!!!!!!!!!!!!!!!!', global.dev); describe('SMTP', function () { const { host, port } = defaultTestSMTPServer; @@ -22,7 +24,7 @@ describe('Mail service', function () { // Ethereial is a special SMTP sever for mailing tests in collaboration with Nodemailer. const accounts = await Promise.all([nodemailer.createTestAccount(), nodemailer.createTestAccount(), nodemailer.createTestAccount()]); const auth = accounts.map(account => { return { user: account.user, pass: account.pass }}); - auth.forEach(credential => mailService.createTransporter(host, port, credential, { from: 'some.mail@ethereal.com' })); + auth.forEach(credential => mailService.createTransporter(ctx, host, port, credential, { from: 'some.mail@ethereal.com' })); for (let i = 0; i < auth.length; i++) { const credentials = auth[i]; @@ -36,7 +38,7 @@ describe('Mail service', function () { } const accountToBeDeleted = auth[1]; - mailService.deleteTransporter(host, accountToBeDeleted.user); + mailService.deleteTransporter(ctx, host, accountToBeDeleted.user); const errorPromise = mailService.send( host,