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

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

utsushiiro 7 лет назад
Родитель
Сommit
e1cc942b11
100 измененных файлов с 3561 добавлено и 2255 удалено
  1. 18 1
      CHANGES.md
  2. 2 0
      README.md
  3. 30 0
      bin/download-cdn-resources.js
  4. 22 0
      bin/download-resources.js
  5. 1 1
      bin/wercker/trigger-growi-docker.sh
  6. 2 0
      config/env.dev.js
  7. 3 0
      config/webpack.common.js
  8. 13 11
      package.json
  9. BIN
      public/images/themes/christmas/christmas-navbar.jpg
  10. BIN
      public/images/themes/christmas/christmas.jpg
  11. 191 0
      resource/cdn-manifests.js
  12. 9 3
      resource/locales/en-US/translation.json
  13. 1 1
      resource/locales/en-US/welcome.md
  14. 8 2
      resource/locales/ja/translation.json
  15. 1 1
      resource/locales/ja/welcome.md
  16. 34 44
      resource/search/mappings.json
  17. 15 12
      src/client/js/app.js
  18. 66 0
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  19. 87 37
      src/client/js/components/InstallerForm.js
  20. 64 0
      src/client/js/components/Page.jsx
  21. 114 0
      src/client/js/components/Page/RevisionLoader.jsx
  22. 10 37
      src/client/js/components/Page/RevisionRenderer.jsx
  23. 2 4
      src/client/js/components/PageComment/Comment.js
  24. 3 4
      src/client/js/components/PageComment/CommentForm.jsx
  25. 66 37
      src/client/js/components/PageEditor.js
  26. 20 4
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  27. 1 0
      src/client/js/components/PageEditor/Editor.jsx
  28. 1 1
      src/client/js/components/PageEditor/MarkdownTableDataImportForm.jsx
  29. 17 12
      src/client/js/components/PageList/PageListMeta.js
  30. 17 6
      src/client/js/components/PageList/PagePath.js
  31. 2 2
      src/client/js/components/RecentCreated/RecentCreated.jsx
  32. 6 5
      src/client/js/components/SearchPage.js
  33. 2 2
      src/client/js/components/SearchPage/SearchResult.js
  34. 4 4
      src/client/js/components/SearchPage/SearchResultList.js
  35. 2 0
      src/client/js/hackmd-agent.js
  36. 21 12
      src/client/js/legacy/crowi.js
  37. 10 2
      src/client/js/util/Crowi.js
  38. 5 5
      src/client/js/util/GrowiRenderer.js
  39. 7 2
      src/client/js/util/markdown-it/task-lists.js
  40. 127 0
      src/client/styles/agile-admin/inverse/colors/christmas.scss
  41. 2 2
      src/client/styles/agile-admin/inverse/eliteadmin.scss
  42. 3 5
      src/client/styles/agile-admin/inverse/widgets.scss
  43. 21 1
      src/client/styles/scss/_search.scss
  44. 23 15
      src/client/styles/scss/_wiki.scss
  45. 8 0
      src/client/styles/scss/theme/christmas.scss
  46. 156 0
      src/lib/service/cdn-resources-downloader.js
  47. 163 0
      src/lib/service/cdn-resources-service.js
  48. 112 0
      src/migrations/20181019114028-abolish-page-group-relation.js
  49. 21 32
      src/server/crowi/index.js
  50. 15 0
      src/server/events/bookmark.js
  51. 11 0
      src/server/events/search.js
  52. 19 17
      src/server/events/user.js
  53. 7 6
      src/server/form/admin/securityGeneral.js
  54. 2 1
      src/server/form/register.js
  55. 3 10
      src/server/models/GlobalNotificationSetting/index.js
  56. 28 37
      src/server/models/bookmark.js
  57. 14 0
      src/server/models/config.js
  58. 486 629
      src/server/models/page.js
  59. 0 28
      src/server/models/revision.js
  60. 19 9
      src/server/models/user-group-relation.js
  61. 13 0
      src/server/models/user.js
  62. 75 66
      src/server/routes/admin.js
  63. 16 3
      src/server/routes/attachment.js
  64. 53 47
      src/server/routes/bookmark.js
  65. 50 30
      src/server/routes/comment.js
  66. 7 6
      src/server/routes/index.js
  67. 9 1
      src/server/routes/installer.js
  68. 1 2
      src/server/routes/login.js
  69. 301 578
      src/server/routes/page.js
  70. 31 22
      src/server/routes/revision.js
  71. 52 36
      src/server/routes/search.js
  72. 7 0
      src/server/service/file-uploader/aws.js
  73. 28 0
      src/server/service/file-uploader/gridfs.js
  74. 7 0
      src/server/service/file-uploader/local.js
  75. 44 0
      src/server/util/apiPaginate.js
  76. 456 228
      src/server/util/search.js
  77. 26 0
      src/server/util/swigFunctions.js
  78. 2 2
      src/server/views/_form.html
  79. 84 80
      src/server/views/admin/customize.html
  80. 71 1
      src/server/views/admin/search.html
  81. 45 3
      src/server/views/admin/security.html
  82. 5 33
      src/server/views/installer.html
  83. 5 0
      src/server/views/layout-crowi/base/layout.html
  84. 1 1
      src/server/views/layout-crowi/page_list.html
  85. 5 0
      src/server/views/layout-growi/base/layout.html
  86. 1 1
      src/server/views/layout-growi/page_list.html
  87. 5 0
      src/server/views/layout-kibela/base/layout.html
  88. 1 1
      src/server/views/layout-kibela/page_list.html
  89. 4 27
      src/server/views/layout/layout.html
  90. 2 16
      src/server/views/page_presentation.html
  91. 5 0
      src/server/views/search.html
  92. 1 1
      src/server/views/widget/create_portal.html
  93. 0 1
      src/server/views/widget/forbidden_content.html
  94. 0 1
      src/server/views/widget/not_found_content.html
  95. 2 1
      src/server/views/widget/not_found_tabs.html
  96. 14 17
      src/server/views/widget/page_alerts.html
  97. 12 2
      src/server/views/widget/page_content.html
  98. 1 2
      src/server/views/widget/page_list.html
  99. 1 1
      src/server/views/widget/page_list_and_timeline.html
  100. 1 1
      src/server/views/widget/page_list_and_timeline_kibela.html

+ 18 - 1
CHANGES.md

@@ -1,9 +1,26 @@
 CHANGES
 ========
 
+## 3.3.0-RC
+
+* Feature: NO_CDN Mode
+* Feature: Add option to show/hide restricted pages in list
+* Improvement: Refactor Access Control
+* Improvement: Checkbox behavior of task list
+* Improvement: Fixed search input on search result page
+* Improvement: Add 'christmas' theme
+* Fix: Hide restricted pages contents in timeline
+* Support: Upgrade libs
+    * googleapis
+    * passport-saml
+
 ## 3.2.10
 
-* 
+* Fix: Pages in trash are available to create
+* Fix: Couldn't create portal page under Crowi Classic Behavior
+* Fix: Table tag in Timeline/SearchResult missed border and BS3 styles
+* I18n: Installer
+
 
 ## 3.2.9
 

+ 2 - 0
README.md

@@ -158,6 +158,7 @@ Environment Variables
 * **Option**
     * NODE_ENV: `production` OR `development`.
     * PORT: Server port. default: `3000`.
+    * NO_CDN: If `true`, system doesn't use CDN, all resources will be downloaded from CDN when build client, and served by the GROWI Express server. default: `false`.
     * 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.
@@ -168,6 +169,7 @@ Environment Variables
       * `mongodb` : MongoDB GridFS (Setting-less)
       * `local` : Server's Local file system (Setting-less)
       * `none` : Disable file uploading
+    * MONGODB_GRIDFS_LIMIT: Limit amount of uploaded file with GridFS: `Infinity`
 * **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).**

+ 30 - 0
bin/download-cdn-resources.js

@@ -0,0 +1,30 @@
+/**
+ * the tool for download CDN resources and save as file
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+require('module-alias/register');
+
+const logger = require('@alias/logger')('growi:bin:download-resources');
+
+// check env var
+const noCdn = !!process.env.NO_CDN;
+if (!noCdn) {
+  logger.info('Using CDN. No resources are downloaded.');
+  // exit
+  process.exit(0);
+}
+
+const CdnResourcesService = require('@commons/service/cdn-resources-service');
+
+const service = new CdnResourcesService();
+
+logger.info('This is NO_CDN mode. Start to download resources.');
+
+service.downloadAndWriteAll()
+  .then(() => {
+    logger.info('Download is terminated successfully');
+  })
+  .catch(err => {
+    logger.error(err);
+  });

+ 22 - 0
bin/download-resources.js

@@ -0,0 +1,22 @@
+/**
+ * the tool for download CDN resources and save as file
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+
+require('module-alias/register');
+
+const logger = require('@alias/logger')('growi:bin:download-resources');
+const CdnResourcesService = require('@commons/service/cdn-resources-service');
+
+const service = new CdnResourcesService();
+
+logger.info('Start to download.');
+
+service.downloadAndWriteAll()
+  .then(() => {
+    logger.info('Download is terminated successfully');
+  })
+  .catch(err => {
+    logger.error(err);
+  });

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

@@ -24,7 +24,7 @@ RESPONSE=`curl -X POST \
       }, \
       { \
         "key": "GROWI_REPOS_GIT_COMMIT", \
-        "value": "'$WERCKER_GIT_COMMIT'" \
+        "value": "'$RELEASE_GIT_COMMIT'" \
       } \
     ] \
   }' \

+ 2 - 0
config/env.dev.js

@@ -1,7 +1,9 @@
 module.exports = {
   NODE_ENV: 'development',
   FILE_UPLOAD: 'mongodb',
+  // MONGODB_GRIDFS_LIMIT: 10485760,   // 10MB
   // MATHJAX: 1,
+  // NO_CDN: true,
   ELASTICSEARCH_URI: 'http://localhost:9200/growi',
   HACKMD_URI: 'http://localhost:3010',
   PLUGIN_NAMES_TOBE_LOADED: [

+ 3 - 0
config/webpack.common.js

@@ -41,6 +41,7 @@ module.exports = (options) => {
       'styles/theme-kibela':          './src/client/styles/scss/theme/kibela.scss',
       'styles/theme-halloween':       './src/client/styles/scss/theme/halloween.scss',
       'styles/theme-wood':          './src/client/styles/scss/theme/wood.scss',
+      'styles/theme-christmas':          './src/client/styles/scss/theme/christmas.scss',
       'styles/theme-island':      './src/client/styles/scss/theme/island.scss',
       // styles for external services
       'styles/style-hackmd':          './src/client/styles/hackmd/style.scss',
@@ -63,6 +64,7 @@ module.exports = (options) => {
       alias: {
         '@root': helpers.root('/'),
         '@commons': helpers.root('src/lib'),
+        '@client': helpers.root('src/client'),
         '@tmp': helpers.root('tmp'),
         '@alias/logger': helpers.root('src/lib/service/logger'),
         '@alias/locales': helpers.root('resource/locales'),
@@ -77,6 +79,7 @@ module.exports = (options) => {
           exclude: {
             test:    helpers.root('node_modules'),
             exclude: [  // include as a result
+              { test: helpers.root('node_modules', 'growi-plugin-') },
               helpers.root('node_modules/codemirror/src'),
               helpers.root('node_modules/string-width'),
               helpers.root('node_modules/is-fullwidth-code-point'), // depends from string-width

+ 13 - 11
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.2.10-RC",
+  "version": "3.3.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -20,18 +20,15 @@
     "url": "https://github.com/weseek/growi/issues"
   },
   "scripts": {
-    "build:dev:analyze": "npm-run-all -s build:dev:dll build:dev:app:analyze",
-    "build:dev:app:analyze": "cross-env ANALYZE=1 npm run build:dev:app:watch -- --profile",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
-    "build:dev:app": "npm run clean:app && env-cmd config/env.dev.js webpack --config config/webpack.dev.js --progress",
+    "build:dev:app": "env-cmd config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
     "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
     "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
-    "build:prod": "npm run clean && env-cmd config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
+    "build:prod": "env-cmd config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
     "clean:app": "rimraf -- public/js public/styles",
-    "clean:dll": "rimraf -- public/dll",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
     "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
@@ -44,10 +41,12 @@
     "migrate:down": "migrate-mongo down -f config/migrate.js",
     "mkdirp": "mkdirp",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
-    "prebuild:dev:app": "env-cmd config/env.dev.js npm run plugin:def",
-    "prebuild:prod": "npm run plugin:def",
+    "prebuild:dev:watch": "npm run prebuild:dev",
+    "prebuild:dev": "npm run clean:app && env-cmd config/env.dev.js npm run plugin:def && env-cmd config/env.dev.js npm run resource",
+    "prebuild:prod": "npm run clean && env-cmd config/env.prod.js npm run plugin:def && env-cmd config/env.dev.js npm run resource",
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
+    "resource": "node bin/download-cdn-resources.js",
     "server:debug": "env-cmd config/env.dev.js node-dev --inspect src/server/app.js",
     "server:dev": "env-cmd config/env.dev.js node-dev --respawn src/server/app.js",
     "server:prod:ci": "npm run server:prod -- --ci",
@@ -85,7 +84,7 @@
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^35.0.0",
+    "googleapis": "^36.0.0",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
@@ -112,7 +111,7 @@
     "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
-    "passport-saml": "^0.35.0",
+    "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
@@ -168,7 +167,7 @@
     "markdown-it-mathjax": "^2.0.0",
     "markdown-it-named-headers": "^0.0.4",
     "markdown-it-plantuml": "^1.0.0",
-    "markdown-it-task-lists": "^2.1.0",
+    "markdown-it-task-checkbox": "^1.0.6",
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
@@ -193,12 +192,15 @@
     "react-dropzone": "^7.0.1",
     "react-frame-component": "^4.0.0",
     "react-i18next": "=7.13.0",
+    "react-waypoint": "^8.1.0",
+    "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.1.0",
     "simple-load-script": "^1.0.2",
     "sinon": "^7.0.0",
     "sinon-chai": "^3.2.0",
     "socket.io-client": "^2.0.3",
+    "stream-to-promise": "^2.2.0",
     "style-loader": "^0.23.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",

BIN
public/images/themes/christmas/christmas-navbar.jpg


BIN
public/images/themes/christmas/christmas.jpg


+ 191 - 0
resource/cdn-manifests.js

@@ -0,0 +1,191 @@
+module.exports = {
+  js: [
+    {
+      name: 'basis',
+      url: 'https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1,npm/bootstrap@3.3.7/dist/js/bootstrap.min.js',
+      groups: ['basis'],
+      args: {
+        integrity: '',
+      }
+    },
+    {
+      name: 'highlight',
+      url: 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js',
+      groups: ['basis'],
+      args: {
+        integrity: '',
+      }
+    },
+    {
+      name: 'highlight-addons',
+      url: 'https://cdn.jsdelivr.net/combine/' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/dockerfile.min.js,' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/go.min.js,' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/gradle.min.js,' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/json.min.js,' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/less.min.js,' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/scss.min.js,' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/typescript.min.js,' +
+'gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js',
+      args: {
+        async: true,
+        integrity: '',
+      }
+    },
+    {
+      name: 'mathjax',
+      url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js',
+      args: {
+        async: true,
+        integrity: '',
+      }
+    },
+    {
+      name: 'codemirror-dialog',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/addon/dialog/dialog.min.js',
+      args: {
+        integrity: '',
+      }
+    },
+    {
+      name: 'codemirror-keymap-vim',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/vim.min.js',
+      args: {
+        integrity: '',
+      }
+    },
+    {
+      name: 'codemirror-keymap-emacs',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/emacs.min.js',
+      args: {
+        integrity: '',
+      }
+    },
+    {
+      name: 'codemirror-keymap-sublime',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/keymap/sublime.min.js',
+      args: {
+        integrity: '',
+      }
+    },
+  ],
+  style: [
+    {
+      name: 'lato',
+      url: 'https://fonts.googleapis.com/css?family=Lato:400,700',
+      groups: ['basis'],
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'font-awesome',
+      url: 'https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css',
+      groups: ['basis'],
+      args: {
+        integrity: '',
+      }
+    },
+    {
+      name: 'themify-icons',
+      url: 'https://cdn.jsdelivr.net/npm/cd-themify-icons@0.0.1/index.min.css',
+      groups: ['basis'],
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'simple-line-icons',
+      url: 'https://cdn.jsdelivr.net/npm/simple-line-icons@2.4.1/css/simple-line-icons.min.css',
+      groups: ['basis'],
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'emojione',
+      url: 'https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css',
+      groups: ['basis'],
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'jquery-ui',
+      url: 'https://cdn.jsdelivr.net/jquery.ui/1.11.4/jquery-ui.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'highlight-theme-github',
+      url: 'https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-dialog',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/addon/dialog/dialog.min.css',
+      args: {
+        integrity: '',
+      }
+    },
+    {
+      name: 'codemirror-theme-eclipse',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/eclipse.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-theme-elegant',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/elegant.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-theme-neo',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/neo.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-theme-mdn-like',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/mdn-like.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-theme-material',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/material.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-theme-dracula',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/dracula.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-theme-monokai',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/monokai.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+    {
+      name: 'codemirror-theme-twilight',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0/theme/twilight.min.css',
+      args: {
+        integrity: ''
+      },
+    },
+  ]
+};

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

@@ -314,7 +314,12 @@
     "restrict_emails": "You can restrict registerable e-mail address.",
 		"for_instance": " For instance, if you use growi within a company, you can write ",
 		"only_those": " Only those whose e-mail address including the company address can register.",
-		"insert_single": "Please insert single e-mail address per line.",
+    "insert_single": "Please insert single e-mail address per line.",
+    "page_listing_1": "Page listing<br>restricted by 'Just Me'",
+    "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing",
+    "page_listing_2": "Page listing<br>restricted by User Group",
+    "page_listing_2_desc": "Show pages that are restricted by User Group when listing",
+
 		"Authentication mechanism settings": "Authentication mechanism settings",
     "note": "Note",
     "require_server_restart_change_auth": "Restarting the server is required if you switch the auth mechanism.",
@@ -508,6 +513,7 @@
     "tab_switch": "Save tab-switching in the browser",
     "save_edit": "Save edit tab and history tab switching in the browser and make it object for forward/back command of the browser.",
     "by_invalidating": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
+    "nocdn_desc": "This function is disabled when the environment variable <code>NO_CDN=true</code>.<br>Github style has been forcibly applied.",
     "Custom CSS": "Custom CSS",
     "write_CSS": "You can write CSS that is applied to whole system.",
     "reflect_change": "You need to reload the page to reflect the change.",
@@ -516,8 +522,8 @@
     "write_java": "You can write Javascript that is applied to whole system.",
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
-    "show_document_number": "Manage the number of documents to be displayed",
-    "show_document_number_desc": "Set the number of items to display on one page in Recent Created on the home screen"
+    "recent_created_page_num": "Recent Created Paging num",
+    "recent_created_page_num_desc": "The number of pages to show in Recent Created Page List on the user's home"
   },
 
   "user_management": {

+ 1 - 1
resource/locales/en-US/welcome.md

@@ -3,7 +3,7 @@
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
-<div class="panel panel-default">
+<div class="panel panel-primary">
   <div class="panel-heading">Tips</div>
   <div class="panel-body"><ul>
     <li>Ctrl(⌘)-/ to show quick help</li>

+ 8 - 2
resource/locales/ja/translation.json

@@ -332,6 +332,11 @@
     "for_instance":"例えば、",
     "only_those":"と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
     "insert_single":"1行に1メールアドレス入力してください。",
+    "page_listing_1": "ページのリスト表示<br>'自分のみ'に閲覧制限しているページ",
+    "page_listing_1_desc": "ページのリスト表示時、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "page_listing_2": "ページのリスト表示<br>特定グループに閲覧制限しているページ",
+    "page_listing_2_desc": "ページのリスト表示時、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+
     "Authentication mechanism settings":"認証機構設定",
     "note": "メモ",
     "require_server_restart_change_auth": "認証機構の変更後はサーバーを再起動してください。",
@@ -523,6 +528,7 @@
     "tab_switch": "タブ変更をブラウザ履歴に保存",
     "save_edit": "編集タブやヒストリータブ等の切り替えをブラウザ履歴に保存し、ブラウザの戻る/進む操作の対象にします。",
     "by_invalidating": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
+    "nocdn_desc": "この機能は、環境変数 <code>NO_CDN=true</code> の時は無効化されます。<br>GitHub スタイルが適用されています。",
     "Custom CSS": "カスタム CSS",
     "write_CSS": " システム全体に適用されるCSSを記述できます。",
     "reflect_change": "変更の反映はページの更新が必要です。",
@@ -531,8 +537,8 @@
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-    "show_document_number": "表示ドキュメント数管理",
-    "show_document_number_desc": "ホーム画面の Recent Created で、1ページに表示する件数を設定します。"
+    "recent_created_page_num": "Recent Created ページングサイズ",
+    "recent_created_page_num_desc": "ホーム画面の Recent Created で、1ページに表示する件数を設定します。"
   },
 
   "user_management": {

+ 1 - 1
resource/locales/ja/welcome.md

@@ -3,7 +3,7 @@
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
-<div class="panel panel-default">
+<div class="panel panel-primary">
   <div class="panel-heading">Tips</div>
   <div class="panel-body"><ul>
     <li>Ctrl(⌘)-/ でショートカットヘルプを表示します</li>

+ 34 - 44
resource/search/mappings.json

@@ -24,13 +24,6 @@
         }
       },
       "analyzer": {
-        "autocomplete": {
-          "tokenizer":  "keyword",
-          "filter": [
-            "lowercase",
-            "nGram"
-          ]
-        },
         "japanese": {
           "tokenizer": "kuromoji_tokenizer",
           "char_filter" : ["icu_normalizer"]
@@ -48,52 +41,40 @@
     }
   },
   "mappings": {
-    "users": {
-      "properties" : {
-        "name": {
-          "type": "text",
-          "analyzer": "autocomplete"
-        }
-      }
-    },
     "pages": {
       "properties" : {
         "path": {
           "type": "text",
-          "copy_to": ["path_raw", "path_ja", "path_en"],
-          "index": "false"
-        },
-        "path_raw": {
-          "type": "text",
-          "analyzer": "standard"
-        },
-        "path_ja": {
-          "type": "text",
-          "analyzer": "japanese"
-        },
-        "path_en": {
-          "type": "text",
-          "analyzer": "english"
+          "fields": {
+            "raw": {
+              "type": "text",
+              "analyzer": "keyword"
+            },
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english"
+            }
+          }
         },
         "body": {
           "type": "text",
-          "copy_to": ["body_raw", "body_ja", "body_en"],
-          "index": "false"
-        },
-        "body_raw": {
-          "type": "text",
-          "analyzer": "standard"
-        },
-        "body_ja": {
-          "type": "text",
-          "analyzer": "japanese"
-        },
-        "body_en": {
-          "type": "text",
-          "analyzer": "english"
+          "fields": {
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english"
+            }
+          }
         },
         "username": {
-          "type": "text"
+          "type": "keyword"
         },
         "comment_count": {
           "type": "integer"
@@ -104,6 +85,15 @@
         "like_count": {
           "type": "integer"
         },
+        "grant": {
+          "type": "integer"
+        },
+        "granted_users": {
+          "type": "keyword"
+        },
+        "granted_group": {
+          "type": "keyword"
+        },
         "created_at": {
           "type": "date",
           "format": "dateOptionalTime"

+ 15 - 12
src/client/js/app.js

@@ -3,8 +3,6 @@ import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import * as toastr from 'toastr';
 
-import io from 'socket.io-client';
-
 import i18nFactory from './i18n';
 
 import loggerFactory from '@alias/logger';
@@ -38,6 +36,7 @@ import RecentCreated from './components/RecentCreated/RecentCreated';
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
+import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 
 import * as entities from 'entities';
 
@@ -50,8 +49,6 @@ if (!window) {
 const userlang = $('body').data('userlang');
 const i18n = i18nFactory(userlang);
 
-const socket = io();
-
 // setup xss library
 const xss = new Xss();
 window.xss = xss;
@@ -68,7 +65,7 @@ let pageContent = '';
 let markdown = '';
 let slackChannels;
 if (mainContent !== null) {
-  pageId = mainContent.getAttribute('data-page-id');
+  pageId = mainContent.getAttribute('data-page-id') || null;
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
   pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
   pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
@@ -95,6 +92,7 @@ crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').text
 if (isLoggedin) {
   crowi.fetchUsers();
 }
+const socket = crowi.getWebSocket();
 const socketClientId = crowi.getSocketClientId();
 
 const crowiRenderer = new GrowiRenderer(crowi, null, {
@@ -228,11 +226,7 @@ const saveWithSubmitButton = function() {
   options.socketClientId = socketClientId;
 
   let promise = undefined;
-  if (editorMode === 'builtin') {
-    // get markdown
-    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
-  }
-  else {
+  if (editorMode === 'hackmd') {
     // get markdown
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
     // use revisionId of PageEditorByHackmd
@@ -240,6 +234,10 @@ const saveWithSubmitButton = function() {
     // set option to sync
     options.isSyncRevisionToHackmd = true;
   }
+  else {
+    // get markdown
+    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
+  }
   // create or update
   if (pageId == null) {
     promise = promise.then(markdown => {
@@ -331,7 +329,6 @@ if (savePageControlsElem) {
   componentInstances.savePageControls = savePageControls;
 }
 
-// RecentCreated dev GC-939 start
 const recentCreatedControlsElem = document.getElementById('user-created-list');
 if (recentCreatedControlsElem) {
   let limit = crowi.getConfig().recentCreatedLimit;
@@ -344,7 +341,6 @@ if (recentCreatedControlsElem) {
     </RecentCreated>, document.getElementById('user-created-list')
   );
 }
-// RecentCreated dev GC-939 end
 
 /*
  * HackMD Editor
@@ -477,6 +473,13 @@ if (customHeaderEditorElem != null) {
     customHeaderEditorElem
   );
 }
+const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
+if (adminRebuildSearchElem != null) {
+  ReactDOM.render(
+    <AdminRebuildSearch crowi={crowi} />,
+    adminRebuildSearchElem
+  );
+}
 
 // notification from websocket
 function updatePageStatusAlert(page, user) {

+ 66 - 0
src/client/js/components/Admin/AdminRebuildSearch.jsx

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class AdminRebuildSearch extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isCompleted: false,
+      total: 0,
+      current: 0,
+      skip: 0,
+    };
+  }
+
+  componentDidMount() {
+    const socket = this.props.crowi.getWebSocket();
+
+    socket.on('admin:addPageProgress', data => {
+      const newStates = Object.assign(data, { isCompleted: false });
+      this.setState(newStates);
+    });
+
+    socket.on('admin:finishAddPage', data => {
+      const newStates = Object.assign(data, { isCompleted: true });
+      this.setState(newStates);
+    });
+  }
+
+  render() {
+    const { total, current, skip, isCompleted } = this.state;
+    if (total === 0) {
+      return null;
+    }
+
+    const progressBarLabel = isCompleted ? 'Completed' : `Processing.. ${current}/${total} (${skip} skips)`;
+    const progressBarWidth = isCompleted ? '100%' : `${(current / total) * 100}%`;
+    const progressBarClassNames = isCompleted
+      ? 'progress-bar progress-bar-success'
+      : 'progress-bar progress-bar-striped progress-bar-animated active';
+
+    return (
+      <div>
+        <h5>
+          {progressBarLabel}
+          <span className="pull-right">{progressBarWidth}</span>
+        </h5>
+        <div className="progress progress-sm">
+          <div
+            className={progressBarClassNames}
+            role="progressbar"
+            aria-valuemin="0"
+            aria-valuenow={current}
+            aria-valuemax={total}
+            style={{ width: progressBarWidth }}
+          >
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+AdminRebuildSearch.propTypes = {
+  crowi: PropTypes.object.isRequired,
+};

+ 87 - 37
src/client/js/components/InstallerForm.js

@@ -1,49 +1,99 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import i18next from 'i18next';
 import { translate } from 'react-i18next';
 
 class InstallerForm extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isValidUserName: true,
+    };
+    this.checkUserName = this.checkUserName.bind(this);
+  }
+
+  checkUserName(event) {
+    const axios = require('axios').create({
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Requested-With': 'XMLHttpRequest'
+      },
+      responseType: 'json'
+    });
+    axios.get('/_api/check_username', {params: {username: event.target.value}})
+      .then((res) => this.setState({ isValidUserName: res.data.valid }));
+  }
+
+  changeLanguage(locale) {
+    i18next.changeLanguage(locale);
+  }
+
   render() {
+    const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
+    const unavailableUserId = this.state.isValidUserName ? '' : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
     return (
-      <form role="form" action="/installer/createAdmin" method="post" id="register-form">
-        <div className="input-group" id="input-group-username">
-          <span className="input-group-addon"><i className="icon-user"></i></span>
-          <input type="text" className="form-control" placeholder={ this.props.t('User ID') }
-            name="registerForm[username]" defaultValue={this.props.userName} required />
-        </div>
-        <p className="help-block">
-          <span id="help-block-username"></span>
+      <div className={'login-dialog p-t-10 p-b-10 col-sm-offset-4 col-sm-4' + hasErrorClass}>
+        <p className="alert alert-success">
+          <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
+          <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
         </p>
 
-        <div className="input-group">
-          <span className="input-group-addon"><i className="icon-tag"></i></span>
-          <input type="text" className="form-control" placeholder={ this.props.t('Name') } name="registerForm[name]" defaultValue={ this.props.name } required />
-        </div>
-
-        <div className="input-group">
-          <span className="input-group-addon"><i className="icon-envelope"></i></span>
-          <input type="email" className="form-control" placeholder={ this.props.t('Email') } name="registerForm[email]" defaultValue={ this.props.email } required />
-        </div>
-
-        <div className="input-group">
-          <span className="input-group-addon"><i className="icon-lock"></i></span>
-          <input type="password" className="form-control" placeholder={ this.props.t('Password') } name="registerForm[password]" required />
-        </div>
-
-        <input type="hidden" name="_csrf" value={ this.props.csrf } />
-        <div className="input-group m-t-30 m-b-20 d-flex justify-content-center">
-          <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
-            <span className="btn-label"><i className="icon-user-follow"></i></span>
-            { this.props.t('Create') }
-          </button>
-        </div>
-
-        <div className="input-group m-t-30 d-flex justify-content-center">
-          <a href="https://growi.org" className="link-growi-org">
-            <span className="growi">GROWI</span>.<span className="org">ORG</span>
-          </a>
-        </div>
-      </form>
+        <form role="form" action="/installer/createAdmin" method="post" id="register-form">
+          <div className={'input-group' + hasErrorClass}>
+            <span className="input-group-addon"><i className="icon-user" /></span>
+            <input type="text" className="form-control" placeholder={ this.props.t('User ID') }
+              name="registerForm[username]" defaultValue={this.props.userName} onBlur={this.checkUserName} required />
+          </div>
+          <p className="help-block">{ unavailableUserId }</p>
+
+          <div className="input-group">
+            <span className="input-group-addon"><i className="icon-tag" /></span>
+            <input type="text" className="form-control" placeholder={ this.props.t('Name') }
+                   name="registerForm[name]" defaultValue={ this.props.name } required />
+          </div>
+
+          <div className="input-group">
+            <span className="input-group-addon"><i className="icon-envelope" /></span>
+            <input type="email" className="form-control" placeholder={ this.props.t('Email') }
+                   name="registerForm[email]" defaultValue={ this.props.email } required />
+          </div>
+
+          <div className="input-group">
+            <span className="input-group-addon"><i className="icon-lock" /></span>
+            <input type="password" className="form-control" placeholder={ this.props.t('Password') }
+                   name="registerForm[password]" required />
+          </div>
+
+          <input type="hidden" name="_csrf" value={ this.props.csrf } />
+
+          <div className="input-group m-t-20 m-b-20 mx-auto">
+            <div className="radio radio-primary radio-inline">
+              <input type="radio" id="radioLangEn" name="registerForm[app:globalLang]" value="en-US"
+                     defaultChecked={ true } onClick={() => this.changeLanguage('en-US')} />
+              <label htmlFor="radioLangEn">{ this.props.t('English') }</label>
+            </div>
+            <div className="radio radio-primary radio-inline">
+              <input type="radio" id="radioLangJa" name="registerForm[app:globalLang]" value="ja"
+                     defaultChecked={ false } onClick={() => this.changeLanguage('ja')} />
+              <label htmlFor="radioLangJa">{ this.props.t('Japanese') }</label>
+            </div>
+          </div>
+
+          <div className="input-group m-t-30 m-b-20 d-flex justify-content-center">
+            <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
+              <span className="btn-label"><i className="icon-user-follow" /></span>
+              { this.props.t('Create') }
+            </button>
+          </div>
+
+          <div className="input-group m-t-30 d-flex justify-content-center">
+            <a href="https://growi.org" className="link-growi-org">
+              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+            </a>
+          </div>
+        </form>
+      </div>
     );
   }
 }

+ 64 - 0
src/client/js/components/Page.jsx

@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import RevisionRenderer from './Page/RevisionRenderer';
+import HandsontableModal from './PageEditor/HandsontableModal';
+import MarkdownTable from '../models/MarkdownTable';
+import mtu from './PageEditor/MarkdownTableUtil';
+
+export default class Page extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      markdown: this.props.markdown,
+      currentTargetTableArea: null
+    };
+
+    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
+  }
+
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+  }
+
+  /**
+   * launch HandsontableModal with data specified by arguments
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  launchHandsontableModal(beginLineNumber, endLineNumber) {
+    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
+    this.setState({currentTargetTableArea: {beginLineNumber, endLineNumber}});
+    this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
+  }
+
+  saveHandlerForHandsontableModal(markdownTable) {
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(markdownTable, this.state.markdown, this.state.currentTargetTableArea.beginLineNumber, this.state.currentTargetTableArea.endLineNumber);
+    this.props.onSaveWithShortcut(newMarkdown);
+    this.setState({currentTargetTableArea: null});
+  }
+
+  render() {
+    const isMobile = this.props.crowi.isMobile;
+
+    return <div className={isMobile ? 'page-mobile' : ''}>
+      <RevisionRenderer
+          crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
+          markdown={this.state.markdown}
+          pagePath={this.props.pagePath}
+      />
+      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
+    </div>;
+  }
+}
+
+Page.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  showHeadEditButton: PropTypes.bool,
+};

+ 114 - 0
src/client/js/components/Page/RevisionLoader.jsx

@@ -0,0 +1,114 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Waypoint  from 'react-waypoint';
+
+import RevisionRenderer from './RevisionRenderer';
+
+/**
+ * Load data from server and render RevisionBody component
+ */
+export default class RevisionLoader extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.logger = require('@alias/logger')('growi:Page:RevisionLoader');
+
+    this.state = {
+      markdown: '',
+      isLoading: false,
+      isLoaded: false,
+      error: null,
+    };
+
+    this.loadData = this.loadData.bind(this);
+    this.onWaypointChange = this.onWaypointChange.bind(this);
+  }
+
+  componentWillMount() {
+    if (!this.props.lazy) {
+      this.loadData();
+    }
+  }
+
+  loadData() {
+    if (!this.state.isLoaded && !this.state.isLoading) {
+      this.setState({ isLoading: true });
+    }
+
+    const requestData = {
+      page_id: this.props.pageId,
+      revision_id: this.props.revisionId,
+    };
+
+    // load data with REST API
+    this.props.crowi.apiGet('/revisions.get', requestData)
+      .then(res => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+
+        this.setState({
+          markdown: res.revision.body,
+          error: null,
+        });
+      })
+      .catch(err => {
+        this.setState({ error: err });
+      })
+      .finally(() => {
+        this.setState({ isLoaded: true, isLoading: false });
+      });
+  }
+
+  onWaypointChange(event) {
+    if (event.currentPosition === Waypoint.above || event.currentPosition === Waypoint.inside) {
+      this.loadData();
+    }
+  }
+
+  render() {
+    // ----- before load -----
+    if (this.props.lazy && !this.state.isLoaded) {
+      return <Waypoint onPositionChange={this.onWaypointChange} bottomOffset='-100px'>
+        <div className="wiki"></div>
+      </Waypoint>;
+    }
+
+    // ----- loading -----
+    if (this.state.isLoading) {
+      return (
+        <div className="wiki">
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+          </div>
+        </div>
+      );
+    }
+
+    // ----- after load -----
+    let markdown = this.state.markdown;
+    if (this.state.error != null) {
+      markdown = `<span class="text-muted"><em>${this.state.error}</em></span>`;
+    }
+
+    return (
+      <RevisionRenderer
+          crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
+          pagePath={this.props.pagePath}
+          markdown={markdown}
+          highlightKeywords={this.props.highlightKeywords}
+      />
+    );
+  }
+}
+
+RevisionLoader.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  pageId: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  lazy: PropTypes.bool,
+  highlightKeywords: PropTypes.string,
+};

+ 10 - 37
src/client/js/components/Page.js → src/client/js/components/Page/RevisionRenderer.jsx

@@ -1,29 +1,25 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import RevisionBody from './Page/RevisionBody';
-import HandsontableModal from './PageEditor/HandsontableModal';
-import MarkdownTable from '../models/MarkdownTable';
-import mtu from './PageEditor/MarkdownTableUtil';
+import RevisionBody from './RevisionBody';
 
-export default class Page extends React.Component {
+export default class RevisionRenderer extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
       html: '',
-      markdown: '',
-      currentTargetTableArea: null
     };
 
     this.renderHtml = this.renderHtml.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
-    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
+
+    this.setMarkdown(this.props.markdown);
   }
 
-  componentWillMount() {
-    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
+  componentWillReceiveProps(nextProps) {
+    this.renderHtml(nextProps.markdown, this.props.highlightKeywords);
   }
 
   setMarkdown(markdown) {
@@ -52,27 +48,9 @@ export default class Page extends React.Component {
     return returnBody;
   }
 
-  /**
-   * launch HandsontableModal with data specified by arguments
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  launchHandsontableModal(beginLineNumber, endLineNumber) {
-    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
-    this.setState({currentTargetTableArea: {beginLineNumber, endLineNumber}});
-    this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
-  }
-
-  saveHandlerForHandsontableModal(markdownTable) {
-    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(markdownTable, this.state.markdown, this.state.currentTargetTableArea.beginLineNumber, this.state.currentTargetTableArea.endLineNumber);
-    this.props.onSaveWithShortcut(newMarkdown);
-    this.setState({currentTargetTableArea: null});
-  }
-
   renderHtml(markdown, highlightKeywords) {
     let context = {
       markdown,
-      dom: this.revisionBodyElement,
       currentPagePath: this.props.pagePath,
     };
 
@@ -89,7 +67,7 @@ export default class Page extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
 
         // highlight
         if (highlightKeywords != null) {
@@ -108,27 +86,22 @@ export default class Page extends React.Component {
 
   render() {
     const config = this.props.crowi.getConfig();
-    const isMobile = this.props.crowi.isMobile;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
-    return <div className={isMobile ? 'page-mobile' : ''}>
+    return (
       <RevisionBody
           html={this.state.html}
-          inputRef={el => this.revisionBodyElement = el}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
       />
-      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
-    </div>;
+    );
   }
 }
 
-Page.propTypes = {
+RevisionRenderer.propTypes = {
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
-  onSaveWithShortcut: PropTypes.func.isRequired,
   markdown: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
-  showHeadEditButton: PropTypes.bool,
   highlightKeywords: PropTypes.string,
 };

+ 2 - 4
src/client/js/components/PageComment/Comment.js

@@ -74,7 +74,6 @@ export default class Comment extends React.Component {
     const isMathJaxEnabled = !!config.env.MATHJAX;
     return (
       <RevisionBody html={this.state.html}
-          inputRef={el => this.revisionBodyElement = el}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
           additionalClassName="comment" />
@@ -82,9 +81,8 @@ export default class Comment extends React.Component {
   }
 
   renderHtml(markdown) {
-    var context = {
+    const context = {
       markdown,
-      dom: this.revisionBodyElement,
     };
 
     const crowiRenderer = this.props.crowiRenderer;
@@ -101,7 +99,7 @@ export default class Comment extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderCommentHtml', context))

+ 3 - 4
src/client/js/components/PageComment/CommentForm.jsx

@@ -129,15 +129,14 @@ export default class CommentForm extends React.Component {
   getCommentHtml() {
     return (
       <CommentPreview
-        html={this.state.html}
-        inputRef={el => this.previewElement = el}/>
+        inputRef={el => this.previewElement = el}
+        html={this.state.html} />
     );
   }
 
   renderHtml(markdown) {
     const context = {
       markdown,
-      dom: this.previewElement,
     };
 
     const growiRenderer = this.growiRenderer;
@@ -154,7 +153,7 @@ export default class CommentForm extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderCommentPreviewHtml', context))

+ 66 - 37
src/client/js/components/PageEditor.js

@@ -9,6 +9,8 @@ import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import * as toastr from 'toastr';
+
 
 export default class PageEditor extends React.Component {
 
@@ -42,6 +44,7 @@ export default class PageEditor extends React.Component {
     this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
 
     // for scrolling
     this.lastScrolledDateWithCursor = null;
@@ -114,41 +117,54 @@ export default class PageEditor extends React.Component {
    * the upload event handler
    * @param {any} files
    */
-  onUpload(file) {
-    const endpoint = '/attachments.add';
-
-    // create a FromData instance
-    const formData = new FormData();
-    formData.append('_csrf', this.props.crowi.csrfToken);
-    formData.append('file', file);
-    formData.append('path', this.props.pagePath);
-    formData.append('page_id', this.state.pageId || 0);
-
-    // post
-    this.props.crowi.apiPost(endpoint, formData)
-      .then((res) => {
-        const url = res.url;
-        const attachment = res.attachment;
-        const fileName = attachment.originalName;
-
-        let insertText = `[${fileName}](${url})`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = '!' + insertText;
-        }
-        this.refs.editor.insertText(insertText);
-
-        // when if created newly
-        if (res.pageCreated) {
-          // do nothing
-        }
-      })
-      .catch(this.apiErrorHandler)
-      // finally
-      .then(() => {
-        this.refs.editor.terminateUploadingState();
-      });
+  async onUpload(file) {
+    try {
+      let res  = await this.props.crowi.apiGet('/attachments.limit', {_csrf: this.props.crowi.csrfToken, fileSize: file.size});
+      if (!res.isUploadable) {
+        toastr.error(undefined, 'MongoDB for uploading files reaches limit', {
+          closeButton: true,
+          progressBar: true,
+          newestOnTop: false,
+          showDuration: '100',
+          hideDuration: '100',
+          timeOut: '5000',
+        });
+        throw new Error('MongoDB for uploading files reaches limit');
+      }
+      const endpoint = '/attachments.add';
+
+      // create a FromData instance
+      const formData = new FormData();
+      formData.append('_csrf', this.props.crowi.csrfToken);
+      formData.append('file', file);
+      formData.append('path', this.props.pagePath);
+      formData.append('page_id', this.state.pageId || 0);
+
+      // post
+      res = await this.props.crowi.apiPost(endpoint, formData)
+      const url = res.url;
+      const attachment = res.attachment;
+      const fileName = attachment.originalName;
+
+      let insertText = `[${fileName}](${url})`;
+      // when image
+      if (attachment.fileFormat.startsWith('image/')) {
+        // modify to "![fileName](url)" syntax
+        insertText = '!' + insertText;
+      }
+      this.refs.editor.insertText(insertText);
+
+      // when if created newly
+      if (res.pageCreated) {
+        // do nothing
+      }
+    }
+    catch (e) {
+      this.apiErrorHandler(e);
+    }
+    finally {
+      this.refs.editor.terminateUploadingState();
+    }
   }
 
   /**
@@ -263,7 +279,6 @@ export default class PageEditor extends React.Component {
     // render html
     const context = {
       markdown: this.state.markdown,
-      dom: this.previewElement,
       currentPagePath: decodeURIComponent(location.pathname)
     };
 
@@ -281,7 +296,7 @@ export default class PageEditor extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderPreviewHtml', context))
@@ -293,7 +308,20 @@ export default class PageEditor extends React.Component {
 
   }
 
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
   render() {
+    const config = this.props.crowi.getConfig();
+    const noCdn = !!config.env.NO_CDN;
     const emojiStrategy = this.props.crowi.getEmojiStrategy();
 
     return (
@@ -301,6 +329,7 @@ export default class PageEditor extends React.Component {
         <div className="col-md-6 col-sm-12 page-editor-editor-container">
           <Editor ref="editor" value={this.state.markdown}
             editorOptions={this.state.editorOptions}
+            noCdn={noCdn}
             isMobile={this.props.crowi.isMobile}
             isUploadable={this.state.isUploadable}
             isUploadableFile={this.state.isUploadableFile}

+ 20 - 4
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -97,6 +97,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   init() {
     this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0';
+    this.cmNoCdnScriptRoot = '/js/cdn';
+    this.cmNoCdnStyleRoot = '/styles/cdn';
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([
@@ -308,7 +310,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
    */
   loadTheme(theme) {
     if (!this.loadedThemeSet.has(theme)) {
-      this.loadCss(urljoin(this.cmCdnRoot, `theme/${theme}.min.css`));
+      const url = this.props.noCdn
+        ? urljoin(this.cmNoCdnStyleRoot, `codemirror-theme-${theme}.css`)
+        : urljoin(this.cmCdnRoot, `theme/${theme}.min.css`);
+
+      this.loadCss(url);
 
       // update Set
       this.loadedThemeSet.add(theme);
@@ -326,12 +332,22 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     // add dependencies
     if (this.loadedKeymapSet.size == 0) {
-      scriptList.push(loadScript(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js')));
-      cssList.push(loadCss(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.css')));
+      const dialogScriptUrl = this.props.noCdn
+        ? urljoin(this.cmNoCdnScriptRoot, 'codemirror-dialog.js')
+        : urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js');
+      const dialogStyleUrl = this.props.noCdn
+        ? urljoin(this.cmNoCdnStyleRoot, 'codemirror-dialog.css')
+        : urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.css');
+
+      scriptList.push(loadScript(dialogScriptUrl));
+      cssList.push(loadCss(dialogStyleUrl));
     }
     // load keymap
     if (!this.loadedKeymapSet.has(keymapMode)) {
-      scriptList.push(loadScript(urljoin(this.cmCdnRoot, `keymap/${keymapMode}.min.js`)));
+      const keymapScriptUrl = this.props.noCdn
+        ? urljoin(this.cmNoCdnScriptRoot, `codemirror-keymap-${keymapMode}.js`)
+        : urljoin(this.cmCdnRoot, `keymap/${keymapMode}.min.js`);
+      scriptList.push(loadScript(keymapScriptUrl));
       // update Set
       this.loadedKeymapSet.add(keymapMode);
     }

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

@@ -286,6 +286,7 @@ export default class Editor extends AbstractEditor {
 }
 
 Editor.propTypes = Object.assign({
+  noCdn: PropTypes.bool,
   isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,

+ 1 - 1
src/client/js/components/PageEditor/MarkdownTableDataImportForm.jsx

@@ -68,7 +68,7 @@ export default class MarkdownTableDataImportForm extends React.Component {
         <Collapse in={this.state.parserErrorMessage != null}>
           <FormGroup>
             <ControlLabel>Parse Error</ControlLabel>
-            <FormControl componentClass="textarea" style={{ height: 100 }}  value={this.state.parserErrorMessage} readOnly/>
+            <FormControl componentClass="textarea" style={{ height: 100 }} value={this.state.parserErrorMessage || ''} readOnly/>
           </FormGroup>
         </Collapse>
         <div className="d-flex justify-content-end">

+ 17 - 12
src/client/js/components/PageList/PageListMeta.js

@@ -17,34 +17,39 @@ export default class PageListMeta extends React.Component {
     const page = this.props.page;
 
     // portal check
-    let PortalLabel;
+    let portalLabel;
     if (this.isPortalPath(page.path)) {
-      PortalLabel = <span className="label label-info">PORTAL</span>;
+      portalLabel = <span className="label label-info">PORTAL</span>;
     }
 
     // template check
-    let TemplateLabel;
+    let templateLabel;
     if (templateChecker(page.path)) {
-      TemplateLabel = <span className="label label-info">TMPL</span>;
+      templateLabel = <span className="label label-info">TMPL</span>;
     }
 
-    let CommentCount;
+    let commentCount;
     if (page.commentCount > 0) {
-      CommentCount = <span><i className="icon-bubble" />{page.commentCount}</span>;
+      commentCount = <span><i className="icon-bubble" />{page.commentCount}</span>;
     }
 
-    let LikerCount;
+    let likerCount;
     if (page.liker.length > 0) {
-      LikerCount = <span><i className="icon-like" />{page.liker.length}</span>;
+      likerCount = <span><i className="icon-like" />{page.liker.length}</span>;
     }
 
+    let locked;
+    if (page.grant != 1) {
+      locked = <span><i className="icon-lock" /></span>;
+    }
 
     return (
       <span className="page-list-meta">
-        {PortalLabel}
-        {TemplateLabel}
-        {CommentCount}
-        {LikerCount}
+        {portalLabel}
+        {templateLabel}
+        {commentCount}
+        {likerCount}
+        {locked}
       </span>
     );
   }

+ 17 - 6
src/client/js/components/PageList/PagePath.js

@@ -29,24 +29,35 @@ export default class PagePath extends React.Component {
 
   render() {
     const page = this.props.page;
-    const pagePath = page.path.replace(this.props.excludePathString.replace(/^\//, ''), '');
+    const isShortPathOnly = this.props.isShortPathOnly;
+    const pagePath = decodeURIComponent(page.path.replace(this.props.excludePathString.replace(/^\//, ''), ''));
     const shortPath = this.getShortPath(pagePath);
+
     const shortPathEscaped = escapeStringRegexp(shortPath);
     const pathPrefix = pagePath.replace(new RegExp(shortPathEscaped + '(/)?$'), '');
 
-    return (
-      <span className="page-path">
-        {pathPrefix}<strong>{shortPath}</strong>
-      </span>
-    );
+    let classNames = ['page-path'];
+    classNames = classNames.concat(this.props.additionalClassNames);
+
+    if (isShortPathOnly) {
+      return <span className={classNames.join(' ')}>{shortPath}</span>;
+    }
+    else {
+      return <span className={classNames.join(' ')}>{pathPrefix}<strong>{shortPath}</strong></span>;
+    }
+
   }
 }
 
 PagePath.propTypes = {
   page: PropTypes.object.isRequired,
+  isShortPathOnly: PropTypes.bool,
+  excludePathString: PropTypes.string,
+  additionalClassNames: PropTypes.array,
 };
 
 PagePath.defaultProps = {
   page: {},
+  additionalClassNames: [],
   excludePathString: '',
 };

+ 2 - 2
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -30,9 +30,9 @@ export default class RecentCreated extends React.Component {
     // pagesList get and pagination calculate
     this.props.crowi.apiGet('/pages.recentCreated', { page_id: pageId, user: userId, limit, offset })
       .then(res => {
-        const totalCount = res.pages[0].totalCount;
+        const totalCount = res.totalCount;
+        const pages = res.pages;
         const activePage = selectPageNumber;
-        const pages = res.pages[1];
         // pagiNation calculate function call
         const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
         this.setState({

+ 6 - 5
src/client/js/components/SearchPage.js

@@ -91,11 +91,12 @@ export default class SearchPage extends React.Component {
   render() {
     return (
       <div>
-        <SearchForm
-          onSearchFormChanged={this.search}
-          keyword={this.state.searchingKeyword}
-          />
-
+        <div className="search-page-input">
+          <SearchForm
+            onSearchFormChanged={this.search}
+            keyword={this.state.searchingKeyword}
+            />
+        </div>
         <SearchResult
           crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
           pages={this.state.searchedPages}

+ 2 - 2
src/client/js/components/SearchPage/SearchResult.js

@@ -251,7 +251,7 @@ export default class SearchResult extends React.Component {
 
     // TODO あとでなんとかする
     setTimeout(() => {
-      $('#search-result-list > nav').affix({ offset: { top: 120 }});
+      $('#search-result-list > nav').affix({ offset: { top: 50 }});
     }, 1200);
 
     /*
@@ -262,7 +262,7 @@ export default class SearchResult extends React.Component {
       <div className="content-main">
         <div className="search-result row" id="search-result">
           <div className="col-md-4 hidden-xs hidden-sm page-list search-result-list" id="search-result-list">
-            <nav data-spy="affix" data-offset-top="120">
+            <nav data-spy="affix" data-offset-top="50">
               <div className="pull-right">
                 {deletionModeButtons}
                 {allSelectCheck}

+ 4 - 4
src/client/js/components/SearchPage/SearchResultList.js

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 
 import GrowiRenderer from '../../util/GrowiRenderer';
 
-import Page from '../Page.js';
+import RevisionLoader from '../Page/RevisionLoader';
 
 export default class SearchResultList extends React.Component {
 
@@ -15,15 +15,15 @@ export default class SearchResultList extends React.Component {
 
   render() {
     const resultList = this.props.pages.map((page) => {
-      const pageBody = page.revision.body;
       return (
         <div id={page._id} key={page._id} className="search-result-page">
           <h2><a href={page.path}>{page.path}</a></h2>
-          <Page
+          <RevisionLoader
             crowi={this.props.crowi}
             crowiRenderer={this.growiRenderer}
-            markdown={pageBody}
+            pageId={page._id}
             pagePath={page.path}
+            revisionId={page.revision}
             highlightKeywords={this.props.searchingKeyword}
           />
         </div>

+ 2 - 0
src/client/js/hackmd-agent.js

@@ -122,6 +122,8 @@ function connectToParentWithPenpal() {
   });
   connection.promise.then(parent => {
     window.growi = parent;
+  }).catch(err => {
+    console.log(err);
   });
 }
 

+ 21 - 12
src/client/js/legacy/crowi.js

@@ -17,7 +17,7 @@ require('jquery.cookie');
 require('bootstrap-select');
 
 import GrowiRenderer from '../util/GrowiRenderer';
-import Page from '../components/Page';
+import RevisionLoader from '../components/Page/RevisionLoader';
 
 require('./thirdparty-js/agile-admin');
 
@@ -471,8 +471,7 @@ $(function() {
         $('#delete-errors').addClass('alert-danger');
       }
       else {
-        const page = res.page;
-        top.location.href = page.path + '?unlinked=true';
+        top.location.href = res.path + '?unlinked=true';
       }
     });
 
@@ -480,7 +479,10 @@ $(function() {
   });
 
   $('#create-portal-button').on('click', function(e) {
+    $('a[data-toggle="tab"][href="#edit"]').tab('show');
+
     $('body').addClass('on-edit');
+    $('body').addClass('builtin-editor');
 
     const path = $('.content-main').data('path');
     if (path != '/' && $('.content-main').data('page-id') == '') {
@@ -493,8 +495,10 @@ $(function() {
     }
   });
   $('#portal-form-close').on('click', function(e) {
+    $('#edit').removeClass('active');
     $('body').removeClass('on-edit');
-    return false;
+    $('body').removeClass('builtin-editor');
+    location.hash = '#';
   });
 
   /*
@@ -521,7 +525,7 @@ $(function() {
 
   // for list page
   let growiRendererForTimeline = null;
-  $('a[data-toggle="tab"][href="#view-timeline"]').on('show.bs.tab', function() {
+  $('a[data-toggle="tab"][href="#view-timeline"]').on('shown.bs.tab', function() {
     const isShown = $('#view-timeline').data('shown');
 
     if (growiRendererForTimeline == null) {
@@ -531,16 +535,21 @@ $(function() {
     if (isShown == 0) {
       $('#view-timeline .timeline-body').each(function() {
         const id = $(this).attr('id');
-        const contentId = '#' + id + ' > script';
         const revisionBody = '#' + id + ' .revision-body';
         const revisionBodyElem = document.querySelector(revisionBody);
         /* eslint-disable no-unused-vars */
         const revisionPath = '#' + id + ' .revision-path';
         /* eslint-enable */
-        const pagePath = document.getElementById(id).getAttribute('data-page-path');
-        const markdown = entities.decodeHTML($(contentId).html());
-
-        ReactDOM.render(<Page crowi={crowi} crowiRenderer={growiRendererForTimeline} markdown={markdown} pagePath={pagePath} />, revisionBodyElem);
+        const timelineElm = document.getElementById(id);
+        const pageId = timelineElm.getAttribute('data-page-id');
+        const pagePath = timelineElm.getAttribute('data-page-path');
+        const revisionId = timelineElm.getAttribute('data-revision');
+
+        ReactDOM.render(
+          <RevisionLoader lazy={true}
+            crowi={crowi} crowiRenderer={growiRendererForTimeline}
+            pageId={pageId} pagePath={pagePath} revisionId={revisionId} />,
+          revisionBodyElem);
       });
 
       $('#view-timeline').data('shown', 1);
@@ -836,6 +845,6 @@ window.addEventListener('keydown', (event) => {
 });
 
 // adjust min-height of page for print temporarily
-window.onbeforeprint = function () {
-  $("#page-wrapper").css("min-height", "0px");
+window.onbeforeprint = function() {
+  $('#page-wrapper').css('min-height', '0px');
 };

+ 10 - 2
src/client/js/util/Crowi.js

@@ -3,6 +3,7 @@
  */
 
 import axios from 'axios';
+import io from 'socket.io-client';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
@@ -50,6 +51,8 @@ export default class Crowi {
     this.editorOptions = {};
 
     this.recoverData();
+
+    this.socket = io();
   }
 
   /**
@@ -75,6 +78,10 @@ export default class Crowi {
     this.pageEditor = pageEditor;
   }
 
+  getWebSocket() {
+    return this.socket;
+  }
+
   getSocketClientId() {
     return this.socketClientId;
   }
@@ -94,9 +101,10 @@ export default class Crowi {
     ];
 
     keys.forEach(key => {
-      if (this.localStorage[key]) {
+      const keyContent = this.localStorage[key];
+      if (keyContent) {
         try {
-          this[key] = JSON.parse(this.localStorage[key]);
+          this[key] = JSON.parse(keyContent);
         }
         catch (e) {
           this.localStorage.removeItem(key);

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

@@ -99,13 +99,13 @@ export default class GrowiRenderer {
           new TableConfigurer(crowi)
         ]);
         break;
-      case 'comment':
+      // case 'comment':
+      //   break;
+      default:
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
           new TableConfigurer(crowi)
         ]);
         break;
-      default:
-        break;
     }
   }
 
@@ -151,12 +151,12 @@ export default class GrowiRenderer {
     return this.md.render(markdown);
   }
 
-  postProcess(html, dom) {
+  postProcess(html) {
     for (let i = 0; i < this.postProcessors.length; i++) {
       if (!this.postProcessors[i].process) {
         continue;
       }
-      html = this.postProcessors[i].process(html, dom);
+      html = this.postProcessors[i].process(html);
     }
 
     return html;

+ 7 - 2
src/client/js/util/markdown-it/task-lists.js

@@ -5,8 +5,13 @@ export default class TaskListsConfigurer {
   }
 
   configure(md) {
-    md.use(require('markdown-it-task-lists'), {
-      enabled: true,
+    md.use(require('markdown-it-task-checkbox'), {
+      disabled: true,
+      divWrap: true,
+      divClass: 'checkbox checkbox-primary',
+      idPrefix: 'cbx_',
+      ulClass: 'task-list',
+      liClass: 'task-list-item',
     });
   }
 

+ 127 - 0
src/client/styles/agile-admin/inverse/colors/christmas.scss

@@ -0,0 +1,127 @@
+@import '../variables';
+
+$themecolor: #b3000c;
+$themelight: white;
+$subthemecolor:	#017e20;
+$topbar: $themecolor;
+$sidebar: $themelight;
+$bodycolor: $themelight;
+$headingtext: $subthemecolor;
+$bodytext: black;
+$linktext: lighten(#0d5901, 5%);
+$linktext-hover: lighten($linktext, 12%);
+$sidebar-text: #ffffff;
+$primary: $themecolor;
+$logo-mark-fill: lighten(desaturate($topbar, 50%), 50%);
+$wikilinktext: lighten($themecolor, 5%);
+$wikilinktext-hover: lighten($wikilinktext, 15%);
+$inline-code-color: darken($subthemecolor, 5%);
+$inline-code-bg: lighten($subthemecolor, 70%);
+$border-original: $border;
+$border: $subthemecolor;
+$navbar-border: $themecolor;
+$active-nav-tabs-bgcolor: white;
+
+@import 'apply-colors';
+@import 'apply-colors-light';
+
+// change color of highlighted header in wiki (default: orange)
+.wiki {
+  .code-line.revision-head.highlighted {
+    background-color: lighten($themecolor, 20%);
+    color: $themelight;
+
+    .icon-note,
+    .icon-link {
+      color: $themelight;
+    }
+  }
+}
+
+.sidebar{
+  background: $themecolor;
+}
+
+.rbt-menu {
+  background: $themelight;
+}
+
+.main-container > #wrapper > #page-wrapper,
+.page-editor-preview-container {
+  background-image: url("/images/themes/christmas/christmas.jpg");
+  background-size: cover;
+  background-attachment: fixed;
+}
+
+.bg-title {
+  background-color: #ffffff;
+}
+
+#wrapper > .navbar > .navbar-header {
+  background-image: url("/images/themes/christmas/christmas-navbar.jpg");
+  border-bottom: 4px solid $subthemecolor;
+}
+
+/*
+ * Tabs
+ */
+body:not(.on-edit) .nav.nav-tabs {
+  > li.active > a {
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 0%,
+      rgba($active-nav-tabs-bgcolor, 0) 90%,
+      $active-nav-tabs-bgcolor 100%);         // overwrite only the bottom pixel
+  }
+}
+
+// login page
+.nologin {
+  .input-group {
+    .input-group-addon {
+      background-color: rgba(lighten(black, 10%), 0.6);
+    }
+    .form-control {
+      background-color: rgba(lighten(black, 10%), 0.6);
+    }
+  }
+
+  &.login-page {
+    .login-header, .login-dialog {
+      background-color: rgba(#ccc, 0.5);
+    }
+    .link-switch {
+      color: #bd3425;
+    }
+  }
+}
+
+/*
+ * Modal
+ */
+.modal-dialog .modal-header.bg-primary {
+  background-image: url("/images/themes/christmas/christmas-navbar.jpg");
+  border-bottom: 2px solid $subthemecolor;
+}
+
+/*
+ * Panel
+ */
+.panel {
+  &.panel-white, &.panel-default {
+    border-color: $border-original;
+    .panel-heading {
+        color: $dark;
+        background-color: $border-original;
+        border-bottom:1px solid $border-original;
+    }
+  }
+}
+
+.panel.panel-primary {
+  border-color: #bd3425;
+  .panel-heading {
+    color: white;
+    background-image: url("/images/themes/christmas/christmas-navbar.jpg");
+    background-color: $themecolor;
+  }
+}

+ 2 - 2
src/client/styles/agile-admin/inverse/eliteadmin.scss

@@ -12,7 +12,7 @@ body {
   margin: 0;
   overflow-x: hidden;
   color: $bodytext;
-  font-weight:300;
+  font-weight:400;
 }
 html {
     position: relative;
@@ -23,7 +23,7 @@ h1, h2, h3, h4, h5, h6 {
   color: $headingtext;
   font-family: $basefont2;
   margin: 10px 0;
-  font-weight:300;
+  font-weight:400;
 }
 h1 {
   line-height: 48px;

+ 3 - 5
src/client/styles/agile-admin/inverse/widgets.scss

@@ -808,7 +808,6 @@ border-radius:$radius;
 */
 
 /*Progressbars*/
-/*
 .progress {
 -webkit-box-shadow: none !important;
 background-color: $border;
@@ -894,10 +893,9 @@ animation-duration: 5s;
 animation-name: myanimation;
 transition: 5s all;
 }
-*/
+
 
 /* Progressbar Animated */
-/*
 @-webkit-keyframes myanimation {
 from {
   width:0;
@@ -908,7 +906,6 @@ from {
   width:0;
 }
 }
-*/
 
 /* Progressbar Vertical */
 /*
@@ -1296,11 +1293,12 @@ background:$white;
   box-shadow: none !important;
 }
 }
-
+/*
 .input-group-addon {
 border-radius: $radius;
 border: 1px solid $border;
 }
+*/
 /*
 .input-daterange input:first-child, .input-daterange input:last-child{border-radius:$radius;}
 */

+ 21 - 1
src/client/styles/scss/_search.scss

@@ -114,7 +114,7 @@
     nav {
       padding-right: 0;
       &.affix {
-        top: 8px;
+        top: 58px;
         width: 33%;
         padding-right: 5px;
         padding-bottom: 50px;
@@ -147,6 +147,10 @@
     padding-bottom: 32px;
 
     .search-result-page {
+      // adjust for anchor links by the height of fixed .search-page-input
+      margin-top: -48px;
+      padding-top: 48px;
+
       > h2 {
         font-size: 20px;
         line-height: 1em;
@@ -164,3 +168,19 @@
     }
   }
 }
+
+.search-page-input{
+  padding: 10px 0;
+  position: sticky;
+  top: 0;
+  z-index: 99;
+  .form{
+    margin: 0;
+    .input-group .form-control{
+      height: 100%;
+    }
+    .input-group-btn .btn{
+      height: 34px;
+    }
+  }
+}

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

@@ -79,17 +79,32 @@ div.body {
     }
   }
 
-  // borrowed from https://www.npmjs.com/package/github-markdown-css
-  .contains-task-list {
+  .task-list {
     .task-list-item {
       list-style-type: none;
-    }
-    .task-list-item+.task-list-item {
-      margin-top: 3px;
-    }
-    .task-list-item input {
       margin: 0 0.2em 0.25em -1.6em;
-      vertical-align: middle;
+    }
+    .task-list-item > .task-list {
+      margin-left: 30px;
+    }
+    // use awesome-bootstrap-checkbox
+    .task-list-item .checkbox input[type="checkbox"] {
+      // layout
+      +label {
+        padding-left: 0.3em;
+        &:before {
+          margin-top: 0.4em;
+        }
+      }
+      // styles
+      cursor: default;
+      +label {
+        cursor: default;
+        opacity: 1;
+        &:before, &:after {
+          cursor: default;
+        }
+      }
     }
   }
 
@@ -190,13 +205,6 @@ div.body {
       }
     }
 
-    // borrowed from https://www.npmjs.com/package/github-markdown-css
-    .contains-task-list {
-      .task-list-item input {
-        margin: 0 0.2em * $ratio 0.25em * $ratio -1.6em * $ratio;
-      }
-    }
-
     .revision-head {
       .revision-head-link,
       .revision-head-edit-button {

+ 8 - 0
src/client/styles/scss/theme/christmas.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/christmas';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 156 - 0
src/lib/service/cdn-resources-downloader.js

@@ -0,0 +1,156 @@
+const axios = require('axios');
+const path = require('path');
+const { URL } = require('url');
+const urljoin = require('url-join');
+const fs = require('graceful-fs');
+const mkdirp = require('mkdirp');
+const replaceStream = require('replacestream');
+const streamToPromise = require('stream-to-promise');
+
+
+/**
+ * Value Object
+ */
+class CdnResource {
+  constructor(name, url, outDir) {
+    this.name = name;
+    this.url = url;
+    this.outDir = outDir;
+  }
+}
+
+class CdnResourcesDownloader {
+  constructor() {
+    this.logger = require('@alias/logger')('growi:service:CdnResourcesDownloader');
+  }
+
+  /**
+   * Download script files from CDN
+   * @param {CdnResource[]} cdnResources JavaScript resource data
+   * @param {any} options
+   */
+  async downloadScripts(cdnResources, options) {
+    this.logger.debug('Downloading scripts', cdnResources);
+
+    const opts = Object.assign({}, options);
+    const ext = opts.ext || 'js';
+
+    const promises = cdnResources.map(cdnResource => {
+      this.logger.info(`Processing CdnResource '${cdnResource.name}'`);
+
+      return this.downloadAndWriteToFS(
+        cdnResource.url,
+        cdnResource.outDir,
+        `${cdnResource.name}.${ext}`);
+    });
+
+    return Promise.all(promises);
+  }
+
+  /**
+   * Download style sheet file from CDN
+   *  Assets in CSS is also downloaded
+   * @param {CdnResource[]} cdnResources CSS resource data
+   * @param {any} options
+   */
+  async downloadStyles(cdnResources, options) {
+    this.logger.debug('Downloading styles', cdnResources);
+
+    const opts = Object.assign({}, options);
+    const ext = opts.ext || 'css';
+
+    // styles
+    const assetsResourcesStore = [];
+    const promisesForStyle = cdnResources.map(cdnResource => {
+      this.logger.info(`Processing CdnResource '${cdnResource.name}'`);
+
+      let urlReplacer = null;
+
+      // generate replaceStream instance
+      if (opts.replaceUrl != null) {
+        urlReplacer = this.generateReplaceUrlInCssStream(cdnResource, assetsResourcesStore, opts.replaceUrl.webroot);
+      }
+
+      return this.downloadAndWriteToFS(
+        cdnResource.url,
+        cdnResource.outDir,
+        `${cdnResource.name}.${ext}`,
+        urlReplacer);
+    });
+
+    // wait until all styles are downloaded
+    await Promise.all(promisesForStyle);
+
+    this.logger.debug('Downloading assets', assetsResourcesStore);
+
+    // assets in css
+    const promisesForAssets = assetsResourcesStore.map(cdnResource => {
+      this.logger.info(`Processing assts in css '${cdnResource.name}'`);
+
+      return this.downloadAndWriteToFS(
+        cdnResource.url,
+        cdnResource.outDir,
+        cdnResource.name);
+    });
+
+    return Promise.all(promisesForAssets);
+  }
+
+  /**
+   * Generate replaceStream instance to replace 'url(..)'
+   *
+   * e.g.
+   *  Before  : url(../images/logo.svg)
+   *  After   : url(/path/to/webroot/${cdnResources.name}/logo.svg)
+   *
+   * @param {CdnResource[]} cdnResource CSS resource data
+   * @param {CdnResource[]} assetsResourcesStore An array to store CdnResource that is detected by 'url()' in CSS
+   * @param {string} webroot
+   */
+  generateReplaceUrlInCssStream(cdnResource, assetsResourcesStore, webroot) {
+    return replaceStream(
+      /url\((?!"data:)["']?(.+?)["']?\)/g,    // https://regex101.com/r/Sds38A/2
+      (match, url) => {
+        // generate URL Object
+        const parsedUrl = url.startsWith('http')
+          ? new URL(url)                    // when url is fqcn
+          : new URL(url, cdnResource.url);  // when url is relative
+        const basename = path.basename(parsedUrl.pathname);
+
+        this.logger.debug(`${cdnResource.name} has ${parsedUrl.toString()}`);
+
+        // add assets metadata to download later
+        assetsResourcesStore.push(
+          new CdnResource(
+            basename,
+            parsedUrl.toString(),
+            path.join(cdnResource.outDir, cdnResource.name)
+          )
+        );
+
+        const replaceUrl = urljoin(webroot, cdnResource.name, basename);
+        return `url(${replaceUrl})`;
+      });
+  }
+
+  async downloadAndWriteToFS(url, outDir, fileName, replacestream) {
+    // get
+    const response = await axios.get(url, { responseType: 'stream' });
+    // mkdir -p
+    mkdirp.sync(outDir);
+
+    // replace and write
+    let stream = response.data;
+    if (replacestream != null) {
+      stream = stream.pipe(replacestream);
+    }
+    const file = path.join(outDir, fileName);
+    stream = stream.pipe(fs.createWriteStream(file));
+
+    return streamToPromise(stream);
+  }
+
+}
+
+CdnResourcesDownloader.CdnResource = CdnResource;
+module.exports = CdnResourcesDownloader;

+ 163 - 0
src/lib/service/cdn-resources-service.js

@@ -0,0 +1,163 @@
+const { URL } = require('url');
+const urljoin = require('url-join');
+
+const helpers = require('@commons/util/helpers');
+
+const CdnResourcesDownloader = require('./cdn-resources-downloader');
+const CdnResource = CdnResourcesDownloader.CdnResource;
+
+const cdnLocalScriptRoot = 'public/js/cdn';
+const cdnLocalScriptWebRoot = '/js/cdn';
+const cdnLocalStyleRoot = 'public/styles/cdn';
+const cdnLocalStyleWebRoot = '/styles/cdn';
+
+
+class CdnResourcesService {
+  constructor() {
+    this.logger = require('@alias/logger')('growi:service:CdnResourcesService');
+
+    this.noCdn = !!process.env.NO_CDN;
+    this.loadManifests();
+  }
+
+  loadManifests() {
+    this.cdnManifests = require('@root/resource/cdn-manifests');
+    this.logger.debug('manifest data loaded : ', this.cdnManifests);
+  }
+
+  getScriptManifestByName(name) {
+    const manifests = this.cdnManifests.js
+      .filter(manifest => manifest.name === name);
+
+    return (manifests.length > 0) ? manifests[0] : null;
+  }
+
+  getStyleManifestByName(name) {
+    const manifests = this.cdnManifests.style
+      .filter(manifest => manifest.name === name);
+
+    return (manifests.length > 0) ? manifests[0] : null;
+  }
+
+  async downloadAndWriteAll() {
+    const downloader = new CdnResourcesDownloader();
+
+    const cdnScriptResources = this.cdnManifests.js.map(manifest => {
+      const outDir = helpers.root(cdnLocalScriptRoot);
+      return new CdnResource(manifest.name, manifest.url, outDir);
+    });
+    const cdnStyleResources = this.cdnManifests.style.map(manifest => {
+      const outDir = helpers.root(cdnLocalStyleRoot);
+      return new CdnResource(manifest.name, manifest.url, outDir);
+    });
+
+    const dlStylesOptions = {
+      replaceUrl: {
+        webroot: cdnLocalStyleWebRoot,
+      }
+    };
+
+    return Promise.all([
+      downloader.downloadScripts(cdnScriptResources),
+      downloader.downloadStyles(cdnStyleResources, dlStylesOptions),
+    ]);
+  }
+
+  /**
+   * Generate script tag string
+   *
+   * @param {Object} manifest
+   * @param {boolean} noCdn
+   */
+  generateScriptTag(manifest, noCdn) {
+    const attrs = [];
+    const args = manifest.args || {};
+
+    if (args.async) {
+      attrs.push('async');
+    }
+    if (args.defer) {
+      attrs.push('defer');
+    }
+
+    // TODO process integrity
+
+    const url = noCdn
+      ? urljoin(cdnLocalScriptWebRoot, manifest.name) + '.js'
+      : manifest.url;
+    return `<script src="${url}" ${attrs.join(' ')}></script>`;
+  }
+
+  getScriptTagByName(name) {
+    const manifest = this.getScriptManifestByName(name);
+    return this.generateScriptTag(manifest, this.noCdn);
+  }
+
+  getScriptTagsByGroup(group) {
+    return this.cdnManifests.js
+      .filter(manifest => {
+        return manifest.groups != null && manifest.groups.includes(group);
+      })
+      .map(manifest => {
+        return this.generateScriptTag(manifest, this.noCdn);
+      });
+  }
+
+  /**
+   * Generate style tag string
+   *
+   * @param {Object} manifest
+   * @param {boolean} noCdn
+   */
+  generateStyleTag(manifest, noCdn) {
+    const attrs = [];
+    const args = manifest.args || {};
+
+    if (args.async) {
+      attrs.push('async');
+    }
+    if (args.defer) {
+      attrs.push('defer');
+    }
+
+    // TODO process integrity
+
+    const url = noCdn
+      ? urljoin(cdnLocalStyleWebRoot, manifest.name) + '.css'
+      : manifest.url;
+
+    return `<link rel="stylesheet" href="${url}" ${attrs.join(' ')}>`;
+  }
+
+  getStyleTagByName(name) {
+    const manifest = this.getStyleManifestByName(name);
+    return this.generateStyleTag(manifest, this.noCdn);
+  }
+
+  getStyleTagsByGroup(group) {
+    return this.cdnManifests.style
+      .filter(manifest => {
+        return manifest.groups != null && manifest.groups.includes(group);
+      })
+      .map(manifest => {
+        return this.generateStyleTag(manifest, this.noCdn);
+      });
+  }
+
+  getHighlightJsStyleTag(styleName) {
+    let manifest = this.getStyleManifestByName('highlight-theme-github');
+
+    // replace style
+    if (!this.noCdn) {
+      const url = new URL(`${styleName}.css`, manifest.url);  // resolve `${styleName}.css` from manifest.url
+
+      // clone manifest
+      manifest = Object.assign(manifest, { url: url.toString() });
+    }
+
+    return this.generateStyleTag(manifest, this.noCdn);
+  }
+
+}
+
+module.exports = CdnResourcesService;

+ 112 - 0
src/migrations/20181019114028-abolish-page-group-relation.js

@@ -0,0 +1,112 @@
+'use strict';
+
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:abolish-page-group-relation');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+
+async function isCollectionExists(db, collectionName) {
+  const collections = await db.listCollections({ name: collectionName }).toArray();
+  return collections.length > 0;
+}
+
+/**
+ * BEFORE
+ *   - 'pagegrouprelations' collection exists (related to models/page-group-relation.js)
+ *     - schema:
+ *       {
+ *         "_id" : ObjectId("5bc9de4d745e137e0424ed89"),
+ *         "targetPage" : ObjectId("5b028f13c1f7ba2e58d2fd21"),
+ *         "relatedGroup" : ObjectId("5b07e6e6929bad5d3cce9995"),
+ *         "__v" : 0
+ *       }
+ * AFTER
+ *   - 'pagegrouprelations' collection is dropped and models/page-group-relation.js is removed
+ *   - Page model has 'grantedGroup' field newly
+ */
+module.exports = {
+
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const isPagegrouprelationsExists = await isCollectionExists(db, 'pagegrouprelations');
+    if (!isPagegrouprelationsExists) {
+      logger.info("'pagegrouprelations' collection doesn't exist");   // eslint-disable-line
+      logger.info('Migration has successfully applied');
+      return;
+    }
+
+    const Page = require('@server/models/page')();
+    const UserGroup = require('@server/models/user-group')();
+
+    // retrieve all documents from 'pagegrouprelations'
+    const relations = await db.collection('pagegrouprelations').find().toArray();
+
+    for (let relation of relations) {
+      const page = await Page.findOne({ _id: relation.targetPage });
+
+      // skip if grant mismatch
+      if (page.grant !== Page.GRANT_USER_GROUP) {
+        continue;
+      }
+
+      const userGroup = await UserGroup.findOne({ _id: relation.relatedGroup });
+
+      // skip if userGroup does not exist
+      if (userGroup == null) {
+        continue;
+      }
+
+      page.grantedGroup = userGroup;
+      await page.save();
+    }
+
+    // drop collection
+    await db.collection('pagegrouprelations').drop();
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db) {
+    logger.info('Undo migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Page = require('@server/models/page')();
+    const UserGroup = require('@server/models/user-group')();
+
+    // retrieve all Page documents which granted by UserGroup
+    const relatedPages = await Page.find({ grant: Page.GRANT_USER_GROUP });
+    const insertDocs = [];
+    for (let page of relatedPages) {
+      if (page.grantedGroup == null) {
+        continue;
+      }
+
+      const userGroup = await UserGroup.findOne({ _id: page.grantedGroup });
+
+      // skip if userGroup does not exist
+      if (userGroup == null) {
+        continue;
+      }
+
+      // create a new document for 'pagegrouprelations' collection that is managed by mongoose
+      insertDocs.push({
+        targetPage: page._id,
+        relatedGroup: userGroup._id,
+        __v: 0,
+      });
+
+      // clear 'grantedGroup' field
+      page.grantedGroup = undefined;
+      await page.save();
+    }
+
+    await db.collection('pagegrouprelations').insertMany(insertDocs);
+
+    logger.info('Migration has successfully undoed');
+  }
+
+};

+ 21 - 32
src/server/crowi/index.js

@@ -5,6 +5,7 @@ const debug = require('debug')('growi:crowi')
   , logger = require('@alias/logger')('growi:crowi')
   , pkg = require('@root/package.json')
   , InterceptorManager = require('@commons/service/interceptor-manager')
+  , CdnResourcesService = require('@commons/service/cdn-resources-service')
   , Xss = require('@commons/service/xss')
   , path = require('path')
   , sep = path.sep
@@ -39,6 +40,7 @@ function Crowi(rootdir) {
   this.passportService = null;
   this.globalNotificationService = null;
   this.restQiitaAPIService = null;
+  this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.xss = new Xss();
 
@@ -54,6 +56,8 @@ function Crowi(rootdir) {
   this.events = {
     user: new (require(self.eventsDir + 'user'))(this),
     page: new (require(self.eventsDir + 'page'))(this),
+    search: new (require(self.eventsDir + 'search'))(this),
+    bookmark: new (require(self.eventsDir + 'bookmark'))(this),
   };
 
 }
@@ -66,38 +70,23 @@ function getMongoUrl(env) {
     ((process.env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
 }
 
-Crowi.prototype.init = function() {
-  var self = this;
-
-  return Promise.resolve()
-    .then(function() {
-      // setup database server and load all modesl
-      return self.setupDatabase();
-    }).then(function() {
-      return self.setupModels();
-    }).then(function() {
-      return self.setupSessionConfig();
-    }).then(function() {
-      return self.setupAppConfig();
-    }).then(function() {
-      return self.setupConfigManager();
-    }).then(function() {
-      return self.scanRuntimeVersions();
-    }).then(function() {
-      return self.setupPassport();
-    }).then(function() {
-      return self.setupSearcher();
-    }).then(function() {
-      return self.setupMailer();
-    }).then(function() {
-      return self.setupSlack();
-    }).then(function() {
-      return self.setupCsrf();
-    }).then(function() {
-      return self.setUpGlobalNotification();
-    }).then(function() {
-      return self.setUpRestQiitaAPI();
-    });
+Crowi.prototype.init = async function() {
+  await this.setupDatabase();
+  await this.setupModels();
+  await this.setupSessionConfig();
+  await this.setupAppConfig();
+  await this.setupConfigManager();
+
+  await Promise.all([
+    this.scanRuntimeVersions(),
+    this.setupPassport(),
+    this.setupSearcher(),
+    this.setupMailer(),
+    this.setupSlack(),
+    this.setupCsrf(),
+    this.setUpGlobalNotification(),
+    this.setUpRestQiitaAPI(),
+  ]);
 };
 
 Crowi.prototype.isPageId = function(pageId) {

+ 15 - 0
src/server/events/bookmark.js

@@ -0,0 +1,15 @@
+// var debug = require('debug')('crowi:events:page')
+const util = require('util');
+const events = require('events');
+
+function BookmarkEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(BookmarkEvent, events.EventEmitter);
+
+BookmarkEvent.prototype.onCreate = function(bookmark) {};
+BookmarkEvent.prototype.onDelete = function(bookmark) {};
+
+module.exports = BookmarkEvent;

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

@@ -0,0 +1,11 @@
+const util = require('util');
+const events = require('events');
+
+function SearchEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(SearchEvent, events.EventEmitter);
+
+module.exports = SearchEvent;

+ 19 - 17
src/server/events/user.js

@@ -1,6 +1,6 @@
-var debug = require('debug')('growi:events:user');
-var util = require('util');
-var events = require('events');
+const debug = require('debug')('growi:events:user');
+const util = require('util');
+const events = require('events');
 
 function UserEvent(crowi) {
   this.crowi = crowi;
@@ -9,25 +9,27 @@ function UserEvent(crowi) {
 }
 util.inherits(UserEvent, events.EventEmitter);
 
-UserEvent.prototype.onActivated = function(user) {
-  var User = this.crowi.model('User');
-  var Page = this.crowi.model('Page');
+UserEvent.prototype.onActivated = async function(user) {
+  const Page = this.crowi.model('Page');
+
+  const userPagePath = Page.getUserPagePath(user);
+
+  const page = await Page.findByPathAndViewer(userPagePath, user);
+
+  if (page == null) {
+    const body = `# ${user.username}\nThis is ${user.username}'s page`;
 
-  var userPagePath = Page.getUserPagePath(user);
-  Page.findPage(userPagePath, user, {}, false)
-  .then(function(page) {
-    // do nothing because user page is already exists.
-  }).catch(function(err) {
-    var body = `# ${user.username}\nThis is ${user.username}\'s page`;
     // create user page
-    Page.create(userPagePath, body, user, {})
-    .then(function(page) {
+    try {
+      await Page.create(userPagePath, body, user, {});
+
       // page created
       debug('User page created', page);
-    }).catch(function(err) {
+    }
+    catch (err) {
       debug('Failed to create user page', err);
-    });
-  });
+    }
+  }
 };
 
 module.exports = UserEvent;

+ 7 - 6
src/server/form/admin/securityGeneral.js

@@ -1,15 +1,16 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field
-  , stringToArray = require('../../util/formUtil').stringToArrayFilter
-  , normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter
-  ;
+const form = require('express-form')
+const field = form.field;
+const stringToArray = require('../../util/formUtil').stringToArrayFilter;
+const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
 
 module.exports = form(
   field('settingForm[security:basicName]'),
   field('settingForm[security:basicSecret]'),
   field('settingForm[security:restrictGuestMode]').required(),
   field('settingForm[security:registrationMode]').required(),
-  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray)
+  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
+  field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),
+  field('settingForm[security:list-policy:hideRestrictedByGroup]').trim().toBooleanStrict(),
 );

+ 2 - 1
src/server/form/register.js

@@ -9,5 +9,6 @@ module.exports = form(
   field('registerForm.email').required(),
   field('registerForm.password').required().is(/^[\x20-\x7F]{6,}$/),
   field('registerForm.googleId'),
-  field('registerForm.googleImage')
+  field('registerForm.googleImage'),
+  field('registerForm[app:globalLang]')
 );

+ 3 - 10
src/server/models/GlobalNotificationSetting/index.js

@@ -1,4 +1,5 @@
 const mongoose = require('mongoose');
+const nodePath = require('path');
 
 /**
  * parent schema for GlobalNotificationSetting model
@@ -74,22 +75,14 @@ class GlobalNotificationSetting {
   }
 }
 
-
-// move this to util
-// remove this from models/page
-const cutOffLastSlash = path => {
-  const lastSlash = path.lastIndexOf('/');
-  return path.substr(0, lastSlash);
-};
-
 const generatePathsOnTree = (path, pathList) => {
   pathList.push(path);
 
-  if (path === '') {
+  if (path === '/') {
     return pathList;
   }
 
-  const newPath = cutOffLastSlash(path);
+  const newPath = nodePath.posix.dirname(path);
 
   return generatePathsOnTree(newPath, pathList);
 };

+ 28 - 37
src/server/models/bookmark.js

@@ -1,8 +1,9 @@
 module.exports = function(crowi) {
-  var debug = require('debug')('growi:models:bookmark')
-    , mongoose = require('mongoose')
-    , ObjectId = mongoose.Schema.Types.ObjectId
-    , bookmarkSchema;
+  const debug = require('debug')('growi:models:bookmark');
+  const mongoose = require('mongoose');
+  const ObjectId = mongoose.Schema.Types.ObjectId;
+
+  let bookmarkSchema = null;
 
 
   bookmarkSchema = new mongoose.Schema({
@@ -12,32 +13,18 @@ module.exports = function(crowi) {
   });
   bookmarkSchema.index({page: 1, user: 1}, {unique: true});
 
-  bookmarkSchema.statics.populatePage = function(bookmarks, requestUser) {
+  bookmarkSchema.statics.countByPageId = async function(pageId) {
+    return await this.count({ page: pageId });
+  };
+
+  bookmarkSchema.statics.populatePage = async function(bookmarks) {
     const Bookmark = this;
     const User = crowi.model('User');
-    const Page = crowi.model('Page');
-
-    requestUser = requestUser || null;
-
-    // mongoose promise に置き換えてみたものの、こいつは not native promise but original promise だったので
-    // これ以上は置き換えないことにする ...
-    // @see http://eddywashere.com/blog/switching-out-callbacks-with-promises-in-mongoose/
-    return Bookmark.populate(bookmarks, {path: 'page'})
-      .then(function(bookmarks) {
-        return Bookmark.populate(bookmarks, {path: 'page.revision', model: 'Revision'});
-      }).then(function(bookmarks) {
-        // hmm...
-        bookmarks = bookmarks.filter(function(bookmark) {
-          // requestUser を指定しない場合 public のみを返す
-          if (requestUser === null) {
-            return bookmark.page.isPublic();
-          }
 
-          return bookmark.page.isGrantedFor(requestUser);
-        });
-
-        return Bookmark.populate(bookmarks, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS});
-      });
+    return Bookmark.populate(bookmarks, [
+      {path: 'page'},
+      {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS},
+    ]);
   };
 
   // bookmark チェック用
@@ -63,15 +50,14 @@ module.exports = function(crowi) {
    * }
    */
   bookmarkSchema.statics.findByUser = function(user, option) {
-    var User = crowi.model('User');
-    var Bookmark = this;
-    var requestUser = option.requestUser || null;
+    const Bookmark = this;
+    const requestUser = option.requestUser || null;
 
     debug('Finding bookmark with requesting user:', requestUser);
 
-    var limit = option.limit || 50;
-    var offset = option.offset || 0;
-    var populatePage = option.populatePage || false;
+    const limit = option.limit || 50;
+    const offset = option.offset || 0;
+    const populatePage = option.populatePage || false;
 
     return new Promise(function(resolve, reject) {
       Bookmark
@@ -94,10 +80,10 @@ module.exports = function(crowi) {
   };
 
   bookmarkSchema.statics.add = function(page, user) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
-      var newBookmark = new Bookmark;
+      const newBookmark = new Bookmark;
 
       newBookmark.page = page;
       newBookmark.user = user;
@@ -116,8 +102,13 @@ module.exports = function(crowi) {
     });
   };
 
+  /**
+   * Remove bookmark
+   * used only when removing the page
+   * @param {string} pageId
+   */
   bookmarkSchema.statics.removeBookmarksByPageId = function(pageId) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
       Bookmark.remove({page: pageId}, function(err, data) {
@@ -132,7 +123,7 @@ module.exports = function(crowi) {
   };
 
   bookmarkSchema.statics.removeBookmark = function(page, user) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
       Bookmark.findOneAndRemove({page: page, user: user}, function(err, data) {

+ 14 - 0
src/server/models/config.js

@@ -60,6 +60,9 @@ module.exports = function(crowi) {
       'security:registrationMode'      : 'Open',
       'security:registrationWhiteList' : [],
 
+      'security:list-policy:hideRestrictedByOwner' : false,
+      'security:list-policy:hideRestrictedByGroup' : false,
+
       'security:isEnabledPassport' : false,
       'security:passport-ldap:isEnabled' : false,
       'security:passport-ldap:serverUrl' : undefined,
@@ -377,6 +380,16 @@ module.exports = function(crowi) {
     return SECURITY_RESTRICT_GUEST_MODE_READONLY === config.crowi['security:restrictGuestMode'];
   };
 
+  configSchema.statics.hidePagesRestrictedByOwnerInList = function(config) {
+    const key = 'security:list-policy:hideRestrictedByOwner';
+    return getValueForCrowiNS(config, key);
+  };
+
+  configSchema.statics.hidePagesRestrictedByGroupInList = function(config) {
+    const key = 'security:list-policy:hideRestrictedByGroup';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isEnabledPlugins = function(config) {
     const key = 'plugin:isEnabledPlugins';
     return getValueForCrowiNS(config, key);
@@ -628,6 +641,7 @@ module.exports = function(crowi) {
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
         HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
+        NO_CDN: env.NO_CDN || null,
       },
       recentCreatedLimit: Config.showRecentCreatedNumber(config),
       isAclEnabled: !Config.isPublicWikiOnly(config),

Разница между файлами не показана из-за своего большого размера
+ 486 - 629
src/server/models/page.js


+ 0 - 28
src/server/models/revision.js

@@ -31,31 +31,6 @@ module.exports = function(crowi) {
   //   next();
   // });
 
-  revisionSchema.statics.findLatestRevision = function(path, cb) {
-    this.find({path: path})
-      .sort({createdAt: -1})
-      .limit(1)
-      .exec(function(err, data) {
-        cb(err, data.shift());
-      });
-  };
-
-  revisionSchema.statics.findRevision = function(id) {
-    const Revision = this;
-
-    return new Promise(function(resolve, reject) {
-      Revision.findById(id)
-        .populate('author')
-        .exec(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(data);
-        });
-    });
-  };
-
   revisionSchema.statics.findRevisions = function(ids) {
     const Revision = this,
       User = crowi.model('User');
@@ -157,8 +132,5 @@ module.exports = function(crowi) {
     });
   };
 
-  revisionSchema.statics.updatePath = function(pathName) {
-  };
-
   return mongoose.model('Revision', revisionSchema);
 };

+ 19 - 9
src/server/models/user-group-relation.js

@@ -128,6 +128,21 @@ class UserGroupRelation {
       });
   }
 
+  /**
+   * find all UserGroup IDs that related to specified User
+   *
+   * @static
+   * @param {User} user
+   * @returns {Promise<ObjectId[]>}
+   */
+  static async findAllUserGroupIdsRelatedToUser(user) {
+    const relations = await this.find({ relatedUser: user.id })
+      .select('relatedGroup')
+      .exec();
+
+    return relations.map(relation => relation.relatedGroup);
+  }
+
   /**
    * find all entities with pagination
    *
@@ -156,25 +171,20 @@ class UserGroupRelation {
   }
 
   /**
-   * find one result by related group id and related user
+   * count by related group id and related user
    *
    * @static
    * @param {string} userGroupId find query param for relatedGroup
    * @param {User} userData find query param for relatedUser
-   * @returns {Promise<UserGroupRelation>}
-   * @memberof UserGroupRelation
+   * @returns {Promise<number>}
    */
-  static findByGroupIdAndUser(userGroupId, userData) {
+  static async countByGroupIdAndUser(userGroupId, userData) {
     const query = {
       relatedGroup: userGroupId,
       relatedUser: userData.id
     };
 
-    return this
-      .findOne(query)
-      .populate('relatedUser')
-      .populate('relatedGroup')
-      .exec();
+    return this.count(query);
   }
 
   /**

+ 13 - 0
src/server/models/user.js

@@ -658,6 +658,11 @@ module.exports = function(crowi) {
           newUser.createdAt = Date.now();
           newUser.status = STATUS_INVITED;
 
+          const globalLang = Config.globalLang(config);
+          if (globalLang != null) {
+            newUser.lang = globalLang;
+          }
+
           newUser.save(function(err, userData) {
             if (err) {
               createdUserList.push({
@@ -746,6 +751,14 @@ module.exports = function(crowi) {
     if (password != null) {
       newUser.setPassword(password);
     }
+
+    const Config = crowi.model('Config');
+    const config = crowi.getConfig();
+    const globalLang = Config.globalLang(config);
+    if (globalLang != null) {
+      newUser.lang = globalLang;
+    }
+
     if (lang != null) {
       newUser.lang = lang;
     }

+ 75 - 66
src/server/routes/admin.js

@@ -1,28 +1,33 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  const debug = require('debug')('growi:routes:admin')
-    , logger = require('@alias/logger')('growi:routes:admin')
-    , fs = require('fs')
-    , models = crowi.models
-    , Page = models.Page
-    , PageGroupRelation = models.PageGroupRelation
-    , User = models.User
-    , ExternalAccount = models.ExternalAccount
-    , UserGroup = models.UserGroup
-    , UserGroupRelation = models.UserGroupRelation
-    , Config = models.Config
-    , GlobalNotificationSetting = models.GlobalNotificationSetting
-    , GlobalNotificationMailSetting = models.GlobalNotificationMailSetting
-    , GlobalNotificationSlackSetting = models.GlobalNotificationSlackSetting  // eslint-disable-line no-unused-vars
-    , PluginUtils = require('../plugins/plugin-utils')
-    , pluginUtils = new PluginUtils()
-    , ApiResponse = require('../util/apiResponse')
-    , recommendedXssWhiteList = require('@commons/service/xss/recommendedXssWhiteList')
-    , importer = require('../util/importer')(crowi)
-
-    , MAX_PAGE_LIST = 50
-    , actions = {};
+  const debug = require('debug')('growi:routes:admin');
+  const logger = require('@alias/logger')('growi:routes:admin');
+  const fs = require('fs');
+
+  const models = crowi.models;
+  const Page = models.Page;
+  const PageGroupRelation = models.PageGroupRelation;
+  const User = models.User;
+  const ExternalAccount = models.ExternalAccount;
+  const UserGroup = models.UserGroup;
+  const UserGroupRelation = models.UserGroupRelation;
+  const Config = models.Config;
+  const GlobalNotificationSetting = models.GlobalNotificationSetting;
+  const GlobalNotificationMailSetting = models.GlobalNotificationMailSetting;
+  const GlobalNotificationSlackSetting = models.GlobalNotificationSlackSetting; // eslint-disable-line no-unused-vars
+
+  const recommendedXssWhiteList = require('@commons/service/xss/recommendedXssWhiteList');
+  const PluginUtils = require('../plugins/plugin-utils');
+  const ApiResponse = require('../util/apiResponse');
+  const importer = require('../util/importer')(crowi);
+
+  const searchEvent = crowi.event('search');
+  const pluginUtils = new PluginUtils();
+
+  const MAX_PAGE_LIST = 50;
+  const actions = {};
+
 
   function createPager(total, limit, page, pagesCount, maxPageList) {
     const pager = {
@@ -294,12 +299,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.search = {};
-  actions.search.index = function(req, res) {
-    return res.render('admin/search', {
-    });
-  };
-
   // app.post('/admin/notification/slackIwhSetting' , admin.notification.slackIwhSetting);
   actions.notification.slackIwhSetting = function(req, res) {
     var slackIwhSetting = req.form.slackIwhSetting;
@@ -425,47 +424,14 @@ module.exports = function(crowi, app) {
     return triggerEvents;
   };
 
-  actions.search.buildIndex = function(req, res) {
-    var search = crowi.getSearcher();
+  actions.search = {}
+  actions.search.index = function(req, res) {
+    const search = crowi.getSearcher();
     if (!search) {
       return res.redirect('/admin');
     }
 
-    return new Promise(function(resolve, reject) {
-      search.deleteIndex()
-        .then(function(data) {
-          debug('Index deleted.');
-          resolve();
-        }).catch(function(err) {
-          debug('Delete index Error, but if it is initialize, its ok.', err);
-          resolve();
-        });
-    })
-    .then(function() {
-      return search.buildIndex();
-    })
-    .then(function(data) {
-      if (!data.errors) {
-        debug('Index created.');
-      }
-      return search.addAllPages();
-    })
-    .then(function(data) {
-      if (!data.errors) {
-        debug('Data is successfully indexed.');
-        req.flash('successMessage', 'Data is successfully indexed.');
-      }
-      else {
-        debug('Data index error.', data.errors);
-        req.flash('errorMessage', `Data index error: ${data.errors}`);
-      }
-      return res.redirect('/admin/search');
-    })
-    .catch(function(err) {
-      debug('Error', err);
-      req.flash('errorMessage', `Error: ${err}`);
-      return res.redirect('/admin/search');
-    });
+    return res.render('admin/search', {});
   };
 
   actions.user = {};
@@ -606,7 +572,7 @@ module.exports = function(crowi, app) {
       return ExternalAccount.remove({user: userData}).then(() => userData);
     })
     .then((userData) => {
-      return Page.removePageByPath(`/user/${username}`).then(() => userData);
+      return Page.removeByPath(`/user/${username}`).then(() => userData);
     })
     .then((userData) => {
       req.flash('successMessage', `${username} さんのアカウントを削除しました`);
@@ -1418,6 +1384,49 @@ module.exports = function(crowi, app) {
     }
   };
 
+
+  actions.api.searchBuildIndex = async function(req, res) {
+    const search = crowi.getSearcher();
+    if (!search) {
+      return res.json(ApiResponse.error('ElasticSearch Integration is not set up.'));
+    }
+
+    // first, delete index
+    try {
+      await search.deleteIndex();
+    }
+    catch (err) {
+      logger.warn('Delete index Error, but if it is initialize, its ok.', err);
+    }
+
+    // second, create index
+    try {
+      await search.buildIndex();
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.json(ApiResponse.error(err));
+    }
+
+    searchEvent.on('addPageProgress', (total, current, skip) => {
+      crowi.getIo().sockets.emit('admin:addPageProgress', { total, current, skip });
+    });
+    searchEvent.on('finishAddPage', (total, current, skip) => {
+      crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
+    });
+    // add all page
+    search
+      .addAllPages()
+      .then(() => {
+        debug('Data is successfully indexed. ------------------ ✧✧');
+      })
+      .catch(err => {
+        logger.error('Error', err);
+      });
+
+    return res.json(ApiResponse.success());
+  };
+
   /**
    * save settings, update config cache, and response json
    *

+ 16 - 3
src/server/routes/attachment.js

@@ -106,6 +106,16 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {get} /attachments.limit get available capacity of uploaded file with GridFS
+   * @apiName AddAttachments
+   * @apiGroup Attachment
+   */
+  api.limit = async function(req, res) {
+    const isUploadable = await fileUploader.checkCapacity(req.query.fileSize);
+    return res.json(ApiResponse.success({isUploadable: isUploadable}));
+  };
+
   /**
    * @api {post} /attachments.add Add attachment to the page
    * @apiName AddAttachments
@@ -114,7 +124,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id
    * @apiParam {File} file
    */
-  api.add = function(req, res) {
+  api.add = async function(req, res) {
     var id = req.body.page_id || 0,
       path = decodeURIComponent(req.body.path) || null,
       pageCreated = false,
@@ -123,11 +133,14 @@ module.exports = function(crowi, app) {
     debug('id and path are: ', id, path);
 
     var tmpFile = req.file || null;
+    const isUploadable = await fileUploader.checkCapacity(tmpFile.size);
+    if (!isUploadable) {
+      return res.json(ApiResponse.error('MongoDB for uploading files reaches limit'));
+    }
     debug('Uploaded tmpFile: ', tmpFile);
     if (!tmpFile) {
       return res.json(ApiResponse.error('File error.'));
     }
-
     new Promise(function(resolve, reject) {
       if (id == 0) {
         if (path === null) {
@@ -142,7 +155,7 @@ module.exports = function(crowi, app) {
           .catch(reject);
       }
       else {
-        Page.findPageById(id).then(resolve).catch(reject);
+        Page.findById(id).then(resolve).catch(reject);
       }
     }).then(function(pageData) {
       page = pageData;

+ 53 - 47
src/server/routes/bookmark.js

@@ -1,15 +1,12 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:bookmark')
-    , Bookmark = crowi.model('Bookmark')
-    , Page = crowi.model('Page')
-    , User = crowi.model('User')
-    , Revision = crowi.model('Revision')
-    , Bookmark = crowi.model('Bookmark')
-    , ApiResponse = require('../util/apiResponse')
-    , actions = {}
-  ;
+  const debug = require('debug')('growi:routes:bookmark');
+  const Bookmark = crowi.model('Bookmark');
+  const Page = crowi.model('Page');
+  const ApiResponse = require('../util/apiResponse');
+  const ApiPaginate = require('../util/apiPaginate');
+  let actions = {};
   actions.api = {};
 
   /**
@@ -20,20 +17,35 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   actions.api.get = function(req, res) {
-    var pageId = req.query.page_id;
+    let pageId = req.query.page_id;
 
     Bookmark.findByPageIdAndUserId(pageId, req.user)
-    .then(function(data) {
-      debug('bookmark found', pageId, data);
-      var result = {};
-      if (data) {
-      }
+      .then(function(data) {
+        debug('bookmark found', pageId, data);
+        let result = {};
 
-      result.bookmark = data;
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
+        result.bookmark = data;
+        return res.json(ApiResponse.success(result));
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
+  /**
+   *
+   */
+  actions.api.list = function(req, res) {
+    let paginateOptions = ApiPaginate.parseOptions(req.query);
+
+    let options = Object.assign(paginateOptions, { populatePage: true });
+    Bookmark.findByUserId(req.user._id, options)
+      .then(function(result) {
+        return res.json(ApiResponse.success(result));
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
   };
 
   /**
@@ -43,27 +55,21 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} page_id Page Id.
    */
-  actions.api.add = function(req, res) {
-    var pageId = req.body.page_id;
+  actions.api.add = async function(req, res) {
+    const pageId = req.body.page_id;
+
+    const page = await Page.findByIdAndViewer(pageId, req.user);
+    if (page == null) {
+      return res.json(ApiResponse.success({ bookmark: null }));
+    }
 
-    Page.findPageByIdAndGrantedUser(pageId, req.user)
-    .then(function(pageData) {
-      if (pageData) {
-        return Bookmark.add(pageData, req.user);
-      }
-      else {
-        return res.json(ApiResponse.success({bookmark: null}));
-      }
-    }).then(function(data) {
-      var result = {};
-      data.depopulate('page');
-      data.depopulate('user');
+    const bookmark = await Bookmark.add(page, req.user);
 
-      result.bookmark = data;
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
+    bookmark.depopulate('page');
+    bookmark.depopulate('user');
+    const result = { bookmark };
+
+    return res.json(ApiResponse.success(result));
   };
 
   /**
@@ -74,17 +80,17 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   actions.api.remove = function(req, res) {
-    var pageId = req.body.page_id;
+    let pageId = req.body.page_id;
 
     Bookmark.removeBookmark(pageId, req.user)
-    .then(function(data) {
-      debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
-      return res.json(ApiResponse.success());
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
+      .then(function(data) {
+        debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
+        return res.json(ApiResponse.success());
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
   };
 
-
   return actions;
 };

+ 50 - 30
src/server/routes/comment.js

@@ -1,8 +1,7 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  const debug = require('debug')('growi:routs:comment')
-    , logger = require('@alias/logger')('growi:routes:comment')
+  const logger = require('@alias/logger')('growi:routes:comment')
     , Comment = crowi.model('Comment')
     , User = crowi.model('User')
     , Page = crowi.model('Page')
@@ -13,6 +12,7 @@ module.exports = function(crowi, app) {
 
   actions.api = api;
 
+
   /**
    * @api {get} /comments.get Get comments of the page of the revision
    * @apiName GetComments
@@ -21,25 +21,31 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} revision_id Revision Id.
    */
-  api.get = function(req, res) {
+  api.get = async function(req, res) {
     const pageId = req.query.page_id;
     const revisionId = req.query.revision_id;
 
-    if (revisionId) {
-      return Comment.getCommentsByRevisionId(revisionId)
-        .then(function(comments) {
-          res.json(ApiResponse.success({comments}));
-        }).catch(function(err) {
-          res.json(ApiResponse.error(err));
-        });
+    // check whether accessible
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+    if (!isAccessible) {
+      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
-    return Comment.getCommentsByPageId(pageId)
-      .then(function(comments) {
-        res.json(ApiResponse.success({comments}));
-      }).catch(function(err) {
-        res.json(ApiResponse.error(err));
-      });
+    let comments = null;
+
+    try {
+      if (revisionId) {
+        comments = await Comment.getCommentsByRevisionId(revisionId);
+      }
+      else {
+        comments = await Comment.getCommentsByPageId(pageId);
+      }
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+
+    res.json(ApiResponse.success({comments}));
   };
 
   /**
@@ -67,6 +73,12 @@ module.exports = function(crowi, app) {
     const position = commentForm.comment_position || -1;
     const isMarkdown = commentForm.is_markdown;
 
+    // check whether accessible
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+    if (!isAccessible) {
+      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
+    }
+
     const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
       .catch(function(err) {
         return res.json(ApiResponse.error(err));
@@ -114,26 +126,34 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} comment_id Comment Id.
    */
-  api.remove = function(req, res) {
+  api.remove = async function(req, res) {
     const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
     }
 
-    return Comment.findById(commentId).exec()
-      .then(function(comment) {
-        return comment.remove()
-        .then(function() {
-          return Page.updateCommentCount(comment.page);
-        })
-        .then(function() {
-          return res.json(ApiResponse.success({}));
-        });
-      })
-      .catch(function(err) {
-        return res.json(ApiResponse.error(err));
-      });
+    try {
+      const comment = await Comment.findById(commentId).exec();
+
+      if (comment == null) {
+        throw new Error('This comment does not exist.');
+      }
+
+      // check whether accessible
+      const pageId = comment.page;
+      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+      if (!isAccessible) {
+        throw new Error('Current user is not accessible to this page.');
+      }
+
+      await comment.remove();
+      await Page.updateCommentCount(comment.page);
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
 
+    return res.json(ApiResponse.success({}));
   };
 
   return actions;

+ 7 - 6
src/server/routes/index.js

@@ -27,7 +27,7 @@ module.exports = function(crowi, app) {
 
   /* eslint-disable comma-spacing */
 
-  app.get('/'                        , middleware.applicationInstalled(), loginRequired(crowi, app, false) , page.pageListShow);
+  app.get('/'                        , middleware.applicationInstalled(), loginRequired(crowi, app, false) , page.showTopPage);
 
   app.get('/installer'               , middleware.applicationNotInstalled() , middleware.checkSearchIndicesGenerated(crowi, app) , installer.index);
   app.post('/installer/createAdmin'  , middleware.applicationNotInstalled() , form.register , csrf, installer.createAdmin);
@@ -102,7 +102,7 @@ module.exports = function(crowi, app) {
 
   // search admin
   app.get('/admin/search'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.search.index);
-  app.post('/admin/search/build'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.search.buildIndex);
+  app.post('/_api/admin/search/build'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.searchBuildIndex);
 
   // notification admin
   app.get('/admin/notification'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.index);
@@ -173,8 +173,8 @@ module.exports = function(crowi, app) {
   app.post('/me/auth/google'          , loginRequired(crowi, app) , me.authGoogle);
   app.get( '/me/auth/google/callback' , loginRequired(crowi, app) , me.authGoogleCallback);
 
-  app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app, false) , page.api.redirector);
-  app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app, false) , page.api.redirector); // alias
+  app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app, false) , page.redirector);
+  app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app, false) , page.redirector); // alias
   app.get( '/download/:id([0-9a-z]{24})' , loginRequired(crowi, app, false) , attachment.api.download);
   app.get( '/attachment/:pageId/:fileName'  , loginRequired(crowi, app, false), attachment.api.get);
 
@@ -212,6 +212,7 @@ module.exports = function(crowi, app) {
   app.get( '/_api/attachments.list'   , accessTokenParser , loginRequired(crowi, app, false) , attachment.api.list);
   app.post('/_api/attachments.add'    , uploads.single('file'), accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.add);
   app.post('/_api/attachments.remove' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.remove);
+  app.get( '/_api/attachments.limit' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.limit);
 
   app.get( '/_api/revisions.get'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.get);
   app.get( '/_api/revisions.ids'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.ids);
@@ -229,6 +230,6 @@ module.exports = function(crowi, app) {
   // API v3
   app.use('/_api/v3', require('./apiv3')(crowi));
 
-  app.get('/*/$'                   , loginRequired(crowi, app, false) , page.pageListShowWrapper);
-  app.get('/*'                     , loginRequired(crowi, app, false) , page.pageShowWrapper);
+  app.get('/*/$'                   , loginRequired(crowi, app, false) , page.showPageWithEndOfSlash, page.notFound);
+  app.get('/*'                     , loginRequired(crowi, app, false) , page.showPage, page.notFound);
 };

+ 9 - 1
src/server/routes/installer.js

@@ -34,13 +34,16 @@ module.exports = function(crowi, app) {
 
   actions.createAdmin = function(req, res) {
     var registerForm = req.body.registerForm || {};
-    var language = req.language || 'en-US';
 
     if (req.form.isValid) {
       var name = registerForm.name;
       var username = registerForm.username;
       var email = registerForm.email;
       var password = registerForm.password;
+      var language = registerForm['app:globalLang'] || (req.language || 'en-US');
+      // for config.globalLang setting.
+      var langForm = {};
+      langForm['app:globalLang'] = language;
 
       User.createUserByEmailAndPassword(name, username, email, password, language, function(err, userData) {
         if (err) {
@@ -69,6 +72,11 @@ module.exports = function(crowi, app) {
           // create initial pages
           createInitialPages(userData, language);
         });
+
+        // save config settings, and update config cache
+        Config.updateNamespaceByArray('crowi', langForm, function(err, config) {
+          Config.updateConfigCache('crowi', config);
+        });
       });
     }
     else {

+ 1 - 2
src/server/routes/login.js

@@ -141,7 +141,6 @@ module.exports = function(crowi, app) {
 
   actions.register = function(req, res) {
     var googleAuth = require('../util/googleAuth')(config);
-    var lang= req.lang || User.LANG_EN_US;
 
     // ログイン済みならさようなら
     if (req.user) {
@@ -186,7 +185,7 @@ module.exports = function(crowi, app) {
           return res.redirect('/register');
         }
 
-        User.createUserByEmailAndPassword(name, username, email, password, lang, function(err, userData) {
+        User.createUserByEmailAndPassword(name, username, email, password, undefined, function(err, userData) {
           if (err) {
             if (err.name === 'UserUpperLimitException') {
               req.flash('registerWarningMessage', 'Can not register more than the maximum number of users.');

Разница между файлами не показана из-за своего большого размера
+ 301 - 578
src/server/routes/page.js


+ 31 - 22
src/server/routes/revision.js

@@ -1,12 +1,13 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:revision')
-    , Page = crowi.model('Page')
-    , Revision = crowi.model('Revision')
-    , ApiResponse = require('../util/apiResponse')
-    , actions = {}
-  ;
+  const debug = require('debug')('growi:routes:revision');
+  const logger = require('@alias/logger')('growi:routes:revision');
+  const Page = crowi.model('Page');
+  const Revision = crowi.model('Revision');
+  const ApiResponse = require('../util/apiResponse');
+
+  const actions = {};
   actions.api = {};
 
   /**
@@ -14,23 +15,31 @@ module.exports = function(crowi, app) {
    * @apiName GetRevision
    * @apiGroup Revision
    *
+   * @apiParam {String} page_id Page Id.
    * @apiParam {String} revision_id Revision Id.
    */
-  actions.api.get = function(req, res) {
-    var revisionId = req.query.revision_id;
+  actions.api.get = async function(req, res) {
+    const pageId = req.query.page_id;
+    const revisionId = req.query.revision_id;
 
-    Revision
-      .findRevision(revisionId)
-      .then(function(revisionData) {
-        var result = {
-          revision: revisionData,
-        };
-        return res.json(ApiResponse.success(result));
-      })
-      .catch(function(err) {
-        debug('Error revisios.get', err);
-        return res.json(ApiResponse.error(err));
-      });
+    if (!pageId || !revisionId) {
+      return res.json(ApiResponse.error('Parameter page_id and revision_id are required.'));
+    }
+
+    // check whether accessible
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+    if (!isAccessible) {
+      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
+    }
+
+    try {
+      const revision = await Revision.findById(revisionId);
+      return res.json(ApiResponse.success({ revision }));
+    }
+    catch (err) {
+      logger.error('Error revisios.get', err);
+      return res.json(ApiResponse.error(err));
+    }
   };
 
   /**
@@ -44,7 +53,7 @@ module.exports = function(crowi, app) {
     const pageId = req.query.page_id || null;
 
     if (pageId && crowi.isPageId(pageId)) {
-      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      Page.findByIdAndViewer(pageId, req.user)
       .then(function(pageData) {
         debug('Page found', pageData._id, pageData.path);
         return Revision.findRevisionIdList(pageData.path);
@@ -72,7 +81,7 @@ module.exports = function(crowi, app) {
     const pageId = req.query.page_id || null;
 
     if (pageId) {
-      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      Page.findByIdAndViewer(pageId, req.user)
       .then(function(pageData) {
         debug('Page found', pageData._id, pageData.path);
         return Revision.findRevisionList(pageData.path, {});

+ 52 - 36
src/server/routes/search.js

@@ -1,17 +1,16 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:search')
-    , Page = crowi.model('Page')
-    , User = crowi.model('User')
-    , ApiResponse = require('../util/apiResponse')
-
-    , actions = {};
-  var api = actions.api = {};
+  // var debug = require('debug')('growi:routes:search')
+  const Page = crowi.model('Page');
+  const ApiResponse = require('../util/apiResponse');
+  const ApiPaginate = require('../util/apiPaginate');
+  const actions = {};
+  const api = (actions.api = {});
 
   actions.searchPage = function(req, res) {
-    var keyword = req.query.q || null;
-    var search = crowi.getSearcher();
+    const keyword = req.query.q || null;
+    const search = crowi.getSearcher();
     if (!search) {
       return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
     }
@@ -28,47 +27,64 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} q keyword
    * @apiParam {String} path
+   * @apiParam {String} offset
+   * @apiParam {String} limit
    */
-  api.search = function(req, res) {
-    var keyword = req.query.q || null;
-    var tree = req.query.tree || null;
+  api.search = async function(req, res) {
+    const user = req.user;
+    const { q: keyword = null, tree = null, type = null } = req.query;
+    let paginateOpts;
+
+    try {
+      paginateOpts = ApiPaginate.parseOptionsForElasticSearch(req.query);
+    }
+    catch (e) {
+      res.json(ApiResponse.error(e));
+    }
+
     if (keyword === null || keyword === '') {
       return res.json(ApiResponse.error('keyword should not empty.'));
     }
 
-    var search = crowi.getSearcher();
+    const search = crowi.getSearcher();
     if (!search) {
       return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
     }
 
+    let userGroups = [];
+    if (user != null) {
+      const UserGroupRelation = crowi.model('UserGroupRelation');
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const searchOpts = { ...paginateOpts, type };
+
+    const result = {};
+    try {
+      let esResult;
+      if (tree) {
+        esResult = await search.searchKeywordUnderPath(keyword, tree, user, userGroups, searchOpts);
+      }
+      else {
+        esResult = await search.searchKeyword(keyword, user, userGroups, searchOpts);
+      }
+
+      const findResult = await Page.findListByPageIds(esResult.data);
 
-    var doSearch;
-    if (tree) {
-      doSearch = search.searchKeywordUnderPath(keyword, tree, {});
+      result.meta = esResult.meta;
+      result.totalCount = findResult.totalCount;
+      result.data = findResult.pages
+        .map(page => {
+          page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
+          return page;
+        });
     }
-    else {
-      doSearch = search.searchKeyword(keyword, {});
+    catch (err) {
+      return res.json(ApiResponse.error(err));
     }
-    var result = {};
-    doSearch
-      .then(function(data) {
-        result.meta = data.meta;
 
-        return Page.populatePageListToAnyObjects(data.data);
-      }).then(function(pages) {
-        result.data = pages.filter(function(page) {
-          if (Object.keys(page).length < 12) { // FIXME: 12 is a number of columns.
-            return false;
-          }
-          return true;
-        });
-        return res.json(ApiResponse.success(result));
-      })
-      .catch(function(err) {
-        return res.json(ApiResponse.error(err));
-      });
+    return res.json(ApiResponse.success(result));
   };
 
-
   return actions;
 };

+ 7 - 0
src/server/service/file-uploader/aws.js

@@ -161,6 +161,13 @@ module.exports = function(crowi) {
     return false;
   };
 
+  /**
+   * chech storage for fileUpload reaches MONGODB_GRIDFS_LIMIT (for gridfs)
+   */
+  lib.checkCapacity = async(uploadFileSize) => {
+    return true;
+  };
+
   return lib;
 };
 

+ 28 - 0
src/server/service/file-uploader/gridfs.js

@@ -18,6 +18,7 @@ module.exports = function(crowi) {
 
   // obtain a model
   const AttachmentFile = gridfs.model;
+  const Chunks = mongoose.model('Chunks', gridfs.schema, 'attachmentFiles.chunks');
 
   // delete a file
   lib.deleteFile = async function(fileId, filePath) {
@@ -44,6 +45,33 @@ module.exports = function(crowi) {
     }
   };
 
+  /**
+   * get size of data uploaded files using (Promise wrapper)
+   */
+  const getCollectionSize = () => {
+    return new Promise((resolve, reject) => {
+      Chunks.collection.stats((err, data) => {
+        if (err) {
+          reject(err);
+        }
+        resolve(data.size);
+      });
+    });
+  };
+
+  /**
+   * chech storage for fileUpload reaches MONGODB_GRIDFS_LIMIT (for gridfs)
+   */
+  lib.checkCapacity = async(uploadFileSize) => {
+    // skip checking if env var is undefined
+    if (process.env.MONGODB_GRIDFS_LIMIT == null) {
+      return true;
+    }
+
+    const usingFilesSize = await getCollectionSize();
+    return (+process.env.MONGODB_GRIDFS_LIMIT > usingFilesSize + +uploadFileSize);
+  };
+
   lib.uploadFile = async function(filePath, contentType, fileStream, options) {
     debug('File uploading: ' + filePath);
     await writeFile(filePath, contentType, fileStream);

+ 7 - 0
src/server/service/file-uploader/local.js

@@ -55,6 +55,13 @@ module.exports = function(crowi) {
     return Promise.resolve(lib.generateUrl(filePath));
   };
 
+  /**
+   * chech storage for fileUpload reaches MONGODB_GRIDFS_LIMIT (for gridfs)
+   */
+  lib.checkCapacity = async(uploadFileSize) => {
+    return true;
+  };
+
   return lib;
 };
 

+ 44 - 0
src/server/util/apiPaginate.js

@@ -0,0 +1,44 @@
+'use strict';
+
+const LIMIT_DEFAULT = 50;
+const LIMIT_MAX = 1000;
+
+const OFFSET_DEFAULT = 0;
+
+const DEFAULT_MAX_RESULT_WINDOW = 10000;
+
+const parseIntValue = function(value, defaultValue, maxLimit) {
+  if (!value) {
+    return defaultValue;
+  }
+
+  let v = parseInt(value);
+  if (!maxLimit) {
+    return v;
+  }
+
+  return v <= maxLimit ? v : maxLimit;
+};
+
+function ApiPaginate() {}
+
+ApiPaginate.parseOptionsForElasticSearch = function(params) {
+  let limit = parseIntValue(params.limit, LIMIT_DEFAULT, LIMIT_MAX);
+  let offset = parseIntValue(params.offset, OFFSET_DEFAULT);
+
+  // See https://github.com/crowi/crowi/pull/293
+  if (limit + offset > DEFAULT_MAX_RESULT_WINDOW) {
+    throw new Error(`(limit + offset) must be less than or equal to ${DEFAULT_MAX_RESULT_WINDOW}`);
+  }
+
+  return { limit: limit, offset: offset };
+};
+
+ApiPaginate.parseOptions = function(params) {
+  let limit = parseIntValue(params.limit, LIMIT_DEFAULT, LIMIT_MAX);
+  let offset = parseIntValue(params.offset, OFFSET_DEFAULT);
+
+  return { limit: limit, offset: offset };
+};
+
+module.exports = ApiPaginate;

+ 456 - 228
src/server/util/search.js

@@ -2,24 +2,52 @@
  * Search
  */
 
-var elasticsearch = require('elasticsearch'),
-  debug = require('debug')('growi:lib:search');
+const elasticsearch = require('elasticsearch');
+const debug = require('debug')('growi:lib:search');
+const logger = require('@alias/logger')('growi:lib:search');
 
 function SearchClient(crowi, esUri) {
   this.DEFAULT_OFFSET = 0;
   this.DEFAULT_LIMIT = 50;
 
+  this.esNodeName = '-';
+  this.esNodeNames = [];
+  this.esVersion = 'unknown';
+  this.esVersions = [];
+  this.esPlugin = [];
+  this.esPlugins = [];
   this.esUri = esUri;
   this.crowi = crowi;
+  this.searchEvent = crowi.event('search');
+
+  // In Elasticsearch RegExp, we don't need to used ^ and $.
+  // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-regexp-query.html#_standard_operators
+  this.queries = {
+    PORTAL: {
+      regexp: {
+        'path.raw': '.*/',
+      },
+    },
+    PUBLIC: {
+      regexp: {
+        'path.raw': '.*[^/]',
+      },
+    },
+    USER: {
+      prefix: {
+        'path.raw': '/user/',
+      },
+    },
+  };
 
-  var uri = this.parseUri(this.esUri);
+  const uri = this.parseUri(this.esUri);
   this.host = uri.host;
-  this.index_name = uri.index_name;
+  this.indexName = uri.indexName;
 
   this.client = new elasticsearch.Client({
     host: this.host,
     requestTimeout: 5000,
-    //log: 'debug',
+    // log: 'debug',
   });
 
   this.registerUpdateEvent();
@@ -31,87 +59,126 @@ SearchClient.prototype.getInfo = function() {
   return this.client.info({});
 };
 
+SearchClient.prototype.checkESVersion = async function() {
+  try {
+    const nodes = await this.client.nodes.info();
+    if (!nodes._nodes || !nodes.nodes) {
+      throw new Error('no nodes info');
+    }
+
+    for (const [nodeName, nodeInfo] of Object.entries(nodes.nodes)) {
+      this.esNodeName = nodeName;
+      this.esNodeNames.push(nodeName);
+      this.esVersion = nodeInfo.version;
+      this.esVersions.push(nodeInfo.version);
+      this.esPlugin = nodeInfo.plugins;
+      this.esPlugins.push(nodeInfo.plugins);
+    }
+  }
+  catch (error) {
+    logger.error('es check version error:', error);
+  }
+};
+
 SearchClient.prototype.registerUpdateEvent = function() {
   const pageEvent = this.crowi.event('page');
   pageEvent.on('create', this.syncPageCreated.bind(this));
   pageEvent.on('update', this.syncPageUpdated.bind(this));
   pageEvent.on('delete', this.syncPageDeleted.bind(this));
+
+  const bookmarkEvent = this.crowi.event('bookmark');
+  bookmarkEvent.on('create', this.syncBookmarkChanged.bind(this));
+  bookmarkEvent.on('delete', this.syncBookmarkChanged.bind(this));
 };
 
 SearchClient.prototype.shouldIndexed = function(page) {
-  // FIXME: Magic Number
-  if (page.grant !== 1) {
-    return false;
-  }
-
-  if (page.redirectTo !== null) {
-    return false;
-  }
-
-  if (page.isDeleted()) {
-    return false;
-  }
-
-  return true;
+  return (page.redirectTo == null);
 };
 
-
 // BONSAI_URL is following format:
 // => https://{ID}:{PASSWORD}@{HOST}
 SearchClient.prototype.parseUri = function(uri) {
-  var index_name = 'crowi';
-  var host = uri;
-  if (m = uri.match(/^(https?:\/\/[^\/]+)\/(.+)$/)) {
+  let indexName = 'crowi';
+  let host = uri;
+  let m;
+  if ((m = uri.match(/^(https?:\/\/[^/]+)\/(.+)$/))) {
     host = m[1];
-    index_name = m[2];
+    indexName = m[2];
   }
 
   return {
     host,
-    index_name,
+    indexName,
   };
 };
 
 SearchClient.prototype.buildIndex = function(uri) {
   return this.client.indices.create({
-    index: this.index_name,
-    body: require(this.mappingFile)
+    index: this.indexName,
+    body: require(this.mappingFile),
   });
 };
 
 SearchClient.prototype.deleteIndex = function(uri) {
   return this.client.indices.delete({
-    index: this.index_name,
+    index: this.indexName,
   });
 };
 
+/**
+ * generate object that is related to page.grant*
+ */
+function generateDocContentsRelatedToRestriction(page) {
+  let grantedUserIds = null;
+  if (page.grantedUsers != null && page.grantedUsers.length > 0) {
+    grantedUserIds = page.grantedUsers.map(user => {
+      const userId = (user._id == null) ? user : user._id;
+      return userId.toString();
+    });
+  }
+
+  let grantedGroupId = null;
+  if (page.grantedGroup != null) {
+    const groupId = (page.grantedGroup._id == null) ? page.grantedGroup : page.grantedGroup._id;
+    grantedGroupId = groupId.toString();
+  }
+
+  return {
+    grant: page.grant,
+    granted_users: grantedUserIds,
+    granted_group: grantedGroupId,
+  };
+}
+
 SearchClient.prototype.prepareBodyForUpdate = function(body, page) {
   if (!Array.isArray(body)) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     update: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
-  var document = {
-    doc: {
-      path: page.path,
-      body: page.revision.body,
-      comment_count: page.commentCount,
-      bookmark_count: 0, // todo
-      like_count: page.liker.length || 0,
-      updated_at: page.updatedAt,
-    },
-    doc_as_upsert: true,
+  let document = {
+    path: page.path,
+    body: page.revision.body,
+    comment_count: page.commentCount,
+    bookmark_count: page.bookmarkCount || 0,
+    like_count: page.liker.length || 0,
+    updated_at: page.updatedAt,
   };
 
+  document = Object.assign(document, generateDocContentsRelatedToRestriction(page));
+
   body.push(command);
-  body.push(document);
+  body.push({
+    doc: document,
+    doc_as_upsert: true,
+  });
 };
 
 SearchClient.prototype.prepareBodyForCreate = function(body, page) {
@@ -119,25 +186,28 @@ SearchClient.prototype.prepareBodyForCreate = function(body, page) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     index: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
-  var document = {
+  const bookmarkCount = page.bookmarkCount || 0;
+  let document = {
     path: page.path,
     body: page.revision.body,
     username: page.creator.username,
     comment_count: page.commentCount,
-    bookmark_count: 0, // todo
+    bookmark_count: bookmarkCount,
     like_count: page.liker.length || 0,
     created_at: page.createdAt,
     updated_at: page.updatedAt,
   };
 
+  document = Object.assign(document, generateDocContentsRelatedToRestriction(page));
+
   body.push(command);
   body.push(document);
 };
@@ -147,117 +217,121 @@ SearchClient.prototype.prepareBodyForDelete = function(body, page) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     delete: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
   body.push(command);
 };
 
+SearchClient.prototype.addPages = async function(pages) {
+  const Bookmark = this.crowi.model('Bookmark');
+  const body = [];
 
-SearchClient.prototype.addPages = function(pages) {
-  var self = this;
-  var body = [];
-
-  pages.map(function(page) {
-    self.prepareBodyForCreate(body, page);
-  });
+  for (const page of pages) {
+    page.bookmarkCount = await Bookmark.countByPageId(page._id);
+    this.prepareBodyForCreate(body, page);
+  }
 
-  debug('addPages(): Sending Request to ES', body);
+  logger.debug('addPages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
 SearchClient.prototype.updatePages = function(pages) {
-  var self = this;
-  var body = [];
+  let self = this;
+  let body = [];
 
   pages.map(function(page) {
     self.prepareBodyForUpdate(body, page);
   });
 
-  debug('updatePages(): Sending Request to ES', body);
+  logger.debug('updatePages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
 SearchClient.prototype.deletePages = function(pages) {
-  var self = this;
-  var body = [];
+  let self = this;
+  let body = [];
 
   pages.map(function(page) {
     self.prepareBodyForDelete(body, page);
   });
 
-  debug('deletePages(): Sending Request to ES', body);
+  logger.debug('deletePages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
-SearchClient.prototype.addAllPages = function() {
-  var self = this;
-  var Page = this.crowi.model('Page');
-  var cursor = Page.getStreamOfFindAll();
-  var body = [];
-  var sent = 0;
-  var skipped = 0;
-
-  return new Promise(function(resolve, reject) {
-    cursor.on('data', function(doc) {
-      if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
-        //debug('Skipped', doc.path);
-        skipped++;
-        return ;
-      }
-
-      self.prepareBodyForCreate(body, doc);
-      //debug(body.length);
-      if (body.length > 2000) {
-        sent++;
-        debug('Sending request (seq, skipped)', sent, skipped);
-        self.client.bulk({
+SearchClient.prototype.addAllPages = async function() {
+  const self = this;
+  const Page = this.crowi.model('Page');
+  const allPageCount = await Page.allPageCount();
+  const Bookmark = this.crowi.model('Bookmark');
+  const cursor = Page.getStreamOfFindAll();
+  let body = [];
+  let sent = 0;
+  let skipped = 0;
+  let total = 0;
+
+  return new Promise((resolve, reject) => {
+    const bulkSend = body => {
+      self.client
+        .bulk({
           body: body,
           requestTimeout: Infinity,
-        }).then(res => {
-          debug('addAllPages add anyway (items, errors, took): ', (res.items || []).length, res.errors, res.took);
-        }).catch(err => {
-          debug('addAllPages error on add anyway: ', err);
+        })
+        .then(res => {
+          logger.info('addAllPages add anyway (items, errors, took): ', (res.items || []).length, res.errors, res.took, 'ms');
+        })
+        .catch(err => {
+          logger.error('addAllPages error on add anyway: ', err);
         });
+    };
+
+    cursor
+      .eachAsync(async doc => {
+        if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
+          // debug('Skipped', doc.path);
+          skipped++;
+          return;
+        }
+        total++;
 
-        body = [];
-      }
-    }).on('error', function(err) {
-      // TODO: handle err
-      debug('Error cursor:', err);
-    }).on('close', function() {
-      // all done
-
-      // return if body is empty
-      // see: https://github.com/weseek/growi/issues/228
-      if (body.length == 0) {
-        return resolve();
-      }
+        const bookmarkCount = await Bookmark.countByPageId(doc._id);
+        const page = { ...doc, bookmarkCount };
+        self.prepareBodyForCreate(body, page);
+
+        if (body.length >= 4000) {
+          // send each 2000 docs. (body has 2 elements for each data)
+          sent++;
+          logger.debug('Sending request (seq, total, skipped)', sent, total, skipped);
+          bulkSend(body);
+          this.searchEvent.emit('addPageProgress', allPageCount, total, skipped);
 
-      // 最後にすべてを送信
-      self.client.bulk({
-        body: body,
-        requestTimeout: Infinity,
+          body = [];
+        }
       })
-      .then(function(res) {
-        debug('Reponse from es (item length, errros, took):', (res.items || []).length, res.errors, res.took);
-        return resolve(res);
-      }).catch(function(err) {
-        debug('Err from es:', err);
-        return reject(err);
+      .then(() => {
+        // send all remaining data on body[]
+        logger.debug('Sending last body of bulk operation:', body.length);
+        bulkSend(body);
+        this.searchEvent.emit('finishAddPage', allPageCount, total, skipped);
+
+        resolve();
+      })
+      .catch(e => {
+        logger.error('Error wile iterating cursor.eachAsync()', e);
+        reject(e);
       });
-    });
   });
 };
 
@@ -268,46 +342,49 @@ SearchClient.prototype.addAllPages = function() {
  *   data: [ pages ...],
  * }
  */
-SearchClient.prototype.search = function(query) {
-  var self = this;
-
-  return new Promise(function(resolve, reject) {
-    self.client.search(query)
-    .then(function(data) {
-      var result = {
-        meta: {
-          took: data.took,
-          total: data.hits.total,
-          results: data.hits.hits.length,
-        },
-        data: data.hits.hits.map(function(elm) {
-          return {_id: elm._id, _score: elm._score};
-        })
-      };
-
-      resolve(result);
-    }).catch(function(err) {
-      reject(err);
+SearchClient.prototype.search = async function(query) {
+  // for debug
+  if (process.env.NODE_ENV === 'development') {
+    const result = await this.client.indices.validateQuery({
+      explain: true,
+      body: {
+        query: query.body.query
+      },
     });
-  });
+    logger.info('ES returns explanations: ', result.explanations);
+  }
+
+  const result = await this.client.search(query);
+
+  return {
+    meta: {
+      took: result.took,
+      total: result.hits.total,
+      results: result.hits.hits.length,
+    },
+    data: result.hits.hits.map(function(elm) {
+      return { _id: elm._id, _score: elm._score, _source: elm._source };
+    }),
+  };
+
 };
 
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
   // getting path by default is almost for debug
-  var fields = ['path'];
+  let fields = ['path', 'bookmark_count'];
   if (option) {
     fields = option.fields || fields;
   }
 
   // default is only id field, sorted by updated_at
-  var query = {
-    index: this.index_name,
+  let query = {
+    index: this.indexName,
     type: 'pages',
     body: {
-      sort: [{ updated_at: { order: 'desc'}}],
+      sort: [{ updated_at: { order: 'desc' } }],
       query: {}, // query
       _source: fields,
-    }
+    },
   };
   this.appendResultSize(query);
 
@@ -315,20 +392,20 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
 };
 
 SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
-  var fields = ['path'];
+  let fields = ['path', 'bookmark_count'];
   if (option) {
     fields = option.fields || fields;
   }
 
   // sort by score
-  var query = {
-    index: this.index_name,
+  let query = {
+    index: this.indexName,
     type: 'pages',
     body: {
-      sort: [ {_score: { order: 'desc'} }],
+      sort: [{ _score: { order: 'desc' } }],
       query: {}, // query
       _source: fields,
-    }
+    },
   };
   this.appendResultSize(query);
 
@@ -340,21 +417,32 @@ SearchClient.prototype.appendResultSize = function(query, from, size) {
   query.size = size || this.DEFAULT_LIMIT;
 };
 
-SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword) {
+SearchClient.prototype.initializeBoolQuery = function(query) {
   // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
   if (!query.body.query.bool) {
     query.body.query.bool = {};
   }
-  if (!query.body.query.bool.must || !Array.isArray(query.body.query.must)) {
+
+  const isInitialized = query => !!query && Array.isArray(query);
+
+  if (!isInitialized(query.body.query.bool.filter)) {
+    query.body.query.bool.filter = [];
+  }
+  if (!isInitialized(query.body.query.bool.must)) {
     query.body.query.bool.must = [];
   }
-  if (!query.body.query.bool.must_not || !Array.isArray(query.body.query.must_not)) {
+  if (!isInitialized(query.body.query.bool.must_not)) {
     query.body.query.bool.must_not = [];
   }
+  return query;
+};
 
-  var appendMultiMatchQuery = function(query, type, keywords) {
-    var target;
-    var operator = 'and';
+SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword) {
+  query = this.initializeBoolQuery(query);
+
+  const appendMultiMatchQuery = function(query, type, keywords) {
+    let target;
+    let operator = 'and';
     switch (type) {
       case 'not_match':
         target = query.body.query.bool.must_not;
@@ -369,21 +457,15 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
       multi_match: {
         query: keywords.join(' '),
         // TODO: By user's i18n setting, change boost or search target fields
-        fields: [
-          'path_ja^2',
-          'path_en^2',
-          'body_ja',
-          // "path_en",
-          // "body_en",
-        ],
+        fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
         operator: operator,
-      }
+      },
     });
 
     return query;
   };
 
-  var parsedKeywords = this.getParsedKeywords(keyword);
+  let parsedKeywords = this.getParsedKeywords(keyword);
 
   if (parsedKeywords.match.length > 0) {
     query = appendMultiMatchQuery(query, 'match', parsedKeywords.match);
@@ -394,17 +476,18 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
   }
 
   if (parsedKeywords.phrase.length > 0) {
-    var phraseQueries = [];
+    let phraseQueries = [];
     parsedKeywords.phrase.forEach(function(phrase) {
       phraseQueries.push({
         multi_match: {
           query: phrase, // each phrase is quoteted words
           type: 'phrase',
-          fields: [ // Not use "*.ja" fields here, because we want to analyze (parse) search words
-            'path_raw^2',
-            'body_raw',
+          fields: [
+            // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            'path.raw^2',
+            'body',
           ],
-        }
+        },
       });
     });
 
@@ -412,17 +495,18 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
   }
 
   if (parsedKeywords.not_phrase.length > 0) {
-    var notPhraseQueries = [];
+    let notPhraseQueries = [];
     parsedKeywords.not_phrase.forEach(function(phrase) {
       notPhraseQueries.push({
         multi_match: {
           query: phrase, // each phrase is quoteted words
           type: 'phrase',
-          fields: [ // Not use "*.ja" fields here, because we want to analyze (parse) search words
-            'path_raw^2',
-            'body_raw',
+          fields: [
+            // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            'path.raw^2',
+            'body',
           ],
-        }
+        },
       });
     });
 
@@ -431,64 +515,196 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
 };
 
 SearchClient.prototype.appendCriteriaForPathFilter = function(query, path) {
-  // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
-  if (!query.body.query.bool) {
-    query.body.query.bool = {};
-  }
-
-  if (!query.body.query.bool.filter || !Array.isArray(query.body.query.bool.filter)) {
-    query.body.query.bool.filter = [];
-  }
+  query = this.initializeBoolQuery(query);
 
   if (path.match(/\/$/)) {
     path = path.substr(0, path.length - 1);
   }
   query.body.query.bool.filter.push({
     wildcard: {
-      'path': path + '/*'
-    }
+      'path.raw': path + '/*',
+    },
   });
 };
 
-SearchClient.prototype.searchKeyword = function(keyword, option) {
-  /* eslint-disable no-unused-vars */
-  var from = option.offset || null;
-  /* eslint-enable */
-  var query = this.createSearchQuerySortedByScore();
+SearchClient.prototype.filterPagesByViewer = async function(query, user, userGroups) {
+  const Config = this.crowi.model('Config');
+  const config = this.crowi.getConfig();
+
+  // determine User condition
+  const hidePagesRestrictedByOwner = Config.hidePagesRestrictedByOwnerInList(config);
+  user = hidePagesRestrictedByOwner ? user : null;
+
+  // determine UserGroup condition
+  const hidePagesRestrictedByGroup = Config.hidePagesRestrictedByGroupInList(config);
+  if (hidePagesRestrictedByGroup && user != null) {
+    const UserGroupRelation = this.crowi.model('UserGroupRelation');
+    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+  }
+
+  query = this.initializeBoolQuery(query);
+
+  const Page = this.crowi.model('Page');
+  const { GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP } = Page;
+
+  const grantConditions = [
+    { term: { grant: GRANT_PUBLIC } },
+  ];
+
+  if (user == null) {
+    grantConditions.push(
+      { term: { grant: GRANT_RESTRICTED } },
+      { term: { grant: GRANT_SPECIFIED } },
+      { term: { grant: GRANT_OWNER } },
+    );
+  }
+  else {
+    grantConditions.push(
+      { bool: {
+        must: [
+          { term: { grant: GRANT_RESTRICTED } },
+          { term: { granted_users: user._id.toString() } }
+        ]
+      } },
+      { bool: {
+        must: [
+          { term: { grant: GRANT_SPECIFIED } },
+          { term: { granted_users: user._id.toString() } }
+        ]
+      } },
+      { bool: {
+        must: [
+          { term: { grant: GRANT_OWNER } },
+          { term: { granted_users: user._id.toString() } }
+        ]
+      } },
+    );
+  }
+
+  if (userGroups == null) {
+    grantConditions.push(
+      { term: { grant: GRANT_USER_GROUP } },
+    );
+  }
+  else if (userGroups.length > 0) {
+    const userGroupIds = userGroups.map(group => group._id.toString() );
+    grantConditions.push(
+      { bool: {
+        must: [
+          { term: { grant: GRANT_USER_GROUP } },
+          { terms: { granted_group: userGroupIds } }
+        ]
+      } },
+    );
+  }
+
+  query.body.query.bool.filter.push({ bool: { should: grantConditions } });
+};
+
+SearchClient.prototype.filterPortalPages = function(query) {
+  query = this.initializeBoolQuery(query);
+
+  query.body.query.bool.must_not.push(this.queries.USER);
+  query.body.query.bool.filter.push(this.queries.PORTAL);
+};
+
+SearchClient.prototype.filterPublicPages = function(query) {
+  query = this.initializeBoolQuery(query);
+
+  query.body.query.bool.must_not.push(this.queries.USER);
+  query.body.query.bool.filter.push(this.queries.PUBLIC);
+};
+
+SearchClient.prototype.filterUserPages = function(query) {
+  query = this.initializeBoolQuery(query);
+
+  query.body.query.bool.filter.push(this.queries.USER);
+};
+
+SearchClient.prototype.filterPagesByType = function(query, type) {
+  const Page = this.crowi.model('Page');
+
+  switch (type) {
+    case Page.TYPE_PORTAL:
+      return this.filterPortalPages(query);
+    case Page.TYPE_PUBLIC:
+      return this.filterPublicPages(query);
+    case Page.TYPE_USER:
+      return this.filterUserPages(query);
+    default:
+      return query;
+  }
+};
+
+SearchClient.prototype.appendFunctionScore = function(query) {
+  const User = this.crowi.model('User');
+  const count = User.count({}) || 1;
+  // newScore = oldScore + log(1 + factor * 'bookmark_count')
+  query.body.query = {
+    function_score: {
+      query: { ...query.body.query },
+      field_value_factor: {
+        field: 'bookmark_count',
+        modifier: 'log1p',
+        factor: 10000 / count,
+        missing: 0,
+      },
+      boost_mode: 'sum',
+    },
+  };
+};
+
+SearchClient.prototype.searchKeyword = async function(keyword, user, userGroups, option) {
+  const from = option.offset || null;
+  const size = option.limit || null;
+  const type = option.type || null;
+  const query = this.createSearchQuerySortedByScore();
   this.appendCriteriaForKeywordContains(query, keyword);
 
+  this.filterPagesByType(query, type);
+  await this.filterPagesByViewer(query, user, userGroups);
+
+  this.appendResultSize(query, from, size);
+
+  this.appendFunctionScore(query);
+
   return this.search(query);
 };
 
-SearchClient.prototype.searchByPath = function(keyword, prefix) {
+SearchClient.prototype.searchByPath = async function(keyword, prefix) {
   // TODO path 名だけから検索
 };
 
-SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option) {
-  var from = option.offset || null;
-  var query = this.createSearchQuerySortedByScore();
+SearchClient.prototype.searchKeywordUnderPath = async function(keyword, path, user, userGroups, option) {
+  const from = option.offset || null;
+  const size = option.limit || null;
+  const type = option.type || null;
+  const query = this.createSearchQuerySortedByScore();
   this.appendCriteriaForKeywordContains(query, keyword);
   this.appendCriteriaForPathFilter(query, path);
 
-  if (from) {
-    this.appendResultSize(query, from);
-  }
+  this.filterPagesByType(query, type);
+  await this.filterPagesByViewer(query, user, userGroups);
+
+  this.appendResultSize(query, from, size);
+
+  this.appendFunctionScore(query);
 
   return this.search(query);
 };
 
 SearchClient.prototype.getParsedKeywords = function(keyword) {
-  var matchWords = [];
-  var notMatchWords = [];
-  var phraseWords = [];
-  var notPhraseWords = [];
+  let matchWords = [];
+  let notMatchWords = [];
+  let phraseWords = [];
+  let notPhraseWords = [];
 
   keyword.trim();
   keyword = keyword.replace(/\s+/g, ' ');
 
   // First: Parse phrase keywords
-  var phraseRegExp = new RegExp(/(-?"[^"]+")/g);
-  var phrases = keyword.match(phraseRegExp);
+  let phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+  let phrases = keyword.match(phraseRegExp);
 
   if (phrases !== null) {
     keyword = keyword.replace(phraseRegExp, '');
@@ -511,7 +727,7 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
     }
 
     if (word.match(/^-(.+)$/)) {
-      notMatchWords.push((RegExp.$1));
+      notMatchWords.push(RegExp.$1);
     }
     else {
       matchWords.push(word);
@@ -526,58 +742,70 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
   };
 };
 
-SearchClient.prototype.syncPageCreated = function(page, user) {
+SearchClient.prototype.syncPageCreated = function(page, user, bookmarkCount = 0) {
   debug('SearchClient.syncPageCreated', page.path);
 
   if (!this.shouldIndexed(page)) {
-    return ;
+    return;
   }
 
+  page.bookmarkCount = bookmarkCount;
   this.addPages([page])
-  .then(function(res) {
-    debug('ES Response', res);
-  })
-  .catch(function(err) {
-    debug('ES Error', err);
-  });
+    .then(function(res) {
+      debug('ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('ES Error', err);
+    });
 };
 
-SearchClient.prototype.syncPageUpdated = function(page, user) {
+SearchClient.prototype.syncPageUpdated = function(page, user, bookmarkCount = 0) {
   debug('SearchClient.syncPageUpdated', page.path);
   // TODO delete
   if (!this.shouldIndexed(page)) {
     this.deletePages([page])
-    .then(function(res) {
-      debug('deletePages: ES Response', res);
-    })
-    .catch(function(err) {
-      debug('deletePages:ES Error', err);
-    });
+      .then(function(res) {
+        debug('deletePages: ES Response', res);
+      })
+      .catch(function(err) {
+        logger.error('deletePages:ES Error', err);
+      });
 
-    return ;
+    return;
   }
 
+  page.bookmarkCount = bookmarkCount;
   this.updatePages([page])
-  .then(function(res) {
-    debug('ES Response', res);
-  })
-  .catch(function(err) {
-    debug('ES Error', err);
-  });
+    .then(function(res) {
+      debug('ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('ES Error', err);
+    });
 };
 
 SearchClient.prototype.syncPageDeleted = function(page, user) {
   debug('SearchClient.syncPageDeleted', page.path);
 
   this.deletePages([page])
-  .then(function(res) {
-    debug('deletePages: ES Response', res);
-  })
-  .catch(function(err) {
-    debug('deletePages:ES Error', err);
-  });
+    .then(function(res) {
+      debug('deletePages: ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('deletePages:ES Error', err);
+    });
+};
 
-  return ;
+SearchClient.prototype.syncBookmarkChanged = async function(pageId) {
+  const Page = this.crowi.model('Page');
+  const Bookmark = this.crowi.model('Bookmark');
+  const page = await Page.findPageById(pageId);
+  const bookmarkCount = await Bookmark.countByPageId(pageId);
+
+  page.bookmarkCount = bookmarkCount;
+  this.updatePages([page])
+    .then(res => debug('ES Response', res))
+    .catch(err => logger.error('ES Error', err));
 };
 
 module.exports = SearchClient;

+ 26 - 0
src/server/util/swigFunctions.js

@@ -5,6 +5,7 @@ module.exports = function(crowi, app, req, locals) {
     , Config = crowi.model('Config')
     , User = crowi.model('User')
     , passportService = crowi.passportService
+    , cdnResourcesService = crowi.cdnResourcesService
   ;
 
   debug('initializing swigFunctions');
@@ -77,6 +78,31 @@ module.exports = function(crowi, app, req, locals) {
     return Config.globalLang(config);
   };
 
+  locals.noCdn = function() {
+    return !!process.env.NO_CDN;
+  };
+
+  locals.cdnScriptTag = function(name) {
+    return cdnResourcesService.getScriptTagByName(name);
+  };
+  locals.cdnScriptTagsByGroup = function(group) {
+    const tags = cdnResourcesService.getScriptTagsByGroup(group);
+    return tags.join('\n');
+  };
+
+  locals.cdnStyleTag = function(name) {
+    return cdnResourcesService.getStyleTagByName(name);
+  };
+
+  locals.cdnStyleTagsByGroup = function(group) {
+    const tags = cdnResourcesService.getStyleTagsByGroup(group);
+    return tags.join('\n');
+  };
+
+  locals.cdnHighlightJsStyleTag = function(styleName) {
+    return cdnResourcesService.getHighlightJsStyleTag(styleName);
+  };
+
   /**
    * return true if enabled
    */

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

@@ -17,8 +17,8 @@
 
   <div id="save-page-controls"
     data-grant="{{ page.grant }}"
-    data-grant-group="{{ pageRelatedGroup._id.toString() }}"
-    data-grant-group-name="{{ pageRelatedGroup.name }}">
+    data-grant-group="{{ page.grantedGroup._id.toString() }}"
+    data-grant-group-name="{{ page.grantedGroup.name }}">
   </div>
 
 </div>

+ 84 - 80
src/server/views/admin/customize.html

@@ -25,7 +25,7 @@
 {% block html_additional_headers %}
   {% parent %}
   <!-- CodeMirror -->
-  <link rel="stylesheet" href="https://cdn.jsdelivr.net/jquery.ui/1.11.4/jquery-ui.min.css">
+  {{ cdnStyleTag('jquery-ui') }}
   <style>
     .CodeMirror {
       border: 1px solid #eee;
@@ -141,6 +141,7 @@
             {% include 'widget/theme-colorbox.html' with { name: 'mono-blue',   bg: '#F7FBFD', topbar: '#00587A', theme: '#00587A'} %}
             {% include 'widget/theme-colorbox.html' with { name: 'wood',   bg: '#fffefb', topbar: '#aaa45f', theme: '#dddebf'} %}
             {% include 'widget/theme-colorbox.html' with { name: 'island',   bg: '#8ecac0', topbar: '#0c2a44', theme: '#cef2ef'} %}
+            {% include 'widget/theme-colorbox.html' with { name: 'christmas',   bg: '#fffefb', topbar: '#b3000c', theme: '#017e20'} %}
           </div>
           {# Dark Themes #}
           <div class="d-flex mt-3">
@@ -216,96 +217,96 @@
       </form>
 
       <form action="/_api/admin/customize/features" method="post" class="form-horizontal" id="customfeaturesSettingForm" role="form">
-      <fieldset>
-      <legend>{{ t('customize_page.Function') }}</legend>
-        <p class="well">{{ t("customize_page.function_choose") }}</p>
+        <fieldset>
+        <legend>{{ t('customize_page.Function') }}</legend>
+          <p class="well">{{ t("customize_page.function_choose") }}</p>
 
-        <div class="form-group">
-          <label for="settingForm[customize:isEnabledTimeline]" class="col-xs-3 control-label">{{ t('customize_page.Timeline function') }}</label>
-          <div class="col-xs-9">
-            <div class="btn-group btn-toggle" data-toggle="buttons">
-              <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isEnabledTimeline'] %}active{% endif %}" data-active-class="primary">
-                <input name="settingForm[customize:isEnabledTimeline]" value="true" type="radio"
-                    {% if true === settingForm['customize:isEnabledTimeline'] %}checked{% endif %}> ON
-              </label>
-              <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isEnabledTimeline'] %}active{% endif %}" data-active-class="default">
-                <input name="settingForm[customize:isEnabledTimeline]" value="false" type="radio"
-                    {% if !settingForm['customize:isEnabledTimeline'] %}checked{% endif %}> OFF
-              </label>
-            </div>
+          <div class="form-group">
+            <label for="settingForm[customize:isEnabledTimeline]" class="col-xs-3 control-label">{{ t('customize_page.Timeline function') }}</label>
+            <div class="col-xs-9">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isEnabledTimeline'] %}active{% endif %}" data-active-class="primary">
+                  <input name="settingForm[customize:isEnabledTimeline]" value="true" type="radio"
+                      {% if true === settingForm['customize:isEnabledTimeline'] %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isEnabledTimeline'] %}active{% endif %}" data-active-class="default">
+                  <input name="settingForm[customize:isEnabledTimeline]" value="false" type="radio"
+                      {% if !settingForm['customize:isEnabledTimeline'] %}checked{% endif %}> OFF
+                </label>
+              </div>
 
-            <p class="help-block">
-              {{ t("customize_page.subpage_display") }}
-            </p>
-            <p class="help-block">
-              {{ t("customize_page.performance_decrease") }}<br>
-              {{ t("customize_page.list_page_display") }}
-            </p>
+              <p class="help-block">
+                {{ t("customize_page.subpage_display") }}
+              </p>
+              <p class="help-block">
+                {{ t("customize_page.performance_decrease") }}<br>
+                {{ t("customize_page.list_page_display") }}
+              </p>
+            </div>
           </div>
-        </div>
 
-        <div class="form-group">
-          <label for="settingForm[customize:isSavedStatesOfTabChanges]" class="col-xs-3 control-label">{{ t("customize_page.tab_switch") }}</label>
-          <div class="col-xs-9">
-            <div class="btn-group btn-toggle" data-toggle="buttons">
-              <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isSavedStatesOfTabChanges'] %}active{% endif %}" data-active-class="primary">
-                <input name="settingForm[customize:isSavedStatesOfTabChanges]" value="true" type="radio"
-                    {% if true === settingForm['customize:isSavedStatesOfTabChanges'] %}checked{% endif %}> ON
-              </label>
-              <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isSavedStatesOfTabChanges'] %}active{% endif %}" data-active-class="default">
-                <input name="settingForm[customize:isSavedStatesOfTabChanges]" value="false" type="radio"
-                    {% if !settingForm['customize:isSavedStatesOfTabChanges'] %}checked{% endif %}> OFF
-              </label>
-            </div>
+          <div class="form-group">
+            <label for="settingForm[customize:isSavedStatesOfTabChanges]" class="col-xs-3 control-label">{{ t("customize_page.tab_switch") }}</label>
+            <div class="col-xs-9">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isSavedStatesOfTabChanges'] %}active{% endif %}" data-active-class="primary">
+                  <input name="settingForm[customize:isSavedStatesOfTabChanges]" value="true" type="radio"
+                      {% if true === settingForm['customize:isSavedStatesOfTabChanges'] %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isSavedStatesOfTabChanges'] %}active{% endif %}" data-active-class="default">
+                  <input name="settingForm[customize:isSavedStatesOfTabChanges]" value="false" type="radio"
+                      {% if !settingForm['customize:isSavedStatesOfTabChanges'] %}checked{% endif %}> OFF
+                </label>
+              </div>
 
-            <p class="help-block">
-              {{ t("customize_page.save_edit") }}<br>
-              {{ t("customize_page.by_invalidating") }}
-            </p>
+              <p class="help-block">
+                {{ t("customize_page.save_edit") }}<br>
+                {{ t("customize_page.by_invalidating") }}
+              </p>
+            </div>
           </div>
-        </div>
 
-        <div class="form-group">
-          <label for="settingForm[customize:isEnabledAttachTitleHeader]" class="col-xs-3 control-label">{{ t("customize_page.attach_title_header") }}</label>
-          <div class="col-xs-9">
-            <div class="btn-group btn-toggle" data-toggle="buttons">
-              <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isEnabledAttachTitleHeader'] %}active{% endif %}" data-active-class="primary">
-                <input name="settingForm[customize:isEnabledAttachTitleHeader]" value="true" type="radio" {% if true===settingForm['customize:isEnabledAttachTitleHeader'] %}checked{% endif %}> ON
-              </label>
-              <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isEnabledAttachTitleHeader'] %}active{% endif %}" data-active-class="default">
-                <input name="settingForm[customize:isEnabledAttachTitleHeader]" value="false" type="radio" {% if !settingForm['customize:isEnabledAttachTitleHeader'] %}checked{% endif %}> OFF
-              </label>
-            </div>
+          <div class="form-group">
+            <label for="settingForm[customize:isEnabledAttachTitleHeader]" class="col-xs-3 control-label">{{ t("customize_page.attach_title_header") }}</label>
+            <div class="col-xs-9">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isEnabledAttachTitleHeader'] %}active{% endif %}" data-active-class="primary">
+                  <input name="settingForm[customize:isEnabledAttachTitleHeader]" value="true" type="radio" {% if true===settingForm['customize:isEnabledAttachTitleHeader'] %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isEnabledAttachTitleHeader'] %}active{% endif %}" data-active-class="default">
+                  <input name="settingForm[customize:isEnabledAttachTitleHeader]" value="false" type="radio" {% if !settingForm['customize:isEnabledAttachTitleHeader'] %}checked{% endif %}> OFF
+                </label>
+              </div>
 
-            <p class="help-block">
-              {{ t("customize_page.attach_title_header_desc") }}
-            </p>
+              <p class="help-block">
+                {{ t("customize_page.attach_title_header_desc") }}
+              </p>
+            </div>
           </div>
-        </div>
 
-        <div class="form-group">
-          <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.show_document_number") }}</label>
-          <div class="col-xs-5">
-            <select class="form-control selectpicker" name="settingForm[customize:showRecentCreatedNumber]" value="{{ settingForm['customize:showRecentCreatedNumber'] }}">
-              <option value="10" {% if 10 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>10</option>
-              <option value="30" {% if 30 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>30</option>
-              <option value="50" {% if 50 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>50</option>
-            </select>
-
-            <p class="help-block">
-              {{ t("customize_page.show_document_number_desc") }}
-            </p>
+          <div class="form-group">
+            <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.recent_created_page_num") }}</label>
+            <div class="col-xs-5">
+              <select class="form-control selectpicker" name="settingForm[customize:showRecentCreatedNumber]" value="{{ settingForm['customize:showRecentCreatedNumber'] }}">
+                <option value="10" {% if 10 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>10</option>
+                <option value="30" {% if 30 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>30</option>
+                <option value="50" {% if 50 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>50</option>
+              </select>
+
+              <p class="help-block">
+                {{ t("customize_page.recent_created_page_num_desc") }}
+              </p>
+            </div>
           </div>
-        </div>
 
-        <div class="form-group">
-          <div class="col-xs-offset-3 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+          <div class="form-group">
+            <div class="col-xs-offset-3 col-xs-6">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+            </div>
           </div>
-        </div>
 
-      </fieldset>
+        </fieldset>
       </form>
 
       <form action="/_api/admin/customize/highlightJsStyle" method="post" class="form-horizontal" id="customhighlightJsStyleSettingForm" role="form">
@@ -314,11 +315,12 @@
           <div class="form-group">
             <label for="settingForm[customize:highlightJsStyle]" class="col-xs-3 control-label">{{ t('customize_page.Theme') }}</label>
             <div class="col-xs-9">
-              <select class="form-control selectpicker" name="settingForm[customize:highlightJsStyle]" onChange="selectHighlightJsStyle(event)">
+              <select class="form-control selectpicker" name="settingForm[customize:highlightJsStyle]" onChange="selectHighlightJsStyle(event)" {% if noCdn() %}disabled{% endif %}>
                 {% for key in Object.keys(highlightJsCssSelectorOptions) %}
                   <option value={{key}} {% if key == highlightJsStyle() %} selected {% endif %}>{{highlightJsCssSelectorOptions[key].name}}</option>
                 {% endfor %}
               </select>
+              <p class="help-block text-warning">{{ t('customize_page.nocdn_desc') }}</p>
             </div>
           </div>
 
@@ -338,7 +340,9 @@
             </div>
           </div>
 
-          <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/{{ highlightJsStyle() }}.css" class="highlightJsCss">
+          <div id="highlightJsCssContainer">
+            {{ cdnHighlightJsStyleTag(highlightJsStyle()) }}
+          </div>
 
           <p class="help-block">
             Examples:
@@ -590,7 +594,7 @@ window.addEventListener('load', (event) => {
     hljs.initHighlightingOnLoad()
 
     function selectHighlightJsStyle(event) {
-      var highlightJsCssDOM = $(".highlightJsCss")[0]
+      var highlightJsCssDOM = $("#highlightJsCssContainer link")[0]
       // selected value
       var val = event.target.value
       // replace css url

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

@@ -45,12 +45,16 @@
         </div>
         {% endif %}
 
-        <form action="/admin/search/build" method="post" class="form-horizontal" id="appSettingForm" role="form">
+        <form action="/_api/admin/search/build" method="post" class="form-horizontal" id="buildIndexForm" role="form">
           <fieldset>
             <legend>Index Build</legend>
             <div class="form-group">
               <label for="" class="col-xs-3 control-label">Index Build</label>
               <div class="col-xs-6">
+
+                <div id="admin-rebuild-search">
+                </div>
+
                 <button type="submit" class="btn btn-inverse">Build Now</button>
                 <p class="help-block">
                   Force rebuild index.<br>
@@ -67,6 +71,72 @@
   </div>
 
 </div>
+
+<script>
+  /**
+   * show flash message
+   */
+  function showMessage(formId, msg, status) {
+    $('#' + formId + ' .alert').remove();
+
+    if (!status) {
+      status = 'success';
+    }
+    var $message = $('<p class="alert"></p>');
+    $message.addClass('alert-' + status);
+    $message.html(msg.replace('\n', '<br>'));
+    $message.insertAfter('#' + formId + ' legend');
+
+    if (status == 'success') {
+      setTimeout(function()
+      {
+        $message.fadeOut({
+          complete: function() {
+            $message.remove();
+          }
+        });
+      }, 5000);
+    }
+  }
+
+  /**
+   * Post form data and process UI
+   */
+  function postData(form, button, action) {
+    var id = form.attr('id');
+    button.attr('disabled', 'disabled');
+    var jqxhr = $.post(action, form.serialize(), function(res)
+      {
+        if (!res.ok) {
+          showMessage(id, `Error: ${res.message}`, 'danger');
+        }
+        else {
+          showMessage(id, 'Building request is successfully posted.');
+        }
+      })
+      .fail(function() {
+        showMessage(id, "エラーが発生しました", 'danger');
+      })
+      .always(function() {
+        button.prop('disabled', false);
+      });
+    return false;
+  }
+
+  /**
+   * Handle submit button esa
+   */
+  $('#buildIndexForm').each(function() {
+    var $form = $(this);
+    var $button = $("#buildIndexForm" + $(this).attr('name') + " button[type='submit']");
+    var $action = $form.attr('action');
+    var $success_msg = $button.attr('data-success-message');
+    var $error_msg = $button.attr('data-error-message');
+    $form.submit(function() { return postData($form, $button, $action, $success_msg, $error_msg) });
+  });
+
+</script>
+
 {% endblock content_main %}
 
 {% block content_footer %}

+ 45 - 3
src/server/views/admin/security.html

@@ -51,7 +51,7 @@
               <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}" {% if not isAclEnabled  %}readonly{% endif%}>
             </div>
             <div class="col-xs-offset-3 col-xs-9">
-              <p class="help-block">
+              <p class="help-block small">
                 {% if not isAclEnabled %}
                   {{ t("security_setting.basic_acl_disable") }}<br>
                 {% else %}
@@ -81,7 +81,7 @@
                 <option value="{{ t(modeValue) }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
-              <p class="help-block">{{ t('The contents entered here will be shown in the header etc') }}</p>
+              <p class="help-block small">{{ t('The contents entered here will be shown in the header etc') }}</p>
             </div>
           </div>
 
@@ -89,11 +89,53 @@
             <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">{{ t('The whitelist of registration permission E-mail address') }}</label>
             <div class="col-xs-8">
               <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @growi.org">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
-              <p class="help-block">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
+              <p class="help-block small">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
               {{ t("security_setting.insert_single") }}</p>
             </div>
           </div>
 
+          <div class="form-group">
+            {% set configName = 'settingForm[security:list-policy:hideRestrictedByOwner]' %}
+            {% set configValue = settingForm['security:list-policy:hideRestrictedByOwner'] %}
+            {% set isEnabled = !configValue %}
+            <label for="{{configName}}" class="col-xs-3 control-label">{{ t("security_setting.page_listing_1") }}</label>
+            <div class="col-xs-9">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if isEnabled %}active{% endif %}" data-active-class="primary">
+                  <input name="{{configName}}" value="false" type="radio" {% if isEnabled %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !isEnabled %}active{% endif %}" data-active-class="default">
+                  <input name="{{configName}}" value="true" type="radio" {% if !isEnabled %}checked{% endif %}> OFF
+                </label>
+              </div>
+
+              <p class="help-block small">
+                {{ t("security_setting.page_listing_1_desc") }}
+              </p>
+            </div>
+          </div>
+
+          <div class="form-group">
+            {% set configName = 'settingForm[security:list-policy:hideRestrictedByGroup]' %}
+            {% set configValue = settingForm['security:list-policy:hideRestrictedByGroup'] %}
+            {% set isEnabled = !configValue %}
+            <label for="{{configName}}" class="col-xs-3 control-label">{{ t("security_setting.page_listing_2") }}</label>
+            <div class="col-xs-9">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if isEnabled %}active{% endif %}" data-active-class="primary">
+                  <input name="{{configName}}" value="false" type="radio" {% if isEnabled %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !isEnabled %}active{% endif %}" data-active-class="default">
+                  <input name="{{configName}}" value="true" type="radio" {% if !isEnabled %}checked{% endif %}> OFF
+                </label>
+              </div>
+
+              <p class="help-block small">
+                {{ t("security_setting.page_listing_2_desc") }}
+              </p>
+            </div>
+          </div>
+
           <div class="form-group">
             <div class="col-xs-offset-3 col-xs-6">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">

+ 5 - 33
src/server/views/installer.html

@@ -44,44 +44,16 @@
       </div>
     </div>
 
-    <div class="login-dialog p-t-10 p-b-10 col-sm-offset-4 col-sm-4" id="login-dialog">
-      <p class="alert alert-success">
-        <strong>{{ t('installer.create_initial_account') }}</strong><br>
-        <small>{{ t('installer.initial_account_will_be_administrator_automatically') }}</small>
-      </p>
-
-      <div id='installer-form'
-        data-user-name="{{ req.body.registerForm.username }}"
-        data-name="{{ googleName|default(req.body.registerForm.name) }}"
-        data-email="{{ googleEmail|default(req.body.registerForm.email) }}"
-        data-csrf="{{ csrf() }}">
-      </div>
+    <div id='installer-form'
+      data-user-name="{{ req.body.registerForm.username }}"
+      data-name="{{ googleName|default(req.body.registerForm.name) }}"
+      data-email="{{ googleEmail|default(req.body.registerForm.email) }}"
+      data-csrf="{{ csrf() }}">
     </div>
 
   </div>{# /.row #}
 
 </div>{# /.main #}
 
-<script>
-$(function() {
-  $('#register-form input[name="registerForm[username]"]').change(function(e) {
-    var username = $(this).val();
-    $('#login-dialog').removeClass('has-error');
-    $('#input-group-username').removeClass('has-error');
-    $('#help-block-username').html("");
-
-    $.getJSON('/_api/check_username', {username: username}, function(json) {
-      if (!json.valid) {
-        $('#help-block-username').html(
-          '<i class="icon-fw icon-ban"></i>{{ t("installer.unavaliable_user_id") }}'
-        );
-        $('#login-dialog').addClass('has-error');
-        $('#input-group-username').addClass('has-error');
-      }
-    });
-  });
-});
-</script>
-
 {% endblock %}
 

+ 5 - 0
src/server/views/layout-crowi/base/layout.html

@@ -2,6 +2,11 @@
 
 {% block html_title %}{{ customTitle(path) }}{% endblock %}
 
+{% block html_additional_headers %}
+  {% parent %}
+  {{ cdnScriptTag('highlight-addons') }}
+{% endblock %}
+
 {% block layout_main %}
 <div class="container-fluid">
 

+ 1 - 1
src/server/views/layout-crowi/page_list.html

@@ -63,7 +63,7 @@
     {% include '../widget/page_content.html' %}
   </div>
 
-  <div class="row page-list hidden-print {% if isPortal %}m-t-30{% endif %}">
+  <div class="row page-list hidden-print {% if page.isPortal() %}m-t-30{% endif %}">
     <div class="col-md-12">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 5 - 0
src/server/views/layout-growi/base/layout.html

@@ -1,5 +1,10 @@
 {% extends '../../layout/layout.html' %}
 
+{% block html_additional_headers %}
+  {% parent %}
+  {{ cdnScriptTag('highlight-addons') }}
+{% endblock %}
+
 {% block layout_main %}
 <div class="container-fluid">
 

+ 1 - 1
src/server/views/layout-growi/page_list.html

@@ -33,7 +33,7 @@
 
   </div>
 
-  <div class="row page-list hidden-print {% if isPortal %}m-t-30{% endif %}">
+  <div class="row page-list hidden-print {% if page.isPortal() %}m-t-30{% endif %}">
     <div class="col-md-10">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 5 - 0
src/server/views/layout-kibela/base/layout.html

@@ -1,5 +1,10 @@
 {% extends '../../layout/layout.html' %}
 
+{% block html_additional_headers %}
+  {% parent %}
+  {{ cdnScriptTag('highlight-addons') }}
+{% endblock %}
+
 {% block layout_main %}
 <div class="container-fluid">
 

+ 1 - 1
src/server/views/layout-kibela/page_list.html

@@ -35,7 +35,7 @@
 
 
 
-  <div class="row page-list bg-white round-corner p-t-10 m-20 m-b-30 {% if isPortal %}m-t-30{% endif %}">
+  <div class="row page-list bg-white round-corner p-t-10 m-20 m-b-30 {% if page.isPortal() %}m-t-30{% endif %}">
     <div class="col-xs-12">
       {% include '../widget/page_list_and_timeline_kibela.html' %}
     </div>

+ 4 - 27
src/server/views/layout/layout.html

@@ -27,20 +27,7 @@
     }
   </script>
 
-  <!-- jQuery, emojione, bootstrap -->
-  <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1,npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"></script>
-  <!-- highlight.js -->
-  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
-  <script src="https://cdn.jsdelivr.net/combine/
-gh/highlightjs/cdn-release@9.12.0/build/languages/dockerfile.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/go.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/gradle.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/json.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/less.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/scss.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/typescript.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
-" defer></script>
+  {{ cdnScriptTagsByGroup('basis') }}
 
   {% if local_config.env.MATHJAX %}
     <!-- Mathjax -->
@@ -58,7 +45,7 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
         messageStyle: "none"
       });
     </script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js" async></script>
+    {{ cdnScriptTag('mathjax') }}
   {% endif %}
 
   {% if env === 'development' %}
@@ -103,18 +90,8 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
     {% endif %}
   {% endblock %}
 
-  <!-- Google Fonts -->
-  <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
-  <!-- Font Awesome -->
-  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
-  <!-- Themify Icons -->
-  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cd-themify-icons@0.0.1/index.min.css">
-  <!-- Simple Line icons -->
-  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/simple-line-icons@2.4.1/css/simple-line-icons.min.css">
-  <!-- emojione -->
-  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css">
-  <!-- highlight.js -->
-  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/{{ highlightJsStyle() }}.css">
+  {{ cdnStyleTagsByGroup('basis') }}
+  {{ cdnHighlightJsStyleTag(highlightJsStyle()) }}
 
   {% block html_additional_headers %}{% endblock %}
 

+ 2 - 16
src/server/views/page_presentation.html

@@ -19,20 +19,7 @@
       }
     </script>
 
-    <!-- jQuery, emojione (expect to hit the cache) -->
-    <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
-    <!-- highlight.js -->
-    <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
-    <script src="https://cdn.jsdelivr.net/combine/
-gh/highlightjs/cdn-release@9.12.0/build/languages/dockerfile.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/go.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/gradle.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/json.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/less.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/scss.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/typescript.min.js,
-gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
-" defer></script>
+    {{ cdnScriptTagsByGroup('basis') }}
 
     {% if env === 'development' %}
       <script src="/dll/dll.js"></script>
@@ -46,8 +33,7 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
     <!-- styles -->
     <link rel="stylesheet" href="{{ webpack_asset('styles/style-presentation.css') }}">
 
-    <!-- Google Fonts -->
-    <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
+    {{ cdnStyleTagsByGroup('basis') }}
 
     <style>
       {{ customCss() }}

+ 5 - 0
src/server/views/search.html

@@ -1,5 +1,10 @@
 {% extends 'layout/layout.html' %}
 
+{% block html_additional_headers %}
+  {% parent %}
+  {{ cdnScriptTag('highlight-addons') }}
+{% endblock %}
+
 {% block html_base_attr %}
   data-spy="scroll"
   data-target="#search-result-list"

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

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

+ 0 - 1
src/server/views/widget/forbidden_content.html

@@ -9,7 +9,6 @@
 
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path | preventXss }}"
-  data-path-shortname="{{ path|path2name | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   >
 

+ 0 - 1
src/server/views/widget/not_found_content.html

@@ -9,7 +9,6 @@
 
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path | preventXss }}"
-  data-path-shortname="{{ path|path2name | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   >
 

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

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

+ 14 - 17
src/server/views/widget/page_alerts.html

@@ -7,19 +7,21 @@
       {% elseif page.grant == 4 %}
         <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
       {% elseif page.grant == 5 %}
-        <i class="icon-fw icon-organization"></i><strong>'{{ pageRelatedGroup.name | preventXss }}' only</strong> ({{ t('Browsing of this page is restricted') }})
+        <i class="icon-fw icon-organization"></i><strong>'{{ page.grantedGroup.name | preventXss }}' only</strong> ({{ t('Browsing of this page is restricted') }})
       {% endif %}
       </p>
     {% endif %}
 
-    {% if page.isDeleted() %}
+    {% if isTrashPage() %}
     <div class="alert alert-warning alert-trash d-flex align-items-center justify-content-between">
       <div>
         <i class="icon-trash" aria-hidden="true"></i>
-        This page is in the trash.<br>
-        Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm img-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+        This page is in the trash.
+        {% if page.isDeleted() %}
+        <br>Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm img-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+        {% endif %}
       </div>
-      {% if user %}
+      {% if page.isDeleted() and user %}
       <ul class="list-inline">
         <li>
           <a href="#" class="btn btn-default btn-rounded btn-sm" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</a>
@@ -32,24 +34,19 @@
     </div>
     {% endif %}
 
-    {% if req.query.renamed and not page.isDeleted() %}
-    <div class="alert alert-info alert-moved">
-      <span>
-        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.renamed)) }}
-      </span>
-    </div>
-    {% endif %}
-
-    {% if req.query.redirectFrom and not page.isDeleted() %}
+    {% if not page.isDeleted() and (req.query.renamed or req.query.redirectFrom) %}
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
       <span>
-        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.redirectFrom)) }}
+        {% if req.query.renamed %}
+          <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.renamed)) }}
+        {% else %}
+          <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.redirectFrom)) }}
+        {% endif %}
       </span>
       {% if user %}
       <form role="form" id="unlink-page-form" onsubmit="return false;">
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
-        <input type="hidden" name="path" value="{{ page.path }}">
-        <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+        <input type="hidden" name="path" value="{{ path }}">
         <button type="submit" class="btn btn-default btn-sm pull-right">
           <i class="ti-unlink" aria-hidden="true"></i>
           Unlink

+ 12 - 2
src/server/views/widget/page_content.html

@@ -1,8 +1,8 @@
+{% if page %}
 <div id="content-main" class="content-main"
   data-path="{{ path }}"
-  data-path-shortname="{{ path|path2name }}"
-  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-revision-id-hackmd-synced="{% if revisionHackmdSynced %}{{ revisionHackmdSynced.toString() }}{% endif %}"
@@ -11,6 +11,13 @@
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   >
+{% else %}
+<div id="content-main" class="content-main"
+  data-path="{{ path }}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-slack-channels="{{ slack|default('') }}"
+  >
+{% endif %}
 
   {% include 'page_alerts.html' %}
 
@@ -33,6 +40,9 @@
         </div>
         <div id="page" class="m-t-15"></div>
       </div>
+    {% elseif 'crowi' === behaviorType() %}
+      <div class="tab-pane active" id="cancel-creating-portal">
+      </div>
     {% endif %}
 
     {% if not page.isDeleted() %}

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

@@ -11,8 +11,7 @@
   <img src="{{ page.lastUpdateUser|picture }}" class="picture img-circle">
   <a href="{{ page.path }}"
     class="page-list-link"
-    data-path="{{ page.path }}"
-    data-short-path="{{ page.path|path2name }}">{{ decodeURIComponent(page.path) }}
+    data-path="{{ page.path }}">{{ decodeURIComponent(page.path) }}
   </a>
   <span class="page-list-meta">
     {% if page.isPortal() %}

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

@@ -26,7 +26,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane m-t-30" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-id="{{ page.id }}" data-page-path="{{ page.path }}" data-revision="{{ page.revision.toString() }}">
         <div class="panel panel-timeline">
           <div class="panel-heading"><a href="{{ page.path }}">{{ decodeURIComponent(page.path) }}</a></div>
           <div class="panel-body">

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

@@ -26,7 +26,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane m-t-30" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-id="{{ page.id }}" data-page-path="{{ page.path }}" data-revision="{{ page.revision._id }}">
         <div class="panel panel-timeline">
           <div class="panel-heading"><a href="{{ page.path }}">{{ decodeURIComponent(page.path) }}</a></div>
           <div class="panel-body">

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