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

Merge branch 'master' into support/apply-bootstrap4

# Conflicts:
#	src/client/styles/scss/_override-bootstrap-variables.scss
#	src/server/views/admin/markdown.html
#	yarn.lock
sooouh 6 лет назад
Родитель
Сommit
dfc1c6b50e
100 измененных файлов с 3862 добавлено и 1233 удалено
  1. 1 1
      .eslintignore
  2. 46 0
      .github/workflows/build-rc.yml
  3. 74 0
      .github/workflows/build.yml
  4. 164 0
      .github/workflows/ci.yml
  5. 37 0
      .github/workflows/release.yml
  6. 2 1
      .stylelintrc.json
  7. 7 2
      .vscode/launch.json
  8. 71 4
      CHANGES.md
  9. 29 84
      README.md
  10. 5 5
      app.json
  11. 6 0
      bin/github-actions/bump-version.sh
  12. 3 0
      bin/heroku/install-packages.sh
  13. 0 7
      bin/heroku/install-plugins.sh
  14. 10 8
      config/jest.config.js
  15. 14 8
      config/migrate.js
  16. 117 0
      docker/Dockerfile
  17. 14 0
      docker/Dockerfile.dockerignore
  18. 142 0
      docker/README.md
  19. 10 0
      docker/bin/remove-except-artifacts.sh
  20. 14 0
      docker/docker-entrypoint.sh
  21. 5 0
      docker/nocdn/env.prod.js
  22. 15 16
      package.json
  23. 46 18
      resource/locales/en-US/translation.json
  24. 40 12
      resource/locales/ja/translation.json
  25. 17 4
      src/client/js/app.jsx
  26. 0 0
      src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx
  27. 10 9
      src/client/js/components/Admin/ExportArchiveData/ArchiveFilesTable.jsx
  28. 5 5
      src/client/js/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.jsx
  29. 5 5
      src/client/js/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  30. 16 15
      src/client/js/components/Admin/ExportArchiveDataPage.jsx
  31. 52 0
      src/client/js/components/Admin/ImportData/GrowiArchive/ErrorViewer.jsx
  32. 228 0
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  33. 253 0
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  34. 507 0
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  35. 10 10
      src/client/js/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  36. 27 22
      src/client/js/components/Admin/ImportData/GrowiArchiveSection.jsx
  37. 0 364
      src/client/js/components/Admin/ImportData/GrowiZipImportForm.jsx
  38. 12 9
      src/client/js/components/Admin/ImportDataPage.jsx
  39. 80 0
      src/client/js/components/Admin/ManageExternalAccount.jsx
  40. 122 0
      src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx
  41. 0 115
      src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx
  42. 15 83
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  43. 122 0
      src/client/js/components/Admin/MarkdownSetting/PresentationForm.jsx
  44. 86 0
      src/client/js/components/Admin/MarkdownSetting/PresentationLineBreakOptions.jsx
  45. 15 5
      src/client/js/components/Admin/MarkdownSetting/WhiteListInput.jsx
  46. 24 4
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  47. 132 0
      src/client/js/components/Admin/Users/ExternalAccountTable.jsx
  48. 0 78
      src/client/js/components/Admin/Users/ManageExternalAccount.jsx
  49. 2 1
      src/client/js/components/Admin/Users/UserRemoveButton.jsx
  50. 16 12
      src/client/js/components/Page/RevisionLoader.jsx
  51. 73 0
      src/client/js/services/AdminExternalAccountsContainer.js
  52. 11 5
      src/client/js/services/AdminUsersContainer.js
  53. 65 0
      src/client/js/services/MarkDownSettingContainer.js
  54. 1 1
      src/client/js/util/apiNotification.js
  55. 0 1
      src/client/styles/agile-admin/inverse/colors/antarctic.scss
  56. 60 0
      src/client/styles/scss/_override-bootstrap-variables.scss
  57. 13 0
      src/lib/models/admin/growi-archive-import-option.js
  58. 20 0
      src/lib/models/admin/import-option-for-pages.js
  59. 15 0
      src/lib/models/admin/import-option-for-revisions.js
  60. 21 0
      src/linter-checker/test.js
  61. 23 0
      src/linter-checker/test.scss
  62. 28 0
      src/migrations/20191102223900-drop-configs-indices.js
  63. 29 0
      src/migrations/20191102223901-drop-pages-indices.js
  64. 41 0
      src/migrations/20191126173016-adjust-pages-path.js
  65. 33 0
      src/migrations/20191127023815-drop-wrong-index-of-page-tag-relation.js
  66. 5 8
      src/server/crowi/index.js
  67. 2 2
      src/server/middlewares/ApiV3FormValidator.js
  68. 3 1
      src/server/models/attachment.js
  69. 7 4
      src/server/models/bookmark.js
  70. 10 7
      src/server/models/config.js
  71. 1 4
      src/server/models/external-account.js
  72. 0 3
      src/server/models/index.js
  73. 5 0
      src/server/models/page-tag-relation.js
  74. 3 3
      src/server/models/page.js
  75. 2 0
      src/server/models/tag.js
  76. 3 0
      src/server/models/user-group-relation.js
  77. 4 4
      src/server/models/user.js
  78. 13 0
      src/server/models/vo/collection-progress.js
  79. 35 0
      src/server/models/vo/collection-progressing-status.js
  80. 3 4
      src/server/models/vo/error-apiv3.js
  81. 1 50
      src/server/routes/admin.js
  82. 5 6
      src/server/routes/apiv3/healthcheck.js
  83. 165 109
      src/server/routes/apiv3/import.js
  84. 250 5
      src/server/routes/apiv3/markdown-setting.js
  85. 62 0
      src/server/routes/apiv3/overwrite-params/pages.js
  86. 31 0
      src/server/routes/apiv3/overwrite-params/revisions.js
  87. 6 2
      src/server/routes/apiv3/response.js
  88. 3 1
      src/server/routes/apiv3/user-group-relation.js
  89. 7 3
      src/server/routes/apiv3/user-group.js
  90. 76 3
      src/server/routes/apiv3/users.js
  91. 22 10
      src/server/routes/attachment.js
  92. 7 4
      src/server/routes/comment.js
  93. 1 4
      src/server/routes/index.js
  94. 0 2
      src/server/routes/installer.js
  95. 19 13
      src/server/routes/page.js
  96. 18 6
      src/server/service/config-loader.js
  97. 27 57
      src/server/service/export.js
  98. 1 1
      src/server/service/file-uploader/aws.js
  99. 3 2
      src/server/service/file-uploader/gcs.js
  100. 1 1
      src/server/service/file-uploader/gridfs.js

+ 1 - 1
.eslintignore

@@ -4,5 +4,5 @@
 /public/**
 /src/client/js/legacy/thirdparty-js/**
 /src/client/js/util/reveal/plugins/markdown.js
-/test/**
+/src/linter-checker/**
 /tmp/**

+ 46 - 0
.github/workflows/build-rc.yml

@@ -0,0 +1,46 @@
+name: Release Docker Images for RC
+
+on:
+  push:
+    branches:
+      - rc/*
+
+jobs:
+
+  build-rc:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v1
+
+    - name: Set up Docker Buildx
+      uses: crazy-max/ghaction-docker-buildx@v1.0.4
+
+    - name: Login to docker.io registry
+      run: |
+        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
+
+    - name: Build Docker Image
+      run: |
+        CACHE_REF=weseek/growi-cache:3
+        docker buildx build \
+          --tag growi \
+          --platform linux/amd64 \
+          --load \
+          --cache-from=type=registry,ref=$CACHE_REF \
+          --cache-to=type=registry,ref=$CACHE_REF,mode=max \
+          --file ./docker/Dockerfile .
+
+    - name: Get SemVer
+      run: |
+        semver=`npm run version --silent`
+        echo ::set-env name=SEMVER::$semver
+
+    - name: Docker Tags by SemVer
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+      with:
+        source: growi
+        target: weseek/growi
+        semver: ${{ env.SEMVER }}
+        publish: true

+ 74 - 0
.github/workflows/build.yml

@@ -0,0 +1,74 @@
+name: Release Docker Images
+
+on:
+  push:
+    tags:
+      - v3.*
+
+jobs:
+
+  build:
+
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        flavor: [default, nocdn]
+
+    steps:
+    - uses: actions/checkout@v1
+
+    - name: Determine suffix
+      run: |
+        [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
+        echo ::set-env name=SUFFIX::$suffix
+
+    - name: Set up Docker Buildx
+      uses: crazy-max/ghaction-docker-buildx@v1.0.4
+
+    - name: Login to docker.io registry
+      run: |
+        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
+
+    - name: Build Docker Image
+      run: |
+        CACHE_REF=weseek/growi-cache:3${{ env.SUFFIX }}
+        docker buildx build \
+          --tag growi \
+          --build-arg flavor=${{ matrix.flavor }} \
+          --platform linux/amd64 \
+          --load \
+          --cache-from=type=registry,ref=$CACHE_REF \
+          --cache-to=type=registry,ref=$CACHE_REF,mode=max \
+          --file ./docker/Dockerfile .
+
+    - name: Get SemVer
+      run: |
+        semver=`npm run version --silent`
+        echo ::set-env name=SEMVER::$semver
+
+    - name: Docker Tags by SemVer
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.5
+      with:
+        source: growi
+        target: weseek/growi
+        semver: ${{ env.SEMVER }}
+        suffix: ${{ env.SUFFIX }}
+        additional-tags: 'latest'
+        publish: true
+
+  publish-desc:
+
+    runs-on: ubuntu-latest
+    needs: build
+
+    steps:
+    - uses: actions/checkout@v1
+
+    - name: Update Docker Hub Description
+      uses: peter-evans/dockerhub-description@v2.1.0
+      env:
+        DOCKERHUB_USERNAME: wsmoogle
+        DOCKERHUB_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        DOCKERHUB_REPOSITORY: weseek/growi
+        README_FILEPATH: ./docker/README.md

+ 164 - 0
.github/workflows/ci.yml

@@ -0,0 +1,164 @@
+name: Node CI
+
+on: [push]
+
+jobs:
+
+  resolve-dependencies:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [10.x, 12.x]
+
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: Cache/Restore node_modules
+      id: cache
+      uses: actions/cache@v1
+      with:
+        path: node_modules
+        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Install dependencies
+      if: steps.cache.outputs.cache-hit != 'true'
+      run: |
+        yarn
+    - name: Install plugins
+      if: steps.cache.outputs.cache-hit != 'true'
+      run: |
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+        yarn add -D react-images react-motion
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
+
+
+  test:
+    runs-on: ubuntu-latest
+    needs: resolve-dependencies
+
+    strategy:
+      matrix:
+        node-version: [10.x, 12.x]
+
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: Cache/Restore node_modules
+      uses: actions/cache@v1
+      with:
+        path: node_modules
+        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: yarn lint
+      run: |
+        yarn lint
+    - name: Launch MongoDB
+      uses: wbari/start-mongoDB@v0.2
+      with:
+        mongoDBVersion: 3.6
+    - name: yarn test
+      run: |
+        yarn test
+      env:
+        MONGO_URI: mongodb://localhost:27017/growi_test
+
+    - name: Slack Notification
+      uses: homoluctus/slatify@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*test (${{ matrix.node-version }})*'
+        channel: '#ci'
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+  build-dev:
+    runs-on: ubuntu-latest
+    needs: resolve-dependencies
+
+    strategy:
+      matrix:
+        node-version: [10.x, 12.x]
+
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: Cache/Restore node_modules
+      uses: actions/cache@v1
+      with:
+        path: node_modules
+        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: yarn build:dev
+      run: |
+        yarn build:dev
+
+    - name: Slack Notification
+      uses: homoluctus/slatify@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*build-dev (${{ matrix.node-version }})*'
+        channel: '#ci'
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  build-prod:
+    runs-on: ubuntu-latest
+    needs: resolve-dependencies
+
+    strategy:
+      matrix:
+        node-version: [10.x, 12.x]
+
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: Cache/Restore node_modules
+      uses: actions/cache@v1
+      with:
+        path: node_modules
+        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Launch MongoDB
+      uses: wbari/start-mongoDB@v0.2
+      with:
+        mongoDBVersion: 3.6
+    - name: yarn build:prod:analyze
+      run: |
+        yarn build:prod:analyze
+    - name: Shrink dependencies for production
+      run: |
+        yarn install --production
+    - name: yarn server:prod:ci
+      run: |
+        yarn server:prod:ci
+      env:
+        MONGO_URI: mongodb://localhost:27017/growi
+
+    - name: Upload reports
+      uses: actions/upload-artifact@v1
+      if: success()
+      with:
+        name: report
+        path: report
+    - name: Slack Notification
+      uses: homoluctus/slatify@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*build-prod (${{ matrix.node-version }})*'
+        channel: '#ci'
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 37 - 0
.github/workflows/release.yml

@@ -0,0 +1,37 @@
+name: GitHub Release
+
+on:
+  push:
+    branches:
+      - release
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v1
+      with:
+        fetch-depth: 1
+
+    - name: Init Git
+      run: |
+        git config --local user.name "GitHub Action"
+        git config --local user.email "info@weseek.co.jp"
+
+    - name: Bump version
+      run: |
+        npm version patch
+        sh ./bin/github-actions/bump-version.sh
+
+    - name: Commit
+      run: |
+        git commit -am "Release $RELEASE_VERSION"
+
+    - name: Push tag
+      uses: ad-m/github-push-action@master
+      with:
+        branch: null
+        github_token: ${{ secrets. GITHUB_TOKEN }}
+

+ 2 - 1
.stylelintrc.json

@@ -4,7 +4,8 @@
     "./node_modules/prettier-stylelint/config.js"
   ],
   "ignoreFiles": [
-    "src/client/styles/scss/_override-bootstrap-variables.scss"
+    "src/client/styles/scss/_override-bootstrap-variables.scss",
+    "src/linter-checker/test.scss"
   ],
   "rules": {
     "indentation": 2,

+ 7 - 2
.vscode/launch.json

@@ -4,6 +4,12 @@
     // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
     "version": "0.2.0",
     "configurations": [
+      {
+        "type": "node",
+        "request": "attach",
+        "name": "Debug: Attach Debugger to Server",
+        "port": 9229
+      },
       {
         "type": "node",
         "request": "launch",
@@ -11,10 +17,9 @@
         "runtimeExecutable": "npm",
         "runtimeArgs": [
           "run",
-          "server:debug"
+          "server:nolazy"
         ],
         "port": 9229,
-        "timeout": 30000,
         "restart": true,
         "console": "integratedTerminal",
         "internalConsoleOptions": "neverOpen"

+ 71 - 4
CHANGES.md

@@ -1,9 +1,76 @@
 # CHANGES
 
-## 3.5.18-RC
+## 3.6.0-RC
 
-* Improvement: Optimize handling promise of stream when exporting
+### BREAKING CHANGES
+
+* GROWI v3.6.0 no longer support Node.js v8.x
+* The name of database that is storing migrations meta data has been changed
+    * This affects **only when `MONGO_URI` has parameters**
+    * v3.5.x or above has a bug ([#1361](https://github.com/weseek/growi/issues/1361))
+
+Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/36x.html
+
+### Updates
+
+* Improvement: Drop unnecessary MongoDB collection indexes
+* Fix: Appending tag is failed by wrong index of PageTagRelation
+    * Introduced by 3.5.20
+* Support: Support Node.js v12
+* Support: Upgrade libs
+    * growi-commons
+
+## 3.5.24
+
+* Fix: Plugins are not working on Heroku
+
+## 3.5.23
+
+* Fix: Global Notification failed to send e-mail
+* Fix: Pagination is not working for trash list
+* Fix: Healthcheck API with `?connectToMiddlewares` returns error
+* Support: Upgrade libs
+    * growi-commons
+
+## 3.5.22
+
+* Improvement: Add `FILE_UPLOAD_DISABLED` env var
+
+## 3.5.21
+
+* Improvement: Cache control when retrieving attachment data
+* Fix: Inviting user doesn't work
+    * Introduced by 3.5.20
+
+## 3.5.20
+
+* Improvement: Organize MongoDB collection indexes uniqueness
+* Improvement: Reactify admin pages (External Account Management)
+* Fix: Search result or Timeline shows loading icon eternally when retrieving not accessible page
+* Support: Use SearchBox Elasticsearch Addon on Heroku
+* Support: Upgrade libs
+    * cross-env
+    * eslint-plugin-jest
+    * i18next
+    * i18next-browser-languagedetector
+    * migrate-mongo
+    * react-i18next
+    * validator
+
+## 3.5.19 (Missing number)
+
+## 3.5.18
+
+* Improvement: Import GROWI Archive
+    * Process asynchronously
+    * Collection configurations
+    * Selectable mode (insert/upsert/flush and insert)
+    * Safely mode settings for configs and users collections
+    * Show errors view
+* Improvement: Optimize handling promise of stream when exporting archive
 * Improvement: Optimize handling promise of stream when building indices
+* Improvement: Add link to [docs.growi.org](https://docs.growi.org)
+* Fix: Monospace font code is broken when printing on Mac
 
 ## 3.5.17
 
@@ -158,7 +225,7 @@
 * The restriction mode of the root page (`/`) will be set 'Public'
 * The restriction mode of the root page (`/`) can not be changed after v 3.5.1
 
-Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
+Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/35x.html
 
 ### Updates
 
@@ -275,7 +342,7 @@ Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
 
 None.
 
-Upgrading Guide: https://docs.growi.org/guide/upgrading/34x.html
+Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 
 ### Updates
 

+ 29 - 84
README.md

@@ -19,16 +19,16 @@
 GROWI 
 ===========
 
-[![wercker status](https://app.wercker.com/status/595b761d0e26796ddb304679f7bf27de/s/master "wercker status")](https://app.wercker.com/project/byKey/595b761d0e26796ddb304679f7bf27de)
+[![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![dependencies status](https://david-dm.org/weseek/growi.svg)](https://david-dm.org/weseek/growi)
 [![devDependencies Status](https://david-dm.org/weseek/growi/dev-status.svg)](https://david-dm.org/weseek/growi?type=dev)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 
 - [Features](#features)
 - [Quick Start for Production](#quick-start-for-production)
+    - [Heroku](#heroku)
+    - [docker-compose](#docker-compose)
     - [On-premise](#on-premise)
-    - [Using Heroku](#using-heroku)
-    - [Using docker-compose](#using-docker-compose)
 - [Environment Variables](#environment-variables)
 - [Documentation](#documentation)
 - [License](#license)
@@ -37,56 +37,53 @@ GROWI
 Features
 ========
 
-* **Pluggable**
-  * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/growi-plugin) or [github](https://github.com/search?q=topic%3Agrowi-plugin)!
 * **Features**
-  * Create hierarchical pages with markdown
+  * Create hierarchical pages with markdown -> [HERE](https://docs.growi.org/en/guide/getting-started/five_minutes.html) is 5 minutes tutorial
   * Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
+      * [GROWI Docs: HackMD(CodiMD) Integration](https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html)
   * Support Authentication with LDAP / Active Directory, OAuth
   * SSO(Single Sign On) with SAML
   * Slack/Mattermost, IFTTT Integration
-  * [Miscellaneous features](https://github.com/weseek/growi/wiki/Additional-Features)
+  * [GROWI Docs: Features](https://docs.growi.org/en/guide/features/page_layout.html)
+* **Pluggable**
+  * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/growi-plugin) or [github](https://github.com/search?q=topic%3Agrowi-plugin)!
 * **[Docker Ready][dockerhub]**
 * **[Docker Compose Ready][docker-compose]**
-  * [Multiple sites example](https://github.com/weseek/growi-docker-compose/tree/master/examples/multi-app)
-  * [HTTPS(with Let's Encrypt) proxy integration example](https://github.com/weseek/growi-docker-compose/tree/master/examples/https-portal)
-* Support IE11 (Experimental)
+  * [GROWI Docs: Multiple sites](https://docs.growi.org/en/admin-guide/admin-cookbook/multi-app.html)
+  * [GROWI Docs: HTTPS(with Let's Encrypt) proxy integration](https://docs.growi.org/en/admin-guide/admin-cookbook/lets-encrypt.html)
 
 Quick Start for Production
 ===========================
 
-Using Heroku
-------------
+### Heroku
+
+- [GROWI Docs: Launch on Heroku](https://docs.growi.org/en/admin-guide/getting-started/heroku.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/heroku.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/heroku.html))
+
+### docker-compose
 
-1. Go to https://heroku.com/deploy
-2. (Optional) Input INSTALL_PLUGINS to install plugins
+- [GROWI Docs: Launch with docker-compose](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/docker-compose.html))
 
-Using docker-compose
----------------------
+### On-premise
 
-```bash
-git clone https://github.com/weseek/growi-docker-compose.git growi
-cd growi
-docker-compose up
-```
+**[Migration Guide from Crowi](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html) ([en](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html)/[ja](https://docs.growi.org/ja/admin-guide/migration-guide/from-crowi-onpremise.html))** is here.
 
-See also [weseek/growi-docker-compose][docker-compose]
+- [GROWI Docs: Install on Ubuntu Server](https://docs.growi.org/en/admin-guide/getting-started/ubuntu-server.html)
+- [GROWI Docs: Install on CentOS](https://docs.growi.org/en/admin-guide/getting-started/centos.html)
 
-On-premise
-----------
 
-[**Migration Guide from Crowi** is here](https://docs.growi.org/guide/migration-guide/from-crowi-onpremise.html).
+Development
+==========
 
-### Dependencies
+## Dependencies
 
 - Node.js v10.x (DON'T USE 11.x)
 - npm 6.x
 - yarn
 - MongoDB 3.x
 
-See [confirmed versions](https://docs.growi.org/dev/startup/dev-env.html#versions-confirmed-to-work).
+See [confirmed versions](https://docs.growi.org/en/dev/startup/dev-env.html#set-up-node-js-environment).
 
-#### Optional Dependencies
+### Optional Dependencies
 
 - Redis 3.x
 - ElasticSearch 6.x (needed when using Full-text search)
@@ -94,30 +91,7 @@ See [confirmed versions](https://docs.growi.org/dev/startup/dev-env.html#version
       - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
       - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)
 
-### How to start
-
-#### Build and run the app
-
-```bash
-git clone https://github.com/weseek/growi.git
-cd growi
-yarn
-MONGO_URI=mongodb://MONGO_HOST:MONGO_PORT/growi npm start
-```
-
-**DO NOT USE `npm install`**, use `yarn` instead.
-
-If you launch growi with ElasticSearch, add environment variables before `npm start` like following:
-
-```
-export MONGO_URI=mongodb://MONGO_HOST:MONGO_PORT/growi
-export ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi
-npm start
-```
-
-For more info, see [Developers Guide](https://docs.growi.org/dev/).
-
-#### Command details
+## Command details
 
 |command|desc|
 |--|--|
@@ -125,28 +99,7 @@ For more info, see [Developers Guide](https://docs.growi.org/dev/).
 |`npm run server:prod`|Launch the server|
 |`npm start`|Invoke `npm run build:prod` and `npm run server:prod`|
 
-### How to upgrade
-
-```bash
-git pull
-yarn
-npm start
-```
-
-### How to install plugins
-
-* Stop server if server is running
-* `yarn add` to install plugin or `npm install`
-* `npm start` to build client app and start server
-
-#### Examples
-
-```bash
-yarn add growi-plugin-lsx
-npm start
-```
-
-For more info, see [Developers Guide](https://docs.growi.org/dev/) on docs.growi.org.
+For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup/launch.html#list-of-npm-commands).
 
 
 Environment Variables
@@ -177,10 +130,12 @@ Environment Variables
         * `mongodb` : MongoDB GridFS (Setting-less)
         * `local` : Server's Local file system (Setting-less)
         * `none` : Disable file uploading
+    * FILE_UPLOAD_DISABLED: If `true`, file uploading will be disabled. However, the files can be still viewed. Default: `false`
     * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
     * FILE_UPLOAD_TOTAL_LIMIT: Total capacity limit for uploads (bytes). default: `Infinity`
     * GCS_API_KEY_JSON_PATH: Path of the JSON file that contains [service account key to authenticate to GCP API](https://cloud.google.com/iam/docs/creating-managing-service-account-keys)
     * GCS_BUCKET: Name of the GCS bucket
+    * GCS_UPLOAD_NAMESPACE: Directory name to create in the bucket
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
         * MONGO_GRIDFS_TOTAL_LIMIT setting takes precedence over FILE_UPLOAD_TOTAL_LIMIT.
 * **Option to integrate with external systems**
@@ -238,16 +193,6 @@ Repository. If you would like to *implement* a new feature, firstly please submi
 It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
 * **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 
-Translation
---------------
-
-We have some Transifex Projects.
-
-* [GROWI (Internationalize)](https://www.transifex.com/weseek-inc/growi)
-* [GROWI Docs (Internationalize)](https://www.transifex.com/weseek-inc/growi-docs)
-
-Please join to our team!
-
 
 Language on GitHub
 ------------------

+ 5 - 5
app.json

@@ -25,18 +25,18 @@
       "description": "A password seed is used by password hash generator. ",
       "generator": "secret"
     },
-    "INSTALL_PLUGINS": {
-      "description": "Comma-separated list of plugin package names to install.",
-      "value": "growi-plugin-lsx,growi-plugin-pukiwiki-like-linker",
+    "ADDITIONAL_PACKAGES": {
+      "description": "Space-separated list of npm package names to install.",
+      "value": "growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images react-motion",
       "required": false
     }
   },
   "addons": [
     "mongolab",
     {
-      "plan": "bonsai:sandbox-6",
+      "plan": "searchbox:starter",
       "options": {
-        "version": "6.5.4"
+        "es_version": "6"
       }
     }
   ]

+ 6 - 0
bin/github-actions/bump-version.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+
+cd docker
+
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.5\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.5-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/nocdn\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 3 - 0
bin/heroku/install-packages.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+
+yarn add $ADDITIONAL_PACKAGES

+ 0 - 7
bin/heroku/install-plugins.sh

@@ -1,7 +0,0 @@
-#!/bin/sh
-
-export IFS=","
-
-for plugin in $INSTALL_PLUGINS; do
-  yarn add $plugin
-done

+ 10 - 8
config/jest.config.js

@@ -1,6 +1,15 @@
 // For a detailed explanation regarding each configuration property, visit:
 // https://jestjs.io/docs/en/configuration.html
 
+const MODULE_NAME_MAPPING = {
+  '@root/(.+)': '<rootDir>/$1',
+  '@commons/(.+)': '<rootDir>/src/lib/$1',
+  '@server/(.+)': '<rootDir>/src/server/$1',
+  '@alias/logger': '<rootDir>/src/lib/service/logger',
+  // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
+  // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
+};
+
 module.exports = {
   // Indicates whether each individual test should be reported during the run
   verbose: true,
@@ -19,14 +28,7 @@ module.exports = {
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       // A map from regular expressions to module names that allow to stub out resources with a single module
-      moduleNameMapper: {
-        '@root/(.+)': '<rootDir>/$1',
-        '@commons/(.+)': '<rootDir>/src/lib/$1',
-        '@server/(.+)': '<rootDir>/src/server/$1',
-        '@alias/logger': '<rootDir>/src/lib/service/logger',
-        // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
-        // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
-      },
+      moduleNameMapper: MODULE_NAME_MAPPING,
     },
     // {
     //   displayName: 'client',

+ 14 - 8
config/migrate.js

@@ -7,20 +7,26 @@
 
 require('module-alias/register');
 
+const { URL } = require('url');
+
 const { getMongoUri } = require('@commons/util/mongoose-utils');
 
 const mongoUri = getMongoUri();
-const match = mongoUri.match(/^(.+)\/([^/]+)$/);
+
+// parse url
+const url = new URL(mongoUri);
+
+const mongodb = {
+  url: `${url.protocol}//${url.host}`,
+  databaseName: url.pathname.substring(1), // omit heading slash
+  options: {
+    useNewUrlParser: true, // removes a deprecation warning when connecting
+  },
+};
 
 module.exports = {
   mongoUri,
-  mongodb: {
-    url: match[0],
-    databaseName: match[2],
-    options: {
-      useNewUrlParser: true, // removes a deprecation warning when connecting
-    },
-  },
+  mongodb,
   migrationsDir: 'src/migrations/',
   changelogCollectionName: 'migrations',
 };

+ 117 - 0
docker/Dockerfile

@@ -0,0 +1,117 @@
+# syntax = docker/dockerfile:experimental
+
+ARG flavor=default
+
+
+
+##
+## deps-resolver
+##
+FROM node:12-slim AS deps-resolver
+LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
+
+ENV appDir /opt/growi
+
+COPY ./package.json ${appDir}/
+COPY ./yarn.lock ${appDir}/
+WORKDIR ${appDir}
+
+# setup
+RUN yarn config set network-timeout 300000
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn
+# install official plugins
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+# install peerDependencies
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn add -D react-images react-motion
+
+
+
+##
+## deps-resolver-prod
+##
+FROM deps-resolver AS deps-resolver-prod
+
+# shrink dependencies for production
+RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
+  yarn install --production
+
+
+
+##
+## prebuilder-default
+##
+FROM node:12-slim AS prebuilder-default
+LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
+
+ENV appDir /opt/growi
+
+COPY . ${appDir}
+
+
+
+##
+## prebuilder-nocdn
+##
+FROM prebuilder-default AS prebuilder-nocdn
+
+# replace env.prod.js for NO_CDN
+COPY docker/nocdn/env.prod.js ${appDir}/config/
+
+
+
+##
+## prebuilder (alias)
+##
+FROM prebuilder-${flavor} AS prebuilder
+
+
+
+##
+## builder
+##
+FROM deps-resolver AS builder
+
+ENV appDir /opt/growi
+
+COPY --from=prebuilder ${appDir} ${appDir}
+
+# build
+RUN yarn build:prod
+
+# remove except artifacts
+WORKDIR /tmp
+RUN --mount=target=. sh docker/bin/remove-except-artifacts.sh
+WORKDIR ${appDir}
+
+
+
+##
+## release
+##
+FROM node:12-alpine
+LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
+
+ENV appDir /opt/growi
+
+# install tini
+RUN --mount=type=cache,target=/var/cache/apk \
+  apk add tini su-exec
+
+COPY docker/docker-entrypoint.sh /
+RUN chmod 700 /docker-entrypoint.sh
+
+COPY --from=deps-resolver-prod --chown=node:node \
+  ${appDir}/node_modules ${appDir}/node_modules
+COPY --from=builder --chown=node:node \
+  ${appDir} ${appDir}
+
+WORKDIR ${appDir}
+
+VOLUME /data
+EXPOSE 3000
+
+ENTRYPOINT ["/sbin/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
+CMD ["yarn", "server:prod"]

+ 14 - 0
docker/Dockerfile.dockerignore

@@ -0,0 +1,14 @@
+.git
+.github
+.vscode
+node_modules
+src/linter-checker
+src/test
+.editorconfig
+.eslint*
+.gitignore
+.prettier*
+.stylelint*
+app.json
+Procfile
+wercker.yml

+ 142 - 0
docker/README.md

@@ -0,0 +1,142 @@
+
+growi-docker
+===========
+
+[![wercker status](https://app.wercker.com/status/592aa5cebb59c67c4c266ce1f33b6a7e/s/ "wercker status")](https://app.wercker.com/project/byKey/592aa5cebb59c67c4c266ce1f33b6a7e) [![docker-build-automated](https://img.shields.io/docker/automated/jrottenberg/ffmpeg.svg)](https://hub.docker.com/r/weseek/growi/) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
+
+![GROWI-x-docker](https://user-images.githubusercontent.com/1638767/38307565-105956e2-384f-11e8-8534-b1128522d68d.png)
+
+
+Supported tags and respective Dockerfile links
+------------------------------------------------
+
+* [`3.5.13`, `3.5`, `3`, `latest` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.13/Dockerfile)
+* [`3.5.13-nocdn`, `3.5-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.13/nocdn/Dockerfile)
+* [`3.4.7`, `3.4`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/Dockerfile)
+* [`3.4.7-nocdn`, `3.4-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/nocdn/Dockerfile)
+
+
+What is GROWI?
+-------------
+
+GROWI is a team collaboration software and it forked from [crowi](https://github.com/weseek/crowi/crowi)
+
+see: [weseek/growi](https://github.com/weseek/growi)
+
+What is growi-docker?
+-------------------
+
+The GROWI official docker image for production use which concludes several official plugins.
+
+- [growi-plugin-lsx](https://www.npmjs.com/package/growi-plugin-lsx)
+- [growi-plugin-pukiwiki-like-linker](https://www.npmjs.com/package/growi-plugin-pukiwiki-like-linker)
+- [growi-plugin-attachment-refs](https://www.npmjs.com/package/growi-plugin-attachment-refs)
+
+
+
+Requirements
+-------------
+
+* MongoDB (>= 3.6)
+
+### Optional Dependencies
+
+* ElasticSearch (>= 6.6)
+  * Japanese (kuromoji) Analysis plugin
+  * ICU Analysis Plugin
+
+
+Usage
+-----
+
+```bash
+docker run -d \
+    -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_PORT/growi \
+    weseek/growi
+```
+
+and go to `http://localhost:3000/` .
+
+If you use ElasticSearch, type this:
+
+```bash
+docker run -d \
+    -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_PORT/growi \
+    -e ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi \
+    weseek/growi
+```
+
+
+### docker-compose
+
+Using docker-compose is the fastest and the most convenient way to boot GROWI.
+
+see: [weseek/growi-docker-compose](https://github.com/weseek/growi-docker-compose)
+
+
+Environment Variables
+-------------------
+
+* **Required**
+    * MONGO_URI: URI to connect to MongoDB.
+* **Option**
+    * NODE_ENV: `production` OR `development`.
+    * PORT: Server port. default: `3000`
+    * ELASTICSEARCH_URI: URI to connect to Elasticearch.
+    * REDIS_URI: URI to connect to Redis (use it as a session store instead of MongoDB).
+    * PASSWORD_SEED: A password seed used by password hash generator.
+    * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
+    * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
+    * FILE_UPLOAD: Attached files storage. default: `aws`
+        * `aws` : AWS S3 (needs AWS settings on Admin page)
+        * `mongodb` : MongoDB GridFS (Setting-less)
+        * `local` : Server's Local file system (Setting-less)
+        * `none` : Disable file uploading
+    * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
+    * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
+    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
+    * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
+    * FORCE_WIKI_MODE: Forces wiki mode. default: undefined
+      * `public`  : Forces all pages to become public
+      * `private` : Forces all pages to become private
+      * undefined : Publicity will be configured by the admin security page settings
+    * FORMAT_NODE_LOG: If `false`, Output server log as JSON. defautl: `true` (Enabled only when `NODE_ENV=production`)
+* **Option to integrate with external systems**
+    * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
+        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/management-cookbook/integrate-with-hackmd).**
+    * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
+    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
+    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
+* **Option (Overwritable in admin page)**
+    * APP_SITE_URL: Site URL. e.g. `https://example.com`, `https://example.com:8080`
+    * LOCAL_STRATEGY_ENABLED: Enable or disable ID/Pass login
+    * LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some ID/Pass login options
+    * SAML_ENABLED: Enable or disable SAML
+    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some SAML options
+    * SAML_ENTRY_POINT: IdP entry point
+    * SAML_ISSUER: Issuer string to supply to IdP
+    * SAML_ATTR_MAPPING_ID: Attribute map for id
+    * SAML_ATTR_MAPPING_USERNAME: Attribute map for username
+    * SAML_ATTR_MAPPING_MAIL: Attribute map for email
+    * SAML_ATTR_MAPPING_FIRST_NAME: Attribute map for first name
+    * SAML_ATTR_MAPPING_LAST_NAME:  Attribute map for last name
+    * SAML_CERT: PEM-encoded X.509 signing certificate string to validate the response from IdP
+    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
+    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
+    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
+    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
+    * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
+    * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
+
+Other Documentation
+--------------------
+
+* [GROWI Github wiki](https://github.com/weseek/growi/wiki)
+  * [Questions and Answers](https://github.com/weseek/growi/wiki/Questions-and-Answers)
+
+
+Issues
+------
+
+If you have any issues or questions about this image, please contact us through  [GitHub issue](https://github.com/weseek/growi-docker/issues).
+

+ 10 - 0
docker/bin/remove-except-artifacts.sh

@@ -0,0 +1,10 @@
+#!/bin/sh
+
+set -e
+
+rm -rf \
+  ${appDir}/bin \
+  ${appDir}/docker \
+  ${appDir}/node_modules \
+  ${appDir}/src/client \
+  ${appDir}/babel.config.js \

+ 14 - 0
docker/docker-entrypoint.sh

@@ -0,0 +1,14 @@
+#!/bin/sh
+
+set -e
+
+# Support `FILE_UPLOAD=local`
+mkdir -p /data/uploads
+if [ ! -e "$appDir/public/uploads" ]; then
+  ln -s /data/uploads $appDir/public/uploads
+fi
+
+chown node:node /data/uploads
+chown -h node:node $appDir/public/uploads
+
+su-exec node $@

+ 5 - 0
docker/nocdn/env.prod.js

@@ -0,0 +1,5 @@
+module.exports = {
+  NODE_ENV: 'production',
+  NO_CDN: true,
+  // FORMAT_NODE_LOG: false,
+};

+ 15 - 16
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.18-RC",
+  "version": "3.6.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -33,7 +33,7 @@
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
     "console": "env-cmd -f config/env.dev.js node --experimental-repl-await src/server/console.js",
-    "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
+    "heroku-postbuild": "sh bin/heroku/install-packages.sh && npm run build:prod",
     "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
     "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:styles:fix": "prettier-stylelint --quiet --write src/client/styles/scss/**/*.scss",
@@ -53,8 +53,8 @@
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "resource": "node bin/download-cdn-resources.js",
-    "server:debug": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
-    "server:dev": "env-cmd -f config/env.dev.js node-dev --respawn src/server/app.js",
+    "server:nolazy": "env-cmd -f config/env.dev.js node-dev --nolazy --inspect src/server/app.js",
+    "server:dev": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
     "server:prod:ci": "npm run server:prod -- --ci",
     "server:prod": "env-cmd -f config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",
@@ -84,7 +84,7 @@
     "connect-mongo": "^3.0.0",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
-    "cross-env": "^5.0.5",
+    "cross-env": "^6.0.3",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "diff": "^4.0.1",
@@ -101,15 +101,15 @@
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^4.0.7",
+    "growi-commons": "^4.0.8",
     "helmet": "^3.13.0",
-    "i18next": "^17.0.3",
+    "i18next": "^19.0.0",
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^6.0.0",
+    "migrate-mongo": "^7.0.1",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "mongoose": "5.4.4",
@@ -139,7 +139,7 @@
     "uglifycss": "^0.0.29",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
-    "validator": "^11.1.0",
+    "validator": "^12.0.0",
     "xss": "^1.0.6"
   },
   "devDependencies": {
@@ -174,14 +174,13 @@
     "eslint": "^6.0.1",
     "eslint-config-weseek": "^1.0.3",
     "eslint-plugin-import": "^2.18.0",
-    "eslint-plugin-jest": "^22.7.1",
+    "eslint-plugin-jest": "^23.0.3",
     "eslint-plugin-react": "^7.14.2",
     "file-loader": "^4.0.0",
     "handsontable": "=6.2.2",
-    "i18next-browser-languagedetector": "^3.0.1",
+    "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
     "jest": "^24.8.0",
-    "jest-each": "^24.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -218,7 +217,7 @@
     "react-dropzone": "^10.1.3",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
-    "react-i18next": "^10.6.1",
+    "react-i18next": "^11.1.0",
     "react-waypoint": "^9.0.0",
     "reactstrap": "^8.0.1",
     "replacestream": "^4.0.3",
@@ -248,8 +247,8 @@
     "debug": "src/lib/service/logger/alias-for-debug"
   },
   "engines": {
-    "node": ">=8.11.1 <11",
-    "npm": ">=5.6.0 <7",
-    "yarn": ">=1.5.1 <2"
+    "node": ">=10.17.0 <13",
+    "npm": ">=6.11.3 <7",
+    "yarn": ">=1.19.1 <2"
   }
 }

+ 46 - 18
resource/locales/en-US/translation.json

@@ -113,7 +113,7 @@
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
-  "Export Data": "Export Data",
+  "Export Archive Data": "Export Archive Data",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Register limitation": "Register limitation",
@@ -602,9 +602,9 @@
     "line_break_setting": "Line Break Setting",
     "line_break_setting_desc": "You can change line break settings.",
     "Enable Line Break": "Enable Line Break",
-    "Enable Line Break desc": "Treat line break in the text page as <code>&lt;br&gt;</code> in HTML",
+    "Enable Line Break desc": "Treat line break in the text page as<code>&lt;br&gt;</code>in HTML",
     "Enable Line Break for comment": "Enable Line Break in comment",
-    "Enable Line Break for comment desc": "Treat line break in comment as <code>&lt;br&gt;</code> in HTML",
+    "Enable Line Break for comment desc": "Treat line break in comment as<code>&lt;br&gt;</code>in HTML",
     "presentation_setting": "Presentation Setting",
     "presentation_setting_desc": "You can change presentation settings.",
     "Page break setting": "Page break Setting",
@@ -625,7 +625,10 @@
     "Custom Whitelist": "Custom Whitelist",
     "Tag names":"Tag names",
     "Tag attributes":"Tag attributes",
-    "import_recommended": "Import recommended %s"
+    "import_recommended": "Import recommended %s",
+    "updated_lineBreak": "Succeeded to update line braek setting",
+    "updated_presentation": "Succeeded to update presentation setting",
+    "updated_xss": "Succeeded to update XSS setting"
   },
 
   "notification_setting": {
@@ -705,7 +708,7 @@
     "send_new_password": "Please send the new password to the user.",
     "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
     "reset_password": "Reset Password",
-    "related_username": "Related user's <code>%s</code>",
+    "related_username": "Related user's ",
     "accept": "Accept",
     "deactivate_account":"Deactivate Account",
     "your_own":"You cannot deactivate your own account",
@@ -715,11 +718,12 @@
     "current_users": "Current users:",
     "valid_email": "Valid email address is required",
     "existing_email": "The following emails already exist",
-    "give_user_admin": "Give {{username}} admin success",
-    "remove_user_admin": "Remove {{username}} admin success",
-    "activate_user_success": "Activating {{username}} success",
-    "deactivate_user_success": "Deactivating {{username}} success",
-    "remove_user_success": "Removing {{username}} success"
+    "give_user_admin": "Succeeded to give {{username}} admin",
+    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "activate_user_success": "Succeeded to activating {{username}}",
+    "deactivate_user_success": "Succeeded to deactivate {{username}}",
+    "remove_user_success": "Succeeded to removing {{username}} ",
+    "remove_external_user_success": "Succeeded to remove {{accountId}} "
   },
 
   "user_group_management": {
@@ -746,14 +750,12 @@
   },
 
   "importer_management": {
-    "import_form_esa": "Import from esa.io",
-    "import_form_qiita": "import_from Qiita:Team",
     "beta_warning": "This function is Beta.",
-    "import_from": "Import from %s",
-    "import_form_growi": "Import from GROWI",
+    "import_from": "Import from {{from}}",
+    "import_growi_archive": "Import GROWI Archive",
     "growi_settings": {
       "overwrite_documents": "Imported documents will overwrite existing documents",
-      "zip_file": "Exported Zip File",
+      "growi_archive_file": "GROWI Archive File",
       "uploaded_data": "Uploaded Data",
       "extracted_file": "Extracted File",
       "collection": "Collection",
@@ -763,6 +765,32 @@
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
+      },
+      "configuration": {
+        "pages": {
+          "overwrite_author": {
+            "label": "Overwrite page's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "set_public_to_page": {
+            "label": "Set 'Public' to the pages that is '{{from}}'",
+            "desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
+          },
+          "initialize_meta_datas": {
+            "label": "Initialize page's like, read users and comment count",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "initialize_hackmd_related_datas": {
+            "label": "Initialize HackMD related data",
+            "desc": "Recommended to check this unless there is important drafts on HackMD."
+          }
+        },
+        "revisions": {
+          "overwrite_author": {
+            "label": "Overwrite revision's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          }
+        }
       }
     },
     "esa_settings": {
@@ -788,13 +816,13 @@
     "rebuild_description_3":"This may take a while."
   },
   "export_management": {
-    "exporting_data_list": "Exporting Data List",
-    "exported_data_list": "Exported Data List",
+    "exporting_collection_list": "Exporting Collection List",
+    "exported_data_list": "Exported Archive Data List",
     "export_collections": "Export Collections",
     "check_all": "Check All",
     "uncheck_all": "Uncheck All",
     "desc_password_seed": "DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.<br><br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.",
-    "create_new_exported_data": "Create New Exported Data",
+    "create_new_archive_data": "Create New Archive Data",
     "export": "Export",
     "cancel": "Cancel",
     "file": "File",

+ 40 - 12
resource/locales/ja/translation.json

@@ -113,7 +113,7 @@
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
-  "Export Data": "データエクスポート",
+  "Export Archive Data": "データアーカイブ",
   "Basic Settings": "基本設定",
   "Register limitation": "登録の制限",
   "The contents entered here will be shown in the header etc": "ここに入力した内容は、ヘッダー等に表示されます。",
@@ -609,7 +609,10 @@
     "Custom Whitelist": "カスタムホワイトリスト",
     "Tag names": "タグ名のホワイトリスト",
     "Tag attributes": "タグ属性のホワイトリスト",
-    "import_recommended": "おすすめをインポート"
+    "import_recommended": "おすすめをインポート",
+    "updated_lineBreak": "改行設定を更新しました",
+    "updated_presentation": "プレゼンテーション設定を更新しました",
+    "updated_xss": "XSS設定を更新しました"
   },
 
   "notification_setting": {
@@ -689,7 +692,7 @@
     "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
     "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
     "reset_password": "パスワードの再発行",
-    "related_username": "関連付けられているユーザーの <code>%s</code>",
+    "related_username": "関連付けられているユーザーの ",
     "accept": "承認する",
     "deactivate_account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
@@ -703,7 +706,8 @@
     "remove_user_admin": "{{username}}を管理者から外しました",
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
-    "remove_user_success": "{{username}}を削除しました"
+    "remove_user_success": "{{username}}を削除しました",
+    "remove_external_user_success": "{{accountId}}を削除しました "
   },
 
   "user_group_management": {
@@ -731,14 +735,12 @@
   },
 
   "importer_management": {
-    "import_form_esa": "esa.ioからインポート",
-    "import_form_qiita": "Qiita:Teamからインポート",
     "beta_warning": "この機能はベータ版です",
-    "import_from": "%s からインポート",
-    "import_form_growi": "GROWIからインポート",
+    "import_from": "{{from}} からインポート",
+    "import_growi_archive": "GROWI アーカイブをインポート",
     "growi_settings": {
       "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
-      "zip_file": "エクスポートされた Zip ファイル",
+      "growi_archive_file": "GROWI アーカイブファイル",
       "uploaded_data": "アップロードされたデータ",
       "extracted_file": "展開されたファイル",
       "collection": "コレクション",
@@ -748,6 +750,32 @@
         "at_least_one": "コレクションが選択されていません",
         "page_and_revision": "'Pages' と 'Revisions' はセットでインポートする必要があります",
         "depends": "'{{condition}}' をインポートする場合は、'{{target}}' を一緒に選択する必要があります"
+      },
+      "configuration": {
+        "pages": {
+          "overwrite_author": {
+            "label": "ページ作成者を現在のユーザーで上書きする",
+            "desc": "users を同時に復元しない場合、このオプションは<span class=\"text-danger\">非推奨</span>です。"
+          },
+          "set_public_to_page": {
+            "label": "'{{from}}' 設定のページを '公開' 設定にする",
+            "desc": "全ての <b>'{{from}}'</b> 設定のページが<span class=\"text-danger\">全ユーザーから</span>読み取り可能になることに注意してください。"
+          },
+          "initialize_meta_datas": {
+            "label": "「いいね」「閲覧したユーザー」「コメント数」を初期化する",
+            "desc": "users を同時に復元しない場合、このオプションは<span class=\"text-danger\">非推奨</span>です。"
+          },
+          "initialize_hackmd_related_datas": {
+            "label": "HackMD 関連データを初期化する",
+            "desc": "HackMD に重要な下書きデータがない限りはこのオプションをチェックすることを推奨します。"
+          }
+        },
+        "revisions": {
+          "overwrite_author": {
+            "label": "リビジョン作成者を現在のユーザーで上書きする",
+            "desc": "users を同時に復元しない場合、このオプションは<span class=\"text-danger\">非推奨</span>です。"
+          }
+        }
       }
     },
     "esa_settings": {
@@ -773,13 +801,13 @@
     "rebuild_description_3":""
   },
   "export_management": {
-    "exporting_data_list": "エクスポート中のデータ",
-    "exported_data_list": "エクスポートデータリスト",
+    "exporting_collection_list": "エクスポート中のコレクション",
+    "exported_data_list": "エクスポートされたアーカイブリスト",
     "export_collections": "コレクションのエクスポート",
     "check_all": "全てにチェックを付ける",
     "uncheck_all": "全てからチェックを外す",
     "desc_password_seed": "ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。",
-    "create_new_exported_data": "エクスポートデータの新規作成",
+    "create_new_archive_data": "アーカイブデータの新規作成",
     "export": "エクスポート",
     "cancel": "キャンセル",
     "file": "ファイル名",

+ 17 - 4
src/client/js/app.jsx

@@ -40,11 +40,11 @@ import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
-import ManageExternalAccount from './components/Admin/Users/ManageExternalAccount';
+import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
 import ImportDataPage from './components/Admin/ImportDataPage';
-import ExportDataPage from './components/Admin/ExportDataPage';
+import ExportArchiveDataPage from './components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from './components/Admin/FullTextSearchManagement';
 
 import AppContainer from './services/AppContainer';
@@ -56,6 +56,7 @@ import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 import MarkDownSettingContainer from './services/MarkDownSettingContainer';
+import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
 
 const logger = loggerFactory('growi:app');
 
@@ -109,7 +110,6 @@ let componentMappings = {
 
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-customize': <Customize />,
-  'admin-external-account-setting': <ManageExternalAccount />,
 
   'staff-credit': <StaffCredit />,
   'admin-importer': <ImportDataPage />,
@@ -170,6 +170,19 @@ if (adminUsersElem != null) {
   );
 }
 
+const adminExternalAccountsElem = document.getElementById('admin-external-account-setting');
+if (adminExternalAccountsElem != null) {
+  const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[injectableContainers, adminExternalAccountsContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <ManageExternalAccount />
+      </I18nextProvider>
+    </Provider>,
+    adminExternalAccountsElem,
+  );
+}
+
 const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 if (adminUserGroupDetailElem != null) {
   const userGroupDetailContainer = new UserGroupDetailContainer(appContainer);
@@ -249,7 +262,7 @@ if (adminExportPageElem != null) {
   ReactDOM.render(
     <Provider inject={[appContainer, websocketContainer]}>
       <I18nextProvider i18n={i18n}>
-        <ExportDataPage
+        <ExportArchiveDataPage
           crowi={appContainer}
         />
       </I18nextProvider>

+ 0 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx


+ 10 - 9
src/client/js/components/Admin/ExportData/ZipFileTable.jsx → src/client/js/components/Admin/ExportArchiveData/ArchiveFilesTable.jsx

@@ -3,12 +3,13 @@ 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 {
+import ArchiveFilesTableMenu from './ArchiveFilesTableMenu';
+
+class ArchiveFilesTable extends React.Component {
 
   render() {
     // eslint-disable-next-line no-unused-vars
@@ -26,15 +27,15 @@ class ZipFileTable extends React.Component {
           </tr>
         </thead>
         <tbody>
-          {this.props.zipFileStats.map(({ meta, fileName, fileStats }) => {
+          {this.props.zipFileStats.map(({ meta, fileName, innerFileStats }) => {
             return (
               <tr key={fileName}>
                 <th>{fileName}</th>
                 <td>{meta.version}</td>
-                <td className="text-capitalize">{fileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+                <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
                 <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
                 <td>
-                  <ExportTableMenu
+                  <ArchiveFilesTableMenu
                     fileName={fileName}
                     onZipFileStatRemove={this.props.onZipFileStatRemove}
                   />
@@ -49,7 +50,7 @@ class ZipFileTable extends React.Component {
 
 }
 
-ZipFileTable.propTypes = {
+ArchiveFilesTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
@@ -60,8 +61,8 @@ ZipFileTable.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ZipFileTableWrapper = (props) => {
-  return createSubscribedElement(ZipFileTable, props, [AppContainer]);
+const ArchiveFilesTableWrapper = (props) => {
+  return createSubscribedElement(ArchiveFilesTable, props, [AppContainer]);
 };
 
-export default withTranslation()(ZipFileTableWrapper);
+export default withTranslation()(ArchiveFilesTableWrapper);

+ 5 - 5
src/client/js/components/Admin/ExportData/ExportTableMenu.jsx → src/client/js/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.jsx

@@ -6,7 +6,7 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
-class ExportTableMenu extends React.Component {
+class ArchiveFilesTableMenu extends React.Component {
 
   render() {
     const { t } = this.props;
@@ -35,7 +35,7 @@ class ExportTableMenu extends React.Component {
 
 }
 
-ExportTableMenu.propTypes = {
+ArchiveFilesTableMenu.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   fileName: PropTypes.string.isRequired,
@@ -45,8 +45,8 @@ ExportTableMenu.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ExportTableMenuWrapper = (props) => {
-  return createSubscribedElement(ExportTableMenu, props, [AppContainer]);
+const ArchiveFilesTableMenuWrapper = (props) => {
+  return createSubscribedElement(ArchiveFilesTableMenu, props, [AppContainer]);
 };
 
-export default withTranslation()(ExportTableMenuWrapper);
+export default withTranslation()(ArchiveFilesTableMenuWrapper);

+ 5 - 5
src/client/js/components/Admin/ExportData/ExportZipFormModal.jsx → src/client/js/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -22,7 +22,7 @@ const GROUPS_CONFIG = [
 ];
 const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 
-class ExportZipFormModal extends React.Component {
+class SelectCollectionsModal extends React.Component {
 
   constructor(props) {
     super(props);
@@ -228,7 +228,7 @@ class ExportZipFormModal extends React.Component {
 
 }
 
-ExportZipFormModal.propTypes = {
+SelectCollectionsModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
@@ -241,8 +241,8 @@ ExportZipFormModal.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ExportZipFormModalWrapper = (props) => {
-  return createSubscribedElement(ExportZipFormModal, props, [AppContainer]);
+const SelectCollectionsModalWrapper = (props) => {
+  return createSubscribedElement(SelectCollectionsModal, props, [AppContainer]);
 };
 
-export default withTranslation()(ExportZipFormModalWrapper);
+export default withTranslation()(SelectCollectionsModalWrapper);

+ 16 - 15
src/client/js/components/Admin/ExportDataPage.jsx → src/client/js/components/Admin/ExportArchiveDataPage.jsx

@@ -4,17 +4,18 @@ import { withTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
 
-import ProgressBar from './Common/ProgressBar';
 import { createSubscribedElement } from '../UnstatedUtils';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
 import AppContainer from '../../services/AppContainer';
 import WebsocketContainer from '../../services/WebsocketContainer';
-// import { toastSuccess, toastError } from '../../../util/apiNotification';
 
+import ProgressBar from './Common/ProgressBar';
 
-import ExportZipFormModal from './ExportData/ExportZipFormModal';
-import ZipFileTable from './ExportData/ZipFileTable';
+import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
+import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
 
-class ExportDataPage extends React.Component {
+class ExportArchiveDataPage extends React.Component {
 
   constructor(props) {
     super(props);
@@ -86,7 +87,7 @@ class ExportDataPage extends React.Component {
       });
 
       // TODO: toastSuccess, toastError
-      toastr.success(undefined, `New Exported Data '${addedZipFileStat.fileName}' is added`, {
+      toastr.success(undefined, `New Archive Data '${addedZipFileStat.fileName}' is added`, {
         closeButton: true,
         progressBar: true,
         newestOnTop: false,
@@ -201,15 +202,15 @@ class ExportDataPage extends React.Component {
 
     return (
       <Fragment>
-        <h2>{t('Export Data')}</h2>
+        <h2>{t('Export Archive Data')}</h2>
 
         <button type="button" className="btn btn-default" disabled={isExporting} onClick={this.openExportModal}>
-          {t('export_management.create_new_exported_data')}
+          {t('export_management.create_new_archive_data')}
         </button>
 
         { showExportingData && (
           <div className="mt-5">
-            <h3>{t('export_management.exporting_data_list')}</h3>
+            <h3>{t('export_management.exporting_collection_list')}</h3>
             { this.renderProgressBarsForCollections() }
             { this.renderProgressBarForZipping() }
           </div>
@@ -217,13 +218,13 @@ class ExportDataPage extends React.Component {
 
         <div className="mt-5">
           <h3>{t('export_management.exported_data_list')}</h3>
-          <ZipFileTable
+          <ArchiveFilesTable
             zipFileStats={this.state.zipFileStats}
             onZipFileStatRemove={this.onZipFileStatRemove}
           />
         </div>
 
-        <ExportZipFormModal
+        <SelectCollectionsModal
           isOpen={this.state.isExportModalOpen}
           onExportingRequested={this.exportingRequestedHandler}
           onClose={this.closeExportModal}
@@ -235,7 +236,7 @@ class ExportDataPage extends React.Component {
 
 }
 
-ExportDataPage.propTypes = {
+ExportArchiveDataPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
@@ -244,8 +245,8 @@ ExportDataPage.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ExportDataPageFormWrapper = (props) => {
-  return createSubscribedElement(ExportDataPage, props, [AppContainer, WebsocketContainer]);
+const ExportArchiveDataPageWrapper = (props) => {
+  return createSubscribedElement(ExportArchiveDataPage, props, [AppContainer, WebsocketContainer]);
 };
 
-export default withTranslation()(ExportDataPageFormWrapper);
+export default withTranslation()(ExportArchiveDataPageWrapper);

+ 52 - 0
src/client/js/components/Admin/ImportData/GrowiArchive/ErrorViewer.jsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+
+import { createSubscribedElement } from '../../../UnstatedUtils';
+
+
+class ErrorViewer extends React.Component {
+
+  render() {
+    const { errors } = this.props;
+
+    let value = '(no errors)';
+    if (errors != null && errors.length > 0) {
+      const lines = errors.map((obj) => {
+        return JSON.stringify(obj);
+      });
+      value = lines.join('\n');
+    }
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
+        <Modal.Header closeButton className="bg-danger">
+          <Modal.Title className="text-white">Errors</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <textarea className="form-control" rows="8" readOnly wrap="off" defaultValue={value}></textarea>
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
+}
+
+ErrorViewer.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+
+  errors: PropTypes.arrayOf(PropTypes.object),
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ErrorViewerWrapper = (props) => {
+  return createSubscribedElement(ErrorViewer, props, []);
+};
+
+export default withTranslation()(ErrorViewerWrapper);

+ 228 - 0
src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx

@@ -0,0 +1,228 @@
+/* eslint-disable react/no-danger */
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+
+import GrowiArchiveImportOption from '@commons/models/admin/growi-archive-import-option';
+
+import { createSubscribedElement } from '../../../UnstatedUtils';
+import AppContainer from '../../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+
+class ImportCollectionConfigurationModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      option: null,
+    };
+
+    this.initialize = this.initialize.bind(this);
+    this.updateOption = this.updateOption.bind(this);
+  }
+
+  async initialize() {
+    await this.setState({
+      option: Object.assign({}, this.props.option), // clone
+    });
+  }
+
+  /**
+   * invoked when the value of control is changed
+   * @param {object} updateObj
+   */
+  changeHandler(updateObj) {
+    const { option } = this.state;
+    const newOption = Object.assign(option, updateObj);
+    this.setState({ option: newOption });
+  }
+
+  updateOption() {
+    const {
+      collectionName, onOptionChange, onClose,
+    } = this.props;
+
+    if (onOptionChange != null) {
+      onOptionChange(collectionName, this.state.option);
+    }
+
+    onClose();
+  }
+
+  renderPagesContents() {
+    const { t } = this.props;
+    const { option } = this.state;
+
+    const translationBase = 'importer_management.growi_settings.configuration.pages';
+
+    /* eslint-disable react/no-unescaped-entities */
+    return (
+      <>
+        <div className="checkbox checkbox-warning">
+          <input
+            id="cbOpt4"
+            type="checkbox"
+            checked={option.isOverwriteAuthorWithCurrentUser || false} // add ' || false' to avoid uncontrolled input warning
+            onChange={() => this.changeHandler({ isOverwriteAuthorWithCurrentUser: !option.isOverwriteAuthorWithCurrentUser })}
+          />
+          <label htmlFor="cbOpt4">
+            {t(`${translationBase}.overwrite_author.label`)}
+            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
+          </label>
+        </div>
+        <div className="checkbox checkbox-warning">
+          <input
+            id="cbOpt1"
+            type="checkbox"
+            checked={option.makePublicForGrant2 || false} // add ' || false' to avoid uncontrolled input warning
+            onChange={() => this.changeHandler({ makePublicForGrant2: !option.makePublicForGrant2 })}
+          />
+          <label htmlFor="cbOpt1">
+            {t(`${translationBase}.set_public_to_page.label`, { from: t('Anyone with the link') })}
+            <p
+              className="help-block mt-0"
+              dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Anyone with the link') }) }}
+            />
+          </label>
+        </div>
+        <div className="checkbox checkbox-warning">
+          <input
+            id="cbOpt2"
+            type="checkbox"
+            checked={option.makePublicForGrant4 || false} // add ' || false' to avoid uncontrolled input warning
+            onChange={() => this.changeHandler({ makePublicForGrant4: !option.makePublicForGrant4 })}
+          />
+          <label htmlFor="cbOpt2">
+            {t(`${translationBase}.set_public_to_page.label`, { from: t('Just me') })}
+            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Just me') }) }} />
+          </label>
+        </div>
+        <div className="checkbox checkbox-warning">
+          <input
+            id="cbOpt3"
+            type="checkbox"
+            checked={option.makePublicForGrant5 || false} // add ' || false' to avoid uncontrolled input warning
+            onChange={() => this.changeHandler({ makePublicForGrant5: !option.makePublicForGrant5 })}
+          />
+          <label htmlFor="cbOpt3">
+            {t(`${translationBase}.set_public_to_page.label`, { from: t('Only inside the group') })}
+            <p
+              className="help-block mt-0"
+              dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Only inside the group') }) }}
+            />
+          </label>
+        </div>
+        <div className="checkbox checkbox-default">
+          <input
+            id="cbOpt5"
+            type="checkbox"
+            checked={option.initPageMetadatas || false} // add ' || false' to avoid uncontrolled input warning
+            onChange={() => this.changeHandler({ initPageMetadatas: !option.initPageMetadatas })}
+          />
+          <label htmlFor="cbOpt5">
+            {t(`${translationBase}.initialize_meta_datas.label`)}
+            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.initialize_meta_datas.desc`) }} />
+          </label>
+        </div>
+        <div className="checkbox checkbox-default">
+          <input
+            id="cbOpt6"
+            type="checkbox"
+            checked={option.initHackmdDatas || false} // add ' || false' to avoid uncontrolled input warning
+            onChange={() => this.changeHandler({ initHackmdDatas: !option.initHackmdDatas })}
+          />
+          <label htmlFor="cbOpt6">
+            {t(`${translationBase}.initialize_hackmd_related_datas.label`)}
+            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.initialize_hackmd_related_datas.desc`) }} />
+          </label>
+        </div>
+      </>
+    );
+    /* eslint-enable react/no-unescaped-entities */
+  }
+
+  renderRevisionsContents() {
+    const { t } = this.props;
+    const { option } = this.state;
+
+    const translationBase = 'importer_management.growi_settings.configuration.revisions';
+
+    /* eslint-disable react/no-unescaped-entities */
+    return (
+      <>
+        <div className="checkbox checkbox-warning">
+          <input
+            id="cbOpt1"
+            type="checkbox"
+            checked={option.isOverwriteAuthorWithCurrentUser || false} // add ' || false' to avoid uncontrolled input warning
+            onChange={() => this.changeHandler({ isOverwriteAuthorWithCurrentUser: !option.isOverwriteAuthorWithCurrentUser })}
+          />
+          <label htmlFor="cbOpt1">
+            {t(`${translationBase}.overwrite_author.label`)}
+            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
+          </label>
+        </div>
+      </>
+    );
+    /* eslint-enable react/no-unescaped-entities */
+  }
+
+  render() {
+    const { t, collectionName } = this.props;
+    const { option } = this.state;
+
+    let contents = null;
+    if (option != null) {
+      switch (collectionName) {
+        case 'pages':
+          contents = this.renderPagesContents();
+          break;
+        case 'revisions':
+          contents = this.renderRevisionsContents();
+          break;
+      }
+    }
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose} onEnter={this.initialize}>
+        <Modal.Header closeButton>
+          <Modal.Title>{`'${collectionName}'`} Configuration</Modal.Title>
+        </Modal.Header>
+
+        <Modal.Body>
+          {contents}
+        </Modal.Body>
+
+        <Modal.Footer>
+          <button type="button" className="btn btn-sm btn-default" onClick={this.props.onClose}>{t('Cancel')}</button>
+          <button type="button" className="btn btn-sm btn-primary" onClick={this.updateOption}>{t('Update')}</button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+ImportCollectionConfigurationModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+  onOptionChange: PropTypes.func,
+
+  collectionName: PropTypes.string,
+  option: PropTypes.instanceOf(GrowiArchiveImportOption).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ImportCollectionConfigurationModalWrapper = (props) => {
+  return createSubscribedElement(ImportCollectionConfigurationModal, props, [AppContainer]);
+};
+
+export default withTranslation()(ImportCollectionConfigurationModalWrapper);

+ 253 - 0
src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -0,0 +1,253 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+// eslint-disable-next-line no-unused-vars
+import { withTranslation } from 'react-i18next';
+
+import ProgressBar from 'react-bootstrap/es/ProgressBar';
+
+import GrowiArchiveImportOption from '@commons/models/admin/growi-archive-import-option';
+
+
+const MODE_ATTR_MAP = {
+  insert: { color: 'info', icon: 'icon-plus', label: 'Insert' },
+  upsert: { color: 'success', icon: 'icon-plus', label: 'Upsert' },
+  flushAndInsert: { color: 'danger', icon: 'icon-refresh', label: 'Flush and Insert' },
+};
+
+export const DEFAULT_MODE = 'insert';
+
+export const MODE_RESTRICTED_COLLECTION = {
+  configs: ['flushAndInsert'],
+  users: ['insert', 'upsert'],
+};
+
+export default class ImportCollectionItem extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.changeHandler = this.changeHandler.bind(this);
+    this.modeSelectedHandler = this.modeSelectedHandler.bind(this);
+    this.configButtonClickedHandler = this.configButtonClickedHandler.bind(this);
+    this.errorLinkClickedHandler = this.errorLinkClickedHandler.bind(this);
+  }
+
+  changeHandler(e) {
+    const { collectionName, onChange } = this.props;
+
+    if (onChange != null) {
+      onChange(collectionName, e.target.checked);
+    }
+  }
+
+  modeSelectedHandler(mode) {
+    const { collectionName, onOptionChange } = this.props;
+
+    if (onOptionChange == null) {
+      return;
+    }
+
+    onOptionChange(collectionName, { mode });
+  }
+
+  configButtonClickedHandler() {
+    const { collectionName, onConfigButtonClicked } = this.props;
+
+    if (onConfigButtonClicked == null) {
+      return;
+    }
+
+    onConfigButtonClicked(collectionName);
+  }
+
+  errorLinkClickedHandler() {
+    const { collectionName, onErrorLinkClicked } = this.props;
+
+    if (onErrorLinkClicked == null) {
+      return;
+    }
+
+    onErrorLinkClicked(collectionName);
+  }
+
+  renderModeLabel(mode, isColorized = false) {
+    const attrMap = MODE_ATTR_MAP[mode];
+    const className = isColorized ? `text-${attrMap.color}` : '';
+    return <span className={className}><i className={attrMap.icon}></i> {attrMap.label}</span>;
+  }
+
+  renderCheckbox() {
+    const {
+      collectionName, isSelected, isImporting,
+    } = this.props;
+
+    return (
+      <div className="checkbox checkbox-info my-0">
+        <input
+          type="checkbox"
+          id={collectionName}
+          name={collectionName}
+          className="form-check-input"
+          value={collectionName}
+          checked={isSelected}
+          disabled={isImporting}
+          onChange={this.changeHandler}
+        />
+        <label className="text-capitalize form-check-label" htmlFor={collectionName}>
+          {collectionName}
+        </label>
+      </div>
+    );
+  }
+
+  renderModeSelector() {
+    const {
+      collectionName, option, isImporting,
+    } = this.props;
+
+    const attrMap = MODE_ATTR_MAP[option.mode];
+    const btnColor = `btn-${attrMap.color}`;
+
+    const modes = MODE_RESTRICTED_COLLECTION[collectionName] || Object.keys(MODE_ATTR_MAP);
+
+    return (
+      <span className="d-inline-flex align-items-center">
+        Mode:&nbsp;
+        <div className="dropdown d-inline-block">
+          <button
+            className={`btn ${btnColor} btn-xs dropdown-toggle`}
+            type="button"
+            id="ddmMode"
+            disabled={isImporting}
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="true"
+          >
+            {this.renderModeLabel(option.mode)}
+            <span className="caret ml-2"></span>
+          </button>
+          <ul className="dropdown-menu" aria-labelledby="ddmMode">
+            { modes.map((mode) => {
+              return (
+                <li key={`buttonMode_${mode}`}>
+                  <a type="button" role="button" onClick={() => this.modeSelectedHandler(mode)}>
+                    {this.renderModeLabel(mode, true)}
+                  </a>
+                </li>
+              );
+            }) }
+          </ul>
+        </div>
+      </span>
+    );
+  }
+
+  renderConfigButton() {
+    const { isImporting, isConfigButtonAvailable } = this.props;
+
+    return (
+      <button
+        type="button"
+        className="btn btn-default btn-xs ml-2"
+        disabled={isImporting || !isConfigButtonAvailable}
+        onClick={isConfigButtonAvailable ? this.configButtonClickedHandler : null}
+      >
+        <i className="icon-settings"></i>
+      </button>
+    );
+  }
+
+  renderProgressBar() {
+    const {
+      isImporting, insertedCount, modifiedCount, errorsCount,
+    } = this.props;
+
+    const total = insertedCount + modifiedCount + errorsCount;
+
+    return (
+      <ProgressBar className="mb-0">
+        <ProgressBar max={total} striped={isImporting} active={isImporting} now={insertedCount} bsStyle="info" />
+        <ProgressBar max={total} striped={isImporting} active={isImporting} now={modifiedCount} bsStyle="success" />
+        <ProgressBar max={total} striped={isImporting} active={isImporting} now={errorsCount} bsStyle="danger" />
+      </ProgressBar>
+    );
+  }
+
+  renderBody() {
+    const { isImporting, isImported } = this.props;
+
+    if (!isImporting && !isImported) {
+      return 'Ready';
+    }
+
+    const { insertedCount, modifiedCount, errorsCount } = this.props;
+    return (
+      <div className="w-100 text-center">
+        <span className="text-info"><strong>{insertedCount}</strong> Inserted</span>,&nbsp;
+        <span className="text-success"><strong>{modifiedCount}</strong> Modified</span>,&nbsp;
+        { errorsCount > 0
+          ? <a className="text-danger" role="button" onClick={this.errorLinkClickedHandler}><u><strong>{errorsCount}</strong> Failed</u></a>
+          : <span className="text-muted"><strong>0</strong> Failed</span>
+        }
+      </div>
+    );
+
+  }
+
+  render() {
+    const {
+      isSelected,
+    } = this.props;
+
+    return (
+      <div className="panel panel-default">
+        <div className="panel-heading">
+          <div className="d-flex justify-content-between align-items-center">
+            {/* left */}
+            {this.renderCheckbox()}
+            {/* right */}
+            <span className="d-flex align-items-center">
+              {this.renderModeSelector()}
+              {this.renderConfigButton()}
+            </span>
+          </div>
+        </div>
+        { isSelected && (
+          <>
+            {this.renderProgressBar()}
+            <div className="panel-body">
+              {this.renderBody()}
+            </div>
+          </>
+        ) }
+      </div>
+    );
+  }
+
+}
+
+ImportCollectionItem.propTypes = {
+  collectionName: PropTypes.string.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  option: PropTypes.instanceOf(GrowiArchiveImportOption).isRequired,
+
+  isImporting: PropTypes.bool.isRequired,
+  isImported: PropTypes.bool.isRequired,
+  insertedCount: PropTypes.number,
+  modifiedCount: PropTypes.number,
+  errorsCount: PropTypes.number,
+
+  isConfigButtonAvailable: PropTypes.bool,
+
+  onChange: PropTypes.func,
+  onOptionChange: PropTypes.func,
+  onConfigButtonClicked: PropTypes.func,
+  onErrorLinkClicked: PropTypes.func,
+};
+
+ImportCollectionItem.defaultProps = {
+  insertedCount: 0,
+  modifiedCount: 0,
+  errorsCount: 0,
+};

+ 507 - 0
src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -0,0 +1,507 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import GrowiArchiveImportOption from '@commons/models/admin/growi-archive-import-option';
+import ImportOptionForPages from '@commons/models/admin/import-option-for-pages';
+import ImportOptionForRevisions from '@commons/models/admin/import-option-for-revisions';
+
+import { createSubscribedElement } from '../../../UnstatedUtils';
+import AppContainer from '../../../../services/AppContainer';
+import WebsocketContainer from '../../../../services/WebsocketContainer';
+import { toastSuccess, toastError } from '../../../../util/apiNotification';
+
+
+import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportCollectionItem';
+import ImportCollectionConfigurationModal from './ImportCollectionConfigurationModal';
+import ErrorViewer from './ErrorViewer';
+
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+const IMPORT_OPTION_CLASS_MAPPING = {
+  pages: ImportOptionForPages,
+  revisions: ImportOptionForRevisions,
+};
+
+class ImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      isImporting: false,
+      isImported: false,
+      progressMap: [],
+      errorsMap: [],
+
+      selectedCollections: new Set(),
+
+      // store relations from collection name to file name
+      collectionNameToFileNameMap: {},
+      // store relations from collection name to GrowiArchiveImportOption instance
+      optionsMap: {},
+
+      isConfigurationModalOpen: false,
+      collectionNameForConfiguration: null,
+
+      isErrorsViewerOpen: false,
+      collectionNameForErrorsViewer: null,
+
+      canImport: false,
+      warnForPageGroups: [],
+      warnForUserGroups: [],
+      warnForConfigGroups: [],
+      warnForOtherGroups: [],
+    };
+
+    this.props.innerFileStats.forEach((fileStat) => {
+      const { fileName, collectionName } = fileStat;
+      this.initialState.collectionNameToFileNameMap[collectionName] = fileName;
+
+      // determine initial mode
+      const initialMode = (MODE_RESTRICTED_COLLECTION[collectionName] != null)
+        ? MODE_RESTRICTED_COLLECTION[collectionName][0]
+        : DEFAULT_MODE;
+      // create GrowiArchiveImportOption instance
+      const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
+      this.initialState.optionsMap[collectionName] = new ImportOption(initialMode);
+    });
+
+    this.state = this.initialState;
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.checkAll = this.checkAll.bind(this);
+    this.uncheckAll = this.uncheckAll.bind(this);
+    this.updateOption = this.updateOption.bind(this);
+    this.openConfigurationModal = this.openConfigurationModal.bind(this);
+    this.showErrorsViewer = this.showErrorsViewer.bind(this);
+    this.validate = this.validate.bind(this);
+    this.import = this.import.bind(this);
+  }
+
+  get allCollectionNames() {
+    return Object.keys(this.state.collectionNameToFileNameMap);
+  }
+
+  componentWillMount() {
+    this.setupWebsocketEventHandler();
+  }
+
+  componentWillUnmount() {
+    this.teardownWebsocketEventHandler();
+  }
+
+  setupWebsocketEventHandler() {
+    const socket = this.props.websocketContainer.getWebSocket();
+
+    // websocket event
+    // eslint-disable-next-line object-curly-newline
+    socket.on('admin:onProgressForImport', ({ collectionName, collectionProgress, appendedErrors }) => {
+      const { progressMap, errorsMap } = this.state;
+      progressMap[collectionName] = collectionProgress;
+
+      const errors = errorsMap[collectionName] || [];
+      errorsMap[collectionName] = errors.concat(appendedErrors);
+
+      this.setState({
+        isImporting: true,
+        progressMap,
+        errorsMap,
+      });
+    });
+
+    // websocket event
+    socket.on('admin:onTerminateForImport', () => {
+      this.setState({
+        isImporting: false,
+        isImported: true,
+      });
+
+      toastSuccess(undefined, 'Import process has terminated.');
+    });
+
+    // websocket event
+    socket.on('admin:onErrorForImport', (err) => {
+      this.setState({
+        isImporting: false,
+        isImported: false,
+      });
+
+      toastError(err, 'Import process has failed.');
+    });
+  }
+
+  teardownWebsocketEventHandler() {
+    const socket = this.props.websocketContainer.getWebSocket();
+
+    socket.removeAllListeners('admin:onProgressForImport');
+    socket.removeAllListeners('admin:onTerminateForImport');
+  }
+
+  async toggleCheckbox(collectionName, bool) {
+    const selectedCollections = new Set(this.state.selectedCollections);
+    if (bool) {
+      selectedCollections.add(collectionName);
+    }
+    else {
+      selectedCollections.delete(collectionName);
+    }
+
+    await this.setState({ selectedCollections });
+
+    this.validate();
+  }
+
+  async checkAll() {
+    await this.setState({ selectedCollections: new Set(this.allCollectionNames) });
+    this.validate();
+  }
+
+  async uncheckAll() {
+    await this.setState({ selectedCollections: new Set() });
+    this.validate();
+  }
+
+  updateOption(collectionName, data) {
+    const { optionsMap } = this.state;
+    const options = optionsMap[collectionName];
+
+    // merge
+    Object.assign(options, data);
+
+    optionsMap[collectionName] = options;
+    this.setState({ optionsMap });
+  }
+
+  openConfigurationModal(collectionName) {
+    this.setState({ isConfigurationModalOpen: true, collectionNameForConfiguration: collectionName });
+  }
+
+  showErrorsViewer(collectionName) {
+    this.setState({ isErrorsViewerOpen: true, collectionNameForErrorsViewer: collectionName });
+  }
+
+  async validate() {
+    // init errors
+    await this.setState({
+      warnForPageGroups: [],
+      warnForUserGroups: [],
+      warnForConfigGroups: [],
+      warnForOtherGroups: [],
+    });
+
+    await this.validateCollectionSize();
+    await this.validatePagesCollectionPairs();
+    await this.validateExternalAccounts();
+    await this.validateUserGroups();
+    await this.validateUserGroupRelations();
+
+    const errors = [
+      ...this.state.warnForPageGroups,
+      ...this.state.warnForUserGroups,
+      ...this.state.warnForConfigGroups,
+      ...this.state.warnForOtherGroups,
+    ];
+    const canImport = errors.length === 0;
+
+    this.setState({ canImport });
+  }
+
+  async validateCollectionSize(validationErrors) {
+    const { t } = this.props;
+    const { warnForOtherGroups, selectedCollections } = this.state;
+
+    if (selectedCollections.size === 0) {
+      warnForOtherGroups.push(t('importer_management.growi_settings.errors.at_least_one'));
+    }
+
+    this.setState({ warnForOtherGroups });
+  }
+
+  async validatePagesCollectionPairs() {
+    const { t } = this.props;
+    const { warnForPageGroups, selectedCollections } = this.state;
+
+    const pageRelatedCollectionsLength = ['pages', 'revisions'].filter((collectionName) => {
+      return selectedCollections.has(collectionName);
+    }).length;
+
+    // MUST be included both or neither when importing
+    if (pageRelatedCollectionsLength !== 0 && pageRelatedCollectionsLength !== 2) {
+      warnForPageGroups.push(t('importer_management.growi_settings.errors.page_and_revision'));
+    }
+
+    this.setState({ warnForPageGroups });
+  }
+
+  async validateExternalAccounts() {
+    const { t } = this.props;
+    const { warnForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'users' if 'externalaccounts' is selected
+    if (selectedCollections.has('externalaccounts')) {
+      if (!selectedCollections.has('users')) {
+        warnForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Externalaccounts' }));
+      }
+    }
+
+    this.setState({ warnForUserGroups });
+  }
+
+  async validateUserGroups() {
+    const { t } = this.props;
+    const { warnForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'users' if 'usergroups' is selected
+    if (selectedCollections.has('usergroups')) {
+      if (!selectedCollections.has('users')) {
+        warnForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Usergroups' }));
+      }
+    }
+
+    this.setState({ warnForUserGroups });
+  }
+
+  async validateUserGroupRelations() {
+    const { t } = this.props;
+    const { warnForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'usergroups' if 'usergrouprelations' is selected
+    if (selectedCollections.has('usergrouprelations')) {
+      if (!selectedCollections.has('usergroups')) {
+        warnForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Usergroups', condition: 'Usergrouprelations' }));
+      }
+    }
+
+    this.setState({ warnForUserGroups });
+  }
+
+  async import() {
+    const { appContainer, fileName, onPostImport } = this.props;
+    const { selectedCollections, optionsMap } = this.state;
+
+    // init progress data
+    await this.setState({
+      isImporting: true,
+      progressMap: [],
+      errorsMap: [],
+    });
+
+    try {
+      // TODO: use appContainer.apiv3.post
+      await appContainer.apiv3Post('/import', {
+        fileName,
+        collections: Array.from(selectedCollections),
+        optionsMap,
+      });
+
+      if (onPostImport != null) {
+        onPostImport();
+      }
+
+      toastSuccess(undefined, 'Import process has requested.');
+    }
+    catch (err) {
+      toastError(err, 'Import request failed.');
+    }
+  }
+
+  renderWarnForGroups(errors, key) {
+    if (errors.length === 0) {
+      return null;
+    }
+
+    return (
+      <div key={key} className="alert alert-warning">
+        <ul>
+          { errors.map((error, index) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`${key}-${index}`}>{error}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }
+
+  renderGroups(groupList, groupName, errors, { wellContent } = {}) {
+    const collectionNames = groupList.filter((collectionName) => {
+      return this.allCollectionNames.includes(collectionName);
+    });
+
+    if (collectionNames.length === 0) {
+      return null;
+    }
+
+    return (
+      <div className="mt-4">
+        <legend>{groupName} Collections</legend>
+        { wellContent != null && (
+          <div className="well well-sm small">
+            <ul>
+              <li>{wellContent}</li>
+            </ul>
+          </div>
+        ) }
+        { this.renderImportItems(collectionNames) }
+        { this.renderWarnForGroups(errors, `warnFor${groupName}`) }
+      </div>
+    );
+  }
+
+  renderOthers() {
+    const collectionNames = this.allCollectionNames.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    return this.renderGroups(collectionNames, 'Other', this.state.warnForOtherGroups);
+  }
+
+  renderImportItems(collectionNames) {
+    const {
+      isImporting,
+      isImported,
+      progressMap,
+      errorsMap,
+
+      selectedCollections,
+      optionsMap,
+    } = this.state;
+
+    return (
+      <div className="row">
+        {collectionNames.map((collectionName) => {
+          const collectionProgress = progressMap[collectionName];
+          const errors = errorsMap[collectionName];
+          const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
+
+          return (
+            <div className="col-xs-6 my-1" key={collectionName}>
+              <ImportCollectionItem
+                isImporting={isImporting}
+                isImported={collectionProgress ? isImported : false}
+                insertedCount={collectionProgress ? collectionProgress.insertedCount : 0}
+                modifiedCount={collectionProgress ? collectionProgress.modifiedCount : 0}
+                errorsCount={errors ? errors.length : 0}
+
+                collectionName={collectionName}
+                isSelected={selectedCollections.has(collectionName)}
+                option={optionsMap[collectionName]}
+
+                isConfigButtonAvailable={isConfigButtonAvailable}
+
+                onChange={this.toggleCheckbox}
+                onOptionChange={this.updateOption}
+                onConfigButtonClicked={this.openConfigurationModal}
+                onErrorLinkClicked={this.showErrorsViewer}
+              />
+            </div>
+          );
+        })}
+      </div>
+    );
+  }
+
+  renderConfigurationModal() {
+    const { isConfigurationModalOpen, collectionNameForConfiguration: collectionName, optionsMap } = this.state;
+
+    if (collectionName == null) {
+      return null;
+    }
+
+    return (
+      <ImportCollectionConfigurationModal
+        isOpen={isConfigurationModalOpen}
+        onClose={() => this.setState({ isConfigurationModalOpen: false })}
+        onOptionChange={this.updateOption}
+        collectionName={collectionName}
+        option={optionsMap[collectionName]}
+      />
+    );
+  }
+
+  renderErrorsViewer() {
+    const { isErrorsViewerOpen, errorsMap, collectionNameForErrorsViewer } = this.state;
+    const errors = errorsMap[collectionNameForErrorsViewer];
+
+    return (
+      <ErrorViewer
+        isOpen={isErrorsViewerOpen}
+        onClose={() => this.setState({ isErrorsViewerOpen: false })}
+        errors={errors}
+      />
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    const {
+      canImport, isImporting,
+      warnForPageGroups, warnForUserGroups, warnForConfigGroups,
+    } = this.state;
+
+    return (
+      <>
+        <form className="form-inline">
+          <div className="form-group">
+            <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>
+          </div>
+          <div className="form-group">
+            <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>
+        </form>
+
+        { this.renderGroups(GROUPS_PAGE, 'Page', warnForPageGroups, { wellContent: t('importer_management.growi_settings.overwrite_documents') }) }
+        { this.renderGroups(GROUPS_USER, 'User', warnForUserGroups) }
+        { this.renderGroups(GROUPS_CONFIG, 'Config', warnForConfigGroups) }
+        { this.renderOthers() }
+
+        <div className="mt-4 text-center">
+          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
+            { t('importer_management.growi_settings.discard') }
+          </button>
+          <button type="button" className="btn btn-primary mx-1" onClick={this.import} disabled={!canImport || isImporting}>
+            { t('importer_management.import') }
+          </button>
+        </div>
+
+        { this.renderConfigurationModal() }
+        { this.renderErrorsViewer() }
+      </>
+    );
+  }
+
+}
+
+ImportForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+
+  fileName: PropTypes.string,
+  innerFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onDiscard: PropTypes.func.isRequired,
+  onPostImport: PropTypes.func,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ImportFormWrapper = (props) => {
+  return createSubscribedElement(ImportForm, props, [AppContainer, WebsocketContainer]);
+};
+
+export default withTranslation()(ImportFormWrapper);

+ 10 - 10
src/client/js/components/Admin/ImportData/GrowiZipUploadForm.jsx → src/client/js/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -2,11 +2,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
+import { createSubscribedElement } from '../../../UnstatedUtils';
+import AppContainer from '../../../../services/AppContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
-class GrowiZipUploadForm extends React.Component {
+class UploadForm extends React.Component {
 
   constructor(props) {
     super(props);
@@ -31,8 +31,7 @@ class GrowiZipUploadForm extends React.Component {
     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);
+    const { data } = await this.props.appContainer.apiv3Post('/import/upload', formData);
     this.props.onUpload(data);
     // TODO: toastSuccess, toastError
   }
@@ -52,12 +51,13 @@ class GrowiZipUploadForm extends React.Component {
       <form className="form-horizontal" onSubmit={this.uploadZipFile}>
         <fieldset>
           <div className="form-group">
-            <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.zip_file')}</label>
+            <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.growi_archive_file')}</label>
             <div className="col-xs-6">
               <input
                 type="file"
                 name="file"
                 className="form-control-file"
+                accept=".growi.zip"
                 ref={this.inputRef}
                 onChange={this.changeFileName}
               />
@@ -77,7 +77,7 @@ class GrowiZipUploadForm extends React.Component {
 
 }
 
-GrowiZipUploadForm.propTypes = {
+UploadForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   onUpload: PropTypes.func.isRequired,
@@ -86,8 +86,8 @@ GrowiZipUploadForm.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const GrowiZipUploadFormWrapper = (props) => {
-  return createSubscribedElement(GrowiZipUploadForm, props, [AppContainer]);
+const UploadFormWrapper = (props) => {
+  return createSubscribedElement(UploadForm, props, [AppContainer]);
 };
 
-export default withTranslation()(GrowiZipUploadFormWrapper);
+export default withTranslation()(UploadFormWrapper);

+ 27 - 22
src/client/js/components/Admin/ImportData/GrowiZipImportSection.jsx → src/client/js/components/Admin/ImportData/GrowiArchiveSection.jsx

@@ -7,17 +7,17 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
-import GrowiZipUploadForm from './GrowiZipUploadForm';
-import GrowiZipImportForm from './GrowiZipImportForm';
+import UploadForm from './GrowiArchive/UploadForm';
+import ImportForm from './GrowiArchive/ImportForm';
 
-class GrowiZipImportSection extends React.Component {
+class GrowiArchiveSection extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.initialState = {
-      fileName: '',
-      fileStats: [],
+      fileName: null,
+      innerFileStats: null,
     };
 
     this.state = this.initialState;
@@ -27,17 +27,27 @@ class GrowiZipImportSection extends React.Component {
     this.resetState = this.resetState.bind(this);
   }
 
-  handleUpload({ meta, fileName, fileStats }) {
+  async componentWillMount() {
+    // get uploaded file status
+    const res = await this.props.appContainer.apiv3Get('/import/status');
+
+    if (res.data.zipFileStat != null) {
+      const { fileName, innerFileStats } = res.data.zipFileStat;
+      this.setState({ fileName, innerFileStats });
+    }
+  }
+
+  handleUpload({ meta, fileName, innerFileStats }) {
     this.setState({
       fileName,
-      fileStats,
+      innerFileStats,
     });
   }
 
   async discardData() {
     try {
       const { fileName } = this.state;
-      await this.props.appContainer.apiDelete(`/v3/import/${this.state.fileName}`, {});
+      await this.props.appContainer.apiv3Delete('/import/all');
       this.resetState();
 
       // TODO: toastSuccess, toastError
@@ -73,23 +83,18 @@ class GrowiZipImportSection extends React.Component {
 
     return (
       <Fragment>
-        <h2>{t('importer_management.import_form_growi')}</h2>
-
-        <div className="alert alert-warning">
-          <i className="icon-exclamation"></i> { t('importer_management.beta_warning') }
-        </div>
+        <h2>{t('importer_management.import_growi_archive')}</h2>
 
-        {this.state.fileName ? (
+        { this.state.fileName != null ? (
           <div className="px-4">
-            <GrowiZipImportForm
+            <ImportForm
               fileName={this.state.fileName}
-              fileStats={this.state.fileStats}
+              innerFileStats={this.state.innerFileStats}
               onDiscard={this.discardData}
-              onPostImport={this.resetState}
             />
           </div>
         ) : (
-          <GrowiZipUploadForm
+          <UploadForm
             onUpload={this.handleUpload}
           />
         )}
@@ -99,7 +104,7 @@ class GrowiZipImportSection extends React.Component {
 
 }
 
-GrowiZipImportSection.propTypes = {
+GrowiArchiveSection.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
@@ -107,8 +112,8 @@ GrowiZipImportSection.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const GrowiZipImportSectionWrapper = (props) => {
-  return createSubscribedElement(GrowiZipImportSection, props, [AppContainer]);
+const GrowiArchiveSectionWrapper = (props) => {
+  return createSubscribedElement(GrowiArchiveSection, props, [AppContainer]);
 };
 
-export default withTranslation()(GrowiZipImportSectionWrapper);
+export default withTranslation()(GrowiArchiveSectionWrapper);

+ 0 - 364
src/client/js/components/Admin/ImportData/GrowiZipImportForm.jsx

@@ -1,364 +0,0 @@
-import React 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';
-
-const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations',
-];
-const GROUPS_USER = [
-  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-];
-const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings',
-];
-const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
-
-
-class GrowiImportForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.initialState = {
-      collectionNameToFileNameMap: {},
-      selectedCollections: new Set(),
-      schema: {
-        pages: {},
-        revisions: {},
-        // ...
-      },
-
-      canImport: false,
-      errorsForPageGroups: [],
-      errorsForUserGroups: [],
-      errorsForConfigGroups: [],
-      errorsForOtherGroups: [],
-    };
-
-    this.props.fileStats.forEach((fileStat) => {
-      const { fileName, collectionName } = fileStat;
-      this.initialState.collectionNameToFileNameMap[collectionName] = fileName;
-    });
-
-    this.state = this.initialState;
-
-    this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.checkAll = this.checkAll.bind(this);
-    this.uncheckAll = this.uncheckAll.bind(this);
-    this.validate = this.validate.bind(this);
-    this.import = this.import.bind(this);
-  }
-
-  get allCollectionNames() {
-    return Object.keys(this.state.collectionNameToFileNameMap);
-  }
-
-  async toggleCheckbox(e) {
-    const { target } = e;
-    const { name, checked } = target;
-
-    await this.setState((prevState) => {
-      const selectedCollections = new Set(prevState.selectedCollections);
-      if (checked) {
-        selectedCollections.add(name);
-      }
-      else {
-        selectedCollections.delete(name);
-      }
-      return { selectedCollections };
-    });
-
-    this.validate();
-  }
-
-  async checkAll() {
-    await this.setState({ selectedCollections: new Set(this.allCollectionNames) });
-    this.validate();
-  }
-
-  async uncheckAll() {
-    await this.setState({ selectedCollections: new Set() });
-    this.validate();
-  }
-
-  async validate() {
-    // init errors
-    await this.setState({
-      errorsForPageGroups: [],
-      errorsForUserGroups: [],
-      errorsForConfigGroups: [],
-      errorsForOtherGroups: [],
-    });
-
-    await this.validateCollectionSize();
-    await this.validatePagesCollectionPairs();
-    await this.validateExternalAccounts();
-    await this.validateUserGroups();
-    await this.validateUserGroupRelations();
-
-    const errors = [
-      ...this.state.errorsForPageGroups,
-      ...this.state.errorsForUserGroups,
-      ...this.state.errorsForConfigGroups,
-      ...this.state.errorsForOtherGroups,
-    ];
-    const canImport = errors.length === 0;
-
-    this.setState({ canImport });
-  }
-
-  async validateCollectionSize(validationErrors) {
-    const { t } = this.props;
-    const { errorsForOtherGroups, selectedCollections } = this.state;
-
-    if (selectedCollections.size === 0) {
-      errorsForOtherGroups.push(t('importer_management.growi_settings.errors.at_least_one'));
-    }
-
-    this.setState({ errorsForOtherGroups });
-  }
-
-  async validatePagesCollectionPairs() {
-    const { t } = this.props;
-    const { errorsForPageGroups, selectedCollections } = this.state;
-
-    const pageRelatedCollectionsLength = ['pages', 'revisions'].filter((collectionName) => {
-      return selectedCollections.has(collectionName);
-    }).length;
-
-    // MUST be included both or neither when importing
-    if (pageRelatedCollectionsLength !== 0 && pageRelatedCollectionsLength !== 2) {
-      errorsForPageGroups.push(t('importer_management.growi_settings.errors.page_and_revision'));
-    }
-
-    this.setState({ errorsForPageGroups });
-  }
-
-  async validateExternalAccounts() {
-    const { t } = this.props;
-    const { errorsForUserGroups, selectedCollections } = this.state;
-
-    // MUST include also 'users' if 'externalaccounts' is selected
-    if (selectedCollections.has('externalaccounts')) {
-      if (!selectedCollections.has('users')) {
-        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Externalaccounts' }));
-      }
-    }
-
-    this.setState({ errorsForUserGroups });
-  }
-
-  async validateUserGroups() {
-    const { t } = this.props;
-    const { errorsForUserGroups, selectedCollections } = this.state;
-
-    // MUST include also 'users' if 'usergroups' is selected
-    if (selectedCollections.has('usergroups')) {
-      if (!selectedCollections.has('users')) {
-        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Usergroups' }));
-      }
-    }
-
-    this.setState({ errorsForUserGroups });
-  }
-
-  async validateUserGroupRelations() {
-    const { t } = this.props;
-    const { errorsForUserGroups, selectedCollections } = this.state;
-
-    // MUST include also 'usergroups' if 'usergrouprelations' is selected
-    if (selectedCollections.has('usergrouprelations')) {
-      if (!selectedCollections.has('usergroups')) {
-        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Usergroups', condition: 'Usergrouprelations' }));
-      }
-    }
-
-    this.setState({ errorsForUserGroups });
-  }
-
-  async import() {
-    try {
-      // TODO: use appContainer.apiv3.post
-      const { results } = await this.props.appContainer.apiPost('/v3/import', {
-        fileName: this.props.fileName,
-        collections: Array.from(this.state.selectedCollections),
-        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',
-      });
-    }
-  }
-
-  renderWarnForGroups(errors, key) {
-    if (errors.length === 0) {
-      return null;
-    }
-
-    return (
-      <div key={key} className="alert alert-warning">
-        <ul>
-          { errors.map((error, index) => {
-            // eslint-disable-next-line react/no-array-index-key
-            return <li key={`${key}-${index}`}>{error}</li>;
-          }) }
-        </ul>
-      </div>
-    );
-  }
-
-  renderGroups(groupList, groupName, errors, { wellContent, color } = {}) {
-    const collectionNames = groupList.filter((collectionName) => {
-      return this.allCollectionNames.includes(collectionName);
-    });
-
-    if (collectionNames.length === 0) {
-      return null;
-    }
-
-    return (
-      <div className="mt-4">
-        <legend>{groupName} Collections</legend>
-        { wellContent != null && (
-          <div className="well well-sm small">
-            <ul>
-              <li>{wellContent}</li>
-            </ul>
-          </div>
-        ) }
-        { this.renderCheckboxes(collectionNames, color) }
-        { this.renderWarnForGroups(errors, `warnFor${groupName}`) }
-      </div>
-    );
-  }
-
-  renderOthers() {
-    const collectionNames = this.allCollectionNames.filter((collectionName) => {
-      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
-    });
-
-    return this.renderGroups(collectionNames, 'Other', this.state.errorsForOtherGroups);
-  }
-
-  renderCheckboxes(collectionNames, color) {
-    const checkboxColor = color ? `checkbox-${color}` : 'checkbox-info';
-
-    return (
-      <div className={`row checkbox ${checkboxColor}`}>
-        {collectionNames.map((collectionName) => {
-          return (
-            <div className="col-xs-6 my-1" key={collectionName}>
-              <input
-                type="checkbox"
-                id={collectionName}
-                name={collectionName}
-                className="form-check-input"
-                value={collectionName}
-                checked={this.state.selectedCollections.has(collectionName)}
-                onChange={this.toggleCheckbox}
-              />
-              <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
-                {collectionName}
-              </label>
-            </div>
-          );
-        })}
-      </div>
-    );
-  }
-
-
-  render() {
-    const { t } = this.props;
-    const { errorsForPageGroups, errorsForUserGroups, errorsForConfigGroups } = this.state;
-
-    return (
-      <>
-        <form className="form-inline">
-          <div className="form-group">
-            <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>
-          </div>
-          <div className="form-group">
-            <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>
-        </form>
-
-        { this.renderGroups(GROUPS_PAGE, 'Page', errorsForPageGroups, { wellContent: t('importer_management.growi_settings.overwrite_documents') }) }
-        { this.renderGroups(GROUPS_USER, 'User', errorsForUserGroups) }
-        { this.renderGroups(GROUPS_CONFIG, 'Config', errorsForConfigGroups) }
-        { this.renderOthers() }
-
-        <div className="mt-5 text-center">
-          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
-            { t('importer_management.growi_settings.discard') }
-          </button>
-          <button type="button" className="btn btn-primary mx-1" onClick={this.import} disabled={!this.state.canImport}>
-            { t('importer_management.import') }
-          </button>
-        </div>
-      </>
-    );
-  }
-
-}
-
-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);

+ 12 - 9
src/client/js/components/Admin/ImportDataPage.jsx

@@ -8,7 +8,7 @@ import { toastSuccess, toastError } from '../../util/apiNotification';
 
 import AppContainer from '../../services/AppContainer';
 
-import GrowiZipImportSection from './ImportData/GrowiZipImportSection';
+import GrowiArchiveSection from './ImportData/GrowiArchiveSection';
 
 const logger = loggerFactory('growi:importer');
 
@@ -22,6 +22,7 @@ class ImportDataPage extends React.Component {
       qiitaTeamName: '',
       qiitaAccessToken: '',
     };
+
     this.esaHandleSubmit = this.esaHandleSubmit.bind(this);
     this.esaHandleSubmitTest = this.esaHandleSubmitTest.bind(this);
     this.esaHandleSubmitUpdate = this.esaHandleSubmitUpdate.bind(this);
@@ -134,15 +135,15 @@ class ImportDataPage extends React.Component {
     const { t } = this.props;
     return (
       <Fragment>
-        <GrowiZipImportSection />
+        <GrowiArchiveSection />
 
         <form
-          className="form-horizontal"
+          className="form-horizontal mt-5"
           id="importerSettingFormEsa"
           role="form"
         >
           <fieldset>
-            <legend>{ t('importer_management.import_form_esa') }</legend>
+            <legend>{ t('importer_management.import_from', { from: 'esa.io' }) }</legend>
             <table className="table table-bordered table-mapping">
               <thead>
                 <tr>
@@ -232,7 +233,7 @@ class ImportDataPage extends React.Component {
           role="form"
         >
           <fieldset>
-            <legend>{ t('importer_management.import_form_qiita', 'Qiita:Team') }</legend>
+            <legend>{ t('importer_management.import_from', { from: 'Qiita:Team' }) }</legend>
             <table className="table table-bordered table-mapping">
               <thead>
                 <tr>
@@ -329,6 +330,12 @@ class ImportDataPage extends React.Component {
 
 }
 
+ImportDataPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+
 /**
  * Wrapper component for using unstated
  */
@@ -336,9 +343,5 @@ const ImportDataPageWrapper = (props) => {
   return createSubscribedElement(ImportDataPage, props, [AppContainer]);
 };
 
-ImportDataPage.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  t: PropTypes.func.isRequired, // i18next
-};
 
 export default withTranslation()(ImportDataPageWrapper);

+ 80 - 0
src/client/js/components/Admin/ManageExternalAccount.jsx

@@ -0,0 +1,80 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import AdminExternalAccountsContainer from '../../services/AdminExternalAccountsContainer';
+import ExternalAccountTable from './Users/ExternalAccountTable';
+import { toastError } from '../../util/apiNotification';
+
+
+class ManageExternalAccount extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.xss = window.xss;
+    this.handleExternalAccountPage = this.handleExternalAccountPage.bind(this);
+  }
+
+  componentWillMount() {
+    this.handleExternalAccountPage(1);
+  }
+
+  async handleExternalAccountPage(selectedPage) {
+    try {
+      await this.props.adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminExternalAccountsContainer } = this.props;
+
+    const pager = (
+      <div className="pull-right">
+        <PaginationWrapper
+          activePage={adminExternalAccountsContainer.state.activePage}
+          changePage={this.handleExternalAccountPage}
+          totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
+          pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
+        />
+      </div>
+    );
+    return (
+      <Fragment>
+        <p>
+          <a className="btn btn-default" href="/admin/users">
+            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+            { t('user_management.back_to_user_management') }
+          </a>
+        </p>
+
+        <h2>{ t('user_management.external_account_list') }</h2>
+
+        { pager }
+        <ExternalAccountTable />
+        { pager }
+
+      </Fragment>
+    );
+  }
+
+}
+
+ManageExternalAccount.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
+};
+
+const ManageExternalAccountWrapper = (props) => {
+  return createSubscribedElement(ManageExternalAccount, props, [AppContainer, AdminExternalAccountsContainer]);
+};
+
+
+export default withTranslation()(ManageExternalAccountWrapper);

+ 122 - 0
src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx

@@ -0,0 +1,122 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+
+const logger = loggerFactory('growi:importer');
+
+class LineBreakForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+
+  async onClickSubmit() {
+    const { t } = this.props;
+
+    try {
+      await this.props.markDownSettingContainer.updateLineBreakSetting();
+      toastSuccess(t('markdown_setting.updated_lineBreak'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  renderLineBreakOption() {
+    const { t, markDownSettingContainer } = this.props;
+    const { isEnabledLinebreaks } = markDownSettingContainer.state;
+
+    const helpLineBreak = { __html: t('markdown_setting.Enable Line Break desc') };
+
+    return (
+      <div className="form-group row">
+        <div className="col-xs-offset-4 col-xs-6 text-left">
+          <div className="checkbox checkbox-success">
+            <input
+              type="checkbox"
+              id="isEnabledLinebreaks"
+              checked={isEnabledLinebreaks}
+              onChange={() => { markDownSettingContainer.setState({ isEnabledLinebreaks: !isEnabledLinebreaks }) }}
+            />
+            <label htmlFor="isEnabledLinebreaks">
+              { t('markdown_setting.Enable Line Break') }
+            </label>
+          </div>
+          <p className="help-block" dangerouslySetInnerHTML={helpLineBreak} />
+        </div>
+      </div>
+    );
+  }
+
+  renderLineBreakInCommentOption() {
+    const { t, markDownSettingContainer } = this.props;
+    const { isEnabledLinebreaksInComments } = markDownSettingContainer.state;
+
+    const helpLineBreakInComment = { __html: t('markdown_setting.Enable Line Break for comment desc') };
+
+    return (
+      <div className="form-group row">
+        <div className="col-xs-offset-4 col-xs-6 text-left">
+          <div className="checkbox checkbox-success">
+            <input
+              type="checkbox"
+              id="isEnabledLinebreaksInComments"
+              checked={isEnabledLinebreaksInComments}
+              onChange={() => { markDownSettingContainer.setState({ isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments }) }}
+            />
+            <label htmlFor="isEnabledLinebreaksInComments">
+              { t('markdown_setting.Enable Line Break for comment') }
+            </label>
+          </div>
+          <p className="help-block" dangerouslySetInnerHTML={helpLineBreakInComment} />
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <React.Fragment>
+        <fieldset className="row">
+          {this.renderLineBreakOption()}
+          {this.renderLineBreakInCommentOption()}
+        </fieldset>
+        <div className="form-group my-3">
+          <div className="col-xs-offset-4 col-xs-5">
+            <button type="submit" className="btn btn-primary" onClick={this.onClickSubmit}>{ t('Update') }</button>
+          </div>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const LineBreakFormWrapper = (props) => {
+  return createSubscribedElement(LineBreakForm, props, [AppContainer, MarkDownSettingContainer]);
+};
+
+LineBreakForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+};
+
+export default withTranslation()(LineBreakFormWrapper);

+ 0 - 115
src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx

@@ -1,115 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import loggerFactory from '@alias/logger';
-
-import { createSubscribedElement } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
-
-import AppContainer from '../../../services/AppContainer';
-
-const logger = loggerFactory('growi:importer');
-
-class LineBreakSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { appContainer } = this.props;
-
-    this.state = {
-      isEnabledLinebreaks: appContainer.config.isEnabledLinebreaks,
-      isEnabledLinebreaksInComments: appContainer.config.isEnabledLinebreaksInComments,
-    };
-    this.onChangeEnableLineBreaks = this.onChangeEnableLineBreaks.bind(this);
-    this.onChangeEnableLineBreaksInComments = this.onChangeEnableLineBreaksInComments.bind(this);
-    this.changeLineBreakSettings = this.changeLineBreakSettings.bind(this);
-  }
-
-
-  onChangeEnableLineBreaks() {
-    this.setState({ isEnabledLinebreaks: !this.state.isEnabledLinebreaks });
-  }
-
-  onChangeEnableLineBreaksInComments() {
-    this.setState({ isEnabledLinebreaksInComments: !this.state.isEnabledLinebreaksInComments });
-  }
-
-  async changeLineBreakSettings() {
-    const { appContainer } = this.props;
-    const params = {
-      isEnabledLinebreaks: this.state.isEnabledLinebreaks,
-      isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
-    };
-    try {
-      await appContainer.apiPost('/admin/markdown/lineBreaksSetting', { params });
-      toastSuccess('Success change line braek setting');
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <React.Fragment>
-        <div className="row my-3">
-          <div className="form-group">
-            <legend>{ t('markdown_setting.line_break_setting') }</legend>
-            <p className="well">{ t('markdown_setting.line_break_setting_desc') }</p>
-            <fieldset className="row">
-              <div className="form-group">
-                <div className="col-xs-4 text-right">
-                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaks}>
-                    <input type="checkbox" name="isEnabledLinebreaks" checked={this.state.isEnabledLinebreaks} />
-                    <label>
-                      { t('markdown_setting.Enable Line Break') }
-                    </label>
-                    <p className="help-block">{ t('markdown_setting.Enable Line Break desc') }</p>
-                  </div>
-                </div>
-              </div>
-            </fieldset>
-            <fieldset className="row">
-              <div className="form-group my-3">
-                <div className="col-xs-4 text-right">
-                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaksInComments}>
-                    <input type="checkbox" name="isEnabledLinebreaksInComments" checked={this.state.isEnabledLinebreaksInComments} />
-                    <label>
-                      { t('markdown_setting.Enable Line Break for comment') }
-                    </label>
-                    <p className="help-block">{ t('markdown_setting.Enable Line Break for comment desc') }</p>
-                  </div>
-                </div>
-              </div>
-            </fieldset>
-          </div>
-          <div className="form-group my-3">
-            <div className="col-xs-offset-4 col-xs-5">
-              <button type="submit" className="btn btn-primary" onClick={this.changeLineBreakSettings}>{ t('Update') }</button>
-            </div>
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const LineBreakSettingWrapper = (props) => {
-  return createSubscribedElement(LineBreakSetting, props, [AppContainer]);
-};
-
-LineBreakSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(LineBreakSettingWrapper);

+ 15 - 83
src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx

@@ -1,4 +1,3 @@
-/* eslint-disable max-len */
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
@@ -6,101 +5,34 @@ import { withTranslation } from 'react-i18next';
 import { createSubscribedElement } from '../../UnstatedUtils';
 
 import AppContainer from '../../../services/AppContainer';
-import LineBreakSetting from './LineBreakSetting';
+import LineBreakForm from './LineBreakForm';
+import PresentationForm from './PresentationForm';
 import XssForm from './XssForm';
 
 class MarkdownSetting extends React.Component {
 
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      // TODO GW-220 get correct BreakOption value
-      pageBreakOption: 1,
-      // TODO GW-258 get correct custom regular expression
-      customRegularExpression: '',
-    };
-
-    this.handleInputChange = this.handleInputChange.bind(this);
-  }
-
-  // TODO Delete after component split
-  handleInputChange(e) {
-    const target = e.target;
-    const value = target.type === 'checkbox' ? target.checked : target.value;
-    const name = target.name;
-
-    this.setState({ [name]: value });
-  }
-
   render() {
     const { t } = this.props;
 
     return (
-      // TODO GW-322 adjust layout
       <React.Fragment>
-        <div>
-          {/* Line Break Setting */}
-          <LineBreakSetting />
+        {/* Line Break Setting */}
+        <div className="row mb-5">
+          <h2 className="border-bottom">{ t('markdown_setting.line_break_setting') }</h2>
+          <p className="well">{ t('markdown_setting.line_break_setting_desc') }</p>
+          <LineBreakForm />
         </div>
 
-        <div className="row my-3">
-          <div className="form-group">
-            <legend>{ t('markdown_setting.presentation_setting') }</legend>
-            <p className="well">{ t('markdown_setting.presentation_setting_desc') }</p>
-          </div>
-          <fieldset className="form-group row my-2">
-
-            <label className="col-xs-3 control-label text-right">
-              { t('markdown_setting.Page break setting') }
-            </label>
-
-            <div className="col-xs-3 radio radio-primary">
-              <input type="radio" id="pageBreakOption1" name="pageBreakOption" value="1" checked={this.state.pageBreakOption === 1} onChange={this.handleInputChange} />
-              <label htmlFor="pageBreakOption1">
-                <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
-                <p className="mt-3">
-                  { t('markdown_setting.Preset one separator desc') }
-                  <pre><code>{ t('markdown_setting.Preset one separator value') }</code></pre>
-                </p>
-              </label>
-            </div>
-
-            <div className="col-xs-3 radio radio-primary mt-3">
-              <input type="radio" id="pageBreakOption2" name="pageBreakOption" value="2" checked={this.state.pageBreakOption === 2} onChange={this.handleInputChange} />
-              <label htmlFor="pageBreakOption2">
-                <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
-                <p className="mt-3">
-                  { t('markdown_setting.Preset two separator desc') }
-                  <pre><code>{ t('markdown_setting.Preset two separator value') }</code></pre>
-                </p>
-              </label>
-            </div>
-
-            <div className="col-xs-3 radio radio-primary mt-3">
-              <input type="radio" id="pageBreakOption3" name="pageBreakOption" value="3" checked={this.state.pageBreakOption === 3} onChange={this.handleInputChange} />
-              <label htmlFor="pageBreakOption3">
-                <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
-                <p className="mt-3">
-                  { t('markdown_setting.Custom separator desc') }
-                  <div>
-                    <input className="form-control" name="customRegularExpression" value={this.state.customRegularExpression} onChange={this.handleInputChange} />
-                  </div>
-                </p>
-              </label>
-            </div>
-
-            <div className="form-group my-3">
-              <div className="col-xs-offset-4 col-xs-5">
-                <button type="submit" className="btn btn-primary">{ t('Update') }</button>
-              </div>
-            </div>
-
-          </fieldset>
+        {/* Presentation Setting */}
+        <div className="row mb-5">
+          <h2 className="border-bottom">{ t('markdown_setting.presentation_setting') }</h2>
+          <p className="well">{ t('markdown_setting.presentation_setting_desc') }</p>
+          <PresentationForm />
         </div>
+
         {/* XSS Setting */}
-        <div className="row my-3">
-          <h2>{ t('markdown_setting.XSS_setting') }</h2>
+        <div className="row mb-5">
+          <h2 className="border-bottom">{ t('markdown_setting.XSS_setting') }</h2>
           <p className="well">{ t('markdown_setting.XSS_setting_desc') }</p>
           <XssForm />
         </div>

+ 122 - 0
src/client/js/components/Admin/MarkdownSetting/PresentationForm.jsx

@@ -0,0 +1,122 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+
+const logger = loggerFactory('growi:markdown:presentation');
+
+class PresentationForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t } = this.props;
+
+    try {
+      await this.props.markDownSettingContainer.updatePresentationSetting();
+      toastSuccess(t('markdown_setting.updated_presentation'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+
+  render() {
+    const { t, markDownSettingContainer } = this.props;
+    const { pageBreakSeparator, pageBreakCustomSeparator } = markDownSettingContainer.state;
+
+    return (
+      <fieldset className="form-group row my-2">
+
+        <label className="col-xs-3 control-label text-right">
+          { t('markdown_setting.Page break setting') }
+        </label>
+
+        <div className="col-xs-3 radio radio-primary">
+          <input
+            type="radio"
+            id="pageBreakOption1"
+            checked={pageBreakSeparator === 1}
+            onChange={() => markDownSettingContainer.switchPageBreakSeparator(1)}
+          />
+          <label htmlFor="pageBreakOption1">
+            <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
+            <div className="mt-3">
+              { t('markdown_setting.Preset one separator desc') }
+              <pre><code>{ t('markdown_setting.Preset one separator value') }</code></pre>
+            </div>
+          </label>
+        </div>
+
+        <div className="col-xs-3 radio radio-primary mt-3">
+          <input
+            type="radio"
+            id="pageBreakOption2"
+            checked={pageBreakSeparator === 2}
+            onChange={() => markDownSettingContainer.switchPageBreakSeparator(2)}
+          />
+          <label htmlFor="pageBreakOption2">
+            <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
+            <div className="mt-3">
+              { t('markdown_setting.Preset two separator desc') }
+              <pre><code>{ t('markdown_setting.Preset two separator value') }</code></pre>
+            </div>
+          </label>
+        </div>
+
+        <div className="col-xs-3 radio radio-primary mt-3">
+          <input
+            type="radio"
+            id="pageBreakOption3"
+            checked={pageBreakSeparator === 3}
+            onChange={() => markDownSettingContainer.switchPageBreakSeparator(3)}
+          />
+          <label htmlFor="pageBreakOption3">
+            <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
+            <div className="mt-3">
+              { t('markdown_setting.Custom separator desc') }
+              <input
+                className="form-control"
+                value={pageBreakCustomSeparator}
+                onChange={(e) => { markDownSettingContainer.setPageBreakCustomSeparator(e.target.value) }}
+              />
+            </div>
+          </label>
+        </div>
+
+        <div className="form-group my-3">
+          <div className="col-xs-offset-4 col-xs-5">
+            <div className="btn btn-primary" onClick={this.onClickSubmit}>{ t('Update') }</div>
+          </div>
+        </div>
+
+      </fieldset>
+    );
+  }
+
+}
+
+const PresentationFormWrapper = (props) => {
+  return createSubscribedElement(PresentationForm, props, [AppContainer, MarkDownSettingContainer]);
+};
+
+PresentationForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+
+};
+
+export default withTranslation()(PresentationFormWrapper);

+ 86 - 0
src/client/js/components/Admin/MarkdownSetting/PresentationLineBreakOptions.jsx

@@ -0,0 +1,86 @@
+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 MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+
+class PresentationLineBreakOptions extends React.Component {
+
+  render() {
+    const { t, markDownSettingContainer } = this.props;
+    const { pageBreakOption, customRegularExpression } = markDownSettingContainer.state;
+
+    return (
+      <Fragment>
+        <div className="col-xs-3 radio radio-primary">
+          <input
+            type="radio"
+            id="pageBreakOption1"
+            checked={pageBreakOption === 1}
+            onChange={() => { markDownSettingContainer.setState({ pageBreakOption: 1 }) }}
+          />
+          <label htmlFor="pageBreakOption1">
+            <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
+            <div className="mt-3">
+              { t('markdown_setting.Preset one separator desc') }
+              <pre><code>{ t('markdown_setting.Preset one separator value') }</code></pre>
+            </div>
+          </label>
+        </div>
+
+        <div className="col-xs-3 radio radio-primary mt-3">
+          <input
+            type="radio"
+            id="pageBreakOption2"
+            checked={pageBreakOption === 2}
+            onChange={() => { markDownSettingContainer.setState({ pageBreakOption: 2 }) }}
+          />
+          <label htmlFor="pageBreakOption2">
+            <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
+            <div className="mt-3">
+              { t('markdown_setting.Preset two separator desc') }
+              <pre><code>{ t('markdown_setting.Preset two separator value') }</code></pre>
+            </div>
+          </label>
+        </div>
+
+        <div className="col-xs-3 radio radio-primary mt-3">
+          <input
+            type="radio"
+            id="pageBreakOption3"
+            checked={pageBreakOption === 3}
+            onChange={() => { markDownSettingContainer.setState({ pageBreakOption: 3 }) }}
+          />
+          <label htmlFor="pageBreakOption3">
+            <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
+            <div className="mt-3">
+              { t('markdown_setting.Custom separator desc') }
+              <input
+                className="form-control"
+                value={customRegularExpression}
+                onChange={(e) => { markDownSettingContainer.setState({ customRegularExpression: e.target.value }) }}
+              />
+            </div>
+          </label>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+const PresentationLineBreakOptionsWrapper = (props) => {
+  return createSubscribedElement(PresentationLineBreakOptions, props, [AppContainer, MarkDownSettingContainer]);
+};
+
+PresentationLineBreakOptions.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+
+};
+
+export default withTranslation()(PresentationLineBreakOptionsWrapper);

+ 15 - 5
src/client/js/components/Admin/MarkdownSetting/WhiteListInput.jsx

@@ -10,16 +10,26 @@ import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer
 
 class WhiteListInput extends React.Component {
 
-  renderRecommendBtn() {
-    const { t } = this.props;
+  renderRecommendTagBtn() {
+    const { t, markDownSettingContainer } = this.props;
 
     return (
-      <p id="btn-import-tags" className="btn btn-xs btn-primary">
+      <p id="btn-import-tags" className="btn btn-xs btn-primary" onClick={() => { markDownSettingContainer.setState({ tagWhiteList: tags }) }}>
         { t('markdown_setting.import_recommended', 'tags') }
       </p>
     );
   }
 
+  renderRecommendAttrBtn() {
+    const { t, markDownSettingContainer } = this.props;
+
+    return (
+      <p id="btn-import-tags" className="btn btn-xs btn-primary" onClick={() => { markDownSettingContainer.setState({ attrWhiteList: attrs }) }}>
+        { t('markdown_setting.import_recommended', 'Attrs') }
+      </p>
+    );
+  }
+
   renderTagValue() {
     const { customizable, markDownSettingContainer } = this.props;
 
@@ -48,7 +58,7 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             { t('markdown_setting.Tag names') }
-            {customizable && this.renderRecommendBtn()}
+            {customizable && this.renderRecommendTagBtn()}
           </div>
           <textarea
             className="form-control xss-list"
@@ -63,7 +73,7 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             { t('markdown_setting.Tag attributes') }
-            {customizable && this.renderRecommendBtn()}
+            {customizable && this.renderRecommendAttrBtn()}
           </div>
           <textarea
             className="form-control xss-list"

+ 24 - 4
src/client/js/components/Admin/MarkdownSetting/XssForm.jsx

@@ -1,14 +1,18 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
 import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
 
 import WhiteListInput from './WhiteListInput';
 
+const logger = loggerFactory('growi:importer');
+
 class XssForm extends React.Component {
 
   constructor(props) {
@@ -18,7 +22,16 @@ class XssForm extends React.Component {
   }
 
   async onClickSubmit() {
-    // TODO GW-303 create apiV3 of update setting
+    const { t } = this.props;
+
+    try {
+      await this.props.markDownSettingContainer.updateXssSetting();
+      toastSuccess(t('markdown_setting.updated_xss'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
   }
 
   xssOptions() {
@@ -82,9 +95,16 @@ class XssForm extends React.Component {
       <React.Fragment>
         <form className="row">
           <div className="form-group">
-            <div className="col-xs-4 text-right">
-              <div className="checkbox checkbox-success" onChange={markDownSettingContainer.switchEnableXss}>
-                <input type="checkbox" id="XssEnable" className="form-check-input" name="isEnabledXss" checked={isEnabledXss} />
+            <div className="col-xs-offset-4 col-xs-4 text-left">
+              <div className="checkbox checkbox-success">
+                <input
+                  type="checkbox"
+                  id="XssEnable"
+                  className="form-check-input"
+                  name="isEnabledXss"
+                  checked={isEnabledXss}
+                  onChange={markDownSettingContainer.switchEnableXss}
+                />
                 <label htmlFor="XssEnable">
                   { t('markdown_setting.Enable XSS prevention') }
                 </label>

+ 132 - 0
src/client/js/components/Admin/Users/ExternalAccountTable.jsx

@@ -0,0 +1,132 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminExternalAccountsContainer from '../../../services/AdminExternalAccountsContainer';
+
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExternalAccountTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+    this.removeExtenalAccount = this.removeExtenalAccount.bind(this);
+  }
+
+  // remove external-account
+  async removeExtenalAccount(externalAccountId) {
+    const { t } = this.props;
+
+    try {
+      const accountId = await this.props.adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
+      toastSuccess(t('user_management.remove_external_user_success', { accountId }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+
+  render() {
+    const { t, adminExternalAccountsContainer } = this.props;
+    return (
+      <Fragment>
+        <table className="table table-bordered table-user-list">
+          <thead>
+            <tr>
+              <th width="120px">{ t('user_management.authentication_provider') }</th>
+              <th><code>accountId</code></th>
+              <th>{ t('user_management.related_username') }<code>username</code></th>
+              <th>
+                { t('user_management.password_setting') }
+                <div
+                  className="text-muted"
+                  data-toggle="popover"
+                  data-placement="top"
+                  data-trigger="hover focus"
+                  tabIndex="0"
+                  role="button"
+                  data-animation="false"
+                  data-html="true"
+                  data-content={t('user_management.password_setting_help')}
+                >
+                  <small>
+                    <i className="icon-question" aria-hidden="true"></i>
+                  </small>
+                </div>
+              </th>
+              <th width="100px">{ t('Created') }</th>
+              <th width="70px"></th>
+            </tr>
+          </thead>
+          <tbody>
+            {adminExternalAccountsContainer.state.externalAccounts.map((ea) => {
+              return (
+                <tr key={ea._id}>
+                  <td>{ea.providerType}</td>
+                  <td>
+                    <strong>{ea.accountId}</strong>
+                  </td>
+                  <td>
+                    <strong>{ ea.user.username }</strong>
+                  </td>
+                  <td>
+                    { ea.user.password
+                      ? (
+                        <span className="label label-info">
+                          { t('user_management.set') }
+                        </span>
+                      )
+                      : (
+                        <span className="label label-warning">
+                          { t('user_management.unset') }
+                        </span>
+                      )
+                    }
+                  </td>
+                  <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
+                  <td>
+                    <div className="btn-group admin-user-menu">
+                      <button type="button" className="btn btn-default btn-sm 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('user_management.edit_menu') }</li>
+                        <li>
+                          <a onClick={() => { return this.removeExtenalAccount(ea._id) }}>
+                            <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+                          </a>
+                        </li>
+                      </ul>
+                    </div>
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </Fragment>
+    );
+  }
+
+}
+
+ExternalAccountTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
+};
+
+const ExternalAccountTableWrapper = (props) => {
+  return createSubscribedElement(ExternalAccountTable, props, [AppContainer, AdminExternalAccountsContainer]);
+};
+
+
+export default withTranslation()(ExternalAccountTableWrapper);

+ 0 - 78
src/client/js/components/Admin/Users/ManageExternalAccount.jsx

@@ -1,78 +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';
-
-
-class ManageExternalAccount extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-    };
-  }
-
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
-        <p>
-          <a className="btn btn-default" href="/admin/users">
-            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-            { t('user_management.back_to_user_management') }
-          </a>
-        </p>
-
-        <h2>{ t('user_management.external_account_list') }</h2>
-
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th width="120px">{ t('user_management.authentication_provider') }</th>
-              <th><code>accountId</code></th>
-              <th>{ t('user_management.related_username', 'username') }</th>
-              <th>
-                { t('user_management.password_setting') }
-                <div
-                  className="text-muted"
-                  data-toggle="popover"
-                  data-placement="top"
-                  data-trigger="hover focus"
-                  tabIndex="0"
-                  role="button"
-                  data-animation="false"
-                  data-html="true"
-                  data-content="<small>{{ t('user_management.password_setting_help') }}</small>"
-                >
-                  <small>
-                    <i className="icon-question" aria-hidden="true"></i>
-                  </small>
-                </div>
-              </th>
-              <th width="100px">{ t('Created') }</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          {/* TODO GW-328 */}
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-const ManageExternalAccountWrapper = (props) => {
-  return createSubscribedElement(ManageExternalAccount, props, [AppContainer]);
-};
-
-ManageExternalAccount.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(ManageExternalAccountWrapper);

+ 2 - 1
src/client/js/components/Admin/Users/UserRemoveButton.jsx

@@ -19,7 +19,8 @@ class UserRemoveButton extends React.Component {
     const { t } = this.props;
 
     try {
-      const username = await this.props.adminUsersContainer.removeUser(this.props.user._id);
+      await this.props.adminUsersContainer.removeUser(this.props.user._id);
+      const { username } = this.props.user;
       toastSuccess(t('user_management.remove_user_success', { username }));
     }
     catch (err) {

+ 16 - 12
src/client/js/components/Page/RevisionLoader.jsx

@@ -46,21 +46,25 @@ class RevisionLoader extends React.Component {
     };
 
     // load data with REST API
-    const res = await this.props.appContainer.apiGet('/revisions.get', requestData);
-    this.setState({ isLoaded: true, isLoading: false });
+    try {
+      const res = await this.props.appContainer.apiGet('/revisions.get', requestData);
 
-    if (res != null && !res.ok) {
-      throw new Error(res.error);
-    }
-
-    this.setState({
-      markdown: res.revision.body,
-      error: null,
-    });
+      this.setState({
+        markdown: res.revision.body,
+        error: null,
+      });
 
-    if (this.props.onRevisionLoaded != null) {
-      this.props.onRevisionLoaded(res.revision);
+      if (this.props.onRevisionLoaded != null) {
+        this.props.onRevisionLoaded(res.revision);
+      }
     }
+    catch (error) {
+      this.setState({ error });
+    }
+    finally {
+      this.setState({ isLoaded: true, isLoading: false });
+    }
+
   }
 
   onWaypointChange(event) {

+ 73 - 0
src/client/js/services/AdminExternalAccountsContainer.js

@@ -0,0 +1,73 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
+
+/**
+ * Service container for admin external-accounts page (ManageExternalAccountsContainer.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminExternalAccountsContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      externalAccounts: [],
+      totalAccounts: 0,
+      activePage: 1,
+      pagingLimit: Infinity,
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminExternalAccountsContainer';
+  }
+
+
+  /**
+   * syncExternalAccounts of selectedPage
+   * @memberOf AdminExternalAccountsContainer
+   * @param {number} selectedPage
+   */
+  async retrieveExternalAccountsByPagingNum(selectedPage) {
+
+    const params = { page: selectedPage };
+    const { data } = await this.appContainer.apiv3.get('/users/external-accounts', params);
+
+    if (data.paginateResult == null) {
+      throw new Error('data must conclude \'paginateResult\' property.');
+    }
+    const { docs: externalAccounts, totalDocs: totalAccounts, limit: pagingLimit } = data.paginateResult;
+    this.setState({
+      externalAccounts,
+      totalAccounts,
+      pagingLimit,
+      activePage: selectedPage,
+    });
+
+  }
+
+  /**
+   * remove external account
+   *
+   * @memberOf AdminExternalAccountsContainer
+   * @param {string} externalAccountId id of the External Account to be removed
+   */
+  async removeExternalAccountById(externalAccountId) {
+    const res = await this.appContainer.apiv3.delete(`/users/external-accounts/${externalAccountId}/remove`);
+    const deletedUserData = res.data.externalAccount;
+    await this.retrieveExternalAccountsByPagingNum(this.state.activePage);
+    return deletedUserData.accountId;
+  }
+
+}

+ 11 - 5
src/client/js/services/AdminUsersContainer.js

@@ -74,8 +74,9 @@ export default class AdminUsersContainer extends Container {
       shapedEmailList,
       sendEmail,
     });
-    const { emailList } = response.data;
-    return emailList;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
+    const { invitedUserList } = response.data;
+    return invitedUserList;
   }
 
   /**
@@ -115,6 +116,7 @@ export default class AdminUsersContainer extends Container {
   async giveUserAdmin(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/giveAdmin`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -127,6 +129,7 @@ export default class AdminUsersContainer extends Container {
   async removeUserAdmin(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/removeAdmin`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -139,6 +142,7 @@ export default class AdminUsersContainer extends Container {
   async activateUser(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/activate`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -151,6 +155,7 @@ export default class AdminUsersContainer extends Container {
   async deactivateUser(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/deactivate`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -158,12 +163,13 @@ export default class AdminUsersContainer extends Container {
    * remove user
    * @memberOf AdminUsersContainer
    * @param {string} userId
-   * @return {string} username
+   * @return {object} removedUserData
    */
   async removeUser(userId) {
     const response = await this.appContainer.apiv3.delete(`/users/${userId}/remove`);
-    const { username } = response.data.userData;
-    return username;
+    const removedUserData = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
+    return removedUserData;
   }
 
 }

+ 65 - 0
src/client/js/services/MarkDownSettingContainer.js

@@ -12,6 +12,12 @@ export default class MarkDownSettingContainer extends Container {
     this.appContainer = appContainer;
 
     this.state = {
+      isEnabledLinebreaks: appContainer.config.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: appContainer.config.isEnabledLinebreaksInComments,
+      pageBreakSeparator: appContainer.config.pageBreakSeparator,
+      pageBreakCustomSeparator: appContainer.config.pageBreakCustomSeparator || '',
+      // pageBreakOption: appContainer.config.pageBreakOption,
+      customRegularExpression: appContainer.config.customRegularExpression || '',
       isEnabledXss: (appContainer.config.xssOption != null),
       xssOption: appContainer.config.xssOption,
       tagWhiteList: appContainer.config.tagWhiteList || '',
@@ -28,6 +34,20 @@ export default class MarkDownSettingContainer extends Container {
     return 'MarkDownSettingContainer';
   }
 
+  /**
+   * Switch PageBreakSeparator
+   */
+  switchPageBreakSeparator(pageBreakSeparator) {
+    this.setState({ pageBreakSeparator });
+  }
+
+  /**
+   * Set PageBreakCustomSeparator
+   */
+  setPageBreakCustomSeparator(pageBreakCustomSeparator) {
+    this.setState({ pageBreakCustomSeparator });
+  }
+
   /**
    * Switch enableXss
    */
@@ -38,4 +58,49 @@ export default class MarkDownSettingContainer extends Container {
     this.setState({ isEnabledXss: !this.state.isEnabledXss });
   }
 
+  /**
+   * Update LineBreak Setting
+   */
+  async updateLineBreakSetting() {
+
+    const response = await this.appContainer.apiv3.put('/markdown-setting/lineBreak', {
+      isEnabledLinebreaks: this.state.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
+    });
+
+    return response;
+  }
+
+  /**
+   * Update Xss Setting
+   */
+  async updateXssSetting() {
+
+    const response = await this.appContainer.apiv3.put('/markdown-setting/xss', {
+      isEnabledXss: this.state.isEnabledXss,
+      xssOption: this.state.xssOption,
+      tagWhiteList: this.state.tagWhiteList,
+      attrWhiteList: this.state.attrWhiteList,
+    });
+
+    return response;
+  }
+
+  /**
+   * Update Presentation Setting
+   */
+  async updatePresentationSetting() {
+
+    const response = await this.appContainer.apiv3.put('/markdown-setting/presentation', {
+      pageBreakSeparator: this.state.pageBreakSeparator,
+      pageBreakCustomSeparator: this.state.pageBreakCustomSeparator,
+    });
+
+    this.setState({
+      pageBreakSeparator: response.data.presentationParams.pageBreakSeparator,
+      pageBreakCustomSeparator: response.data.presentationParams.pageBreakCustomSeparator,
+    });
+    return response;
+  }
+
 }

+ 1 - 1
src/client/js/util/apiNotification.js

@@ -10,7 +10,7 @@ const toastrOption = {
     newestOnTop: false,
     showDuration: '100',
     hideDuration: '100',
-    timeOut: '3000',
+    timeOut: '0',
   },
   success: {
     closeButton: true,

+ 0 - 1
src/client/styles/agile-admin/inverse/colors/antarctic.scss

@@ -25,7 +25,6 @@ $background-color: rgba(
   $color: $themelight,
   $alpha: 0.8,
 );
-$info: $subthemecolor;
 
 @import 'apply-colors';
 @import 'apply-colors-light';

+ 60 - 0
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -0,0 +1,60 @@
+//
+// Variables
+// --------------------------------------------------
+
+//== Colors
+//
+//## Gray and brand colors for use across Bootstrap.
+$gray-base:              #000 !default;
+$gray-darker:            lighten($gray-base, 13.5%); // #222
+$gray-dark:              lighten($gray-base, 20%);   // #333
+$gray:                   lighten($gray-base, 33.5%); // #555
+$gray-light:             lighten($gray-base, 46.7%); // #777
+$gray-lighter:           lighten($gray-base, 93.5%); // #eee
+$gray-extralight:        #fafafa; // Growi original
+
+$brand-primary:         $primary;
+$brand-success:         $success;
+$brand-info:            $info;
+$brand-warning:         $warning;
+$brand-danger:          $danger;
+
+
+
+//== Typography
+//
+//## Font, line-height, and color for body text, headings, and more.
+$font-family-sans-serif:  Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif;
+$font-family-serif:       Georgia, "Times New Roman", Times, serif;
+$font-family-monospace:   SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
+$font-family-base:        $font-family-sans-serif;
+
+
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+$border-radius-base: 0;
+$border-radius-large: 0;
+$border-radius-small: 0;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+$btn-default-bg: $btn-default-bgcolor;
+
+
+
+//== Forms
+//
+//##
+
+//** `<input>` background color
+$input-bg:                       $bodycolor;
+//** `<input disabled>` background color
+$input-bg-disabled:              $btn-default-bgcolor;
+
+//** `<input>` border color
+$input-border:                   $border;

+ 13 - 0
src/lib/models/admin/growi-archive-import-option.js

@@ -0,0 +1,13 @@
+class GrowiArchiveImportOption {
+
+  constructor(mode, initProps = {}) {
+    this.mode = mode;
+
+    Object.entries(initProps).forEach(([key, value]) => {
+      this[key] = value;
+    });
+  }
+
+}
+
+module.exports = GrowiArchiveImportOption;

+ 20 - 0
src/lib/models/admin/import-option-for-pages.js

@@ -0,0 +1,20 @@
+const GrowiArchiveImportOption = require('./growi-archive-import-option');
+
+const DEFAULT_PROPS = {
+  isOverwriteAuthorWithCurrentUser: false,
+  makePublicForGrant2: false,
+  makePublicForGrant4: false,
+  makePublicForGrant5: false,
+  initPageMetadatas: false,
+  initHackmdDatas: false,
+};
+
+class ImportOptionForPages extends GrowiArchiveImportOption {
+
+  constructor(mode, initProps) {
+    super(mode, initProps || DEFAULT_PROPS);
+  }
+
+}
+
+module.exports = ImportOptionForPages;

+ 15 - 0
src/lib/models/admin/import-option-for-revisions.js

@@ -0,0 +1,15 @@
+const GrowiArchiveImportOption = require('./growi-archive-import-option');
+
+const DEFAULT_PROPS = {
+  isOverwriteAuthorWithCurrentUser: false,
+};
+
+class ImportOptionForRevisions extends GrowiArchiveImportOption {
+
+  constructor(mode, initProps) {
+    super(mode, initProps || DEFAULT_PROPS);
+  }
+
+}
+
+module.exports = ImportOptionForRevisions;

+ 21 - 0
src/linter-checker/test.js

@@ -0,0 +1,21 @@
+/*
+ * VSCode の Eslint 設定チェック方法
+ *
+ * 1. VSCode で以下のエラーが表示されていることを確認
+ *   - constructor で eslint(space-before-blocks)
+ *   - ファイル末尾の ";" で eslint(eol-last)
+ *
+ * 2. VSCode で上書き保存
+ *
+ * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ *   - "constructor() {" のように間にスペースが入る
+ *   - ファイル末尾に空行が入る
+ *
+ */
+class EslintTest {
+  constructor(){
+    this.i = 0;
+  }
+}
+
+module.exports = EslintTest;

+ 23 - 0
src/linter-checker/test.scss

@@ -0,0 +1,23 @@
+/*
+ * VSCode の Stylelint 設定チェック方法
+ *
+ * 1. VSCode で以下のエラーが表示されていることを確認
+ *   - color で stylelint(order/properties-order)
+ *   - ul で stylelint(selector-combinator-space-after)
+ *
+ * 2. VSCode で上書き保存
+ *
+ * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ *   - color が background の上の行にくる
+ *   - ul と li の間にスペースが入る
+ *
+ */
+
+.test {
+  background: #ccc;
+  color: #333;
+
+  ul>li {
+    margin-left: 0;
+  }
+}

+ 28 - 0
src/migrations/20191102223900-drop-configs-indices.js

@@ -0,0 +1,28 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:drop-configs-indices');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+async function dropIndexIfExists(collection, indexName) {
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const collection = db.collection('configs');
+    await dropIndexIfExists(collection, 'ns_1');
+    await dropIndexIfExists(collection, 'key_1');
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 29 - 0
src/migrations/20191102223901-drop-pages-indices.js

@@ -0,0 +1,29 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:drop-pages-indices');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+async function dropIndexIfExists(collection, indexName) {
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const collection = db.collection('pages');
+    await dropIndexIfExists(collection, 'lastUpdateUser_1');
+    await dropIndexIfExists(collection, 'liker_1');
+    await dropIndexIfExists(collection, 'seenUsers_1');
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 41 - 0
src/migrations/20191126173016-adjust-pages-path.js

@@ -0,0 +1,41 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:adjust-pages-path');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const pathUtils = require('growi-commons').pathUtils;
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Page = require('@server/models/page')();
+
+    // retrieve target data
+    const pages = await Page.find({ path: /^(?!\/)/ });
+
+
+    // create requests for bulkWrite
+    const requests = pages.map((page) => {
+      const adjustedPath = pathUtils.addHeadingSlash(page.path);
+      return {
+        updateOne: {
+          filter: { _id: page._id },
+          update: { $set: { path: adjustedPath } },
+        },
+      };
+    });
+
+    if (requests.length > 0) {
+      await db.collection('pages').bulkWrite(requests);
+    }
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 33 - 0
src/migrations/20191127023815-drop-wrong-index-of-page-tag-relation.js

@@ -0,0 +1,33 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:drop-wrong-index-of-page-tag-relation');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+async function dropIndexIfExists(db, collectionName, indexName) {
+  // check existence of the collection
+  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  if (items.length === 0) {
+    return;
+  }
+
+  const collection = await db.collection(collectionName);
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    await dropIndexIfExists(db, 'pagetagrelations', 'page_1_user_1');
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 5 - 8
src/server/crowi/index.js

@@ -330,19 +330,16 @@ Crowi.prototype.setupPassport = async function() {
 };
 
 Crowi.prototype.setupSearcher = async function() {
-  const self = this;
-  const searcherUri = this.env.ELASTICSEARCH_URI
-    || this.env.BONSAI_URL
-    || null;
+  const SearchService = require('@server/service/search');
+  const searchService = new SearchService(this);
 
-  if (searcherUri) {
+  if (searchService.isAvailable) {
     try {
-      self.searcher = new (require(path.join(self.libDir, 'util', 'search')))(self, searcherUri);
-      self.searcher.initIndices();
+      this.searcher = searchService;
     }
     catch (e) {
       logger.error('Error on setup searcher', e);
-      self.searcher = null;
+      this.searcher = null;
     }
   }
 };

+ 2 - 2
src/server/middlewares/ApiV3FormValidator.js

@@ -1,11 +1,11 @@
 const logger = require('@alias/logger')('growi:middlewares:ApiV3FormValidator');
 const { validationResult } = require('express-validator/check');
 
+const ErrorV3 = require('../models/vo/error-apiv3');
+
 class ApiV3FormValidator {
 
   constructor(crowi) {
-    const { ErrorV3 } = crowi.models;
-
     return (req, res, next) => {
       logger.debug('req.query', req.query);
       logger.debug('req.params', req.params);

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

@@ -6,6 +6,7 @@ const logger = require('@alias/logger')('growi:models:attachment');
 const path = require('path');
 
 const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -21,12 +22,13 @@ module.exports = function(crowi) {
     page: { type: ObjectId, ref: 'Page', index: true },
     creator: { type: ObjectId, ref: 'User', index: true },
     filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
-    fileName: { type: String, required: true },
+    fileName: { type: String, required: true, unique: true },
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
     createdAt: { type: Date, default: Date.now },
   });
+  attachmentSchema.plugin(uniqueValidator);
 
   attachmentSchema.virtual('filePathProxied').get(function() {
     return `/attachment/${this._id}`;

+ 7 - 4
src/server/models/bookmark.js

@@ -1,10 +1,12 @@
-// disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
+const debug = require('debug')('growi:models:bookmark');
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
 module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:bookmark');
-  const mongoose = require('mongoose');
-  const ObjectId = mongoose.Schema.Types.ObjectId;
   const bookmarkEvent = crowi.event('bookmark');
 
   let bookmarkSchema = null;
@@ -16,6 +18,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
+  bookmarkSchema.plugin(uniqueValidator);
 
   bookmarkSchema.statics.countByPageId = async function(pageId) {
     return await this.count({ page: pageId });

+ 10 - 7
src/server/models/config.js

@@ -1,21 +1,22 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-/* eslint-disable no-use-before-define */
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
 
 module.exports = function(crowi) {
-  const mongoose = require('mongoose');
 
   const configSchema = new mongoose.Schema({
-    ns: { type: String, required: true, index: true },
-    key: { type: String, required: true, index: true },
+    ns: { type: String, required: true },
+    key: { type: String, required: true },
     value: { type: String, required: true },
   });
+  // define unique compound index
+  configSchema.index({ ns: 1, key: 1 }, { unique: true });
+  configSchema.plugin(uniqueValidator);
 
   /**
    * default values when GROWI is cleanly installed
    */
   function getConfigsForInstalling() {
+    // eslint-disable-next-line no-use-before-define
     const config = getDefaultCrowiConfigs();
 
     // overwrite
@@ -175,6 +176,8 @@ module.exports = function(crowi) {
       layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+      pageBreakSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
+      pageBreakCustomSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
       isEnabledTimeline: crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
       xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),

+ 1 - 4
src/server/models/external-account.js

@@ -169,10 +169,7 @@ class ExternalAccount {
       options.limit = ExternalAccount.DEFAULT_LIMIT;
     }
 
-    return this.paginate(query, options)
-      .catch((err) => {
-        debug('Error on pagination:', err);
-      });
+    return this.paginate(query, options);
   }
 
 }

+ 0 - 3
src/server/models/index.js

@@ -15,7 +15,4 @@ module.exports = {
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
-
-  // non-persistent models
-  ErrorV3: require('./ErrorV3'),
 };

+ 5 - 0
src/server/models/page-tag-relation.js

@@ -5,6 +5,7 @@ const flatMap = require('array.prototype.flatmap');
 
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -17,6 +18,7 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     ref: 'Page',
     required: true,
+    index: true,
   },
   relatedTag: {
     type: ObjectId,
@@ -24,7 +26,10 @@ const schema = new mongoose.Schema({
     required: true,
   },
 });
+// define unique compound index
+schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
 
 /**
  * PageTagRelation Class

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

@@ -39,9 +39,9 @@ const pageSchema = new mongoose.Schema({
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
   grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
   creator: { type: ObjectId, ref: 'User', index: true },
-  lastUpdateUser: { type: ObjectId, ref: 'User', index: true },
-  liker: [{ type: ObjectId, ref: 'User', index: true }],
-  seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+  liker: [{ type: ObjectId, ref: 'User' }],
+  seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
   extended: {
     type: String,

+ 2 - 0
src/server/models/tag.js

@@ -3,6 +3,7 @@
 
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 /*
  * define schema
@@ -15,6 +16,7 @@ const schema = new mongoose.Schema({
   },
 });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
 
 /**
  * Tag Class

+ 3 - 0
src/server/models/user-group-relation.js

@@ -1,6 +1,7 @@
 const debug = require('debug')('growi:models:userGroupRelation');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -14,6 +15,8 @@ const schema = new mongoose.Schema({
   createdAt: { type: Date, default: Date.now, required: true },
 });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
 
 /**
  * UserGroupRelation Class

+ 4 - 4
src/server/models/user.js

@@ -44,14 +44,14 @@ module.exports = function(crowi) {
     name: { type: String },
     username: { type: String, required: true, unique: true },
     email: { type: String, unique: true, sparse: true },
-    // === The official settings
+    // === Crowi settings
     // username: { type: String, index: true },
     // email: { type: String, required: true, index: true },
     // === crowi-plus (>= 2.1.0, <2.3.0) settings
     // email: { type: String, required: true, unique: true },
-    introduction: { type: String },
+    introduction: String,
     password: String,
-    apiToken: String,
+    apiToken: { type: String, index: true },
     lang: {
       type: String,
       // eslint-disable-next-line no-eval
@@ -627,7 +627,7 @@ module.exports = function(crowi) {
   userSchema.statics.createUsersByEmailList = async function(emailList) {
     const User = this;
 
-    // check exists and get list of tyr to create
+    // check exists and get list of try to create
     const existingUserList = await User.find({ email: { $in: emailList }, userStatus: { $ne: STATUS_DELETED } });
     const existingEmailList = existingUserList.map((user) => { return user.email });
     const creationEmailList = emailList.filter((email) => { return existingEmailList.indexOf(email) === -1 });

+ 13 - 0
src/server/models/vo/collection-progress.js

@@ -0,0 +1,13 @@
+class CollectionProgress {
+
+  constructor(collectionName, totalCount) {
+    this.collectionName = collectionName;
+    this.currentCount = 0;
+    this.insertedCount = 0;
+    this.modifiedCount = 0;
+    this.totalCount = totalCount;
+  }
+
+}
+
+module.exports = CollectionProgress;

+ 35 - 0
src/server/models/vo/collection-progressing-status.js

@@ -0,0 +1,35 @@
+const CollectionProgress = require('./collection-progress');
+
+class CollectionProgressingStatus {
+
+  constructor(collections) {
+    this.totalCount = 0;
+    this.progressMap = {};
+
+    this.progressList = collections.map((collectionName) => {
+      return new CollectionProgress(collectionName, 0);
+    });
+
+    // collection name to instance mapping
+    this.progressList.forEach((p) => {
+      this.progressMap[p.collectionName] = p;
+    });
+  }
+
+  recalculateTotalCount() {
+    this.progressList.forEach((p) => {
+      this.progressMap[p.collectionName] = p;
+      this.totalCount += p.totalCount;
+    });
+  }
+
+  get currentCount() {
+    return this.progressList.reduce(
+      (acc, crr) => acc + crr.currentCount,
+      0,
+    );
+  }
+
+}
+
+module.exports = CollectionProgressingStatus;

+ 3 - 4
src/server/models/ErrorV3.js → src/server/models/vo/error-apiv3.js

@@ -1,13 +1,12 @@
 class ErrorV3 extends Error {
 
-  constructor(message = '', code = '') {
+  constructor(message = '', code = '', stack = undefined) {
     super(); // do not provide message to the super constructor
     this.message = message;
     this.code = code;
+    this.stack = stack;
   }
 
 }
 
-module.exports = function(crowi) {
-  return ErrorV3;
-};
+module.exports = ErrorV3;

+ 1 - 50
src/server/routes/admin.js

@@ -137,22 +137,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  // app.post('/admin/markdown/lineBreaksSetting' , admin.markdown.lineBreaksSetting);
-  actions.markdown.lineBreaksSetting = async function(req, res) {
-
-    const markdownSetting = req.form.markdownSetting;
-
-    if (req.form.isValid) {
-      await configManager.updateConfigsInTheSameNamespace('markdown', markdownSetting);
-      req.flash('successMessage', ['Successfully updated!']);
-    }
-    else {
-      req.flash('errorMessage', req.form.errors);
-    }
-    return res.redirect('/admin/markdown');
-
-  };
-
   // app.post('/admin/markdown/presentationSetting' , admin.markdown.presentationSetting);
   actions.markdown.presentationSetting = async function(req, res) {
     const markdownSetting = req.form.markdownSetting;
@@ -168,29 +152,6 @@ module.exports = function(crowi, app) {
     return res.redirect('/admin/markdown');
   };
 
-  // app.post('/admin/markdown/xss-setting' , admin.markdown.xssSetting);
-  actions.markdown.xssSetting = async function(req, res) {
-    const xssSetting = req.form.markdownSetting;
-
-    xssSetting['markdown:xss:tagWhiteList'] = csvToArray(xssSetting['markdown:xss:tagWhiteList']);
-    xssSetting['markdown:xss:attrWhiteList'] = csvToArray(xssSetting['markdown:xss:attrWhiteList']);
-
-    if (req.form.isValid) {
-      await configManager.updateConfigsInTheSameNamespace('markdown', xssSetting);
-      req.flash('successMessage', ['Successfully updated!']);
-    }
-    else {
-      req.flash('errorMessage', req.form.errors);
-    }
-
-    return res.redirect('/admin/markdown');
-  };
-
-  const csvToArray = (string) => {
-    const array = string.split(',');
-    return array.map((item) => { return item.trim() });
-  };
-
   // app.get('/admin/customize' , admin.customize.index);
   actions.customize = {};
   actions.customize.index = function(req, res) {
@@ -486,17 +447,7 @@ module.exports = function(crowi, app) {
 
   actions.externalAccount = {};
   actions.externalAccount.index = function(req, res) {
-    const page = parseInt(req.query.page) || 1;
-
-    ExternalAccount.findAllWithPagination({ page })
-      .then((result) => {
-        const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
-
-        return res.render('admin/external-accounts', {
-          accounts: result.docs,
-          pager,
-        });
-      });
+    return res.render('admin/external-accounts');
   };
 
   actions.externalAccount.remove = async function(req, res) {

+ 5 - 6
src/server/routes/apiv3/healthcheck.js

@@ -25,7 +25,7 @@ module.exports = (crowi) => {
    *      parameters:
    *        - name: connectToMiddlewares
    *          in: query
-   *          description: Check also MongoDB and Elasticsearch
+   *          description: Check also MongoDB and SearchService
    *          schema:
    *            type: boolean
    *      responses:
@@ -38,14 +38,13 @@ module.exports = (crowi) => {
    *                  mongo:
    *                    type: string
    *                    description: 'OK'
-   *                  esInfo:
+   *                  searchInfo:
    *                    type: object
-   *                    description: A result of `client.info()` of Elasticsearch Info API
    */
   router.get('/', helmet.noCache(), async(req, res) => {
     const connectToMiddlewares = req.query.connectToMiddlewares;
 
-    // return 200 w/o connecting to MongoDB and Elasticsearch
+    // return 200 w/o connecting to MongoDB and SearchService
     if (connectToMiddlewares == null) {
       res.status(200).send({ status: 'OK' });
       return;
@@ -57,9 +56,9 @@ module.exports = (crowi) => {
       await Config.findOne({});
       // connect to Elasticsearch
       const search = crowi.getSearcher();
-      const esInfo = await search.getInfo();
+      const searchInfo = await search.getInfo();
 
-      res.status(200).send({ mongo: 'OK', esInfo });
+      res.status(200).send({ mongo: 'OK', searchInfo });
     }
     catch (err) {
       res.status(503).send({ err });

+ 165 - 109
src/server/routes/apiv3/import.js

@@ -3,13 +3,16 @@ 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');
 
+// eslint-disable-next-line no-unused-vars
 const { ObjectId } = require('mongoose').Types;
 
 const express = require('express');
 
+const GrowiArchiveImportOption = require('@commons/models/admin/growi-archive-import-option');
+
+
 const router = express.Router();
 
 /**
@@ -18,6 +21,44 @@ const router = express.Router();
  *    name: Import
  */
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      ImportStatus:
+ *        type: object
+ *        properties:
+ *          zipFileStat:
+ *            type: object
+ *            description: the property object
+ *          progressList:
+ *            type: array
+ *            items:
+ *              type: object
+ *              description: progress data for each exporting collections
+ *          isImporting:
+ *            type: boolean
+ *            description: whether the current importing job exists or not
+ */
+
+/**
+ * generate overwrite params with overwrite-params/* modules
+ * @param {string} collectionName
+ * @param {object} req Request Object
+ * @param {GrowiArchiveImportOption} options GrowiArchiveImportOption instance
+ */
+const generateOverwriteParams = (collectionName, req, options) => {
+  switch (collectionName) {
+    case 'pages':
+      return require('./overwrite-params/pages')(req, options);
+    case 'revisions':
+      return require('./overwrite-params/revisions')(req, options);
+    default:
+      return {};
+  }
+};
+
 module.exports = (crowi) => {
   const { growiBridgeService, importService } = crowi;
   const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
@@ -25,6 +66,19 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middleware/admin-required')(crowi);
   const csrf = require('../../middleware/csrf')(crowi);
 
+  this.adminEvent = crowi.event('admin');
+
+  // setup event
+  this.adminEvent.on('onProgressForImport', (data) => {
+    crowi.getIo().sockets.emit('admin:onProgressForImport', data);
+  });
+  this.adminEvent.on('onTerminateForImport', (data) => {
+    crowi.getIo().sockets.emit('admin:onTerminateForImport', data);
+  });
+  this.adminEvent.on('onErrorForImport', (data) => {
+    crowi.getIo().sockets.emit('admin:onErrorForImport', data);
+  });
+
   const uploads = multer({
     storage: multer.diskStorage({
       destination: (req, file, cb) => {
@@ -43,48 +97,33 @@ module.exports = (crowi) => {
     },
   });
 
+
   /**
-   * 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 }
+   * @swagger
    *
-   * @param {object} Model instance of mongoose model
-   * @param {object} req request object
-   * @return {object} document to be persisted
+   *  /import/status:
+   *    get:
+   *      tags: [Import]
+   *      description: Get properties of stored zip files for import
+   *      responses:
+   *        200:
+   *          description: the zip file statuses
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  status:
+   *                    $ref: '#/components/schemas/ImportStatus'
    */
-  const overwriteParamsFn = async(Model, schema, req) => {
-    const collectionName = Model.collection.name;
-
-    /* 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}"`);
+  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    try {
+      const status = await importService.getStatus();
+      return res.apiv3(status);
+    }
+    catch (err) {
+      return res.apiv3Err(err, 500);
     }
-    /* eslint-enable no-case-declarations */
-  };
+  });
 
   /**
    * @swagger
@@ -93,66 +132,105 @@ module.exports = (crowi) => {
    *    post:
    *      tags: [Import]
    *      description: import a collection from a zipped json
+   *      requestBody:
+   *        required: true
+   *        content:
+   *          application/json:
+   *            schema:
+   *              type: object
+   *              properties:
+   *                fileName:
+   *                  description: the file name of zip file
+   *                  type: string
+   *                collections:
+   *                  description: collection names to import
+   *                  type: array
+   *                  items:
+   *                    type: string
+   *                optionsMap:
+   *                  description: |
+   *                    the map object of importing option that have collection name as the key
+   *                  additionalProperties:
+   *                    type: object
+   *                    properties:
+   *                      mode:
+   *                        description: Import mode
+   *                        type: string
+   *                        enum: [insert, upsert, flushAndInsert]
    *      responses:
    *        200:
-   *          description: the data is successfully imported
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  results:
-   *                    type: array
-   *                    items:
-   *                      type: object
-   *                      description: collectionName, insertedIds, failedIds
+   *          description: Import process has requested
    */
   router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     // TODO: add express validator
 
-    const { fileName, collections, schema } = req.body;
+    const { fileName, collections, optionsMap } = 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);
+    // return response first
+    res.apiv3();
 
-    // delete zip file after unzipping and parsing it
-    fs.unlinkSync(zipFile);
+    /*
+     * unzip, parse
+     */
+    let meta = null;
+    let fileStatsToImport = null;
+    try {
+      // unzip
+      await importService.unzip(zipFile);
 
-    // filter fileStats
-    const filteredFileStats = fileStats.filter(({ fileName, collectionName, size }) => { return collections.includes(collectionName) });
+      // eslint-disable-next-line no-unused-vars
+      const { meta: parsedMeta, fileStats, innerFileStats } = await growiBridgeService.parseZipFile(zipFile);
+      meta = parsedMeta;
 
+      // filter innerFileStats
+      fileStatsToImport = innerFileStats.filter(({ fileName, collectionName, size }) => {
+        return collections.includes(collectionName);
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      this.adminEvent.emit('onErrorForImport', { message: err.message });
+      return;
+    }
+
+    /*
+     * validate with meta.json
+     */
     try {
-      // validate with meta.json
       importService.validate(meta);
+    }
+    catch (err) {
+      logger.error(err);
+      this.adminEvent.emit('onErrorForImport', { message: err.message });
+      return;
+    }
 
-      const results = await Promise.all(filteredFileStats.map(async({ fileName, collectionName, size }) => {
-        const Model = growiBridgeService.getModelFromCollectionName(collectionName);
-        const jsonFile = importService.getFile(fileName);
+    // generate maps of ImportSettings to import
+    const importSettingsMap = {};
+    fileStatsToImport.forEach(({ fileName, collectionName }) => {
+      // instanciate GrowiArchiveImportOption
+      const options = new GrowiArchiveImportOption(null, optionsMap[collectionName]);
 
-        let overwriteParams;
-        if (overwriteParamsFn[collectionName] != null) {
-          // await in case overwriteParamsFn[collection] is a Promise
-          overwriteParams = await overwriteParamsFn(Model, schema[collectionName], req);
-        }
+      // generate options
+      const importSettings = importService.generateImportSettings(options.mode);
+      importSettings.jsonFileName = fileName;
 
-        const { insertedIds, failedIds } = await importService.import(Model, jsonFile, overwriteParams);
+      // generate overwrite params
+      importSettings.overwriteParams = generateOverwriteParams(collectionName, req, options);
 
-        return {
-          collectionName,
-          insertedIds,
-          failedIds,
-        };
-      }));
+      importSettingsMap[collectionName] = importSettings;
+    });
 
-      // TODO: use res.apiv3
-      return res.send({ ok: true, results });
+    /*
+     * import
+     */
+    try {
+      importService.import(collections, importSettingsMap);
     }
     catch (err) {
-      // TODO: use ApiV3Error
       logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
+      this.adminEvent.emit('onErrorForImport', { message: err.message });
     }
   });
 
@@ -192,11 +270,7 @@ module.exports = (crowi) => {
       // validate with meta.json
       importService.validate(data.meta);
 
-      // TODO: use res.apiv3
-      return res.send({
-        ok: true,
-        data,
-      });
+      return res.apiv3(data);
     }
     catch (err) {
       // TODO: use ApiV3Error
@@ -208,41 +282,23 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *  /import/{fileName}:
-   *    post:
+   *  /import/all:
+   *    delete:
    *      tags: [Import]
-   *      description: delete a zip file
-   *      parameters:
-   *        - name: fileName
-   *          in: path
-   *          description: the file name of zip file
-   *          required: true
-   *          schema:
-   *            type: string
+   *      description: Delete all zip files
    *      responses:
    *        200:
-   *          description: the file is deleted
-   *          content:
-   *            application/json:
-   *              schema:
-   *                type: object
+   *          description: all files are deleted
    */
-  router.delete('/:fileName', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
-    const { fileName } = req.params;
-
+  router.delete('/all', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     try {
-      const zipFile = importService.getFile(fileName);
-      fs.unlinkSync(zipFile);
+      importService.deleteAllZipFiles();
 
-      // TODO: use res.apiv3
-      return res.send({
-        ok: true,
-      });
+      return res.apiv3();
     }
     catch (err) {
-      // TODO: use ApiV3Error
       logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
+      return res.apiv3Err(err, 500);
     }
   });
 

+ 250 - 5
src/server/routes/apiv3/markdown-setting.js

@@ -1,26 +1,271 @@
-/* eslint-disable no-unused-vars */
 const loggerFactory = require('@alias/logger');
 
+// eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:user-group');
 
 const express = require('express');
 
 const router = express.Router();
 
+const { body } = require('express-validator/check');
+
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const validator = {
+  lineBreak: [
+    body('isEnabledLinebreaks').isBoolean(),
+    body('isEnabledLinebreaksInComments').isBoolean(),
+  ],
+  presentationSetting: [
+    body('pageBreakSeparator').isInt().not().isEmpty(),
+  ],
+  xssSetting: [
+    body('isEnabledXss').isBoolean(),
+    body('tagWhiteList').toArray(),
+    body('attrWhiteList').toArray(),
+  ],
+};
+
+
 /**
  * @swagger
  *  tags:
  *    name: MarkDownSetting
  */
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      LineBreakParams:
+ *        type: object
+ *        properties:
+ *          isEnabledLinebreaks:
+ *            type: boolean
+ *            description: enable lineBreak
+ *          isEnabledLinebreaksInComments:
+ *            type: boolean
+ *            description: enable lineBreak in comment
+ *      PresentationParams:
+ *        type: object
+ *        properties:
+ *          pageBreakSeparator:
+ *            type: number
+ *            description: number of pageBreakSeparator
+ *          pageBreakCustomSeparator:
+ *            type: string
+ *            description: string of pageBreakCustomSeparator
+ *      XssParams:
+ *        type: object
+ *        properties:
+ *          isEnabledPrevention:
+ *            type: boolean
+ *            description: enable xss
+ *          xssOption:
+ *            type: number
+ *            description: number of xss option
+ *          tagWhiteList:
+ *            type: array
+ *            description: array of tag whiteList
+ *            items:
+ *              type: string
+ *              description: tag whitelist
+ *          attrWhiteList:
+ *            type: array
+ *            description: array of attr whiteList
+ *            items:
+ *              type: string
+ *              description: attr whitelist
+ */
+
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
   const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  /**
+   * @swagger
+   *
+   *    /markdown-setting/lineBreak:
+   *      put:
+   *        tags: [MarkDownSetting]
+   *        description: Update lineBreak setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                type: object
+   *                properties:
+   *                  isEnabledLinebreaks:
+   *                    description: enable lineBreak
+   *                    type: boolean
+   *                  isEnabledLinebreaksInComments:
+   *                    description: enable lineBreak in comment
+   *                    type: boolean
+   *        responses:
+   *          200:
+   *            description: Succeeded to update lineBreak setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    status:
+   *                      $ref: '#/components/schemas/LineBreakParams'
+   */
+  router.put('/lineBreak', loginRequiredStrictly, adminRequired, csrf, validator.lineBreak, ApiV3FormValidator, async(req, res) => {
+
+    const requestLineBreakParams = {
+      'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
+      'markdown:isEnabledLinebreaksInComments': req.body.isEnabledLinebreaksInComments,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('markdown', requestLineBreakParams);
+      const lineBreaksParams = {
+        isEnabledLinebreaks: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+        isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+      };
+      return res.apiv3({ lineBreaksParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating lineBreak';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-lineBreak-failed'));
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /markdown-setting/presentation:
+   *      put:
+   *        tags: [MarkDownSetting]
+   *        description: Update presentation
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                type: object
+   *                properties:
+   *                  pageBreakSeparator:
+   *                    description: number of pageBreakSeparator
+   *                    type: number
+   *                  pageBreakCustomSeparator:
+   *                    description: string of pageBreakCustomSeparator
+   *                    type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to update presentation setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    status:
+   *                      $ref: '#/components/schemas/PresentationParams'
+   */
+  router.put('/presentation', loginRequiredStrictly, adminRequired, csrf, validator.presentationSetting, ApiV3FormValidator, async(req, res) => {
+    if (req.body.pageBreakSeparator === 3 && req.body.pageBreakCustomSeparator === '') {
+      return res.apiv3Err(new ErrorV3('customRegularExpression is required'));
+    }
+
+    const requestPresentationParams = {
+      'markdown:presentation:pageBreakSeparator': req.body.pageBreakSeparator,
+      'markdown:presentation:pageBreakCustomSeparator': req.body.pageBreakCustomSeparator,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('markdown', requestPresentationParams);
+      const presentationParams = {
+        pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
+        pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator') || '',
+      };
+      return res.apiv3({ presentationParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating presentation';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-presentation-failed'));
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /markdown-setting/xss:
+   *      put:
+   *        tags: [MarkDownSetting]
+   *        description: Update xss
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                type: object
+   *                properties:
+   *                  isEnabledPrevention:
+   *                    description: enable xss
+   *                    type: boolean
+   *                  xssOption:
+   *                    description: number of xss option
+   *                    type: number
+   *                  tagWhiteList:
+   *                    description: array of tag whiteList
+   *                    type: array
+   *                    items:
+   *                      type: string
+   *                      description: tag whitelist
+   *                  attrWhiteList:
+   *                    description: array of attr whiteList
+   *                    type: array
+   *                    items:
+   *                      type: string
+   *                      description: attr whitelist
+   *        responses:
+   *          200:
+   *            description: Succeeded to update xss setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    status:
+   *                      $ref: '#/components/schemas/XssParams'
+   */
+  router.put('/xss', loginRequiredStrictly, adminRequired, csrf, validator.xssSetting, ApiV3FormValidator, async(req, res) => {
+    if (req.body.isEnabledXss && req.body.xssOption == null) {
+      return res.apiv3Err(new ErrorV3('xss option is required'));
+    }
+
+    const reqestXssParams = {
+      'markdown:xss:isEnabledPrevention': req.body.isEnabledXss,
+      'markdown:xss:option': req.body.xssOption,
+      'markdown:xss:tagWhiteList': req.body.tagWhiteList,
+      'markdown:xss:attrWhiteList': req.body.attrWhiteList,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('markdown', reqestXssParams);
+      const xssParams = {
+        isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+        xssOption: await crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
+        tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
+        attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
+      };
+      return res.apiv3({ xssParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating xss';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
+    }
 
-  const {
-    ErrorV3,
-    Config,
-  } = crowi.models;
+  });
 
   return router;
 };

+ 62 - 0
src/server/routes/apiv3/overwrite-params/pages.js

@@ -0,0 +1,62 @@
+const mongoose = require('mongoose');
+
+// eslint-disable-next-line no-unused-vars
+const ImportOptionForPages = require('@commons/models/admin/import-option-for-pages');
+
+const { ObjectId } = mongoose.Types;
+
+const {
+  GRANT_PUBLIC,
+} = mongoose.model('Page');
+
+class PageOverwriteParamsFactory {
+
+  /**
+   * generate overwrite params object
+   * @param {object} req
+   * @param {ImportOptionForPages} option
+   * @return object
+   *  key: property name
+   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
+   */
+  static generate(req, option) {
+    const params = {};
+
+    if (option.isOverwriteAuthorWithCurrentUser) {
+      const userId = ObjectId(req.user._id);
+      params.creator = userId;
+      params.lastUpdateUser = userId;
+    }
+
+    params.grant = (value, { document, schema, propertyName }) => {
+      if (option.makePublicForGrant2 && value === 2) {
+        return GRANT_PUBLIC;
+      }
+      if (option.makePublicForGrant4 && value === 4) {
+        return GRANT_PUBLIC;
+      }
+      if (option.makePublicForGrant5 && value === 5) {
+        return GRANT_PUBLIC;
+      }
+      return value;
+    };
+
+    if (option.initPageMetadatas) {
+      params.liker = [];
+      params.seenUsers = [];
+      params.commentCount = 0;
+      params.extended = {};
+    }
+
+    if (option.initHackmdDatas) {
+      params.pageIdOnHackmd = undefined;
+      params.revisionHackmdSynced = undefined;
+      params.hasDraftOnHackmd = undefined;
+    }
+
+    return params;
+  }
+
+}
+
+module.exports = (req, option) => PageOverwriteParamsFactory.generate(req, option);

+ 31 - 0
src/server/routes/apiv3/overwrite-params/revisions.js

@@ -0,0 +1,31 @@
+const mongoose = require('mongoose');
+
+// eslint-disable-next-line no-unused-vars
+const ImportOptionForPages = require('@commons/models/admin/import-option-for-pages');
+
+const { ObjectId } = mongoose.Types;
+
+class RevisionOverwriteParamsFactory {
+
+  /**
+   * generate overwrite params object
+   * @param {object} req
+   * @param {ImportOptionForPages} option
+   * @return object
+   *  key: property name
+   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
+   */
+  static generate(req, option) {
+    const params = {};
+
+    if (option.isOverwriteAuthorWithCurrentUser) {
+      const userId = ObjectId(req.user._id);
+      params.author = userId;
+    }
+
+    return params;
+  }
+
+}
+
+module.exports = (req, option) => RevisionOverwriteParamsFactory.generate(req, option);

+ 6 - 2
src/server/routes/apiv3/response.js

@@ -1,9 +1,10 @@
 const toArrayIfNot = require('../../../lib/util/toArrayIfNot');
 
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
 const addCustomFunctionToResponse = (express, crowi) => {
-  const { ErrorV3 } = crowi.models;
 
-  express.response.apiv3 = function(obj) { // not arrow function
+  express.response.apiv3 = function(obj = {}) { // not arrow function
     // obj must be object
     if (typeof obj !== 'object' || obj instanceof Array) {
       throw new Error('invalid value supplied to res.apiv3');
@@ -22,6 +23,9 @@ const addCustomFunctionToResponse = (express, crowi) => {
       if (e instanceof ErrorV3) {
         return e;
       }
+      if (e instanceof Error) {
+        return new ErrorV3(e.message, null, e.stack);
+      }
       if (typeof e === 'string') {
         return { message: e };
       }

+ 3 - 1
src/server/routes/apiv3/user-group-relation.js

@@ -4,6 +4,8 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslin
 
 const express = require('express');
 
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
 const router = express.Router();
 
 /**
@@ -16,7 +18,7 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
   const adminRequired = require('../../middleware/admin-required')(crowi);
 
-  const { ErrorV3, UserGroup, UserGroupRelation } = crowi.models;
+  const { UserGroup, UserGroupRelation } = crowi.models;
 
   /**
    * @swagger

+ 7 - 3
src/server/routes/apiv3/user-group.js

@@ -9,12 +9,17 @@ const router = express.Router();
 const { body, param, query } = require('express-validator/check');
 const { sanitizeQuery } = require('express-validator/filter');
 
-const validator = {};
+const mongoose = require('mongoose');
 
-const { ObjectId } = require('mongoose').Types;
+const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
+const validator = {};
+
+const { ObjectId } = mongoose.Types;
+
+
 /**
  * @swagger
  *  tags:
@@ -27,7 +32,6 @@ module.exports = (crowi) => {
   const csrf = require('../../middleware/csrf')(crowi);
 
   const {
-    ErrorV3,
     UserGroup,
     UserGroupRelation,
     User,

+ 76 - 3
src/server/routes/apiv3/users.js

@@ -9,6 +9,8 @@ const router = express.Router();
 const { body } = require('express-validator/check');
 const { isEmail } = require('validator');
 
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
 const PAGE_ITEMS = 50;
 
 const validator = {};
@@ -25,7 +27,6 @@ module.exports = (crowi) => {
   const csrf = require('../../middleware/csrf')(crowi);
 
   const {
-    ErrorV3,
     User,
     Page,
     ExternalAccount,
@@ -118,8 +119,8 @@ module.exports = (crowi) => {
    */
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, ApiV3FormValidator, async(req, res) => {
     try {
-      const emailList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
-      return res.apiv3({ emailList });
+      const invitedUserList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
+      return res.apiv3({ invitedUserList });
     }
     catch (err) {
       logger.error('Error', err);
@@ -333,5 +334,77 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users:
+   *      get:
+   *        tags: [Users]
+   *        description: Get external-account
+   *        responses:
+   *          200:
+   *            description: external-account are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    paginateResult:
+   *                      $ref: '#/components/schemas/PaginateResult'
+   */
+  router.get('/external-accounts/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const page = parseInt(req.query.page) || 1;
+    try {
+      const paginateResult = await ExternalAccount.findAllWithPagination({ page });
+      return res.apiv3({ paginateResult });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching external-account list  ';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg + err.message, 'external-account-list-fetch-failed'), 500);
+    }
+  });
+
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/external-accounts/{id}/remove:
+   *      delete:
+   *        tags: [Users]
+   *        description: Delete ExternalAccount
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of ExternalAccount
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description:  External Account is removed
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    externalAccount:
+   *                      type: object
+   *                      description: A result of `ExtenralAccount.findByIdAndRemove`
+   */
+  router.delete('/external-accounts/:id/remove', loginRequiredStrictly, adminRequired, ApiV3FormValidator, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const externalAccount = await ExternalAccount.findByIdAndRemove(id);
+
+      return res.apiv3({ externalAccount });
+    }
+    catch (err) {
+      const msg = 'Error occurred in deleting a external account  ';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg + err.message, 'extenral-account-delete-failed'));
+    }
+  });
   return router;
 };

+ 22 - 10
src/server/routes/attachment.js

@@ -47,21 +47,32 @@ module.exports = function(crowi, app) {
   /**
    * Common method to response
    *
+   * @param {Request} req
    * @param {Response} res
    * @param {User} user
    * @param {Attachment} attachment
    * @param {boolean} forceDownload
    */
-  async function responseForAttachment(res, user, attachment, forceDownload) {
+  async function responseForAttachment(req, res, attachment, forceDownload) {
     if (attachment == null) {
       return res.json(ApiResponse.error('attachment not found'));
     }
 
+    const user = req.user;
     const isAccessible = await isAccessibleByViewer(user, attachment);
     if (!isAccessible) {
       return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'`));
     }
 
+    // add headers before evaluating 'req.fresh'
+    setHeaderToRes(res, attachment, forceDownload);
+
+    // return 304 if request is "fresh"
+    // see: http://expressjs.com/en/5x/api.html#req.fresh
+    if (req.fresh) {
+      return res.sendStatus(304);
+    }
+
     let fileStream;
     try {
       fileStream = await fileUploader.findDeliveryFile(attachment);
@@ -71,7 +82,6 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(e.message));
     }
 
-    setHeaderToRes(res, attachment, forceDownload);
     return fileStream.pipe(res);
   }
 
@@ -83,14 +93,16 @@ module.exports = function(crowi, app) {
    * @param {boolean} forceDownload
    */
   function setHeaderToRes(res, attachment, forceDownload) {
+    res.set({
+      ETag: `Attachment-${attachment._id}`,
+      'Last-Modified': attachment.createdAt,
+    });
+
     // download
     if (forceDownload) {
-      const headers = {
-        'Content-Type': 'application/force-download',
-        'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
-      };
-
-      res.writeHead(200, headers);
+      res.set({
+        'Content-Disposition': `attachment;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+      });
     }
     // reference
     else {
@@ -134,7 +146,7 @@ module.exports = function(crowi, app) {
 
     const attachment = await Attachment.findById(id);
 
-    return responseForAttachment(res, req.user, attachment, true);
+    return responseForAttachment(req, res, attachment, true);
   };
 
   /**
@@ -149,7 +161,7 @@ module.exports = function(crowi, app) {
 
     const attachment = await Attachment.findById(id);
 
-    return responseForAttachment(res, req.user, attachment);
+    return responseForAttachment(req, res, attachment);
   };
 
   /**

+ 7 - 4
src/server/routes/comment.js

@@ -121,10 +121,13 @@ module.exports = function(crowi, app) {
     }
 
     // update page
-    const page = await Page.findOneAndUpdate({ _id: pageId }, {
-      lastUpdateUser: req.user,
-      updatedAt: new Date(),
-    });
+    const page = await Page.findOneAndUpdate(
+      { _id: pageId },
+      {
+        lastUpdateUser: req.user,
+        updatedAt: new Date(),
+      },
+    );
 
     res.json(ApiResponse.success({ comment: createdComment }));
 

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

@@ -91,10 +91,7 @@ module.exports = function(crowi, app) {
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback);
 
   // markdown admin
-  app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index); // TODO delete
-  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);
+  app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index);
 
   // markdown admin
   app.get('/admin/customize'                , loginRequiredStrictly , adminRequired , admin.customize.index);

+ 0 - 2
src/server/routes/installer.js

@@ -17,9 +17,7 @@ module.exports = function(crowi, app) {
       return;
     }
 
-    await search.deleteIndex();
     await search.buildIndex();
-    await search.addAllPages();
   }
 
   async function createInitialPages(owner, lang) {

+ 19 - 13
src/server/routes/page.js

@@ -72,9 +72,6 @@ module.exports = function(crowi, app) {
   }
 
   function generatePager(offset, limit, totalCount) {
-    let next = null;
-
-
     let prev = null;
 
     if (offset > 0) {
@@ -84,12 +81,10 @@ module.exports = function(crowi, app) {
       }
     }
 
-    if (totalCount < limit) {
+    let next = offset + limit;
+    if (totalCount < next) {
       next = null;
     }
-    else {
-      next = offset + limit;
-    }
 
     return {
       prev,
@@ -164,7 +159,7 @@ module.exports = function(crowi, app) {
 
     const queryOptions = {
       offset,
-      limit: limit + 1,
+      limit,
       includeTrashed: path.startsWith('/trash/'),
       isRegExpEscapedFromPath,
     };
@@ -469,13 +464,15 @@ module.exports = function(crowi, app) {
   };
 
   actions.deletedPageListShow = async function(req, res) {
-    const path = `/trash${getPathFromRequest(req)}`;
+    // normalizePath makes '/trash/' -> '/trash'
+    const path = pathUtils.normalizePath(`/trash${getPathFromRequest(req)}`);
+
     const limit = 50;
     const offset = parseInt(req.query.offset) || 0;
 
     const queryOptions = {
       offset,
-      limit: limit + 1,
+      limit,
       includeTrashed: true,
     };
 
@@ -576,7 +573,7 @@ module.exports = function(crowi, app) {
    */
   api.create = async function(req, res) {
     const body = req.body.body || null;
-    const pagePath = req.body.path || null;
+    let pagePath = req.body.path || null;
     const grant = req.body.grant || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
@@ -589,6 +586,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
     }
 
+    // check whether path starts slash
+    pagePath = pathUtils.addHeadingSlash(pagePath);
+
     // check page existence
     const isExist = await Page.count({ path: pagePath }) > 0;
     if (isExist) {
@@ -1067,7 +1067,7 @@ module.exports = function(crowi, app) {
   api.rename = async function(req, res) {
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
-    const newPagePath = pathUtils.normalizePath(req.body.new_path);
+    let newPagePath = pathUtils.normalizePath(req.body.new_path);
     const options = {
       createRedirectPage: (req.body.create_redirect != null),
       updateMetadata: (req.body.remain_metadata == null),
@@ -1079,6 +1079,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Could not use the path '${newPagePath})'`, 'invalid_path'));
     }
 
+    // check whether path starts slash
+    newPagePath = pathUtils.addHeadingSlash(newPagePath);
+
     const isExist = await Page.count({ path: newPagePath }) > 0;
     if (isExist) {
       // if page found, cannot cannot rename to that path
@@ -1133,7 +1136,7 @@ module.exports = function(crowi, app) {
    */
   api.duplicate = async function(req, res) {
     const pageId = req.body.page_id;
-    const newPagePath = pathUtils.normalizePath(req.body.new_path);
+    let newPagePath = pathUtils.normalizePath(req.body.new_path);
 
     const page = await Page.findByIdAndViewer(pageId, req.user);
 
@@ -1141,6 +1144,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
+    // check whether path starts slash
+    newPagePath = pathUtils.addHeadingSlash(newPagePath);
+
     await page.populateDataToShowRevision();
     const originTags = await page.findRelatedTagsById();
 

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

@@ -22,12 +22,6 @@ const TYPES = {
  *  So, parameters of these are under consideration.
  */
 const ENV_VAR_NAME_TO_CONFIG_INFO = {
-  // ELASTICSEARCH_URI: {
-  //   ns:      ,
-  //   key:     ,
-  //   type:    ,
-  //   default:
-  // },
   // FILE_UPLOAD: {
   //   ns:      ,
   //   key:     ,
@@ -136,6 +130,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  FILE_UPLOAD_DISABLED: {
+    ns:      'crowi',
+    key:     'app:fileUploadDisabled',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
+  ELASTICSEARCH_URI: {
+    ns:      'crowi',
+    key:     'app:elasticsearchUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  SEARCHBOX_SSL_URL: {
+    ns:      'crowi',
+    key:     'app:searchboxSslUrl',
+    type:    TYPES.STRING,
+    default: null,
+  },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',
     key:     'gridfs:totalLimit',

+ 27 - 57
src/server/service/export.js

@@ -7,54 +7,25 @@ const { Transform } = require('stream');
 const streamToPromise = require('stream-to-promise');
 const archiver = require('archiver');
 
-
 const toArrayIfNot = require('../../lib/util/toArrayIfNot');
 
+const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 
-class ExportingProgress {
-
-  constructor(collectionName, totalCount) {
-    this.collectionName = collectionName;
-    this.currentCount = 0;
-    this.totalCount = totalCount;
-  }
-
-}
-
-class ExportingStatus {
-
-  constructor() {
-    this.totalCount = 0;
-
-    this.progressList = null;
-    this.progressMap = {};
-  }
-
-  async init(collections) {
-    const promisesForCreatingInstance = collections.map(async(collectionName) => {
-      const collection = mongoose.connection.collection(collectionName);
-      const totalCount = await collection.count();
-      return new ExportingProgress(collectionName, totalCount);
-    });
-    this.progressList = await Promise.all(promisesForCreatingInstance);
+class ExportProgressingStatus extends CollectionProgressingStatus {
 
-    // collection name to instance mapping
-    this.progressList.forEach((p) => {
-      this.progressMap[p.collectionName] = p;
-      this.totalCount += p.totalCount;
+  async init() {
+    // retrieve total document count from each collections
+    const promises = this.progressList.map(async(collectionProgress) => {
+      const collection = mongoose.connection.collection(collectionProgress.collectionName);
+      collectionProgress.totalCount = await collection.count();
     });
-  }
+    await Promise.all(promises);
 
-  get currentCount() {
-    return this.progressList.reduce(
-      (acc, crr) => acc + crr.currentCount,
-      0,
-    );
+    this.recalculateTotalCount();
   }
 
 }
 
-
 class ExportService {
 
   constructor(crowi) {
@@ -68,14 +39,14 @@ class ExportService {
 
     this.adminEvent = crowi.event('admin');
 
-    this.currentExportingStatus = null;
+    this.currentProgressingStatus = null;
   }
 
   /**
    * parse all zip files in downloads dir
    *
    * @memberOf ExportService
-   * @return {object} info for zip files and whether currentExportingStatus exists
+   * @return {object} info for zip files and whether currentProgressingStatus exists
    */
   async getStatus() {
     const zipFiles = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' });
@@ -87,12 +58,12 @@ class ExportService {
     // filter null object (broken zip)
     const filtered = zipFileStats.filter(element => element != null);
 
-    const isExporting = this.currentExportingStatus != null;
+    const isExporting = this.currentProgressingStatus != null;
 
     return {
       zipFileStats: filtered,
       isExporting,
-      progressList: isExporting ? this.currentExportingStatus.progressList : null,
+      progressList: isExporting ? this.currentProgressingStatus.progressList : null,
     };
   }
 
@@ -123,7 +94,7 @@ class ExportService {
 
   /**
    *
-   * @param {ExportProguress} exportProgress
+   * @param {ExportProgress} exportProgress
    * @return {Transform}
    */
   generateLogStream(exportProgress) {
@@ -196,7 +167,7 @@ class ExportService {
     const transformStream = this.generateTransformStream();
 
     // log configuration
-    const exportProgress = this.currentExportingStatus.progressMap[collectionName];
+    const exportProgress = this.currentProgressingStatus.progressMap[collectionName];
     const logStream = this.generateLogStream(exportProgress);
 
     // create WritableStream
@@ -246,18 +217,18 @@ class ExportService {
   }
 
   async export(collections) {
-    if (this.currentExportingStatus != null) {
+    if (this.currentProgressingStatus != null) {
       throw new Error('There is an exporting process running.');
     }
 
-    this.currentExportingStatus = new ExportingStatus();
-    await this.currentExportingStatus.init(collections);
+    this.currentProgressingStatus = new ExportProgressingStatus(collections);
+    await this.currentProgressingStatus.init();
 
     try {
       await this.exportCollectionsToZippedJson(collections);
     }
     finally {
-      this.currentExportingStatus = null;
+      this.currentProgressingStatus = null;
     }
 
   }
@@ -267,14 +238,14 @@ class ExportService {
    *
    * @memberOf ExportService
    *
-   * @param {ExportProgress} exportProgress
+   * @param {CollectionProgress} collectionProgress
    * @param {number} currentCount number of items exported
    */
-  logProgress(exportProgress, currentCount) {
-    const output = `${exportProgress.collectionName}: ${currentCount}/${exportProgress.totalCount} written`;
+  logProgress(collectionProgress, currentCount) {
+    const output = `${collectionProgress.collectionName}: ${currentCount}/${collectionProgress.totalCount} written`;
 
     // update exportProgress.currentCount
-    exportProgress.currentCount = currentCount;
+    collectionProgress.currentCount = currentCount;
 
     // output every this.per items
     if (currentCount % this.per === 0) {
@@ -282,7 +253,7 @@ class ExportService {
       this.emitProgressEvent();
     }
     // output last item
-    else if (currentCount === exportProgress.totalCount) {
+    else if (currentCount === collectionProgress.totalCount) {
       logger.info(output);
       this.emitProgressEvent();
     }
@@ -290,10 +261,9 @@ class ExportService {
 
   /**
    * emit progress event
-   * @param {ExportProgress} exportProgress
    */
-  emitProgressEvent(exportProgress) {
-    const { currentCount, totalCount, progressList } = this.currentExportingStatus;
+  emitProgressEvent() {
+    const { currentCount, totalCount, progressList } = this.currentProgressingStatus;
     const data = {
       currentCount,
       totalCount,
@@ -331,7 +301,7 @@ class ExportService {
     const configs = toArrayIfNot(_configs);
     const appTitle = this.appService.getAppTitle();
     const timeStamp = (new Date()).getTime();
-    const zipFile = path.join(this.baseDir, `${appTitle}-${timeStamp}.zip`);
+    const zipFile = path.join(this.baseDir, `${appTitle}-${timeStamp}.growi.zip`);
     const archive = archiver('zip', {
       zlib: { level: this.zlibLevel },
     });

+ 1 - 1
src/server/service/file-uploader/aws.js

@@ -49,7 +49,7 @@ module.exports = function(crowi) {
     return filePath;
   }
 
-  lib.getIsUploadable = function() {
+  lib.isValidUploadSettings = function() {
     return this.configManager.getConfig('crowi', 'aws:accessKeyId') != null
       && this.configManager.getConfig('crowi', 'aws:secretAccessKey') != null
       && (

+ 3 - 2
src/server/service/file-uploader/gcs.js

@@ -38,8 +38,9 @@ module.exports = function(crowi) {
     return filePath;
   }
 
-  lib.getIsUploadable = function() {
-    return this.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
+  lib.isValidUploadSettings = function() {
+    return this.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
+      && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
   };
 
   lib.deleteFile = async function(attachment) {

+ 1 - 1
src/server/service/file-uploader/gridfs.js

@@ -22,7 +22,7 @@ module.exports = function(crowi) {
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
   AttachmentFile.promisifiedUnlink = util.promisify(AttachmentFile.unlink).bind(AttachmentFile);
 
-  lib.getIsUploadable = function() {
+  lib.isValidUploadSettings = function() {
     return true;
   };
 

Некоторые файлы не были показаны из-за большого количества измененных файлов