Просмотр исходного кода

Merge pull request #2552 from weseek/feat/sync-config-cache

Feat/sync config cache
Yuki Takei 5 лет назад
Родитель
Сommit
d61cc0f2b6

+ 1 - 0
CHANGES.md

@@ -11,6 +11,7 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
 
 ### Updates
 
+* Fix: New settings of SMTP and AWS SES are not reflected when server is running
 * Support: Support Node.js v14
 
 

+ 2 - 0
config/env.dev.js

@@ -6,10 +6,12 @@ module.exports = {
   // NO_CDN: true,
   MONGO_URI: 'mongodb://mongo:27017/growi',
   // REDIS_URI: 'http://redis:6379',
+  // NCHAN_URI: 'http://nchan',
   ELASTICSEARCH_URI: 'http://elasticsearch:9200/growi',
   HACKMD_URI: 'http://localhost:3010',
   HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
+  // CONFIG_PUBSUB_SERVER_TYPE: 'nchan',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 2 - 3
config/logger/config.dev.js

@@ -16,7 +16,9 @@ module.exports = {
   'growi:routes:login-passport': 'debug',
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
+  'growi:service:config-pubsub:*': 'debug',
   // 'growi:service:ConfigManager': 'debug',
+  // 'growi:service:mail': 'debug',
   'growi:lib:search': 'debug',
   // 'growi:service:GlobalNotification': 'debug',
   // 'growi:lib:importer': 'debug',
@@ -24,9 +26,6 @@ module.exports = {
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
 
-  // email
-  // 'growi:lib:mailer': 'debug',
-
   /*
    * configure level for client
    */

+ 1 - 0
package.json

@@ -148,6 +148,7 @@
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "validator": "^12.0.0",
+    "websocket": "^1.0.31",
     "xss": "^1.0.6"
   },
   "devDependencies": {

+ 2 - 0
src/server/crowi/express-init.js

@@ -20,6 +20,7 @@ module.exports = function(crowi, app) {
 
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
+  const autoReconnectToConfigPubsub = require('../middlewares/auto-reconnect-to-config-pubsub')(crowi);
   const { listLocaleIds } = require('@commons/util/locale-utils');
 
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
@@ -117,6 +118,7 @@ module.exports = function(crowi, app) {
 
   app.use(registerSafeRedirect);
   app.use(injectCurrentuserToLocalvars);
+  app.use(autoReconnectToConfigPubsub);
 
   const middlewares = require('../util/middlewares')(crowi, app);
   app.use(middlewares.swigFilters(swig));

+ 38 - 20
src/server/crowi/index.js

@@ -37,7 +37,7 @@ function Crowi(rootdir) {
 
   this.config = {};
   this.configManager = null;
-  this.mailer = {};
+  this.mailService = null;
   this.passportService = null;
   this.globalNotificationService = null;
   this.slackNotificationService = null;
@@ -249,7 +249,16 @@ Crowi.prototype.setupSessionConfig = async function() {
 Crowi.prototype.setupConfigManager = async function() {
   const ConfigManager = require('../service/config-manager');
   this.configManager = new ConfigManager(this.model('Config'));
-  return this.configManager.loadConfigs();
+  await this.configManager.loadConfigs();
+
+  // setup pubsub
+  this.configPubsub = require('../service/config-pubsub')(this);
+  if (this.configPubsub != null) {
+    this.configPubsub.subscribe();
+    this.configManager.setPubsub(this.configPubsub);
+    // add as a message handler
+    this.configPubsub.addMessageHandler(this.configManager);
+  }
 };
 
 Crowi.prototype.setupModels = async function() {
@@ -277,10 +286,6 @@ Crowi.prototype.scanRuntimeVersions = async function() {
   });
 };
 
-Crowi.prototype.getMailer = function() {
-  return this.mailer;
-};
-
 Crowi.prototype.getSlack = function() {
   return this.slack;
 };
@@ -308,18 +313,24 @@ Crowi.prototype.setupPassport = async function() {
   this.passportService.setupSerializer();
   // setup strategies
   try {
-    this.passportService.setupLocalStrategy();
-    this.passportService.setupLdapStrategy();
-    this.passportService.setupGoogleStrategy();
-    this.passportService.setupGitHubStrategy();
-    this.passportService.setupTwitterStrategy();
-    this.passportService.setupOidcStrategy();
-    this.passportService.setupSamlStrategy();
-    this.passportService.setupBasicStrategy();
+    this.passportService.setupStrategyById('local');
+    this.passportService.setupStrategyById('ldap');
+    this.passportService.setupStrategyById('saml');
+    this.passportService.setupStrategyById('oidc');
+    this.passportService.setupStrategyById('basic');
+    this.passportService.setupStrategyById('google');
+    this.passportService.setupStrategyById('github');
+    this.passportService.setupStrategyById('twitter');
   }
   catch (err) {
     logger.error(err);
   }
+
+  // add as a message handler
+  if (this.configPubsub != null) {
+    this.configPubsub.addMessageHandler(this.passportService);
+  }
+
   return Promise.resolve();
 };
 
@@ -329,11 +340,13 @@ Crowi.prototype.setupSearcher = async function() {
 };
 
 Crowi.prototype.setupMailer = async function() {
-  const self = this;
-  return new Promise(((resolve, reject) => {
-    self.mailer = require('../util/mailer')(self);
-    resolve();
-  }));
+  const MailService = require('@server/service/mail');
+  this.mailService = new MailService(this);
+
+  // add as a message handler
+  if (this.configPubsub != null) {
+    this.configPubsub.addMessageHandler(this.mailService);
+  }
 };
 
 Crowi.prototype.setupSlack = async function() {
@@ -483,9 +496,14 @@ Crowi.prototype.setUpAcl = async function() {
 Crowi.prototype.setUpCustomize = async function() {
   const CustomizeService = require('../service/customize');
   if (this.customizeService == null) {
-    this.customizeService = new CustomizeService(this.configManager, this.appService, this.xssService);
+    this.customizeService = new CustomizeService(this.configManager, this.appService, this.xssService, this.configPubsub);
     this.customizeService.initCustomCss();
     this.customizeService.initCustomTitle();
+
+    // add as a message handler
+    if (this.configPubsub != null) {
+      this.configPubsub.addMessageHandler(this.customizeService);
+    }
   }
 };
 

+ 11 - 0
src/server/middlewares/auto-reconnect-to-config-pubsub.js

@@ -0,0 +1,11 @@
+module.exports = (crowi) => {
+  const { configPubsub } = crowi;
+
+  return (req, res, next) => {
+    if (configPubsub != null && configPubsub.shouldResubscribe()) {
+      configPubsub.subscribe();
+    }
+
+    return next();
+  };
+};

+ 3 - 3
src/server/models/user.js

@@ -604,8 +604,8 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.sendEmailbyUserList = async function(userList) {
-    const mailer = crowi.getMailer();
-    const appTitle = crowi.appService.getAppTitle();
+    const { appService, mailService } = crowi;
+    const appTitle = appService.getAppTitle();
 
     await Promise.all(userList.map(async(user) => {
       if (user.password == null) {
@@ -613,7 +613,7 @@ module.exports = function(crowi) {
       }
 
       try {
-        return mailer.send({
+        return mailService.send({
           to: user.email,
           subject: `Invitation to ${appTitle}`,
           template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),

+ 26 - 0
src/server/models/vo/config-pubsub-message.js

@@ -0,0 +1,26 @@
+class ConfigPubsubMessage {
+
+  constructor(eventName, body) {
+    this.eventName = eventName;
+    for (const [key, value] of Object.entries(body)) {
+      this[key] = value;
+    }
+  }
+
+  setPublisherUid(uid) {
+    this.publisherUid = uid;
+  }
+
+  static parse(messageString) {
+    const body = JSON.parse(messageString);
+
+    if (body.eventName == null) {
+      throw new Error('message body must contain \'eventName\'');
+    }
+
+    return new ConfigPubsubMessage(body.eventName, body);
+  }
+
+}
+
+module.exports = ConfigPubsubMessage;

+ 23 - 10
src/server/routes/apiv3/app-settings.js

@@ -292,7 +292,7 @@ module.exports = (crowi) => {
    * validate mail setting send test mail
    */
   async function validateMailSetting(req) {
-    const mailer = crowi.mailer;
+    const { mailService } = crowi;
     const option = {
       host: req.body.smtpHost,
       port: req.body.smtpPort,
@@ -307,7 +307,7 @@ module.exports = (crowi) => {
       option.secure = true;
     }
 
-    const smtpClient = mailer.createSMTPClient(option);
+    const smtpClient = mailService.createSMTPClient(option);
     debug('mailer setup for validate SMTP setting', smtpClient);
 
     const mailOptions = {
@@ -344,7 +344,6 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/MailSettingParams'
    */
   router.put('/mail-setting', loginRequiredStrictly, adminRequired, csrf, validator.mailSetting, apiV3FormValidator, async(req, res) => {
-    // テストメール送信によるバリデート
     try {
       await validateMailSetting(req);
     }
@@ -365,13 +364,20 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestMailSettingParams);
+      const { configManager, mailService } = crowi;
+
+      // update config without publishing ConfigPubsubMessage
+      await configManager.updateConfigsInTheSameNamespace('crowi', requestMailSettingParams, true);
+
+      await mailService.initialize();
+      mailService.publishUpdatedMessage();
+
       const mailSettingParams = {
-        fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
-        smtpHost: crowi.configManager.getConfig('crowi', 'mail:smtpHost'),
-        smtpPort: crowi.configManager.getConfig('crowi', 'mail:smtpPort'),
-        smtpUser: crowi.configManager.getConfig('crowi', 'mail:smtpUser'),
-        smtpPassword: crowi.configManager.getConfig('crowi', 'mail:smtpPassword'),
+        fromAddress: configManager.getConfig('crowi', 'mail:from'),
+        smtpHost: configManager.getConfig('crowi', 'mail:smtpHost'),
+        smtpPort: configManager.getConfig('crowi', 'mail:smtpPort'),
+        smtpUser: configManager.getConfig('crowi', 'mail:smtpUser'),
+        smtpPassword: configManager.getConfig('crowi', 'mail:smtpPassword'),
       };
       return res.apiv3({ mailSettingParams });
     }
@@ -415,7 +421,14 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams);
+      const { configManager, mailService } = crowi;
+
+      // update config without publishing ConfigPubsubMessage
+      await configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams, true);
+
+      await mailService.initialize();
+      mailService.publishUpdatedMessage();
+
       const awsSettingParams = {
         region: crowi.configManager.getConfig('crowi', 'aws:region'),
         customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),

+ 6 - 2
src/server/routes/apiv3/customize-setting.js

@@ -375,7 +375,9 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams, true);
+      crowi.customizeService.publishUpdatedMessage();
+
       const customizedParams = {
         customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
       };
@@ -458,7 +460,9 @@ module.exports = (crowi) => {
       'customize:css': req.body.customizeCss,
     };
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams, true);
+      crowi.customizeService.publishUpdatedMessage();
+
       const customizedParams = {
         customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
       };

+ 27 - 19
src/server/routes/apiv3/security-setting.js

@@ -324,6 +324,16 @@ module.exports = (crowi) => {
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
+  async function updateAndReloadStrategySettings(authId, params) {
+    const { configManager, passportService } = crowi;
+
+    // update config without publishing ConfigPubsubMessage
+    await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+
+    await passportService.setupStrategyById(authId);
+    passportService.publishUpdatedMessage(authId);
+  }
+
   /**
    * @swagger
    *
@@ -489,9 +499,7 @@ module.exports = (crowi) => {
     const enableParams = { [`security:passport-${authId}:isEnabled`]: isEnabled };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', enableParams);
-
-      await crowi.passportService.setupStrategyById(authId);
+      await updateAndReloadStrategySettings(authId, enableParams);
 
       const responseParams = {
         [`security:passport-${authId}:isEnabled`]: await crowi.configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
@@ -613,8 +621,8 @@ module.exports = (crowi) => {
       'security:registrationWhiteList': req.body.registrationWhiteList,
     };
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('local');
+      await updateAndReloadStrategySettings('local', requestParams);
+
       const localSettingParams = {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
@@ -666,8 +674,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('ldap');
+      await updateAndReloadStrategySettings('ldap', requestParams);
+
       const securitySettingParams = {
         serverUrl: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
         isUserBind: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
@@ -757,8 +765,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('saml');
+      await updateAndReloadStrategySettings('saml', requestParams);
+
       const securitySettingParams = {
         missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
         samlEntryPoint: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
@@ -826,8 +834,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('oidc');
+      await updateAndReloadStrategySettings('oidc', requestParams);
+
       const securitySettingParams = {
         oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
         oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
@@ -884,8 +892,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('basic');
+      await updateAndReloadStrategySettings('basic', requestParams);
+
       const securitySettingParams = {
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser'),
       };
@@ -927,8 +935,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('google');
+      await updateAndReloadStrategySettings('google', requestParams);
+
       const securitySettingParams = {
         googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
@@ -972,8 +980,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('github');
+      await updateAndReloadStrategySettings('github', requestParams);
+
       const securitySettingParams = {
         githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
         githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
@@ -1022,8 +1030,8 @@ module.exports = (crowi) => {
     requestParams = removeNullPropertyFromObject(requestParams);
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('twitter');
+      await updateAndReloadStrategySettings('twitter', requestParams);
+
       const securitySettingParams = {
         twitterConsumerId: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:consumerKey'),
         twitterConsumerSecret: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:consumerSecret'),

+ 2 - 3
src/server/routes/login.js

@@ -7,9 +7,8 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login');
   const logger = require('@alias/logger')('growi:routes:login');
   const path = require('path');
-  const mailer = crowi.getMailer();
   const User = crowi.model('User');
-  const { configManager, appService, aclService } = crowi;
+  const { configManager, appService, aclService, mailService } = crowi;
 
   const actions = {};
 
@@ -158,7 +157,7 @@ module.exports = function(crowi, app) {
     const appTitle = appService.getAppTitle();
 
     const promises = admins.map((admin) => {
-      return mailer.send({
+      return mailService.send({
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
         template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),

+ 24 - 0
src/server/service/config-loader.js

@@ -107,6 +107,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
   //   type:    ,
   //   default:
   // },
+  NCHAN_URI: {
+    ns:      'crowi',
+    key:     'app:nchanUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
   APP_SITE_URL: {
     ns:      'crowi',
     key:     'app:siteUrl',
@@ -119,6 +125,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.BOOLEAN,
     default: false,
   },
+  CONFIG_PUBSUB_SERVER_TYPE: {
+    ns:      'crowi',
+    key:     'configPubsub:serverType',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  CONFIG_PUBSUB_NCHAN_PUBLISH_PATH: {
+    ns:      'crowi',
+    key:     'configPubsub:nchan:publishPath',
+    type:    TYPES.STRING,
+    default: '/pubsub',
+  },
+  CONFIG_PUBSUB_NCHAN_SUBSCRIBE_PATH: {
+    ns:      'crowi',
+    key:     'configPubsub:nchan:subscribePath',
+    type:    TYPES.STRING,
+    default: '/pubsub',
+  },
   MAX_FILE_SIZE: {
     ns:      'crowi',
     key:     'app:maxFileSize',

+ 56 - 4
src/server/service/config-manager.js

@@ -1,5 +1,9 @@
 const logger = require('@alias/logger')('growi:service:ConfigManager');
-const ConfigLoader = require('../service/config-loader');
+
+const ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
+const ConfigLoader = require('./config-loader');
 
 const KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION = [
   'security:passport-local:isEnabled',
@@ -18,13 +22,16 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:ABLCRule',
 ];
 
-class ConfigManager {
+class ConfigManager extends ConfigPubsubMessageHandlable {
 
   constructor(configModel) {
+    super();
+
     this.configModel = configModel;
     this.configLoader = new ConfigLoader(this.configModel);
     this.configObject = null;
     this.configKeys = [];
+    this.lastLoadedAt = null;
 
     this.getConfig = this.getConfig.bind(this);
   }
@@ -38,6 +45,16 @@ class ConfigManager {
 
     // cache all config keys
     this.reloadConfigKeys();
+
+    this.lastLoadedAt = new Date();
+  }
+
+  /**
+   * Set ConfigPubsubDelegator instance
+   * @param {ConfigPubsubDelegator} configPubsub
+   */
+  async setPubsub(configPubsub) {
+    this.configPubsub = configPubsub;
   }
 
   /**
@@ -163,7 +180,7 @@ class ConfigManager {
    *  );
    * ```
    */
-  async updateConfigsInTheSameNamespace(namespace, configs) {
+  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingConfigPubsubMessage) {
     const queries = [];
     for (const key of Object.keys(configs)) {
       queries.push({
@@ -177,7 +194,11 @@ class ConfigManager {
     await this.configModel.bulkWrite(queries);
 
     await this.loadConfigs();
-    this.reloadConfigKeys();
+
+    // publish updated date after reloading
+    if (this.configPubsub != null && !withoutPublishingConfigPubsubMessage) {
+      this.publishUpdateMessage();
+    }
   }
 
   /**
@@ -287,6 +308,37 @@ class ConfigManager {
     return JSON.stringify(value === '' ? null : value);
   }
 
+  async publishUpdateMessage() {
+    const configPubsubMessage = new ConfigPubsubMessage('configUpdated', { updatedAt: new Date() });
+
+    try {
+      await this.configPubsub.publish(configPubsubMessage);
+    }
+    catch (e) {
+      logger.error('Failed to publish update message with configPubsub: ', e.message);
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt } = configPubsubMessage;
+    if (eventName !== 'configUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    logger.info('Reload configs by pubsub notification');
+    return this.loadConfigs();
+  }
+
 }
 
 module.exports = ConfigManager;

+ 46 - 0
src/server/service/config-pubsub/base.js

@@ -0,0 +1,46 @@
+const logger = require('@alias/logger')('growi:service:config-pubsub:base');
+
+const ConfigPubsubMessageHandlable = require('../config-pubsub/handlable');
+
+class ConfigPubsubDelegator {
+
+  constructor(uri) {
+    this.uid = Math.floor(Math.random() * 100000);
+    this.uri = uri;
+
+    if (uri == null) {
+      throw new Error('uri must be set');
+    }
+  }
+
+  shouldResubscribe() {
+    throw new Error('implement this');
+  }
+
+  subscribe(forceReconnect) {
+    throw new Error('implement this');
+  }
+
+  /**
+   * Publish message
+   * @param {ConfigPubsubMessage} configPubsubMessage
+   */
+  async publish(configPubsubMessage) {
+    configPubsubMessage.setPublisherUid(this.uid);
+  }
+
+  /**
+   * Add message handler
+   * @param {ConfigPubsubMessageHandlable} handlable
+   */
+  addMessageHandler(handlable) {
+    if (!(handlable instanceof ConfigPubsubMessageHandlable)) {
+      logger.warn('Unsupported instance');
+      logger.debug('Unsupported instance: ', handlable);
+      return;
+    }
+  }
+
+}
+
+module.exports = ConfigPubsubDelegator;

+ 14 - 0
src/server/service/config-pubsub/handlable.js

@@ -0,0 +1,14 @@
+// TODO: make interface with TS
+class ConfigPubsubMessageHandlable {
+
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    throw new Error('implement this');
+  }
+
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    throw new Error('implement this');
+  }
+
+}
+
+module.exports = ConfigPubsubMessageHandlable;

+ 43 - 0
src/server/service/config-pubsub/index.js

@@ -0,0 +1,43 @@
+const logger = require('@alias/logger')('growi:service:ConfigPubsubFactory');
+
+const envToModuleMappings = {
+  redis:   'redis',
+  nchan:   'nchan',
+};
+
+class ConfigPubsubFactory {
+
+  initializeDelegator(crowi) {
+    const type = crowi.configManager.getConfig('crowi', 'configPubsub:serverType');
+
+    if (type == null) {
+      logger.info('Config pub/sub server is not defined.');
+      return;
+    }
+
+    logger.info(`Config pub/sub server type '${type}' is set.`);
+
+    const module = envToModuleMappings[type];
+
+    const modulePath = `./${module}`;
+    this.delegator = require(modulePath)(crowi);
+
+    if (this.delegator == null) {
+      logger.warn('Failed to initialize config pub/sub delegator.');
+    }
+  }
+
+  getDelegator(crowi) {
+    if (this.delegator == null) {
+      this.initializeDelegator(crowi);
+    }
+    return this.delegator;
+  }
+
+}
+
+const factory = new ConfigPubsubFactory();
+
+module.exports = (crowi) => {
+  return factory.getDelegator(crowi);
+};

+ 183 - 0
src/server/service/config-pubsub/nchan.js

@@ -0,0 +1,183 @@
+const logger = require('@alias/logger')('growi:service:config-pubsub:nchan');
+
+const path = require('path');
+const axios = require('axios');
+const WebSocketClient = require('websocket').client;
+
+const ConfigPubsubMessage = require('../../models/vo/config-pubsub-message');
+const ConfigPubsubDelegator = require('./base');
+
+
+class NchanDelegator extends ConfigPubsubDelegator {
+
+  constructor(uri, publishPath, subscribePath, channelId) {
+    super(uri);
+
+    this.publishPath = publishPath;
+    this.subscribePath = subscribePath;
+
+    this.channelId = channelId;
+    this.isConnecting = false;
+
+    /**
+     * A list of ConfigPubsubHandler instance
+     */
+    this.handlableList = [];
+
+    this.client = null;
+    this.connection = null;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldResubscribe() {
+    if (this.connection != null && this.connection.connected) {
+      return false;
+    }
+
+    return !this.isConnecting;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  subscribe(forceReconnect = false) {
+    if (forceReconnect) {
+      if (this.connection != null && this.connection.connected) {
+        this.connection.close();
+      }
+    }
+
+    // init client
+    if (this.client == null) {
+      this.initClient();
+    }
+
+    if (this.shouldResubscribe()) {
+      logger.info('The connection to config pubsub server is offline. Try to reconnect...');
+    }
+
+    // connect
+    this.isConnecting = true;
+    const url = this.constructUrl(this.subscribePath).toString();
+    this.client.connect(url.toString());
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async publish(configPubsubMessage) {
+    await super.publish(configPubsubMessage);
+
+    logger.debug('Publish message', configPubsubMessage);
+
+    const url = this.constructUrl(this.publishPath).toString();
+    return axios.post(url, JSON.stringify(configPubsubMessage));
+  }
+
+  /**
+   * @inheritdoc
+   */
+  addMessageHandler(handlable) {
+    super.addMessageHandler(handlable);
+
+    this.handlableList.push(handlable);
+
+    if (this.connection != null) {
+      this.connection.on('message', (messageObj) => {
+        this.handleMessage(messageObj, handlable);
+      });
+    }
+  }
+
+  constructUrl(basepath) {
+    const pathname = this.channelId == null
+      ? basepath //                                 /pubsub
+      : path.join(basepath, this.channelId); //     /pubsub/my-channel-id
+
+    return new URL(pathname, this.uri);
+  }
+
+  initClient() {
+    const client = new WebSocketClient();
+
+    client.on('connectFailed', (error) => {
+      logger.warn(`Connect Error: ${error.toString()}`);
+      this.isConnecting = false;
+    });
+
+    client.on('connect', (connection) => {
+      this.isConnecting = false;
+      this.connection = connection;
+
+      logger.info('WebSocket client connected');
+
+      connection.on('error', (error) => {
+        this.isConnecting = false;
+        logger.error(`Connection Error: ${error.toString()}`);
+      });
+      connection.on('close', () => {
+        logger.info('WebSocket connection closed');
+      });
+
+      // register all message handlers
+      this.handlableList.forEach(handler => this.addMessageHandler(handler));
+    });
+
+    this.client = client;
+  }
+
+  /**
+   * Handle message string with the specified ConfigPubsubHandler
+   *
+   * @see https://github.com/theturtle32/WebSocket-Node/blob/1f7ffba2f7a6f9473bcb39228264380ce2772ba7/docs/WebSocketConnection.md#message
+   *
+   * @param {object} message WebSocket-Node message object
+   * @param {ConfigPubsubHandler} handlable
+   */
+  handleMessage(message, handlable) {
+    if (message.type !== 'utf8') {
+      logger.warn('Only utf8 message is supported.');
+    }
+
+    try {
+      const configPubsubMessage = ConfigPubsubMessage.parse(message.utf8Data);
+
+      // check uid
+      if (configPubsubMessage.publisherUid === this.uid) {
+        logger.debug(`Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`, `from ${this.uid}`);
+        return;
+      }
+
+      // check shouldHandleConfigPubsubMessage
+      const shouldHandle = handlable.shouldHandleConfigPubsubMessage(configPubsubMessage);
+      logger.debug(`${handlable.constructor.name}.shouldHandleConfigPubsubMessage(`, configPubsubMessage, `) => ${shouldHandle}`);
+
+      if (shouldHandle) {
+        handlable.handleConfigPubsubMessage(configPubsubMessage);
+      }
+    }
+    catch (err) {
+      logger.warn('Could not handle a message: ', err.message);
+    }
+  }
+
+}
+
+module.exports = function(crowi) {
+  const { configManager } = crowi;
+
+  const uri = configManager.getConfig('crowi', 'app:nchanUri');
+
+  // when nachan server URI is not set
+  if (uri == null) {
+    logger.warn('NCHAN_URI is not specified.');
+    return;
+  }
+
+  const publishPath = configManager.getConfig('crowi', 'configPubsub:nchan:publishPath');
+  const subscribePath = configManager.getConfig('crowi', 'configPubsub:nchan:subscribePath');
+
+  return new NchanDelegator(uri, publishPath, subscribePath);
+};

+ 5 - 0
src/server/service/config-pubsub/redis.js

@@ -0,0 +1,5 @@
+const logger = require('@alias/logger')('growi:service:config-pubsub:redis');
+
+module.exports = function(crowi) {
+  logger.warn('Config pub/sub with Redis has not implemented yet.');
+};

+ 52 - 1
src/server/service/customize.js

@@ -3,15 +3,62 @@ const logger = require('@alias/logger')('growi:service:CustomizeService');
 
 const DevidedPagePath = require('@commons/models/devided-page-path');
 
+const ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
+
 /**
  * the service class of CustomizeService
  */
-class CustomizeService {
+class CustomizeService extends ConfigPubsubMessageHandlable {
 
   constructor(configManager, appService, xssService) {
+    super();
+
     this.configManager = configManager;
     this.appService = appService;
     this.xssService = xssService;
+
+    this.lastLoadedAt = null;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt } = configPubsubMessage;
+    if (eventName !== 'customizeServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    const { configManager } = this.appService;
+
+    logger.info('Reset customized value by pubsub notification');
+    await configManager.loadConfigs();
+    this.initCustomCss();
+    this.initCustomTitle();
+  }
+
+  async publishUpdatedMessage() {
+    const { configPubsub } = this.appService;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('customizeServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with configPubsub: ', e.message);
+      }
+    }
   }
 
   /**
@@ -24,6 +71,8 @@ class CustomizeService {
 
     // uglify and store
     this.customCss = uglifycss.processString(rawCss);
+
+    this.lastLoadedAt = new Date();
   }
 
   getCustomCss() {
@@ -42,6 +91,8 @@ class CustomizeService {
     }
 
     this.customTitleTemplate = configValue;
+
+    this.lastLoadedAt = new Date();
   }
 
   generateCustomTitle(pageOrPath) {

+ 3 - 2
src/server/service/global-notification/global-notification-mail.js

@@ -8,7 +8,6 @@ class GlobalNotificationMailService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.mailer = crowi.getMailer();
     this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
@@ -24,13 +23,15 @@ class GlobalNotificationMailService {
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
   async fire(event, path, triggeredBy, vars) {
+    const { mailService } = this.crowi;
+
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
 
     const option = this.generateOption(event, path, triggeredBy, vars);
 
     await Promise.all(notifications.map((notification) => {
-      return this.mailer.send({ ...option, to: notification.toEmail });
+      return mailService.send({ ...option, to: notification.toEmail });
     }));
   }
 

+ 167 - 0
src/server/service/mail.js

@@ -0,0 +1,167 @@
+const logger = require('@alias/logger')('growi:service:mail');
+
+const nodemailer = require('nodemailer');
+const swig = require('swig-templates');
+
+
+const ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
+
+class MailService extends ConfigPubsubMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.appService = crowi.appService;
+    this.configManager = crowi.configManager;
+    this.configPubsub = crowi.configPubsub;
+
+    this.mailConfig = {};
+    this.mailer = {};
+
+    this.initialize();
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt } = configPubsubMessage;
+    if (eventName !== 'mailServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    const { configManager } = this;
+
+    logger.info('Initialize mail settings by pubsub notification');
+    await configManager.loadConfigs();
+    this.initialize();
+  }
+
+  async publishUpdatedMessage() {
+    const { configPubsub } = this;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('mailServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with configPubsub: ', e.message);
+      }
+    }
+  }
+
+
+  initialize() {
+    const { appService, configManager } = this;
+
+    if (!configManager.getConfig('crowi', 'mail:from')) {
+      this.mailer = null;
+      return;
+    }
+
+    // Priority 1. SMTP
+    if (configManager.getConfig('crowi', 'mail:smtpHost') && configManager.getConfig('crowi', 'mail:smtpPort')) {
+      this.mailer = this.createSMTPClient();
+    }
+    // Priority 2. SES
+    else if (configManager.getConfig('crowi', 'aws:accessKeyId') && configManager.getConfig('crowi', 'aws:secretAccessKey')) {
+      this.mailer = this.createSESClient();
+    }
+    else {
+      this.mailer = null;
+    }
+
+    this.mailConfig.from = configManager.getConfig('crowi', 'mail:from');
+    this.mailConfig.subject = `${appService.getAppTitle()}からのメール`;
+
+    logger.debug('mailer initialized');
+  }
+
+  createSMTPClient(option) {
+    const { configManager } = this;
+
+    logger.debug('createSMTPClient option', option);
+    if (!option) {
+      option = { // eslint-disable-line no-param-reassign
+        host: configManager.getConfig('crowi', 'mail:smtpHost'),
+        port: configManager.getConfig('crowi', 'mail:smtpPort'),
+      };
+
+      if (configManager.getConfig('crowi', 'mail:smtpUser') && configManager.getConfig('crowi', 'mail:smtpPassword')) {
+        option.auth = {
+          user: configManager.getConfig('crowi', 'mail:smtpUser'),
+          pass: configManager.getConfig('crowi', 'mail:smtpPassword'),
+        };
+      }
+      if (option.port === 465) {
+        option.secure = true;
+      }
+    }
+    option.tls = { rejectUnauthorized: false };
+
+    const client = nodemailer.createTransport(option);
+
+    logger.debug('mailer set up for SMTP', client);
+    return client;
+  }
+
+  createSESClient(option) {
+    const { configManager } = this;
+
+    if (!option) {
+      option = { // eslint-disable-line no-param-reassign
+        accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
+        secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
+      };
+    }
+
+    const ses = require('nodemailer-ses-transport');
+    const client = nodemailer.createTransport(ses(option));
+
+    logger.debug('mailer set up for SES', client);
+    return client;
+  }
+
+  setupMailConfig(overrideConfig) {
+    const c = overrideConfig;
+
+    let mc = {};
+    mc = this.mailConfig;
+
+    mc.to = c.to;
+    mc.from = c.from || this.mailConfig.from;
+    mc.text = c.text;
+    mc.subject = c.subject || this.mailConfig.subject;
+
+    return mc;
+  }
+
+  async send(config) {
+    if (this.mailer == null) {
+      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
+    }
+
+    const templateVars = config.vars || {};
+    const output = await swig.renderFile(
+      config.template,
+      templateVars,
+    );
+
+    config.text = output;
+    return this.mailer.sendMail(this.setupMailConfig(config));
+  }
+
+}
+
+module.exports = MailService;

+ 97 - 46
src/server/service/passport.js

@@ -1,6 +1,7 @@
-const debug = require('debug')('growi:service:PassportService');
+const logger = require('@alias/logger')('growi:service:PassportService');
 const urljoin = require('url-join');
 const luceneQueryParser = require('lucene-query-parser');
+
 const passport = require('passport');
 const LocalStrategy = require('passport-local').Strategy;
 const LdapStrategy = require('passport-ldapauth');
@@ -12,10 +13,13 @@ const SamlStrategy = require('passport-saml').Strategy;
 const OIDCIssuer = require('openid-client').Issuer;
 const BasicStrategy = require('passport-http').BasicStrategy;
 
+const ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
 /**
  * the service class of Passport
  */
-class PassportService {
+class PassportService extends ConfigPubsubMessageHandlable {
 
   // see '/lib/form/login.js'
   static get USERNAME_FIELD() { return 'loginForm[username]' }
@@ -23,7 +27,10 @@ class PassportService {
   static get PASSWORD_FIELD() { return 'loginForm[password]' }
 
   constructor(crowi) {
+    super();
+
     this.crowi = crowi;
+    this.lastLoadedAt = null;
 
     /**
      * the flag whether LocalStrategy is set up successfully
@@ -118,6 +125,49 @@ class PassportService {
     };
   }
 
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt, strategyId } = configPubsubMessage;
+    if (eventName !== 'passportServiceUpdated' || updatedAt == null || strategyId == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    const { configManager } = this.crowi;
+    const { strategyId } = configPubsubMessage;
+
+    logger.info('Reset strategy by pubsub notification');
+    await configManager.loadConfigs();
+    return this.setupStrategyById(strategyId);
+  }
+
+  async publishUpdatedMessage(strategyId) {
+    const { configPubsub } = this.crowi;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('passportStrategyReloaded', {
+        updatedAt: new Date(),
+        strategyId,
+      });
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with configPubsub: ', e.message);
+      }
+    }
+  }
+
   /**
    * get SetupStrategies
    *
@@ -152,17 +202,18 @@ class PassportService {
   /**
    * setup strategy by target name
    */
-  setupStrategyById(authId) {
+  async setupStrategyById(authId) {
     const func = this.getSetupFunction(authId);
 
     try {
       this[func.setup]();
     }
     catch (err) {
-      debug(err);
+      logger.debug(err);
       this[func.reset]();
     }
 
+    this.lastLoadedAt = new Date();
   }
 
   /**
@@ -171,7 +222,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetLocalStrategy() {
-    debug('LocalStrategy: reset');
+    logger.debug('LocalStrategy: reset');
     passport.unuse('local');
     this.isLocalStrategySetup = false;
   }
@@ -194,7 +245,7 @@ class PassportService {
       return;
     }
 
-    debug('LocalStrategy: setting up..');
+    logger.debug('LocalStrategy: setting up..');
 
     const User = this.crowi.model('User');
 
@@ -217,7 +268,7 @@ class PassportService {
     ));
 
     this.isLocalStrategySetup = true;
-    debug('LocalStrategy: setup is done');
+    logger.debug('LocalStrategy: setup is done');
   }
 
   /**
@@ -226,7 +277,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetLdapStrategy() {
-    debug('LdapStrategy: reset');
+    logger.debug('LdapStrategy: reset');
     passport.unuse('ldapauth');
     this.isLdapStrategySetup = false;
   }
@@ -250,11 +301,11 @@ class PassportService {
       return;
     }
 
-    debug('LdapStrategy: setting up..');
+    logger.debug('LdapStrategy: setting up..');
 
     passport.use(new LdapStrategy(this.getLdapConfigurationFunc(config, { passReqToCallback: true }),
       (req, ldapAccountInfo, done) => {
-        debug('LDAP authentication has succeeded', ldapAccountInfo);
+        logger.debug('LDAP authentication has succeeded', ldapAccountInfo);
 
         // store ldapAccountInfo to req
         req.ldapAccountInfo = ldapAccountInfo;
@@ -263,7 +314,7 @@ class PassportService {
       }));
 
     this.isLdapStrategySetup = true;
-    debug('LdapStrategy: setup is done');
+    logger.debug('LdapStrategy: setup is done');
   }
 
   /**
@@ -335,23 +386,23 @@ class PassportService {
     // see: https://regex101.com/r/0tuYBB/1
     const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     if (match == null || match.length < 1) {
-      debug('LdapStrategy: serverUrl is invalid');
+      logger.debug('LdapStrategy: serverUrl is invalid');
       return (req, callback) => { callback({ message: 'serverUrl is invalid' }) };
     }
     const url = match[1];
     const searchBase = match[2] || '';
 
-    debug(`LdapStrategy: url=${url}`);
-    debug(`LdapStrategy: searchBase=${searchBase}`);
-    debug(`LdapStrategy: isUserBind=${isUserBind}`);
+    logger.debug(`LdapStrategy: url=${url}`);
+    logger.debug(`LdapStrategy: searchBase=${searchBase}`);
+    logger.debug(`LdapStrategy: isUserBind=${isUserBind}`);
     if (!isUserBind) {
-      debug(`LdapStrategy: bindDN=${bindDN}`);
-      debug(`LdapStrategy: bindCredentials=${bindCredentials}`);
+      logger.debug(`LdapStrategy: bindDN=${bindDN}`);
+      logger.debug(`LdapStrategy: bindCredentials=${bindCredentials}`);
     }
-    debug(`LdapStrategy: searchFilter=${searchFilter}`);
-    debug(`LdapStrategy: groupSearchBase=${groupSearchBase}`);
-    debug(`LdapStrategy: groupSearchFilter=${groupSearchFilter}`);
-    debug(`LdapStrategy: groupDnProperty=${groupDnProperty}`);
+    logger.debug(`LdapStrategy: searchFilter=${searchFilter}`);
+    logger.debug(`LdapStrategy: groupSearchBase=${groupSearchBase}`);
+    logger.debug(`LdapStrategy: groupSearchFilter=${groupSearchFilter}`);
+    logger.debug(`LdapStrategy: groupDnProperty=${groupDnProperty}`);
 
     return (req, callback) => {
       // get credentials from form data
@@ -385,7 +436,7 @@ class PassportService {
           passwordField: PassportService.PASSWORD_FIELD,
           server: serverOpt,
         }, opts);
-        debug('ldap configuration: ', mergedOpts);
+        logger.debug('ldap configuration: ', mergedOpts);
 
         // store configuration to req
         req.ldapConfiguration = mergedOpts;
@@ -412,7 +463,7 @@ class PassportService {
       return;
     }
 
-    debug('GoogleStrategy: setting up..');
+    logger.debug('GoogleStrategy: setting up..');
     passport.use(
       new GoogleStrategy(
         {
@@ -434,7 +485,7 @@ class PassportService {
     );
 
     this.isGoogleStrategySetup = true;
-    debug('GoogleStrategy: setup is done');
+    logger.debug('GoogleStrategy: setup is done');
   }
 
   /**
@@ -443,7 +494,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetGoogleStrategy() {
-    debug('GoogleStrategy: reset');
+    logger.debug('GoogleStrategy: reset');
     passport.unuse('google');
     this.isGoogleStrategySetup = false;
   }
@@ -460,7 +511,7 @@ class PassportService {
       return;
     }
 
-    debug('GitHubStrategy: setting up..');
+    logger.debug('GitHubStrategy: setting up..');
     passport.use(
       new GitHubStrategy(
         {
@@ -482,7 +533,7 @@ class PassportService {
     );
 
     this.isGitHubStrategySetup = true;
-    debug('GitHubStrategy: setup is done');
+    logger.debug('GitHubStrategy: setup is done');
   }
 
   /**
@@ -491,7 +542,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetGitHubStrategy() {
-    debug('GitHubStrategy: reset');
+    logger.debug('GitHubStrategy: reset');
     passport.unuse('github');
     this.isGitHubStrategySetup = false;
   }
@@ -508,7 +559,7 @@ class PassportService {
       return;
     }
 
-    debug('TwitterStrategy: setting up..');
+    logger.debug('TwitterStrategy: setting up..');
     passport.use(
       new TwitterStrategy(
         {
@@ -530,7 +581,7 @@ class PassportService {
     );
 
     this.isTwitterStrategySetup = true;
-    debug('TwitterStrategy: setup is done');
+    logger.debug('TwitterStrategy: setup is done');
   }
 
   /**
@@ -539,7 +590,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetTwitterStrategy() {
-    debug('TwitterStrategy: reset');
+    logger.debug('TwitterStrategy: reset');
     passport.unuse('twitter');
     this.isTwitterStrategySetup = false;
   }
@@ -556,7 +607,7 @@ class PassportService {
       return;
     }
 
-    debug('OidcStrategy: setting up..');
+    logger.debug('OidcStrategy: setting up..');
 
     // setup client
     // extend oidc request timeouts
@@ -568,7 +619,7 @@ class PassportService {
       ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/oidc/callback')
       : configManager.getConfig('crowi', 'security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
     const oidcIssuer = await OIDCIssuer.discover(issuerHost);
-    debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+    logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
     const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
     if (authorizationEndpoint) {
@@ -602,7 +653,7 @@ class PassportService {
     if (jwksUri) {
       oidcIssuer.metadata.jwks_uri = jwksUri;
     }
-    debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+    logger.debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
     const client = new oidcIssuer.Client({
       client_id: clientId,
@@ -625,7 +676,7 @@ class PassportService {
     })));
 
     this.isOidcStrategySetup = true;
-    debug('OidcStrategy: setup is done');
+    logger.debug('OidcStrategy: setup is done');
   }
 
   /**
@@ -634,7 +685,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetOidcStrategy() {
-    debug('OidcStrategy: reset');
+    logger.debug('OidcStrategy: reset');
     passport.unuse('oidc');
     this.isOidcStrategySetup = false;
   }
@@ -651,7 +702,7 @@ class PassportService {
       return;
     }
 
-    debug('SamlStrategy: setting up..');
+    logger.debug('SamlStrategy: setting up..');
     passport.use(
       new SamlStrategy(
         {
@@ -673,7 +724,7 @@ class PassportService {
     );
 
     this.isSamlStrategySetup = true;
-    debug('SamlStrategy: setup is done');
+    logger.debug('SamlStrategy: setup is done');
   }
 
   /**
@@ -682,7 +733,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetSamlStrategy() {
-    debug('SamlStrategy: reset');
+    logger.debug('SamlStrategy: reset');
     passport.unuse('saml');
     this.isSamlStrategySetup = false;
   }
@@ -718,15 +769,15 @@ class PassportService {
   verifySAMLResponseByABLCRule(response) {
     const rule = this.crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule');
     if (rule == null) {
-      debug('There is no ABLCRule.');
+      logger.debug('There is no ABLCRule.');
       return true;
     }
 
     const luceneRule = this.parseABLCRule(rule);
-    debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
+    logger.debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
 
     const attributes = this.extractAttributesFromSAMLResponse(response);
-    debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
+    logger.debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
 
     return this.evaluateRuleForSamlAttributes(attributes, luceneRule);
   }
@@ -827,7 +878,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetBasicStrategy() {
-    debug('BasicStrategy: reset');
+    logger.debug('BasicStrategy: reset');
     passport.unuse('basic');
     this.isBasicStrategySetup = false;
   }
@@ -849,7 +900,7 @@ class PassportService {
       return;
     }
 
-    debug('BasicStrategy: setting up..');
+    logger.debug('BasicStrategy: setting up..');
 
     passport.use(new BasicStrategy(
       (userId, password, done) => {
@@ -861,7 +912,7 @@ class PassportService {
     ));
 
     this.isBasicStrategySetup = true;
-    debug('BasicStrategy: setup is done');
+    logger.debug('BasicStrategy: setup is done');
   }
 
   /**
@@ -875,7 +926,7 @@ class PassportService {
       throw new Error('serializer/deserializer have already been set up');
     }
 
-    debug('setting up serializer and deserializer');
+    logger.debug('setting up serializer and deserializer');
 
     const User = this.crowi.model('User');
 

+ 0 - 120
src/server/util/mailer.js

@@ -1,120 +0,0 @@
-/**
- * mailer
- */
-
-module.exports = function(crowi) {
-  const logger = require('@alias/logger')('growi:lib:mailer');
-  const nodemailer = require('nodemailer');
-  const swig = require('swig-templates');
-
-  const { configManager, appService } = crowi;
-
-  const mailConfig = {};
-  let mailer = {};
-
-  function createSMTPClient(option) {
-    logger.debug('createSMTPClient option', option);
-    if (!option) {
-      option = { // eslint-disable-line no-param-reassign
-        host: configManager.getConfig('crowi', 'mail:smtpHost'),
-        port: configManager.getConfig('crowi', 'mail:smtpPort'),
-      };
-
-      if (configManager.getConfig('crowi', 'mail:smtpUser') && configManager.getConfig('crowi', 'mail:smtpPassword')) {
-        option.auth = {
-          user: configManager.getConfig('crowi', 'mail:smtpUser'),
-          pass: configManager.getConfig('crowi', 'mail:smtpPassword'),
-        };
-      }
-      if (option.port === 465) {
-        option.secure = true;
-      }
-    }
-    option.tls = { rejectUnauthorized: false };
-
-    const client = nodemailer.createTransport(option);
-
-    logger.debug('mailer set up for SMTP', client);
-    return client;
-  }
-
-  function createSESClient(option) {
-    if (!option) {
-      option = { // eslint-disable-line no-param-reassign
-        accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
-        secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
-      };
-    }
-
-    const ses = require('nodemailer-ses-transport');
-    const client = nodemailer.createTransport(ses(option));
-
-    logger.debug('mailer set up for SES', client);
-    return client;
-  }
-
-  function initialize() {
-    if (!configManager.getConfig('crowi', 'mail:from')) {
-      mailer = undefined;
-      return;
-    }
-
-    if (configManager.getConfig('crowi', 'mail:smtpHost') && configManager.getConfig('crowi', 'mail:smtpPort')
-    ) {
-      // SMTP 設定がある場合はそれを優先
-      mailer = createSMTPClient();
-    }
-    else if (configManager.getConfig('crowi', 'aws:accessKeyId') && configManager.getConfig('crowi', 'aws:secretAccessKey')) {
-      // AWS 設定がある場合はSESを設定
-      mailer = createSESClient();
-    }
-    else {
-      mailer = undefined;
-    }
-
-    mailConfig.from = configManager.getConfig('crowi', 'mail:from');
-    mailConfig.subject = `${appService.getAppTitle()}からのメール`;
-
-    logger.debug('mailer initialized');
-  }
-
-  function setupMailConfig(overrideConfig) {
-    const c = overrideConfig;
-
-
-    let mc = {};
-    mc = mailConfig;
-
-    mc.to = c.to;
-    mc.from = c.from || mailConfig.from;
-    mc.text = c.text;
-    mc.subject = c.subject || mailConfig.subject;
-
-    return mc;
-  }
-
-  async function send(config) {
-    if (mailer == null) {
-      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
-    }
-
-    const templateVars = config.vars || {};
-    const output = await swig.renderFile(
-      config.template,
-      templateVars,
-    );
-
-    config.text = output;
-    return mailer.sendMail(setupMailConfig(config));
-  }
-
-
-  initialize();
-
-  return {
-    createSMTPClient,
-    createSESClient,
-    mailer,
-    send,
-  };
-};

+ 58 - 0
src/test/service/config-manager.test.js

@@ -0,0 +1,58 @@
+const { getInstance } = require('../setup-crowi');
+
+describe('ConfigManager test', () => {
+  let crowi;
+  let configManager;
+
+  beforeEach(async(done) => {
+    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
+
+    crowi = await getInstance();
+    configManager = crowi.configManager;
+    done();
+  });
+
+
+  describe('updateConfigsInTheSameNamespace()', () => {
+
+    const configModelMock = {};
+
+    beforeEach(async(done) => {
+      configManager.configPubsub = {};
+
+      // prepare mocks for updateConfigsInTheSameNamespace method
+      configManager.configModel = configModelMock;
+
+      done();
+    });
+
+    test('invoke publishUpdateMessage()', async() => {
+      configModelMock.bulkWrite = jest.fn();
+      configManager.loadConfigs = jest.fn();
+      configManager.publishUpdateMessage = jest.fn();
+
+      const dummyConfig = { dummyKey: 'dummyValue' };
+      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
+
+      expect(configModelMock.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('does not invoke publishUpdateMessage()', async() => {
+      configModelMock.bulkWrite = jest.fn();
+      configManager.loadConfigs = jest.fn();
+      configManager.publishUpdateMessage = jest.fn();
+
+      const dummyConfig = { dummyKey: 'dummyValue' };
+      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
+
+      expect(configModelMock.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
+    });
+
+  });
+
+
+});

+ 72 - 0
yarn.lock

@@ -4624,6 +4624,14 @@ cyclist@~0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
 
+d@1, d@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
+  integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==
+  dependencies:
+    es5-ext "^0.10.50"
+    type "^1.0.1"
+
 dashdash@^1.12.0, dashdash@^1.14.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
@@ -5400,6 +5408,24 @@ es-to-primitive@^1.2.0:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+es5-ext@^0.10.35, es5-ext@^0.10.50:
+  version "0.10.53"
+  resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1"
+  integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==
+  dependencies:
+    es6-iterator "~2.0.3"
+    es6-symbol "~3.1.3"
+    next-tick "~1.0.0"
+
+es6-iterator@~2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
+  integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
+  dependencies:
+    d "1"
+    es5-ext "^0.10.35"
+    es6-symbol "^3.1.1"
+
 es6-object-assign@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c"
@@ -5426,6 +5452,14 @@ es6-promisify@^5.0.0:
   dependencies:
     es6-promise "^4.0.3"
 
+es6-symbol@^3.1.1, es6-symbol@~3.1.3:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18"
+  integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==
+  dependencies:
+    d "^1.0.1"
+    ext "^1.1.2"
+
 esa-nodejs@^0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/esa-nodejs/-/esa-nodejs-0.0.7.tgz#c4749412605ad430d5da17aa4928291927561b42"
@@ -5905,6 +5939,13 @@ express@^4.16.3:
     utils-merge "1.0.1"
     vary "~1.1.2"
 
+ext@^1.1.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244"
+  integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==
+  dependencies:
+    type "^2.0.0"
+
 extend-shallow@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
@@ -9751,6 +9792,11 @@ neo-async@^2.6.1:
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
   integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==
 
+next-tick@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
+  integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
+
 nice-try@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4"
@@ -14547,6 +14593,16 @@ type-is@~1.6.16:
     media-typer "0.3.0"
     mime-types "~2.1.18"
 
+type@^1.0.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0"
+  integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==
+
+type@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3"
+  integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow==
+
 typed-styles@^0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9"
@@ -15194,6 +15250,17 @@ webpack@^4.39.3:
     watchpack "^1.6.0"
     webpack-sources "^1.4.1"
 
+websocket@^1.0.31:
+  version "1.0.31"
+  resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.31.tgz#e5d0f16c3340ed87670e489ecae6144c79358730"
+  integrity sha512-VAouplvGKPiKFDTeCCO65vYHsyay8DqoBSlzIO3fayrfOgU94lQN5a1uWVnFrMLceTJw/+fQXR5PGbUVRaHshQ==
+  dependencies:
+    debug "^2.2.0"
+    es5-ext "^0.10.50"
+    nan "^2.14.0"
+    typedarray-to-buffer "^3.1.5"
+    yaeti "^0.0.6"
+
 whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
@@ -15514,6 +15581,11 @@ y18n@^3.2.1:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
 
+yaeti@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577"
+  integrity sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc=
+
 yallist@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"