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

Merge branch 'master' into imprv/refactor-acl

# Conflicts:
#	src/client/js/legacy/crowi.js
#	src/server/views/widget/page_tabs.html
Yuki Takei 7 лет назад
Родитель
Сommit
223c67c7e5
46 измененных файлов с 992 добавлено и 161 удалено
  1. 23 2
      CHANGES.md
  2. 5 1
      README.md
  3. 6 1
      bin/wercker/trigger-growi-docker.sh
  4. 1 1
      config/env.dev.js
  5. 2 0
      config/webpack.common.js
  6. 0 0
      config/webpack.dev.dll.js
  7. 6 6
      package.json
  8. 9 0
      resource/locales/en-US/translation.json
  9. 9 0
      resource/locales/ja/translation.json
  10. 6 6
      src/client/js/app.js
  11. 61 0
      src/client/js/components/InstallerForm.js
  12. 1 1
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  13. 0 1
      src/client/js/components/PageEditor/Editor.jsx
  14. 14 0
      src/client/js/components/PageEditor/EmojiAutoCompleteHelper.js
  15. 127 10
      src/client/js/components/PageEditor/HandsontableModal.jsx
  16. 25 0
      src/client/js/installer.js
  17. 7 0
      src/client/js/legacy/crowi.js
  18. 7 2
      src/client/js/models/MarkdownTable.js
  19. 41 0
      src/client/js/util/codemirror/update-display-util.ext.js
  20. 2 2
      src/client/styles/scss/_editor-attachment.scss
  21. 0 5
      src/client/styles/scss/_editor-overlay.scss
  22. 0 3
      src/client/styles/scss/_on-edit.scss
  23. 9 0
      src/server/crowi/index.js
  24. 1 0
      src/server/form/admin/securityPassportSaml.js
  25. 1 2
      src/server/models/attachment.js
  26. 22 2
      src/server/models/config.js
  27. 16 2
      src/server/models/external-account.js
  28. 6 6
      src/server/routes/admin.js
  29. 25 1
      src/server/routes/attachment.js
  30. 1 0
      src/server/routes/index.js
  31. 6 3
      src/server/routes/login-passport.js
  32. 200 0
      src/server/service/config-loader.js
  33. 125 0
      src/server/service/config-manager.js
  34. 127 24
      src/server/service/file-uploader/gridfs.js
  35. 8 1
      src/server/service/file-uploader/index.js
  36. 1 1
      src/server/util/middlewares.js
  37. 2 2
      src/server/views/admin/app.html
  38. 1 1
      src/server/views/admin/users.html
  39. 17 0
      src/server/views/admin/widget/passport/saml.html
  40. 14 50
      src/server/views/installer.html
  41. 1 1
      src/server/views/widget/create_portal.html
  42. 2 1
      src/server/views/widget/not_found_tabs.html
  43. 6 4
      src/server/views/widget/page_alerts.html
  44. 1 1
      src/server/views/widget/page_tabs_kibela.html
  45. 27 1
      wercker.yml
  46. 21 17
      yarn.lock

+ 23 - 2
CHANGES.md

@@ -1,9 +1,30 @@
 CHANGES
 ========
 
-## 3.2.8-RC
+## 3.2.10-RC
 
-* 
+* Fix: Pages in trash are available to create
+* Fix: Couldn't create portal page under Crowi Classic Behavior
+
+
+## 3.2.9
+
+* Feature: Attachment Storing to MongoDB GridFS
+* Fix: row/col moving of Spreadsheet like GUI (Handsontable) doesn't work
+* Fix: Emoji AutoComplete dialog pops up at wrong position
+* Support: Upgrade libs
+    * codemirror
+    * react-codemirror2
+
+## 3.2.8
+
+* Improvement: Add an option to use email for account link when using SAML federation
+* Fix: Editor layout is sometimes broken
+* Fix: Normalize table data for Spreadsheet like GUI (Handsontable) when import
+* Support: Improve development environment
+* Support: Upgrade libs
+    * googleapis
+    * react-dropzone
 
 ## 3.2.7
 

+ 5 - 1
README.md

@@ -163,7 +163,11 @@ Environment Variables
     * PASSWORD_SEED: A password seed used by password hash generator.
     * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
     * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
-    * FILE_UPLOAD: `aws` (default), `local`, `none`
+    * FILE_UPLOAD: Attached files storage. default: `aws`
+      * `aws` : AWS S3 (needs AWS settings on Admin page)
+      * `mongodb` : MongoDB GridFS (Setting-less)
+      * `local` : Server's Local file system (Setting-less)
+      * `none` : Disable file uploading
 * **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/management-cookbook/integrate-with-hackmd).**

+ 6 - 1
bin/wercker/trigger-growi-docker.sh

@@ -9,17 +9,22 @@
 #   - $WERCKER_TOKEN
 #   - $GROWI_DOCKER_PIPELINE_ID
 #   - $RELEASE_VERSION
+#   - $WERCKER_GIT_COMMIT
 #
 RESPONSE=`curl -X POST \
   -H "Content-Type: application/json" \
   -H "Authorization: Bearer $WERCKER_TOKEN" \
   https://app.wercker.com/api/v3/runs -d '{ \
     "pipelineId": "'$GROWI_DOCKER_PIPELINE_ID'", \
-    "branch": "release", \
+    "branch": "master", \
     "envVars": [ \
       { \
         "key": "RELEASE_VERSION", \
         "value": "'$RELEASE_VERSION'" \
+      }, \
+      { \
+        "key": "GROWI_REPOS_GIT_COMMIT", \
+        "value": "'$WERCKER_GIT_COMMIT'" \
       } \
     ] \
   }' \

+ 1 - 1
config/env.dev.js

@@ -1,6 +1,6 @@
 module.exports = {
   NODE_ENV: 'development',
-  FILE_UPLOAD: 'local',
+  FILE_UPLOAD: 'mongodb',
   // MATHJAX: 1,
   ELASTICSEARCH_URI: 'http://localhost:9200/growi',
   HACKMD_URI: 'http://localhost:3010',

+ 2 - 0
config/webpack.common.js

@@ -20,6 +20,7 @@ module.exports = (options) => {
     mode: options.mode,
     entry: Object.assign({
       'js/app':                       './src/client/js/app',
+      'js/installer':                 './src/client/js/installer',
       'js/legacy':                    './src/client/js/legacy/crowi',
       'js/legacy-admin':              './src/client/js/legacy/crowi-admin',
       'js/legacy-presentation':       './src/client/js/legacy/crowi-presentation',
@@ -76,6 +77,7 @@ module.exports = (options) => {
           exclude: {
             test:    helpers.root('node_modules'),
             exclude: [  // include as a result
+              helpers.root('node_modules/codemirror/src'),
               helpers.root('node_modules/string-width'),
               helpers.root('node_modules/is-fullwidth-code-point'), // depends from string-width
             ]

+ 0 - 0
config/webpack.dll.js → config/webpack.dev.dll.js


+ 6 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.2.8-RC",
+  "version": "3.2.10-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -24,7 +24,7 @@
     "build:dev:app:analyze": "cross-env ANALYZE=1 npm run build:dev:app:watch -- --profile",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app": "npm run clean:app && env-cmd config/env.dev.js webpack --config config/webpack.dev.js --progress",
-    "build:dev:dll": "webpack --config config/webpack.dll.js",
+    "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
     "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
     "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
@@ -85,7 +85,7 @@
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^34.0.0",
+    "googleapis": "^35.0.0",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
@@ -140,7 +140,7 @@
     "bunyan-debug": "^2.0.0",
     "chai": "^4.1.0",
     "cli": "~1.0.1",
-    "codemirror": "^5.37.0",
+    "codemirror": "^5.42.0",
     "colors": "^1.2.5",
     "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
@@ -188,9 +188,9 @@
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "^3.1.5",
     "react-clipboard.js": "^2.0.0",
-    "react-codemirror2": "^5.0.4",
+    "react-codemirror2": "^5.1.0",
     "react-dom": "^16.4.1",
-    "react-dropzone": "^6.0.2",
+    "react-dropzone": "^7.0.1",
     "react-frame-component": "^4.0.0",
     "react-i18next": "=7.13.0",
     "reveal.js": "^3.5.0",

+ 9 - 0
resource/locales/en-US/translation.json

@@ -101,6 +101,13 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
 
+  "installer": {
+    "setup": "Setup",
+    "create_initial_account": "Create an initial account",
+    "initial_account_will_be_administrator_automatically": "The initial account will be administrator automatically.",
+    "unavaliable_user_id": "This 'User ID' is unavailable."
+  },
+
   "page_register": {
     "notice": {
       "restricted": "Admin approval required.",
@@ -352,6 +359,8 @@
     "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
+    "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
+    "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
     "Use env var if empty": "Use env var <code>%s</code> if empty",
     "ldap": {
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",

+ 9 - 0
resource/locales/ja/translation.json

@@ -118,6 +118,13 @@
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
 
+  "installer": {
+    "setup": "セットアップ",
+    "create_initial_account": "最初のアカウントの作成",
+    "initial_account_will_be_administrator_automatically": "初めに作成するアカウントは、自動的に管理者権限が付与されます",
+    "unavaliable_user_id": "このユーザーIDは利用できません。"
+  },
+
   "page_register": {
     "notice": {
        "restricted": "この Wiki への新規登録は制限されています。",
@@ -368,6 +375,8 @@
     "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",

+ 6 - 6
src/client/js/app.js

@@ -68,7 +68,7 @@ let pageContent = '';
 let markdown = '';
 let slackChannels;
 if (mainContent !== null) {
-  pageId = mainContent.getAttribute('data-page-id');
+  pageId = mainContent.getAttribute('data-page-id') || null;
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
   pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
   pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
@@ -228,11 +228,7 @@ const saveWithSubmitButton = function() {
   options.socketClientId = socketClientId;
 
   let promise = undefined;
-  if (editorMode === 'builtin') {
-    // get markdown
-    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
-  }
-  else {
+  if (editorMode === 'hackmd') {
     // get markdown
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
     // use revisionId of PageEditorByHackmd
@@ -240,6 +236,10 @@ const saveWithSubmitButton = function() {
     // set option to sync
     options.isSyncRevisionToHackmd = true;
   }
+  else {
+    // get markdown
+    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
+  }
   // create or update
   if (pageId == null) {
     promise = promise.then(markdown => {

+ 61 - 0
src/client/js/components/InstallerForm.js

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+class InstallerForm extends React.Component {
+  render() {
+    return (
+      <form role="form" action="/installer/createAdmin" method="post" id="register-form">
+        <div className="input-group" id="input-group-username">
+          <span className="input-group-addon"><i className="icon-user"></i></span>
+          <input type="text" className="form-control" placeholder={ this.props.t('User ID') }
+            name="registerForm[username]" defaultValue={this.props.userName} required />
+        </div>
+        <p className="help-block">
+          <span id="help-block-username"></span>
+        </p>
+
+        <div className="input-group">
+          <span className="input-group-addon"><i className="icon-tag"></i></span>
+          <input type="text" className="form-control" placeholder={ this.props.t('Name') } name="registerForm[name]" defaultValue={ this.props.name } required />
+        </div>
+
+        <div className="input-group">
+          <span className="input-group-addon"><i className="icon-envelope"></i></span>
+          <input type="email" className="form-control" placeholder={ this.props.t('Email') } name="registerForm[email]" defaultValue={ this.props.email } required />
+        </div>
+
+        <div className="input-group">
+          <span className="input-group-addon"><i className="icon-lock"></i></span>
+          <input type="password" className="form-control" placeholder={ this.props.t('Password') } name="registerForm[password]" required />
+        </div>
+
+        <input type="hidden" name="_csrf" value={ this.props.csrf } />
+        <div className="input-group m-t-30 m-b-20 d-flex justify-content-center">
+          <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
+            <span className="btn-label"><i className="icon-user-follow"></i></span>
+            { this.props.t('Create') }
+          </button>
+        </div>
+
+        <div className="input-group m-t-30 d-flex justify-content-center">
+          <a href="https://growi.org" className="link-growi-org">
+            <span className="growi">GROWI</span>.<span className="org">ORG</span>
+          </a>
+        </div>
+      </form>
+    );
+  }
+}
+
+InstallerForm.propTypes = {
+  // i18next
+  t: PropTypes.func.isRequired,
+  // for input value
+  userName: PropTypes.string,
+  name: PropTypes.string,
+  email: PropTypes.string,
+  csrf: PropTypes.string,
+};
+
+export default translate()(InstallerForm);

+ 1 - 1
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -96,7 +96,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   init() {
-    this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.37.0';
+    this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0';
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([

+ 0 - 1
src/client/js/components/PageEditor/Editor.jsx

@@ -233,7 +233,6 @@ export default class Editor extends AbstractEditor {
         <Dropzone
             ref="dropzone"
             disableClick
-            disablePreview={true}
             accept={this.getDropzoneAccept()}
             className={this.getDropzoneClassName()}
             acceptClassName="dropzone-accepted"

+ 14 - 0
src/client/js/components/PageEditor/EmojiAutoCompleteHelper.js

@@ -1,3 +1,5 @@
+import UpdateDisplayUtil from '../../util/codemirror/update-display-util.ext';
+
 class EmojiAutoCompleteHelper {
 
   constructor(emojiStrategy) {
@@ -42,6 +44,18 @@ class EmojiAutoCompleteHelper {
       return;
     }
 
+    /*
+     * https://github.com/weseek/growi/issues/703 is caused
+     * because 'editor.display.viewOffset' is zero
+     *
+     * call stack:
+     *   1. https://github.com/codemirror/CodeMirror/blob/5.42.0/addon/hint/show-hint.js#L220
+     *   2. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/edit/methods.js#L189
+     *   3. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/measurement/position_measurement.js#L372
+     *   4. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/measurement/position_measurement.js#L315
+     */
+    UpdateDisplayUtil.forceUpdateViewOffset(editor);
+
     // see https://codemirror.net/doc/manual.html#addon_show-hint
     editor.showHint({
       completeSingle: false,

+ 127 - 10
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -21,10 +21,21 @@ const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
 
 export default class HandsontableModal extends React.PureComponent {
 
-
   constructor(props) {
     super(props);
 
+    /*
+     * ## Note ##
+     * Currently, this component try to synchronize the cells data and alignment data of state.markdownTable with these of the HotTable.
+     * However, changes made by the following operations are not synchronized.
+     *
+     * 1. move columns: Alignment changes are synchronized but data changes are not.
+     * 2. move rows: Data changes are not synchronized.
+     * 3. insert columns or rows: Data changes are synchronized but alignment changes are not.
+     * 4. delete columns or rows: Data changes are synchronized but alignment changes are not.
+     *
+     * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
+     */
     this.state = {
       show: false,
       isDataImportAreaExpanded: false,
@@ -39,10 +50,11 @@ export default class HandsontableModal extends React.PureComponent {
     this.cancel = this.cancel.bind(this);
     this.save = this.save.bind(this);
     this.afterLoadDataHandler = this.afterLoadDataHandler.bind(this);
-    this.beforeColumnMoveHandler = this.beforeColumnMoveHandler.bind(this);
     this.beforeColumnResizeHandler = this.beforeColumnResizeHandler.bind(this);
     this.afterColumnResizeHandler = this.afterColumnResizeHandler.bind(this);
     this.modifyColWidthHandler = this.modifyColWidthHandler.bind(this);
+    this.beforeColumnMoveHandler = this.beforeColumnMoveHandler.bind(this);
+    this.afterColumnMoveHandler = this.afterColumnMoveHandler.bind(this);
     this.synchronizeAlignment = this.synchronizeAlignment.bind(this);
     this.alignButtonHandler = this.alignButtonHandler.bind(this);
     this.toggleDataImportArea = this.toggleDataImportArea.bind(this);
@@ -120,6 +132,13 @@ export default class HandsontableModal extends React.PureComponent {
     });
   }
 
+  /**
+   * Reset table data to initial value
+   *
+   * ## Note ##
+   * It may not return completely to the initial state because of the manualColumnMove operations.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
   reset() {
     this.setState({ markdownTable: this.state.markdownTableOnInit.clone() });
   }
@@ -129,15 +148,39 @@ export default class HandsontableModal extends React.PureComponent {
   }
 
   save() {
+    const markdownTable = new MarkdownTable(
+      this.refs.hotTable.hotInstance.getData(),
+      {align: [].concat(this.state.markdownTable.options.align)}
+    ).normalizeCells();
+
     if (this.props.onSave != null) {
-      this.props.onSave(this.state.markdownTable.clone().normalizeCells());
+      this.props.onSave(markdownTable);
     }
 
     this.hide();
   }
 
+  /**
+   * An afterLoadData hook
+   *
+   * This performs the following operations.
+   * - clear 'manuallyResizedColumnIndicesSet' for the first loading
+   * - synchronize the handsontable alignment to the markdowntable alignment
+   *
+   * ## Note ##
+   * The afterLoadData hook is called when one of the following states of this component are passed into the setState.
+   *
+   * - markdownTable
+   * - handsontableHeight
+   *
+   * In detail, when the setState method is called with those state passed,
+   * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
+   * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
+   * In updateSettings method, the loadData method is called in some case. (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
+   * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
+   * After the loadData method is executed, afterLoadData hooks are called.
+   */
   afterLoadDataHandler(initialLoad) {
-    // clear 'manuallyResizedColumnIndicesSet' for the first loading
     if (initialLoad) {
       this.manuallyResizedColumnIndicesSet.clear();
     }
@@ -145,11 +188,6 @@ export default class HandsontableModal extends React.PureComponent {
     this.synchronizeAlignment();
   }
 
-  beforeColumnMoveHandler(columns, target) {
-    // clear 'manuallyResizedColumnIndicesSet'
-    this.manuallyResizedColumnIndicesSet.clear();
-  }
-
   beforeColumnResizeHandler(currentColumn) {
     /*
      * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
@@ -186,6 +224,77 @@ export default class HandsontableModal extends React.PureComponent {
     return Math.max(80, Math.min(400, width));
   }
 
+  beforeColumnMoveHandler(columns, target) {
+    // clear 'manuallyResizedColumnIndicesSet'
+    this.manuallyResizedColumnIndicesSet.clear();
+  }
+
+  /**
+   * An afterColumnMove hook.
+   *
+   * This synchronizes alignment when columns are moved by manualColumnMove
+   */
+  afterColumnMoveHandler(columns, target) {
+    const align = [].concat(this.state.markdownTable.options.align);
+    const removed = align.splice(columns[0], columns.length);
+
+    /*
+     * The following is a description of the algorithm for the alignment synchronization.
+     *
+     * Consider the case where the target is X and the columns are [2,3] and data is as follows.
+     *
+     * 0 1 2 3 4 5 (insert position number)
+     * +-+-+-+-+-+
+     * | | | | | |
+     * +-+-+-+-+-+
+     *  0 1 2 3 4  (column index number)
+     *
+     * At first, remove columns by the splice.
+     *
+     * 0 1 2   4 5
+     * +-+-+   +-+
+     * | | |   | |
+     * +-+-+   +-+
+     *  0 1     4
+     *
+     * Next, insert those columns into a new position.
+     * However the target number is a insert position number before deletion, it may be changed.
+     * These are changed as follows.
+     *
+     * Before:
+     * 0 1 2   4 5
+     * +-+-+   +-+
+     * | | |   | |
+     * +-+-+   +-+
+     *
+     * After:
+     * 0 1 2   2 3
+     * +-+-+   +-+
+     * | | |   | |
+     * +-+-+   +-+
+     *
+     * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
+     * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
+     *
+     */
+    let insertPosition = 0;
+    if (target <= columns[0]) {
+      insertPosition = target;
+    }
+    else if (columns[columns.length - 1] < target) {
+      insertPosition = target - columns.length;
+    }
+    align.splice.apply(align, [insertPosition, 0].concat(removed));
+
+    this.setState((prevState) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, {align: align});
+      return { markdownTable: newMarkdownTable };
+    }, () => {
+      this.synchronizeAlignment();
+    });
+  }
+
   /**
    * change the markdownTable alignment and synchronize the handsontable alignment to it
    */
@@ -244,6 +353,13 @@ export default class HandsontableModal extends React.PureComponent {
     this.setState({ isDataImportAreaExpanded: !this.state.isDataImportAreaExpanded });
   }
 
+  /**
+   * Import a markdowntable
+   *
+   * ## Note ##
+   * The manualColumnMove operation affects the column order of imported data.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
   importData(markdownTable) {
     this.init(markdownTable);
     this.toggleDataImportArea();
@@ -320,6 +436,7 @@ export default class HandsontableModal extends React.PureComponent {
                 beforeColumnMove={this.beforeColumnMoveHandler}
                 beforeColumnResize={this.beforeColumnResizeHandler}
                 afterColumnResize={this.afterColumnResizeHandler}
+                afterColumnMove={this.afterColumnMoveHandler}
               />
           </div>
         </Modal.Body>
@@ -358,7 +475,7 @@ export default class HandsontableModal extends React.PureComponent {
       manualColumnMove: true,
       manualColumnResize: true,
       selectionMode: 'multiple',
-      outsideClickDeselects: false,
+      outsideClickDeselects: false
     };
   }
 }

+ 25 - 0
src/client/js/installer.js

@@ -0,0 +1,25 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
+
+import i18nFactory from './i18n';
+
+import InstallerForm    from './components/InstallerForm';
+
+const userlang = $('body').data('userlang');
+const i18n = i18nFactory(userlang);
+
+// render InstallerForm
+const installerFormElem = document.getElementById('installer-form');
+if (installerFormElem) {
+  const userName = installerFormElem.dataset.userName;
+  const name = installerFormElem.dataset.name;
+  const email = installerFormElem.dataset.email;
+  const csrf = installerFormElem.dataset.csrf;
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
+    </I18nextProvider>,
+    installerFormElem
+  );
+}

+ 7 - 0
src/client/js/legacy/crowi.js

@@ -495,8 +495,10 @@ $(function() {
     }
   });
   $('#portal-form-close').on('click', function(e) {
+    $('#edit').removeClass('active');
     $('body').removeClass('on-edit');
     $('body').removeClass('builtin-editor');
+    location.hash = '#';
   });
 
   /*
@@ -728,11 +730,16 @@ window.addEventListener('load', function(e) {
   if (location.hash) {
     if (location.hash === '#edit' || location.hash === '#edit-form') {
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
+      $('body').addClass('on-edit');
+      $('body').addClass('builtin-editor');
+
       // focus
       Crowi.setCaretLineAndFocusToEditor();
     }
     else if (location.hash == '#hackmd') {
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
+      $('body').addClass('on-edit');
+      $('body').addClass('hackmd');
     }
     else if (location.hash == '#revision-history') {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');

+ 7 - 2
src/client/js/models/MarkdownTable.js

@@ -41,12 +41,17 @@ export default class MarkdownTable {
   }
 
   /**
-   * normalize all cell data(trim & convert the newline character to space)
+   * normalize all cell data(trim & convert the newline character to space or pad '' if cell data is null)
    */
   normalizeCells() {
     for (let i = 0; i < this.table.length; i++) {
       for (let j = 0; j < this.table[i].length; j++) {
-        this.table[i][j] = this.table[i][j].trim().replace(/\r?\n/g, ' ');
+        if (this.table[i][j] != null) {
+          this.table[i][j] = this.table[i][j].trim().replace(/\r?\n/g, ' ');
+        }
+        else {
+          this.table[i][j] = '';
+        }
       }
     }
 

+ 41 - 0
src/client/js/util/codemirror/update-display-util.ext.js

@@ -0,0 +1,41 @@
+import { sawCollapsedSpans } from 'codemirror/src/line/saw_special_spans';
+import { getLine } from 'codemirror/src/line/utils_line';
+import { heightAtLine, visualLineEndNo, visualLineNo } from 'codemirror/src/line/spans';
+import { DisplayUpdate } from 'codemirror/src/display/update_display';
+import { adjustView } from 'codemirror/src/display/view_tracking';
+
+class UpdateDisplayUtil {
+
+  /**
+   * Transplant 'updateDisplayIfNeeded' method to fix weseek/growi#703
+   *
+   * @see https://github.com/weseek/growi/issues/703
+   * @see https://github.com/codemirror/CodeMirror/blob/5.42.0/src/display/update_display.js#L125
+   *
+   * @param {CodeMirror} cm
+   */
+  static forceUpdateViewOffset(cm) {
+    const doc = cm.doc;
+    const display = cm.display;
+
+    const update = new DisplayUpdate(cm, cm.getViewport());
+
+    // Compute a suitable new viewport (from & to)
+    let end = doc.first + doc.size;
+    let from = Math.max(update.visible.from - cm.options.viewportMargin, doc.first);
+    let to = Math.min(end, update.visible.to + cm.options.viewportMargin);
+    if (display.viewFrom < from && from - display.viewFrom < 20) from = Math.max(doc.first, display.viewFrom);
+    if (display.viewTo > to && display.viewTo - to < 20) to = Math.min(end, display.viewTo);
+    if (sawCollapsedSpans) {
+      from = visualLineNo(cm.doc, from);
+      to = visualLineEndNo(cm.doc, to);
+    }
+    adjustView(cm, from, to);
+
+    display.viewOffset = heightAtLine(getLine(doc, display.viewFrom));
+  }
+
+}
+
+
+export default UpdateDisplayUtil;

+ 2 - 2
src/client/styles/scss/_editor-attachment.scss

@@ -14,7 +14,7 @@
 
     position: relative;   // against .overlay position: absolute
 
-    @include overlay-processing-style(overlay-dropzone-active, 2.5em);
+    @include overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
 
     // unuploadable or rejected
     &.dropzone-unuploadable, &.dropzone-rejected {
@@ -28,7 +28,7 @@
     }
     // uploading
     &.dropzone-uploading {
-      @include overlay-processing-style(overlay-dropzone-active, 2.5em);
+      @include overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
     }
 
     // unuploadable

+ 0 - 5
src/client/styles/scss/_editor-overlay.scss

@@ -23,11 +23,6 @@
     right: 0;
     bottom: 0;
     left: 0;
-    .overlay-content {
-      padding: 0.5em;
-      right: 0;
-      bottom: 0;
-    }
   }
 
   // loading keymap

+ 0 - 3
src/client/styles/scss/_on-edit.scss

@@ -290,9 +290,6 @@ body.on-edit {
 
 // overwrite .CodeMirror-hints
 .CodeMirror-hints {
-  // FIXME: required because .content-main.on-edit has 'z-index:1050'
-  // z-index: 1060 !important;
-
   max-height: 30em !important;
 
   .CodeMirror-hint.crowi-emoji-autocomplete {

+ 9 - 0
src/server/crowi/index.js

@@ -33,6 +33,7 @@ function Crowi(rootdir) {
   this.cacheDir    = path.join(this.tmpDir, 'cache');
 
   this.config = {};
+  this.configManager = null;
   this.searcher = null;
   this.mailer = {};
   this.passportService = null;
@@ -78,6 +79,8 @@ Crowi.prototype.init = function() {
       return self.setupSessionConfig();
     }).then(function() {
       return self.setupAppConfig();
+    }).then(function() {
+      return self.setupConfigManager();
     }).then(function() {
       return self.scanRuntimeVersions();
     }).then(function() {
@@ -205,6 +208,12 @@ Crowi.prototype.setupAppConfig = function() {
   });
 };
 
+Crowi.prototype.setupConfigManager = async function() {
+  const ConfigManager = require('../service/config-manager');
+  this.configManager = new ConfigManager(this.model('Config'));
+  return await this.configManager.loadConfigs();
+};
+
 Crowi.prototype.setupModels = function() {
   var self = this
     ;

+ 1 - 0
src/server/form/admin/securityPassportSaml.js

@@ -14,4 +14,5 @@ module.exports = form(
   field('settingForm[security:passport-saml:attrMapLastName]').trim(),
   field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );

+ 1 - 2
src/server/models/attachment.js

@@ -21,7 +21,7 @@ module.exports = function(crowi) {
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
-    createdAt: { type: Date, default: Date.now }
+    createdAt: { type: Date, default: Date.now },
   }, {
     toJSON: {
       virtuals: true
@@ -52,7 +52,6 @@ module.exports = function(crowi) {
         }
         return resolve(data);
       });
-
     });
   };
 

+ 22 - 2
src/server/models/config.js

@@ -75,6 +75,7 @@ module.exports = function(crowi) {
       'security:passport-ldap:groupDnProperty' : undefined,
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
       'security:passport-saml:isEnabled' : false,
+      'security:passport-saml:isSameEmailTreatedAsIdenticalUser': false,
       'security:passport-google:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
@@ -148,6 +149,20 @@ module.exports = function(crowi) {
     return config.markdown[key];
   }
 
+  /**
+   * It is deprecated to use this for anything other than ConfigLoader#load.
+   */
+  configSchema.statics.getDefaultCrowiConfigsObject = function() {
+    return getDefaultCrowiConfigs();
+  };
+
+  /**
+   * It is deprecated to use this for anything other than ConfigLoader#load.
+   */
+  configSchema.statics.getDefaultMarkdownConfigsObject = function() {
+    return getDefaultMarkdownConfigs();
+  };
+
   configSchema.statics.getRestrictGuestModeLabels = function() {
     var labels = {};
     labels[SECURITY_RESTRICT_GUEST_MODE_DENY]     = 'security_setting.guest_mode.deny';
@@ -199,7 +214,7 @@ module.exports = function(crowi) {
     });
   };
 
-  configSchema.statics.setupCofigFormData = function(ns, config) {
+  configSchema.statics.setupConfigFormData = function(ns, config) {
     var defaultConfig = {};
 
     // set Default Settings
@@ -329,6 +344,11 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
+  configSchema.statics.isSameEmailTreatedAsIdenticalUser = function(config, providerType) {
+    const key = `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`;
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isUploadable = function(config) {
     const method = process.env.FILE_UPLOAD || 'aws';
 
@@ -343,7 +363,7 @@ module.exports = function(crowi) {
     return method != 'none';
   };
 
-  configSchema.statics.isGuesstAllowedToRead = function(config) {
+  configSchema.statics.isGuestAllowedToRead = function(config) {
     // return true if puclic wiki mode
     if (Config.isPublicWikiOnly(config)) {
       return true;

+ 16 - 2
src/server/models/external-account.js

@@ -67,10 +67,12 @@ class ExternalAccount {
    * @param {object} usernameToBeRegistered the username of User entity that will be created when accountId is not found
    * @param {object} nameToBeRegistered the name of User entity that will be created when accountId is not found
    * @param {object} mailToBeRegistered the mail of User entity that will be created when accountId is not found
+   * @param {boolean} isSameUsernameTreatedAsIdenticalUser
+   * @param {boolean} isSameEmailTreatedAsIdenticalUser
    * @returns {Promise<ExternalAccount>}
    * @memberof ExternalAccount
    */
-  static findOrRegister(providerType, accountId, usernameToBeRegistered, nameToBeRegistered, mailToBeRegistered) {
+  static findOrRegister(providerType, accountId, usernameToBeRegistered, nameToBeRegistered, mailToBeRegistered, isSameUsernameTreatedAsIdenticalUser, isSameEmailTreatedAsIdenticalUser) {
 
     return this.findOne({ providerType, accountId })
       .then(account => {
@@ -82,7 +84,19 @@ class ExternalAccount {
 
         const User = ExternalAccount.crowi.model('User');
 
-        return User.findOne({username: usernameToBeRegistered})
+        let promise = User.findOne({username: usernameToBeRegistered});
+        if (isSameUsernameTreatedAsIdenticalUser && isSameEmailTreatedAsIdenticalUser) {
+          promise = promise
+            .then(user => {
+              if (user == null) { return User.findOne({email: mailToBeRegistered}) }
+              return user;
+            });
+        }
+        else if (isSameEmailTreatedAsIdenticalUser) {
+          promise = User.findOne({email: mailToBeRegistered});
+        }
+
+        return promise
           .then(user => {
             // when the User that have the same `username` exists
             if (user != null) {

+ 6 - 6
src/server/routes/admin.js

@@ -90,7 +90,7 @@ module.exports = function(crowi, app) {
   actions.app = {};
   actions.app.index = function(req, res) {
     var settingForm;
-    settingForm = Config.setupCofigFormData('crowi', req.config);
+    settingForm = Config.setupConfigFormData('crowi', req.config);
 
     return res.render('admin/app', {
       settingForm: settingForm,
@@ -103,7 +103,7 @@ module.exports = function(crowi, app) {
   // app.get('/admin/security'                  , admin.security.index);
   actions.security = {};
   actions.security.index = function(req, res) {
-    const settingForm = Config.setupCofigFormData('crowi', req.config);
+    const settingForm = Config.setupConfigFormData('crowi', req.config);
     const isAclEnabled = !Config.isPublicWikiOnly(req.config);
     return res.render('admin/security', { settingForm, isAclEnabled });
   };
@@ -112,7 +112,7 @@ module.exports = function(crowi, app) {
   actions.markdown = {};
   actions.markdown.index = function(req, res) {
     const config = crowi.getConfig();
-    const markdownSetting = Config.setupCofigFormData('markdown', config);
+    const markdownSetting = Config.setupConfigFormData('markdown', config);
 
     return res.render('admin/markdown', {
       markdownSetting: markdownSetting,
@@ -189,7 +189,7 @@ module.exports = function(crowi, app) {
   actions.customize = {};
   actions.customize.index = function(req, res) {
     var settingForm;
-    settingForm = Config.setupCofigFormData('crowi', req.config);
+    settingForm = Config.setupConfigFormData('crowi', req.config);
 
     const highlightJsCssSelectorOptions = {
       'github':           { name: '[Light] GitHub',         border: false },
@@ -215,7 +215,7 @@ module.exports = function(crowi, app) {
   actions.notification.index = async(req, res) => {
     const config = crowi.getConfig();
     const UpdatePost = crowi.model('UpdatePost');
-    let slackSetting = Config.setupCofigFormData('notification', config);
+    let slackSetting = Config.setupConfigFormData('notification', config);
     const hasSlackIwhUrl = Config.hasSlackIwhUrl(config);
     const hasSlackToken = Config.hasSlackToken(config);
 
@@ -1010,7 +1010,7 @@ module.exports = function(crowi, app) {
   actions.importer.index = function(req, res) {
 
     var settingForm;
-    settingForm = Config.setupCofigFormData('crowi', req.config);
+    settingForm = Config.setupConfigFormData('crowi', req.config);
 
     return res.render('admin/importer', {
       settingForm: settingForm,

+ 25 - 1
src/server/routes/attachment.js

@@ -28,7 +28,7 @@ module.exports = function(crowi, app) {
             if (fileName.match(/^\/uploads/)) {
               return res.download(path.join(crowi.publicDir, fileName), data.originalName);
             }
-            // aws
+            // aws or gridfs
             else {
               const options = {
                 headers: {
@@ -47,6 +47,30 @@ module.exports = function(crowi, app) {
       });
   };
 
+  /**
+   * @api {get} /attachments.get get attachments from mongoDB
+   * @apiName get
+   * @apiGroup Attachment
+   *
+   * @apiParam {String} pageId, fileName
+   */
+  api.get = async function(req, res) {
+    if (process.env.FILE_UPLOAD !== 'mongodb') {
+      return res.status(400);
+    }
+    const pageId = req.params.pageId;
+    const fileName = req.params.fileName;
+    const filePath = `attachment/${pageId}/${fileName}`;
+    try {
+      const fileData = await fileUploader.getFileData(filePath);
+      res.set('Content-Type', fileData.contentType);
+      return res.send(ApiResponse.success(fileData.data));
+    }
+    catch (e) {
+      return res.json(ApiResponse.error('attachment not found'));
+    }
+  };
+
   /**
    * @api {get} /attachments.list Get attachments of the page
    * @apiName ListAttachments

+ 1 - 0
src/server/routes/index.js

@@ -176,6 +176,7 @@ module.exports = function(crowi, app) {
   app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app, false) , page.redirector);
   app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app, false) , page.redirector); // alias
   app.get( '/download/:id([0-9a-z]{24})' , loginRequired(crowi, app, false) , attachment.api.download);
+  app.get( '/attachment/:pageId/:fileName'  , loginRequired(crowi, app, false), attachment.api.get);
 
   app.get( '/_search'                 , loginRequired(crowi, app, false) , search.searchPage);
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app, false) , search.api.search);

+ 6 - 3
src/server/routes/login-passport.js

@@ -427,6 +427,9 @@ module.exports = function(crowi, app) {
   };
 
   const getOrCreateUser = async(req, res, userInfo, providerId) => {
+    // get option
+    const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, providerId);
+    const isSameEmailTreatedAsIdenticalUser = Config.isSameEmailTreatedAsIdenticalUser(config, providerId);
     try {
       // find or register(create) user
       const externalAccount = await ExternalAccount.findOrRegister(
@@ -435,14 +438,14 @@ module.exports = function(crowi, app) {
         userInfo.username,
         userInfo.name,
         userInfo.email,
+        isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser,
       );
       return externalAccount;
     }
     catch (err) {
       if (err.name === 'DuplicatedUsernameException') {
-        // get option
-        const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, providerId);
-        if (isSameUsernameTreatedAsIdenticalUser) {
+        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
           // associate to existing user
           debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
           return ExternalAccount.associate(providerId, userInfo.id, err.user);

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

@@ -0,0 +1,200 @@
+const debug = require('debug')('growi:service:ConfigLoader');
+
+const TYPES = {
+  NUMBER:  { parse: (v) => parseInt(v) },
+  STRING:  { parse: (v) => v },
+  BOOLEAN: { parse: (v) => /^(true|1)$/i.test(v) }
+};
+
+/**
+ * The following env vars are excluded because these are currently used before the configuration setup.
+ * - MONGO_URI
+ * - NODE_ENV
+ * - PORT
+ * - REDIS_URI
+ * - SESSION_NAME
+ * - PASSWORD_SEED
+ * - SECRET_TOKEN
+ *
+ *  The commented out item has not yet entered the migration work.
+ *  So, parameters of these are under consideration.
+ */
+const ENV_VAR_NAME_TO_CONFIG_INFO = {
+  // ELASTICSEARCH_URI: {
+  //   ns:      ,
+  //   key:     ,
+  //   type:    ,
+  //   default:
+  // },
+  // FILE_UPLOAD: {
+  //   ns:      ,
+  //   key:     ,
+  //   type:    ,
+  //   default:
+  // },
+  // HACKMD_URI: {
+  //   ns:      ,
+  //   key:     ,
+  //   type:    ,
+  //   default:
+  // },
+  // HACKMD_URI_FOR_SERVER: {
+  //   ns:      ,
+  //   key:     ,
+  //   type:    ,
+  //   default:
+  // },
+  // PLANTUML_URI: {
+  //   ns:      ,
+  //   key:     ,
+  //   type:    ,
+  //   default:
+  // },
+  // BLOCKDIAG_URI: {
+  //   ns:      ,
+  //   key:     ,
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_GOOGLE_CLIENT_ID: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-google:clientId',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_GOOGLE_CLIENT_SECRET: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-google:clientSecret',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_GOOGLE_CALLBACK_URI: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-google:callbackUrl',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_GITHUB_CLIENT_ID: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-github:clientId',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_GITHUB_CLIENT_SECRET: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-github:clientSecret',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_GITHUB_CALLBACK_URI: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-github:callbackUrl',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_TWITTER_CONSUMER_KEY: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-twitter:consumerKey',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_TWITTER_CONSUMER_SECRET: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-twitter:consumerSecret',
+  //   type:    ,
+  //   default:
+  // },
+  // OAUTH_TWITTER_CALLBACK_URI: {
+  //   ns:      'crowi',
+  //   key:     'security:passport-twitter:callbackUrl',
+  //   type:    ,
+  //   default:
+  // },
+  SAML_ENTRY_POINT: {
+    ns:      'crowi',
+    key:     'security:passport-saml:entryPoint',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_CALLBACK_URI: {
+    ns:      'crowi',
+    key:     'security:passport-saml:callbackUrl',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ISSUER: {
+    ns:      'crowi',
+    key:     'security:passport-saml:issuer',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_CERT: {
+    ns:      'crowi',
+    key:     'security:passport-saml:cert',
+    type:    TYPES.STRING,
+    default: null
+  }
+};
+
+class ConfigLoader {
+
+  constructor(configModel) {
+    this.configModel = configModel;
+  }
+
+  /**
+   * return a config object
+   */
+  async load() {
+    const configFromDB = await this.loadFromDB();
+    const configFromEnvVars = this.loadFromEnvVars();
+
+    // merge defaults
+    let mergedConfigFromDB = Object.assign({'crowi': this.configModel.getDefaultCrowiConfigsObject()}, configFromDB);
+    mergedConfigFromDB = Object.assign({'markdown': this.configModel.getDefaultMarkdownConfigsObject()}, mergedConfigFromDB);
+
+    return {
+      fromDB: mergedConfigFromDB,
+      fromEnvVars: configFromEnvVars
+    };
+  }
+
+  async loadFromDB() {
+    const config = {};
+    const docs = await this.configModel.find().exec();
+
+    for (const doc of docs) {
+      if (!config[doc.ns]) {
+        config[doc.ns] = {};
+      }
+      config[doc.ns][doc.key] = JSON.parse(doc.value);
+    }
+
+    debug('ConfigLoader#loadFromDB', config);
+
+    return config;
+  }
+
+  loadFromEnvVars() {
+    const config = {};
+    for (const ENV_VAR_NAME of Object.keys(ENV_VAR_NAME_TO_CONFIG_INFO)) {
+      const configInfo = ENV_VAR_NAME_TO_CONFIG_INFO[ENV_VAR_NAME];
+      if (config[configInfo.ns] === undefined) {
+        config[configInfo.ns] = {};
+      }
+
+      if (process.env[ENV_VAR_NAME] === undefined) {
+        config[configInfo.ns][configInfo.key] = configInfo.default;
+      }
+      else {
+        config[configInfo.ns][configInfo.key] = configInfo.type.parse(process.env[ENV_VAR_NAME]);
+      }
+    }
+
+    debug('ConfigLoader#loadFromEnvVars', config);
+
+    return config;
+  }
+}
+
+module.exports = ConfigLoader;

+ 125 - 0
src/server/service/config-manager.js

@@ -0,0 +1,125 @@
+const ConfigLoader = require('../service/config-loader')
+  , debug = require('debug')('growi:service:ConfigManager');
+
+class ConfigManager {
+
+  constructor(configModel) {
+    this.configModel = configModel;
+    this.configLoader = new ConfigLoader(this.configModel);
+    this.configObject = null;
+  }
+
+  /**
+   * load configs from the database and the environment variables
+   */
+  async loadConfigs() {
+    this.configObject = await this.configLoader.load();
+
+    debug('ConfigManager#loadConfigs', this.configObject);
+  }
+
+  /**
+   * get a config specified by namespace & key
+   *
+   * Basically, search a specified config from configs loaded from database at first
+   * and then from configs loaded from env vars.
+   *
+   * In some case, this search method changes.(not yet implemented)
+   */
+  getConfig(namespace, key) {
+    return this.defaultSearch(namespace, key);
+  }
+
+  /**
+   * private api
+   *
+   * Search a specified config from configs loaded from database at first
+   * and then from configs loaded from env vars.
+   *
+   * the followings are the meanings of each special return value.
+   * - null:      a specified config is not set.
+   * - undefined: a specified config does not exist.
+   */
+  defaultSearch(namespace, key) {
+    if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
+      return undefined;
+    }
+
+    if (this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key) ) {
+      return this.configObject.fromDB[namespace][key];
+    }
+
+    if (!this.configExistsInDB(namespace, key) && this.configExistsInEnvVars(namespace, key) ) {
+      return this.configObject.fromEnvVars[namespace][key];
+    }
+
+    if (this.configExistsInDB(namespace, key) && this.configExistsInEnvVars(namespace, key) ) {
+      if (this.configObject.fromDB[namespace][key] !== null) {
+        return this.configObject.fromDB[namespace][key];
+      }
+      else {
+        return this.configObject.fromEnvVars[namespace][key];
+      }
+    }
+  }
+
+  /**
+   * check whether a specified config exists in configs loaded from the database
+   * @returns {boolean}
+   */
+  configExistsInDB(namespace, key) {
+    if (this.configObject.fromDB[namespace] === undefined) {
+      return false;
+    }
+
+    return this.configObject.fromDB[namespace][key] !== undefined;
+  }
+
+  /**
+   * check whether a specified config exists in configs loaded from the environment variables
+   * @returns {boolean}
+   */
+  configExistsInEnvVars(namespace, key) {
+    if (this.configObject.fromEnvVars[namespace] === undefined) {
+      return false;
+    }
+
+    return this.configObject.fromEnvVars[namespace][key] !== undefined;
+  }
+
+  /**
+   * update configs by a iterable object consisting of several objects with ns, key, value fields
+   *
+   * For example:
+   * ```
+   *  updateConfigs(
+   *   [{
+   *     ns:    'some namespace 1',
+   *     key:   'some key 1',
+   *     value: 'some value 1'
+   *   }, {
+   *     ns:    'some namespace 2',
+   *     key:   'some key 2',
+   *     value: 'some value 2'
+   *   }]
+   *  );
+   * ```
+   */
+  async updateConfigs(configs) {
+    const results = [];
+    for (const config of configs) {
+      results.push(
+        this.configModel.findOneAndUpdate(
+          { ns: config.ns, key: config.key },
+          { ns: config.ns, key: config.key, value: JSON.stringify(config.value) },
+          { upsert: true, }
+        ).exec()
+      );
+    }
+    await Promise.all(results);
+
+    await this.loadConfigs();
+  }
+}
+
+module.exports = ConfigManager;

+ 127 - 24
src/server/service/file-uploader/gridfs.js

@@ -3,46 +3,149 @@
 module.exports = function(crowi) {
   'use strict';
 
-  var debug = require('debug')('growi:service:fileUploaderLocal')
-  var mongoose = require('mongoose');
-  var path = require('path');
-  var lib = {};
-  var AttachmentFile = {};
+  const debug = require('debug')('growi:service:fileUploaderGridfs');
+  const mongoose = require('mongoose');
+  const path = require('path');
+  const fs = require('fs');
+  const lib = {};
 
   // instantiate mongoose-gridfs
-  var gridfs = require('mongoose-gridfs')({
-    collection: 'attachments',
+  const gridfs = require('mongoose-gridfs')({
+    collection: 'attachmentFiles',
     model: 'AttachmentFile',
     mongooseConnection: mongoose.connection
   });
 
   // obtain a model
-  AttachmentFile = gridfs.model;
-
-  // // delete a file
-  // lib.deleteFile = async function(fileId, filePath) {
-  //   debug('File deletion: ' + fileId);
-  //   await AttachmentFile.unlinkById(fileId, function(error, unlinkedAttachment) {
-  //     if (error) {
-  //       throw new Error(error);
-  //     }
-  //   });
-  // };
-
-  // create or save a file
+  const AttachmentFile = gridfs.model;
+
+  // delete a file
+  lib.deleteFile = async function(fileId, filePath) {
+    debug('File deletion: ' + fileId);
+    const file = await getFile(filePath);
+    const id = file.id;
+    AttachmentFile.unlinkById(id, function(error, unlinkedAttachment) {
+      if (error) {
+        throw new Error(error);
+      }
+    });
+    clearCache(fileId);
+  };
+
+  const clearCache = (fileId) => {
+    const cacheFile = createCacheFileName(fileId);
+    const stats = fs.statSync(crowi.cacheDir);
+    if (stats.isFile(`attachment-${fileId}`)) {
+      fs.unlink(cacheFile, (err) => {
+        if (err) {
+          throw new Error('fail to delete cache file', err);
+        }
+      });
+    }
+  };
+
   lib.uploadFile = async function(filePath, contentType, fileStream, options) {
     debug('File uploading: ' + filePath);
-    await AttachmentFile.write({filename: filePath, contentType: contentType}, fileStream,
+    await writeFile(filePath, contentType, fileStream);
+  };
+
+  /**
+   * write file to MongoDB with GridFS (Promise wrapper)
+   */
+  const writeFile = (filePath, contentType, fileStream) => {
+    return new Promise((resolve, reject) => {
+      AttachmentFile.write({
+        filename: filePath,
+        contentType: contentType
+      }, fileStream,
       function(error, createdFile) {
         if (error) {
-          throw new Error('Failed to upload ' + createdFile + 'to gridFS', error);
+          reject(error);
+        }
+        resolve();
+      });
+    });
+  };
+
+  lib.getFileData = async function(filePath) {
+    const file = await getFile(filePath);
+    const id = file.id;
+    const contentType = file.contentType;
+    const data = await readFileData(id);
+    return {
+      data,
+      contentType
+    };
+  };
+
+  /**
+   * get file from MongoDB (Promise wrapper)
+   */
+  const getFile = (filePath) => {
+    return new Promise((resolve, reject) => {
+      AttachmentFile.findOne({
+        filename: filePath
+      }, function(err, file) {
+        if (err) {
+          reject(err);
+        }
+        resolve(file);
+      });
+    });
+  };
+
+  /**
+   * read File in MongoDB (Promise wrapper)
+   */
+  const readFileData = (id) => {
+    return new Promise((resolve, reject) => {
+      let buf;
+      const stream = AttachmentFile.readById(id);
+      stream.on('error', function(error) {
+        reject(error);
+      });
+      stream.on('data', function(data) {
+        if (buf) {
+          buf = Buffer.concat([buf, data]);
+        }
+        else {
+          buf = data;
         }
-        return createdFile._id;
       });
+      stream.on('close', function() {
+        debug('GridFS readstream closed');
+        resolve(buf);
+      });
+    });
+  };
+
+  lib.findDeliveryFile = async function(fileId, filePath) {
+    const cacheFile = createCacheFileName(fileId);
+    debug('Load attachement file into local cache file', cacheFile);
+    const fileStream = fs.createWriteStream(cacheFile);
+    const file = await getFile(filePath);
+    const id = file.id;
+    const buf = await readFileData(id);
+    await writeCacheFile(fileStream, buf);
+    return cacheFile;
+  };
+
+  const createCacheFileName = (fileId) => {
+    return path.join(crowi.cacheDir, `attachment-${fileId}`);
+  };
+
+  /**
+   * write cache file (Promise wrapper)
+   */
+  const writeCacheFile = (fileStream, data) => {
+    return new Promise((resolve, reject) => {
+      fileStream.write(data);
+      resolve();
+    });
   };
 
   lib.generateUrl = function(filePath) {
-    return path.posix.join('/uploads', filePath);
+    return `/${filePath}`;
   };
 
   return lib;

+ 8 - 1
src/server/service/file-uploader/index.js

@@ -1,8 +1,15 @@
+const envToModuleMappings = {
+  aws:     'aws',
+  local:   'local',
+  none:    'none',
+  mongodb: 'gridfs',
+};
+
 class FileUploaderFactory {
 
   getUploader(crowi) {
     if (this.uploader == null) {
-      const method = process.env.FILE_UPLOAD || 'aws';
+      const method = envToModuleMappings[process.env.FILE_UPLOAD] || 'aws';
       const modulePath = `./${method}`;
       this.uploader = require(modulePath)(crowi);
     }

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

@@ -223,7 +223,7 @@ exports.loginRequired = function(crowi, app, isStrictly = true) {
       var Config = crowi.model('Config');
 
       // when allowed to read
-      if (Config.isGuesstAllowedToRead(config)) {
+      if (Config.isGuestAllowedToRead(config)) {
         return next();
       }
     }

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

@@ -63,11 +63,11 @@
           <label class="col-xs-3 control-label tbd">(TBD) {{ 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="userForm[globalLang]" value="{{ consts.language.LANG_EN_US }}" {% if user.lang == consts.language.LANG_EN_US %}checked="checked"{% endif %}>
+                <input type="radio" id="radioLangEn" name="settingForm[app:globalLang]" value="{{ consts.language.LANG_EN }}" {% if appGlobalLang() == consts.language.LANG_EN %}checked="checked"{% endif %}>
                 <label for="radioLangEn">{{ t('English') }}</label>
             </div>
             <div class="radio radio-primary radio-inline">
-                <input type="radio" id="radioLangJa" name="userForm[globalLang]" value="{{ consts.language.LANG_JA }}" {% if user.lang == consts.language.LANG_JA %}checked="checked"{% endif %}>
+                <input type="radio" id="radioLangJa" name="settingForm[app:globalLang]" value="{{ consts.language.LANG_JA }}" {% if appGlobalLang() == consts.language.LANG_JA %}checked="checked"{% endif %}>
                 <label for="radioLangJa">{{ t('Japanese') }}</label>
             </div>
           </div>

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

@@ -134,7 +134,7 @@
               Reset user: <code id="admin-password-reset-done-user"></code>
               </p>
               <p>
-              New passwrod: <code id="admin-password-reset-done-password"></code>
+              New password: <code id="admin-password-reset-done-password"></code>
               </p>
             </div>
             <div class="modal-footer">

+ 17 - 0
src/server/views/admin/widget/passport/saml.html

@@ -117,6 +117,23 @@
       </div>
     </div>
 
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByEmail-SAML" name="settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-saml:isSameEmailTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByEmail-SAML">
+            {{ t("security_setting.Treat email matching as identical", "email") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat email matching as identical_warn", "email") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
     <div class="form-group">
       <label for="settingForm[security:passport-saml:attrMapFirstName]" class="col-xs-3 control-label">{{ t("security_setting.SAML.First Name") }}</label>
       <div class="col-xs-6">

+ 14 - 50
src/server/views/installer.html

@@ -2,9 +2,7 @@
 
 {% block html_base_css %}installer nologin{% endblock %}
 
-{% block html_title %}{{ customTitle('セットアップ') }}{% endblock %}
-
-
+{% block html_title %}{{ customTitle(t('installer.setup')) }}{% endblock %}
 
 {#
  # Remove default contents
@@ -18,7 +16,10 @@
 {% block sidebar %}
 {% endblock %}
 
-
+{% block html_additional_headers %}
+  {% parent %}
+  <script src="{{ webpack_asset('js/installer.js') }}" defer></script>
+{% endblock %}
 
 {% block layout_main %}
 
@@ -45,53 +46,16 @@
 
     <div class="login-dialog p-t-10 p-b-10 col-sm-offset-4 col-sm-4" id="login-dialog">
       <p class="alert alert-success">
-        <strong>最初のアカウントの作成</strong><br>
-        <small>初めに作成するアカウントは、自動的に管理者権限が付与されます</small>
-      </p>
-
-      <p class="alert alert-warning p-b-10 p-t-10">
-        <small>現在の言語設定: {{ appGlobalLang() }}</small>
+        <strong>{{ t('installer.create_initial_account') }}</strong><br>
+        <small>{{ t('installer.initial_account_will_be_administrator_automatically') }}</small>
       </p>
 
-      <form role="form" action="/installer/createAdmin" method="post" id="register-form">
-
-        <div class="input-group" id="input-group-username">
-          <span class="input-group-addon"><i class="icon-user"></i></span>
-          <input type="text" class="form-control" placeholder="{{ t('User ID') }}" name="registerForm[username]" value="{{ req.body.registerForm.username }}" required>
-        </div>
-        <p class="help-block">
-          <span id="help-block-username"></span>
-        </p>
-
-        <div class="input-group">
-          <span class="input-group-addon"><i class="icon-tag"></i></span>
-          <input type="text" class="form-control" placeholder="{{ t('Name') }}" name="registerForm[name]" value="{{ googleName|default(req.body.registerForm.name) }}" required>
-        </div>
-
-        <div class="input-group">
-          <span class="input-group-addon"><i class="icon-envelope"></i></span>
-          <input type="email" class="form-control" placeholder="{{ t('Email') }}" name="registerForm[email]" value="{{ googleEmail|default(req.body.registerForm.email) }}" required>
-        </div>
-
-        <div class="input-group">
-          <span class="input-group-addon"><i class="icon-lock"></i></span>
-          <input type="password" class="form-control" placeholder="{{ t('Password') }}" name="registerForm[password]" required>
-        </div>
-
-        <input type="hidden" name="_csrf" value="{{ csrf() }}">
-        <div class="input-group m-t-30 m-b-20 d-flex justify-content-center">
-          <button type="submit" class="fcbtn btn btn-success btn-1b btn-register">
-            <span class="btn-label"><i class="icon-user-follow"></i></span>
-            {{ t('Create') }}
-          </button>
-        </div>
-
-        <div class="input-group m-t-30 d-flex justify-content-center">
-          <a href="https://growi.org" class="link-growi-org">
-            <span class="growi">GROWI</span>.<span class="org">ORG
-          </a>
-        </div>
-      </form>
+      <div id='installer-form'
+        data-user-name="{{ req.body.registerForm.username }}"
+        data-name="{{ googleName|default(req.body.registerForm.name) }}"
+        data-email="{{ googleEmail|default(req.body.registerForm.email) }}"
+        data-csrf="{{ csrf() }}">
+      </div>
     </div>
 
   </div>{# /.row #}
@@ -109,7 +73,7 @@ $(function() {
     $.getJSON('/_api/check_username', {username: username}, function(json) {
       if (!json.valid) {
         $('#help-block-username').html(
-          '<i class="icon-fw icon-ban"></i>このユーザーIDは利用できません。'
+          '<i class="icon-fw icon-ban"></i>{{ t("installer.unavaliable_user_id") }}'
         );
         $('#login-dialog').addClass('has-error');
         $('#input-group-username').addClass('has-error');

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

@@ -1,4 +1,4 @@
 <div class="portal-form-button">
-  <button class="btn btn-primary" id="create-portal-button" {% if not user %}disabled{% endif %}>Create Portal</button>
+  <a class="btn btn-primary" id="create-portal-button" href="#edit" data-toggle="tab" {% if not user %}disabled{% endif %}>Create Portal</a>
   <p class="help-block"><a href="#" data-target="#help-portal" data-toggle="modal"><i class="icon-question"></i> What is Portal?</a></p>
 </div>

+ 2 - 1
src/server/views/widget/not_found_tabs.html

@@ -5,10 +5,11 @@
     </a>
   </li>
 
+  {% if !isTrashPage() and !page.isDeleted() %}
   <li class="nav-main-left-tab">
     <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
       <i class="icon-note"></i> {{ t('Create') }}
     </a>
   </li>
-
+  {% endif %}
 </ul>

+ 6 - 4
src/server/views/widget/page_alerts.html

@@ -12,14 +12,16 @@
       </p>
     {% endif %}
 
-    {% if page.isDeleted() %}
+    {% if isTrashPage() %}
     <div class="alert alert-warning alert-trash d-flex align-items-center justify-content-between">
       <div>
         <i class="icon-trash" aria-hidden="true"></i>
-        This page is in the trash.<br>
-        Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm img-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+        This page is in the trash.
+        {% if page.isDeleted() %}
+        <br>Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm img-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+        {% endif %}
       </div>
-      {% if user %}
+      {% if page.isDeleted() and user %}
       <ul class="list-inline">
         <li>
           <a href="#" class="btn btn-default btn-rounded btn-sm" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</a>

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

@@ -81,7 +81,7 @@
 <ul class="nav nav-tabs customtab nav-tabs-create-portal hidden-print">
 
   <li class="nav-main-left-tab">
-    <a id="portal-form-close" href="#">
+    <a id="portal-form-close" href="#" data-toggle="tab">
       <i class="icon-action-undo"></i> {{ t('Cancel') }}
     </a>
   </li>

+ 27 - 1
wercker.yml

@@ -134,7 +134,10 @@ release: # would be run on release branch
         git reset --hard
         # npm version to bump version
         npm version patch
-        # get version
+
+    - script:
+      name: get RELEASE_VERSION
+      code: |
         export RELEASE_VERSION=`npm run version --silent`
         echo "export RELEASE_VERSION=$RELEASE_VERSION"
 
@@ -167,3 +170,26 @@ release: # would be run on release branch
       username: wercker
       notify_on: "failed"
 
+
+release-rc: # would be run on rc/* branches
+  steps:
+    - install-packages:
+      packages: jq
+
+    - script:
+      name: get RELEASE_VERSION
+      code: |
+        export RELEASE_VERSION=`npm run version --silent`
+        echo "export RELEASE_VERSION=$RELEASE_VERSION"
+
+    - script:
+      name: trigger growi-docker release-rc pipeline
+      code: sh ./bin/wercker/trigger-growi-docker.sh
+
+  after-steps:
+    - slack-notifier:
+      url: $SLACK_WEBHOOK_URL
+      channel: ci
+      username: wercker
+      notify_on: "failed"
+

+ 21 - 17
yarn.lock

@@ -2010,9 +2010,9 @@ code-point-at@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
 
-codemirror@^5.37.0:
-  version "5.39.0"
-  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.39.0.tgz#4654f7d2f7e525e04a62e72d9482348ccb37dce5"
+codemirror@^5.42.0:
+  version "5.42.0"
+  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.42.0.tgz#2d5b640ed009e89dee9ed8a2a778e2a25b65f9eb"
 
 collection-visit@^1.0.0:
   version "1.0.0"
@@ -3991,13 +3991,13 @@ google-p12-pem@^1.0.0:
     node-forge "^0.7.1"
     pify "^3.0.0"
 
-googleapis-common@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-0.3.0.tgz#97ba111f7420367e636a30a2b87be4a7f220c7e9"
+googleapis-common@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-0.4.0.tgz#3b3c2c26f731dcf72101239e70dfa3d677e079e6"
   dependencies:
     axios "^0.18.0"
     google-auth-library "^2.0.0"
-    pify "^3.0.0"
+    pify "^4.0.0"
     qs "^6.5.2"
     url-template "^2.0.8"
     uuid "^3.2.1"
@@ -4010,12 +4010,12 @@ googleapis@^16.0.0:
     google-auth-library "~0.10.0"
     string-template "~1.0.0"
 
-googleapis@^34.0.0:
-  version "34.0.0"
-  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-34.0.0.tgz#15323c2334edeff3bae500faeec7e15ea6fb0047"
+googleapis@^35.0.0:
+  version "35.0.0"
+  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-35.0.0.tgz#958503baa2d32b2702aed7308f8b6abd15a9b5c1"
   dependencies:
     google-auth-library "^2.0.0"
-    googleapis-common "^0.3.0"
+    googleapis-common "^0.4.0"
 
 graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6:
   version "4.1.11"
@@ -6828,6 +6828,10 @@ pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
 
+pify@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
+
 pikaday@1.5.1:
   version "1.5.1"
   resolved "http://registry.npmjs.org/pikaday/-/pikaday-1.5.1.tgz#0a48549bc1a14ea1d08c44074d761bc2f2bfcfd3"
@@ -7463,9 +7467,9 @@ react-clipboard.js@^2.0.0:
     clipboard "^2.0.0"
     prop-types "^15.5.0"
 
-react-codemirror2@^5.0.4:
-  version "5.0.4"
-  resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-5.0.4.tgz#d44a2d7a63a96509ba65db9b771bd61a781b8a0d"
+react-codemirror2@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-5.1.0.tgz#62de4460178adea40eb52eabf7491669bf3794b8"
 
 react-dom@^16.4.1:
   version "16.4.1"
@@ -7476,9 +7480,9 @@ react-dom@^16.4.1:
     object-assign "^4.1.1"
     prop-types "^15.6.0"
 
-react-dropzone@^6.0.2:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-6.0.2.tgz#53a9fdc7b9125c5f2aa9d985a75878b200ee241c"
+react-dropzone@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-7.0.1.tgz#bc76bc1686fb47ed0c8301f968fffa6aecdff47b"
   dependencies:
     attr-accept "^1.1.3"
     prop-types "^15.6.2"