Browse Source

Merge branch 'master' into rc/3.3.0

Yuki Takei 7 years ago
parent
commit
1714504097
50 changed files with 991 additions and 265 deletions
  1. 3 0
      CHANGES.md
  2. 2 1
      README.md
  3. 30 0
      bin/download-cdn-resources.js
  4. 22 0
      bin/download-resources.js
  5. 2 1
      config/env.dev.js
  6. 8 7
      package.json
  7. 191 0
      resource/cdn-manifests.js
  8. 18 4
      resource/locales/en-US/translation.json
  9. 1 1
      resource/locales/en-US/welcome.md
  10. 17 6
      resource/locales/ja/translation.json
  11. 1 1
      resource/locales/ja/welcome.md
  12. 17 13
      src/client/js/components/InstallerForm.jsx
  13. 3 0
      src/client/js/components/PageEditor.js
  14. 20 4
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  15. 1 0
      src/client/js/components/PageEditor/Editor.jsx
  16. 5 3
      src/client/js/components/PageHistory.js
  17. 22 10
      src/client/js/legacy/crowi.js
  18. 17 6
      src/client/styles/agile-admin/inverse/colors/christmas.scss
  19. 2 2
      src/client/styles/agile-admin/inverse/eliteadmin.scss
  20. 2 1
      src/client/styles/agile-admin/inverse/widgets.scss
  21. 156 0
      src/lib/service/cdn-resources-downloader.js
  22. 163 0
      src/lib/service/cdn-resources-service.js
  23. 19 32
      src/server/crowi/index.js
  24. 1 0
      src/server/models/config.js
  25. 3 3
      src/server/models/page.js
  26. 2 14
      src/server/models/revision.js
  27. 13 0
      src/server/models/user.js
  28. 1 2
      src/server/routes/login.js
  29. 52 26
      src/server/routes/page.js
  30. 1 1
      src/server/routes/revision.js
  31. 1 1
      src/server/service/file-uploader/aws.js
  32. 3 3
      src/server/service/file-uploader/gridfs.js
  33. 1 1
      src/server/service/file-uploader/local.js
  34. 5 6
      src/server/util/apiResponse.js
  35. 26 0
      src/server/util/swigFunctions.js
  36. 1 1
      src/server/views/admin/app.html
  37. 7 4
      src/server/views/admin/customize.html
  38. 5 0
      src/server/views/layout-crowi/base/layout.html
  39. 5 0
      src/server/views/layout-growi/base/layout.html
  40. 5 0
      src/server/views/layout-kibela/base/layout.html
  41. 4 27
      src/server/views/layout/layout.html
  42. 19 8
      src/server/views/modal/delete.html
  43. 1 6
      src/server/views/modal/duplicate.html
  44. 19 12
      src/server/views/modal/put_back.html
  45. 26 35
      src/server/views/modal/rename.html
  46. 1 6
      src/server/views/modal/unportalize.html
  47. 2 16
      src/server/views/page_presentation.html
  48. 5 0
      src/server/views/search.html
  49. 21 0
      src/server/views/widget/modal/page-api-error-messages.html
  50. 39 1
      yarn.lock

+ 3 - 0
CHANGES.md

@@ -3,11 +3,14 @@ CHANGES
 
 ## 3.3.0-RC
 
+* Feature: NO_CDN Mode
 * Feature: Add option to show/hide restricted pages in list
+* Feature: MongoDB GridFS quota
 * Improvement: Refactor Access Control
 * Improvement: Checkbox behavior of task list
 * Improvement: Fixed search input on search result page
 * Improvement: Add 'christmas' theme
+* Improvement: Select default language of new users
 * Fix: Hide restricted pages contents in timeline
 * Support: Upgrade libs
     * googleapis

+ 2 - 1
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,7 +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`
+    * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `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);
+  });

+ 2 - 1
config/env.dev.js

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

+ 8 - 7
package.json

@@ -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",
@@ -194,12 +193,14 @@
     "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",

+ 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: ''
+      },
+    },
+  ]
+};

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

@@ -174,6 +174,12 @@
       }
   },
 
+  "page_api_error": {
+    "notfound_or_forbidden": "Original page is not found or forbidden.",
+    "already_exists": "New page is already exists.",
+    "outdated": "Page is updated someone and now outdated. "
+  },
+
   "modal_rename": {
     "label": {
       "Rename page": "Rename page",
@@ -194,8 +200,12 @@
   "modal_delete": {
     "label": {
       "Delete Page": "Delete Page",
-      "recursively": "Process recursively",
-      "completely": "Delete completely"
+      "Delete recursively": "Delete recursively",
+      "Delete completely": "Delete completely"
+    },
+    "help": {
+      "recursively": "Delete children of under <code>%s</code> recursively",
+      "completely": "Delete completely instead of putting it into trash"
     }
   },
 
@@ -207,10 +217,13 @@
     }
   },
 
-  "modal_putBack": {
+  "modal_putback": {
     "label": {
       "Put Back Page": "Put Back Page",
-      "recursively": "Process recursively"
+      "recursively": "Put Back recursively"
+    },
+    "help": {
+      "recursively": "Put Back children of under <code>%s</code> recursively"
     }
   },
 
@@ -509,6 +522,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.",

+ 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>

+ 17 - 6
resource/locales/ja/translation.json

@@ -190,8 +190,11 @@
       }
   },
 
-
-
+  "page_api_error": {
+    "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
+    "already_exists": "新しいページが既に存在しています。",
+    "outdated": "ページが他のユーザーによって更新されました。"
+  },
 
   "modal_rename": {
     "label": {
@@ -213,8 +216,12 @@
   "modal_delete": {
     "label": {
       "Delete Page": "ページを削除する",
-      "recursively": "全ての子ページも処理",
-      "completely": "完全削除"
+      "Delete recursively": "全ての子ページも削除",
+      "Delete completely": "完全削除"
+    },
+    "help": {
+      "recursively": "<code>%s</code> 配下のページも削除します",
+      "completely": "ゴミ箱を経由せず、完全に削除します"
     }
   },
 
@@ -226,10 +233,13 @@
     }
   },
 
-  "modal_putBack": {
+  "modal_putback": {
     "label": {
       "Put Back Page": "ページを元に戻す",
-      "recursively": "全ての子ページも処理"
+      "recursively": "全ての子ページも元に戻す"
+    },
+    "help": {
+      "recursively": "<code>%s</code> 配下のページも元に戻します"
     }
   },
 
@@ -524,6 +534,7 @@
     "tab_switch": "タブ変更をブラウザ履歴に保存",
     "save_edit": "編集タブやヒストリータブ等の切り替えをブラウザ履歴に保存し、ブラウザの戻る/進む操作の対象にします。",
     "by_invalidating": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
+    "nocdn_desc": "この機能は、環境変数 <code>NO_CDN=true</code> の時は無効化されます。<br>GitHub スタイルが適用されています。",
     "Custom CSS": "カスタム CSS",
     "write_CSS": " システム全体に適用されるCSSを記述できます。",
     "reflect_change": "変更の反映はページの更新が必要です。",

+ 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>

+ 17 - 13
src/client/js/components/InstallerForm.js → src/client/js/components/InstallerForm.jsx

@@ -13,6 +13,10 @@ class InstallerForm extends React.Component {
     this.checkUserName = this.checkUserName.bind(this);
   }
 
+  componentWillMount() {
+    this.changeLanguage('en-US');
+  }
+
   checkUserName(event) {
     const axios = require('axios').create({
       headers: {
@@ -40,6 +44,19 @@ class InstallerForm extends React.Component {
         </p>
 
         <form role="form" action="/installer/createAdmin" method="post" id="register-form">
+          <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">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">日本語</label>
+            </div>
+          </div>
+
           <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') }
@@ -67,19 +84,6 @@ class InstallerForm extends React.Component {
 
           <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>

+ 3 - 0
src/client/js/components/PageEditor.js

@@ -320,6 +320,8 @@ export default class PageEditor extends React.Component {
   }
 
   render() {
+    const config = this.props.crowi.getConfig();
+    const noCdn = !!config.env.NO_CDN;
     const emojiStrategy = this.props.crowi.getEmojiStrategy();
 
     return (
@@ -327,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,

+ 5 - 3
src/client/js/components/PageHistory.js

@@ -97,7 +97,9 @@ class PageHistory extends React.Component {
       return ;
     }
 
-    this.props.crowi.apiGet('/revisions.get', {revision_id: revision._id})
+    this.props.crowi.apiGet('/revisions.get',
+      { page_id: this.props.pageId, revision_id: revision._id}
+    )
     .then(res => {
       if (res.ok) {
         this.setState({
@@ -110,10 +112,10 @@ class PageHistory extends React.Component {
           })
         });
       }
-    }).catch(err => {
+    })
+    .catch(err => {
 
     });
-
   }
 
   render() {

+ 22 - 10
src/client/js/legacy/crowi.js

@@ -351,7 +351,7 @@ $(function() {
   // rename/unportalize
   $('#renamePage, #unportalize').on('shown.bs.modal', function(e) {
     $('#renamePage #newPageName').focus();
-    $('#renamePage .msg-already-exists, #unportalize .msg-already-exists').hide();
+    $('#renamePage .msg, #unportalize .msg').hide();
   });
   $('#renamePageForm, #unportalize-form').submit(function(e) {
     // create name-value map
@@ -369,9 +369,10 @@ $(function() {
       dataType: 'json'
     })
     .done(function(res) {
+      // error
       if (!res.ok) {
-        // if already exists
-        $('#renamePage .msg-already-exists, #unportalize .msg-already-exists').show();
+        $('#renamePage .msg, #unportalize .msg').hide();
+        $(`#renamePage .msg-${res.code}, #unportalize .msg-${res.code}`).show();
         $('#renamePage #linkToNewPage, #unportalize #linkToNewPage').html(`
           <a href="${nameValueMap.new_path}">${nameValueMap.new_path} <i class="icon-login"></i></a>
         `);
@@ -388,7 +389,7 @@ $(function() {
   // duplicate
   $('#duplicatePage').on('shown.bs.modal', function(e) {
     $('#duplicatePage #duplicatePageName').focus();
-    $('#duplicatePage .msg-already-exists').hide();
+    $('#duplicatePage .msg').hide();
   });
   $('#duplicatePageForm, #unportalize-form').submit(function(e) {
     // create name-value map
@@ -403,9 +404,10 @@ $(function() {
       data: $(this).serialize(),
       dataType: 'json'
     }).done(function(res) {
+      // error
       if (!res.ok) {
-        // if already exists
-        $('#duplicatePage .msg-already-exists').show();
+        $('#duplicatePage .msg').hide();
+        $(`#duplicatePage .msg-${res.code}`).show();
         $('#duplicatePage #linkToNewPage').html(`
           <a href="${nameValueMap.new_path}">${nameValueMap.new_path} <i class="icon-login"></i></a>
         `);
@@ -420,6 +422,9 @@ $(function() {
   });
 
   // delete
+  $('#deletePage').on('shown.bs.modal', function(e) {
+    $('#deletePage .msg').hide();
+  });
   $('#delete-page-form').submit(function(e) {
     $.ajax({
       type: 'POST',
@@ -427,9 +432,10 @@ $(function() {
       data: $('#delete-page-form').serialize(),
       dataType: 'json'
     }).done(function(res) {
+      // error
       if (!res.ok) {
-        $('#delete-errors').html('<i class="fa fa-times-circle"></i> ' + res.error);
-        $('#delete-errors').addClass('alert-danger');
+        $('#deletePage .msg').hide();
+        $(`#deletePage .msg-${res.code}`).show();
       }
       else {
         const page = res.page;
@@ -439,6 +445,11 @@ $(function() {
 
     return false;
   });
+
+  // Put Back
+  $('#putBackPage').on('shown.bs.modal', function(e) {
+    $('#putBackPage .msg').hide();
+  });
   $('#revert-delete-page-form').submit(function(e) {
     $.ajax({
       type: 'POST',
@@ -446,9 +457,10 @@ $(function() {
       data: $('#revert-delete-page-form').serialize(),
       dataType: 'json'
     }).done(function(res) {
+      // error
       if (!res.ok) {
-        $('#delete-errors').html('<i class="fa fa-times-circle"></i> ' + res.error);
-        $('#delete-errors').addClass('alert-danger');
+        $('#putBackPage .msg').hide();
+        $(`#putBackPage .msg-${res.code}`).show();
       }
       else {
         const page = res.page;

+ 17 - 6
src/client/styles/agile-admin/inverse/colors/christmas.scss

@@ -7,17 +7,17 @@ $topbar: $themecolor;
 $sidebar: $themelight;
 $bodycolor: $themelight;
 $headingtext: $subthemecolor;
-$bodytext: #bd3425;
+$bodytext: black;
 $linktext: lighten(#0d5901, 5%);
 $linktext-hover: lighten($linktext, 12%);
 $sidebar-text: #ffffff;
 $primary: $themecolor;
-$info: lighten($themecolor, 10%);
 $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;
@@ -59,7 +59,7 @@ $active-nav-tabs-bgcolor: white;
 
 #wrapper > .navbar > .navbar-header {
   background-image: url("/images/themes/christmas/christmas-navbar.jpg");
-  border-bottom: 3px solid $subthemecolor;
+  border-bottom: 4px solid $subthemecolor;
 }
 
 /*
@@ -90,7 +90,7 @@ body:not(.on-edit) .nav.nav-tabs {
       background-color: rgba(#ccc, 0.5);
     }
     .link-switch {
-      color: $bodytext;
+      color: #bd3425;
     }
   }
 }
@@ -106,8 +106,19 @@ body:not(.on-edit) .nav.nav-tabs {
 /*
  * Panel
  */
-.panel.panel-default {
-  border-color: $bodytext;
+.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");

+ 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;

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

@@ -1293,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;}
 */

+ 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;

+ 19 - 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();
 
@@ -68,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) {

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

@@ -641,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),

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

@@ -897,7 +897,7 @@ module.exports = function(crowi) {
         savedPage = newPage;
       })
       .then(() => {
-        const newRevision = Revision.prepareRevision(savedPage, body, user, {format: format});
+        const newRevision = Revision.prepareRevision(savedPage, body, null, user, {format: format});
         return pushRevision(savedPage, newRevision, user);
       })
       .then(() => {
@@ -908,7 +908,7 @@ module.exports = function(crowi) {
       });
   };
 
-  pageSchema.statics.updatePage = async function(pageData, body, user, options = {}) {
+  pageSchema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
     validateCrowi();
 
     const Page = this
@@ -922,7 +922,7 @@ module.exports = function(crowi) {
     // update existing page
     applyGrant(pageData, user, grant, grantUserGroupId);
     let savedPage = await pageData.save();
-    const newRevision = await Revision.prepareRevision(pageData, body, user);
+    const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
     const revision = await pushRevision(savedPage, newRevision, user, grant, grantUserGroupId);
     savedPage = await Page.findByPath(revision.path).populate('revision').populate('creator');
 

+ 2 - 14
src/server/models/revision.js

@@ -31,15 +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.findRevisions = function(ids) {
     const Revision = this,
       User = crowi.model('User');
@@ -102,7 +93,7 @@ module.exports = function(crowi) {
     });
   };
 
-  revisionSchema.statics.prepareRevision = function(pageData, body, user, options) {
+  revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, options) {
     const Revision = this;
 
     if (!options) {
@@ -121,7 +112,7 @@ module.exports = function(crowi) {
     newRevision.author = user._id;
     newRevision.createdAt = Date.now();
     if (pageData.revision != null) {
-      newRevision.hasDiffToPrev = body !== pageData.revision.body;
+      newRevision.hasDiffToPrev = body !== previousBody;
     }
 
     return newRevision;
@@ -141,8 +132,5 @@ module.exports = function(crowi) {
     });
   };
 
-  revisionSchema.statics.updatePath = function(pathName) {
-  };
-
   return mongoose.model('Revision', revisionSchema);
 };

+ 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;
     }

+ 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.');

+ 52 - 26
src/server/routes/page.js

@@ -399,6 +399,10 @@ module.exports = function(crowi, app) {
       }
     }
 
+    const limit = 50;
+    const offset = parseInt(req.query.offset)  || 0;
+    await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit);
+
     return res.render(view, renderVars);
   };
 
@@ -523,7 +527,7 @@ module.exports = function(crowi, app) {
     // check page existence
     const isExist = await Page.count({path: pagePath}) > 0;
     if (isExist) {
-      return res.json(ApiResponse.error('Page exists'));
+      return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
 
     const options = {grant, grantUserGroupId, socketClientId};
@@ -580,13 +584,13 @@ module.exports = function(crowi, app) {
     // check page existence
     const isExist = await Page.count({_id: pageId}) > 0;
     if (!isExist) {
-      return res.json(ApiResponse.error(`Page('${pageId}' does not exist`));
+      return res.json(ApiResponse.error(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
     // check revision
     let page = await Page.findByIdAndViewer(pageId, req.user);
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.'));
+      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
     }
 
     const options = {isSyncRevisionToHackmd, socketClientId};
@@ -597,11 +601,10 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    // store previous revision
-    const previousRevision = page.revision;
-
     try {
-      page = await Page.updatePage(page, pageBody, req.user, options);
+      const Revision = crowi.model('Revision');
+      const previousRevision = await Revision.findById(revisionId);
+      page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
     }
     catch (err) {
       logger.error('error on _api/pages.update', err);
@@ -622,6 +625,8 @@ module.exports = function(crowi, app) {
 
     // user notification
     if (isSlackEnabled && slackChannels != null) {
+      const Revision = crowi.model('Revision');
+      const previousRevision = await Revision.findById(page.revision);
       await notifyToSlackByUser(page, req.user, slackChannels, 'update', previousRevision);
     }
   };
@@ -651,6 +656,11 @@ module.exports = function(crowi, app) {
       else if (pagePath) {
         page = await Page.findByPathAndViewer(pagePath, req.user);
       }
+
+      if (page == null) {
+        throw new Error(`Page '${pageId || pagePath}' is not found or forbidden`, 'notfound_or_forbidden');
+      }
+
       page.initLatestRevisionField();
 
       // populate
@@ -719,6 +729,9 @@ module.exports = function(crowi, app) {
     let page;
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);
+      if (page == null) {
+        throw new Error(`Page '${pageId}' is not found or forbidden`);
+      }
       page = await page.like(req.user);
     }
     catch (err) {
@@ -758,6 +771,9 @@ module.exports = function(crowi, app) {
     let page;
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);
+      if (page == null) {
+        throw new Error(`Page '${pageId}' is not found or forbidden`);
+      }
       page = await page.unlike(req.user);
     }
     catch (err) {
@@ -822,7 +838,7 @@ module.exports = function(crowi, app) {
     let page = await Page.findByIdAndViewer(pageId, req.user);
 
     if (page == null) {
-      return res.json(ApiResponse.error('The page does not exist.'));
+      return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
     debug('Delete page', page._id, page.path);
@@ -838,7 +854,7 @@ module.exports = function(crowi, app) {
       }
       else {
         if (!page.isUpdatable(previousRevision)) {
-          throw new Error('Someone could update this page, so couldn\'t delete.');
+          return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
         if (isRecursively) {
@@ -851,7 +867,7 @@ module.exports = function(crowi, app) {
     }
     catch (err) {
       logger.error('Error occured while get setting', err);
-      return res.json(ApiResponse.error('Failed to delete page.'));
+      return res.json(ApiResponse.error('Failed to delete page.', 'unknown'));
     }
 
     debug('Page deleted', page.path);
@@ -882,7 +898,7 @@ module.exports = function(crowi, app) {
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);
       if (page == null) {
-        throw new Error('The page is not found or the user does not have permission');
+        throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
 
       if (isRecursively) {
@@ -923,27 +939,32 @@ module.exports = function(crowi, app) {
       moveUnderTrees: req.body.move_trees || 0,
       socketClientId: +req.body.socketClientId || undefined,
     };
-    const isRecursiveMove = req.body.move_recursively || 0;
+    const isRecursively = req.body.recursively || 0;
 
     if (!Page.isCreatableName(newPagePath)) {
-      return res.json(ApiResponse.error(`このページ名は作成できません (${newPagePath})`));
+      return res.json(ApiResponse.error(`Could not use the path '${newPagePath})'`, 'invalid_path'));
     }
 
     const isExist = await Page.count({ path: newPagePath }) > 0;
     if (isExist) {
       // if page found, cannot cannot rename to that path
-      return res.json(ApiResponse.error('The page already exists'));
+      return res.json(ApiResponse.error(`'new_path=${newPagePath}' already exists`, 'already_exists'));
     }
 
     let page;
 
     try {
-      page = await Page.findById(pageId);
+      page = await Page.findByIdAndViewer(pageId, req.user);
+
+      if (page == null) {
+        return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
+      }
+
       if (!page.isUpdatable(previousRevision)) {
-        throw new Error('Someone could update this page, so couldn\'t delete.');
+        return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
       }
 
-      if (isRecursiveMove) {
+      if (isRecursively) {
         page = await Page.renameRecursively(page, newPagePath, req.user, options);
       }
       else {
@@ -952,7 +973,7 @@ module.exports = function(crowi, app) {
     }
     catch (err) {
       logger.error(err);
-      return res.json(ApiResponse.error('Failed to update page.'));
+      return res.json(ApiResponse.error('Failed to update page.', 'unknown'));
     }
 
     const result = {};
@@ -974,18 +995,23 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} new_path
    */
-  api.duplicate = function(req, res) {
+  api.duplicate = async function(req, res) {
     const pageId = req.body.page_id;
     const newPagePath = Page.normalizePath(req.body.new_path);
 
-    Page.findById(pageId)
-      .then(function(pageData) {
-        req.body.path = newPagePath;
-        req.body.body = pageData.revision.body;
-        req.body.grant = pageData.grant;
+    const page = await Page.findByIdAndViewer(pageId, req.user);
 
-        return api.create(req, res);
-      });
+    if (page == null) {
+      return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
+    }
+
+    await page.populateDataToShowRevision();
+
+    req.body.path = newPagePath;
+    req.body.body = page.revision.body;
+    req.body.grant = page.grant;
+
+    return api.create(req, res);
   };
 
   /**

+ 1 - 1
src/server/routes/revision.js

@@ -33,7 +33,7 @@ module.exports = function(crowi, app) {
     }
 
     try {
-      const revision = await Revision.findById(revisionId);
+      const revision = await Revision.findById(revisionId).populate('author', 'User');
       return res.json(ApiResponse.success({ revision }));
     }
     catch (err) {

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

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

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

@@ -60,16 +60,16 @@ module.exports = function(crowi) {
   };
 
   /**
-   * chech storage for fileUpload reaches MONGODB_GRIDFS_LIMIT (for gridfs)
+   * chech storage for fileUpload reaches MONGO_GRIDFS_TOTAL_LIMIT (for gridfs)
    */
   lib.checkCapacity = async(uploadFileSize) => {
     // skip checking if env var is undefined
-    if (process.env.MONGODB_GRIDFS_LIMIT == null) {
+    if (process.env.MONGO_GRIDFS_TOTAL_LIMIT == null) {
       return true;
     }
 
     const usingFilesSize = await getCollectionSize();
-    return (+process.env.MONGODB_GRIDFS_LIMIT > usingFilesSize + +uploadFileSize);
+    return (+process.env.MONGO_GRIDFS_TOTAL_LIMIT > usingFilesSize + +uploadFileSize);
   };
 
   lib.uploadFile = async function(filePath, contentType, fileStream, options) {

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

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

+ 5 - 6
src/server/util/apiResponse.js

@@ -3,12 +3,11 @@
 function ApiResponse() {
 }
 
-ApiResponse.error = function(err) {
-  var result = {};
+ApiResponse.error = function(err, code) {
+  const result = {};
 
-  result = {
-    ok: false
-  };
+  result.ok = false;
+  result.code = code;
 
   if (err instanceof Error) {
     result.error = err.toString();
@@ -21,7 +20,7 @@ ApiResponse.error = function(err) {
 };
 
 ApiResponse.success = function(data) {
-  var result = data || {};
+  const result = data || {};
 
   result.ok = true;
   return result;

+ 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');
@@ -57,6 +58,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
    */

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

@@ -60,7 +60,7 @@
         </div>
 
         <div class="form-group">
-          <label class="col-xs-3 control-label tbd">(TBD) {{ t('app_setting.Default Language for new users') }}</label>
+          <label class="col-xs-3 control-label">{{ t('app_setting.Default Language for new users') }}</label>
           <div class="col-xs-6">
             <div class="radio radio-primary radio-inline">
                 <input type="radio" id="radioLangEn" name="settingForm[app:globalLang]" value="{{ consts.language.LANG_EN }}" {% if appGlobalLang() == consts.language.LANG_EN %}checked="checked"{% endif %}>

+ 7 - 4
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;
@@ -315,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>
 
@@ -339,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:
@@ -591,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

+ 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">
 

+ 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">
 

+ 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">
 

+ 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 %}
 

+ 19 - 8
src/server/views/modal/delete.html

@@ -8,7 +8,7 @@
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
           <div class="modal-title">
             {% if page.isDeleted() %}
-            <i class="icon-fw icon-fire"></i> {{ t('modal_delete.label.completely') }}
+            <i class="icon-fw icon-fire"></i> {{ t('modal_delete.label.Delete completely') }}
             {% else %}
             <i class="icon-fw icon-trash"></i> {{ t('modal_delete.label.Delete Page') }}
             {% endif %}
@@ -19,18 +19,32 @@
             <label for="">Deleting page:</label><br>
             <code>{{ page.path }}</code>
           </div>
+
+          <hr>
+
+          <div class="checkbox checkbox-warning">
+            <input name="recursively" id="cbDeleteRecursively" value="1" type="checkbox" checked>
+            <label for="cbDeleteRecursively">{{ t('modal_delete.label.Delete recursively') }}</label>
+            <p class="help-block"> {{ t('modal_delete.help.recursively', page.path) }}
+            </p>
+          </div>
+          {% if not page.isDeleted() %}
+          <div class="checkbox checkbox-danger">
+            <input name="completely" id="cbDeleteCompletely" value="1"  type="checkbox">
+              <label for="cbDeleteCompletely" class="text-danger">{{ t('modal_delete.label.Delete completely') }}</label>
+              <p class="help-block"> {{ t('modal_delete.help.completely') }}
+              </p>
+          </div>
+          {% endif %}
         </div>
         <div class="modal-footer">
           <div class="d-flex justify-content-between">
-            <p><small id="delete-errors"></small></p>
+            {% include '../widget/modal/page-api-error-messages.html' %}
             <div>
               <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="revision_id" value="{{ page.revision._id.toString() }}">
-              <label class="checkbox-inline">
-                <input type="checkbox" name="recursively">{{ t('modal_delete.label.recursively') }}
-              </label>
               {% if page.isDeleted() %}
                 <input type="hidden" name="completely" value="true">
                 <button type="submit" class="m-l-10 btn btn-sm btn-danger delete-button">
@@ -38,9 +52,6 @@
                   {{ t('Delete Completely') }}
                 </button>
               {% else %}
-                <label class="checkbox-inline text-danger">
-                  <input type="checkbox" name="completely">{{ t('modal_delete.label.completely') }}
-                </label>
                 <button type="submit" class="m-l-10 btn btn-sm btn-default delete-button">
                   <i class="icon-trash" aria-hidden="true"></i>
                   {{ t('Delete') }}

+ 1 - 6
src/server/views/modal/duplicate.html

@@ -23,12 +23,7 @@
         </div>
         <div class="modal-footer">
           <div class="d-flex justify-content-between">
-            <p>
-              <span class="text-danger msg-already-exists">
-                <strong><i class="icon-fw icon-ban"></i>{{ t('Page is already exists.') }}</strong>
-              </span>
-              <small id="linkToNewPage" class="msg-already-exists"></small>
-            </p>
+            {% include '../widget/modal/page-api-error-messages.html' %}
             <div>
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
               <input type="hidden" name="path" value="{{ page.path }}">

+ 19 - 12
src/server/views/modal/put_back.html

@@ -6,26 +6,33 @@
 
         <div class="modal-header bg-info">
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-          <div class="modal-title"><i class="icon-action-undo"></i> {{ t('modal_putBack.label.Put Back Page') }}</div>
+          <div class="modal-title"><i class="icon-action-undo"></i> {{ t('modal_putback.label.Put Back Page') }}</div>
         </div>
         <div class="modal-body">
           <div class="form-group">
             <label for="">Put back page:</label><br>
             <code>{{ page.path }}</code>
           </div>
+          <div class="checkbox checkbox-warning">
+            <input name="recursively" id="cbPutbackRecursively" value="1" type="checkbox" checked>
+            <label for="cbPutbackRecursively">{{ t('modal_putback.label.recursively') }}</label>
+            <p class="help-block"> {{ t('modal_putback.help.recursively', page.path) }}
+            </p>
+          </div>
         </div>
         <div class="modal-footer">
-          <p><small class="pull-left" id="put_back-errors"></small></p>
-          <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() }}">
-          <label class="checkbox-inline">
-            <input type="checkbox" name="recursively" checked> {{ t('modal_putBack.label.recursively') }}
-          </label>
-          <button type="submit" class="btn btn-sm btn-info putBack-button">
-            <i class="icon-action-undo" aria-hidden="true"></i>
-            {{ t('Put Back') }}
-          </button>
+          <div class="d-flex justify-content-between">
+            {% include '../widget/modal/page-api-error-messages.html' %}
+            <div>
+              <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() }}">
+              <button type="submit" class="btn btn-sm btn-info putBack-button">
+                <i class="icon-action-undo" aria-hidden="true"></i>
+                {{ t('Put Back') }}
+              </button>
+            </div>
+          </div>
         </div>
 
       </form>

+ 26 - 35
src/server/views/modal/rename.html

@@ -6,48 +6,39 @@
 
         <div class="modal-header bg-primary">
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-          <div class="modal-title">{{ t('Rename page') }}</div>
+          <div class="modal-title">{{ t('modal_rename.label.Rename page') }}</div>
         </div>
         <div class="modal-body">
-            <div class="form-group">
-              <label for="">{{ t('Current page name') }}</label><br>
-              <code>{{ page.path }}</code>
-            </div>
-            <div class="form-group">
-              <label for="newPageName">{{ t('New page name') }}</label><br>
-              <div class="input-group">
-                <span class="input-group-addon">{{ config.crowi['app:siteUrl:fixed'] }}</span>
-                <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
-              </div>
+          <div class="form-group">
+            <label for="">{{ t('modal_rename.label.Current page name') }}</label><br>
+            <code>{{ page.path }}</code>
+          </div>
+          <div class="form-group">
+            <label for="newPageName">{{ t('modal_rename.label.New page name') }}</label><br>
+            <div class="input-group">
+              <span class="input-group-addon">{{ config.crowi['app:siteUrl:fixed'] }}</span>
+              <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
             </div>
-            <div class="checkbox checkbox-info">
-              <input name="move_recursively" id="cbRecursively" value="1" type="checkbox" checked>
-              <label for="cbRecursively">{{ t('modal_rename.label.Move recursively') }}</label>
-              <p class="help-block"> {{ t('modal_rename.help.recursive', page.path) }}
+          </div>
+
+          <hr>
+
+          <div class="checkbox checkbox-warning">
+            <input name="recursively" id="cbRenameRecursively" value="1" type="checkbox" checked>
+            <label for="cbRenameRecursively">{{ t('modal_rename.label.Move recursively') }}</label>
+            <p class="help-block"> {{ t('modal_rename.help.recursive', page.path) }}
+            </p>
+          </div>
+          <div class="checkbox checkbox-info">
+            <input name="create_redirect" id="cbRenameRedirect" value="1"  type="checkbox">
+              <label for="cbRenameRedirect">{{ t('modal_rename.label.Redirect') }}</label>
+              <p class="help-block"> {{ t('modal_rename.help.redirect', page.path) }}
               </p>
-            </div>
-            <div class="checkbox checkbox-info">
-              <input name="create_redirect" id="cbRedirect" value="1"  type="checkbox">
-               <label for="cbRedirect">{{ t('modal_rename.label.Redirect') }}</label>
-               <p class="help-block"> {{ t('modal_rename.help.redirect', page.path) }}
-               </p>
-            </div>
-            {# <div class="checkbox"> #}
-            {#    <label> #}
-            {#      <input name="moveUnderTrees" value="1" type="checkbox"> 下層ページも全部移動する #}
-            {#    </label> #}
-            {#    <p class="help-block">チェックを入れると、<code>{{ page.path }}</code>以下の階層以下もすべて移動します。</p> #}
-            {#    <p class="help-block">例: <code>/hoge/fuga/move</code> を <code>/foo/bar/move</code> に移動すると、<code>/hoge/fuga/move/page1</code> も <code>/foo/bar/move/page1</code> に。</p> #}
-            {# </div> #}
+          </div>
         </div>
         <div class="modal-footer">
           <div class="d-flex justify-content-between">
-            <p>
-              <span class="text-danger msg-already-exists">
-                <strong><i class="icon-fw icon-ban"></i>{{ t('Page is already exists.') }}</strong>
-              </span>
-              <small id="linkToNewPage" class="msg-already-exists"></small>
-            </p>
+            {% include '../widget/modal/page-api-error-messages.html' %}
             <div>
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
               <input type="hidden" name="path" value="{{ page.path }}">

+ 1 - 6
src/server/views/modal/unportalize.html

@@ -33,12 +33,7 @@
         </div>
         <div class="modal-footer">
           <div class="d-flex justify-content-between">
-            <p>
-              <span class="text-danger msg-already-exists">
-                <strong><i class="icon-fw icon-ban"></i>{{ t('Page is already exists.') }}</strong>
-              </span>
-              <small id="linkToNewPage" class="msg-already-exists"></small>
-            </p>
+            {% include '../widget/modal/page-api-error-messages.html' %}
             <div>
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
               <input type="hidden" name="path" value="{{ page.path }}">

+ 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"

+ 21 - 0
src/server/views/widget/modal/page-api-error-messages.html

@@ -0,0 +1,21 @@
+<p>
+  <span class="text-danger msg msg-notfound_or_forbidden">
+    <strong><i class="icon-fw icon-ban"></i>{{ t('page_api_error.notfound_or_forbidden') }}</strong>
+  </span>
+  <span class="text-danger msg msg-already_exists">
+    <strong><i class="icon-fw icon-ban"></i>{{ t('page_api_error.already_exists') }}</strong>
+    <small id="linkToNewPage"></small>
+  </span>
+  <span class="text-warning msg msg-outdated">
+    <strong><i class="icon-fw icon-bulb"></i> {{ t('page_api_error.outdated') }}</strong>
+    <a href="javascript:location.reload();">
+      <i class="fa fa-angle-double-right"></i> {{ t('Load latest') }}
+    </a>
+  </span>
+  <span class="text-danger msg msg-invalid_path">
+    <strong><i class="icon-fw icon-ban"></i> Invalid path</strong>
+  </span>
+  <span class="text-danger msg msg-unknown">
+    <strong><i class="icon-fw icon-ban"></i> Unknown error occured</strong>
+  </span>
+</p>

+ 39 - 1
yarn.lock

@@ -409,6 +409,10 @@ ansistyles@~0.1.1:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/ansistyles/-/ansistyles-0.1.3.tgz#5de60415bda071bb37127854c864f41b23254539"
 
+any-promise@^1.1.0, any-promise@~1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
+
 anymatch@^1.3.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
@@ -2944,6 +2948,12 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0:
   dependencies:
     once "^1.4.0"
 
+end-of-stream@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.1.0.tgz#e9353258baa9108965efc41cb0ef8ade2f3cfb07"
+  dependencies:
+    once "~1.3.0"
+
 engine.io-client@~3.1.0:
   version "3.1.4"
   resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.1.4.tgz#4fcf1370b47163bd2ce9be2733972430350d4ea1"
@@ -3082,7 +3092,7 @@ escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
 
-escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.3, escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
 
@@ -6453,6 +6463,12 @@ once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0:
   dependencies:
     wrappy "1"
 
+once@~1.3.0:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20"
+  dependencies:
+    wrappy "1"
+
 onetime@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
@@ -7772,6 +7788,14 @@ repeating@^2.0.0:
   dependencies:
     is-finite "^1.0.0"
 
+replacestream@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/replacestream/-/replacestream-4.0.3.tgz#3ee5798092be364b1cdb1484308492cb3dff2f36"
+  dependencies:
+    escape-string-regexp "^1.0.3"
+    object-assign "^4.0.1"
+    readable-stream "^2.0.2"
+
 request@2:
   version "2.83.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356"
@@ -8656,6 +8680,20 @@ stream-throttle@^0.1.3:
     commander "^2.2.0"
     limiter "^1.0.5"
 
+stream-to-array@~2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/stream-to-array/-/stream-to-array-2.3.0.tgz#bbf6b39f5f43ec30bc71babcb37557acecf34353"
+  dependencies:
+    any-promise "^1.1.0"
+
+stream-to-promise@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/stream-to-promise/-/stream-to-promise-2.2.0.tgz#b1edb2e1c8cb11289d1b503c08d3f2aef51e650f"
+  dependencies:
+    any-promise "~1.3.0"
+    end-of-stream "~1.1.0"
+    stream-to-array "~2.3.0"
+
 streamsearch@0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"