Преглед изворни кода

Merge pull request #1106 from weseek/master

release v3.5.2
Yuki Takei пре 6 година
родитељ
комит
a0c8685d3d
35 измењених фајлова са 235 додато и 159 уклоњено
  1. 14 4
      CHANGES.md
  2. 4 0
      README.md
  3. 3 1
      bin/download-cdn-resources.js
  4. 1 1
      config/env.dev.js
  5. 2 2
      package.json
  6. 2 0
      resource/cdn-manifests.js
  7. 21 20
      resource/locales/en-US/translation.json
  8. 14 15
      resource/locales/ja/translation.json
  9. 3 2
      src/client/js/components/PageEditor.jsx
  10. 2 2
      src/client/js/components/PageEditor/ScrollSyncHelper.js
  11. 1 1
      src/client/js/components/SavePageControls/GrantSelector.jsx
  12. 15 12
      src/lib/service/cdn-resources-service.js
  13. 3 1
      src/lib/service/logger/stream.prod.js
  14. 1 1
      src/server/form/admin/securityGeneral.js
  15. 1 1
      src/server/models/config.js
  16. 6 3
      src/server/models/page.js
  17. 11 8
      src/server/routes/admin.js
  18. 4 4
      src/server/routes/page.js
  19. 25 6
      src/server/service/acl.js
  20. 9 1
      src/server/service/config-loader.js
  21. 1 1
      src/server/util/middlewares.js
  22. 1 1
      src/server/util/swigFunctions.js
  23. 3 3
      src/server/views/admin/external-accounts.html
  24. 12 4
      src/server/views/admin/security.html
  25. 4 4
      src/server/views/admin/user-group-detail.html
  26. 6 3
      src/server/views/admin/user-groups.html
  27. 3 3
      src/server/views/admin/users.html
  28. 1 1
      src/server/views/me/external-accounts.html
  29. 13 11
      src/server/views/modal/delete.html
  30. 21 9
      src/server/views/modal/rename.html
  31. 1 1
      src/server/views/widget/page_tabs.html
  32. 1 1
      src/server/views/widget/page_tabs_kibela.html
  33. 0 8
      src/test/models/page.test.js
  34. 22 20
      src/test/models/user.test.js
  35. 4 4
      yarn.lock

+ 14 - 4
CHANGES.md

@@ -1,6 +1,16 @@
 # CHANGES
 
-## 3.5.1-RC
+## 3.5.2-RC
+
+* Feature: Remain metadata option when Move/Rename page
+* Improvement: Support code highlight for Swift and Kotlin
+* Fix: Couldn't duplicate a page when it restricted by a user group permission
+* Fix: Consider timezone on admin page
+* Fix: Editor doesn't work on Microsoft Edge
+* Support: Upgrade libs
+    * growi-commons
+
+## 3.5.1
 
 ### BREAKING CHANGES
 
@@ -8,9 +18,9 @@
     * Protection system with Basic Authentication
     * Crowi Classic Authentication Mechanism
     * [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
-    * GROWI Plugins with schema version 2
-        * Upgrade [weseek/growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx) to v3.0.0 or above
-        * Upgrade [weseek/growi-plugin-pukiwiki-like-linker
+* GROWI no lonnger supports plugins with schema version 2
+    * Upgrade [weseek/growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx) to v3.0.0 or above
+    * Upgrade [weseek/growi-plugin-pukiwiki-like-linker
 ](https://github.com/weseek/growi-plugin-pukiwiki-like-linker
 ) to v3.0.0 or above
 * The restriction mode of the root page (`/`) will be set 'Public'

+ 4 - 0
README.md

@@ -172,6 +172,10 @@ Environment Variables
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
     * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
     * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
+    * FORCE_WIKI_MODE: Forces wiki mode. default: undefined
+      * `public`  : Forces all pages to become public
+      * `private` : Forces all pages to become private
+      * undefined : Publicity will be configured by the admin security page settings
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
         * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**

+ 3 - 1
bin/download-cdn-resources.js

@@ -7,8 +7,10 @@ require('module-alias/register');
 
 const logger = require('@alias/logger')('growi:bin:download-cdn-resources');
 
+const { envUtils } = require('growi-commons');
+
 // check env var
-const noCdn = !!process.env.NO_CDN;
+const noCdn = envUtils.toBoolean(process.env.NO_CDN);
 if (!noCdn) {
   logger.info('Using CDN. No resources are downloaded.');
   // exit

+ 1 - 1
config/env.dev.js

@@ -13,5 +13,5 @@ module.exports = {
   // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
-  // PUBLIC_WIKI_ONLY: true,
+  // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
 };

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.1-RC",
+  "version": "3.5.2-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -92,7 +92,7 @@
     "express-validator": "^5.3.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^4.0.1",
+    "growi-commons": "^4.0.3",
     "helmet": "^3.13.0",
     "i18next": "^17.0.3",
     "i18next-express-middleware": "^1.4.1",

+ 2 - 0
resource/cdn-manifests.js

@@ -28,6 +28,8 @@ module.exports = {
         + 'gh/highlightjs/cdn-release@9.13.0/build/languages/scss.min.js,'
         + 'gh/highlightjs/cdn-release@9.13.0/build/languages/typescript.min.js,'
         + 'gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/swift.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/kotlin.min.js,'
         + 'npm/highlightjs-line-numbers.js@2.6.0/dist/highlightjs-line-numbers.min.js',
       args: {
         async: true,

+ 21 - 20
resource/locales/en-US/translation.json

@@ -6,7 +6,7 @@
   "Duplicate": "Duplicate",
   "Copy": "Copy",
   "Click to copy": "Click to copy",
-  "Move": "Move",
+  "Move/Rename": "Move/Rename",
   "Moved": "Moved",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
@@ -27,6 +27,8 @@
   "Category": "Category",
   "User": "User",
   "status":"Status",
+  "account_id": "Account Id",
+
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -105,13 +107,12 @@
   "Customize": "Customize",
   "Notification Settings": "Notification Settings",
   "User_Management": "User Management",
-  "External Account management": "External Account management",
+  "external_account_management": "External Account Management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
-  "Guest users access": "Guest users access",
   "Register limitation": "Register limitation",
   "The contents entered here will be shown in the header etc": "The contents entered here will be shown in the header etc",
   "Public": "Public",
@@ -267,15 +268,17 @@
 
   "modal_rename": {
     "label": {
-      "Rename page": "Rename page",
+      "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
       "Current page name": "Current page name",
-      "Move recursively": "Move recursively",
+      "Recursively": "Recursively",
+      "Do not update metadata": "Do not update metadata",
       "Redirect": "Redirect"
     },
     "help": {
       "redirect": "Redirect to new page if someone accesses <code>%s</code>",
-      "recursive": "Rename children of under <code>%s</code> recursively"
+      "metadata": "Remains last update user and updated date as is",
+      "recursive": "Move/Rename children of under <code>%s</code> recursively"
     }
   },
 
@@ -285,9 +288,9 @@
   "modal_delete": {
     "delete_page": "Delete Page",
     "deleting_page": "Deleting Page",
-    "delete_recursively": "Delete child pages under %s recursively.",
+    "delete_recursively": "Delete child pages recursively.",
     "delete_completely": "Delete Completely",
-    "delete_completely_restriction": "You have no admin to delete completely.",
+    "delete_completely_restriction": "You don't have the authority to delete pages completely.",
     "recursively": "Delete children of under <code>%s</code> recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
@@ -433,14 +436,12 @@
   },
 
   "security_setting": {
-		"Basic authentication": "Basic Authentication",
 		"Security settings": "Security settings",
-		"Guest users access": "Guest users access",
-		"Register limitation": "Register limitation",
+    "Guest Users Access": "Guest Users Access",
+    "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
+    "Register limitation": "Register limitation",
+    "Register limitation desc": "Restricts ways to register new user.",
 		"The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-		"common_authentication": "If you set the basic authentication, common authentication is applied on the whole page.",
-		"without_encryption": "Please be noted that your ID and Password will be sent wihtout encryption.",
-		"basic_acl_disable": "Because of Public Wiki  setting, basic authentication can not be used.",
 		"users_without_account": "Users without account is not accessible",
     "example": "Example",
     "restrict_emails": "You can restrict registerable e-mail address.",
@@ -469,13 +470,13 @@
     "clientID": "Client ID",
     "client_secret": "Client Secret",
     "guest_mode": {
-      "deny": "Deny Unregistered Users",
-      "readonly": "View Only"
+      "deny": "Deny (Registered Users Only)",
+      "readonly": "Accept (Guests can read only)"
     },
     "registration_mode": {
-      "open": "Anyone",
-      "restricted": "Require Admin permission",
-      "closed": "Invitation Only"
+      "open": "Open (Anyone can registre)",
+      "restricted": "Restricted (Requires approval by administrators)",
+      "closed": "Closed (Invitation Only)"
     },
     "configuration": " Configuration",
     "optional": "Optional",
@@ -705,7 +706,7 @@
     "group_example": "e.g. : Group1",
     "created_group": "Group was created",
     "add_user": "Add a User to the Created Group",
-    "deny_create_group": "You can't create a new group with the current settings",
+    "deny_create_group": "You can't create a new group.",
     "is_loading_data": "Loading data...",
     "choose_action": "Choose an action for private pages",
     "delete_group": "Delete Group",

+ 14 - 15
resource/locales/ja/translation.json

@@ -6,7 +6,7 @@
   "Duplicate": "複製",
   "Copy": "コピー",
   "Click to copy": "クリックでコピー",
-  "Move": "移動",
+  "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
@@ -27,6 +27,7 @@
   "Category": "カテゴリー",
   "User": "ユーザー",
   "status": "ステータス",
+  "account_id": "アカウントID",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -105,13 +106,11 @@
   "Customize": "カスタマイズ",
   "Notification Settings": "通知設定",
   "User_Management": "ユーザー管理",
-  "External Account management": "外部アカウント管理",
+  "external_account_management": "外部アカウント管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Basic Settings": "基本設定",
-  "Basic authentication": "Basic認証",
-  "Guest users access": "ゲストユーザーのアクセス",
   "Register limitation": "登録の制限",
   "The contents entered here will be shown in the header etc": "ここに入力した内容は、ヘッダー等に表示されます。",
   "Public": "公開",
@@ -267,15 +266,17 @@
 
   "modal_rename": {
     "label": {
-      "Rename page": "ページを移動する",
+      "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
       "Current page name": "現在のページ名",
-      "Move recursively": "再帰的に移動",
+      "Recursively": "再帰的に移動/名前変更",
+      "Do not update metadata": "メタデータを更新しない",
       "Redirect": "リダイレクトする"
     },
     "help": {
       "redirect": "<code>%s</code> にアクセスされた際に自動的に新しいページにジャンプします",
-      "recursive": "<code>%s</code> 配下のページも移動します"
+      "metadata": "最終更新ユーザー、最終更新日を更新せず維持します",
+      "recursive": "<code>%s</code> 配下のページも移動/名前変更します"
     }
   },
 
@@ -433,13 +434,11 @@
    },
 
   "security_setting": {
-    "Basic authentication": "Basic認証",
-    "Guest users access": "ゲストユーザーのアクセス",
+    "Guest Users Access": "ゲストユーザーのアクセス",
+    "Fixed by env var": "環境変数 <code>%s=%s</code> により固定されています。",
     "Register limitation": "登録の制限",
+    "Register limitation desc": "新しいユーザーを登録する方法を制限します.",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
-    "common_authentication": "Basic認証を設定すると、ページ全体に共通の認証がかかります。",
-    "without_encryption": "IDとパスワードは暗号化されずに送信されるのでご注意下さい。",
-    "basic_acl_disable": "Public Wiki の設定のため、Basic認証は利用できません。",
     "users_without_account": "アカウントを持たないユーザーはアクセス不可",
     "example": "例",
     "restrict_emails": "登録可能なメールアドレスを制限することができます。",
@@ -465,8 +464,8 @@
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
     "guest_mode": {
-      "deny": "アカウントを持たないユーザーはアクセス不可",
-      "readonly": "閲覧のみ可"
+      "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
+      "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
     },
     "registration_mode": {
       "open": "公開 (だれでも登録可能)",
@@ -690,7 +689,7 @@
     "group_example": "例: Group1",
     "created_group": "グループを作成しました",
     "add_user": "グループへのユーザー追加",
-    "deny_create_group": "現在の設定では新規グループの作成はできません。",
+    "deny_create_group": "新規グループの作成はできません。",
     "is_loading_data": "データを取得中です...",
     "choose_action": "削除するグループの限定公開ページの処理を選択してください",
     "delete_group": "グループの削除",

+ 3 - 2
src/client/js/components/PageEditor.jsx

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { throttle, debounce } from 'throttle-debounce';
+import { envUtils } from 'growi-commons';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
@@ -161,7 +162,7 @@ class PageEditor extends React.Component {
 
       // when if created newly
       if (res.pageCreated) {
-        logger.info('Page is created', res.pageCreated._id);
+        logger.info('Page is created', res.page._id);
         pageContainer.updateStateAfterSave(res.page);
       }
     }
@@ -321,7 +322,7 @@ class PageEditor extends React.Component {
 
   render() {
     const config = this.props.appContainer.getConfig();
-    const noCdn = !!config.env.NO_CDN;
+    const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
 
     return (

+ 2 - 2
src/client/js/components/PageEditor/ScrollSyncHelper.js

@@ -144,7 +144,7 @@ class ScrollSyncHelper {
 
       scrollTo -= this.getParentElementOffset(previewElement);
 
-      previewElement.scroll(0, previewElement.scrollTop + scrollTo);
+      previewElement.scrollTop += scrollTo;
     }
   }
 
@@ -176,7 +176,7 @@ class ScrollSyncHelper {
         return;
       }
 
-      previewElement.scroll(0, scrollTo);
+      previewElement.scrollTop = scrollTo;
     }
   }
 

+ 1 - 1
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -277,7 +277,7 @@ class GrantSelector extends React.Component {
     return (
       <React.Fragment>
         { this.renderGrantSelector() }
-        { this.props.disabled && this.renderSelectGroupModal() }
+        { !this.props.disabled && this.renderSelectGroupModal() }
       </React.Fragment>
     );
   }

+ 15 - 12
src/lib/service/cdn-resources-service.js

@@ -3,6 +3,8 @@ const urljoin = require('url-join');
 
 const helpers = require('@commons/util/helpers');
 
+const { envUtils } = require('growi-commons');
+
 const cdnLocalScriptRoot = 'public/js/cdn';
 const cdnLocalScriptWebRoot = '/js/cdn';
 const cdnLocalStyleRoot = 'public/styles/cdn';
@@ -14,7 +16,6 @@ class CdnResourcesService {
   constructor() {
     this.logger = require('@alias/logger')('growi:service:CdnResourcesService');
 
-    this.noCdn = !!process.env.NO_CDN;
     this.loadManifests();
   }
 
@@ -23,6 +24,10 @@ class CdnResourcesService {
     this.logger.debug('manifest data loaded : ', this.cdnManifests);
   }
 
+  noCdn() {
+    return envUtils.toBoolean(process.env.NO_CDN);
+  }
+
   getScriptManifestByName(name) {
     const manifests = this.cdnManifests.js
       .filter((manifest) => { return manifest.name === name });
@@ -72,9 +77,8 @@ class CdnResourcesService {
    * Generate script tag string
    *
    * @param {Object} manifest
-   * @param {boolean} noCdn
    */
-  generateScriptTag(manifest, noCdn) {
+  generateScriptTag(manifest) {
     const attrs = [];
     const args = manifest.args || {};
 
@@ -87,7 +91,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = noCdn
+    const url = this.noCdn()
       ? `${urljoin(cdnLocalScriptWebRoot, manifest.name)}.js`
       : manifest.url;
     return `<script src="${url}" ${attrs.join(' ')}></script>`;
@@ -95,7 +99,7 @@ class CdnResourcesService {
 
   getScriptTagByName(name) {
     const manifest = this.getScriptManifestByName(name);
-    return this.generateScriptTag(manifest, this.noCdn);
+    return this.generateScriptTag(manifest);
   }
 
   getScriptTagsByGroup(group) {
@@ -104,7 +108,7 @@ class CdnResourcesService {
         return manifest.groups != null && manifest.groups.includes(group);
       })
       .map((manifest) => {
-        return this.generateScriptTag(manifest, this.noCdn);
+        return this.generateScriptTag(manifest);
       });
   }
 
@@ -112,9 +116,8 @@ class CdnResourcesService {
    * Generate style tag string
    *
    * @param {Object} manifest
-   * @param {boolean} noCdn
    */
-  generateStyleTag(manifest, noCdn) {
+  generateStyleTag(manifest) {
     const attrs = [];
     const args = manifest.args || {};
 
@@ -127,7 +130,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = noCdn
+    const url = this.noCdn()
       ? `${urljoin(cdnLocalStyleWebRoot, manifest.name)}.css`
       : manifest.url;
 
@@ -136,7 +139,7 @@ class CdnResourcesService {
 
   getStyleTagByName(name) {
     const manifest = this.getStyleManifestByName(name);
-    return this.generateStyleTag(manifest, this.noCdn);
+    return this.generateStyleTag(manifest);
   }
 
   getStyleTagsByGroup(group) {
@@ -145,7 +148,7 @@ class CdnResourcesService {
         return manifest.groups != null && manifest.groups.includes(group);
       })
       .map((manifest) => {
-        return this.generateStyleTag(manifest, this.noCdn);
+        return this.generateStyleTag(manifest);
       });
   }
 
@@ -160,7 +163,7 @@ class CdnResourcesService {
       manifest = Object.assign(manifest, { url: url.toString() });
     }
 
-    return this.generateStyleTag(manifest, this.noCdn);
+    return this.generateStyleTag(manifest);
   }
 
 }

+ 3 - 1
src/lib/service/logger/stream.prod.js

@@ -1,3 +1,5 @@
+const { envUtils } = require('growi-commons');
+
 const isBrowser = typeof window !== 'undefined';
 
 let stream;
@@ -9,7 +11,7 @@ if (isBrowser) {
 }
 // node settings
 else {
-  const isFormat = !(process.env.FORMAT_NODE_LOG === 'false');
+  const isFormat = (process.env.FORMAT_NODE_LOG == null) || envUtils.toBoolean(process.env.FORMAT_NODE_LOG);
 
   if (isFormat) {
     const bunyanFormat = require('bunyan-format');

+ 1 - 1
src/server/form/admin/securityGeneral.js

@@ -5,7 +5,7 @@ const stringToArray = require('../../util/formUtil').stringToArrayFilter;
 const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
 
 module.exports = form(
-  field('settingForm[security:restrictGuestMode]').required(),
+  field('settingForm[security:restrictGuestMode]'),
   field('settingForm[security:registrationMode]').required(),
   field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
   field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),

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

@@ -188,7 +188,7 @@ module.exports = function(crowi) {
         NO_CDN: env.NO_CDN || null,
       },
       recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
-      isAclEnabled: !crowi.aclService.getIsPublicWikiOnly(),
+      isAclEnabled: crowi.aclService.isAclEnabled(),
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };
 

+ 6 - 3
src/server/models/page.js

@@ -1228,7 +1228,8 @@ module.exports = function(crowi) {
     const Page = this;
     const Revision = crowi.model('Revision');
     const path = pageData.path;
-    const createRedirectPage = options.createRedirectPage || 0;
+    const createRedirectPage = options.createRedirectPage || false;
+    const updateMetadata = options.updateMetadata || false;
     const socketClientId = options.socketClientId || null;
 
     // sanitize path
@@ -1236,8 +1237,10 @@ module.exports = function(crowi) {
 
     // update Page
     pageData.path = newPagePath;
-    pageData.lastUpdateUser = user;
-    pageData.updatedAt = Date.now();
+    if (updateMetadata) {
+      pageData.lastUpdateUser = user;
+      pageData.updatedAt = Date.now();
+    }
     const updatedPageData = await pageData.save();
 
     // update Rivisions

+ 11 - 8
src/server/routes/admin.js

@@ -105,7 +105,13 @@ module.exports = function(crowi, app) {
   // app.get('/admin/security'                  , admin.security.index);
   actions.security = {};
   actions.security.index = function(req, res) {
-    return res.render('admin/security');
+    const isWikiModeForced = aclService.isWikiModeForced();
+    const guestModeValue = aclService.getGuestModeValue();
+
+    return res.render('admin/security', {
+      isWikiModeForced,
+      guestModeValue,
+    });
   };
 
   // app.get('/admin/markdown'                  , admin.markdown.index);
@@ -624,7 +630,7 @@ module.exports = function(crowi, app) {
   actions.userGroup = {};
   actions.userGroup.index = function(req, res) {
     const page = parseInt(req.query.page) || 1;
-    const isAclEnabled = !aclService.getIsPublicWikiOnly();
+    const isAclEnabled = aclService.isAclEnabled();
     const renderVar = {
       userGroups: [],
       userGroupRelations: new Map(),
@@ -889,12 +895,9 @@ module.exports = function(crowi, app) {
     }
 
     const form = req.form.settingForm;
-    if (aclService.getIsPublicWikiOnly()) {
-      const guestMode = form['security:restrictGuestMode'];
-      if (guestMode === 'Deny') {
-        req.form.errors.push('Private Wikiへの設定変更はできません。');
-        return res.json({ status: false, message: req.form.errors.join('\n') });
-      }
+    if (aclService.isWikiModeForced()) {
+      logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');
+      delete form['security:restrictGuestMode'];
     }
 
     try {

+ 4 - 4
src/server/routes/page.js

@@ -1047,11 +1047,11 @@ module.exports = function(crowi, app) {
     const previousRevision = req.body.revision_id || null;
     const newPagePath = pathUtils.normalizePath(req.body.new_path);
     const options = {
-      createRedirectPage: req.body.create_redirect || 0,
-      moveUnderTrees: req.body.move_trees || 0,
+      createRedirectPage: (req.body.create_redirect != null),
+      updateMetadata: (req.body.remain_metadata == null),
       socketClientId: +req.body.socketClientId || undefined,
     };
-    const isRecursively = req.body.recursively || 0;
+    const isRecursively = (req.body.recursively != null);
 
     if (!Page.isCreatableName(newPagePath)) {
       return res.json(ApiResponse.error(`Could not use the path '${newPagePath})'`, 'invalid_path'));
@@ -1124,7 +1124,7 @@ module.exports = function(crowi, app) {
     req.body.body = page.revision.body;
     req.body.grant = page.grant;
     req.body.grantedUsers = page.grantedUsers;
-    req.body.grantedGroup = page.grantedGroup;
+    req.body.grantUserGroupId = page.grantedGroup;
     req.body.pageTags = originTags;
 
     return api.create(req, res);

+ 25 - 6
src/server/service/acl.js

@@ -16,14 +16,33 @@ class AclService {
     };
   }
 
-  getIsPublicWikiOnly() {
-    const publicWikiOnly = process.env.PUBLIC_WIKI_ONLY;
-    return !!publicWikiOnly;
+  isAclEnabled() {
+    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    return wikiMode !== 'public';
   }
 
-  getIsGuestAllowedToRead() {
-    // return true if puclic wiki mode
-    if (this.getIsPublicWikiOnly()) {
+  isWikiModeForced() {
+    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const isPrivateOrPublic = wikiMode === 'private' || wikiMode === 'public';
+
+    return isPrivateOrPublic;
+  }
+
+  getGuestModeValue() {
+    return this.isGuestAllowedToRead()
+      ? this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY
+      : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
+  }
+
+  isGuestAllowedToRead() {
+    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+
+    // return false if private wiki mode
+    if (wikiMode === 'private') {
+      return false;
+    }
+    // return true if public wiki mode
+    if (wikiMode === 'public') {
       return true;
     }
 

+ 9 - 1
src/server/service/config-loader.js

@@ -1,9 +1,11 @@
 const debug = require('debug')('growi:service:ConfigLoader');
 
+const { envUtils } = require('growi-commons');
+
 const TYPES = {
   NUMBER:  { parse: (v) => { return parseInt(v, 10) } },
   STRING:  { parse: (v) => { return v } },
-  BOOLEAN: { parse: (v) => { return /^(true|1)$/i.test(v) } },
+  BOOLEAN: { parse: (v) => { return envUtils.toBoolean(v) } },
 };
 
 /**
@@ -134,6 +136,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  FORCE_WIKI_MODE: {
+    ns:      'crowi',
+    key:     'security:wikiMode',
+    type:    TYPES.STRING,
+    default: undefined,
+  },
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',

+ 1 - 1
src/server/util/middlewares.js

@@ -194,7 +194,7 @@ module.exports = (crowi, app) => {
       // when the route is not strictly restricted
       if (!isStrictly) {
         // when allowed to read
-        if (crowi.aclService.getIsGuestAllowedToRead()) {
+        if (crowi.aclService.isGuestAllowedToRead()) {
           return next();
         }
       }

+ 1 - 1
src/server/util/swigFunctions.js

@@ -70,7 +70,7 @@ module.exports = function(crowi, app, req, locals) {
   locals.customizeService = customizeService;
 
   locals.noCdn = function() {
-    return !!process.env.NO_CDN;
+    return cdnResourcesService.noCdn();
   };
 
   locals.cdnScriptTag = function(name) {

+ 3 - 3
src/server/views/admin/external-accounts.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customizeService.generateCustomTitle(t('External Account management')) }}{% endblock %}
+{% block html_title %}{{ customizeService.generateCustomTitle(t('external_account_management')) }}{% endblock %}
 
 {% block content_header %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('User_management') }}/{{ t('External Account management') }}</h1>
+    <h1 id="admin-title" class="title">{{ t('User_Management') }} / {{ t('external_account_management') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -92,7 +92,7 @@
               </span>
               {% endif %}
             </td>
-            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ account.createdAt|datetz('Y-m-d') }}</td>
             <td>
               <div class="btn-group admin-user-menu">
 

+ 12 - 4
src/server/views/admin/security.html

@@ -41,13 +41,21 @@
         <legend class="alert-anchor">{{ t('security_settings') }}</legend>
 
           <div class="form-group">
-            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">{{ t('Guest users access') }}</label>
+            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">{{ t('security_setting.Guest Users Access') }}</label>
             <div class="col-xs-6">
-              <select class="form-control selectpicker" name="settingForm[security:restrictGuestMode]" value="{{ getConfig('crowi', 'security:restrictGuestMode') }}">
+              {% set selectedValue = guestModeValue %}
+              <select class="form-control selectpicker" {% if isWikiModeForced %}disabled{% endif %}
+                  name="settingForm[security:restrictGuestMode]" value="{{ getConfig('crowi', 'security:restrictGuestMode') }}">
                 {% for modeValue, modeLabel in consts.restrictGuestMode %}
-                <option value="{{ t(modeValue) }}" {% if modeValue == getConfig('crowi', 'security:restrictGuestMode') %}selected{% endif %} >{{ t(modeLabel) }}</option>
+                  <option value="{{ t(modeValue) }}" {% if modeValue == selectedValue %}selected{% endif %}>{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
+              {% if isWikiModeForced %}
+              <p class="alert alert-warning mt-2">
+                <i class="icon-exclamation icon-fw"></i><b>FIXED</b><br>
+                {{ t('security_setting.Fixed by env var', 'FORCE_WIKI_MODE', getConfig('crowi', 'security:wikiMode')) }}
+              </p>
+              {% endif %}
             </div>
           </div>
 
@@ -59,7 +67,7 @@
                 <option value="{{ t(modeValue) }}" {% if modeValue == getConfig('crowi', 'security:registrationMode') %}selected{% endif %} >{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
-              <p class="help-block small">{{ t('The contents entered here will be shown in the header etc') }}</p>
+              <p class="help-block small">{{ t('security_setting.Register limitation desc') }}</p>
             </div>
           </div>
 

+ 4 - 4
src/server/views/admin/user-group-detail.html

@@ -101,7 +101,7 @@
             <div class="form-group">
               <label class="col-sm-2 control-label">{{ t('Created') }}</label>
               <div class="col-sm-4">
-                <input class="form-control" type="text" disabled value="{{userGroup.createdAt|date('Y-m-d', sRelation.relatedUser.createdAt.getTimezoneOffset()) }}">
+                <input class="form-control" type="text" disabled value="{{userGroup.createdAt|datetz('Y-m-d') }}">
               </div>
             </div>
             <div class="form-group">
@@ -125,7 +125,7 @@
             </th>
             <th>{{ t('Name') }}</th>
             <th width="100px">{{ t('Created') }}</th>
-            <th width="150px">{{ t('Last Login')}}</th>
+            <th width="160px">{{ t('Last Login')}}</th>
             <th width="70px"></th>
           </tr>
         </thead>
@@ -140,9 +140,9 @@
               <strong>{{ sRelation.relatedUser.username }}</strong>
             </td>
             <td>{{ sRelation.relatedUser.name }}</td>
-            <td>{{ sRelation.relatedUser.createdAt|date('Y-m-d', sRelation.relatedUser.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ sRelation.relatedUser.createdAt|datetz('Y-m-d') }}</td>
             <td>
-              {% if sRelation.relatedUser.lastLoginAt %} {{ sRelation.relatedUser.lastLoginAt|date('Y-m-d H:i', sRelation.relatedUser.createdAt.getTimezoneOffset()) }} {% endif %}
+              {% if sRelation.relatedUser.lastLoginAt %} {{ sRelation.relatedUser.lastLoginAt|datetz('Y-m-d H:i:s') }} {% endif %}
             </td>
             <td>
               <div class="btn-group admin-user-menu">

+ 6 - 3
src/server/views/admin/user-groups.html

@@ -36,7 +36,10 @@
         {% if isAclEnabled %}
           <button  data-toggle="collapse" class="btn btn-default" href="#createGroupForm">{{ t('user_group_management.create_group') }}</button>
         {% else %}
-          {{ t('user_group_management.deny_create_group')}}
+          <p class="alert alert-warning">
+            <i class="icon-exclamation icon-fw"></i><b>{{ t('user_group_management.deny_create_group')}}</b><br>
+            {{ t('security_setting.Fixed by env var', 'FORCE_WIKI_MODE', getConfig('crowi', 'security:wikiMode')) }}
+          </p>
         {% endif %}
       </p>
       <form role="form" action="/admin/user-group/create" method="post">
@@ -83,7 +86,7 @@
           <tr>
             <th>{{ t('Name') }}</th>
             <th>{{ t('User') }}</th>
-            <th width="100px">{{ t('Created') }}</th>
+            <th width="160px">{{ t('Created') }}</th>
             <th width="70px"></th>
           </tr>
         </thead>
@@ -101,7 +104,7 @@
               <li class="list-inline-item badge badge-primary">{{relation.relatedUser.username}}</li>
               {% endfor %}
             </ul></td>
-            <td>{{ sGroup.createdAt|date('Y-m-d', sGroup.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ sGroup.createdAt|datetz('Y-m-d H:i:s') }}</td>
             {% if isAclEnabled %}
             <td>
               <div class="btn-group admin-group-menu">

+ 3 - 3
src/server/views/admin/users.html

@@ -155,7 +155,7 @@
             <th>{{ t('Name') }}</th>
             <th>{{ t('Email') }}</th>
             <th width="100px">{{ t('Created') }}</th>
-            <th width="150px">{{ t('Last_Login') }}</th>
+            <th width="160px">{{ t('Last_Login') }}</th>
             <th width="70px"></th>
           </tr>
         </thead>
@@ -181,10 +181,10 @@
             </td>
             <td>{{ sUser.name }}</td>
             <td>{{ sUser.email }}</td>
-            <td>{{ sUser.createdAt|date('Y-m-d', sUser.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ sUser.createdAt|datetz('Y-m-d') }}</td>
             <td>
               {% if sUser.lastLoginAt %}
-                {{ sUser.lastLoginAt|date('Y-m-d H:i', sUser.createdAt.getTimezoneOffset()) }}
+                {{ sUser.lastLoginAt|datetz('Y-m-d H:i:s') }}
               {% endif %}
             </td>
             <td>

+ 1 - 1
src/server/views/me/external-accounts.html

@@ -86,7 +86,7 @@
             <td>
               <strong>{{ account.accountId }}</strong>
             </td>
-            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ account.createdAt|datetz('Y-m-d') }}</td>
             <td class="text-center">
               <button class="btn btn-default btn-sm btn-danger"
                   data-toggle="modal" data-target="#diassociate-external-account" data-provider-type="{{ account.providerType }}" data-account-id="{{ account.accountId }}">

+ 13 - 11
src/server/views/modal/delete.html

@@ -4,7 +4,7 @@
 
       <form role="form" id="delete-page-form" onsubmit="return false;">
 
-        <div class="modal-header {% if page.isDeleted() %}bg-danger{% endif %}">
+        <div class="modal-header {% if page.isDeleted() %}bg-danger{% else %}bg-primary{% endif %}">
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
           <div class="modal-title">
             {% if page.isDeleted() %}
@@ -25,19 +25,21 @@
           {% if page.grant != 2 %}
           <div class="checkbox checkbox-warning">
             <input name="recursively" id="cbDeleteRecursively" value="1" type="checkbox" checked>
-            <label for="cbDeleteRecursively">{{ t('modal_delete.delete_recursively') }}</label>
-            <p class="help-block"> {{ t('modal_delete.recursively', page.path) }}
-            </p>
+            <label for="cbDeleteRecursively">
+              {{ t('modal_delete.delete_recursively') }}
+              <p class="help-block mt-0"> {{ t('modal_delete.recursively', page.path) }}</p>
+            </label>
           </div>
           {% endif %}
           {% if not page.isDeleted() %}
           <div class="checkbox checkbox-danger">
-          <input name="completely" id="cbDeleteCompletely" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} value="1"  type="checkbox">
-            <label for="cbDeleteCompletely" class="text-danger">{{ t('modal_delete.delete_completely') }}</label>
+            <input name="completely" id="cbDeleteCompletely" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} value="1"  type="checkbox">
+            <label for="cbDeleteCompletely" class="text-danger">
+              {{ t('modal_delete.delete_completely') }}
+              <p class="help-block mt-0"> {{ t('modal_delete.completely') }}</p>
+            </label>
             {% if !user.canDeleteCompletely(page.creator._id) %}
-              <p class="bg-danger text-white p-2 mt-2"> <i class="icon-ban" ></i>{{ t('modal_delete.delete_completely_restriction') }}</p>
-            {% else %}
-            <p class="help-block"> {{ t('modal_delete.completely') }}</p>
+              <p class="alert alert-warning p-2 my-0"><i class="icon-ban icon-fw" ></i>{{ t('modal_delete.delete_completely_restriction') }}</p>
             {% endif %}
           </div>
           {% endif %}
@@ -52,12 +54,12 @@
               <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
               {% if page.isDeleted() %}
                 <input type="hidden" name="completely" value="true">
-                <button type="submit" class="m-l-10 btn btn-sm btn-danger delete-button">
+                <button type="submit" class="m-l-10 btn btn-danger delete-button">
                   <i class="icon-fire" aria-hidden="true"></i>
                   {{ t('delete_completely') }}
                 </button>
               {% else %}
-                <button type="submit" class="m-l-10 btn btn-sm btn-default delete-button">
+                <button type="submit" class="m-l-10 btn btn-primary delete-button">
                   <i class="icon-trash" aria-hidden="true"></i>
                   {{ t('Delete') }}
                 </button>

+ 21 - 9
src/server/views/modal/rename.html

@@ -6,7 +6,7 @@
 
         <div class="modal-header bg-primary">
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-          <div class="modal-title">{{ t('modal_rename.label.Rename page') }}</div>
+          <div class="modal-title">{{ t('modal_rename.label.Move/Rename page') }}</div>
         </div>
         <div class="modal-body">
           <div class="form-group">
@@ -29,16 +29,28 @@
 
           <div class="checkbox checkbox-warning">
             <input name="recursively" id="cbRenameRecursively" value="1" type="checkbox" checked>
-            <label for="cbRenameRecursively">{{ t('modal_rename.label.Move recursively') }}</label>
-            <p class="help-block"> {{ t('modal_rename.help.recursive', page.path) }}
-            </p>
+            <label for="cbRenameRecursively">
+              {{ t('modal_rename.label.Recursively') }}
+              <p class="help-block mt-0">{{ t('modal_rename.help.recursive', page.path) }}</p>
+            </label>
           </div>
-          <div class="checkbox checkbox-info">
-            <input name="create_redirect" id="cbRenameRedirect" value="1"  type="checkbox">
-              <label for="cbRenameRedirect">{{ t('modal_rename.label.Redirect') }}</label>
-              <p class="help-block"> {{ t('modal_rename.help.redirect', page.path) }}
-              </p>
+
+          <div class="checkbox checkbox-success">
+            <input name="create_redirect" id="cbRenameRedirect" value="1" type="checkbox">
+            <label for="cbRenameRedirect">
+              {{ t('modal_rename.label.Redirect') }}
+              <p class="help-block mt-0">{{ t('modal_rename.help.redirect', page.path) }}</p>
+            </label>
+          </div>
+
+          <div class="checkbox checkbox-inverse">
+            <input name="remain_metadata" id="cbRenameMetadata" value="1" type="checkbox">
+            <label for="cbRenameMetadata">
+              {{ t('modal_rename.label.Do not update metadata') }}
+              <p class="help-block mt-0">{{ t('modal_rename.help.metadata') }}</p>
+            </label>
           </div>
+
         </div>
         <div class="modal-footer">
           <div class="d-flex justify-content-between">

+ 1 - 1
src/server/views/widget/page_tabs.html

@@ -51,7 +51,7 @@
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
-        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move') }}</a></li>
+        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move/Rename') }}</a></li>
         <li><a href="#" data-target="#duplicatePage" data-toggle="modal"><i class="icon-fw icon-docs"></i> {{ t('Duplicate') }}</a></li>
         <li class="divider"></li>
         <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>

+ 1 - 1
src/server/views/widget/page_tabs_kibela.html

@@ -48,7 +48,7 @@
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
-        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move') }}</a></li>
+        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move/Rename') }}</a></li>
         <li><a href="#" data-target="#duplicatePage" data-toggle="modal"><i class="icon-fw icon-docs"></i> {{ t('Duplicate') }}</a></li>
         <li class="divider"></li>
         <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>

+ 0 - 8
src/test/models/page.test.js

@@ -23,14 +23,6 @@ describe('Page', () => {
     UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
 
-    // remove all
-    await Promise.all([
-      Page.remove({}),
-      User.remove({}),
-      UserGroup.remove({}),
-      UserGroupRelation.remove({}),
-    ]);
-
     await User.insertMany([
       { name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com' },
       { name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com' },

+ 22 - 20
src/test/models/user.test.js

@@ -10,40 +10,42 @@ describe('User', () => {
 
   beforeAll(async(done) => {
     crowi = await getInstance();
-    done();
-  });
-
-  beforeEach(async(done) => {
     User = mongoose.model('User');
+
+    await User.create({
+      name: 'Example for User Test',
+      username: 'usertest',
+      email: 'usertest@example.com',
+      password: 'usertestpass',
+      lang: 'en',
+    });
+
     done();
   });
 
   describe('Create and Find.', () => {
     describe('The user', () => {
-      test('should created', (done) => {
-        User.createUserByEmailAndPassword('Aoi Miyazaki', 'aoi', 'aoi@example.com', 'hogefuga11', 'en', (err, userData) => {
+      test('should created with createUserByEmailAndPassword', (done) => {
+        User.createUserByEmailAndPassword('Example2 for User Test', 'usertest2', 'usertest2@example.com', 'usertest2pass', 'en', (err, userData) => {
           expect(err).toBeNull();
           expect(userData).toBeInstanceOf(User);
+          expect(userData.name).toBe('Example2 for User Test');
           done();
         });
       });
 
-      test('should be found by findUserByUsername', (done) => {
-        User.findUserByUsername('aoi')
-          .then((userData) => {
-            expect(userData).toBeInstanceOf(User);
-            done();
-          });
+      test('should be found by findUserByUsername', async() => {
+        const user = await User.findUserByUsername('usertest');
+        expect(user).toBeInstanceOf(User);
+        expect(user.name).toBe('Example for User Test');
       });
 
-      test('should be found by findUsersByPartOfEmail', (done) => {
-        User.findUsersByPartOfEmail('ao', {})
-          .then((userData) => {
-            expect(userData).toBeInstanceOf(Array);
-            expect(userData[0]).toBeInstanceOf(User);
-            expect(userData[0].email).toEqual('aoi@example.com');
-            done();
-          });
+      test('should be found by findUsersByPartOfEmail', async() => {
+        const users = await User.findUsersByPartOfEmail('usert', {});
+        expect(users).toBeInstanceOf(Array);
+        expect(users.length).toBe(2);
+        expect(users[0]).toBeInstanceOf(User);
+        expect(users[1]).toBeInstanceOf(User);
       });
     });
   });

+ 4 - 4
yarn.lock

@@ -5137,10 +5137,10 @@ graceful-fs@^4.1.15:
   version "4.1.15"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
 
-growi-commons@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-4.0.1.tgz#e0e71c9c286f493e11c0703c809385bcdc6a97a9"
-  integrity sha512-haH4Av1WuQIHic4Jv2RRwDprbKecRKF/3C0wVk9ssBzWtB3V6Oghj5gksajDpYOd7tOKdvkVEqqkFfIV4JQUyQ==
+growi-commons@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-4.0.3.tgz#aa8cec9a45854ff5a66d28bdf3b232adc64e0270"
+  integrity sha512-ktf6wdAOykVkrGCMWBArP+jHjZTg8iDFrnPGNNVoCxm1fnWfRVXBNu7a8mFIvB2wQScSvnoHs2RFBKN/GcJJoA==
 
 growly@^1.3.0:
   version "1.3.0"