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

Merge branch 'master' into imprv/saml-uses-only-env-vars-option

utsushiiro 7 лет назад
Родитель
Сommit
03d22b739b

+ 27 - 1
CHANGES.md

@@ -1,7 +1,31 @@
 CHANGES
 ========
 
-## 3.3.0-RC
+## 3.3.4-RC
+
+* 
+
+## 3.3.3
+
+* Feature: Show line numbers to a code block
+* Feature: Bulk update the scope of descendant pages when create/update page
+* Improvement: The scope of ascendant page will be retrieved and set to controls in advance when creating a new page
+* Fix: Pages that is restricted by groups couldn't be shown in search result page
+* Fix: Pages order in search result page was wrong
+* Fix: Guest user can't search
+* Fix: Possibility that ExternalAccount deletion processing selects incorrect data
+* Support: Upgrade libs
+    * bootstrap-sass
+    * i18next
+    * migrate-mongo
+    * string-width
+
+## 3.3.2
+
+* Fix: Specified Group ACL is not persisted correctly
+    * Introduced 3.3.0
+
+## 3.3.1
 
 * Feature: NO_CDN Mode
 * Feature: Add option to show/hide restricted pages in list
@@ -16,6 +40,8 @@ CHANGES
     * googleapis
     * passport-saml
 
+## 3.3.0 (Missing number)
+
 ## 3.2.10
 
 * Fix: Pages in trash are available to create

+ 7 - 7
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.3.0-RC",
+  "version": "3.3.4-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -87,13 +87,13 @@
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
-    "i18next": "^12.0.0",
+    "i18next": "=12.1.0",
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^4.0.0",
+    "migrate-mongo": "^5.0.1",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "mongoose": "^5.3.1",
@@ -114,9 +114,10 @@
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
-    "string-width": "^2.1.1",
+    "string-width": "^3.0.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
+    "url-join": "^4.0.0",
     "xss": "^1.0.3"
   },
   "devDependencies": {
@@ -130,7 +131,7 @@
     "babel-polyfill": "^6.26.0",
     "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
-    "bootstrap-sass": "~3.3.6",
+    "bootstrap-sass": "~3.4.0",
     "bootstrap-select": "^1.12.4",
     "browser-bunyan": "^1.3.0",
     "browser-sync": "^2.23.6",
@@ -187,7 +188,7 @@
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.1.0",
     "react-dom": "^16.4.1",
-    "react-dropzone": "^7.0.1",
+    "react-dropzone": "=7.0.1",
     "react-frame-component": "^4.0.0",
     "react-i18next": "=7.13.0",
     "react-waypoint": "^8.1.0",
@@ -203,7 +204,6 @@
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "uglifyjs-webpack-plugin": "^2.0.1",
-    "url-join": "^4.0.0",
     "webpack": "^4.12.0",
     "webpack-assets-manifest": "^3.0.1",
     "webpack-bundle-analyzer": "^3.0.2",

+ 2 - 1
resource/cdn-manifests.js

@@ -26,7 +26,8 @@ module.exports = {
 'gh/highlightjs/cdn-release@9.12.0/build/languages/less.min.js,' +
 'gh/highlightjs/cdn-release@9.12.0/build/languages/scss.min.js,' +
 'gh/highlightjs/cdn-release@9.12.0/build/languages/typescript.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js',
+'gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js,' +
+'npm/highlightjs-line-numbers.js@2.6.0/dist/highlightjs-line-numbers.min.js',
       args: {
         async: true,
         integrity: '',

+ 5 - 3
resource/locales/en-US/translation.json

@@ -169,9 +169,11 @@
   },
 
   "page_edit": {
-      "notice": {
-          "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
-      }
+    "Show active line": "Show active line",
+    "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
+    "notice": {
+      "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
+    }
   },
 
   "page_api_error": {

+ 5 - 3
resource/locales/ja/translation.json

@@ -185,9 +185,11 @@
   },
 
   "page_edit": {
-      "notice": {
-          "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
-      }
+    "Show active line": "アクティブ行をハイライト",
+    "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
+    "notice": {
+      "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
+    }
   },
 
   "page_api_error": {

+ 8 - 4
src/client/js/app.js

@@ -213,7 +213,7 @@ const saveWithSubmitButtonSuccessHandler = function() {
   location.href = pagePath;
 };
 
-const saveWithSubmitButton = function() {
+const saveWithSubmitButton = function(submitOpts) {
   const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
   if (editorMode == null) {
     // do nothing
@@ -225,6 +225,9 @@ const saveWithSubmitButton = function() {
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   options.socketClientId = socketClientId;
 
+  // set 'submitOpts.overwriteScopesOfDescendants' to options
+  options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
+
   let promise = undefined;
   if (editorMode === 'hackmd') {
     // get markdown
@@ -408,7 +411,8 @@ if (writeCommentElem) {
 const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
 if (pageEditorOptionsSelectorElem) {
   ReactDOM.render(
-    <OptionsSelector crowi={crowi}
+    <I18nextProvider i18n={i18n}>
+      <OptionsSelector crowi={crowi}
         editorOptions={editorOptions} previewOptions={previewOptions}
         onChange={(newEditorOptions, newPreviewOptions) => { // set onChange event handler
           // set options
@@ -417,8 +421,8 @@ if (pageEditorOptionsSelectorElem) {
           // save
           crowi.saveEditorOptions(newEditorOptions);
           crowi.savePreviewOptions(newPreviewOptions);
-        }}
-      />,
+        }} />
+    </I18nextProvider>,
     pageEditorOptionsSelectorElem
   );
 }

+ 7 - 2
src/client/js/components/PageEditor/OptionsSelector.js

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormControl from 'react-bootstrap/es/FormControl';
@@ -8,7 +9,7 @@ import ControlLabel from 'react-bootstrap/es/ControlLabel';
 import Dropdown from 'react-bootstrap/es/Dropdown';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
-export default class OptionsSelector extends React.Component {
+class OptionsSelector extends React.Component {
 
   constructor(props) {
     super(props);
@@ -184,6 +185,7 @@ export default class OptionsSelector extends React.Component {
   }
 
   renderActiveLineMenuItem() {
+    const { t } = this.props;
     const isActive = this.state.editorOptions.styleActiveLine;
 
     const iconClasses = ['text-info'];
@@ -195,7 +197,7 @@ export default class OptionsSelector extends React.Component {
     return (
       <MenuItem onClick={this.onClickStyleActiveLine}>
         <span className="icon-container"></span>
-        <span className="menuitem-label">Show active line</span>
+        <span className="menuitem-label">{ t('page_edit.Show active line') }</span>
         <span className="icon-container"><i className={iconClassName}></i></span>
       </MenuItem>
     );
@@ -252,8 +254,11 @@ export class PreviewOptions {
 }
 
 OptionsSelector.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
   editorOptions: PropTypes.instanceOf(EditorOptions).isRequired,
   previewOptions: PropTypes.instanceOf(PreviewOptions).isRequired,
   onChange: PropTypes.func.isRequired,
 };
+
+export default translate()(OptionsSelector);

+ 18 - 3
src/client/js/components/SavePageControls.jsx

@@ -2,6 +2,10 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { translate } from 'react-i18next';
 
+import ButtonToolbar from 'react-bootstrap/es/ButtonToolbar';
+import SplitButton  from 'react-bootstrap/es/SplitButton';
+import MenuItem from 'react-bootstrap/es/MenuItem';
+
 import SlackNotification from './SlackNotification';
 import GrantSelector from './SavePageControls/GrantSelector';
 
@@ -16,6 +20,7 @@ class SavePageControls extends React.PureComponent {
 
     this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
     this.submit = this.submit.bind(this);
+    this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
   }
 
   componentWillMount() {
@@ -39,12 +44,17 @@ class SavePageControls extends React.PureComponent {
     this.props.onSubmit();
   }
 
+  submitAndOverwriteScopesOfDescendants() {
+    this.props.onSubmit({ overwriteScopesOfDescendants: true });
+  }
+
   render() {
     const { t } = this.props;
 
     const config = this.props.crowi.getConfig();
     const isAclEnabled = config.isAclEnabled;
-    const label = this.state.pageId == null ? t('Create') : t('Update');
+    const labelSubmitButton = this.state.pageId == null ? t('Create') : t('Update');
+    const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
       <div className="d-flex align-items-center form-inline">
@@ -58,7 +68,6 @@ class SavePageControls extends React.PureComponent {
               slackChannels={this.props.slackChannels} />
         </div>
 
-
         {isAclEnabled &&
           <div className="mr-2">
             <GrantSelector crowi={this.props.crowi}
@@ -73,7 +82,13 @@ class SavePageControls extends React.PureComponent {
           </div>
         }
 
-        <button className="btn btn-primary btn-submit" onClick={this.submit}>{label}</button>
+        <ButtonToolbar>
+          <SplitButton id="spl-btn-submit" bsStyle="primary" className="btn-submit" dropup pullRight onClick={this.submit}
+              title={labelSubmitButton}>
+            <MenuItem eventKey="1" onClick={this.submitAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
+            {/* <MenuItem divider /> */}
+          </SplitButton>
+        </ButtonToolbar>
       </div>
     );
   }

+ 10 - 5
src/client/js/util/GrowiRenderer.js

@@ -167,14 +167,19 @@ export default class GrowiRenderer {
     const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
 
     if (langExt) {
-      const langAndFn = langExt.split(':');
-      const lang = langAndFn[0];
-      const langFn = langAndFn[1] || null;
+      // https://regex101.com/r/qGs7eZ/1
+      const match = langExt.match(/^([^:=\n]+)(=([^:=\n]*))?(:([^:=\n]+))?(=([^:=\n]*))?$/);
+
+      const lang = match[1];
+      const fileName = match[5] || null;
+      const showLinenumbers = (match[2] != null) || (match[6] != null);
+
+      const citeTag = (fileName) ? `<cite>${fileName}</cite>` : '';
 
-      const citeTag = (langFn) ? `<cite>${langFn}</cite>` : '';
       if (hljs.getLanguage(lang)) {
         try {
-          return `<pre class="hljs ${noborder}">${citeTag}<code class="language-${lang}">${hljs.highlight(lang, code, true).value}</code></pre>`;
+          const highlightCode = showLinenumbers ? hljs.lineNumbersValue(hljs.highlight(lang, code, true).value) : hljs.highlight(lang, code, true).value;
+          return `<pre class="hljs ${noborder}">${citeTag}<code class="language-${lang}">${highlightCode}</code></pre>`;
         }
         catch (__) {
           return `<pre class="hljs ${noborder}">${citeTag}<code class="language-${lang}">${code}}</code></pre>`;

+ 15 - 1
src/client/styles/scss/_wiki.scss

@@ -122,7 +122,21 @@ div.body {
       font-weight: bold;
       opacity: 0.6;
     }
-  };
+  }
+
+  // styles for highlightjs-line-numbers
+  .hljs-ln td.hljs-ln-numbers {
+    user-select: none;
+
+    text-align: center;
+    color: #ccc;
+    border-right: 1px solid #CCC;
+    vertical-align: top;
+    padding-right: 5px;
+  }
+  .hljs-ln td.hljs-ln-code {
+    padding-left: 10px;
+  }
 
   p code {  // only inline code blocks
     font-family: $font-family-monospace-not-strictly;

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

@@ -53,7 +53,7 @@ module.exports = function(crowi) {
       'app:confidential'  : '',
 
       'app:fileUpload'    : false,
-      'app:globalLang'    : 'en',
+      'app:globalLang'    : 'en-US',
 
       'security:restrictGuestMode'      : 'Deny',
 

+ 143 - 88
src/server/models/page.js

@@ -52,8 +52,8 @@ const pageSchema = new mongoose.Schema({
   pageIdOnHackmd: String,
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' },  // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean },                        // set true if revision and revisionHackmdSynced are same but HackMD document has modified
-  createdAt: { type: Date, default: Date.now },
-  updatedAt: Date
+  createdAt: { type: Date, default: Date.now() },
+  updatedAt: { type: Date, default: Date.now() },
 }, {
   toJSON: {getters: true},
   toObject: {getters: true}
@@ -168,20 +168,20 @@ class PageQueryBuilder {
     return this;
   }
 
-  addConditionToFilteringByViewer(user, userGroups) {
+  addConditionToFilteringByViewer(user, userGroups, showPagesRestrictedByOwner, showPagesRestrictedByGroup) {
     const grantConditions = [
       {grant: null},
       {grant: GRANT_PUBLIC},
     ];
 
-    if (user == null) {
+    if (showPagesRestrictedByOwner) {
       grantConditions.push(
         {grant: GRANT_RESTRICTED},
         {grant: GRANT_SPECIFIED},
         {grant: GRANT_OWNER},
       );
     }
-    else {
+    else if (user != null) {
       grantConditions.push(
         {grant: GRANT_RESTRICTED, grantedUsers: user._id},
         {grant: GRANT_SPECIFIED, grantedUsers: user._id},
@@ -189,12 +189,12 @@ class PageQueryBuilder {
       );
     }
 
-    if (userGroups == null) {
+    if (showPagesRestrictedByGroup) {
       grantConditions.push(
         {grant: GRANT_USER_GROUP},
       );
     }
-    else {
+    else if (userGroups != null && userGroups.length > 0) {
       grantConditions.push(
         {grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups }},
       );
@@ -409,6 +409,22 @@ module.exports = function(crowi) {
     return this.populate('revision').execPopulate();
   };
 
+  pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
+    this.grant = grant;
+
+    // reset
+    this.grantedUsers = [];
+    this.grantedGroup = null;
+
+    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP) {
+      this.grantedUsers.push(user._id);
+    }
+
+    if (grant === GRANT_USER_GROUP) {
+      this.grantedGroup = grantUserGroupId;
+    }
+  }
+
 
   pageSchema.statics.updateCommentCount = function(pageId) {
     validateCrowi();
@@ -542,19 +558,20 @@ module.exports = function(crowi) {
   /**
    * @param {string} id ObjectId
    * @param {User} user User instance
+   * @param {UserGroup[]} userGroups List of UserGroup instances
    */
-  pageSchema.statics.findByIdAndViewer = async function(id, user) {
+  pageSchema.statics.findByIdAndViewer = async function(id, user, userGroups) {
     const baseQuery = this.findOne({_id: id});
 
-    let userGroups = [];
-    if (user != null) {
+    let relatedUserGroups = userGroups;
+    if (user != null && relatedUserGroups == null) {
       validateCrowi();
       const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
     const queryBuilder = new PageQueryBuilder(baseQuery);
-    queryBuilder.addConditionToFilteringByViewer(user, userGroups);
+    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
 
     return await queryBuilder.query.exec();
   };
@@ -567,25 +584,61 @@ module.exports = function(crowi) {
     return this.findOne({path});
   };
 
-  pageSchema.statics.findByPathAndViewer = async function(path, user) {
+  /**
+   * @param {string} path Page path
+   * @param {User} user User instance
+   * @param {UserGroup[]} userGroups List of UserGroup instances
+   */
+  pageSchema.statics.findByPathAndViewer = async function(path, user, userGroups) {
     if (path == null) {
       throw new Error('path is required.');
     }
 
-    // const Page = this;
     const baseQuery = this.findOne({path});
-    const queryBuilder = new PageQueryBuilder(baseQuery);
 
-    if (user != null) {
+    let relatedUserGroups = userGroups;
+    if (user != null && relatedUserGroups == null) {
       validateCrowi();
       const UserGroupRelation = crowi.model('UserGroupRelation');
-      const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-      queryBuilder.addConditionToFilteringByViewer(user, userGroups);
+      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
+    const queryBuilder = new PageQueryBuilder(baseQuery);
+    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
+
     return await queryBuilder.query.exec();
   };
 
+  /**
+   * @param {string} path Page path
+   * @param {User} user User instance
+   * @param {UserGroup[]} userGroups List of UserGroup instances
+   */
+  pageSchema.statics.findAncestorByPathAndViewer = async function(path, user, userGroups) {
+    if (path == null) {
+      throw new Error('path is required.');
+    }
+
+    if (path === '/') {
+      return null;
+    }
+
+    const parentPath = nodePath.dirname(path);
+
+    let relatedUserGroups = userGroups;
+    if (user != null && relatedUserGroups == null) {
+      validateCrowi();
+      const UserGroupRelation = crowi.model('UserGroupRelation');
+      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const page = await this.findByPathAndViewer(parentPath, user, relatedUserGroups);
+
+    return (page != null)
+      ? page
+      : this.findAncestorByPathAndViewer(parentPath, user, relatedUserGroups);
+  };
+
   pageSchema.statics.findByRedirectTo = function(path) {
     return this.findOne({redirectTo: path});
   };
@@ -696,25 +749,22 @@ module.exports = function(crowi) {
 
     // determine User condition
     const hidePagesRestrictedByOwner = Config.hidePagesRestrictedByOwnerInList(config);
-    const userCondition = hidePagesRestrictedByOwner ? user : null;
+    const hidePagesRestrictedByGroup = Config.hidePagesRestrictedByGroupInList(config);
 
     // determine UserGroup condition
-    let groupCondition = null;
-    const hidePagesRestrictedByGroup = Config.hidePagesRestrictedByGroupInList(config);
-    if (hidePagesRestrictedByGroup && user != null) {
+    let userGroups = null;
+    if (user != null) {
       const UserGroupRelation = crowi.model('UserGroupRelation');
-      groupCondition = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    return builder.addConditionToFilteringByViewer(userCondition, groupCondition);
+    return builder.addConditionToFilteringByViewer(user, userGroups, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
   }
 
   /**
    * export addConditionToFilteringByViewerForList as static method
    */
-  pageSchema.statics.addConditionToFilteringByViewerForList = async function(builder, user) {
-    return addConditionToFilteringByViewerForList(builder, user);
-  };
+  pageSchema.statics.addConditionToFilteringByViewerForList = addConditionToFilteringByViewerForList;
 
   /**
    * Throw error for growi-lsx-plugin (v1.x)
@@ -829,20 +879,11 @@ module.exports = function(crowi) {
     return pageData.save();
   }
 
-  async function applyGrant(page, user, grant, grantUserGroupId) {
+  async function validateAppliedScope(user, grant, grantUserGroupId) {
     if (grant == GRANT_USER_GROUP && grantUserGroupId == null) {
       throw new Error('grant userGroupId is not specified');
     }
 
-    page.grant = grant;
-    if (grant == GRANT_PUBLIC || grant == GRANT_USER_GROUP) {
-      page.grantedUsers = [];
-    }
-    else {
-      page.grantedUsers = [];
-      page.grantedUsers.push(user._id);
-    }
-
     if (grant == GRANT_USER_GROUP) {
       const UserGroupRelation = crowi.model('UserGroupRelation');
       const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
@@ -853,81 +894,72 @@ module.exports = function(crowi) {
     }
   }
 
-  pageSchema.statics.create = function(path, body, user, options = {}) {
+  pageSchema.statics.create = async function(path, body, user, options = {}) {
     validateCrowi();
 
-    const Page = this
-      , Revision = crowi.model('Revision')
-      , format = options.format || 'markdown'
-      , redirectTo = options.redirectTo || null
-      , grantUserGroupId = options.grantUserGroupId || null
-      , socketClientId = options.socketClientId || null
-      ;
-
-    let grant = options.grant || GRANT_PUBLIC;
+    const Page = this;
+    const Revision = crowi.model('Revision');
+    const format = options.format || 'markdown';
+    const redirectTo = options.redirectTo || null;
+    const grantUserGroupId = options.grantUserGroupId || null;
+    const socketClientId = options.socketClientId || null;
 
     // sanitize path
     path = crowi.xss.process(path);
 
+    let grant = options.grant || GRANT_PUBLIC;
     // force public
     if (isPortalPath(path)) {
       grant = GRANT_PUBLIC;
     }
 
-    let savedPage = undefined;
-    return Page.findOne({path: path})
-      .then(pageData => {
-        if (pageData) {
-          throw new Error('Cannot create new page to existed path');
-        }
+    const isExist = await this.count({path: path});
 
-        const newPage = new Page();
-        newPage.path = path;
-        newPage.creator = user;
-        newPage.lastUpdateUser = user;
-        newPage.createdAt = Date.now();
-        newPage.updatedAt = Date.now();
-        newPage.redirectTo = redirectTo;
-        newPage.status = STATUS_PUBLISHED;
-        applyGrant(newPage, user, grant, grantUserGroupId);
-
-        return newPage.save();
-      })
-      .then((newPage) => {
-        savedPage = newPage;
-      })
-      .then(() => {
-        const newRevision = Revision.prepareRevision(savedPage, body, null, user, {format: format});
-        return pushRevision(savedPage, newRevision, user);
-      })
-      .then(() => {
-        if (socketClientId != null) {
-          pageEvent.emit('create', savedPage, user, socketClientId);
-        }
-        return savedPage;
-      });
+    if (isExist) {
+      throw new Error('Cannot create new page to existed path');
+    }
+
+    const page = new Page();
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.redirectTo = redirectTo;
+    page.status = STATUS_PUBLISHED;
+
+    await validateAppliedScope(user, grant, grantUserGroupId);
+    page.applyScope(user, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, {format: format});
+    const revision = await pushRevision(savedPage, newRevision, user, grant, grantUserGroupId);
+    savedPage = await this.findByPath(revision.path).populate('revision').populate('creator');
+
+    if (socketClientId != null) {
+      pageEvent.emit('create', savedPage, user, socketClientId);
+    }
+    return savedPage;
   };
 
   pageSchema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
     validateCrowi();
 
-    const Page = this
-      , Revision = crowi.model('Revision')
-      , grant = options.grant || null
-      , grantUserGroupId = options.grantUserGroupId || null
-      , isSyncRevisionToHackmd = options.isSyncRevisionToHackmd
-      , socketClientId = options.socketClientId || null
-      ;
+    const Revision = crowi.model('Revision');
+    const grant = options.grant || null;
+    const grantUserGroupId = options.grantUserGroupId || null;
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const socketClientId = options.socketClientId || null;
+
+    await validateAppliedScope(user, grant, grantUserGroupId);
+    pageData.applyScope(user, grant, grantUserGroupId);
 
     // update existing page
-    applyGrant(pageData, user, grant, grantUserGroupId);
     let savedPage = await pageData.save();
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
     const revision = await pushRevision(savedPage, newRevision, user, grant, grantUserGroupId);
-    savedPage = await Page.findByPath(revision.path).populate('revision').populate('creator');
+    savedPage = await this.findByPath(revision.path).populate('revision').populate('creator');
 
     if (isSyncRevisionToHackmd) {
-      savedPage = await Page.syncRevisionToHackmd(savedPage);
+      savedPage = await this.syncRevisionToHackmd(savedPage);
     }
 
     if (socketClientId != null) {
@@ -936,6 +968,29 @@ module.exports = function(crowi) {
     return savedPage;
   };
 
+  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
+    const builder = new PageQueryBuilder(this.find());
+    builder.addConditionToListWithDescendants(parentPage.path);
+
+    builder.addConditionToExcludeRedirect();
+
+    // add grant conditions
+    await addConditionToFilteringByViewerForList(builder, user);
+
+    // get all pages that the specified user can update
+    const pages = await builder.query.exec();
+
+    for (const page of pages) {
+      // skip parentPage
+      if (page.id === parentPage.id) {
+        continue;
+      }
+
+      page.applyScope(user, parentPage.grant, parentPage.grantedGroup);
+      page.save();
+    }
+  };
+
   pageSchema.statics.deletePage = async function(pageData, user, options = {}) {
     const newPath = this.getDeletedPageName(pageData.path)
       , isTrashed = checkIfTrashed(pageData.path)

+ 17 - 13
src/server/routes/admin.js

@@ -631,20 +631,24 @@ module.exports = function(crowi, app) {
       });
   };
 
-  actions.externalAccount.remove = function(req, res) {
-    const accountId = req.params.id;
+  actions.externalAccount.remove = async function(req, res) {
+    const id = req.params.id;
 
-    ExternalAccount.findOneAndRemove({accountId})
-      .then((result) => {
-        if (result == null) {
-          req.flash('errorMessage', '削除に失敗しました。');
-          return res.redirect('/admin/users/external-accounts');
-        }
-        else {
-          req.flash('successMessage', `外部アカウント '${accountId}' を削除しました`);
-          return res.redirect('/admin/users/external-accounts');
-        }
-      });
+    let account = null;
+
+    try {
+      account = await ExternalAccount.findByIdAndRemove(id);
+      if (account == null) {
+        throw new Error('削除に失敗しました。');
+      }
+    }
+    catch (err) {
+      req.flash('errorMessage', err.message);
+      return res.redirect('/admin/users/external-accounts');
+    }
+
+    req.flash('successMessage', `外部アカウント '${account.providerType}/${account.accountId}' を削除しました`);
+    return res.redirect('/admin/users/external-accounts');
   };
 
   actions.userGroup = {};

+ 45 - 49
src/server/routes/installer.js

@@ -1,15 +1,15 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:installer')
-    , path = require('path')
-    , fs = require('graceful-fs')
-    , models = crowi.models
-    , Config = models.Config
-    , User = models.User
-    , Page = models.Page
+  const logger = require('@alias/logger')('growi:routes:installer');
+  const path = require('path');
+  const fs = require('graceful-fs');
+  const models = crowi.models;
+  const Config = models.Config;
+  const User = models.User;
+  const Page = models.Page;
 
-    , actions = {};
+  const actions = {};
 
   function createInitialPages(owner, lang) {
     // create portal page for '/'
@@ -32,56 +32,52 @@ module.exports = function(crowi, app) {
     return res.render('installer');
   };
 
-  actions.createAdmin = function(req, res) {
-    var registerForm = req.body.registerForm || {};
+  actions.createAdmin = function(req, res, next) {
+    const registerForm = req.body.registerForm || {};
 
-    if (req.form.isValid) {
-      var name = registerForm.name;
-      var username = registerForm.username;
-      var email = registerForm.email;
-      var password = registerForm.password;
-      var language = registerForm['app:globalLang'] || (req.language || 'en-US');
-      // for config.globalLang setting.
-      var langForm = {};
-      langForm['app:globalLang'] = language;
+    if (!req.form.isValid) {
+      return res.render('installer');
+    }
 
-      User.createUserByEmailAndPassword(name, username, email, password, language, function(err, userData) {
-        if (err) {
-          req.form.errors.push('管理ユーザーの作成に失敗しました。' + err.message);
-          // TODO
-          return res.render('installer');
-        }
+    const name = registerForm.name;
+    const username = registerForm.username;
+    const email = registerForm.email;
+    const password = registerForm.password;
+    const language = registerForm['app:globalLang'] || 'en-US';
 
-        userData.makeAdmin(function(err, userData) {
-          Config.applicationInstall(function(err, configs) {
-            if (err) {
-              // TODO
-              return ;
-            }
+    User.createUserByEmailAndPassword(name, username, email, password, language, function(err, userData) {
+      if (err) {
+        req.form.errors.push('管理ユーザーの作成に失敗しました。' + err.message);
+        // TODO
+        return res.render('installer');
+      }
 
-            // login with passport
-            req.logIn(userData, (err) => {
-              if (err) { return next() }
-              else {
-                req.flash('successMessage', 'GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。');
-                return res.redirect('/admin/app');
-              }
-            });
+      userData.makeAdmin(function(err, userData) {
+        Config.applicationInstall(function(err, configs) {
+          if (err) {
+            logger.error(err);
+            return;
+          }
+
+          // save the globalLang config, and update the config cache
+          Config.updateNamespaceByArray('crowi', {'app:globalLang': language}, function(err, config) {
+            Config.updateConfigCache('crowi', config);
           });
 
-          // create initial pages
-          createInitialPages(userData, language);
+          // login with passport
+          req.logIn(userData, (err) => {
+            if (err) { return next() }
+            else {
+              req.flash('successMessage', 'GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。');
+              return res.redirect('/admin/app');
+            }
+          });
         });
 
-        // save config settings, and update config cache
-        Config.updateNamespaceByArray('crowi', langForm, function(err, config) {
-          Config.updateConfigCache('crowi', config);
-        });
+        // create initial pages
+        createInitialPages(userData, language);
       });
-    }
-    else {
-      return res.render('installer');
-    }
+    });
   };
 
   return actions;

+ 5 - 5
src/server/routes/login.js

@@ -338,11 +338,11 @@ module.exports = function(crowi, app) {
     }
 
     if (req.method == 'POST' && req.form.isValid) {
-      var user = req.user;
-      var invitedForm = req.form.invitedForm || {};
-      var username = invitedForm.username;
-      var name = invitedForm.name;
-      var password = invitedForm.password;
+      const user = req.user;
+      const invitedForm = req.form.invitedForm || {};
+      const username = invitedForm.username;
+      const name = invitedForm.name;
+      const password = invitedForm.password;
 
       // check user upper limit
       const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();

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

@@ -124,6 +124,12 @@ module.exports = function(crowi, app) {
     }
   }
 
+  function addRendarVarsForScope(renderVars, page) {
+    renderVars.grant = page.grant;
+    renderVars.grantedGroupId = page.grantedGroup ? page.grantedGroup.id : null;
+    renderVars.grantedGroupName = page.grantedGroup ? page.grantedGroup.name : null;
+  }
+
   async function addRenderVarsForSlack(renderVars, page) {
     renderVars.slack = await getSlackChannels(page);
   }
@@ -241,6 +247,7 @@ module.exports = function(crowi, app) {
     // populate
     page = await page.populateDataToShowRevision();
     addRendarVarsForPage(renderVars, page);
+    addRendarVarsForScope(renderVars, page);
 
     await addRenderVarsForSlack(renderVars, page);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit);
@@ -397,6 +404,13 @@ module.exports = function(crowi, app) {
         template = replacePlaceholdersOfTemplate(template, req);
         renderVars.template = template;
       }
+
+      // add scope variables by ancestor page
+      const ancestor = await Page.findAncestorByPathAndViewer(path, req.user);
+      if (ancestor != null) {
+        await ancestor.populate('grantedGroup').execPopulate();
+        addRendarVarsForScope(renderVars, ancestor);
+      }
     }
 
     const limit = 50;
@@ -516,6 +530,7 @@ module.exports = function(crowi, app) {
     const pagePath = req.body.path || null;
     const grant = req.body.grant || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
+    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const socketClientId = req.body.socketClientId || undefined;
@@ -530,7 +545,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
 
-    const options = {grant, grantUserGroupId, socketClientId};
+    const options = {grant, grantUserGroupId, overwriteScopesOfDescendants, socketClientId};
     const createdPage = await Page.create(pagePath, body, req.user, options);
 
     const result = { page: serializeToObj(createdPage) };
@@ -538,6 +553,11 @@ module.exports = function(crowi, app) {
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     res.json(ApiResponse.success(result));
 
+    // update scopes for descendants
+    if (overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
+    }
+
     // global notification
     try {
       await globalNotificationService.notifyPageCreate(createdPage);
@@ -572,6 +592,7 @@ module.exports = function(crowi, app) {
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
+    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled;                     // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd;     // cast to boolean
@@ -615,6 +636,11 @@ module.exports = function(crowi, app) {
     result.page.lastUpdateUser = User.filterToPublicFields(page.lastUpdateUser);
     res.json(ApiResponse.success(result));
 
+    // update scopes for descendants
+    if (overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(page, req.user);
+    }
+
     // global notification
     try {
       await globalNotificationService.notifyPageEdit(page);
@@ -684,6 +710,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   api.seen = async function(req, res) {
+    const user = req.user;
     const pageId = req.body.page_id;
     if (!pageId) {
       return res.json(ApiResponse.error('page_id required'));
@@ -694,9 +721,9 @@ module.exports = function(crowi, app) {
 
     let page;
     try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
-      if (req.user != null) {
-        page = await page.seen(req.user);
+      page = await Page.findByIdAndViewer(pageId, user);
+      if (user != null) {
+        page = await page.seen(user);
       }
     }
     catch (err) {

+ 11 - 0
src/server/routes/search.js

@@ -69,6 +69,13 @@ module.exports = function(crowi, app) {
         esResult = await search.searchKeyword(keyword, user, userGroups, searchOpts);
       }
 
+      // create score map for sorting
+      // key: id , value: score
+      const scoreMap = {};
+      for (const esPage of esResult.data) {
+        scoreMap[esPage._id] = esPage._score;
+      }
+
       const findResult = await Page.findListByPageIds(esResult.data);
 
       result.meta = esResult.meta;
@@ -77,6 +84,10 @@ module.exports = function(crowi, app) {
         .map(page => {
           page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
           return page;
+        })
+        .sort((page1, page2) => {
+          // note: this do not consider NaN
+          return scoreMap[page2._id] - scoreMap[page1._id];
         });
     }
     catch (err) {

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

@@ -32,7 +32,7 @@ exports.loginChecker = function(crowi, app) {
       });
     }
     else {
-      req.user = req.session.user = false;
+      req.user = req.session.user = null;
       res.locals.user = req.user;
       next();
     }
@@ -41,9 +41,6 @@ exports.loginChecker = function(crowi, app) {
 
 exports.loginCheckerForPassport = function(crowi, app) {
   return function(req, res, next) {
-    if (req.user == null) {
-      req.user = false;
-    }
     res.locals.user = req.user;
     next();
   };

+ 6 - 14
src/server/util/search.js

@@ -531,16 +531,8 @@ SearchClient.prototype.filterPagesByViewer = async function(query, user, userGro
   const Config = this.crowi.model('Config');
   const config = this.crowi.getConfig();
 
-  // determine User condition
-  const hidePagesRestrictedByOwner = Config.hidePagesRestrictedByOwnerInList(config);
-  user = hidePagesRestrictedByOwner ? user : null;
-
-  // determine UserGroup condition
-  const hidePagesRestrictedByGroup = Config.hidePagesRestrictedByGroupInList(config);
-  if (hidePagesRestrictedByGroup && user != null) {
-    const UserGroupRelation = this.crowi.model('UserGroupRelation');
-    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
+  const showPagesRestrictedByOwner = !Config.hidePagesRestrictedByOwnerInList(config);
+  const showPagesRestrictedByGroup = !Config.hidePagesRestrictedByGroupInList(config);
 
   query = this.initializeBoolQuery(query);
 
@@ -551,14 +543,14 @@ SearchClient.prototype.filterPagesByViewer = async function(query, user, userGro
     { term: { grant: GRANT_PUBLIC } },
   ];
 
-  if (user == null) {
+  if (showPagesRestrictedByOwner) {
     grantConditions.push(
       { term: { grant: GRANT_RESTRICTED } },
       { term: { grant: GRANT_SPECIFIED } },
       { term: { grant: GRANT_OWNER } },
     );
   }
-  else {
+  else if (user != null) {
     grantConditions.push(
       { bool: {
         must: [
@@ -581,12 +573,12 @@ SearchClient.prototype.filterPagesByViewer = async function(query, user, userGro
     );
   }
 
-  if (userGroups == null) {
+  if (showPagesRestrictedByGroup) {
     grantConditions.push(
       { term: { grant: GRANT_USER_GROUP } },
     );
   }
-  else if (userGroups.length > 0) {
+  else if (userGroups != null && userGroups.length > 0) {
     const userGroupIds = userGroups.map(group => group._id.toString() );
     grantConditions.push(
       { bool: {

+ 3 - 3
src/server/views/_form.html

@@ -16,9 +16,9 @@
   </div>
 
   <div id="save-page-controls"
-    data-grant="{{ page.grant }}"
-    data-grant-group="{{ page.grantedGroup._id.toString() }}"
-    data-grant-group-name="{{ page.grantedGroup.name }}">
+    data-grant="{{ grant }}"
+    data-grant-group="{{ grantedGroupId }}"
+    data-grant-group-name="{{ grantedGroupName }}">
   </div>
 
 </div>

+ 2 - 2
src/server/views/admin/app.html

@@ -63,7 +63,7 @@
           <label class="col-xs-3 control-label">{{ t('app_setting.Default Language for new users') }}</label>
           <div class="col-xs-6">
             <div class="radio radio-primary radio-inline">
-                <input type="radio" id="radioLangEn" name="settingForm[app:globalLang]" value="{{ consts.language.LANG_EN }}" {% if appGlobalLang() == consts.language.LANG_EN %}checked="checked"{% endif %}>
+                <input type="radio" id="radioLangEn" name="settingForm[app:globalLang]" value="{{ consts.language.LANG_EN_US }}" {% if appGlobalLang() == consts.language.LANG_EN_US %}checked="checked"{% endif %}>
                 <label for="radioLangEn">{{ t('English') }}</label>
             </div>
             <div class="radio radio-primary radio-inline">
@@ -248,7 +248,7 @@
           }
           var $message = $('<p class="alert"></p>');
           $message.addClass('alert-' + status);
-          $message.html(msg.replace('\n', '<br>'));
+          $message.html(msg.replace(/\n/g, '<br>'));
           $message.insertAfter('#' + formId + ' legend');
 
           if (status == 'success') {

+ 5 - 2
src/server/views/admin/customize.html

@@ -346,7 +346,8 @@
 
           <p class="help-block">
             Examples:
-            <pre class="hljs {% if !settingForm['customize:highlightJsStyleBorder'] %}hljs-no-border{% endif %}"><code class="highlightjs-demo">function $initHighlight(block, cls) {
+            <div class="wiki">
+              <pre class="hljs {% if !settingForm['customize:highlightJsStyleBorder'] %}hljs-no-border{% endif %}"><code class="highlightjs-demo">function $initHighlight(block, cls) {
   try {
     if (cls.search(/\bno\-highlight\b/) != -1)
       return process(block, true, 0x0F) +
@@ -361,6 +362,7 @@
 }
 
 export  $initHighlight;</code></pre>
+            </div>
           </p>
 
           <div class="form-group">
@@ -551,7 +553,7 @@ window.addEventListener('load', (event) => {
           }
           var $message = $('<p id="alert-results" class="alert"></p>');
           $message.addClass('alert-' + status);
-          $message.html(msg.replace('\n', '<br>'));
+          $message.html(msg.replace(/\n/g, '<br>'));
           $message.insertAfter('#' + formId + ' legend');
 
           if (status == 'success') {
@@ -592,6 +594,7 @@ window.addEventListener('load', (event) => {
      * highlight.js style switcher
      */
     hljs.initHighlightingOnLoad()
+    hljs.initLineNumbersOnLoad()
 
     function selectHighlightJsStyle(event) {
       var highlightJsCssDOM = $("#highlightJsCssContainer link")[0]

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

@@ -100,7 +100,7 @@
                 </button>
                 <ul class="dropdown-menu" role="menu">
                   <li class="dropdown-header">{{ t('user_management.Edit menu') }}</li>
-                  <form id="form_remove_{{ loop.index }}" action="/admin/users/external-accounts/{{ account.accountId }}/remove" method="post">
+                  <form id="form_remove_{{ loop.index }}" action="/admin/users/external-accounts/{{ account._id.toString() }}/remove" method="post">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                   </form>
                   <li>

+ 1 - 1
src/server/views/admin/importer.html

@@ -205,7 +205,7 @@
     }
     var $message = $('<p class="alert"></p>');
     $message.addClass('alert-' + status);
-    $message.html(msg.replace('\n', '<br>'));
+    $message.html(msg.replace(/\n/g, '<br>'));
     $message.insertAfter('#' + formId + ' legend');
 
     if (status == 'success') {

+ 12 - 12
src/server/views/admin/markdown.html

@@ -102,8 +102,8 @@
           </label>
 
           <div class="col-xs-3 radio radio-primary">
-            <input type="radio" id="option1" name="{{nameForPageBreakOption}}" value="1" {% if pageBreakSeparator === 1 %}checked{% endif %}>
-            <label for="option1">
+            <input type="radio" id="pageBreakOption1" name="{{nameForPageBreakOption}}" value="1" {% if pageBreakSeparator === 1 %}checked{% endif %}>
+            <label for="pageBreakOption1">
               <p class="font-weight-bold">{{ t('markdown_setting.Preset one separator') }}</p>
               <p class="mt-3">
                 {{ t('markdown_setting.Preset one separator desc') }}
@@ -113,8 +113,8 @@
           </div>
 
           <div class="col-xs-3 radio radio-primary">
-            <input type="radio" id="option2" name="{{nameForPageBreakOption}}" value="2" {% if pageBreakSeparator === 2 %}checked{% endif %}>
-            <label for="option2">
+            <input type="radio" id="pageBreakOption2" name="{{nameForPageBreakOption}}" value="2" {% if pageBreakSeparator === 2 %}checked{% endif %}>
+            <label for="pageBreakOption2">
               <p class="font-weight-bold">{{ t('markdown_setting.Preset two separator') }}</p>
               <p class="mt-3">
                 {{ t('markdown_setting.Preset two separator desc') }}
@@ -124,8 +124,8 @@
           </div>
 
           <div class="col-xs-3 radio radio-primary">
-            <input type="radio" id="option3" name="{{nameForPageBreakOption}}" value="3" {% if pageBreakSeparator === 3 %}checked{% endif %}>
-            <label for="option3">
+            <input type="radio" id="pageBreakOption3" name="{{nameForPageBreakOption}}" value="3" {% if pageBreakSeparator === 3 %}checked{% endif %}>
+            <label for="pageBreakOption3">
               <p class="font-weight-bold">{{ t('markdown_setting.Custom separator') }}</p>
               <p class="mt-3">
                 {{ t('markdown_setting.Custom separator desc') }}
@@ -178,8 +178,8 @@
           {% set xssOption = markdownSetting['markdown:xss:option'] %}
 
           <div class="col-xs-4 radio radio-primary">
-            <input type="radio" id="option1" name="{{nameForXssOption}}" value="1" {% if xssOption === 1 %}checked{% endif %}>
-            <label for="option1">
+            <input type="radio" id="xssOption1" name="{{nameForXssOption}}" value="1" {% if xssOption === 1 %}checked{% endif %}>
+            <label for="xssOption1">
               <p class="font-weight-bold">{{ t('markdown_setting.Ignore all tags') }}</p>
               <div class="m-t-15">
                   {{ t('markdown_setting.Ignore all tags desc') }}
@@ -188,8 +188,8 @@
           </div>
 
           <div class="col-xs-4 radio radio-primary">
-            <input type="radio" id="option2" name="{{nameForXssOption}}" value="2" {% if xssOption === 2 %}checked{% endif %}>
-            <label for="option2">
+            <input type="radio" id="xssOption2" name="{{nameForXssOption}}" value="2" {% if xssOption === 2 %}checked{% endif %}>
+            <label for="xssOption2">
               <p class="font-weight-bold">{{ t('markdown_setting.Recommended setting') }}</p>
               <div class="m-t-15">
                 {{ t('markdown_setting.Tag names') }}
@@ -203,8 +203,8 @@
           </div>
 
           <div class="col-xs-4 radio radio-primary">
-            <input type="radio" id="option3" name="{{nameForXssOption}}" value="3" {% if xssOption === 3 %}checked{% endif %}>
-            <label for="option3">
+            <input type="radio" id="xssOption3" name="{{nameForXssOption}}" value="3" {% if xssOption === 3 %}checked{% endif %}>
+            <label for="xssOption3">
               <p class="font-weight-bold">{{ t('markdown_setting.Custom Whitelist') }}</p>
               <div class="m-t-15">
                 <div class="d-flex justify-content-between">

+ 1 - 1
src/server/views/admin/search.html

@@ -84,7 +84,7 @@
     }
     var $message = $('<p class="alert"></p>');
     $message.addClass('alert-' + status);
-    $message.html(msg.replace('\n', '<br>'));
+    $message.html(msg.replace(/\n/g, '<br>'));
     $message.insertAfter('#' + formId + ' legend');
 
     if (status == 'success') {

+ 1 - 1
src/server/views/admin/security.html

@@ -354,7 +354,7 @@
           }
           var $message = $('<p class="alert"></p>');
           $message.addClass('alert-' + status);
-          $message.html(msg.replace('\n', '<br>'));
+          $message.html(msg.replace(/\n/g, '<br>'));
           $message.insertAfter('#' + formId + ' .alert-anchor');
 
           if (status == 'success') {

+ 1 - 1
src/server/views/admin/widget/passport/ldap.html

@@ -287,7 +287,7 @@
           }
           var $message = $('<p class="alert"></p>');
           $message.addClass('alert-' + status);
-          $message.html(msg.replace('\n', '<br>'));
+          $message.html(msg.replace(/\n/g, '<br>'));
           $message.insertAfter('#' + formId + ' legend');
 
           if (status == 'success') {

+ 1 - 1
src/server/views/widget/passport/ldap-association-tester.html

@@ -38,7 +38,7 @@
 
         var $message = $('<p class="alert"></p>');
         $message.addClass('alert-' + status);
-        $message.html(msg.replace('\n', '<br>'));
+        $message.html(msg.replace(/\n/g, '<br>'));
         $message.appendTo('#' + formId + '> .alert-container');
 
         if (status == 'success') {

+ 5 - 1
wercker.yml

@@ -161,7 +161,11 @@ release: # would be run on release branch
 
     - script:
       name: trigger growi-docker release pipeline
-      code: sh ./bin/wercker/trigger-growi-docker.sh
+      code: GROWI_DOCKER_PIPELINE_ID=$GROWI_DOCKER_PIPELINE_ID_CDN sh ./bin/wercker/trigger-growi-docker.sh
+
+    - script:
+      name: trigger growi-docker release-nocdn pipeline
+      code: GROWI_DOCKER_PIPELINE_ID=$GROWI_DOCKER_PIPELINE_ID_NOCDN sh ./bin/wercker/trigger-growi-docker.sh
 
   after-steps:
     - slack-notifier:

+ 68 - 21
yarn.lock

@@ -385,6 +385,10 @@ ansi-regex@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
 
+ansi-regex@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.0.0.tgz#70de791edf021404c3fd615aa89118ae0432e5a9"
+
 ansi-styles@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
@@ -1396,9 +1400,9 @@ boom@5.x.x:
   dependencies:
     hoek "4.x.x"
 
-bootstrap-sass@~3.3.6:
-  version "3.3.7"
-  resolved "https://registry.yarnpkg.com/bootstrap-sass/-/bootstrap-sass-3.3.7.tgz#6596c7ab40f6637393323ab0bc80d064fc630498"
+bootstrap-sass@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/bootstrap-sass/-/bootstrap-sass-3.4.0.tgz#b1c330a56782347f626d31d497fa4aea16b3f99b"
 
 bootstrap-select@^1.12.4:
   version "1.12.4"
@@ -2081,14 +2085,18 @@ commander@2.15.1, commander@^2.2.0:
   version "2.15.1"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f"
 
-commander@2.18.0, commander@^2.18.0:
-  version "2.18.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.18.0.tgz#2bf063ddee7c7891176981a2cc798e5754bc6970"
+commander@2.19.0:
+  version "2.19.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
 
 commander@^2.11.0, commander@^2.9.0:
   version "2.12.2"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
 
+commander@^2.18.0:
+  version "2.18.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.18.0.tgz#2bf063ddee7c7891176981a2cc798e5754bc6970"
+
 commander@~2.13.0:
   version "2.13.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c"
@@ -2565,7 +2573,11 @@ dasherize@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/dasherize/-/dasherize-2.0.0.tgz#6d809c9cd0cf7bb8952d80fc84fa13d47ddb1308"
 
-date-fns@1.29.0, date-fns@^1.29.0:
+date-fns@1.30.1:
+  version "1.30.1"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
+
+date-fns@^1.29.0:
   version "1.29.0"
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6"
 
@@ -2924,6 +2936,10 @@ emitter-steward@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/emitter-steward/-/emitter-steward-1.0.0.tgz#f3411ade9758a7565df848b2da0cbbd1b46cbd64"
 
+emoji-regex@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.1.tgz#5a132b28ebf84a289ba692862f7d4206ebcd32d0"
+
 emojis-list@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
@@ -3756,9 +3772,9 @@ fs-extra@3.0.1:
     jsonfile "^3.0.0"
     universalify "^0.1.0"
 
-fs-extra@7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.0.tgz#8cc3f47ce07ef7b3593a11b9fb245f7e34c041d6"
+fs-extra@7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
   dependencies:
     graceful-fs "^4.1.2"
     jsonfile "^4.0.0"
@@ -4410,9 +4426,9 @@ i18next-sprintf-postprocessor@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/i18next-sprintf-postprocessor/-/i18next-sprintf-postprocessor-0.2.2.tgz#2e409f1043579382698b6a2da70cdaa551d67ea4"
 
-i18next@^12.0.0:
-  version "12.0.0"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-12.0.0.tgz#27c1494219dde0451a8d714d5bfc19bf055d51bb"
+i18next@=12.1.0:
+  version "12.1.0"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-12.1.0.tgz#387bf4b94d05b0160b6a41d001a6b360e384bdb1"
 
 iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@~0.4.13:
   version "0.4.19"
@@ -5669,17 +5685,17 @@ micromatch@^3.1.4, micromatch@^3.1.8:
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
-migrate-mongo@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/migrate-mongo/-/migrate-mongo-4.0.0.tgz#7bb83c7b8e96253a15b67a298478a57f6e3b08d8"
+migrate-mongo@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/migrate-mongo/-/migrate-mongo-5.0.1.tgz#8829f64edb2df6ccef9d1048cf61759cb9fbfe5f"
   dependencies:
     cli-table "0.3.1"
-    commander "2.18.0"
-    date-fns "1.29.0"
+    commander "2.19.0"
+    date-fns "1.30.1"
     fn-args "3.0.0"
-    fs-extra "7.0.0"
+    fs-extra "7.0.1"
     lodash "4.17.11"
-    mongodb "3.1.6"
+    mongodb "3.1.10"
     p-each-series "1.0.0"
 
 miller-rabin@^4.0.0:
@@ -5871,6 +5887,23 @@ mongodb-core@3.1.5:
   optionalDependencies:
     saslprep "^1.0.0"
 
+mongodb-core@3.1.9:
+  version "3.1.9"
+  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.1.9.tgz#c31ee407bf932b0149eaed775c17ee09974e4ca3"
+  dependencies:
+    bson "^1.1.0"
+    require_optional "^1.0.1"
+    safe-buffer "^5.1.2"
+  optionalDependencies:
+    saslprep "^1.0.0"
+
+mongodb@3.1.10:
+  version "3.1.10"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.1.10.tgz#45ad9b74ea376f4122d0881b75e5489b9e504ed7"
+  dependencies:
+    mongodb-core "3.1.9"
+    safe-buffer "^5.1.2"
+
 mongodb@3.1.6:
   version "3.1.6"
   resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.1.6.tgz#6054641973b5bf5b5ae1c67dcbcf8fa88280273d"
@@ -7488,7 +7521,7 @@ react-dom@^16.4.1:
     object-assign "^4.1.1"
     prop-types "^15.6.0"
 
-react-dropzone@^7.0.1:
+react-dropzone@=7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-7.0.1.tgz#bc76bc1686fb47ed0c8301f968fffa6aecdff47b"
   dependencies:
@@ -8717,6 +8750,14 @@ string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
 
+string-width@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.0.0.tgz#5a1690a57cc78211fffd9bf24bbe24d090604eb1"
+  dependencies:
+    emoji-regex "^7.0.1"
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^5.0.0"
+
 string.prototype.matchall@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-2.0.0.tgz#2af8fe3d2d6dc53ca2a59bd376b089c3c152b3c8"
@@ -8771,6 +8812,12 @@ strip-ansi@^4.0.0:
   dependencies:
     ansi-regex "^3.0.0"
 
+strip-ansi@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.0.0.tgz#f78f68b5d0866c20b2c9b8c61b5298508dc8756f"
+  dependencies:
+    ansi-regex "^4.0.0"
+
 strip-bom@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"