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

Merge pull request #3605 from weseek/master

release v4.2.14
Yuki Takei 5 лет назад
Родитель
Сommit
bf8e241839

+ 16 - 1
CHANGES.md

@@ -1,6 +1,21 @@
 # CHANGES
 
-## v4.2.13-RC
+## v4.2.14-RC
+
+* Feature: Add an option to restrict publishing email property for new users
+* Improvement: Invite modal in admin page without email server settings
+* Improvement: Global notification settings in admin page without email server settings
+* Fix: Can create pages on the share route
+    * Introduced by v4.2.8
+* Fix: Pages restrected by group are excluded for recurrence operation
+    * Introduced by v4.2.8
+* Fix: Rename and duplicate to descendants path does not work correctly
+    * Introduced by v4.2.8
+* Support: Update libs
+    * bunyan
+    * browser-bunyan
+
+## v4.2.13
 
 * Feature: Detect indent size automatically
 * Fix: Some API responses includes email unintentionally

+ 5 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.13-RC",
+  "version": "4.2.14-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -86,7 +86,7 @@
     "aws-sdk": "^2.88.0",
     "axios": "^0.21.1",
     "body-parser": "^1.18.2",
-    "bunyan": "^1.8.12",
+    "bunyan": "^1.8.15",
     "bunyan-format": "^0.2.1",
     "check-node-version": "^4.0.2",
     "connect-flash": "~0.1.1",
@@ -154,6 +154,7 @@
     "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
+    "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "validator": "^12.0.0",
@@ -174,6 +175,7 @@
     "@babel/polyfill": "^7.4.4",
     "@babel/preset-env": "^7.4.5",
     "@babel/preset-react": "^7.0.0",
+    "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@handsontable/react": "=2.1.0",
     "autoprefixer": "^9.0.0",
     "babel-eslint": "^10.0.1",
@@ -181,7 +183,7 @@
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-transform-imports": "^2.0.0",
     "bootstrap": "^4.5.0",
-    "browser-bunyan": "^1.3.0",
+    "browser-bunyan": "^1.6.3",
     "browser-sync": "^2.26.3",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",

+ 9 - 3
resource/locales/en_US/admin/admin.json

@@ -1,4 +1,5 @@
 {
+  "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
     "management_wiki": "Management Wiki",
     "system_information": "System information",
@@ -22,6 +23,7 @@
     "confidential_name": "Confidential name",
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
+    "default_mail_visibility": "Disclose e-mail for new users",
     "file_uploading": "File uploading",
     "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
@@ -253,16 +255,20 @@
     "delete": "Delete"
   },
   "user_management": {
-    "invite_users": "Invite new users",
+    "invite_users": "Temporarily issue a new user",
     "click_twice_same_checkbox": "You should check at least one checkbox.",
     "invite_modal": {
-      "emails": "Emails (Possible to invite multiple people with new lines)",
+      "emails": "Emails (Possible to issue multiple people with new lines)",
+      "description1":"Temporarily issue new users by email addresses.",
+      "description2":"A temporary password will be generated for the first login.",
       "invite_thru_email": "Send invitation email",
+      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",
       "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
-      "existing_email": "The following emails already exist"
+      "existing_email": "The following emails already exist",
+      "issue": "Issue"
     },
     "user_table": {
       "administrator": "Administrator",

+ 10 - 4
resource/locales/ja_JP/admin/admin.json

@@ -1,4 +1,5 @@
 {
+  "mailer_setup_required":"送信するには <a href='/admin/app'>メールの設定</a> が必要です。",
   "admin_top": {
     "management_wiki": "Wiki管理",
     "system_information": "システム情報",
@@ -22,6 +23,7 @@
     "confidential_name": "コンフィデンシャル表示",
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
+    "default_mail_visibility": "新規ユーザーの初期メール公開設定",
     "file_uploading": "ファイルアップロード",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
@@ -251,16 +253,20 @@
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
   "user_management": {
-    "invite_users": "新規ユーザーの招待",
+    "invite_users": "新規ユーザーの仮発行",
     "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
     "invite_modal": {
-      "emails": "メールアドレス (複数行入力で複数人招待可能)",
-      "invite_thru_email": "招待をメールで送信",
+      "emails": "メールアドレス (複数行入力で複数人発行可能)",
+      "description1":"メールアドレスを使用して新規ユーザーを仮発行します。",
+      "description2":"初回のログイン時に使用する仮パスワードが生成されます。",
+      "invite_thru_email": "招待メールを送信する",
+      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>メールの設定</a>",
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
       "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
-      "existing_email": "以下のEmailはすでに存在しています。"
+      "existing_email": "以下のEmailはすでに存在しています。",
+      "issue": "発行"
     },
     "user_table": {
       "administrator": "管理者",

+ 9 - 3
resource/locales/zh_CN/admin/admin.json

@@ -1,4 +1,5 @@
 {
+  "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
 	"admin_top": {
 		"management_wiki": "管理Wiki",
 		"system_information": "系统信息",
@@ -22,6 +23,7 @@
 		"confidential_name": "内部名称",
 		"confidential_example": "ex):仅供内部使用",
 		"default_language": "新用户的默认语言",
+		"default_mail_visibility": "新用户的默认电子邮件可见性",
 		"file_uploading": "文件上传",
 		"enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
 		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
@@ -261,16 +263,20 @@
 		"delete": "删除"
   },
 	"user_management": {
-		"invite_users": "邀请新用户",
+		"invite_users": "临时发布新用户",
 		"click_twice_same_checkbox": "您应该至少选中一个复选框。",
 		"invite_modal": {
 			"emails": "电子邮件",
-			"invite_thru_email": "发送邀请电子邮件",
+      "description1":"通过电子邮件地址临时发布新用户。",
+      "description2":"将为首次登录生成一个临时密码。",
+      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
 			"valid_email": "需要有效的电子邮件地址",
+			"invite_thru_email": "发送邀请电子邮件",
 			"temporary_password": "创建的用户具有临时密码",
 			"send_new_password": "请将新密码发送给用户。",
 			"send_temporary_password": "请确保复制此屏幕上的临时密码并将其发送给用户。",
-			"existing_email": "以下电子邮件已存在"
+			"existing_email": "以下电子邮件已存在",
+      "issue": "Issue"
 		},
 		"user_table": {
 			"administrator": "管理员",

+ 35 - 0
src/client/js/components/Admin/App/AppSetting.jsx

@@ -103,6 +103,41 @@ class AppSetting extends React.Component {
           </div>
         </div>
 
+        <div className="row form-group mb-5">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('admin:app_setting.default_mail_visibility')}
+          </label>
+          <div className="col-md-6 py-2">
+
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radio-email-show"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
+                onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
+              />
+              <label className="custom-control-label" htmlFor="radio-email-show">{t('Show')}</label>
+            </div>
+
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radio-email-hide"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
+                onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
+              />
+              <label className="custom-control-label" htmlFor="radio-email-hide">{t('Hide')}</label>
+            </div>
+
+          </div>
+        </div>
+
         <div className="row form-group mb-5">
           <label
             className="text-left text-md-right col-md-3 col-form-label"

+ 6 - 1
src/client/js/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -96,7 +96,9 @@ class ManageGlobalNotification extends React.Component {
 
 
   render() {
-    const { t } = this.props;
+    const { t, appContainer } = this.props;
+    const { isMailerSetup } = appContainer.config;
+
     return (
       <React.Fragment>
 
@@ -179,7 +181,10 @@ class ManageGlobalNotification extends React.Component {
                     />
 
                   </div>
+
                   <p className="p-2">
+                    {/* eslint-disable-next-line react/no-danger */}
+                    {!isMailerSetup && <span className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />}
                     <b>Hint: </b>
                     <a href="https://ifttt.com/create" target="blank">{t('notification_setting.email.ifttt_link')}
                       <i className="icon-share-alt" />

+ 25 - 6
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -46,7 +46,12 @@ class UserInviteModal extends React.Component {
 
     return (
       <>
-        <label> {t('admin:user_management.invite_modal.emails')}</label>
+        <label>{t('admin:user_management.invite_modal.emails')}</label>
+        <p>
+          {t('admin:user_management.invite_modal.description1')}
+          <br />
+          {t('admin:user_management.invite_modal.description2')}
+        </p>
         <textarea
           className="form-control"
           placeholder="e.g.&#13;&#10;user1@growi.org&#13;&#10;user2@growi.org"
@@ -74,15 +79,29 @@ class UserInviteModal extends React.Component {
   }
 
   renderModalFooter() {
-    const { t } = this.props;
+    const { t, appContainer } = this.props;
+    const { isMailerSetup } = appContainer.config;
 
     return (
       <>
         <div className="col text-left custom-control custom-checkbox custom-checkbox-info text-left" onChange={this.handleCheckBox}>
-          <input type="checkbox" id="sendEmail" className="custom-control-input" name="sendEmail" defaultChecked={this.state.sendEmail} />
+          <input
+            type="checkbox"
+            id="sendEmail"
+            className="custom-control-input"
+            name="sendEmail"
+            defaultChecked={this.state.sendEmail}
+            disabled={!isMailerSetup}
+          />
           <label className="custom-control-label" htmlFor="sendEmail">
             {t('admin:user_management.invite_modal.invite_thru_email')}
           </label>
+          {isMailerSetup
+            // eslint-disable-next-line react/no-danger
+            ? <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:user_management.invite_modal.mail_setting_link') }} />
+            // eslint-disable-next-line react/no-danger
+            : <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
+          }
         </div>
         <div>
           <button
@@ -90,7 +109,7 @@ class UserInviteModal extends React.Component {
             className="btn btn-outline-secondary mr-2"
             onClick={this.onToggleModal}
           >
-            Cancel
+            {t('Cancel')}
           </button>
 
           <button
@@ -99,7 +118,7 @@ class UserInviteModal extends React.Component {
             onClick={this.handleSubmit}
             disabled={!this.validEmail()}
           >
-            Invite
+            {t('admin:user_management.invite_modal.issue')}
           </button>
         </div>
       </>
@@ -119,7 +138,7 @@ class UserInviteModal extends React.Component {
           className="btn btn-outline-secondary"
           onClick={this.onToggleModal}
         >
-          Close
+          {t('Close')}
         </button>
       </>
     );

+ 10 - 0
src/client/js/services/AdminAppContainer.js

@@ -19,6 +19,7 @@ export default class AdminAppContainer extends Container {
       title: this.dummyTitle,
       confidential: '',
       globalLang: '',
+      isEmailPublishedForNewUser: true,
       fileUpload: '',
 
       siteUrl: '',
@@ -78,6 +79,7 @@ export default class AdminAppContainer extends Container {
       title: appSettingsParams.title,
       confidential: appSettingsParams.confidential,
       globalLang: appSettingsParams.globalLang,
+      isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       fileUpload: appSettingsParams.fileUpload,
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
@@ -144,6 +146,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ globalLang });
   }
 
+  /**
+   * Change isEmailPublishedForNewUser
+   */
+  changeIsEmailPublishedForNewUserShow(isEmailPublishedForNewUser) {
+    this.setState({ isEmailPublishedForNewUser });
+  }
+
   /**
    * Change fileUpload
    */
@@ -309,6 +318,7 @@ export default class AdminAppContainer extends Container {
       title: this.state.title,
       confidential: this.state.confidential,
       globalLang: this.state.globalLang,
+      isEmailPublishedForNewUser: this.state.isEmailPublishedForNewUser,
       fileUpload: this.state.fileUpload,
     });
     const { appSettingParams } = response.data;

+ 1 - 3
src/client/styles/scss/theme/spring.scss

@@ -97,7 +97,7 @@ html[dark] {
   //Button
   // Outline buttons are applyed the accent color to this spring theme cuz the primary is too light and it looks like unable to click them.
   .btn.btn-outline-primary {
-    @include button-outline-variant($accentcolor, $accentcolor, lighten($accentcolor, 20%), $accentcolor);
+    @include btn-page-editor-mode-manager(darken($primary, 50%), darken($primary, 50%), lighten($primary, 10%));
   }
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
@@ -150,8 +150,6 @@ html[dark] {
 
   h1,
   h2 {
-    color: $subthemecolor;
-
     svg {
       fill: $subthemecolor;
     }

+ 3 - 1
src/lib/service/logger/alias-for-debug.js

@@ -1,3 +1,5 @@
+const generateBunyanLogger = require('./index');
+
 /**
  * return 'debug' method of bunyan logger
  *
@@ -6,6 +8,6 @@
  * @param {string} name
  */
 module.exports = (name) => {
-  const bunyanLogger = require('./index')(name);
+  const bunyanLogger = generateBunyanLogger(name);
   return bunyanLogger.debug.bind(bunyanLogger);
 };

+ 11 - 63
src/lib/service/logger/index.js

@@ -1,68 +1,16 @@
-const bunyan = require('bunyan'); // will be replaced to browser-bunyan on browser by webpack
-const minimatch = require('minimatch');
+const { createLogger } = require('universal-bunyan');
 
-const isBrowser = typeof window !== 'undefined';
-const isProd = process.env.NODE_ENV === 'production';
+const configForDev = require('@root/config/logger/config.dev');
+const configForProd = require('@root/config/logger/config.prod');
 
-const config = require('@root/config').logger;
-const stream = isProd ? require('./stream.prod') : require('./stream.dev');
+const isProduction = process.env.NODE_ENV === 'production';
+const config = isProduction ? configForProd : configForDev;
 
-// logger store
-const loggers = {};
-
-
-// merge configuration from environment variables
-const envLevelMap = {
-  INFO:   'info',
-  DEBUG:  'debug',
-  WARN:   'warn',
-  TRACE:  'trace',
-  ERROR:  'error',
-};
-Object.keys(envLevelMap).forEach((envName) => { // ['INFO', 'DEBUG', ...].forEach
-  const envVars = process.env[envName]; // process.env.DEBUG should have a value like 'growi:routes:page,growi:models.page,...'
-  if (envVars != null) {
-    const level = envLevelMap[envName];
-    envVars.split(',').forEach((ns) => { // ['growi:routes:page', 'growi:models.page', ...].forEach
-      config[ns.trim()] = level;
-    });
-  }
-});
-
-
-/**
- * determine logger level
- * @param {string} name Logger name
- */
-function determineLoggerLevel(name) {
-  if (isBrowser && isProd) {
-    return 'error';
-  }
-
-  let level = config.default;
-
-  /* eslint-disable array-callback-return, no-useless-return */
-  // retrieve configured level
-  Object.keys(config).some((key) => { //  breakable forEach
-    // test whether 'name' matches to 'key'(blob)
-    if (minimatch(name, key)) {
-      level = config[key];
-      return; //                          break if match
-    }
+const loggerFactory = function(name) {
+  return createLogger({
+    name,
+    config,
   });
-
-  return level;
-}
-
-module.exports = (name) => {
-  // create logger instance if absent
-  if (loggers[name] == null) {
-    loggers[name] = bunyan.createLogger({
-      name,
-      stream,
-      level: determineLoggerLevel(name),
-    });
-  }
-
-  return loggers[name];
 };
+
+module.exports = loggerFactory;

+ 0 - 16
src/lib/service/logger/stream.dev.js

@@ -1,16 +0,0 @@
-const isBrowser = typeof window !== 'undefined';
-
-let stream;
-
-// browser settings
-if (isBrowser) {
-  const ConsoleFormattedStream = require('@browser-bunyan/console-formatted-stream').ConsoleFormattedStream;
-  stream = new ConsoleFormattedStream();
-}
-// node settings
-else {
-  const bunyanFormat = require('bunyan-format');
-  stream = bunyanFormat({ outputMode: 'short' });
-}
-
-module.exports = stream;

+ 0 - 25
src/lib/service/logger/stream.prod.js

@@ -1,25 +0,0 @@
-const { envUtils } = require('growi-commons');
-
-const isBrowser = typeof window !== 'undefined';
-
-let stream;
-
-// browser settings
-if (isBrowser) {
-  const ConsoleFormattedStream = require('@browser-bunyan/console-formatted-stream').ConsoleFormattedStream;
-  stream = new ConsoleFormattedStream();
-}
-// node settings
-else {
-  const isFormat = (process.env.FORMAT_NODE_LOG == null) || envUtils.toBoolean(process.env.FORMAT_NODE_LOG);
-
-  if (isFormat) {
-    const bunyanFormat = require('bunyan-format');
-    stream = bunyanFormat({ outputMode: 'long' });
-  }
-  else {
-    stream = process.stdout;
-  }
-}
-
-module.exports = stream;

+ 1 - 0
src/server/models/config.js

@@ -229,6 +229,7 @@ module.exports = function(crowi) {
       isAclEnabled: crowi.aclService.isAclEnabled(),
       isSearchServiceConfigured: crowi.searchService.isConfigured,
       isSearchServiceReachable: crowi.searchService.isReachable,
+      isMailerSetup: crowi.mailService.isMailerSetup,
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };
 

+ 1 - 1
src/server/models/page.js

@@ -533,7 +533,7 @@ module.exports = function(crowi) {
       /\s+\/\s+/, // avoid miss in renaming
       /.+\/edit$/,
       /.+\.md$/,
-      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags)(\/.*|$)/,
+      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
     ];
 
     let isCreatable = true;

+ 5 - 0
src/server/models/user.js

@@ -268,6 +268,7 @@ module.exports = function(crowi) {
     this.name = name;
     this.username = username;
     this.status = STATUS_ACTIVE;
+    this.isEmailPublished = crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser');
 
     this.save((err, userData) => {
       userEvent.emit('activated', userData);
@@ -651,6 +652,10 @@ module.exports = function(crowi) {
     }
 
     const configManager = crowi.configManager;
+
+    // Default email show/hide is up to the administrator
+    newUser.isEmailPublished = configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser');
+
     const globalLang = configManager.getConfig('crowi', 'app:globalLang');
     if (globalLang != null) {
       newUser.lang = globalLang;

+ 7 - 0
src/server/routes/apiv3/app-settings.js

@@ -38,6 +38,9 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          globalLang:
  *            type: string
  *            description: language set when create user
+ *          isEmailPublishedForNewUser:
+ *            type: boolean
+ *            description: default email show/hide setting when create user
  *          fileUpload:
  *            type: boolean
  *            description: enable upload file except image file
@@ -154,6 +157,7 @@ module.exports = (crowi) => {
       body('title').trim(),
       body('confidential'),
       body('globalLang').isIn(listLocaleIds()),
+      body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
     ],
     siteUrlSetting: [
@@ -219,6 +223,7 @@ module.exports = (crowi) => {
       title: crowi.configManager.getConfig('crowi', 'app:title'),
       confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+      isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
       fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
@@ -289,6 +294,7 @@ module.exports = (crowi) => {
       'app:title': req.body.title,
       'app:confidential': req.body.confidential,
       'app:globalLang': req.body.globalLang,
+      'customize:isEmailPublishedForNewUser': req.body.isEmailPublishedForNewUser,
       'app:fileUpload': req.body.fileUpload,
     };
 
@@ -298,6 +304,7 @@ module.exports = (crowi) => {
         title: crowi.configManager.getConfig('crowi', 'app:title'),
         confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
         globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+        isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
         fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       };
       return res.apiv3({ appSettingParams });

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

@@ -398,6 +398,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  DEFAULT_EMAIL_PUBLISHED: {
+    ns:      'crowi',
+    key:     'customize:isEmailPublishedForNewUser',
+    type:    TYPES.BOOLEAN,
+    default: true,
+  },
 };
 
 class ConfigLoader {

+ 32 - 13
src/server/service/page.js

@@ -5,6 +5,7 @@ const debug = require('debug')('growi:models:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('@server/util/batch-stream');
 const { isTrashPage } = require('@commons/util/path-utils');
+const streamToPromise = require('stream-to-promise');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 const BULK_REINDEX_SIZE = 100;
@@ -57,6 +58,10 @@ class PageService {
     // sanitize path
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
+    if (isRecursively) {
+      await this.renameDescendantsWithStream(page, newPagePath, user, options);
+    }
+
     const update = {};
     // update Page
     update.path = newPagePath;
@@ -74,10 +79,6 @@ class PageService {
       await Page.create(path, body, user, { redirectTo: newPagePath });
     }
 
-    if (isRecursively) {
-      this.renameDescendantsWithStream(page, newPagePath, user, options);
-    }
-
     this.pageEvent.emit('delete', page, user, socketClientId);
     this.pageEvent.emit('create', renamedPage, user, socketClientId);
 
@@ -141,6 +142,9 @@ class PageService {
    */
   async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
     const Page = this.crowi.model('Page');
+    const UserGroupRelation = this.crowi.model('UserGroupRelation');
+
+    const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     const newPagePathPrefix = newPagePath;
     const { PageQueryBuilder } = Page;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
@@ -148,7 +152,7 @@ class PageService {
     const readStream = new PageQueryBuilder(Page.find())
       .addConditionToExcludeRedirect()
       .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
+      .addConditionToFilteringByViewer(user, userGroups)
       .query
       .lean()
       .cursor({ batchSize: BULK_REINDEX_SIZE });
@@ -182,6 +186,8 @@ class PageService {
     readStream
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
+
+    return streamToPromise(writeStream);
   }
 
 
@@ -234,14 +240,14 @@ class PageService {
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
+    if (isRecursively) {
+      await this.duplicateDescendantsWithStream(page, newPagePath, user);
+    }
+
     const createdPage = await Page.create(
       newPagePath, page.revision.body, user, options,
     );
 
-    if (isRecursively) {
-      this.duplicateDescendantsWithStream(page, newPagePath, user);
-    }
-
     // take over tags
     const originTags = await page.findRelatedTagsById();
     let savedTags = [];
@@ -342,6 +348,9 @@ class PageService {
 
   async duplicateDescendantsWithStream(page, newPagePath, user) {
     const Page = this.crowi.model('Page');
+    const UserGroupRelation = this.crowi.model('UserGroupRelation');
+
+    const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     const newPagePathPrefix = newPagePath;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
 
@@ -350,7 +359,7 @@ class PageService {
     const readStream = new PageQueryBuilder(Page.find())
       .addConditionToExcludeRedirect()
       .addConditionToListOnlyDescendants(page.path)
-      .addConditionToFilteringByViewer(user)
+      .addConditionToFilteringByViewer(user, userGroups)
       .query
       .lean()
       .cursor({ batchSize: BULK_REINDEX_SIZE });
@@ -385,6 +394,7 @@ class PageService {
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
 
+    return streamToPromise(writeStream);
   }
 
 
@@ -480,12 +490,15 @@ class PageService {
    */
   async deleteDescendantsWithStream(targetPage, user, options = {}) {
     const Page = this.crowi.model('Page');
+    const UserGroupRelation = this.crowi.model('UserGroupRelation');
+
+    const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     const { PageQueryBuilder } = Page;
 
     const readStream = new PageQueryBuilder(Page.find())
       .addConditionToExcludeRedirect()
       .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
+      .addConditionToFilteringByViewer(user, userGroups)
       .query
       .lean()
       .cursor({ batchSize: BULK_REINDEX_SIZE });
@@ -556,12 +569,15 @@ class PageService {
    */
   async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
     const Page = this.crowi.model('Page');
+    const UserGroupRelation = this.crowi.model('UserGroupRelation');
+
+    const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     const { PageQueryBuilder } = Page;
 
     const readStream = new PageQueryBuilder(Page.find())
       .addConditionToExcludeRedirect()
       .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
+      .addConditionToFilteringByViewer(user, userGroups)
       .query
       .lean()
       .cursor({ batchSize: BULK_REINDEX_SIZE });
@@ -682,12 +698,15 @@ class PageService {
    */
   async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
     const Page = this.crowi.model('Page');
+    const UserGroupRelation = this.crowi.model('UserGroupRelation');
+
+    const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     const { PageQueryBuilder } = Page;
 
     const readStream = new PageQueryBuilder(Page.find())
       .addConditionToExcludeRedirect()
       .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
+      .addConditionToFilteringByViewer(user, userGroups)
       .query
       .lean()
       .cursor({ batchSize: BULK_REINDEX_SIZE });

+ 52 - 17
yarn.lock

@@ -1315,21 +1315,31 @@
   resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
   integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
 
-"@browser-bunyan/console-formatted-stream@^1.3.0":
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.3.0.tgz#3dc059aa5c1b2a7a1f26e2706e2bdeb9a09bbe57"
+"@browser-bunyan/console-formatted-stream@^1.6.2":
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.6.2.tgz#e458a1913b952249afa4be3fde0d7b3068ab9507"
+  integrity sha512-RFY4VG5+ewPG5A4LC3uN4AC8MIXEjlUJ568VlXhdMDfV9/LUrUX1LUbf0UmFQ3OhI2jqbeC31XwP0gBIrwbXpw==
   dependencies:
-    "@browser-bunyan/levels" "^1.3.0"
+    "@browser-bunyan/levels" "^1.6.0"
 
-"@browser-bunyan/console-raw-stream@^1.3.0":
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.3.0.tgz#ccf24b56f2265058297c6517fbecea84ebb7818c"
+"@browser-bunyan/console-plain-stream@^1.6.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-plain-stream/-/console-plain-stream-1.6.0.tgz#295404482150e7693846ccb07045676218bcc911"
+  integrity sha512-92j8/Lk7yD6F4JKygWd7g9++QoNiEIj1MAP5zMGVk0g1ssPs3vqK1F+HgWfzYaHccREJ6S553imX9Ll5OAn2nA==
   dependencies:
-    "@browser-bunyan/levels" "^1.3.0"
+    "@browser-bunyan/levels" "^1.6.0"
 
-"@browser-bunyan/levels@^1.3.0":
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.3.0.tgz#a052303ae5d1a1f9b63eeb3a94495a2f429f4831"
+"@browser-bunyan/console-raw-stream@^1.6.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.6.0.tgz#255f4734c064dc046fe7896353982c563e2ec150"
+  integrity sha512-OqPe4uy/rGOL8ZRiq3iwGM/YIGWKd2ne+8cxWSsMbcLL5hr66IOhzr3nwhyRsSo58JbWDC3K/IaJDyoOTcwrdA==
+  dependencies:
+    "@browser-bunyan/levels" "^1.6.0"
+
+"@browser-bunyan/levels@^1.6.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.6.0.tgz#3a50b8118254aa2ac26caf9d2aafa72d157e374b"
+  integrity sha512-wte6nXXZH62Y/RGysYRlOkKxuJn+4S8xEamMF0fDncxxy0SriCHYwGPyWGF0FWYWmRzbZuEkp7dNebBf9Xfeeg==
 
 "@cnakazawa/watch@^1.0.3":
   version "1.0.3"
@@ -3218,13 +3228,15 @@ brorand@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
 
-browser-bunyan@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/browser-bunyan/-/browser-bunyan-1.3.0.tgz#26378dc58d7a98002cc9bfcfba2ea5d712449992"
+browser-bunyan@^1.6.3:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/browser-bunyan/-/browser-bunyan-1.6.3.tgz#0e58c51ff48507317ba8e5cf579e8b6bad7281e0"
+  integrity sha512-HRg+acpwO3dsY2RWgtjw2wPVHV+uzbCrdhUxD25+qo5NFSTpbfJekrRP0yFNypAhG5LwXFV1Dc5FIc8cxwU5rQ==
   dependencies:
-    "@browser-bunyan/console-formatted-stream" "^1.3.0"
-    "@browser-bunyan/console-raw-stream" "^1.3.0"
-    "@browser-bunyan/levels" "^1.3.0"
+    "@browser-bunyan/console-formatted-stream" "^1.6.2"
+    "@browser-bunyan/console-plain-stream" "^1.6.0"
+    "@browser-bunyan/console-raw-stream" "^1.6.0"
+    "@browser-bunyan/levels" "^1.6.0"
 
 browser-or-node@>=1.2.1:
   version "1.2.1"
@@ -3467,6 +3479,16 @@ bunyan@^1.8.12, bunyan@^1.8.3:
     mv "~2"
     safe-json-stringify "~1"
 
+bunyan@^1.8.15:
+  version "1.8.15"
+  resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.15.tgz#8ce34ca908a17d0776576ca1b2f6cbd916e93b46"
+  integrity sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==
+  optionalDependencies:
+    dtrace-provider "~0.8"
+    moment "^2.19.3"
+    mv "~2"
+    safe-json-stringify "~1"
+
 busboy@^0.2.11:
   version "0.2.14"
   resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
@@ -9803,6 +9825,11 @@ moment@>=2.26.0:
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a"
   integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw==
 
+moment@^2.19.3:
+  version "2.29.1"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
+  integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
+
 mongodb@3.6.2, mongodb@^3.1.0, mongodb@^3.6.2:
   version "3.6.2"
   resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.2.tgz#1154a4ac107bf1375112d83a29c5cf97704e96b6"
@@ -15192,6 +15219,14 @@ unist-util-visit@^1.1.0:
   dependencies:
     unist-util-visit-parents "^2.0.0"
 
+universal-bunyan@^0.9.2:
+  version "0.9.2"
+  resolved "https://registry.yarnpkg.com/universal-bunyan/-/universal-bunyan-0.9.2.tgz#4cf09dc34070390d8f5df4fe9af6a80fcd0dd574"
+  integrity sha512-MkyO17+5AVCpFfhMtYLODvSZmPxV8eHcoOAWobEXXzlXrSnf5YgCV5lBWcMV3VPaaKyZPQ0oG5PSWYmGSBGtIg==
+  dependencies:
+    bunyan-format "^0.2.1"
+    minimatch "^3.0.4"
+
 universalify@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.1.tgz#fa71badd4437af4c148841e3b3b165f9e9e590b7"