Переглянути джерело

Merge branch 'master' into feat/crop-uploaded-image

yusuketk 6 роки тому
батько
коміт
346e435319
42 змінених файлів з 2335 додано та 607 видалено
  1. 9 1
      CHANGES.md
  2. 1 1
      bin/wercker/trigger-growi-docker.sh
  3. 28 0
      bin/wercker/trigger-growi-docs.sh
  4. 1 1
      config/swagger-definition.js
  5. 3 1
      package.json
  6. 29 0
      resource/locales/en-US/translation.json
  7. 29 0
      resource/locales/ja/translation.json
  8. 14 0
      src/client/js/app.jsx
  9. 0 100
      src/client/js/components/Admin/Export/ExportAsZip.jsx
  10. 124 3
      src/client/js/components/Admin/Export/ExportPage.jsx
  11. 52 0
      src/client/js/components/Admin/Export/ExportTableMenu.jsx
  12. 162 0
      src/client/js/components/Admin/Export/ExportZipFormModal.jsx
  13. 67 0
      src/client/js/components/Admin/Export/ZipFileTable.jsx
  14. 181 0
      src/client/js/components/Admin/Import/GrowiZipImportForm.jsx
  15. 119 0
      src/client/js/components/Admin/Import/GrowiZipImportSection.jsx
  16. 93 0
      src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx
  17. 7 7
      src/client/js/components/PageEditor/Cheatsheet.jsx
  18. 37 38
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  19. 33 2
      src/client/js/components/PageEditor/Editor.jsx
  20. 17 17
      src/client/js/services/AppContainer.js
  21. 15 0
      src/lib/util/toArrayIfNot.js
  22. 23 0
      src/server/crowi/index.js
  23. 27 0
      src/server/middleware/access-token-parser.js
  24. 24 0
      src/server/middleware/admin-required.js
  25. 27 0
      src/server/middleware/csrf.js
  26. 49 0
      src/server/middleware/login-required.js
  27. 16 0
      src/server/routes/admin.js
  28. 81 58
      src/server/routes/apiv3/export.js
  29. 250 0
      src/server/routes/apiv3/import.js
  30. 4 0
      src/server/routes/apiv3/index.js
  31. 46 0
      src/server/routes/apiv3/mongo.js
  32. 150 149
      src/server/routes/index.js
  33. 92 96
      src/server/service/export.js
  34. 134 0
      src/server/service/growi-bridge.js
  35. 269 0
      src/server/service/import.js
  36. 2 101
      src/server/util/middlewares.js
  37. 3 0
      src/server/views/admin/importer.html
  38. 10 17
      src/test/middleware/login-required.test.js
  39. 1 0
      tmp/downloads/.keep
  40. 1 0
      tmp/imports/.keep
  41. 10 11
      wercker.yml
  42. 95 4
      yarn.lock

+ 9 - 1
CHANGES.md

@@ -1,10 +1,18 @@
 # CHANGES
 
-## 3.5.14-RC
+## 3.5.16-RC
 
+* 
+
+## 3.5.15
+
+* Feature: Import/Export Page data
+* Fix: The link to Sandbox on Markdown Help Modal doesn't work
 * Support: Upgrade libs
     * codemirror
 
+## 3.5.14 (Missing number)
+
 ## 3.5.13
 
 * Feature: Re-edit comments

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

@@ -9,7 +9,7 @@
 #   - $WERCKER_TOKEN
 #   - $GROWI_DOCKER_PIPELINE_ID
 #   - $RELEASE_VERSION
-#   - $WERCKER_GIT_COMMIT
+#   - $RELEASE_GIT_COMMIT
 #
 RESPONSE=`curl -X POST \
   -H "Content-Type: application/json" \

+ 28 - 0
bin/wercker/trigger-growi-docs.sh

@@ -0,0 +1,28 @@
+#!/bin/sh
+
+# Trigger a new run
+# see: http://devcenter.wercker.com/docs/api/endpoints/runs#trigger-a-run
+
+# exec curl
+#
+# require
+#   - $WERCKER_TOKEN
+#   - $GROWI_DOCS_PIPELINE_ID
+#
+RESPONSE=`curl -X POST \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer $WERCKER_TOKEN" \
+  https://app.wercker.com/api/v3/runs -d '{ \
+    "pipelineId": "'$GROWI_DOCS_PIPELINE_ID'", \
+    "branch": "master"
+  }' \
+`
+
+echo $RESPONSE | jq .
+
+# get wercker run id
+RUN_ID=`echo $RESPONSE | jq .id`
+# exit with failure status
+if [ "$RUN_ID" = "null" ]; then
+  exit 1
+fi

+ 1 - 1
config/swagger-definition.js

@@ -8,7 +8,7 @@ module.exports = {
   },
   servers: [
     {
-      url: 'https://demo.growi.org/api/v3/',
+      url: 'https://demo.growi.org/_api/v3/',
     },
   ],
 };

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.14-RC",
+  "version": "3.5.16-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -68,6 +68,7 @@
       "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0",
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
+    "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
@@ -134,6 +135,7 @@
     "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
+    "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "xss": "^1.0.6"
   },

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

@@ -737,7 +737,18 @@
   },
 
   "importer_management": {
+    "beta_warning": "This function is Beta.",
     "import_from": "Import from %s",
+    "import_form_growi": "Import from GROWI",
+    "growi_settings": {
+      "overwrite_documents": "Imported documents will overwrite existing documents",
+      "zip_file": "Zip File",
+      "uploaded_data": "Uploaded Data",
+      "extracted_file": "Extracted File",
+      "collection": "Collection",
+      "upload": "Upload",
+      "discard": "Discard Uploaded Data"
+    },
     "esa_settings": {
       "team_name": "Team name",
       "access_token": "Access token",
@@ -751,5 +762,23 @@
     "import": "Import",
     "page_skip": "Pages with a name that already exists on GROWI are not imported",
     "Directory_hierarchy_tag": "Directory Hierarchy Tag"
+  },
+
+  "export_management": {
+    "beta_warning": "This function is Beta.",
+    "exported_data_list": "Exported Data List",
+    "export_collections": "Export Collections",
+    "check_all": "Check All",
+    "uncheck_all": "Uncheck All",
+    "create_new_exported_data": "Create New Exported Data",
+    "export": "Export",
+    "cancel": "Cancel",
+    "file": "File",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "Export Menu",
+    "download": "Download",
+    "delete": "Delete"
   }
 }

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

@@ -720,7 +720,18 @@
   },
 
   "importer_management": {
+    "beta_warning": "この機能はベータ版です",
     "import_from": "%s からインポート",
+    "import_form_growi": "GROWIからインポート",
+    "growi_settings": {
+      "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
+      "zip_file": "Zip ファイル",
+      "uploaded_data": "アップロードされたデータ",
+      "extracted_file": "展開されたファイル",
+      "collection": "コレクション",
+      "upload": "アップロード",
+      "discard": "アップロードしたデータを破棄する"
+    },
     "esa_settings": {
       "team_name": "チーム名",
       "access_token": "アクセストークン",
@@ -734,5 +745,23 @@
     "import": "インポート",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
+  },
+
+  "export_management": {
+    "beta_warning": "この機能はベータ版です",
+    "exported_data_list": "エクスポートデータリスト",
+    "export_collections": "コレクションのエクスポート",
+    "check_all": "全てにチェックを付ける",
+    "uncheck_all": "全てからチェックを外す",
+    "create_new_exported_data": "エクスポートデータの新規作成",
+    "export": "エクスポート",
+    "cancel": "キャンセル",
+    "file": "ファイル名",
+    "growi_version": "Growi バージョン",
+    "collections": "コレクション",
+    "exported_at": "エクスポートされた時間",
+    "export_menu": "エクスポートメニュー",
+    "download": "ダウンロード",
+    "delete": "削除"
   }
 }

+ 14 - 0
src/client/js/app.jsx

@@ -39,6 +39,7 @@ import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 import ExportPage from './components/Admin/Export/ExportPage';
+import GrowiZipImportSection from './components/Admin/Import/GrowiZipImportSection';
 import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 import ProfileImageUploader from './components/ProfileImageUploader';
 
@@ -205,6 +206,19 @@ if (adminExportPageElem != null) {
   );
 }
 
+// TODO: move to /imponents/Admin/Importer.jsx
+const growiImportElem = document.getElementById('growi-import');
+if (growiImportElem != null) {
+  ReactDOM.render(
+    <Provider inject={[]}>
+      <I18nextProvider i18n={i18n}>
+        <GrowiZipImportSection />
+      </I18nextProvider>
+    </Provider>,
+    growiImportElem,
+  );
+}
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(

+ 0 - 100
src/client/js/components/Admin/Export/ExportAsZip.jsx

@@ -1,100 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
-// import { toastSuccess, toastError } from '../../../util/apiNotification';
-
-class ExportPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      files: {},
-    };
-
-    this.createZipFile = this.createZipFile.bind(this);
-    this.deleteZipFile = this.deleteZipFile.bind(this);
-  }
-
-  async componentDidMount() {
-    const res = await this.props.appContainer.apiGet('/v3/export', {});
-
-    this.setState({ files: res.files });
-  }
-
-  async createZipFile() {
-    // TODO use appContainer.apiv3.post
-    const res = await this.props.appContainer.apiPost('/v3/export/pages', {});
-    // TODO toastSuccess, toastError
-    this.setState((prevState) => {
-      return {
-        files: {
-          ...prevState.files,
-          [res.collection]: res.file,
-        },
-      };
-    });
-  }
-
-  async deleteZipFile() {
-    // TODO use appContainer.apiv3.delete
-    // TODO toastSuccess, toastError
-  }
-
-  render() {
-    // const { t } = this.props;
-
-    return (
-      <Fragment>
-        <h2>Export Data as Zip</h2>
-        <form className="my-5">
-          {Object.keys(this.state.files).map((file) => {
-            const disabled = file !== 'pages';
-            return (
-              <div className="form-check" key={file}>
-                <input
-                  type="radio"
-                  id={file}
-                  name="collection"
-                  className="form-check-input"
-                  value={file}
-                  disabled={disabled}
-                  checked={!disabled}
-                  onChange={() => {}}
-                />
-                <label className={`form-check-label ml-3 ${disabled ? 'text-muted' : ''}`} htmlFor={file}>
-                  {file} ({this.state.files[file] || 'not found'})
-                </label>
-              </div>
-            );
-          })}
-        </form>
-        <button type="button" className="btn btn-sm btn-default" onClick={this.createZipFile}>Generate</button>
-        <a href="/_api/v3/export/pages">
-          <button type="button" className="btn btn-sm btn-primary ml-2">Download</button>
-        </a>
-        {/* <button type="button" className="btn btn-sm btn-danger ml-2" onClick={this.deleteZipFile}>Clear</button> */}
-      </Fragment>
-    );
-  }
-
-}
-
-ExportPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isAclEnabled: PropTypes.bool,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ExportPageWrapper = (props) => {
-  return createSubscribedElement(ExportPage, props, [AppContainer]);
-};
-
-export default withTranslation()(ExportPageWrapper);

+ 124 - 3
src/client/js/components/Admin/Export/ExportPage.jsx

@@ -1,17 +1,138 @@
 import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
 
-import ExportAsZip from './ExportAsZip';
+import ExportZipFormModal from './ExportZipFormModal';
+import ZipFileTable from './ZipFileTable';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 class ExportPage extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      collections: [],
+      zipFileStats: [],
+      isExportModalOpen: false,
+    };
+
+    this.onZipFileStatAdd = this.onZipFileStatAdd.bind(this);
+    this.onZipFileStatRemove = this.onZipFileStatRemove.bind(this);
+    this.openExportModal = this.openExportModal.bind(this);
+    this.closeExportModal = this.closeExportModal.bind(this);
+  }
+
+  async componentDidMount() {
+    // TODO:: use apiv3.get
+    // eslint-disable-next-line no-unused-vars
+    const [{ collections }, { zipFileStats }] = await Promise.all([
+      this.props.appContainer.apiGet('/v3/mongo/collections', {}),
+      this.props.appContainer.apiGet('/v3/export/status', {}),
+    ]);
+    // TODO: toastSuccess, toastError
+
+    this.setState({ collections: ['pages', 'revisions'], zipFileStats }); // FIXME: delete this line and uncomment the line below
+    // this.setState({ collections, zipFileStats });
+  }
+
+  onZipFileStatAdd(newStat) {
+    this.setState((prevState) => {
+      return {
+        zipFileStats: [...prevState.zipFileStats, newStat],
+      };
+    });
+  }
+
+  async onZipFileStatRemove(fileName) {
+    try {
+      await this.props.appContainer.apiDelete(`/v3/export/${fileName}`, {});
+
+      this.setState((prevState) => {
+        return {
+          zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== fileName),
+        };
+      });
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  openExportModal() {
+    this.setState({ isExportModalOpen: true });
+  }
+
+  closeExportModal() {
+    this.setState({ isExportModalOpen: false });
+  }
+
   render() {
+    const { t } = this.props;
+
     return (
       <Fragment>
-        <ExportAsZip />
+        <div className="alert alert-warning">
+          <i className="icon-exclamation"></i> { t('export_management.beta_warning') }
+        </div>
+
+        <h2>{t('Export Data')}</h2>
+
+        <button type="button" className="btn btn-default" onClick={this.openExportModal}>{t('export_management.create_new_exported_data')}</button>
+
+        <div className="mt-5">
+          <h3>{t('export_management.exported_data_list')}</h3>
+          <ZipFileTable
+            zipFileStats={this.state.zipFileStats}
+            onZipFileStatRemove={this.onZipFileStatRemove}
+          />
+        </div>
+
+        <ExportZipFormModal
+          isOpen={this.state.isExportModalOpen}
+          onClose={this.closeExportModal}
+          collections={this.state.collections}
+          zipFileStats={this.state.zipFileStats}
+          onZipFileStatAdd={this.onZipFileStatAdd}
+        />
       </Fragment>
     );
   }
 
 }
 
-export default ExportPage;
+ExportPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportPageFormWrapper = (props) => {
+  return createSubscribedElement(ExportPage, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportPageFormWrapper);

+ 52 - 0
src/client/js/components/Admin/Export/ExportTableMenu.jsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExportTableMenu extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="btn-group admin-user-menu">
+        <button type="button" className="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown">
+          <i className="icon-settings"></i> <span className="caret"></span>
+        </button>
+        <ul className="dropdown-menu" role="menu">
+          <li className="dropdown-header">{t('export_management.export_menu')}</li>
+          <li>
+            <a type="button" href={`/admin/export/${this.props.fileName}`}>
+              <i className="icon-cloud-download" /> {t('export_management.download')}
+            </a>
+          </li>
+          <li>
+            <a type="button" role="button" onClick={() => this.props.onZipFileStatRemove(this.props.fileName)}>
+              <span className="text-danger"><i className="icon-trash" /> {t('export_management.delete')}</span>
+            </a>
+          </li>
+        </ul>
+      </div>
+    );
+  }
+
+}
+
+ExportTableMenu.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  fileName: PropTypes.string.isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportTableMenuWrapper = (props) => {
+  return createSubscribedElement(ExportTableMenu, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportTableMenuWrapper);

+ 162 - 0
src/client/js/components/Admin/Export/ExportZipFormModal.jsx

@@ -0,0 +1,162 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExportZipFormModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      collections: new Set(),
+    };
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.checkAll = this.checkAll.bind(this);
+    this.uncheckAll = this.uncheckAll.bind(this);
+    this.export = this.export.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const collections = new Set(prevState.collections);
+      if (checked) {
+        collections.add(name);
+      }
+      else {
+        collections.delete(name);
+      }
+
+      return { collections };
+    });
+  }
+
+  checkAll() {
+    this.setState({ collections: new Set(this.props.collections) });
+  }
+
+  uncheckAll() {
+    this.setState({ collections: new Set() });
+  }
+
+  async export(e) {
+    e.preventDefault();
+
+    try {
+      // TODO: use appContainer.apiv3.post
+      const { zipFileStat } = await this.props.appContainer.apiPost('/v3/export', { collections: Array.from(this.state.collections) });
+      // TODO: toastSuccess, toastError
+      this.props.onZipFileStatAdd(zipFileStat);
+      this.props.onClose();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Generated ${zipFileStat.fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  validateForm() {
+    return this.state.collections.size > 0;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
+        <Modal.Header closeButton>
+          <Modal.Title>{t('export_management.export_collections')}</Modal.Title>
+        </Modal.Header>
+
+        <form onSubmit={this.export}>
+          <Modal.Body>
+            <div className="row">
+              <div className="col-sm-12">
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.checkAll}>
+                  <i className="fa fa-check-square-o"></i> {t('export_management.check_all')}
+                </button>
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.uncheckAll}>
+                  <i className="fa fa-square-o"></i> {t('export_management.uncheck_all')}
+                </button>
+              </div>
+            </div>
+            <div className="checkbox checkbox-info">
+              {this.props.collections.map((collectionName) => {
+                return (
+                  <div className="my-1" key={collectionName}>
+                    <input
+                      type="checkbox"
+                      id={collectionName}
+                      name={collectionName}
+                      className="form-check-input"
+                      value={collectionName}
+                      checked={this.state.collections.has(collectionName)}
+                      onChange={this.toggleCheckbox}
+                    />
+                    <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
+                      {collectionName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </Modal.Body>
+
+          <Modal.Footer>
+            <button type="button" className="btn btn-sm btn-default" onClick={this.props.onClose}>{t('export_management.cancel')}</button>
+            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('export_management.export')}</button>
+          </Modal.Footer>
+        </form>
+      </Modal>
+    );
+  }
+
+}
+
+ExportZipFormModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+  collections: PropTypes.arrayOf(PropTypes.string).isRequired,
+  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onZipFileStatAdd: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportZipFormModalWrapper = (props) => {
+  return createSubscribedElement(ExportZipFormModal, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportZipFormModalWrapper);

+ 67 - 0
src/client/js/components/Admin/Export/ZipFileTable.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+
+import ExportTableMenu from './ExportTableMenu';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ZipFileTable extends React.Component {
+
+  render() {
+    // eslint-disable-next-line no-unused-vars
+    const { t } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('export_management.file')}</th>
+            <th>{t('export_management.growi_version')}</th>
+            <th>{t('export_management.collections')}</th>
+            <th>{t('export_management.exported_at')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {this.props.zipFileStats.map(({ meta, fileName, fileStats }) => {
+            return (
+              <tr key={fileName}>
+                <th>{fileName}</th>
+                <td>{meta.version}</td>
+                <td className="text-capitalize">{fileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+                <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+                <td>
+                  <ExportTableMenu
+                    fileName={fileName}
+                    onZipFileStatRemove={this.props.onZipFileStatRemove}
+                  />
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+ZipFileTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ZipFileTableWrapper = (props) => {
+  return createSubscribedElement(ZipFileTable, props, [AppContainer]);
+};
+
+export default withTranslation()(ZipFileTableWrapper);

+ 181 - 0
src/client/js/components/Admin/Import/GrowiZipImportForm.jsx

@@ -0,0 +1,181 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      collections: new Set(),
+      schema: {
+        pages: {},
+        revisions: {},
+        // ...
+      },
+    };
+
+    this.state = this.initialState;
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.import = this.import.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const collections = new Set(prevState.collections);
+      if (checked) {
+        collections.add(name);
+      }
+      else {
+        collections.delete(name);
+      }
+      return { collections };
+    });
+  }
+
+  async import(e) {
+    e.preventDefault();
+
+    try {
+      // TODO: use appContainer.apiv3.post
+      const { results } = await this.props.appContainer.apiPost('/v3/import', {
+        fileName: this.props.fileName,
+        collections: Array.from(this.state.collections),
+        schema: this.state.schema,
+      });
+
+      this.setState(this.initialState);
+      this.props.onPostImport();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Imported documents', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      for (const { collectionName, failedIds } of results) {
+        if (failedIds.length > 0) {
+          toastr.error(`failed to insert ${failedIds.join(', ')}`, collectionName, {
+            closeButton: true,
+            progressBar: true,
+            newestOnTop: false,
+            timeOut: '30000',
+          });
+        }
+      }
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  validateForm() {
+    return this.state.collections.size > 0;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="row" onSubmit={this.import}>
+        <div className="col-xs-12">
+          <table className="table table-bordered table-mapping">
+            <caption>{t('importer_management.growi_settings.uploaded_data')}</caption>
+            <thead>
+              <tr>
+                <th></th>
+                <th>{t('importer_management.growi_settings.extracted_file')}</th>
+                <th>{t('importer_management.growi_settings.collection')}</th>
+              </tr>
+            </thead>
+            <tbody>
+              {this.props.fileStats.map((fileStat) => {
+                  const { fileName, collectionName } = fileStat;
+                  const checked = this.state.collections.has(collectionName);
+                  return (
+                    <Fragment key={collectionName}>
+                      <tr>
+                        <td>
+                          <input
+                            type="checkbox"
+                            id={collectionName}
+                            name={collectionName}
+                            className="form-check-input"
+                            value={collectionName}
+                            checked={checked}
+                            onChange={this.toggleCheckbox}
+                          />
+                        </td>
+                        <td>{fileName}</td>
+                        <td className="text-capitalize">{collectionName}</td>
+                      </tr>
+                      {checked && (
+                        <tr>
+                          <td className="text-muted" colSpan="3">
+                            TBD: define how {collectionName} are imported
+                            {/* TODO: create a component for each collection to modify this.state.schema */}
+                          </td>
+                        </tr>
+                      )}
+                    </Fragment>
+                  );
+                })}
+            </tbody>
+          </table>
+        </div>
+        <div className="col-xs-12 text-center">
+          <button type="submit" className="btn btn-primary mx-1" disabled={!this.validateForm()}>
+            { t('importer_management.import') }
+          </button>
+          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
+            { t('importer_management.growi_settings.discard') }
+          </button>
+        </div>
+      </form>
+    );
+  }
+
+}
+
+GrowiImportForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  fileName: PropTypes.string,
+  fileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onDiscard: PropTypes.func.isRequired,
+  onPostImport: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiImportFormWrapper = (props) => {
+  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiImportFormWrapper);

+ 119 - 0
src/client/js/components/Admin/Import/GrowiZipImportSection.jsx

@@ -0,0 +1,119 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import GrowiZipUploadForm from './GrowiZipUploadForm';
+import GrowiZipImportForm from './GrowiZipImportForm';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiZipImportSection extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      fileName: '',
+      fileStats: [],
+    };
+
+    this.state = this.initialState;
+
+    this.handleUpload = this.handleUpload.bind(this);
+    this.discardData = this.discardData.bind(this);
+    this.resetState = this.resetState.bind(this);
+  }
+
+  handleUpload({ meta, fileName, fileStats }) {
+    this.setState({
+      fileName,
+      fileStats,
+    });
+  }
+
+  async discardData() {
+    try {
+      const { fileName } = this.state;
+      await this.props.appContainer.apiDelete(`/v3/import/${this.state.fileName}`, {});
+      this.resetState();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  resetState() {
+    this.setState(this.initialState);
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <legend>{t('importer_management.import_form_growi')}</legend>
+
+        <div className="alert alert-warning">
+          <i className="icon-exclamation"></i> { t('importer_management.beta_warning') }
+        </div>
+
+        <div className="well well-sm small">
+          <ul>
+            <li>{t('importer_management.growi_settings.overwrite_documents')}</li>
+          </ul>
+        </div>
+
+        {this.state.fileName ? (
+          <Fragment>
+            <GrowiZipImportForm
+              fileName={this.state.fileName}
+              fileStats={this.state.fileStats}
+              onDiscard={this.discardData}
+              onPostImport={this.resetState}
+            />
+          </Fragment>
+        ) : (
+          <GrowiZipUploadForm
+            onUpload={this.handleUpload}
+          />
+        )}
+      </Fragment>
+    );
+  }
+
+}
+
+GrowiZipImportSection.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipImportSectionWrapper = (props) => {
+  return createSubscribedElement(GrowiZipImportSection, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipImportSectionWrapper);

+ 93 - 0
src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx

@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiZipUploadForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.inputRef = React.createRef();
+
+    this.changeFileName = this.changeFileName.bind(this);
+    this.uploadZipFile = this.uploadZipFile.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeFileName(e) {
+    // to trigger rerender at onChange event
+    // eslint-disable-next-line react/no-unused-state
+    this.setState({ dummy: e.target.files[0].name });
+  }
+
+  async uploadZipFile(e) {
+    e.preventDefault();
+
+    const formData = new FormData();
+    formData.append('_csrf', this.props.appContainer.csrfToken);
+    formData.append('file', this.inputRef.current.files[0]);
+
+    // TODO: use appContainer.apiv3.post
+    const { data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
+    this.props.onUpload(data);
+    // TODO: toastSuccess, toastError
+  }
+
+  validateForm() {
+    return (
+      this.inputRef.current // null check
+      && this.inputRef.current.files[0] // null check
+      && /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-horizontal" onSubmit={this.uploadZipFile}>
+        <fieldset>
+          <div className="form-group d-flex align-items-center">
+            <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.zip_file')}</label>
+            <div className="col-xs-6">
+              <input
+                type="file"
+                name="file"
+                className="form-control-file"
+                ref={this.inputRef}
+                onChange={this.changeFileName}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-xs-offset-3 col-xs-6">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
+                {t('importer_management.growi_settings.upload')}
+              </button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    );
+  }
+
+}
+
+GrowiZipUploadForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  onUpload: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipUploadFormWrapper = (props) => {
+  return createSubscribedElement(GrowiZipUploadForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipUploadFormWrapper);

+ 7 - 7
src/client/js/components/PageEditor/Cheatsheet.jsx

@@ -28,7 +28,7 @@ class Cheatsheet extends React.Component {
           <h4>{t('sandbox.line_break')}</h4>
           <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
           <ul className="hljs">
-            <li>text</li>
+            <li>text&nbsp;&nbsp;</li>
             <li>text</li>
           </ul>
           <h4>{t('sandbox.typography')}</h4>
@@ -76,12 +76,12 @@ class Cheatsheet extends React.Component {
             <li>&gt;&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
           </ul>
           <h4>{t('sandbox.table')}</h4>
-          <ul className="hljs text-center">
-            <li>|Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|</li>
-            <li>|:----------|:---------:|----------:|</li>
-            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
-            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
-          </ul>
+          <pre className="border-0">
+            |Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|<br />
+            |:----------|:---------:|----------:|<br />
+            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
+            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
+          </pre>
           <h4>{t('sandbox.image')}</h4>
           <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
           <ul className="hljs">

+ 37 - 38
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import Modal from 'react-bootstrap/es/Modal';
 import Button from 'react-bootstrap/es/Button';
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
@@ -12,7 +11,7 @@ import InterceptorManager from '@commons/service/interceptor-manager';
 
 import AbstractEditor from './AbstractEditor';
 import SimpleCheatsheet from './SimpleCheatsheet';
-import Cheatsheet from './Cheatsheet';
+
 import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
@@ -62,7 +61,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
-      isCheatsheetModalButtonShown: this.props.isGfmMode && this.props.value.length > 0,
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
     };
@@ -507,10 +505,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const isGfmMode = isGfmModeTmp || this.state.isGfmMode;
     const value = valueTmp || this.getCodeMirror().getDoc().getValue();
 
-    // update isSimpleCheatsheetShown, isCheatsheetModalButtonShown
+    // update isSimpleCheatsheetShown
     const isSimpleCheatsheetShown = isGfmMode && value.length === 0;
-    const isCheatsheetModalButtonShown = isGfmMode && value.length > 0;
-    this.setState({ isSimpleCheatsheetShown, isCheatsheetModalButtonShown });
+    this.setState({ isSimpleCheatsheetShown });
+  }
+
+  markdownHelpButtonClickedHandler() {
+    if (this.props.onMarkdownHelpButtonClicked != null) {
+      this.props.onMarkdownHelpButtonClicked();
+    }
   }
 
   renderLoadingKeymapOverlay() {
@@ -533,38 +536,35 @@ export default class CodeMirrorEditor extends AbstractEditor {
       : '';
   }
 
-  renderSimpleCheatsheet() {
-    return <SimpleCheatsheet />;
-  }
-
-  renderCheatsheetModalBody() {
-    return <Cheatsheet />;
-  }
-
   renderCheatsheetModalButton() {
-    const showCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: true });
-    };
+    return (
+      <button type="button" className="btn-link gfm-cheatsheet-modal-link text-muted small p-0" onClick={() => { this.markdownHelpButtonClickedHandler() }}>
+        <i className="icon-question" /> Markdown
+      </button>
+    );
+  }
 
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
+  renderCheatsheetOverlay() {
+    const cheatsheetModalButton = this.renderCheatsheetModalButton();
 
     return (
-      <React.Fragment>
-        <Modal className="modal-gfm-cheatsheet" show={this.state.isCheatsheetModalShown} onHide={() => { hideCheatsheetModal() }}>
-          <Modal.Header closeButton>
-            <Modal.Title><i className="icon-fw icon-question" />Markdown Help</Modal.Title>
-          </Modal.Header>
-          <Modal.Body className="pt-1">
-            { this.renderCheatsheetModalBody() }
-          </Modal.Body>
-        </Modal>
-
-        <button type="button" className="btn-link gfm-cheatsheet-modal-link text-muted small mr-3" onClick={() => { showCheatsheetModal() }}>
-          <i className="icon-question" /> Markdown
-        </button>
-      </React.Fragment>
+      <div className="overlay overlay-gfm-cheatsheet mt-1 p-3">
+        { this.state.isSimpleCheatsheetShown
+          ? (
+            <div className="text-right">
+              {cheatsheetModalButton}
+              <div className="mt-2">
+                <SimpleCheatsheet />
+              </div>
+            </div>
+          )
+          : (
+            <div className="mr-4">
+              {cheatsheetModalButton}
+            </div>
+          )
+        }
+      </div>
     );
   }
 
@@ -808,15 +808,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         { this.renderLoadingKeymapOverlay() }
 
-        <div className="overlay overlay-gfm-cheatsheet mt-1 p-3 pt-3">
-          { this.state.isSimpleCheatsheetShown && this.renderSimpleCheatsheet() }
-          { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
-        </div>
+        { this.renderCheatsheetOverlay() }
 
         <HandsontableModal
           ref={(c) => { this.handsontableModal = c }}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
         />
+
       </React.Fragment>
     );
   }
@@ -827,6 +825,7 @@ CodeMirrorEditor.propTypes = Object.assign({
   editorOptions: PropTypes.object.isRequired,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
+  onMarkdownHelpButtonClicked: PropTypes.func,
 }, AbstractEditor.propTypes);
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,

+ 33 - 2
src/client/js/components/PageEditor/Editor.jsx

@@ -3,14 +3,17 @@ import PropTypes from 'prop-types';
 
 import { Subscribe } from 'unstated';
 
+import Modal from 'react-bootstrap/es/Modal';
 import Dropzone from 'react-dropzone';
+
+import EditorContainer from '../../services/EditorContainer';
+
+import Cheatsheet from './Cheatsheet';
 import AbstractEditor from './AbstractEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
-
 import pasteHelper from './PasteHelper';
-import EditorContainer from '../../services/EditorContainer';
 
 export default class Editor extends AbstractEditor {
 
@@ -21,6 +24,7 @@ export default class Editor extends AbstractEditor {
       isComponentDidMount: false,
       dropzoneActive: false,
       isUploading: false,
+      isCheatsheetModalShown: false,
     };
 
     this.getEditorSubstance = this.getEditorSubstance.bind(this);
@@ -31,6 +35,8 @@ export default class Editor extends AbstractEditor {
     this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
     this.dropHandler = this.dropHandler.bind(this);
 
+    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
+
     this.getAcceptableType = this.getAcceptableType.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
@@ -174,6 +180,10 @@ export default class Editor extends AbstractEditor {
     this.setState({ isUploading: true });
   }
 
+  showMarkdownHelp() {
+    this.setState({ isCheatsheetModalShown: true });
+  }
+
   getDropzoneClassName(isDragAccept, isDragReject) {
     let className = 'dropzone';
     if (!this.props.isUploadable) {
@@ -240,6 +250,23 @@ export default class Editor extends AbstractEditor {
     return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
   }
 
+  renderCheatsheetModal() {
+    const hideCheatsheetModal = () => {
+      this.setState({ isCheatsheetModalShown: false });
+    };
+
+    return (
+      <Modal className="modal-gfm-cheatsheet" show={this.state.isCheatsheetModalShown} onHide={() => { hideCheatsheetModal() }}>
+        <Modal.Header closeButton>
+          <Modal.Title><i className="icon-fw icon-question" />Markdown Help</Modal.Title>
+        </Modal.Header>
+        <Modal.Body className="pt-1">
+          <Cheatsheet />
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -282,6 +309,7 @@ export default class Editor extends AbstractEditor {
                         editorOptions={editorContainer.state.editorOptions}
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}
+                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
                         {...this.props}
                       />
                     )}
@@ -322,6 +350,9 @@ export default class Editor extends AbstractEditor {
           </button>
           )
         }
+
+        { this.renderCheatsheetModal() }
+
       </div>
     );
   }

+ 17 - 17
src/client/js/services/AppContainer.js

@@ -67,6 +67,7 @@ export default class AppContainer extends Container {
     this.fetchUsers = this.fetchUsers.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiPost = this.apiPost.bind(this);
+    this.apiDelete = this.apiDelete.bind(this);
     this.apiRequest = this.apiRequest.bind(this);
   }
 
@@ -278,11 +279,11 @@ export default class AppContainer extends Container {
     targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
   }
 
-  apiGet(path, params) {
+  async apiGet(path, params) {
     return this.apiRequest('get', path, { params });
   }
 
-  apiPost(path, params) {
+  async apiPost(path, params) {
     if (!params._csrf) {
       params._csrf = this.csrfToken;
     }
@@ -290,21 +291,20 @@ export default class AppContainer extends Container {
     return this.apiRequest('post', path, params);
   }
 
-  apiRequest(method, path, params) {
-    return new Promise((resolve, reject) => {
-      axios[method](`/_api${path}`, params)
-        .then((res) => {
-          if (res.data.ok) {
-            resolve(res.data);
-          }
-          else {
-            reject(new Error(res.data.error));
-          }
-        })
-        .catch((res) => {
-          reject(res);
-        });
-    });
+  async apiDelete(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiRequest('delete', path, { data: params });
+  }
+
+  async apiRequest(method, path, params) {
+    const res = await axios[method](`/_api${path}`, params);
+    if (res.data.ok) {
+      return res.data;
+    }
+    throw new Error(res.data.error);
   }
 
 }

+ 15 - 0
src/lib/util/toArrayIfNot.js

@@ -0,0 +1,15 @@
+// converts non-array item to array
+
+const toArrayIfNot = (item) => {
+  if (item == null) {
+    return [];
+  }
+
+  if (Array.isArray(item)) {
+    return item;
+  }
+
+  return [item];
+};
+
+module.exports = toArrayIfNot;

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

@@ -46,7 +46,9 @@ function Crowi(rootdir) {
   this.appService = null;
   this.fileUploadService = null;
   this.restQiitaAPIService = null;
+  this.growiBridgeService = null;
   this.exportService = null;
+  this.importService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.xss = new Xss();
@@ -86,10 +88,12 @@ Crowi.prototype.init = async function() {
   // customizeService depends on AppService and XssService
   // passportService depends on appService
   // slack depends on setUpSlacklNotification
+  // export and import depends on setUpGrowiBridge
   await Promise.all([
     this.setUpApp(),
     this.setUpXss(),
     this.setUpSlacklNotification(),
+    this.setUpGrowiBridge(),
   ]);
 
   await Promise.all([
@@ -105,6 +109,7 @@ Crowi.prototype.init = async function() {
     this.setUpRestQiitaAPI(),
     this.setupUserGroup(),
     this.setupExport(),
+    this.setupImport(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -124,6 +129,7 @@ Crowi.prototype.initForTest = async function() {
     this.setUpApp(),
     // this.setUpXss(),
     // this.setUpSlacklNotification(),
+    // this.setUpGrowiBridge(),
   ]);
 
   await Promise.all([
@@ -137,6 +143,9 @@ Crowi.prototype.initForTest = async function() {
     this.setUpAcl(),
   //   this.setUpCustomize(),
   //   this.setUpRestQiitaAPI(),
+  //   this.setupUserGroup(),
+  //   this.setupExport(),
+  //   this.setupImport(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -534,6 +543,13 @@ Crowi.prototype.setupUserGroup = async function() {
   }
 };
 
+Crowi.prototype.setUpGrowiBridge = async function() {
+  const GrowiBridgeService = require('../service/growi-bridge');
+  if (this.growiBridgeService == null) {
+    this.growiBridgeService = new GrowiBridgeService(this);
+  }
+};
+
 Crowi.prototype.setupExport = async function() {
   const ExportService = require('../service/export');
   if (this.exportService == null) {
@@ -541,4 +557,11 @@ Crowi.prototype.setupExport = async function() {
   }
 };
 
+Crowi.prototype.setupImport = async function() {
+  const ImportService = require('../service/import');
+  if (this.importService == null) {
+    this.importService = new ImportService(this);
+  }
+};
+
 module.exports = Crowi;

+ 27 - 0
src/server/middleware/access-token-parser.js

@@ -0,0 +1,27 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:access-token-parser');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
+    const accessToken = req.query.access_token || req.body.access_token || null;
+    if (!accessToken) {
+      return next();
+    }
+
+    const User = crowi.model('User');
+
+    logger.debug('accessToken is', accessToken);
+
+    const user = await User.findUserByApiToken(accessToken);
+    req.user = user;
+    req.skipCsrfVerify = true;
+
+    logger.debug('Access token parsed: skipCsrfVerify');
+
+    next();
+  };
+
+};

+ 24 - 0
src/server/middleware/admin-required.js

@@ -0,0 +1,24 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:admin-required');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+      if (req.user.admin) {
+        next();
+        return;
+      }
+
+      logger.warn('This user is not admin.');
+
+      return res.redirect('/');
+    }
+
+    logger.warn('This user has not logged in.');
+
+    return res.redirect('/login');
+  };
+
+};

+ 27 - 0
src/server/middleware/csrf.js

@@ -0,0 +1,27 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:csrf');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    const token = req.body._csrf || req.query._csrf || null;
+    const csrfKey = (req.session && req.session.id) || 'anon';
+
+    logger.debug('req.skipCsrfVerify', req.skipCsrfVerify);
+
+    if (req.skipCsrfVerify) {
+      logger.debug('csrf verify skipped');
+      return next();
+    }
+
+    if (crowi.getTokens().verify(csrfKey, token)) {
+      logger.debug('csrf successfully verified');
+      return next();
+    }
+
+    logger.warn('csrf verification failed. return 403', csrfKey, token);
+    return res.sendStatus(403);
+  };
+
+};

+ 49 - 0
src/server/middleware/login-required.js

@@ -0,0 +1,49 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:login-required');
+
+/**
+ * require login handler
+ *
+ * @param {boolean} isGuestAllowed whethere guest user is allowed (default false)
+ */
+module.exports = (crowi, isGuestAllowed = false) => {
+
+  return function(req, res, next) {
+
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    const User = crowi.model('User');
+
+    // check the user logged in
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+      if (req.user.status === User.STATUS_ACTIVE) {
+        // Active の人だけ先に進める
+        return next();
+      }
+      if (req.user.status === User.STATUS_REGISTERED) {
+        return res.redirect('/login/error/registered');
+      }
+      if (req.user.status === User.STATUS_SUSPENDED) {
+        return res.redirect('/login/error/suspended');
+      }
+      if (req.user.status === User.STATUS_INVITED) {
+        return res.redirect('/login/invited');
+      }
+    }
+
+    // is api path
+    const path = req.path || '';
+    if (path.match(/^\/_api\/.+$/)) {
+      return res.sendStatus(403);
+    }
+
+    req.session.jumpTo = req.originalUrl;
+    return res.redirect('/login');
+  };
+
+};

+ 16 - 0
src/server/routes/admin.js

@@ -18,6 +18,7 @@ module.exports = function(crowi, app) {
     aclService,
     slackNotificationService,
     customizeService,
+    exportService,
   } = crowi;
 
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
@@ -874,6 +875,21 @@ module.exports = function(crowi, app) {
     return res.render('admin/export');
   };
 
+  actions.export.download = (req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = exportService.getFile(fileName);
+      return res.download(zipFile);
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.json(ApiResponse.error());
+    }
+  };
+
   actions.api = {};
   actions.api.appSetting = async function(req, res) {
     const form = req.form.settingForm;

+ 81 - 58
src/server/routes/apiv3/export.js

@@ -1,7 +1,8 @@
 const loggerFactory = require('@alias/logger');
 
-const logger = loggerFactory('growi:routes:apiv3:export'); // eslint-disable-line no-unused-vars
+const logger = loggerFactory('growi:routes:apiv3:export');
 const path = require('path');
+const fs = require('fs');
 
 const express = require('express');
 
@@ -14,48 +15,84 @@ const router = express.Router();
  */
 
 module.exports = (crowi) => {
-  const { exportService } = crowi;
-  const { Page } = crowi.models;
+  const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
+  const loginRequired = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const { growiBridgeService, exportService } = crowi;
 
   /**
    * @swagger
    *
-   *  /export:
+   *  /export/status:
    *    get:
    *      tags: [Export]
-   *      description: get mongodb collections names and zip files for them
+   *      description: get properties of stored zip files for export
    *      responses:
    *        200:
-   *          description: export cache info
+   *          description: the zip file statuses
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  zipFileStats:
+   *                    type: array
+   *                    items:
+   *                      type: object
+   *                      description: the property of each file
    */
-  router.get('/', async(req, res) => {
-    const files = exportService.getStatus();
+  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    const zipFileStats = await exportService.getStatus();
 
     // TODO: use res.apiv3
-    return res.json({ ok: true, files });
+    return res.json({ ok: true, zipFileStats });
   });
 
   /**
    * @swagger
    *
-   *  /export/pages:
-   *    get:
+   *  /export:
+   *    post:
    *      tags: [Export]
-   *      description: download a zipped json for page collection
+   *      description: generate zipped jsons for collections
    *      responses:
    *        200:
-   *          description: a zip file
+   *          description: a zip file is generated
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  zipFileStat:
+   *                    type: object
+   *                    description: the property of the zip file
    */
-  router.get('/pages', async(req, res) => {
-    // TODO: rename path to "/:collection" and add express validator
+  router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    // TODO: add express validator
     try {
-      const file = exportService.getZipFile(Page);
+      const { collections } = req.body;
+      // get model for collection
+      const models = collections.map(collectionName => growiBridgeService.getModelFromCollectionName(collectionName));
 
-      if (file == null) {
-        throw new Error('the target file does not exist');
-      }
+      const [metaJson, jsonFiles] = await Promise.all([
+        exportService.createMetaJson(),
+        exportService.exportMultipleCollectionsToJsons(models),
+      ]);
 
-      return res.download(file);
+      // zip json
+      const configs = jsonFiles.map((jsonFile) => { return { from: jsonFile, as: path.basename(jsonFile) } });
+      // add meta.json in zip
+      configs.push({ from: metaJson, as: path.basename(metaJson) });
+      // exec zip
+      const zipFile = await exportService.zipFiles(configs);
+      // get stats for the zip file
+      const zipFileStat = await growiBridgeService.parseZipFile(zipFile);
+
+      // TODO: use res.apiv3
+      return res.status(200).json({
+        ok: true,
+        zipFileStat,
+      });
     }
     catch (err) {
       // TODO: use ApiV3Error
@@ -67,56 +104,42 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *  /export/pages:
-   *    post:
+   *  /export/{fileName}:
+   *    delete:
    *      tags: [Export]
-   *      description: generate a zipped json for page collection
+   *      description: delete the file
+   *      parameters:
+   *        - name: fileName
+   *          in: path
+   *          description: the file name of zip file
+   *          required: true
+   *          schema:
+   *            type: string
    *      responses:
    *        200:
-   *          description: a zip file is generated
+   *          description: the file is deleted
+   *          content:
+   *            application/json:
+   *              schema:
+   *                type: object
    */
-  router.post('/pages', async(req, res) => {
-    // TODO: rename path to "/:collection" and add express validator
+  router.delete('/:fileName', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
     try {
-      const file = await exportService.exportCollection(Page);
+      const zipFile = exportService.getFile(fileName);
+      fs.unlinkSync(zipFile);
+
       // TODO: use res.apiv3
-      return res.status(200).json({
-        ok: true,
-        collection: [Page.collection.collectionName],
-        file: path.basename(file),
-      });
+      return res.status(200).send({ ok: true });
     }
     catch (err) {
       // TODO: use ApiV3Error
       logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
+      return res.status(500).send({ ok: false });
     }
   });
 
-  /**
-   * @swagger
-   *
-   *  /export/pages:
-   *    delete:
-   *      tags: [Export]
-   *      description: unlink a json and zip file for page collection
-   *      responses:
-   *        200:
-   *          description: the json and zip file are removed
-   */
-  // router.delete('/pages', async(req, res) => {
-  //   // TODO: rename path to "/:collection" and add express validator
-  //   try {
-  //     // remove .json and .zip for collection
-  //     // TODO: use res.apiv3
-  //     return res.status(200).send({ status: 'DONE' });
-  //   }
-  //   catch (err) {
-  //     // TODO: use ApiV3Error
-  //     logger.error(err);
-  //     return res.status(500).send({ status: 'ERROR' });
-  //   }
-  // });
-
   return router;
 };

+ 250 - 0
src/server/routes/apiv3/import.js

@@ -0,0 +1,250 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
+
+const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
+
+const { ObjectId } = require('mongoose').Types;
+
+const express = require('express');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Import
+ */
+
+module.exports = (crowi) => {
+  const { growiBridgeService, importService } = crowi;
+  const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
+  const loginRequired = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const uploads = multer({
+    storage: multer.diskStorage({
+      destination: (req, file, cb) => {
+        cb(null, importService.baseDir);
+      },
+      filename(req, file, cb) {
+        // to prevent hashing the file name. files with same name will be overwritten.
+        cb(null, file.originalname);
+      },
+    }),
+    fileFilter: (req, file, cb) => {
+      if (path.extname(file.originalname) === '.zip') {
+        return cb(null, true);
+      }
+      cb(new Error('Only ".zip" is allowed'));
+    },
+  });
+
+  /**
+   * defined overwrite params for each collection
+   * all imported documents are overwriten by this value
+   * each value can be any value or a function (_value, { _document, key, schema }) { return newValue }
+   *
+   * @param {object} Model instance of mongoose model
+   * @param {object} req request object
+   * @return {object} document to be persisted
+   */
+  const overwriteParamsFn = async(Model, schema, req) => {
+    const { collectionName } = Model.collection;
+
+    /* eslint-disable no-case-declarations */
+    switch (Model.collection.collectionName) {
+      case 'pages':
+        // TODO: use schema and req to generate overwriteParams
+        // e.g. { creator: schema.creator === 'me' ? ObjectId(req.user._id) : importService.keepOriginal }
+        return {
+          status: 'published', // FIXME when importing users and user groups
+          grant: 1, // FIXME when importing users and user groups
+          grantedUsers: [], // FIXME when importing users and user groups
+          grantedGroup: null, // FIXME when importing users and user groups
+          creator: ObjectId(req.user._id), // FIXME when importing users
+          lastUpdateUser: ObjectId(req.user._id), // FIXME when importing users
+          liker: [], // FIXME when importing users
+          seenUsers: [], // FIXME when importing users
+          commentCount: 0, // FIXME when importing comments
+          extended: {}, // FIXME when ?
+          pageIdOnHackmd: undefined, // FIXME when importing hackmd?
+          revisionHackmdSynced: undefined, // FIXME when importing hackmd?
+          hasDraftOnHackmd: undefined, // FIXME when importing hackmd?
+        };
+      // case 'revisoins':
+      //   return {};
+      // case 'users':
+      //   return {};
+      // ... add more cases
+      default:
+        throw new Error(`cannot find a model for collection name "${collectionName}"`);
+    }
+    /* eslint-enable no-case-declarations */
+  };
+
+  /**
+   * @swagger
+   *
+   *  /import:
+   *    post:
+   *      tags: [Import]
+   *      description: import a collection from a zipped json
+   *      responses:
+   *        200:
+   *          description: the data is successfully imported
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  results:
+   *                    type: array
+   *                    items:
+   *                      type: object
+   *                      description: collectionName, insertedIds, failedIds
+   */
+  router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    // TODO: add express validator
+
+    const { fileName, collections, schema } = req.body;
+    const zipFile = importService.getFile(fileName);
+
+    // unzip
+    await importService.unzip(zipFile);
+    // eslint-disable-next-line no-unused-vars
+    const { meta, fileStats } = await growiBridgeService.parseZipFile(zipFile);
+
+    // delete zip file after unzipping and parsing it
+    fs.unlinkSync(zipFile);
+
+    // filter fileStats
+    const filteredFileStats = fileStats.filter(({ fileName, collectionName, size }) => { return collections.includes(collectionName) });
+
+    try {
+      // validate with meta.json
+      importService.validate(meta);
+
+      const results = await Promise.all(filteredFileStats.map(async({ fileName, collectionName, size }) => {
+        const Model = growiBridgeService.getModelFromCollectionName(collectionName);
+        const jsonFile = importService.getFile(fileName);
+
+        let overwriteParams;
+        if (overwriteParamsFn[collectionName] != null) {
+          // await in case overwriteParamsFn[collection] is a Promise
+          overwriteParams = await overwriteParamsFn(Model, schema[collectionName], req);
+        }
+
+        const { insertedIds, failedIds } = await importService.import(Model, jsonFile, overwriteParams);
+
+        return {
+          collectionName,
+          insertedIds,
+          failedIds,
+        };
+      }));
+
+      // TODO: use res.apiv3
+      return res.send({ ok: true, results });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  /import/upload:
+   *    post:
+   *      tags: [Import]
+   *      description: upload a zip file
+   *      responses:
+   *        200:
+   *          description: the file is uploaded
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  meta:
+   *                    type: object
+   *                    description: the meta data of the uploaded file
+   *                  fileName:
+   *                    type: string
+   *                    description: the base name of the uploaded file
+   *                  fileStats:
+   *                    type: array
+   *                    items:
+   *                      type: object
+   *                      description: the property of each extracted file
+   */
+  router.post('/upload', uploads.single('file'), accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    const { file } = req;
+    const zipFile = importService.getFile(file.filename);
+
+    try {
+      const data = await growiBridgeService.parseZipFile(zipFile);
+
+      // validate with meta.json
+      importService.validate(data.meta);
+
+      // TODO: use res.apiv3
+      return res.send({
+        ok: true,
+        data,
+      });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  /import/{fileName}:
+   *    post:
+   *      tags: [Import]
+   *      description: delete a zip file
+   *      parameters:
+   *        - name: fileName
+   *          in: path
+   *          description: the file name of zip file
+   *          required: true
+   *          schema:
+   *            type: string
+   *      responses:
+   *        200:
+   *          description: the file is deleted
+   *          content:
+   *            application/json:
+   *              schema:
+   *                type: object
+   */
+  router.delete('/:fileName', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = importService.getFile(fileName);
+      fs.unlinkSync(zipFile);
+
+      // TODO: use res.apiv3
+      return res.send({
+        ok: true,
+      });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
+  return router;
+};

+ 4 - 0
src/server/routes/apiv3/index.js

@@ -9,7 +9,11 @@ const router = express.Router();
 module.exports = (crowi) => {
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
+  router.use('/mongo', require('./mongo')(crowi));
+
   router.use('/export', require('./export')(crowi));
 
+  router.use('/import', require('./import')(crowi));
+
   return router;
 };

+ 46 - 0
src/server/routes/apiv3/mongo.js

@@ -0,0 +1,46 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:mongo'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Mongo
+ */
+
+module.exports = (crowi) => {
+  /**
+   * @swagger
+   *
+   *  /mongo/collections:
+   *    get:
+   *      tags: [Mongo]
+   *      description: get mongodb collections names
+   *      responses:
+   *        200:
+   *          description: a list of collections in mongoDB
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  collections:
+   *                    type: array
+   *                    items:
+   *                      type: string
+   */
+  router.get('/collections', async(req, res) => {
+    const collections = Object.values(crowi.models).map(model => model.collection.collectionName);
+
+    // TODO: use res.apiv3
+    return res.json({
+      ok: true,
+      collections: [...new Set(collections)], // remove duplicates
+    });
+  });
+
+  return router;
+};

+ 150 - 149
src/server/routes/index.js

@@ -5,6 +5,12 @@ autoReap.options.reapOnError = true; // continue reaping the file even if an err
 
 module.exports = function(crowi, app) {
   const middlewares = require('../util/middlewares')(crowi, app);
+  const accessTokenParser = require('../middleware/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../middleware/login-required')(crowi);
+  const loginRequired = require('../middleware/login-required')(crowi, true);
+  const adminRequired = require('../middleware/admin-required')(crowi);
+  const csrf = require('../middleware/csrf')(crowi);
+
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
   const page = require('./page')(crowi, app);
@@ -22,18 +28,12 @@ module.exports = function(crowi, app) {
   const revision = require('./revision')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
-  const {
-    loginRequired,
-    adminRequired,
-    accessTokenParser,
-    csrfVerify: csrf,
-  } = middlewares;
 
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  app.get('/'                        , middlewares.applicationInstalled, loginRequired(false) , page.showTopPage);
+  app.get('/'                        , middlewares.applicationInstalled, loginRequired , page.showTopPage);
 
   // API v3
   app.use('/api-docs', require('./apiv3/docs')(crowi));
@@ -51,33 +51,33 @@ module.exports = function(crowi, app) {
   app.get('/login/invited'           , login.invited);
   app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
   app.post('/login'                  , form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
-  app.post('/_api/login/testLdap'    , loginRequired() , form.login , loginPassport.testLdapCredentials);
+  app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
 
   app.post('/register'               , form.register                        , csrf, login.register);
   app.get('/register'                , middlewares.applicationInstalled    , login.register);
   app.get('/logout'                  , logout.logout);
 
-  app.get('/admin'                          , loginRequired() , adminRequired , admin.index);
-  app.get('/admin/app'                      , loginRequired() , adminRequired , admin.app.index);
-  app.post('/_api/admin/settings/app'       , loginRequired() , adminRequired , csrf, form.admin.app, admin.api.appSetting);
-  app.post('/_api/admin/settings/siteUrl'   , loginRequired() , adminRequired , csrf, form.admin.siteUrl, admin.api.asyncAppSetting);
-  app.post('/_api/admin/settings/mail'      , loginRequired() , adminRequired , csrf, form.admin.mail, admin.api.appSetting);
-  app.post('/_api/admin/settings/aws'       , loginRequired() , adminRequired , csrf, form.admin.aws, admin.api.appSetting);
-  app.post('/_api/admin/settings/plugin'    , loginRequired() , adminRequired , csrf, form.admin.plugin, admin.api.appSetting);
+  app.get('/admin'                          , loginRequiredStrictly , adminRequired , admin.index);
+  app.get('/admin/app'                      , loginRequiredStrictly , adminRequired , admin.app.index);
+  app.post('/_api/admin/settings/app'       , loginRequiredStrictly , adminRequired , csrf, form.admin.app, admin.api.appSetting);
+  app.post('/_api/admin/settings/siteUrl'   , loginRequiredStrictly , adminRequired , csrf, form.admin.siteUrl, admin.api.asyncAppSetting);
+  app.post('/_api/admin/settings/mail'      , loginRequiredStrictly , adminRequired , csrf, form.admin.mail, admin.api.appSetting);
+  app.post('/_api/admin/settings/aws'       , loginRequiredStrictly , adminRequired , csrf, form.admin.aws, admin.api.appSetting);
+  app.post('/_api/admin/settings/plugin'    , loginRequiredStrictly , adminRequired , csrf, form.admin.plugin, admin.api.appSetting);
 
   // security admin
-  app.get('/admin/security'                     , loginRequired() , adminRequired , admin.security.index);
-  app.post('/_api/admin/security/general'       , loginRequired() , adminRequired , form.admin.securityGeneral, admin.api.securitySetting);
-  app.post('/_api/admin/security/passport-local', loginRequired() , adminRequired , csrf, form.admin.securityPassportLocal, admin.api.securityPassportLocalSetting);
-  app.post('/_api/admin/security/passport-ldap' , loginRequired() , adminRequired , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
-  app.post('/_api/admin/security/passport-saml' , loginRequired() , adminRequired , csrf, form.admin.securityPassportSaml, admin.api.securityPassportSamlSetting);
-  app.post('/_api/admin/security/passport-basic' , loginRequired() , adminRequired , csrf, form.admin.securityPassportBasic, admin.api.securityPassportBasicSetting);
+  app.get('/admin/security'                     , loginRequiredStrictly , adminRequired , admin.security.index);
+  app.post('/_api/admin/security/general'       , loginRequiredStrictly , adminRequired , form.admin.securityGeneral, admin.api.securitySetting);
+  app.post('/_api/admin/security/passport-local', loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportLocal, admin.api.securityPassportLocalSetting);
+  app.post('/_api/admin/security/passport-ldap' , loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
+  app.post('/_api/admin/security/passport-saml' , loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportSaml, admin.api.securityPassportSamlSetting);
+  app.post('/_api/admin/security/passport-basic', loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportBasic, admin.api.securityPassportBasicSetting);
 
   // OAuth
-  app.post('/_api/admin/security/passport-google' , loginRequired() , adminRequired , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
-  app.post('/_api/admin/security/passport-github' , loginRequired() , adminRequired , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
-  app.post('/_api/admin/security/passport-twitter', loginRequired() , adminRequired , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
-  app.post('/_api/admin/security/passport-oidc',    loginRequired() , adminRequired , csrf, form.admin.securityPassportOidc, admin.api.securityPassportOidcSetting);
+  app.post('/_api/admin/security/passport-google' , loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
+  app.post('/_api/admin/security/passport-github' , loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
+  app.post('/_api/admin/security/passport-twitter', loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
+  app.post('/_api/admin/security/passport-oidc',    loginRequiredStrictly , adminRequired , csrf, form.admin.securityPassportOidc, admin.api.securityPassportOidcSetting);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
@@ -91,158 +91,159 @@ module.exports = function(crowi, app) {
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback);
 
   // markdown admin
-  app.get('/admin/markdown'                   , loginRequired() , adminRequired , admin.markdown.index);
-  app.post('/admin/markdown/lineBreaksSetting', loginRequired() , adminRequired , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting); // change form name
-  app.post('/admin/markdown/xss-setting'      , loginRequired() , adminRequired , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
-  app.post('/admin/markdown/presentationSetting', loginRequired() , adminRequired , csrf, form.admin.markdownPresentation, admin.markdown.presentationSetting);
+  app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index);
+  app.post('/admin/markdown/lineBreaksSetting', loginRequiredStrictly , adminRequired , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting); // change form name
+  app.post('/admin/markdown/xss-setting'      , loginRequiredStrictly , adminRequired , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
+  app.post('/admin/markdown/presentationSetting', loginRequiredStrictly , adminRequired , csrf, form.admin.markdownPresentation, admin.markdown.presentationSetting);
 
   // markdown admin
-  app.get('/admin/customize'                , loginRequired() , adminRequired , admin.customize.index);
-  app.post('/_api/admin/customize/css'      , loginRequired() , adminRequired , csrf, form.admin.customcss, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/script'   , loginRequired() , adminRequired , csrf, form.admin.customscript, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/header'   , loginRequired() , adminRequired , csrf, form.admin.customheader, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/theme'    , loginRequired() , adminRequired , csrf, form.admin.customtheme, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/title'    , loginRequired() , adminRequired , csrf, form.admin.customtitle, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/behavior' , loginRequired() , adminRequired , csrf, form.admin.custombehavior, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/layout'   , loginRequired() , adminRequired , csrf, form.admin.customlayout, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/features' , loginRequired() , adminRequired , csrf, form.admin.customfeatures, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/highlightJsStyle' , loginRequired() , adminRequired , csrf, form.admin.customhighlightJsStyle, admin.api.customizeSetting);
+  app.get('/admin/customize'                , loginRequiredStrictly , adminRequired , admin.customize.index);
+  app.post('/_api/admin/customize/css'      , loginRequiredStrictly , adminRequired , csrf, form.admin.customcss, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/script'   , loginRequiredStrictly , adminRequired , csrf, form.admin.customscript, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/header'   , loginRequiredStrictly , adminRequired , csrf, form.admin.customheader, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/theme'    , loginRequiredStrictly , adminRequired , csrf, form.admin.customtheme, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/title'    , loginRequiredStrictly , adminRequired , csrf, form.admin.customtitle, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/behavior' , loginRequiredStrictly , adminRequired , csrf, form.admin.custombehavior, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/layout'   , loginRequiredStrictly , adminRequired , csrf, form.admin.customlayout, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/features' , loginRequiredStrictly , adminRequired , csrf, form.admin.customfeatures, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/highlightJsStyle' , loginRequiredStrictly , adminRequired , csrf, form.admin.customhighlightJsStyle, admin.api.customizeSetting);
 
   // search admin
-  app.get('/admin/search'              , loginRequired() , adminRequired , admin.search.index);
-  app.post('/_api/admin/search/build'  , loginRequired() , adminRequired , csrf, admin.api.searchBuildIndex);
+  app.get('/admin/search'              , loginRequiredStrictly , adminRequired , admin.search.index);
+  app.post('/_api/admin/search/build'  , loginRequiredStrictly , adminRequired , csrf, admin.api.searchBuildIndex);
 
   // notification admin
-  app.get('/admin/notification'              , loginRequired() , adminRequired , admin.notification.index);
-  app.post('/admin/notification/slackIwhSetting', loginRequired() , adminRequired , csrf, form.admin.slackIwhSetting, admin.notification.slackIwhSetting);
-  app.post('/admin/notification/slackSetting', loginRequired() , adminRequired , csrf, form.admin.slackSetting, admin.notification.slackSetting);
-  app.get('/admin/notification/slackAuth'    , loginRequired() , adminRequired , admin.notification.slackAuth);
-  app.get('/admin/notification/slackSetting/disconnect', loginRequired() , adminRequired , admin.notification.disconnectFromSlack);
-  app.post('/_api/admin/notification.add'    , loginRequired() , adminRequired , csrf, admin.api.notificationAdd);
-  app.post('/_api/admin/notification.remove' , loginRequired() , adminRequired , csrf, admin.api.notificationRemove);
-  app.get('/_api/admin/users.search'         , loginRequired() , adminRequired , admin.api.usersSearch);
-  app.get('/admin/global-notification/new'   , loginRequired() , adminRequired , admin.globalNotification.detail);
-  app.get('/admin/global-notification/:id'   , loginRequired() , adminRequired , admin.globalNotification.detail);
-  app.post('/admin/global-notification/new'  , loginRequired() , adminRequired , form.admin.notificationGlobal, admin.globalNotification.create);
-  app.post('/_api/admin/global-notification/toggleIsEnabled', loginRequired() , adminRequired , admin.api.toggleIsEnabledForGlobalNotification);
-  app.post('/admin/global-notification/:id/update', loginRequired() , adminRequired , form.admin.notificationGlobal, admin.globalNotification.update);
-  app.post('/admin/global-notification/:id/remove', loginRequired() , adminRequired , admin.globalNotification.remove);
-
-  app.get('/admin/users'                , loginRequired() , adminRequired , admin.user.index);
-  app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired() , adminRequired , csrf, admin.user.invite);
-  app.post('/admin/user/:id/makeAdmin'  , loginRequired() , adminRequired , csrf, admin.user.makeAdmin);
-  app.post('/admin/user/:id/removeFromAdmin', loginRequired() , adminRequired , admin.user.removeFromAdmin);
-  app.post('/admin/user/:id/activate'   , loginRequired() , adminRequired , csrf, admin.user.activate);
-  app.post('/admin/user/:id/suspend'    , loginRequired() , adminRequired , csrf, admin.user.suspend);
-  app.post('/admin/user/:id/remove'     , loginRequired() , adminRequired , csrf, admin.user.remove);
-  app.post('/admin/user/:id/removeCompletely' , loginRequired() , adminRequired , csrf, admin.user.removeCompletely);
+  app.get('/admin/notification'              , loginRequiredStrictly , adminRequired , admin.notification.index);
+  app.post('/admin/notification/slackIwhSetting', loginRequiredStrictly , adminRequired , csrf, form.admin.slackIwhSetting, admin.notification.slackIwhSetting);
+  app.post('/admin/notification/slackSetting', loginRequiredStrictly , adminRequired , csrf, form.admin.slackSetting, admin.notification.slackSetting);
+  app.get('/admin/notification/slackAuth'    , loginRequiredStrictly , adminRequired , admin.notification.slackAuth);
+  app.get('/admin/notification/slackSetting/disconnect', loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
+  app.post('/_api/admin/notification.add'    , loginRequiredStrictly , adminRequired , csrf, admin.api.notificationAdd);
+  app.post('/_api/admin/notification.remove' , loginRequiredStrictly , adminRequired , csrf, admin.api.notificationRemove);
+  app.get('/_api/admin/users.search'         , loginRequiredStrictly , adminRequired , admin.api.usersSearch);
+  app.get('/admin/global-notification/new'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
+  app.get('/admin/global-notification/:id'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
+  app.post('/admin/global-notification/new'  , loginRequiredStrictly , adminRequired , form.admin.notificationGlobal, admin.globalNotification.create);
+  app.post('/_api/admin/global-notification/toggleIsEnabled', loginRequiredStrictly , adminRequired , admin.api.toggleIsEnabledForGlobalNotification);
+  app.post('/admin/global-notification/:id/update', loginRequiredStrictly , adminRequired , form.admin.notificationGlobal, admin.globalNotification.update);
+  app.post('/admin/global-notification/:id/remove', loginRequiredStrictly , adminRequired , admin.globalNotification.remove);
+
+  app.get('/admin/users'                , loginRequiredStrictly , adminRequired , admin.user.index);
+  app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequiredStrictly , adminRequired , csrf, admin.user.invite);
+  app.post('/admin/user/:id/makeAdmin'  , loginRequiredStrictly , adminRequired , csrf, admin.user.makeAdmin);
+  app.post('/admin/user/:id/removeFromAdmin', loginRequiredStrictly , adminRequired , admin.user.removeFromAdmin);
+  app.post('/admin/user/:id/activate'   , loginRequiredStrictly , adminRequired , csrf, admin.user.activate);
+  app.post('/admin/user/:id/suspend'    , loginRequiredStrictly , adminRequired , csrf, admin.user.suspend);
+  app.post('/admin/user/:id/remove'     , loginRequiredStrictly , adminRequired , csrf, admin.user.remove);
+  app.post('/admin/user/:id/removeCompletely' , loginRequiredStrictly , adminRequired , csrf, admin.user.removeCompletely);
   // new route patterns from here:
-  app.post('/_api/admin/users.resetPassword'  , loginRequired() , adminRequired , csrf, admin.user.resetPassword);
+  app.post('/_api/admin/users.resetPassword'  , loginRequiredStrictly , adminRequired , csrf, admin.user.resetPassword);
 
-  app.get('/admin/users/external-accounts'               , loginRequired() , adminRequired , admin.externalAccount.index);
-  app.post('/admin/users/external-accounts/:id/remove'   , loginRequired() , adminRequired , admin.externalAccount.remove);
+  app.get('/admin/users/external-accounts'               , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
+  app.post('/admin/users/external-accounts/:id/remove'   , loginRequiredStrictly , adminRequired , admin.externalAccount.remove);
 
   // user-groups admin
-  app.get('/admin/user-groups'             , loginRequired(), adminRequired, admin.userGroup.index);
-  app.get('/admin/user-group-detail/:id'          , loginRequired(), adminRequired, admin.userGroup.detail);
-  app.post('/admin/user-group/create'      , form.admin.userGroupCreate, loginRequired(), adminRequired, csrf, admin.userGroup.create);
-  app.post('/admin/user-group/:userGroupId/update', loginRequired(), adminRequired, csrf, admin.userGroup.update);
-  app.post('/admin/user-group.remove' , loginRequired(), adminRequired, csrf, admin.userGroup.removeCompletely);
-  app.get('/_api/admin/user-groups', loginRequired(), adminRequired, admin.api.userGroups);
+  app.get('/admin/user-groups'             , loginRequiredStrictly, adminRequired, admin.userGroup.index);
+  app.get('/admin/user-group-detail/:id'          , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
+  app.post('/admin/user-group/create'      , form.admin.userGroupCreate, loginRequiredStrictly, adminRequired, csrf, admin.userGroup.create);
+  app.post('/admin/user-group/:userGroupId/update', loginRequiredStrictly, adminRequired, csrf, admin.userGroup.update);
+  app.post('/admin/user-group.remove' , loginRequiredStrictly, adminRequired, csrf, admin.userGroup.removeCompletely);
+  app.get('/_api/admin/user-groups', loginRequiredStrictly, adminRequired, admin.api.userGroups);
 
   // user-group-relations admin
-  app.post('/admin/user-group-relation/create', loginRequired(), adminRequired, csrf, admin.userGroupRelation.create);
-  app.post('/admin/user-group-relation/:id/remove-relation/:relationId', loginRequired(), adminRequired, csrf, admin.userGroupRelation.remove);
+  app.post('/admin/user-group-relation/create', loginRequiredStrictly, adminRequired, csrf, admin.userGroupRelation.create);
+  app.post('/admin/user-group-relation/:id/remove-relation/:relationId', loginRequiredStrictly, adminRequired, csrf, admin.userGroupRelation.remove);
 
   // importer management for admin
-  app.get('/admin/importer'                , loginRequired() , adminRequired , admin.importer.index);
-  app.post('/_api/admin/settings/importerEsa' , loginRequired() , adminRequired , csrf , form.admin.importerEsa , admin.api.importerSettingEsa);
-  app.post('/_api/admin/settings/importerQiita' , loginRequired() , adminRequired , csrf , form.admin.importerQiita , admin.api.importerSettingQiita);
-  app.post('/_api/admin/import/esa'        , loginRequired() , adminRequired , admin.api.importDataFromEsa);
-  app.post('/_api/admin/import/testEsaAPI' , loginRequired() , adminRequired , csrf , form.admin.importerEsa , admin.api.testEsaAPI);
-  app.post('/_api/admin/import/qiita'        , loginRequired() , adminRequired , admin.api.importDataFromQiita);
-  app.post('/_api/admin/import/testQiitaAPI' , loginRequired() , adminRequired , csrf , form.admin.importerQiita , admin.api.testQiitaAPI);
+  app.get('/admin/importer'                , loginRequiredStrictly , adminRequired , admin.importer.index);
+  app.post('/_api/admin/settings/importerEsa' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerEsa , admin.api.importerSettingEsa);
+  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerQiita , admin.api.importerSettingQiita);
+  app.post('/_api/admin/import/esa'        , loginRequiredStrictly , adminRequired , admin.api.importDataFromEsa);
+  app.post('/_api/admin/import/testEsaAPI' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerEsa , admin.api.testEsaAPI);
+  app.post('/_api/admin/import/qiita'        , loginRequiredStrictly , adminRequired , admin.api.importDataFromQiita);
+  app.post('/_api/admin/import/testQiitaAPI' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerQiita , admin.api.testQiitaAPI);
 
   // export management for admin
-  app.get('/admin/export' , loginRequired() , adminRequired ,admin.export.index);
+  app.get('/admin/export' , loginRequiredStrictly , adminRequired ,admin.export.index);
+  app.get('/admin/export/:fileName' , loginRequiredStrictly , adminRequired ,admin.export.download);
 
-  app.get('/me'                       , loginRequired() , me.index);
-  app.get('/me/password'              , loginRequired() , me.password);
-  app.get('/me/apiToken'              , loginRequired() , me.apiToken);
-  app.post('/me'                      , loginRequired() , csrf , form.me.user , me.index);
+  app.get('/me'                       , loginRequiredStrictly , me.index);
+  app.get('/me/password'              , loginRequiredStrictly , me.password);
+  app.get('/me/apiToken'              , loginRequiredStrictly , me.apiToken);
+  app.post('/me'                      , loginRequiredStrictly , csrf , form.me.user , me.index);
   // external-accounts
-  app.get('/me/external-accounts'                         , loginRequired() , me.externalAccounts.list);
-  app.post('/me/external-accounts/disassociate'           , loginRequired() , me.externalAccounts.disassociate);
-  app.post('/me/external-accounts/associateLdap'          , loginRequired() , form.login , me.externalAccounts.associateLdap);
+  app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
+  app.post('/me/external-accounts/disassociate'           , loginRequiredStrictly , me.externalAccounts.disassociate);
+  app.post('/me/external-accounts/associateLdap'          , loginRequiredStrictly , form.login , me.externalAccounts.associateLdap);
 
-  app.post('/me/password'             , form.me.password          , loginRequired() , me.password);
-  app.post('/me/imagetype'            , form.me.imagetype         , loginRequired() , me.imagetype);
-  app.post('/me/apiToken'             , form.me.apiToken          , loginRequired() , me.apiToken);
+  app.post('/me/password'             , form.me.password          , loginRequiredStrictly , me.password);
+  app.post('/me/imagetype'            , form.me.imagetype         , loginRequiredStrictly , me.imagetype);
+  app.post('/me/apiToken'             , form.me.apiToken          , loginRequiredStrictly , me.apiToken);
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired(false) , page.redirector);
-  app.get('/_r/:id([0-9a-z]{24})'    , loginRequired(false) , page.redirector); // alias
-  app.get('/attachment/:pageId/:fileName'  , loginRequired(false), attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
-  app.get('/attachment/:id([0-9a-z]{24})'  , loginRequired(false), attachment.api.get);
-  app.get('/download/:id([0-9a-z]{24})'    , loginRequired(false), attachment.api.download);
+  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
+  app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
+  app.get('/attachment/:pageId/:fileName'  , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
+  app.get('/attachment/:id([0-9a-z]{24})'  , loginRequired, attachment.api.get);
+  app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
 
-  app.get('/_search'                 , loginRequired(false) , search.searchPage);
-  app.get('/_api/search'             , accessTokenParser , loginRequired(false) , search.api.search);
+  app.get('/_search'                 , loginRequired , search.searchPage);
+  app.get('/_api/search'             , accessTokenParser , loginRequired , search.api.search);
 
   app.get('/_api/check_username'           , user.api.checkUsername);
-  app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequired() , me.api.userGroupRelations);
-  app.get('/_api/user/bookmarks'           , loginRequired(false) , user.api.bookmarks);
+  app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
+  app.get('/_api/user/bookmarks'           , loginRequired , user.api.bookmarks);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  app.get('/_api/users.list'          , accessTokenParser , loginRequired(false) , user.api.list);
-  app.get('/_api/pages.list'          , accessTokenParser , loginRequired(false) , page.api.list);
-  app.get('/_api/pages.recentCreated' , accessTokenParser , loginRequired(false) , page.api.recentCreated);
-  app.post('/_api/pages.create'       , accessTokenParser , loginRequired() , csrf, page.api.create);
-  app.post('/_api/pages.update'       , accessTokenParser , loginRequired() , csrf, page.api.update);
-  app.get('/_api/pages.get'           , accessTokenParser , loginRequired(false) , page.api.get);
-  app.get('/_api/pages.exist'         , accessTokenParser , loginRequired(false) , page.api.exist);
-  app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired(false), page.api.getUpdatePost);
-  app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired(false) , page.api.getPageTag);
+  app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
+  app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
+  app.get('/_api/pages.recentCreated' , accessTokenParser , loginRequired , page.api.recentCreated);
+  app.post('/_api/pages.create'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.create);
+  app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
+  app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);
+  app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
+  app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
+  app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  app.post('/_api/pages.seen'         , accessTokenParser , loginRequired(false) , page.api.seen);
-  app.post('/_api/pages.rename'       , accessTokenParser , loginRequired() , csrf, page.api.rename);
-  app.post('/_api/pages.remove'       , loginRequired() , csrf, page.api.remove); // (Avoid from API Token)
-  app.post('/_api/pages.revertRemove' , loginRequired() , csrf, page.api.revertRemove); // (Avoid from API Token)
-  app.post('/_api/pages.unlink'       , loginRequired() , csrf, page.api.unlink); // (Avoid from API Token)
-  app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequired(), csrf, page.api.duplicate);
-  app.get('/tags'                     , loginRequired(false), tag.showPage);
-  app.get('/_api/tags.list'           , accessTokenParser, loginRequired(false), tag.api.list);
-  app.get('/_api/tags.search'         , accessTokenParser, loginRequired(false), tag.api.search);
-  app.post('/_api/tags.update'        , accessTokenParser, loginRequired(false), tag.api.update);
-  app.get('/_api/comments.get'        , accessTokenParser , loginRequired(false) , comment.api.get);
-  app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequired() , csrf, comment.api.add);
-  app.post('/_api/comments.update'       , comment.api.validators.add(), accessTokenParser , loginRequired() , csrf, comment.api.update);
-  app.post('/_api/comments.remove'    , accessTokenParser , loginRequired() , csrf, comment.api.remove);
-  app.get('/_api/bookmarks.get'       , accessTokenParser , loginRequired(false) , bookmark.api.get);
-  app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired() , csrf, bookmark.api.add);
-  app.post('/_api/bookmarks.remove'   , accessTokenParser , loginRequired() , csrf, bookmark.api.remove);
-  app.post('/_api/likes.add'          , accessTokenParser , loginRequired() , csrf, page.api.like);
-  app.post('/_api/likes.remove'       , accessTokenParser , loginRequired() , csrf, page.api.unlike);
-  app.get('/_api/attachments.list'    , accessTokenParser , loginRequired(false) , attachment.api.list);
-  app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequired() ,csrf, attachment.api.add);
-  app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequired() ,csrf, attachment.api.uploadProfileImage);
-  app.post('/_api/attachments.remove'               , accessTokenParser , loginRequired() , csrf, attachment.api.remove);
-  app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequired() , csrf, attachment.api.removeProfileImage);
-  app.get('/_api/attachments.limit'   , accessTokenParser , loginRequired(), attachment.api.limit);
-
-  app.get('/_api/revisions.get'       , accessTokenParser , loginRequired(false) , revision.api.get);
-  app.get('/_api/revisions.ids'       , accessTokenParser , loginRequired(false) , revision.api.ids);
-  app.get('/_api/revisions.list'      , accessTokenParser , loginRequired(false) , revision.api.list);
-
-  app.get('/trash$'                   , loginRequired(false) , page.trashPageShowWrapper);
-  app.get('/trash/$'                  , loginRequired(false) , page.trashPageListShowWrapper);
-  app.get('/trash/*/$'                , loginRequired(false) , page.deletedPageListShowWrapper);
+  app.post('/_api/pages.seen'         , accessTokenParser , loginRequired , page.api.seen);
+  app.post('/_api/pages.rename'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.rename);
+  app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.api.remove); // (Avoid from API Token)
+  app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.api.revertRemove); // (Avoid from API Token)
+  app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
+  app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
+  app.get('/tags'                     , loginRequired, tag.showPage);
+  app.get('/_api/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
+  app.get('/_api/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
+  app.post('/_api/tags.update'        , accessTokenParser, loginRequired, tag.api.update);
+  app.get('/_api/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
+  app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
+  app.post('/_api/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);
+  app.post('/_api/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, comment.api.remove);
+  app.get('/_api/bookmarks.get'       , accessTokenParser , loginRequired , bookmark.api.get);
+  app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequiredStrictly , csrf, bookmark.api.add);
+  app.post('/_api/bookmarks.remove'   , accessTokenParser , loginRequiredStrictly , csrf, bookmark.api.remove);
+  app.post('/_api/likes.add'          , accessTokenParser , loginRequiredStrictly , csrf, page.api.like);
+  app.post('/_api/likes.remove'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.unlike);
+  app.get('/_api/attachments.list'    , accessTokenParser , loginRequired , attachment.api.list);
+  app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
+  app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
+  app.post('/_api/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);
+  app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
+  app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
+
+  app.get('/_api/revisions.get'       , accessTokenParser , loginRequired , revision.api.get);
+  app.get('/_api/revisions.ids'       , accessTokenParser , loginRequired , revision.api.ids);
+  app.get('/_api/revisions.list'      , accessTokenParser , loginRequired , revision.api.list);
+
+  app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
+  app.get('/trash/$'                  , loginRequired , page.trashPageListShowWrapper);
+  app.get('/trash/*/$'                , loginRequired , page.deletedPageListShowWrapper);
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
-  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.integrate);
-  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.discard);
-  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
+  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
+  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
-  app.get('/*/$'                   , loginRequired(false) , page.showPageWithEndOfSlash, page.notFound);
-  app.get('/*'                     , loginRequired(false) , page.showPage, page.notFound);
+  app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
+  app.get('/*'                     , loginRequired , page.showPage, page.notFound);
 };

+ 92 - 96
src/server/service/export.js

@@ -3,53 +3,59 @@ const fs = require('fs');
 const path = require('path');
 const streamToPromise = require('stream-to-promise');
 const archiver = require('archiver');
+const toArrayIfNot = require('../../lib/util/toArrayIfNot');
 
 class ExportService {
 
   constructor(crowi) {
+    this.crowi = crowi;
+    this.appService = crowi.appService;
+    this.growiBridgeService = crowi.growiBridgeService;
+    this.getFile = this.growiBridgeService.getFile.bind(this);
     this.baseDir = path.join(crowi.tmpDir, 'downloads');
-    this.extension = 'json';
-    this.encoding = 'utf-8';
     this.per = 100;
     this.zlibLevel = 9; // 0(min) - 9(max)
+  }
 
-    this.files = {};
-    // populate this.files
-    // this.files = {
-    //   configs: path.join(this.baseDir, 'configs.json'),
-    //   pages: path.join(this.baseDir, 'pages.json'),
-    //   pagetagrelations: path.join(this.baseDir, 'pagetagrelations.json'),
-    //   ...
-    // };
-    // TODO: handle 3 globalnotificationsettings collection properly
-    // see Object.values(crowi.models).forEach((m) => { return console.log(m.collection.collectionName) });
-    Object.values(crowi.models).forEach((m) => {
-      const name = m.collection.collectionName;
-      this.files[name] = path.join(this.baseDir, `${name}.${this.extension}`);
-    });
+  /**
+   * parse all zip files in downloads dir
+   *
+   * @memberOf ExportService
+   * @return {Array.<object>} info for zip files
+   */
+  async getStatus() {
+    const zipFiles = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' });
+    const zipFileStats = await Promise.all(zipFiles.map((file) => {
+      const zipFile = this.getFile(file);
+      return this.growiBridgeService.parseZipFile(zipFile);
+    }));
+
+    return zipFileStats;
   }
 
   /**
-   * dump a collection into json
+   * create meta.json
    *
    * @memberOf ExportService
-   * @return {object} cache info for exported zip files
+   * @return {string} path to meta.json
    */
-  getStatus() {
-    const status = {};
-    const collections = Object.keys(this.files);
-    collections.forEach((file) => {
-      status[path.basename(file, '.zip')] = null;
-    });
+  async createMetaJson() {
+    const metaJson = path.join(this.baseDir, this.growiBridgeService.getMetaFileName());
+    const writeStream = fs.createWriteStream(metaJson, { encoding: this.growiBridgeService.getEncoding() });
+
+    const metaData = {
+      version: this.crowi.version,
+      url: this.appService.getSiteUrl(),
+      passwordSeed: this.crowi.env.PASSWORD_SEED,
+      exportedAt: new Date(),
+    };
 
-    // extract ${collectionName}.zip
-    const files = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' && collections.includes(path.basename(file, '.zip')) });
+    writeStream.write(JSON.stringify(metaData));
+    writeStream.close();
 
-    files.forEach((file) => {
-      status[path.basename(file, '.zip')] = file;
-    });
+    await streamToPromise(writeStream);
 
-    return status;
+    return metaJson;
   }
 
   /**
@@ -58,29 +64,32 @@ class ExportService {
    * @memberOf ExportService
    * @param {string} file path to json file to be written
    * @param {readStream} readStream  read stream
-   * @param {number} [total] number of target items (optional)
+   * @param {number} total number of target items (optional)
+   * @param {function} [getLogText] (n, total) => { ... }
+   * @return {string} path to the exported json file
    */
-  async export(file, readStream, total) {
+  async export(writeStream, readStream, total, getLogText) {
     let n = 0;
-    const ws = fs.createWriteStream(file, { encoding: this.encoding });
 
     // open an array
-    ws.write('[');
+    writeStream.write('[');
 
     readStream.on('data', (chunk) => {
-      if (n !== 0) ws.write(',');
-      ws.write(JSON.stringify(chunk));
+      if (n !== 0) writeStream.write(',');
+      writeStream.write(JSON.stringify(chunk));
       n++;
-      this.logProgress(n, total);
+      this.logProgress(n, total, getLogText);
     });
 
     readStream.on('end', () => {
       // close the array
-      ws.write(']');
-      ws.close();
+      writeStream.write(']');
+      writeStream.close();
     });
 
     await streamToPromise(readStream);
+
+    return writeStream.path;
   }
 
   /**
@@ -90,19 +99,30 @@ class ExportService {
    * @param {object} Model instance of mongoose model
    * @return {string} path to zip file
    */
-  async exportCollection(Model) {
-    const modelName = Model.collection.collectionName;
-    const file = this.files[modelName];
-    const total = await Model.countDocuments();
+  async exportCollectionToJson(Model) {
+    const { collectionName } = Model.collection;
+    const jsonFileToWrite = path.join(this.baseDir, `${collectionName}.json`);
+    const writeStream = fs.createWriteStream(jsonFileToWrite, { encoding: this.growiBridgeService.getEncoding() });
     const readStream = Model.find().cursor();
+    const total = await Model.countDocuments();
+    const getLogText = (n, total) => `${collectionName}: ${n}/${total} written`;
 
-    await this.export(file, readStream, total);
+    const jsonFileWritten = await this.export(writeStream, readStream, total, getLogText);
 
-    const { file: zipFile, size } = await this.zipSingleFile(file);
+    return jsonFileWritten;
+  }
 
-    logger.info(`exported ${modelName} collection into ${zipFile} (${size} bytes)`);
+  /**
+   * export multiple collections
+   *
+   * @memberOf ExportService
+   * @param {Array.<object>} models array of instances of mongoose model
+   * @return {Array.<string>} paths to json files created
+   */
+  async exportMultipleCollectionsToJsons(models) {
+    const jsonFiles = await Promise.all(models.map(Model => this.exportCollectionToJson(Model)));
 
-    return zipFile;
+    return jsonFiles;
   }
 
   /**
@@ -110,16 +130,11 @@ class ExportService {
    *
    * @memberOf ExportService
    * @param {number} n number of items exported
-   * @param {number} [total] number of target items (optional)
+   * @param {number} total number of target items (optional)
+   * @param {function} [getLogText] (n, total) => { ... }
    */
-  logProgress(n, total) {
-    let output;
-    if (total) {
-      output = `${n}/${total} written`;
-    }
-    else {
-      output = `${n} items written`;
-    }
+  logProgress(n, total, getLogText) {
+    const output = getLogText ? getLogText(n, total) : `${n}/${total} items written`;
 
     // output every this.per items
     if (n % this.per === 0) logger.debug(output);
@@ -128,21 +143,21 @@ class ExportService {
   }
 
   /**
-   * zip a file
+   * zip files into one zip file
    *
    * @memberOf ExportService
-   * @param {string} from path to input file
-   * @param {string} [to=`${path.join(path.dirname(from), `${path.basename(from, path.extname(from))}.zip`)}`] path to output file
-   * @param {string} [as=path.basename(from)] file name after unzipped
-   * @return {object} file path and file size
+   * @param {object|array<object>} configs object or array of object { from: "path to source file", as: "file name after unzipped" }
+   * @return {string} absolute path to the zip file
    * @see https://www.archiverjs.com/#quick-start
    */
-  async zipSingleFile(from, to = this.replaceExtension(from, 'zip'), as = path.basename(from)) {
+  async zipFiles(_configs) {
+    const configs = toArrayIfNot(_configs);
+    const appTitle = this.appService.getAppTitle();
+    const timeStamp = (new Date()).getTime();
+    const zipFile = path.join(this.baseDir, `${appTitle}-${timeStamp}.zip`);
     const archive = archiver('zip', {
       zlib: { level: this.zlibLevel },
     });
-    const input = fs.createReadStream(from);
-    const output = fs.createWriteStream(to);
 
     // good practice to catch warnings (ie stat failures and other non-blocking errors)
     archive.on('warning', (err) => {
@@ -153,8 +168,14 @@ class ExportService {
     // good practice to catch this error explicitly
     archive.on('error', (err) => { throw err });
 
-    // append a file from stream
-    archive.append(input, { name: as });
+    for (const { from, as } of configs) {
+      const input = fs.createReadStream(from);
+
+      // append a file from stream
+      archive.append(input, { name: as });
+    }
+
+    const output = fs.createWriteStream(zipFile);
 
     // pipe archive data to the file
     archive.pipe(output);
@@ -165,39 +186,14 @@ class ExportService {
 
     await streamToPromise(archive);
 
-    return {
-      file: to,
-      size: archive.pointer(),
-    };
-  }
+    logger.info(`zipped growi data into ${zipFile} (${archive.pointer()} bytes)`);
 
-  /**
-   * replace a file extension
-   *
-   * @memberOf ExportService
-   * @param {string} file file path
-   * @param {string} extension new extension
-   * @return {string} path to file with new extension
-   */
-  replaceExtension(file, extension) {
-    return `${path.join(path.dirname(file), `${path.basename(file, path.extname(file))}.${extension}`)}`;
-  }
-
-  /**
-   * get the path to the zipped file for a collection
-   *
-   * @memberOf ExportService
-   * @param {object} Model instance of mongoose model
-   * @return {string} path to zip file
-   */
-  getZipFile(Model) {
-    const json = this.files[Model.collection.collectionName];
-    const zip = this.replaceExtension(json, 'zip');
-    if (!fs.existsSync(zip)) {
-      return null;
+    // delete json files
+    for (const { from } of configs) {
+      fs.unlinkSync(from);
     }
 
-    return zip;
+    return zipFile;
   }
 
 }

+ 134 - 0
src/server/service/growi-bridge.js

@@ -0,0 +1,134 @@
+const logger = require('@alias/logger')('growi:services:GrowiBridgeService'); // eslint-disable-line no-unused-vars
+const fs = require('fs');
+const path = require('path');
+const streamToPromise = require('stream-to-promise');
+const unzipper = require('unzipper');
+
+/**
+ * the service class for bridging GROWIs (export and import)
+ * common properties and methods between export service and import service are defined in this service
+ */
+class GrowiBridgeService {
+
+  constructor(crowi) {
+    this.encoding = 'utf-8';
+    this.metaFileName = 'meta.json';
+
+    // { pages: Page, users: User, ... }
+    this.collectionMap = {};
+    this.initCollectionMap(crowi.models);
+  }
+
+  /**
+   * initialize collection map
+   *
+   * @memberOf GrowiBridgeService
+   * @param {object} models from models/index.js
+   */
+  initCollectionMap(models) {
+    for (const model of Object.values(models)) {
+      this.collectionMap[model.collection.collectionName] = model;
+    }
+  }
+
+  /**
+   * getter for encoding
+   *
+   * @memberOf GrowiBridgeService
+   * @return {string} encoding
+   */
+  getEncoding() {
+    return this.encoding;
+  }
+
+  /**
+   * getter for metaFileName
+   *
+   * @memberOf GrowiBridgeService
+   * @return {string} base name of meta file
+   */
+  getMetaFileName() {
+    return this.metaFileName;
+  }
+
+  /**
+   * get a model from collection name
+   *
+   * @memberOf GrowiBridgeService
+   * @param {string} collectionName collection name
+   * @return {object} instance of mongoose model
+   */
+  getModelFromCollectionName(collectionName) {
+    const Model = this.collectionMap[collectionName];
+
+    if (Model == null) {
+      throw new Error(`cannot find a model for collection name "${collectionName}"`);
+    }
+
+    return Model;
+  }
+
+  /**
+   * get the absolute path to a file
+   * this method must must be bound to the caller (this.baseDir is undefined in this service)
+   *
+   * @memberOf GrowiBridgeService
+   * @param {string} fileName base name of file
+   * @return {string} absolute path to the file
+   */
+  getFile(fileName) {
+    if (this.baseDir == null) {
+      throw new Error('baseDir is not defined');
+    }
+
+    const jsonFile = path.join(this.baseDir, fileName);
+
+    // throws err if the file does not exist
+    fs.accessSync(jsonFile);
+
+    return jsonFile;
+  }
+
+  /**
+   * parse a zip file
+   *
+   * @memberOf GrowiBridgeService
+   * @param {string} zipFile path to zip file
+   * @return {object} meta{object} and files{Array.<object>}
+   */
+  async parseZipFile(zipFile) {
+    const readStream = fs.createReadStream(zipFile);
+    const unzipStream = readStream.pipe(unzipper.Parse());
+    const fileStats = [];
+    let meta = {};
+
+    unzipStream.on('entry', async(entry) => {
+      const fileName = entry.path;
+      const size = entry.vars.uncompressedSize; // There is also compressedSize;
+
+      if (fileName === this.getMetaFileName()) {
+        meta = JSON.parse((await entry.buffer()).toString());
+      }
+      else {
+        fileStats.push({
+          fileName,
+          collectionName: path.basename(fileName, '.json'),
+          size,
+        });
+      }
+
+      entry.autodrain();
+    });
+
+    await streamToPromise(unzipStream);
+
+    return {
+      meta,
+      fileName: path.basename(zipFile),
+      fileStats,
+    };
+  }
+
+}
+
+module.exports = GrowiBridgeService;

+ 269 - 0
src/server/service/import.js

@@ -0,0 +1,269 @@
+const logger = require('@alias/logger')('growi:services:ImportService'); // eslint-disable-line no-unused-vars
+const fs = require('fs');
+const path = require('path');
+const JSONStream = require('JSONStream');
+const streamToPromise = require('stream-to-promise');
+const unzipper = require('unzipper');
+const { ObjectId } = require('mongoose').Types;
+
+class ImportService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.growiBridgeService = crowi.growiBridgeService;
+    this.getFile = this.growiBridgeService.getFile.bind(this);
+    this.baseDir = path.join(crowi.tmpDir, 'imports');
+    this.per = 100;
+    this.keepOriginal = this.keepOriginal.bind(this);
+
+    // { pages: { _id: ..., path: ..., ...}, users: { _id: ..., username: ..., }, ... }
+    this.convertMap = {};
+    this.initConvertMap(crowi.models);
+  }
+
+  /**
+   * initialize convert map. set keepOriginal as default
+   *
+   * @memberOf ImportService
+   * @param {object} models from models/index.js
+   */
+  initConvertMap(models) {
+    // by default, original value is used for imported documents
+    for (const model of Object.values(models)) {
+      const { collectionName } = model.collection;
+      this.convertMap[collectionName] = {};
+
+      for (const key of Object.keys(model.schema.paths)) {
+        this.convertMap[collectionName][key] = this.keepOriginal;
+      }
+    }
+  }
+
+  /**
+   * keep original value
+   * automatically convert ObjectId
+   *
+   * @memberOf ImportService
+   * @param {any} _value value from imported document
+   * @param {{ _document: object, schema: object, key: string }}
+   * @return {any} new value for the document
+   */
+  keepOriginal(_value, { _document, schema, key }) {
+    let value;
+    if (schema[key].instance === 'ObjectID' && ObjectId.isValid(_value)) {
+      value = ObjectId(_value);
+    }
+    else {
+      value = _value;
+    }
+
+    return value;
+  }
+
+  /**
+   * import a collection from json
+   *
+   * @memberOf ImportService
+   * @param {object} Model instance of mongoose model
+   * @param {string} jsonFile absolute path to the jsonFile being imported
+   * @param {object} overwriteParams overwrite each document with unrelated value. e.g. { creator: req.user }
+   * @return {insertedIds: Array.<string>, failedIds: Array.<string>}
+   */
+  async import(Model, jsonFile, overwriteParams = {}) {
+    // streamToPromise(jsonStream) throws an error, use new Promise instead
+    return new Promise((resolve, reject) => {
+      const { collectionName } = Model.collection;
+
+      let counter = 0;
+      let insertedIds = [];
+      let failedIds = [];
+      let unorderedBulkOp = Model.collection.initializeUnorderedBulkOp();
+
+      const readStream = fs.createReadStream(jsonFile, { encoding: this.growiBridgeService.getEncoding() });
+      const jsonStream = readStream.pipe(JSONStream.parse('*'));
+
+      jsonStream.on('data', async(document) => {
+        // documents are not persisted until unorderedBulkOp.execute()
+        unorderedBulkOp.insert(this.convertDocuments(Model, document, overwriteParams));
+
+        counter++;
+
+        if (counter % this.per === 0) {
+          // puase jsonStream to prevent more items to be added to unorderedBulkOp
+          jsonStream.pause();
+
+          const { insertedIds: _insertedIds, failedIds: _failedIds } = await this.execUnorderedBulkOpSafely(unorderedBulkOp);
+          insertedIds = [...insertedIds, ..._insertedIds];
+          failedIds = [...failedIds, ..._failedIds];
+
+          // reset initializeUnorderedBulkOp
+          unorderedBulkOp = Model.collection.initializeUnorderedBulkOp();
+
+          // resume jsonStream
+          jsonStream.resume();
+        }
+      });
+
+      jsonStream.on('end', async(data) => {
+        // insert the rest. avoid errors when unorderedBulkOp has no items
+        if (unorderedBulkOp.s.currentBatch !== null) {
+          const { insertedIds: _insertedIds, failedIds: _failedIds } = await this.execUnorderedBulkOpSafely(unorderedBulkOp);
+          insertedIds = [...insertedIds, ..._insertedIds];
+          failedIds = [...failedIds, ..._failedIds];
+        }
+
+        logger.info(`Done. Inserted ${insertedIds.length} ${collectionName}.`);
+
+        if (failedIds.length > 0) {
+          logger.error(`Failed to insert ${failedIds.length} ${collectionName}: ${failedIds.join(', ')}.`);
+        }
+
+        // clean up tmp directory
+        fs.unlinkSync(jsonFile);
+
+        return resolve({
+          insertedIds,
+          failedIds,
+        });
+      });
+    });
+  }
+
+  /**
+   * extract a zip file
+   *
+   * @memberOf ImportService
+   * @param {string} zipFile absolute path to zip file
+   * @return {Array.<string>} array of absolute paths to extracted files
+   */
+  async unzip(zipFile) {
+    const readStream = fs.createReadStream(zipFile);
+    const unzipStream = readStream.pipe(unzipper.Parse());
+    const files = [];
+
+    unzipStream.on('entry', (entry) => {
+      const fileName = entry.path;
+
+      if (fileName === this.growiBridgeService.getMetaFileName()) {
+        // skip meta.json
+        entry.autodrain();
+      }
+      else {
+        const jsonFile = path.join(this.baseDir, fileName);
+        const writeStream = fs.createWriteStream(jsonFile, { encoding: this.growiBridgeService.getEncoding() });
+        entry.pipe(writeStream);
+        files.push(jsonFile);
+      }
+    });
+
+    await streamToPromise(unzipStream);
+
+    return files;
+  }
+
+  /**
+   * execute unorderedBulkOp and ignore errors
+   *
+   * @memberOf ImportService
+   * @param {object} unorderedBulkOp result of Model.collection.initializeUnorderedBulkOp()
+   * @return {{nInserted: number, failed: Array.<string>}} number of docuemnts inserted and failed
+   */
+  async execUnorderedBulkOpSafely(unorderedBulkOp) {
+    // keep the number of documents inserted and failed for logger
+    let insertedIds = [];
+    let failedIds = [];
+
+    // try catch to skip errors
+    try {
+      const log = await unorderedBulkOp.execute();
+      const _insertedIds = log.result.insertedIds.map(op => op._id);
+      insertedIds = [...insertedIds, ..._insertedIds];
+    }
+    catch (err) {
+      const collectionName = unorderedBulkOp.s.namespace;
+
+      for (const error of err.result.result.writeErrors) {
+        logger.error(`${collectionName}: ${error.errmsg}`);
+      }
+
+      const _failedIds = err.result.result.writeErrors.map(err => err.err.op._id);
+      const _insertedIds = err.result.result.insertedIds.filter(op => !_failedIds.includes(op._id)).map(op => op._id);
+
+      failedIds = [...failedIds, ..._failedIds];
+      insertedIds = [...insertedIds, ..._insertedIds];
+    }
+
+    logger.debug(`Importing ${unorderedBulkOp.s.collection.s.name}. Inserted: ${insertedIds.length}. Failed: ${failedIds.length}.`);
+
+    return {
+      insertedIds,
+      failedIds,
+    };
+  }
+
+  /**
+   * execute unorderedBulkOp and ignore errors
+   *
+   * @memberOf ImportService
+   * @param {object} Model instance of mongoose model
+   * @param {object} _document document being imported
+   * @param {object} overwriteParams overwrite each document with unrelated value. e.g. { creator: req.user }
+   * @return {object} document to be persisted
+   */
+  convertDocuments(Model, _document, overwriteParams) {
+    const collectionName = Model.collection.collectionName;
+    const schema = Model.schema.paths;
+    const convertMap = this.convertMap[collectionName];
+
+    if (convertMap == null) {
+      throw new Error(`attribute map is not defined for ${collectionName}`);
+    }
+
+    const document = {};
+
+    // assign value from documents being imported
+    for (const entry of Object.entries(convertMap)) {
+      const [key, value] = entry;
+
+      // distinguish between null and undefined
+      if (_document[key] === undefined) {
+        continue; // next entry
+      }
+
+      document[key] = (typeof value === 'function') ? value(_document[key], { _document, key, schema }) : value;
+    }
+
+    // overwrite documents with custom values
+    for (const entry of Object.entries(overwriteParams)) {
+      const [key, value] = entry;
+
+      // distinguish between null and undefined
+      if (_document[key] !== undefined) {
+        document[key] = (typeof value === 'function') ? value(_document[key], { _document, key, schema }) : value;
+      }
+    }
+
+    return document;
+  }
+
+  /**
+   * validate using meta.json
+   * to pass validation, all the criteria must be met
+   *   - ${version of this growi} === ${version of growi that exported data}
+   *
+   * @memberOf ImportService
+   * @param {object} meta meta data from meta.json
+   */
+  validate(meta) {
+    if (meta.version !== this.crowi.version) {
+      throw new Error('the version of this growi and the growi that exported the data are not met');
+    }
+
+    // TODO: check if all migrations are completed
+    // - export: throw err if there are pending migrations
+    // - import: throw err if there are pending migrations
+  }
+
+}
+
+module.exports = ImportService;

+ 2 - 101
src/server/util/middlewares.js

@@ -1,5 +1,6 @@
-const debug = require('debug')('growi:lib:middlewares');
+// eslint-disable-next-line no-unused-vars
 const logger = require('@alias/logger')('growi:lib:middlewares');
+
 const { formatDistanceStrict } = require('date-fns');
 const pathUtils = require('growi-commons').pathUtils;
 const md5 = require('md5');
@@ -27,25 +28,6 @@ module.exports = (crowi, app) => {
     next();
   };
 
-  middlewares.csrfVerify = function(req, res, next) {
-    const token = req.body._csrf || req.query._csrf || null;
-    const csrfKey = (req.session && req.session.id) || 'anon';
-
-    debug('req.skipCsrfVerify', req.skipCsrfVerify);
-    if (req.skipCsrfVerify) {
-      debug('csrf verify skipped');
-      return next();
-    }
-
-    if (crowi.getTokens().verify(csrfKey, token)) {
-      debug('csrf successfully verified');
-      return next();
-    }
-
-    logger.warn('csrf verification failed. return 403', csrfKey, token);
-    return res.sendStatus(403);
-  };
-
   middlewares.swigFunctions = function() {
     return function(req, res, next) {
       require('../util/swigFunctions')(crowi, app, req, res.locals);
@@ -174,87 +156,6 @@ module.exports = (crowi, app) => {
     };
   };
 
-  middlewares.adminRequired = function(req, res, next) {
-    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
-      if (req.user.admin) {
-        next();
-        return;
-      }
-      return res.redirect('/');
-    }
-    return res.redirect('/login');
-  };
-
-  /**
-   * require login handler
-   *
-   * @param {boolean} isStrictly whethere strictly restricted (default true)
-   */
-  middlewares.loginRequired = function(isStrictly = true) {
-    return function(req, res, next) {
-
-      // when the route is not strictly restricted
-      if (!isStrictly) {
-        // when allowed to read
-        if (crowi.aclService.isGuestAllowedToRead()) {
-          logger.debug('Allowed to read: ', req.path);
-          return next();
-        }
-      }
-
-      const User = crowi.model('User');
-
-      // check the user logged in
-      if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
-        if (req.user.status === User.STATUS_ACTIVE) {
-          // Active の人だけ先に進める
-          return next();
-        }
-        if (req.user.status === User.STATUS_REGISTERED) {
-          return res.redirect('/login/error/registered');
-        }
-        if (req.user.status === User.STATUS_SUSPENDED) {
-          return res.redirect('/login/error/suspended');
-        }
-        if (req.user.status === User.STATUS_INVITED) {
-          return res.redirect('/login/invited');
-        }
-      }
-
-      // is api path
-      const path = req.path || '';
-      if (path.match(/^\/_api\/.+$/)) {
-        return res.sendStatus(403);
-      }
-
-      req.session.jumpTo = req.originalUrl;
-      return res.redirect('/login');
-    };
-  };
-
-  middlewares.accessTokenParser = function(req, res, next) {
-    // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
-    const accessToken = req.query.access_token || req.body.access_token || null;
-    if (!accessToken) {
-      return next();
-    }
-
-    const User = crowi.model('User');
-
-    debug('accessToken is', accessToken);
-    User.findUserByApiToken(accessToken)
-      .then((userData) => {
-        req.user = userData;
-        req.skipCsrfVerify = true;
-        debug('Access token parsed: skipCsrfVerify');
-
-        next();
-      })
-      .catch((err) => {
-        next();
-      });
-  };
-
   // this is for Installer
   middlewares.applicationNotInstalled = async function(req, res, next) {
     const isInstalled = await appService.isDBInitialized();

+ 3 - 0
src/server/views/admin/importer.html

@@ -39,6 +39,9 @@
       </div>
       {% endif %}
 
+      <!-- TODO: move to /imponents/Admin/Importer.jsx -->
+      <div id="growi-import"></div>
+
       <!-- esa Importer management forms -->
       <form action="/_api/admin/settings/importerEsa" method="post" class="form-horizontal" id="importerSettingFormEsa" role="form"
           data-success-messaage="{{ ('Updated') }}">

+ 10 - 17
src/test/util/middlewares.test.js → src/test/middleware/login-required.test.js

@@ -4,13 +4,15 @@ import each from 'jest-each';
 
 const { getInstance } = require('../setup-crowi');
 
-describe('middlewares.loginRequired', () => {
+describe('loginRequired', () => {
   let crowi;
-  let middlewares;
+  let loginRequiredStrictly;
+  let loginRequired;
 
   beforeEach(async(done) => {
     crowi = await getInstance();
-    middlewares = require('@server/util/middlewares')(crowi, null);
+    loginRequiredStrictly = require('@server/middleware/login-required')(crowi);
+    loginRequired = require('@server/middleware/login-required')(crowi, true);
     done();
   });
 
@@ -30,13 +32,6 @@ describe('middlewares.loginRequired', () => {
     };
     const next = jest.fn().mockReturnValue('next');
 
-    let loginRequired;
-
-    beforeEach(async(done) => {
-      loginRequired = middlewares.loginRequired(false);
-      done();
-    });
-
     test('pass guest user when aclService.isGuestAllowedToRead() returns true', () => {
       // prepare spy for AclService.isGuestAllowedToRead
       const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
@@ -79,11 +74,9 @@ describe('middlewares.loginRequired', () => {
     };
     const next = jest.fn().mockReturnValue('next');
 
-    let loginRequired;
     let isGuestAllowedToReadSpy;
 
     beforeEach(async(done) => {
-      loginRequired = middlewares.loginRequired();
       // reset session object
       req.session = {};
       // spy for AclService.isGuestAllowedToRead
@@ -94,7 +87,7 @@ describe('middlewares.loginRequired', () => {
     test('send status 403 when \'req.path\' starts with \'_api\'', () => {
       req.path = '/_api/someapi';
 
-      const result = loginRequired(req, res, next);
+      const result = loginRequiredStrictly(req, res, next);
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
@@ -107,7 +100,7 @@ describe('middlewares.loginRequired', () => {
     test('redirect to \'/login\' when the user does not loggedin', () => {
       req.path = '/path/that/requires/loggedin';
 
-      const result = loginRequired(req, res, next);
+      const result = loginRequiredStrictly(req, res, next);
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
@@ -126,7 +119,7 @@ describe('middlewares.loginRequired', () => {
         status: User.STATUS_ACTIVE,
       };
 
-      const result = loginRequired(req, res, next);
+      const result = loginRequiredStrictly(req, res, next);
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
@@ -150,7 +143,7 @@ describe('middlewares.loginRequired', () => {
           status: userStatus,
         };
 
-        const result = loginRequired(req, res, next);
+        const result = loginRequiredStrictly(req, res, next);
 
         expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
         expect(next).not.toHaveBeenCalled();
@@ -170,7 +163,7 @@ describe('middlewares.loginRequired', () => {
         status: User.STATUS_DELETED,
       };
 
-      const result = loginRequired(req, res, next);
+      const result = loginRequiredStrictly(req, res, next);
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();

+ 1 - 0
tmp/downloads/.keep

@@ -0,0 +1 @@
+

+ 1 - 0
tmp/imports/.keep

@@ -0,0 +1 @@
+

+ 10 - 11
wercker.yml

@@ -15,6 +15,12 @@ test:
       code: |
         yarn
 
+    - script:
+      name: install plugins
+      code: |
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+        yarn add -D react-images react-motion
+
     - script:
       name: print dependencies
       code: |
@@ -48,12 +54,6 @@ build-prod:
       name: set yarn cache-folder
       code: yarn config set cache-folder $WERCKER_CACHE_DIR/yarn
 
-    - script:
-      name: install plugins
-      code: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
-        yarn add -D react-images react-motion
-
     - script:
       name: npm run build:prod:analyze
       code: |
@@ -90,11 +90,6 @@ build-dev:
       name: set yarn cache-folder
       code: yarn config set cache-folder $WERCKER_CACHE_DIR/yarn
 
-    - script:
-      name: install plugins
-      code: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker
-
     - script:
       name: npm run build:dev
       code: |
@@ -154,6 +149,10 @@ release: # would be run on release branch
       name: trigger growi-docker release-nocdn pipeline
       code: GROWI_DOCKER_PIPELINE_ID=$GROWI_DOCKER_PIPELINE_ID_NOCDN sh ./bin/wercker/trigger-growi-docker.sh
 
+    - script:
+      name: trigger growi-docs deploy pipeline
+      code: sh ./bin/wercker/trigger-growi-docs.sh
+
   after-steps:
     - slack-notifier:
       url: $SLACK_WEBHOOK_URL

+ 95 - 4
yarn.lock

@@ -1263,6 +1263,14 @@
   resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
+JSONStream@^1.3.5:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
+  integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
+  dependencies:
+    jsonparse "^1.2.0"
+    through ">=2.2.7 <3"
+
 abab@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f"
@@ -1965,6 +1973,11 @@ bfj@^6.1.1:
     hoopy "^0.1.2"
     tryer "^1.0.0"
 
+big-integer@^1.6.17:
+  version "1.6.44"
+  resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.44.tgz#4ee9ae5f5839fc11ade338fea216b4513454a539"
+  integrity sha512-7MzElZPTyJ2fNvBkPxtFQ2fWIkVmuzw41+BZHSzpEq3ymB2MfeKp1+yXl/tS75xCx+WnyV+yb0kp+K1C3UNwmQ==
+
 big.js@^3.1.3:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e"
@@ -1983,6 +1996,14 @@ binary-extensions@^1.0.0:
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
 
+binary@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
+  integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=
+  dependencies:
+    buffers "~0.1.1"
+    chainsaw "~0.1.0"
+
 bl@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
@@ -2013,6 +2034,11 @@ bluebird@^3.5.5:
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
   integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==
 
+bluebird@~3.4.1:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
+  integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=
+
 bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
@@ -2288,6 +2314,11 @@ buffer-from@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
 
+buffer-indexof-polyfill@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.1.tgz#a9fb806ce8145d5428510ce72f278bb363a638bf"
+  integrity sha1-qfuAbOgUXVQoUQznLyeLs2OmOL8=
+
 buffer-xor@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
@@ -2308,6 +2339,11 @@ buffer@^5.1.0:
     base64-js "^1.0.2"
     ieee754 "^1.1.4"
 
+buffers@~0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb"
+  integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s=
+
 builtin-modules@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@@ -2508,6 +2544,13 @@ chain-function@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
 
+chainsaw@~0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98"
+  integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=
+  dependencies:
+    traverse ">=0.3.0 <0.4"
+
 chalk@2.4.2, chalk@^2.0.1, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -3810,6 +3853,13 @@ dtrace-provider@~0.8:
   dependencies:
     nan "^2.3.3"
 
+duplexer2@~0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
+  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
+  dependencies:
+    readable-stream "^2.0.2"
+
 duplexer3@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
@@ -5064,6 +5114,16 @@ fstream@^1.0.0, fstream@^1.0.2:
     mkdirp ">=0.5 0"
     rimraf "2"
 
+fstream@^1.0.12:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
+  integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
+  dependencies:
+    graceful-fs "^4.1.2"
+    inherits "~2.0.0"
+    mkdirp ">=0.5 0"
+    rimraf "2"
+
 function-bind@^1.0.2, function-bind@^1.1.0, function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -5368,7 +5428,7 @@ graceful-fs@^4.1.15:
   version "4.1.15"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
 
-graceful-fs@^4.2.0:
+graceful-fs@^4.2.0, graceful-fs@^4.2.2:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02"
   integrity sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==
@@ -7003,6 +7063,11 @@ jsonify@~0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
 
+jsonparse@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+  integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
+
 jsonpointer@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9"
@@ -7212,6 +7277,11 @@ linkify-it@^2.0.0:
   dependencies:
     uc.micro "^1.0.1"
 
+listenercount@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
+  integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=
+
 load-css-file@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/load-css-file/-/load-css-file-1.0.0.tgz#dac097ead6470f4c3f23d4bc5b9ff2c3decb212f"
@@ -10305,7 +10375,7 @@ read-pkg@^3.0.0:
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
-"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.6:
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
   dependencies:
@@ -11186,7 +11256,7 @@ set-value@^2.0.0:
     is-plain-object "^2.0.3"
     split-string "^3.0.1"
 
-setimmediate@^1.0.4, setimmediate@^1.0.5:
+setimmediate@^1.0.4, setimmediate@^1.0.5, setimmediate@~1.0.4:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
 
@@ -12264,7 +12334,7 @@ through2@^2.0.0:
     readable-stream "^2.1.5"
     xtend "~4.0.1"
 
-through@2, through@^2.3.6, through@~2.3, through@~2.3.1:
+through@2, "through@>=2.2.7 <3", through@^2.3.6, through@~2.3, through@~2.3.1:
   version "2.3.8"
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
 
@@ -12367,6 +12437,11 @@ tr46@^1.0.1:
   dependencies:
     punycode "^2.1.0"
 
+"traverse@>=0.3.0 <0.4":
+  version "0.3.9"
+  resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
+  integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=
+
 trim-newlines@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
@@ -12663,6 +12738,22 @@ unzip-response@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
 
+unzipper@^0.10.5:
+  version "0.10.5"
+  resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.5.tgz#4d189ae6f8af634b26efe1a1817c399e0dd4a1a0"
+  integrity sha512-i5ufkXNjWZYxU/0nKKf6LkvW8kn9YzRvfwuPWjXP+JTFce/8bqeR0gEfbiN2IDdJa6ZU6/2IzFRLK0z1v0uptw==
+  dependencies:
+    big-integer "^1.6.17"
+    binary "~0.3.0"
+    bluebird "~3.4.1"
+    buffer-indexof-polyfill "~1.0.0"
+    duplexer2 "~0.1.4"
+    fstream "^1.0.12"
+    graceful-fs "^4.2.2"
+    listenercount "~1.0.1"
+    readable-stream "~2.3.6"
+    setimmediate "~1.0.4"
+
 upath@^1.0.5, upath@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd"