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

Merge branch 'master' into imprv/reactify-admin

# Conflicts:
#	src/client/js/app.js
#	src/server/models/user-group.js
#	src/server/routes/admin.js
#	src/server/routes/index.js
#	src/server/util/middlewares.js
Yuki Takei 6 лет назад
Родитель
Сommit
a5bf2b39d2
100 измененных файлов с 2553 добавлено и 2300 удалено
  1. 0 16
      .babelrc
  2. 3 5
      .eslintrc.js
  3. 46 1
      CHANGES.md
  4. 1 0
      README.md
  5. 1 1
      THIRD-PARTY-NOTICES.md
  6. 23 0
      babel.config.js
  7. 6 1
      bin/generate-plugin-definitions-source.js
  8. 1 1
      bin/templates/plugin-definitions.js.swig
  9. 54 0
      config/jest.config.js
  10. 4 0
      config/logger/config.dev.js
  11. 3 0
      config/migrate.js
  12. 3 2
      config/webpack.common.js
  13. 4 2
      config/webpack.dev.dll.js
  14. 30 28
      package.json
  15. 0 21
      public/images/admin/security/passport-logo.svg
  16. 8 0
      resource/cdn-manifests.js
  17. 65 74
      resource/locales/en-US/translation.json
  18. 42 67
      resource/locales/ja/translation.json
  19. 10 164
      src/client/js/app.js
  20. 15 3
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  21. 31 6
      src/client/js/components/Page.jsx
  22. 3 3
      src/client/js/components/Page/RevisionPath.jsx
  23. 1 1
      src/client/js/components/PageAttachment.jsx
  24. 2 8
      src/client/js/components/PageComments.jsx
  25. 44 47
      src/client/js/components/PageEditor.jsx
  26. 51 31
      src/client/js/components/PageEditorByHackmd.jsx
  27. 2 2
      src/client/js/components/PageStatusAlert.jsx
  28. 23 13
      src/client/js/components/SavePageControls.jsx
  29. 4 2
      src/client/js/components/SavePageControls/GrantSelector.jsx
  30. 1 1
      src/client/js/components/SearchPage.js
  31. 114 0
      src/client/js/components/StaffCredit/Contributor.js
  32. 124 0
      src/client/js/components/StaffCredit/StaffCredit.jsx
  33. 1 1
      src/client/js/components/UnstatedUtils.jsx
  34. 1 2
      src/client/js/ie11-polyfill.js
  35. 25 0
      src/client/js/legacy/crowi.js
  36. 10 3
      src/client/js/plugin.js
  37. 16 47
      src/client/js/services/AppContainer.js
  38. 7 0
      src/client/js/services/CommentContainer.js
  39. 28 0
      src/client/js/services/EditorContainer.js
  40. 189 1
      src/client/js/services/PageContainer.js
  41. 8 1
      src/client/js/services/TagContainer.js
  42. 7 0
      src/client/js/services/WebsocketContainer.js
  43. 0 2
      src/client/js/util/GrowiRenderer.js
  44. 0 0
      src/client/js/util/PostProcessor/.keep
  45. 0 85
      src/client/js/util/PostProcessor/CrowiTemplate.js
  46. 28 16
      src/client/styles/agile-admin/inverse/colors/antarctic.scss
  47. 56 46
      src/client/styles/scss/_comment.scss
  48. 7 0
      src/client/styles/scss/_shortcuts.scss
  49. 93 0
      src/client/styles/scss/_staff_credit.scss
  50. 2 0
      src/client/styles/scss/style-app.scss
  51. 12 0
      src/lib/util/mongoose-utils.js
  52. 3 2
      src/migrations/20180926134048-make-email-unique.js
  53. 13 6
      src/migrations/20180927102719-init-serverurl.js
  54. 11 8
      src/migrations/20181019114028-abolish-page-group-relation.js
  55. 43 0
      src/migrations/20190618055300-abolish-crowi-classic-auth.js
  56. 64 0
      src/migrations/20190618104011-add-config-app-installed.js
  57. 49 0
      src/migrations/20190624110950-fill-last-update-user.js
  58. 29 0
      src/migrations/20190629193445-make-root-page-public.js
  59. 26 47
      src/server/crowi/express-init.js
  60. 131 46
      src/server/crowi/index.js
  61. 1 0
      src/server/form/admin/customfeatures.js
  62. 1 2
      src/server/form/admin/securityGeneral.js
  63. 0 8
      src/server/form/admin/securityGoogle.js
  64. 0 7
      src/server/form/admin/securityMechanism.js
  65. 9 0
      src/server/form/admin/securityPassportBasic.js
  66. 17 0
      src/server/form/admin/securityPassportOidc.js
  67. 2 2
      src/server/form/index.js
  68. 0 2
      src/server/form/register.js
  69. 2 2
      src/server/models/attachment.js
  70. 8 0
      src/server/models/comment.js
  71. 60 534
      src/server/models/config.js
  72. 1 1
      src/server/models/index.js
  73. 0 262
      src/server/models/page-group-relation.js
  74. 10 15
      src/server/models/page.js
  75. 0 5
      src/server/models/user-group-relation.js
  76. 0 2
      src/server/models/user-group.js
  77. 42 27
      src/server/models/user.js
  78. 9 3
      src/server/plugins/plugin.service.js
  79. 149 167
      src/server/routes/admin.js
  80. 30 2
      src/server/routes/attachment.js
  81. 5 0
      src/server/routes/avoid-session-routes.js
  82. 1 1
      src/server/routes/comment.js
  83. 1 1
      src/server/routes/hackmd.js
  84. 158 166
      src/server/routes/index.js
  85. 13 21
      src/server/routes/installer.js
  86. 97 1
      src/server/routes/login-passport.js
  87. 12 124
      src/server/routes/login.js
  88. 0 67
      src/server/routes/me.js
  89. 29 20
      src/server/routes/page.js
  90. 69 0
      src/server/service/acl.js
  91. 54 0
      src/server/service/app.js
  92. 1 1
      src/server/service/config-loader.js
  93. 86 28
      src/server/service/config-manager.js
  94. 56 0
      src/server/service/customize.js
  95. 11 12
      src/server/service/file-uploader/aws.js
  96. 2 1
      src/server/service/file-uploader/gridfs.js
  97. 1 0
      src/server/service/file-uploader/index.js
  98. 2 1
      src/server/service/file-uploader/local.js
  99. 2 2
      src/server/service/file-uploader/none.js
  100. 34 0
      src/server/service/file-uploader/uploader.js

+ 0 - 16
.babelrc

@@ -1,16 +0,0 @@
-{
-  "plugins": [
-    "lodash",
-    ["transform-runtime", {
-      "regenerator": true
-    }]
-  ],
-  "presets": [
-    ["env", {
-      "targets": {
-        "browsers": ["last 2 versions"]
-      }
-    }],
-    "react"
-  ]
-}

+ 3 - 5
.eslintrc.js

@@ -2,10 +2,11 @@ module.exports = {
   extends: [
     'weseek',
     'weseek/react',
+    "plugin:jest/recommended",
   ],
   env: {
-    mocha: true,
     jquery: true,
+    "jest/globals": true,
   },
   globals: {
     $: true,
@@ -15,7 +16,7 @@ module.exports = {
     window: true,
   },
   plugins: [
-    'chai-friendly',
+    "jest",
   ],
   rules: {
     'indent': [
@@ -35,8 +36,5 @@ module.exports = {
     ],
     // eslint-plugin-import rules
     'import/no-unresolved': [2, { ignore: ['^@'] }], // ignore @alias/..., @commons/..., ...
-    // eslint-plugin-chai-friendly rules
-    'no-unused-expressions': 0,
-    'chai-friendly/no-unused-expressions': 2,
   },
 };

+ 46 - 1
CHANGES.md

@@ -1,12 +1,57 @@
 # CHANGES
 
-## 3.4.8-RC
+## 3.5.1-RC
+
+### BREAKING CHANGES
+
+* GROWI no longer supports plugins with schema version 2
+* The restriction mode of the root page (`/`) will be set 'Public'
+* The restriction mode of the root page (`/`) can not be changed after v 3.5.1
+
+### Updates
+
+* Support: Use Babel 7
+* Support: Support plugins with schema version 3
+
+
+## 3.5.0
+
+### BREAKING CHANGES
+
+* GROWI no longer supports
+    * Protection system with Basic Authentication
+    * Crowi Classic Authentication Mechanism
+    * [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
+
+Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
+
+### Updates
 
 * Feature: Comment Thread
+* Feature: OpenID Connect authentication
+* Feature: HTTP Basic authentication
+* Feature: Staff Credits with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
+* Feature: Restricte Complete Deletion of Pages
 * Improvement Draft list
+* Fix: Deleting page completely
+* Fix: Search with `prefix:` param with CJK pathname
+* I18n: User Management Details
+* I18n: Group Management Details
 * Support: Apply unstated
+* Support: Abolish Old Config API
+* Support: Apply Jest for Tests
 * Support: Upgrade libs
+    * async
+    * axios
+    * connect-mongo
+    * file-loader
+    * googleapis
+    * i18next
+    * migrate-mongo
     * mini-css-extract-plugin
+    * mongoose
+    * mongoose-gridfs
+    * mongoose-unique-validator
     * null-loader
 
 ## 3.4.7

+ 1 - 0
README.md

@@ -201,6 +201,7 @@ Documentation
 ==============
 
 - [GROWI Docs](https://docs.growi.org/)
+- [GROWI Developers Wiki (ja)](https://dev.growi.org/)
 
 
 Contribution

+ 1 - 1
THIRD-PARTY-NOTICES.md

@@ -96,4 +96,4 @@ https://creativecommons.org/licenses/by-sa/3.0/
 
 ```
 Copyright (c) 2018 Stephen Hutchings
-```
+```

+ 23 - 0
babel.config.js

@@ -0,0 +1,23 @@
+module.exports = function(api) {
+  api.cache(true);
+
+  const presets = [
+    [
+      '@babel/preset-env',
+      {
+        targets: {
+          node: 'current',
+        },
+      },
+    ],
+    '@babel/preset-react',
+  ];
+  const plugins = [
+    'lodash',
+  ];
+
+  return {
+    presets,
+    plugins,
+  };
+};

+ 6 - 1
bin/generate-plugin-definitions-source.js

@@ -4,13 +4,14 @@
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
 require('module-alias/register');
+const logger = require('@alias/logger')('growi:bin:generate-plugin-definitions-source');
 
 const fs = require('graceful-fs');
 const normalize = require('normalize-path');
 const swig = require('swig-templates');
 
 const helpers = require('@commons/util/helpers');
-const PluginUtils = require('../src/server/plugins/plugin-utils');
+const PluginUtils = require('@server/plugins/plugin-utils');
 
 const pluginUtils = new PluginUtils();
 
@@ -20,12 +21,16 @@ const OUT = helpers.root('tmp/plugins/plugin-definitions.js');
 
 // list plugin names
 let pluginNames = pluginUtils.listPluginNames(helpers.root());
+logger.info('Detected plugins: ', pluginNames);
+
 // add from PLUGIN_NAMES_TOBE_LOADED when development
 if (process.env.NODE_ENV === 'development'
     && process.env.PLUGIN_NAMES_TOBE_LOADED !== undefined
     && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
   const pluginNamesDev = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
 
+  logger.info('Detected plugins from PLUGIN_NAMES_TOBE_LOADED: ', pluginNamesDev);
+
   // merge and remove duplicates
   if (pluginNamesDev.length > 0) {
     pluginNames = pluginNames.concat(pluginNamesDev);

+ 1 - 1
bin/templates/plugin-definitions.js.swig

@@ -8,7 +8,7 @@ module.exports = [
     meta: require('{{ definition.name }}'),
     entries: [
       {% for entryPath in definition.entries %}
-      require('{{ entryPath }}'),
+      require('{{ entryPath }}').default,
       {% endfor %}
     ]
   },

+ 54 - 0
config/jest.config.js

@@ -0,0 +1,54 @@
+// For a detailed explanation regarding each configuration property, visit:
+// https://jestjs.io/docs/en/configuration.html
+
+module.exports = {
+  // Indicates whether each individual test should be reported during the run
+  verbose: true,
+
+  rootDir: '../',
+
+  // Automatically clear mock calls and instances between every test
+  clearMocks: true,
+  // Automatically reset mock state between every test
+  resetMocks: true,
+
+  projects: [
+    {
+      displayName: 'server',
+      testEnvironment: 'node',
+      rootDir: '.',
+      setupFilesAfterEnv: ['<rootDir>/src/test/setup.js'],
+      testMatch: ['<rootDir>/src/test/**/*.test.js'],
+      // A map from regular expressions to module names that allow to stub out resources with a single module
+      moduleNameMapper: {
+        '@root/(.+)': '<rootDir>/$1',
+        '@commons/(.+)': '<rootDir>/src/lib/$1',
+        '@server/(.+)': '<rootDir>/src/server/$1',
+        '@alias/logger': '<rootDir>/src/lib/service/logger',
+        // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
+        // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
+      },
+    },
+    // {
+    //   displayName: 'client',
+    //   rootDir: '.',
+    //   testMatch: ['<rootDir>/src/test/client/**/*.test.js'],
+    // },
+  ],
+
+
+  // Indicates whether the coverage information should be collected while executing the test
+  // collectCoverage: false,
+
+  // An array of glob patterns indicating a set of files for which coverage information should be collected
+  collectCoverageFrom: [
+    'src/client/**/*.js',
+    'src/lib/**/*.js',
+    'src/migrations/**/*.js',
+    'src/server/**/*.js',
+  ],
+
+  // The directory where Jest should output its coverage files
+  coverageDirectory: 'coverage',
+
+};

+ 4 - 0
config/logger/config.dev.js

@@ -1,6 +1,8 @@
 module.exports = {
   default: 'info',
 
+  // 'express-session': 'debug',
+
   /*
    * configure level for server
    */
@@ -13,6 +15,7 @@ module.exports = {
   // 'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:service:PassportService': 'debug',
+  // 'growi:service:ConfigManager': 'debug',
   'growi:lib:search': 'debug',
   // 'growi:service:GlobalNotification': 'debug',
   // 'growi:lib:importer': 'debug',
@@ -28,4 +31,5 @@ module.exports = {
    */
   'growi:app': 'debug',
   'growi:services:*': 'debug',
+  // 'growi:StaffCredit': 'debug',
 };

+ 3 - 0
config/migrate.js

@@ -5,6 +5,8 @@
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
 
+require('module-alias/register');
+
 function getMongoUri(env) {
   return env.MONGOLAB_URI // for B.C.
     || env.MONGODB_URI // MONGOLAB changes their env name
@@ -15,6 +17,7 @@ function getMongoUri(env) {
 
 const mongoUri = getMongoUri(process.env);
 const match = mongoUri.match(/^(.+)\/([^/]+)$/);
+
 module.exports = {
   mongoUri,
   mongodb: {

+ 3 - 2
config/webpack.common.js

@@ -125,6 +125,7 @@ module.exports = (options) => {
 
       // ignore
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
+      new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
 
       new LodashModuleReplacementPlugin({
         flattening: true,
@@ -155,7 +156,7 @@ module.exports = (options) => {
             enforce: true,
           },
           commons: {
-            test: /src[\\/].*\.jsx?$/,
+            test: /(src|resource)[\\/].*\.(js|jsx|json)$/,
             chunks: 'initial',
             name: 'js/commons',
             minChunks: 2,
@@ -163,7 +164,7 @@ module.exports = (options) => {
             priority: 20,
           },
           vendors: {
-            test: /node_modules[\\/].*\.jsx?$/,
+            test: /node_modules[\\/].*\.(js|jsx|json)$/,
             chunks: (chunk) => {
               // ignore patterns
               return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);

+ 4 - 2
config/webpack.dev.dll.js

@@ -11,21 +11,23 @@ module.exports = {
     dlls: [
       // Libraries
       'axios',
-      'babel-polyfill',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',
       'date-fns',
       'diff2html',
       'debug',
       'entities',
+      'growi-commons',
       'i18next', 'i18next-browser-languagedetector',
       'jquery-slimscroll',
       'lodash', 'pako',
       'markdown-it', 'csv-to-markdown-table',
       'react', 'react-dom',
-      'react-bootstrap', 'react-bootstrap-typeahead', 'react-i18next', 'react-dropzone', 'react-copy-to-clipboard',
+      'react-bootstrap', 'react-bootstrap-typeahead',
+      'react-i18next', 'react-dropzone', 'react-hotkeys', 'react-copy-to-clipboard', 'react-waypoint',
       'socket.io-client',
       'toastr',
+      'unstated',
       'xss',
     ],
   },

+ 30 - 28
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.4.8-RC",
+  "version": "3.5.1-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -55,21 +55,25 @@
     "server:prod": "env-cmd -f config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",
     "start": "npm run server:prod",
-    "test": "mocha --timeout 10000 --exit -r src/test/bootstrap.js src/test/**/*.js",
+    "test": "jest --config=config/jest.config.js --passWithNoTests -- ",
     "version": "node -p \"require('./package.json').version\"",
     "webpack": "webpack"
   },
   "dependencies": {
-    "async": "^2.3.0",
+    "//": [
+      "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
+      "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0"
+    ],
+    "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
-    "axios": "^0.18.0",
+    "axios": "^0.19.0",
     "basic-auth-connect": "~1.0.0",
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
-    "check-node-version": "^3.1.1",
+    "check-node-version": "=3.3.0",
     "connect-flash": "~0.1.1",
-    "connect-mongo": "^2.0.1",
+    "connect-mongo": "^3.0.0",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
     "cross-env": "^5.0.5",
@@ -87,36 +91,36 @@
     "express-session": "^1.16.1",
     "express-validator": "^5.3.1",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^39.1.0",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^4.0.1",
     "helmet": "^3.13.0",
-    "i18next": "^15.0.9",
+    "i18next": "^17.0.3",
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^5.0.1",
+    "migrate-mongo": "^6.0.0",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
-    "mongoose": "^5.4.4",
-    "mongoose-gridfs": "^1.0.1",
+    "mongoose": "5.4.4",
+    "mongoose-gridfs": "^1.2.2",
     "mongoose-paginate": "^5.0.3",
-    "mongoose-unique-validator": "^2.0.2",
+    "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "nodemailer": "^6.0.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
+    "openid-client": "^2.5.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-google-auth": "^1.0.2",
+    "passport-http": "^0.3.0",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
-    "react-dropzone": "^10.1.3",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
@@ -129,27 +133,26 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
+    "@babel/core": "^7.4.5",
+    "@babel/polyfill": "^7.4.4",
+    "@babel/preset-env": "^7.4.5",
+    "@babel/preset-react": "^7.0.0",
     "@handsontable/react": "^2.0.0",
     "autoprefixer": "^9.0.0",
-    "babel-core": "^6.25.0",
     "babel-eslint": "^10.0.1",
-    "babel-loader": "^7.1.1",
-    "babel-plugin-lodash": "^3.3.2",
-    "babel-plugin-transform-runtime": "^6.23.0",
-    "babel-polyfill": "^6.26.0",
-    "babel-preset-env": "^1.6.0",
-    "babel-preset-react": "^6.24.1",
+    "babel-loader": "^8.0.6",
+    "babel-plugin-lodash": "^3.3.4",
     "bootstrap-sass": "^3.4.1",
     "bootstrap-select": "^1.12.4",
     "browser-bunyan": "^1.3.0",
     "browser-sync": "^2.26.3",
     "bunyan-debug": "^2.0.0",
-    "chai": "^4.1.0",
     "cli": "~1.0.1",
     "codemirror": "^5.42.0",
     "colors": "^1.2.5",
     "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
+    "core-js": "^2.6.9",
     "css-loader": "^1.0.0",
     "csv-to-markdown-table": "^0.5.0",
     "date-fns": "^1.29.0",
@@ -157,13 +160,14 @@
     "eazy-logger": "^3.0.2",
     "eslint": "^5.15.1",
     "eslint-config-weseek": "^1.0.1",
-    "eslint-plugin-chai-friendly": "^0.4.1",
     "eslint-plugin-import": "^2.16.0",
+    "eslint-plugin-jest": "^22.6.4",
     "eslint-plugin-react": "^7.12.4",
-    "file-loader": "^3.0.1",
+    "file-loader": "^4.0.0",
     "handsontable": "^6.0.1",
     "i18next-browser-languagedetector": "^3.0.1",
     "imports-loader": "^0.8.0",
+    "jest": "^24.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -181,13 +185,11 @@
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
     "mini-css-extract-plugin": "^0.7.0",
-    "mocha": "^6.0.1",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-sass": "^4.11.0",
-    "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
-    "null-loader": "^2.0.0",
+    "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "penpal": "^4.0.0",
@@ -200,15 +202,15 @@
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
+    "react-dropzone": "^10.1.3",
     "react-frame-component": "^4.0.0",
+    "react-hotkeys": "^1.1.4",
     "react-i18next": "^10.6.1",
     "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.1.0",
     "simple-load-script": "^1.0.2",
-    "sinon": "^7.2.2",
-    "sinon-chai": "^3.3.0",
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.23.0",
     "stylelint-config-recess-order": "^2.0.1",

+ 0 - 21
public/images/admin/security/passport-logo.svg

@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg width="400px" height="500px" viewBox="0 0 400 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
-    <!-- Generator: Sketch 3.3.1 (12002) - http://www.bohemiancoding.com/sketch -->
-    <title>Group</title>
-    <desc>Created with Sketch.</desc>
-    <defs/>
-    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
-        <g id="passport_logo_final" sketch:type="MSLayerGroup" transform="translate(-400.000000, -350.000000)">
-            <g id="Group" sketch:type="MSShapeGroup">
-                <g id="Shape">
-                    <g transform="translate(400.000000, 350.000000)">
-                        <path d="M200,0 C89.5,0 0,89.5 0,200 L100,200 C100,144.8 144.8,100 200,100 L200,0 L200,0 Z" fill="#D6FF00"/>
-                        <path d="M400,200 C400,89.5 310.5,0 200,0 L200,100 C255.2,100 300,144.8 300,200 L400,200 L400,200 Z" fill="#34E27A"/>
-                        <path d="M200,400 C310.5,400 400,310.5 400,200 L300,200 C300,255.2 255.2,300 200,300 L200,400 L200,400 Z" fill="#00B9F1"/>
-                        <path d="M100,400 L100,200 L0,200 L0,500 L200,500 L200,400 L100,400 Z" fill="#FFFFFF"/>
-                    </g>
-                </g>
-            </g>
-        </g>
-    </g>
-</svg>

+ 8 - 0
resource/cdn-manifests.js

@@ -87,6 +87,14 @@ module.exports = {
         integrity: '',
       },
     },
+    {
+      name: 'Press Start 2P',
+      url: 'https://fonts.googleapis.com/css?family=Press+Start+2P',
+      groups: ['basis'],
+      args: {
+        integrity: '',
+      },
+    },
     {
       name: 'font-awesome',
       url: 'https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css',

+ 65 - 74
resource/locales/en-US/translation.json

@@ -14,6 +14,7 @@
   "Cancel": "Cancel",
   "Create": "Create",
   "Admin": "Admin",
+  "administrator": "Admin",
   "Tag": "Tag",
   "Tags": "Tags",
   "New": "New",
@@ -25,10 +26,7 @@
   "Page Path": "Page Path",
   "Category": "Category",
   "User": "User",
-  "User Name": "User Name",
-  "User List": "User List",
-  "Add": "Add",
-  "Method": "Method",
+  "status":"Status",
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -51,7 +49,7 @@
 
   "Created": "Created",
   "Last updated": "Updated",
-  "Last Login": "Last Login",
+  "Last_Login": "Last Login",
 
   "Share": "Share",
   "Share Link": "Share Link",
@@ -83,9 +81,7 @@
   "Delete this image?": "Delete this image?",
   "Updated": "Updated",
   "Upload new image": "Upload new image",
-  "Google Setting": "Google Setting",
   "Connected": "Connected",
-  "Disconnect": "Disconnect",
   "Show": "Show",
   "Hide": "Hide",
   "Disclose E-mail": "Disclose E-mail",
@@ -108,7 +104,7 @@
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
   "Notification Settings": "Notification Settings",
-  "User Management": "User Management",
+  "User_Management": "User Management",
   "External Account management": "External Account management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
@@ -126,7 +122,6 @@
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-  "Selecting authentication mechanism": "Selecting authentication mechanism",
   "Add tags for this page": "Add tags for this page",
   "Edit tags for this page": "Edit tags for this page",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
@@ -151,15 +146,13 @@
   },
 
   "breaking_changes": {
-    "v346_passport_is_not_enabled": "Crowi Classic Authentication mechanism currently in use will <strong>no longer be supported</strong> in the near future. Switch to Passport from %s",
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
   },
 
   "page_register": {
     "notice": {
       "restricted": "Admin approval required.",
-      "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki.",
-      "google_account_continue": "Enter your user ID, name and password to continue."
+      "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
     },
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
@@ -171,11 +164,7 @@
   "page_me": {
     "form_help": {
       "profile_image1": "Image upload settings not completed.",
-      "profile_image2": "Set up AWS or enable local uploads.",
-      "google_connect1": "With Google Connect, you can sign in with your Google Account.",
-      "google_connect2": "Only Google Apps accounts with the following email addresses are connectable Google accounts:",
-      "google_disconnect1": "If you disconnect your Google account, you will be unable to sign in using Google Authentication",
-      "google_disconnect2": "After disconnecting your Google account, you can sign in normally using your email and password"
+      "profile_image2": "Set up AWS or enable local uploads."
     }
   },
   "page_me_apitoken": {
@@ -197,7 +186,7 @@
   "Re-enter new password": "Re-enter new password",
   "Password is not set": "Password is not set",
 
-  "Security Settings": "Security Settings",
+  "security_settings": "Security Settings",
 
   "API Settings": "API Settings",
   "API Token Settings": "API Token Settings",
@@ -272,7 +261,8 @@
   "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. "
+    "outdated": "Page is updated someone and now outdated.",
+    "user_not_admin": "Only admin user can delete completely"
   },
 
   "modal_rename": {
@@ -293,15 +283,13 @@
   "Delete Completely": "Delete Completely",
 
   "modal_delete": {
-    "label": {
-      "Delete Page": "Delete Page",
-      "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"
-    }
+    "delete_page": "Delete Page",
+    "deleting_page": "Deleting Page",
+    "delete_recursively": "Delete child pages under %s recursively.",
+    "delete_completely": "Delete Completely",
+    "delete_completely_restriction": "You have no admin to delete completely.",
+    "recursively": "Delete children of under <code>%s</code> recursively.",
+    "completely": "Delete completely instead of putting it into trash."
   },
 
   "modal_duplicate": {
@@ -327,7 +315,10 @@
       "title": "Global shortcuts",
       "Open/Close shortcut help": "Open/Close shortcut help",
       "Edit Page": "Edit Page",
-      "Create Page": "Create Page"
+      "Create Page": "Create Page",
+      "Show Contributors": "Show Contributors",
+      "Konami Code": "Konami Code",
+      "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
     },
     "editor": {
       "title": "Editor shortcuts",
@@ -442,12 +433,11 @@
   },
 
   "security_setting": {
-		"Basic authentication": "Basic authentication",
+		"Basic authentication": "Basic Authentication",
 		"Security settings": "Security settings",
 		"Guest users access": "Guest users access",
 		"Register limitation": "Register limitation",
 		"The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-		"Selecting authentication mechanism": "Selecting authentication mechanism",
 		"common_authentication": "If you set the basic authentication, common authentication is applied on the whole page.",
 		"without_encryption": "Please be noted that your ID and Password will be sent wihtout encryption.",
 		"basic_acl_disable": "Because of Public Wiki  setting, basic authentication can not be used.",
@@ -461,45 +451,30 @@
     "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User Group",
     "page_listing_2_desc": "Show pages that are restricted by User Group when listing/searching",
+    "complete_deletion": "Restrict Complete Deletion of Pages",
+    "complete_deletion_explain": "Restricts users who can completely delete pages.",
+    "admin_only": "Admin Only",
+    "admin_and_author": "Admin and Author",
+    "anyone": "Anyone",
 
-		"Authentication mechanism settings": "Authentication mechanism settings",
-    "note": "Note",
-    "require_server_restart_change_auth": "Restarting the server is required if you switch the auth mechanism.",
-    "auth_mechanism": "authentication mechanism",
-    "recommended": "Recommended",
-    "username_email_password": "Username, Email and Password authentication",
+		"Authentication mechanism settings": "Authentication Mechanism Settings",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the %s",
-    "ldap_auth": "LDAP authentication",
-    "saml_auth": "SAML authentication",
-    "google_auth2": "Google OAuth authentication",
-    "google_auth2_by_crowi_desc": "However, this feature does not create new users, butit only makes it possible to login to the existing user who set up the association.",
-    "facebook_auth2": "Facebook OAuth authentication",
-    "twitter_auth2": "Twitter OAuth authentication",
-    "github_auth2": "GitHub OAuth authentication",
-    "crowi_auth": "Crowi classic authentication mechanism",
-		"require_server_restart": "Restarting the server is required.",
-		"server_on_passport_auth": "The server is running with Passport authentication mechanism.",
-		"server_on_crowi_auth": "The server is running with official Crowi authentication mechanism.",
-		"google_setting": "Google Setting",
-    "connect_api_manager": "You can use your Google account to sign up and login after creating OAuth2 ClientId at <a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager on Google Cloud Platform</a>",
-		"access_api_manager": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
-		"create_project": "Create Project if no projects have been created.",
-		"create_auth_to_oauth": "\"Create credentials\" -> \"OAuth clientID\"",
-		"select_webapp": "Select \"Web Application\"",
-    "change_redirect_url": "Enter <code>https://${crowi.host}/google/callback</code> <br>(where <code>${crowi.host}</code> is your host name) for \"Authorized redirect URIs\".",
-    "clientID": "Client ID",
-    "client_secret": "Client Secret",
     "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting_link":"Go to Markdown settings",
     "callback_URL": "Callback URL",
+    "providerName": "Provider Name",
+    "issuerHost": "Issuer Host",
+    "scope": "Scope",
     "desc_of_callback_URL": "Use it in the setting of the %s provider",
+    "clientID": "Client ID",
+    "client_secret": "Client Secret",
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "readonly": "View Only"
     },
     "registration_mode": {
       "open": "Anyone",
-      "restricted": "Reuqire Admin permission",
+      "restricted": "Require Admin permission",
       "closed": "Invitation Only"
     },
     "configuration": " Configuration",
@@ -535,8 +510,9 @@
       "group_search_base_DN_detail": "The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.",
       "group_search_filter": "Group Search Filter",
       "group_search_filter_detail1": "The query used to filter for groups.",
-      "group_search_filter_detail2": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
-      "group_search_filter_detail3": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
+      "group_search_filter_detail2": "Login via LDAP is accepted only when this query hits one or more groups.",
+      "group_search_filter_detail3": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
+      "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
       "group_search_user_DN_property": "User DN Property",
       "group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
       "test_config": "Test Saved Configuration"
@@ -578,10 +554,21 @@
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
+      "OIDC": {
+        "name": "OpenID Connect",
+        "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
+        "username_detail": "Specification of mappings for <code>username</code> when creating new users",
+        "name_detail": "Specification of mappings for <code>name</code> when creating new users",
+        "mapping_detail": "Specification of mappings for %s when creating new users",
+        "register_1": "Contant to OIDC IdP Administrator",
+        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_3": "Copy and paste your ClientID and Client Secret above"
+      },
       "how_to": {
         "google": "How to configure Google OAuth?",
         "github": "How to configure GitHub OAuth?",
-        "twitter": "How to configure Twitter OAuth?"
+        "twitter": "How to configure Twitter OAuth?",
+        "oidc": "How to configure OIDC?"
       }
     },
     "form_item_name": {
@@ -678,33 +665,37 @@
   },
 
   "user_management": {
-    "User management": "User management",
-    "invite_users": "Invite new users",
+    "target_user": "Target User",
+    "invite_users": "Invite New Users",
     "emails": "Emails",
     "invite_thru_email": "Send Invitation Email",
     "invite": "Invite",
-    "give_admin_access": "Give admin access",
-    "remove_admin_access": "Remove admin access",
-    "external_account": "External account management",
+    "invited": "User was invited",
+    "give_admin_access": "Give Admin Access",
+    "remove_admin_access": "Remove Admin Access",
+    "external_account": "External Account Management",
     "external_account_list": "External Account List",
     "back_to_user_management": "Back to User Management",
     "authentication_provider": "Authentication Provider",
-    "Manage": "Manage",
-    "Edit menu": "Edit menu",
+    "manage": "Manage",
+    "edit_menu": "Edit Menu",
     "password_setting": "Password Setting",
+    "password_setting_help": "Is password set?",
     "set": "Yes",
     "unset": "No",
-    "password_setting_help": "Show whether the related user has a password set",
-    "Reissue password": "Reissue password",
+    "temporary_password": "The created user has a temporary password",
+    "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
+    "send_new_password": "Please send the new password to the user.",
+    "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+    "reset_password": "Reset Password",
     "related_username": "Related user's <code>%s</code>",
-    "Status":"Status",
     "accept": "Accept",
-    "Deactivate account":"Deactivate account",
+    "deactivate_account":"Deactivate Account",
     "your_own":"You cannot deactivate your own account",
-    "Administrator menu":"Administrator menu",
+    "administrator_menu":"Administrator Menu",
     "cannot_remove":"You cannot remove yourself from administrator",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
-    "current users": "Current users:"
+    "current_users": "Current users:"
   },
 
   "user_group_management": {

+ 42 - 67
resource/locales/ja/translation.json

@@ -14,6 +14,7 @@
   "Cancel": "キャンセル",
   "Create": "作成",
   "Admin": "管理",
+  "administrator": "管理者",
   "Tag": "タグ",
   "Tags": "タグ",
   "New": "作成",
@@ -25,10 +26,7 @@
   "Page Path": "ページパス",
   "Category": "カテゴリー",
   "User": "ユーザー",
-  "User Name": "ユーザーネーム",
-  "User List": "ユーザーリスト",
-  "Add": "追加",
-  "Method": "方法",
+  "status": "ステータス",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -51,7 +49,7 @@
 
   "Created": "作成日",
   "Last updated": "最終更新",
-  "Last Login": "最終ログイン",
+  "Last_Login": "最終ログイン",
 
   "Share": "共有",
   "Share Link": "共有用リンク",
@@ -83,9 +81,7 @@
   "Delete this image?": "削除してよろしいですか?",
   "Updated": "更新しました",
   "Upload new image": "新しい画像をアップロード",
-  "Google Setting": "Google設定",
   "Connected": "接続されています",
-  "Disconnect": "接続を解除",
   "Show": "公開",
   "Hide": "非公開",
   "Disclose E-mail": "メールアドレスの公開",
@@ -108,7 +104,7 @@
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Notification Settings": "通知設定",
-  "User Management": "ユーザー管理",
+  "User_Management": "ユーザー管理",
   "External Account management": "外部アカウント管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
@@ -126,7 +122,6 @@
   "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
-  "Selecting authentication mechanism": "認証機構選択",
   "Add tags for this page": "タグを付ける",
   "Edit tags for this page": "タグを編集する",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
@@ -151,15 +146,13 @@
   },
 
   "breaking_changes": {
-    "v346_passport_is_not_enabled": "現在利用中の Crowi Classic Authentication mechanism は、近い将来<strong>サポートされなくなります</strong>。%s から Passport に切り替えてください。",
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
   },
 
   "page_register": {
     "notice": {
        "restricted": "この Wiki への新規登録は制限されています。",
-       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。",
-       "google_account_continue": "ユーザーID、名前、パスワードを決めて登録を継続してください。"
+       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
     },
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
@@ -171,11 +164,7 @@
   "page_me": {
     "form_help": {
       "profile_image1": "画像をアップロードをするための設定がされていません。",
-      "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。",
-      "google_connect1": "Googleコネクトをすると、Googleアカウントでログイン可能になります。",
-      "google_connect2": "コネクト可能なGoogleアカウントは、以下のメールアドレスの発行できるGoogle Appsアカウントに限られます。",
-      "google_disconnect1": "接続を解除すると、Googleでログインができなくなります。",
-      "google_disconnect2": "解除後はメールアドレスとパスワードでログインすることができます。"
+      "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。"
     }
   },
   "page_me_apitoken": {
@@ -197,7 +186,7 @@
   "Re-enter new password": "(確認用)",
   "Password is not set": "パスワードが設定されていません",
 
-  "Security Settings": "セキュリティ設定",
+  "security_settings": "セキュリティ設定",
 
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
@@ -272,7 +261,8 @@
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
-    "outdated": "ページが他のユーザーによって更新されました。"
+    "outdated": "ページが他のユーザーによって更新されました。",
+    "user_not_admin": "権限のあるユーザーのみが完全削除できます"
   },
 
   "modal_rename": {
@@ -293,15 +283,13 @@
   "Delete Completely": "完全削除",
 
   "modal_delete": {
-    "label": {
-      "Delete Page": "ページを削除する",
-      "Delete recursively": "全ての子ページも削除",
-      "Delete completely": "完全削除"
-    },
-    "help": {
-      "recursively": "<code>%s</code> 配下のページも削除します",
-      "completely": "ゴミ箱を経由せず、完全に削除します"
-    }
+    "delete_page": "ページを削除する",
+    "deleting_page": "ページパス",
+    "delete_recursively": "全ての子ページも削除",
+    "delete_completely": "完全削除",
+    "delete_completely_restriction": "完全削除をするための権限がありません。",
+    "recursively": "<code>%s</code> 配下のページも削除します",
+    "completely": "ゴミ箱を経由せず、完全に削除します"
   },
 
   "modal_duplicate": {
@@ -327,7 +315,10 @@
       "title": "グローバルショートカット",
       "Open/Close shortcut help": "ショートカットヘルプの表示/非表示",
       "Edit Page": "ページ編集",
-      "Create Page": "ページ作成"
+      "Create Page": "ページ作成",
+      "Show Contributors": "コントリビューターを表示",
+      "Konami Code": "コナミコマンド",
+      "konami_code_url": "https://ja.wikipedia.org/wiki/コナミコマンド"
     },
     "editor": {
       "titile": "エディターショートカット",
@@ -443,11 +434,9 @@
 
   "security_setting": {
     "Basic authentication": "Basic認証",
-    "Security settings": "セキュリティ設定",
     "Guest users access": "ゲストユーザーのアクセス",
     "Register limitation": "登録の制限",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
-    "Selecting authentication mechanism": "認証機構選択",
     "common_authentication": "Basic認証を設定すると、ページ全体に共通の認証がかかります。",
     "without_encryption": "IDとパスワードは暗号化されずに送信されるのでご注意下さい。",
     "basic_acl_disable": "Public Wiki の設定のため、Basic認証は利用できません。",
@@ -461,38 +450,20 @@
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "complete_deletion": "ページの完全削除",
+    "complete_deletion_explain": "ページを完全に削除できるユーザーを制限します。",
+    "admin_only": "管理者のみ可能",
+    "admin_and_author": "管理者とページ作者が可能",
+    "anyone": "誰でも可能",
 
     "Authentication mechanism settings":"認証機構設定",
-    "note": "メモ",
-    "require_server_restart_change_auth": "認証機構の変更後はサーバーを再起動してください。",
-    "auth_mechanism": "認証機構",
-    "recommended": "推奨",
-    "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。%s から設定してください。",
-    "ldap_auth": "LDAP 認証",
-    "saml_auth": "SAML 認証",
-    "google_auth2": "Google OAuth 認証",
-    "google_auth2_by_crowi_desc": "ただし、この機能では新たなユーザーは作成されず、関連付け設定を行った既存ユーザーをログインできるようにするだけです。",
-    "facebook_auth2": "Facebook OAuth 認証",
-    "twitter_auth2": "Twitter OAuth 認証",
-    "github_auth2": "GitHub OAuth 認証",
-    "crowi_auth": "Crowi Classic OAuth 認証",
-    "require_server_restart": "サーバーを再起動してください。",
-    "server_on_passport_auth": "Passport 認証機構でサーバーが稼働しています。",
-    "server_on_crowi_auth": "Crowi Classic 認証機構でサーバーが稼働しています。",
-    "google_setting": "Google 設定",
-    "connect_api_manager": "Google Cloud Platform の <a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager</a>から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。",
-    "access_api_manager": "<a href=\"%s\" target=\"_blank\">%s</a> へアクセス",
-    "create_project": "プロジェクトを作成していない場合は作成してください",
-    "create_auth_to_oauth": "「認証情報を作成」-> OAuthクライアントID",
-    "select_webapp": "「ウェブアプリケーション」を選択",
-    "change_redirect_url": "承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>(<code>${crowi.host}</code>は環境に合わせて変更してください)",
-    "clientID": "クライアントID",
-    "client_secret": "クライアントシークレット",
     "xss_prevent_setting":"XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link":"マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "desc_of_callback_URL": "%s プロバイダ側の設定で利用してください。",
+    "clientID": "クライアントID",
+    "client_secret": "クライアントシークレット",
     "guest_mode": {
       "deny": "アカウントを持たないユーザーはアクセス不可",
       "readonly": "閲覧のみ許可"
@@ -535,8 +506,9 @@
       "group_search_base_DN_detail": "グループ検索を実行するベース DN。利用する場合は <code>グループ検索フィルター</code> も入力する必要があります。",
       "group_search_filter": "グループ検索フィルター",
       "group_search_filter_detail1": "グループフィルターに用いるクエリ",
-      "group_search_filter_detail2": "ログイン対象ユーザーオブジェクトのプロパティーで置換する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
-      "group_search_filter_detail3": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
+      "group_search_filter_detail2": "このクエリにヒットするグループがあったときのみ、LDAPでのログインが成功します。",
+      "group_search_filter_detail3": "ログイン対象ユーザーオブジェクトのプロパティーで置換する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
+      "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
       "group_search_user_DN_property": "ユーザーの DN プロパティー",
       "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
       "test_config": "ログインテスト"
@@ -678,34 +650,37 @@
   },
 
   "user_management": {
-    "User Management": "ユーザー管理",
+    "target_user": "対象ユーザー",
     "invite_users": "新規ユーザーの招待",
     "emails": "メールアドレス (複数行入力で複数人招待可能)",
     "invite_thru_email": "招待をメールで送信",
     "invite": "招待する",
+    "invited": "ユーザーを招待しました",
     "give_admin_access": "管理者にする",
     "remove_admin_access": "管理者から外す",
     "external_account": "外部アカウントの管理",
-    "user_list": "ユーザー一覧",
     "external_account_list": "外部アカウント一覧",
     "back_to_user_management": "ユーザー管理に戻る",
     "authentication_provider": "認証情報プロバイダ",
-    "Manage": "操作",
-    "Edit menu": "編集メニュー",
+    "manage": "操作",
+    "edit_menu": "編集メニュー",
     "password_setting": "パスワード設定",
     "password_setting_help": "関連付けられているユーザーがパスワードを設定しているかどうかを表示します",
     "set": "設定済み",
     "unset": "未設定",
-    "Reissue password": "パスワードの再発行",
+    "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
+    "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
+    "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
+    "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
+    "reset_password": "パスワードの再発行",
     "related_username": "関連付けられているユーザーの <code>%s</code>",
-    "Status": "ステータス",
     "accept": "承認する",
-    "Deactivate account": "アカウント停止",
+    "deactivate_account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
-    "Administrator menu": "管理者メニュー",
+    "administrator_menu": "管理者メニュー",
     "cannot_remove": "自分自身を管理者から外すことはできません",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
-    "current users": "現在のユーザー数:"
+    "current_users": "現在のユーザー数:"
   },
 
   "user_group_management": {

+ 10 - 164
src/client/js/app.js

@@ -4,7 +4,6 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
-import * as toastr from 'toastr';
 
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
@@ -31,6 +30,7 @@ import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
+import StaffCredit from './components/StaffCredit/StaffCredit';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 
@@ -75,157 +75,6 @@ appContainer.injectToWindow();
 
 const i18n = appContainer.i18n;
 
-/**
- * save success handler when reloading is not needed
- * @param {object} page Page instance
- */
-const saveWithShortcutSuccessHandler = function(result) {
-  const { page, tags } = result;
-  const { editorMode } = appContainer.state;
-
-  // show toastr
-  toastr.success(undefined, 'Saved successfully', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '1200',
-    extendedTimeOut: '150',
-  });
-
-  // update state of PageContainer
-  const newState = {
-    pageId: page._id,
-    revisionId: page.revision._id,
-    revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
-    remoteRevisionId: page.revision._id,
-    revisionIdHackmdSynced: page.revisionHackmdSynced,
-    hasDraftOnHackmd: page.hasDraftOnHackmd,
-    markdown: page.revision.body,
-    tags,
-  };
-  pageContainer.setState(newState);
-
-  // update state of EditorContainer
-  editorContainer.setState({ tags });
-
-  // PageEditor component
-  const pageEditor = appContainer.getComponentInstance('PageEditor');
-  if (pageEditor != null) {
-    if (editorMode !== 'builtin') {
-      pageEditor.updateEditorValue(newState.markdown);
-    }
-  }
-  // PageEditorByHackmd component
-  const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
-  if (pageEditorByHackmd != null) {
-    // reset
-    if (editorMode !== 'hackmd') {
-      pageEditorByHackmd.reset();
-    }
-  }
-
-  // hidden input
-  $('input[name="revision_id"]').val(newState.revisionId);
-};
-
-const errorHandler = function(error) {
-  toastr.error(error.message, 'Error occured', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '3000',
-  });
-};
-
-const saveWithShortcut = function(markdown) {
-  const { editorMode } = appContainer.state;
-
-  const { pageId, path } = pageContainer.state;
-  let { revisionId } = pageContainer.state;
-
-  // get options
-  const options = editorContainer.getCurrentOptionsToSave();
-  options.socketClientId = websocketContainer.getCocketClientId();
-  options.pageTags = editorContainer.state.tags;
-
-  if (editorMode === 'hackmd') {
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-    revisionId = pageContainer.state.revisionIdHackmdSynced;
-  }
-
-  let promise;
-  if (pageId == null) {
-    promise = appContainer.createPage(path, markdown, options);
-  }
-  else {
-    promise = appContainer.updatePage(pageId, revisionId, markdown, options);
-  }
-
-  promise
-    .then(saveWithShortcutSuccessHandler)
-    .catch(errorHandler);
-};
-
-const saveWithSubmitButtonSuccessHandler = function() {
-  const { path } = pageContainer.state;
-  editorContainer.clearDraft(path);
-  window.location.href = path;
-};
-
-const saveWithSubmitButton = function(submitOpts) {
-  const { editorMode } = appContainer.state;
-  if (editorMode == null) {
-    // do nothing
-    return;
-  }
-
-  const { pageId, path } = pageContainer.state;
-  let { revisionId } = pageContainer.state;
-  // get options
-  const options = editorContainer.getCurrentOptionsToSave();
-  options.socketClientId = websocketContainer.getSocketClientId();
-  options.pageTags = editorContainer.state.tags;
-
-  // set 'submitOpts.overwriteScopesOfDescendants' to options
-  options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
-
-  let promise;
-  if (editorMode === 'hackmd') {
-    const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
-    // get markdown
-    promise = pageEditorByHackmd.getMarkdown();
-    // use revisionId of PageEditorByHackmd
-    revisionId = pageContainer.state.revisionIdHackmdSynced;
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-  }
-  else {
-    const pageEditor = appContainer.getComponentInstance('PageEditor');
-    // get markdown
-    promise = Promise.resolve(pageEditor.getMarkdown());
-  }
-  // create or update
-  if (pageId == null) {
-    promise = promise.then((markdown) => {
-      return appContainer.createPage(path, markdown, options);
-    });
-  }
-  else {
-    promise = promise.then((markdown) => {
-      return appContainer.updatePage(pageId, revisionId, markdown, options);
-    });
-  }
-
-  promise
-    .then(saveWithSubmitButtonSuccessHandler)
-    .catch(errorHandler);
-};
-
 /**
  * define components
  *  key: id of element
@@ -241,19 +90,21 @@ let componentMappings = {
 
   'create-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} addTrailingSlash />,
 
-  'page-editor': <PageEditor onSaveWithShortcut={saveWithShortcut} />,
+  'page-editor': <PageEditor />,
   'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
   'page-status-alert': <PageStatusAlert />,
-  'save-page-controls': <SavePageControls onSubmit={saveWithSubmitButton} />,
+  'save-page-controls': <SavePageControls />,
 
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
+
+  'staff-credit': <StaffCredit />,
 };
 
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
   componentMappings = Object.assign({
-    'page-editor-with-hackmd': <PageEditorByHackmd onSaveWithShortcut={saveWithShortcut} />,
+    'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
     'page-attachment':  <PageAttachment />,
     'page-comment-write':  <CommentEditorLazyRenderer />,
@@ -264,13 +115,15 @@ if (pageContainer.state.pageId != null) {
     'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
     'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
     'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+
+    'admin-rebuild-search': <AdminRebuildSearch crowi={appContainer} />,
   }, componentMappings);
 }
 if (pageContainer.state.path != null) {
   componentMappings = Object.assign({
     // eslint-disable-next-line quote-props
-    'page': <Page onSaveWithShortcut={saveWithShortcut} />,
-    'revision-path':  <RevisionPath pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} crowi={appContainer} />,
+    'page': <Page />,
+    'revision-path': <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} />,
     'tag-label':  <TagLabels />,
   }, componentMappings);
 }
@@ -320,13 +173,6 @@ if (customHeaderEditorElem != null) {
     customHeaderEditorElem,
   );
 }
-const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
-if (adminRebuildSearchElem != null) {
-  ReactDOM.render(
-    <AdminRebuildSearch crowi={appContainer} />,
-    adminRebuildSearchElem,
-  );
-}
 
 const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
 if (adminUserGroupPageElem != null) {

+ 15 - 3
src/client/js/components/Admin/AdminRebuildSearch.jsx

@@ -1,7 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-export default class AdminRebuildSearch extends React.Component {
+import { createSubscribedElement } from '../UnstatedUtils';
+import WebsocketContainer from '../../services/AppContainer';
+
+class AdminRebuildSearch extends React.Component {
 
   constructor(props) {
     super(props);
@@ -15,7 +18,7 @@ export default class AdminRebuildSearch extends React.Component {
   }
 
   componentDidMount() {
-    const socket = this.props.crowi.getWebSocket();
+    const socket = this.props.webspcketContainer.getWebSocket();
 
     socket.on('admin:addPageProgress', (data) => {
       const newStates = Object.assign(data, { isCompleted: false });
@@ -65,6 +68,15 @@ export default class AdminRebuildSearch extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const AdminRebuildSearchWrapper = (props) => {
+  return createSubscribedElement(AdminRebuildSearch, props, [WebsocketContainer]);
+};
+
 AdminRebuildSearch.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  webspcketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
 };
+
+export default AdminRebuildSearchWrapper;

+ 31 - 6
src/client/js/components/Page.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import { createSubscribedElement } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
+import EditorContainer from '../services/EditorContainer';
 
 import MarkdownTable from '../models/MarkdownTable';
 
@@ -11,6 +13,8 @@ import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import mtu from './PageEditor/MarkdownTableUtil';
 
+const logger = loggerFactory('growi:Page');
+
 class Page extends React.Component {
 
   constructor(props) {
@@ -25,6 +29,10 @@ class Page extends React.Component {
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
 
+  componentWillMount() {
+    this.props.appContainer.registerComponentInstance('Page', this);
+  }
+
   /**
    * launch HandsontableModal with data specified by arguments
    * @param beginLineNumber
@@ -37,15 +45,33 @@ class Page extends React.Component {
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
-  saveHandlerForHandsontableModal(markdownTable) {
+  async saveHandlerForHandsontableModal(markdownTable) {
+    const { pageContainer, editorContainer } = this.props;
+
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
       this.props.pageContainer.state.markdown,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
     );
-    this.props.onSaveWithShortcut(newMarkdown);
-    this.setState({ currentTargetTableArea: null });
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(newMarkdown);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+    finally {
+      this.setState({ currentTargetTableArea: null });
+    }
   }
 
   render() {
@@ -66,15 +92,14 @@ class Page extends React.Component {
  * Wrapper component for using unstated
  */
 const PageWrapper = (props) => {
-  return createSubscribedElement(Page, props, [AppContainer, PageContainer]);
+  return createSubscribedElement(Page, props, [AppContainer, PageContainer, EditorContainer]);
 };
 
 
 Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
 export default PageWrapper;

+ 3 - 3
src/client/js/components/Page/RevisionPath.jsx

@@ -26,8 +26,8 @@ class RevisionPath extends React.Component {
     this.setState({ isListPage });
 
     // whether set link to '/'
-    const behaviorType = this.props.crowi.getConfig().behaviorType;
-    const isLinkToListPage = (!behaviorType || behaviorType === 'crowi');
+    const { behaviorType } = this.props;
+    const isLinkToListPage = (behaviorType === 'crowi');
     this.setState({ isLinkToListPage });
 
     // generate pages obj
@@ -127,7 +127,7 @@ class RevisionPath extends React.Component {
 
 RevisionPath.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  behaviorType: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
 };

+ 1 - 1
src/client/js/components/PageAttachment.js → src/client/js/components/PageAttachment.jsx

@@ -49,7 +49,7 @@ class PageAttachment extends React.Component {
   }
 
   checkIfFileInUse(attachment) {
-    const { markdown } = this.pageContainer.state;
+    const { markdown } = this.props.pageContainer.state;
 
     if (markdown.match(attachment.filePathProxied)) {
       return true;

+ 2 - 8
src/client/js/components/PageComments.jsx

@@ -74,12 +74,6 @@ class PageComments extends React.Component {
 
   deleteComment() {
     const comment = this.state.commentToDelete;
-    const comments = this.props.commentContainer.state.comments;
-    comments.forEach((reply) => {
-      if (reply.replyTo === comment._id) {
-        this.props.commentContainer.deleteComment(reply);
-      }
-    });
 
     this.props.commentContainer.deleteComment(comment)
       .then(() => {
@@ -161,10 +155,10 @@ class PageComments extends React.Component {
                       <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
                         <Button
                           bsStyle="primary"
-                          className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b"
+                          className="fcbtn btn btn-outline btn-rounded btn-xxs"
                           onClick={() => { return this.replyButtonClickedHandler(commentId) }}
                         >
-                          <i className="icon-bubble"></i> Reply
+                          Reply <i className="fa fa-mail-reply"></i>
                         </Button>
                       </div>
                     )

+ 44 - 47
src/client/js/components/PageEditor.jsx

@@ -1,10 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import { throttle, debounce } from 'throttle-debounce';
 
-import * as toastr from 'toastr';
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 
@@ -14,6 +13,7 @@ import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import EditorContainer from '../services/EditorContainer';
 
+const logger = loggerFactory('growi:PageEditor');
 
 class PageEditor extends React.Component {
 
@@ -35,15 +35,13 @@ class PageEditor extends React.Component {
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
-    this.onSave = this.onSave.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
-    this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
 
     // get renderer
     this.growiRenderer = this.props.appContainer.getRenderer('editor');
@@ -59,28 +57,13 @@ class PageEditor extends React.Component {
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
-
   }
 
   componentWillMount() {
-    this.props.appContainer.registerComponentInstance(this);
+    this.props.appContainer.registerComponentInstance('PageEditor', this);
 
     // initial rendering
     this.renderPreview(this.state.markdown);
-
-    window.addEventListener('beforeunload', this.showUnsavedWarning);
-  }
-
-  componentWillUnmount() {
-    window.removeEventListener('beforeunload', this.showUnsavedWarning);
-  }
-
-  showUnsavedWarning(e) {
-    if (!this.props.appContainer.getIsDocSaved()) {
-      // display browser default message
-      e.returnValue = '';
-      return '';
-    }
   }
 
   getMarkdown() {
@@ -111,12 +94,32 @@ class PageEditor extends React.Component {
   onMarkdownChanged(value) {
     this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
-    this.props.appContainer.setIsDocSaved(false);
   }
 
-  onSave() {
-    this.props.onSaveWithShortcut(this.state.markdown);
-    this.props.appContainer.setIsDocSaved(true);
+  /**
+   * save and update state of containers
+   */
+  async onSaveWithShortcut() {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(this.state.markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
   }
 
   /**
@@ -124,9 +127,10 @@ class PageEditor extends React.Component {
    * @param {any} file
    */
   async onUpload(file) {
+    const { appContainer, pageContainer } = this.props;
+
     try {
-      let res = await this.props.appContainer.apiGet('/attachments.limit', {
-        _csrf: this.props.appContainer.csrfToken,
+      let res = await appContainer.apiGet('/attachments.limit', {
         fileSize: file.size,
       });
 
@@ -135,12 +139,15 @@ class PageEditor extends React.Component {
       }
 
       const formData = new FormData();
-      formData.append('_csrf', this.props.appContainer.csrfToken);
+      const { pageId, path } = pageContainer.state;
+      formData.append('_csrf', appContainer.csrfToken);
       formData.append('file', file);
-      formData.append('path', this.props.pageContainer.state.path);
-      formData.append('page_id', this.state.pageId || 0);
+      formData.append('path', path);
+      if (pageId != null) {
+        formData.append('page_id', pageContainer.state.pageId);
+      }
 
-      res = await this.props.appContainer.apiPost('/attachments.add', formData);
+      res = await appContainer.apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
@@ -154,11 +161,13 @@ class PageEditor extends React.Component {
 
       // when if created newly
       if (res.pageCreated) {
-        // do nothing
+        logger.info('Page is created', res.pageCreated._id);
+        pageContainer.updateStateAfterSave(res.page);
       }
     }
     catch (e) {
-      this.apiErrorHandler(e);
+      logger.error('failed to upload', e);
+      pageContainer.showErrorToastr(e);
     }
     finally {
       this.editor.terminateUploadingState();
@@ -268,6 +277,7 @@ class PageEditor extends React.Component {
     if (!pageContainer.state.revisionId) {
       editorContainer.saveDraft(pageContainer.state.path, this.state.markdown);
     }
+    editorContainer.enableUnsavedWarning();
   }
 
   clearDraft() {
@@ -309,17 +319,6 @@ 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.appContainer.getConfig();
     const noCdn = !!config.env.NO_CDN;
@@ -340,7 +339,7 @@ class PageEditor extends React.Component {
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
             onUpload={this.onUpload}
-            onSave={this.onSave}
+            onSave={this.onSaveWithShortcut}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
@@ -370,8 +369,6 @@ PageEditor.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
 };
 
 export default PageEditorWrapper;

+ 51 - 31
src/client/js/components/PageEditorByHackmd.jsx

@@ -1,17 +1,19 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
-import * as toastr from 'toastr';
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
+import EditorContainer from '../services/EditorContainer';
 
 import { createSubscribedElement } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
+const logger = loggerFactory('growi:PageEditorByHackmd');
+
 class PageEditorByHackmd extends React.Component {
 
   constructor(props) {
@@ -26,13 +28,12 @@ class PageEditorByHackmd extends React.Component {
     this.getHackmdUri = this.getHackmdUri.bind(this);
     this.startToEdit = this.startToEdit.bind(this);
     this.resumeToEdit = this.resumeToEdit.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
-
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
 
   componentWillMount() {
-    this.props.appContainer.registerComponentInstance(this);
+    this.props.appContainer.registerComponentInstance('PageEditorByHackmd', this);
   }
 
   /**
@@ -97,7 +98,9 @@ class PageEditorByHackmd extends React.Component {
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
         });
       })
-      .catch(this.apiErrorHandler)
+      .catch((err) => {
+        pageContainer.showErrorToastr(err);
+      })
       .then(() => {
         this.setState({ isInitializing: false });
       });
@@ -117,12 +120,39 @@ class PageEditorByHackmd extends React.Component {
     this.props.pageContainer.setState({ hasDraftOnHackmd: false });
   }
 
+  /**
+   * save and update state of containers
+   * @param {string} markdown
+   */
+  async onSaveWithShortcut(markdown) {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+  }
+
   /**
    * onChange event of HackmdEditor handler
    */
-  hackmdEditorChangeHandler(body) {
+  async hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer } = this.props;
+    const { pageContainer, editorContainer } = this.props;
 
     if (hackmdUri == null) {
       // do nothing
@@ -130,31 +160,22 @@ class PageEditorByHackmd extends React.Component {
     }
 
     // do nothing if contents are same
-    if (pageContainer.state.markdown === body) {
+    if (this.state.markdown === body) {
       return;
     }
 
+    // enable unsaved warning
+    editorContainer.enableUnsavedWarning();
+
     const params = {
       pageId: pageContainer.state.pageId,
     };
-    this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params)
-      .then((res) => {
-        // do nothing
-      })
-      .catch((err) => {
-        // do nothing
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
+    try {
+      await this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params);
+    }
+    catch (err) {
+      logger.error(err);
+    }
   }
 
   render() {
@@ -176,7 +197,7 @@ class PageEditorByHackmd extends React.Component {
           initializationMarkdown={isResume ? null : this.state.markdown}
           onChange={this.hackmdEditorChangeHandler}
           onSaveWithShortcut={(document) => {
-            this.props.onSaveWithShortcut(document);
+            this.onSaveWithShortcut(document);
           }}
         >
         </HackmdEditor>
@@ -292,14 +313,13 @@ class PageEditorByHackmd extends React.Component {
  * Wrapper component for using unstated
  */
 const PageEditorByHackmdWrapper = (props) => {
-  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer]);
+  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer, EditorContainer]);
 };
 
 PageEditorByHackmd.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
 export default PageEditorByHackmdWrapper;

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

@@ -31,7 +31,7 @@ class PageStatusAlert extends React.Component {
   }
 
   componentWillMount() {
-    this.props.appContainer.registerComponentInstance(this);
+    this.props.appContainer.registerComponentInstance('PageStatusAlert', this);
   }
 
   refreshPage() {
@@ -80,7 +80,7 @@ class PageStatusAlert extends React.Component {
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
-        <a onClick={this.refreshPage}>
+        <a href="#" onClick={this.refreshPage}>
           {label2}
         </a>
       </div>

+ 23 - 13
src/client/js/components/SavePageControls.jsx

@@ -29,8 +29,8 @@ class SavePageControls extends React.Component {
     this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
 
-    this.submit = this.submit.bind(this);
-    this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
+    this.save = this.save.bind(this);
+    this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
   }
 
   slackEnabledFlagChangedHandler(isSlackEnabled) {
@@ -45,18 +45,29 @@ class SavePageControls extends React.Component {
     this.props.editorContainer.setState(data);
   }
 
-  submit() {
-    this.props.appContainer.setIsDocSaved(true);
-    this.props.onSubmit();
+  save() {
+    const { pageContainer, editorContainer } = this.props;
+    // disable unsaved warning
+    editorContainer.disableUnsavedWarning();
+    // save
+    pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave());
   }
 
-  submitAndOverwriteScopesOfDescendants() {
-    this.props.onSubmit({ overwriteScopesOfDescendants: true });
+  saveAndOverwriteScopesOfDescendants() {
+    const { pageContainer, editorContainer } = this.props;
+    // disable unsaved warning
+    editorContainer.disableUnsavedWarning();
+    // save
+    const optionsToSave = Object.assign(editorContainer.getCurrentOptionsToSave(), {
+      overwriteScopesOfDescendants: true,
+    });
+    pageContainer.saveAndReload(optionsToSave);
   }
 
   render() {
-    const { t, editorContainer } = this.props;
-    const labelSubmitButton = this.props.pageContainer.state.pageId == null ? t('Create') : t('Update');
+    const { t, pageContainer, editorContainer } = this.props;
+    const isRootPage = pageContainer.state.path === '/';
+    const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
@@ -78,6 +89,7 @@ class SavePageControls extends React.Component {
           && (
           <div className="mr-2">
             <GrantSelector
+              disabled={isRootPage}
               grant={editorContainer.state.grant}
               grantGroupId={editorContainer.state.grantGroupId}
               grantGroupName={editorContainer.state.grantGroupName}
@@ -94,10 +106,10 @@ class SavePageControls extends React.Component {
             className="btn-submit"
             dropup
             pullRight
-            onClick={this.submit}
+            onClick={this.save}
             title={labelSubmitButton}
           >
-            <MenuItem eventKey="1" onClick={this.submitAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
+            <MenuItem eventKey="1" onClick={this.saveAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
             {/* <MenuItem divider /> */}
           </SplitButton>
         </ButtonToolbar>
@@ -120,8 +132,6 @@ SavePageControls.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  onSubmit: PropTypes.func.isRequired,
 };
 
 export default withTranslation()(SavePageControlsWrapper);

+ 4 - 2
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -205,6 +205,7 @@ class GrantSelector extends React.Component {
     return (
       <FormGroup className="grant-selector m-b-0">
         <FormControl
+          disabled={this.props.disabled}
           componentClass="select"
           placeholder="select"
           defaultValue={selectedValue}
@@ -275,8 +276,8 @@ class GrantSelector extends React.Component {
   render() {
     return (
       <React.Fragment>
-        {this.renderGrantSelector()}
-        {this.renderSelectGroupModal()}
+        { this.renderGrantSelector() }
+        { this.props.disabled && this.renderSelectGroupModal() }
       </React.Fragment>
     );
   }
@@ -294,6 +295,7 @@ GrantSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
+  disabled: PropTypes.bool,
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,

+ 1 - 1
src/client/js/components/SearchPage.js

@@ -16,7 +16,7 @@ class SearchPage extends React.Component {
     super(props);
 
     this.state = {
-      searchingKeyword: this.props.query.q || '',
+      searchingKeyword: decodeURI(this.props.query.q) || '',
       searchedKeyword: '',
       searchedPages: [],
       searchResultMeta: {},

+ 114 - 0
src/client/js/components/StaffCredit/Contributor.js

@@ -0,0 +1,114 @@
+const contributors = [
+  {
+    sectionName: 'GROWI VILLAGE',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-12 my-4',
+        members: [
+          { position: 'Founder', name: 'yuki-takei' },
+          { position: 'Soncho 1st', name: 'mizozobu' },
+          { position: 'Soncho 2nd', name: 'yusuketk' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'utsushiiro' },
+          { name: 'mayumorita' },
+          { name: 'TatsuyaIse' },
+          { name: 'shinoka7' },
+          { name: 'SeiyaTashiro' },
+          { name: 'itizawa' },
+          { name: 'TsuyoshiSuzukief' },
+          { name: 'Yuchan4342' },
+          { name: 'ryu-sato' },
+          { name: 'haruhikonyan' },
+          { name: 'KazuyaNagase' },
+          { name: 'kaishuu0123' },
+          { name: 'kouki-o' },
+          { name: 'Angola' },
+        ],
+      },
+    ],
+  },
+  {
+    sectionName: 'CONTRIBUTER',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'inductor' },
+          { name: 'shield-9' },
+          { name: 'yaodingyd' },
+          { name: 'hitochan777' },
+          { name: 'ttaka66' },
+          { name: 'watagashi' },
+          { name: 'nt-7' },
+          { name: 'hideo54' },
+          { name: 'wadahiro' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'shaminmeerankutty' },
+          { name: 'rabitarochan' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'fumitti' },
+          { name: 'fmy' },
+          { name: 'yaamai' },
+          { name: 'ta2yak' },
+          { name: 'ryo33' },
+          { name: 'r-tateshina' },
+          { name: 'nekoruri' },
+          { name: 'kmyk' },
+          { name: 'aximov' },
+          { name: 'tats-u' },
+        ],
+      },
+    ],
+  },
+  {
+    sectionName: 'VULNERABILITY HUNTER',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'Yoshinori Hayashi' },
+          { name: 'Kanta Nishitani' },
+          { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
+          { position: 'DeCurret', name: 'Yusuke Tanomogi' },
+        ],
+      },
+    ],
+  },
+  {
+    sectionName: 'SPECIAL THANKS',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'Crowi Team' },
+          { position: 'Ambassador', name: 'Tsuyoshi Suzuki' },
+          { name: 'JPCERT/CC' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-12 staff-credit-mt-10',
+        members: [
+          { name: 'AND YOU' },
+        ],
+      },
+    ],
+  },
+];
+
+module.exports = contributors;

+ 124 - 0
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -0,0 +1,124 @@
+import React from 'react';
+import { HotKeys } from 'react-hotkeys';
+
+import loggerFactory from '@alias/logger';
+
+import contributors from './Contributor';
+
+/**
+ * Page staff credit component
+ *
+ * @export
+ * @class StaffCredit
+ * @extends {React.Component}
+ */
+
+export default class StaffCredit extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.logger = loggerFactory('growi:StaffCredit');
+
+    this.state = {
+      isShown: false,
+      userCommand: [],
+    };
+    this.konamiCommand = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
+    this.deleteCredit = this.deleteCredit.bind(this);
+  }
+
+  check(event) {
+    this.logger.debug(`'${event.key}' pressed`);
+
+    // compare keydown and next konamiCommand
+    if (this.konamiCommand[this.state.userCommand.length] === event.key) {
+      const nextValue = this.state.userCommand.concat(event.key);
+      if (nextValue.length === this.konamiCommand.length) {
+        this.setState({
+          isShown: true,
+          userCommand: [],
+        });
+      }
+      else {
+        // add UserCommand
+        this.setState({ userCommand: nextValue });
+
+        this.logger.debug('userCommand', this.state.userCommand);
+      }
+    }
+    else {
+      this.setState({ userCommand: [] });
+    }
+  }
+
+  deleteCredit() {
+    if (this.state.isShown) {
+      this.setState({ isShown: false });
+    }
+  }
+
+  renderMembers(memberGroup, keyPrefix) {
+    // construct members elements
+    const members = memberGroup.members.map((member) => {
+      return (
+        <div className={memberGroup.additionalClass} key={`${keyPrefix}-${member.name}-container`}>
+          <span className="dev-position" key={`${keyPrefix}-${member.name}-position`}>
+            {/* position or '&nbsp;' */}
+            { member.position || '\u00A0' }
+          </span>
+          <p className="dev-name" key={`${keyPrefix}-${member.name}`}>{member.name}</p>
+        </div>
+      );
+    });
+    return (
+      <React.Fragment key={`${keyPrefix}-fragment`}>
+        {members}
+      </React.Fragment>
+    );
+  }
+
+  renderContributors() {
+    if (this.state.isShown) {
+      const credit = contributors.map((contributor) => {
+        // construct members elements
+        const memberGroups = contributor.memberGroups.map((memberGroup, idx) => {
+          return this.renderMembers(memberGroup, `${contributor.sectionName}-group${idx}`);
+        });
+        return (
+          <React.Fragment key={`${contributor.sectionName}-fragment`}>
+            <div className={`row staff-credit-my-10 ${contributor.additionalClass}`} key={`${contributor.sectionName}-row`}>
+              <h2 className="col-md-12 dev-team mt-5 staff-credit-mb-10" key={contributor.sectionName}>{contributor.sectionName}</h2>
+              {memberGroups}
+            </div>
+            <div className="clearfix"></div>
+          </React.Fragment>
+        );
+      });
+      return (
+        <div className="text-center credit-curtain" onClick={this.deleteCredit}>
+          <div className="credit-body">
+            <h1 className="staff-credit-mb-10">GROWI Contributors</h1>
+            <div className="clearfix"></div>
+            {credit}
+          </div>
+        </div>
+      );
+    }
+    return null;
+  }
+
+  render() {
+    const keyMap = { check: ['up', 'down', 'right', 'left', 'b', 'a'] };
+    const handlers = { check: (event) => { return this.check(event) } };
+    return (
+      <HotKeys focused attach={window} keyMap={keyMap} handlers={handlers}>
+        {this.renderContributors()}
+      </HotKeys>
+    );
+  }
+
+}
+
+StaffCredit.propTypes = {
+};

+ 1 - 1
src/client/js/components/UnstatedUtils.jsx

@@ -19,7 +19,7 @@ function generateAutoNamedProps(instances) {
 
   instances.forEach((instance) => {
     // get class name
-    const className = instance.constructor.name;
+    const className = instance.constructor.getClassName();
     // convert initial charactor to lower case
     const propName = `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
 

+ 1 - 2
src/client/js/ie11-polyfill.js

@@ -1,2 +1 @@
-import 'nodelist-foreach-polyfill';
-import 'babel-polyfill';
+import '@babel/polyfill';

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

@@ -212,6 +212,30 @@ Crowi.initSlimScrollForRevisionToc = () => {
   });
 };
 
+Crowi.initClassesByOS = function() {
+  // add classes to cmd-key by OS
+  const platform = navigator.platform.toLowerCase();
+  const isMac = (platform.indexOf('mac') > -1);
+
+  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win');
+    }
+  });
+
+  document.querySelectorAll('#shortcuts-modal .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win', 'key-longer');
+    }
+  });
+};
+
 Crowi.findHashFromUrl = function(url) {
   let match;
   /* eslint-disable no-cond-assign */
@@ -748,6 +772,7 @@ window.addEventListener('load', (e) => {
   Crowi.modifyScrollTop();
   Crowi.initSlimScrollForRevisionToc();
   Crowi.initAffix();
+  Crowi.initClassesByOS();
 });
 
 window.addEventListener('hashchange', (e) => {

+ 10 - 3
src/client/js/plugin.js

@@ -30,12 +30,19 @@ export default class GrowiPlugin {
       switch (meta.pluginSchemaVersion) {
         // v1 is deprecated
         case 1:
+          logger.warn('pluginSchemaVersion 1 is deprecated', definition);
           break;
-        // v2 or above
-        default:
+        // v2 is deprecated
+        case 2:
+          logger.warn('pluginSchemaVersion 2 is deprecated', definition);
+          break;
+        case 3:
           definition.entries.forEach((entry) => {
-            entry(appContainer, originRenderer);
+            entry(appContainer);
           });
+          break;
+        default:
+          logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
       }
     });
 

+ 16 - 47
src/client/js/services/AppContainer.js

@@ -80,6 +80,13 @@ export default class AppContainer extends Container {
     };
   }
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AppContainer';
+  }
+
   initPlugins() {
     if (this.isPluginEnabled) {
       const growiPlugin = window.growiPlugin;
@@ -119,7 +126,7 @@ export default class AppContainer extends Container {
       throw new Error('The specified instance must not be null');
     }
 
-    const className = instance.constructor.name;
+    const className = instance.constructor.getClassName();
 
     if (this.containerInstances[className] != null) {
       throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
@@ -141,28 +148,27 @@ export default class AppContainer extends Container {
 
   /**
    * Register React component instance
+   * @param {string} id
    * @param {object} instance React component instance
    */
-  registerComponentInstance(instance) {
+  registerComponentInstance(id, instance) {
     if (instance == null) {
       throw new Error('The specified instance must not be null');
     }
 
-    const className = instance.constructor.name;
-
-    if (this.componentInstances[className] != null) {
-      throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
+    if (this.componentInstances[id] != null) {
+      throw new Error('The specified instance couldn\'t register because the same id has already been registered');
     }
 
-    this.componentInstances[className] = instance;
+    this.componentInstances[id] = instance;
   }
 
   /**
    * Get registered React component instance
-   * @param {string} className
+   * @param {string} id
    */
-  getComponentInstance(className) {
-    return this.componentInstances[className];
+  getComponentInstance(id) {
+    return this.componentInstances[id];
   }
 
   getOriginRenderer() {
@@ -187,14 +193,6 @@ export default class AppContainer extends Container {
     return renderer;
   }
 
-  setIsDocSaved(isSaved) {
-    this.isDocSaved = isSaved;
-  }
-
-  getIsDocSaved() {
-    return this.isDocSaved;
-  }
-
   getEmojiStrategy() {
     return emojiStrategy;
   }
@@ -280,35 +278,6 @@ export default class AppContainer extends Container {
     return null;
   }
 
-  createPage(pagePath, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      path: pagePath,
-      body: markdown,
-    });
-    return this.apiPost('/pages.create', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return { page: res.page, tags: res.tags };
-      });
-  }
-
-  updatePage(pageId, revisionId, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      page_id: pageId,
-      revision_id: revisionId,
-      body: markdown,
-    });
-    return this.apiPost('/pages.update', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return { page: res.page, tags: res.tags };
-      });
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {

+ 7 - 0
src/client/js/services/CommentContainer.js

@@ -36,6 +36,13 @@ export default class CommentContainer extends Container {
     this.retrieveComments = this.retrieveComments.bind(this);
   }
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'CommentContainer';
+  }
+
   getPageContainer() {
     return this.appContainer.getContainer('PageContainer');
   }

+ 28 - 0
src/client/js/services/EditorContainer.js

@@ -37,6 +37,8 @@ export default class EditorContainer extends Container {
       previewOptions: {},
     };
 
+    this.isSetBeforeunloadEventHandler = false;
+
     this.initStateGrant();
     this.initDrafts();
 
@@ -44,6 +46,13 @@ export default class EditorContainer extends Container {
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'EditorContainer';
+  }
+
   /**
    * initialize state for page permission
    */
@@ -129,6 +138,7 @@ export default class EditorContainer extends Container {
       isSlackEnabled: this.state.isSlackEnabled,
       slackChannels: this.state.slackChannels,
       grant: this.state.grant,
+      pageTags: this.state.tags,
     };
 
     if (this.state.grantGroupId != null) {
@@ -138,6 +148,24 @@ export default class EditorContainer extends Container {
     return opt;
   }
 
+  showUnsavedWarning(e) {
+    // display browser default message
+    e.returnValue = '';
+    return '';
+  }
+
+  disableUnsavedWarning() {
+    window.removeEventListener('beforeunload', this.showUnsavedWarning);
+    this.isSetBeforeunloadEventHandler = false;
+  }
+
+  enableUnsavedWarning() {
+    if (!this.isSetBeforeunloadEventHandler) {
+      window.addEventListener('beforeunload', this.showUnsavedWarning);
+      this.isSetBeforeunloadEventHandler = true;
+    }
+  }
+
   clearDraft(path) {
     delete this.drafts[path];
     window.localStorage.setItem('drafts', JSON.stringify(this.drafts));

+ 189 - 1
src/client/js/services/PageContainer.js

@@ -3,6 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '@alias/logger';
 
 import * as entities from 'entities';
+import * as toastr from 'toastr';
 
 const logger = loggerFactory('growi:services:PageContainer');
 
@@ -40,7 +41,7 @@ export default class PageContainer extends Container {
       likerUserIds: [],
 
       tags: [],
-      templateTagData: mainContent.getAttribute('data-template-tags') || '',
+      templateTagData: mainContent.getAttribute('data-template-tags'),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
@@ -54,10 +55,18 @@ export default class PageContainer extends Container {
     this.initStateMarkdown();
     this.initStateOthers();
 
+    this.save = this.save.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
   }
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PageContainer';
+  }
+
   /**
    * initialize state for markdown data
    */
@@ -101,6 +110,185 @@ export default class PageContainer extends Container {
     });
   }
 
+
+  /**
+   * save success handler
+   * @param {object} page Page instance
+   * @param {Array[Tag]} tags Array of Tag
+   */
+  updateStateAfterSave(page, tags) {
+    const { editorMode } = this.appContainer.state;
+
+    // update state of PageContainer
+    const newState = {
+      pageId: page._id,
+      revisionId: page.revision._id,
+      revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
+      remoteRevisionId: page.revision._id,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      markdown: page.revision.body,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+    this.setState(newState);
+
+    // PageEditor component
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      if (editorMode !== 'builtin') {
+        pageEditor.updateEditorValue(newState.markdown);
+      }
+    }
+    // PageEditorByHackmd component
+    const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+    if (pageEditorByHackmd != null) {
+      // reset
+      if (editorMode !== 'hackmd') {
+        pageEditorByHackmd.reset();
+      }
+    }
+
+    // hidden input
+    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * Save page
+   * @param {string} markdown
+   * @param {object} optionsToSave
+   * @return {object} { page: Page, tags: Tag[] }
+   */
+  async save(markdown, optionsToSave = {}) {
+    const { editorMode } = this.appContainer.state;
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    if (editorMode === 'hackmd') {
+      // set option to sync
+      options.isSyncRevisionToHackmd = true;
+      revisionId = this.state.revisionIdHackmdSynced;
+    }
+
+    let res;
+    if (pageId == null) {
+      res = await this.createPage(path, markdown, options);
+    }
+    else {
+      res = await this.updatePage(pageId, revisionId, markdown, options);
+    }
+
+    this.updateStateAfterSave(res.page, res.tags);
+    return res;
+  }
+
+  async saveAndReload(optionsToSave) {
+    if (optionsToSave == null) {
+      const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
+      throw new Error(msg);
+    }
+
+    const { editorMode } = this.appContainer.state;
+    if (editorMode == null) {
+      logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
+      return;
+    }
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    let markdown;
+    if (editorMode === 'hackmd') {
+      const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+      markdown = await pageEditorByHackmd.getMarkdown();
+      // set option to sync
+      options.isSyncRevisionToHackmd = true;
+      revisionId = this.state.revisionIdHackmdSynced;
+    }
+    else {
+      const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+      markdown = pageEditor.getMarkdown();
+    }
+
+    let res;
+    if (pageId == null) {
+      res = await this.createPage(path, markdown, options);
+    }
+    else {
+      res = await this.updatePage(pageId, revisionId, markdown, options);
+    }
+
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    editorContainer.clearDraft(path);
+    window.location.href = path;
+
+    return res;
+  }
+
+  async createPage(pagePath, markdown, tmpParams) {
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      path: pagePath,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.create', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  async updatePage(pageId, revisionId, markdown, tmpParams) {
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      page_id: pageId,
+      revision_id: revisionId,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.update', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  showSuccessToastr() {
+    toastr.success(undefined, 'Saved successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
+  }
+
+  showErrorToastr(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
   addWebSocketEventHandlers() {
     const pageContainer = this;
     const websocketContainer = this.appContainer.getContainer('WebsocketContainer');

+ 8 - 1
src/client/js/services/TagContainer.js

@@ -19,6 +19,13 @@ export default class TagContainer extends Container {
     this.init();
   }
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'TagContainer';
+  }
+
   /**
    * retrieve tags data
    * !! This method should be invoked after PageContainer and EditorContainer has been initialized !!
@@ -34,7 +41,7 @@ export default class TagContainer extends Container {
 
     const { pageId, templateTagData } = pageContainer.state;
 
-    let tags;
+    let tags = [];
     // when the page exists
     if (pageId != null) {
       const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });

+ 7 - 0
src/client/js/services/WebsocketContainer.js

@@ -22,6 +22,13 @@ export default class WebsocketContainer extends Container {
 
   }
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'WebsocketContainer';
+  }
+
   getWebSocket() {
     return this.socket;
   }

+ 0 - 2
src/client/js/util/GrowiRenderer.js

@@ -3,7 +3,6 @@ import MarkdownIt from 'markdown-it';
 import Linker from './PreProcessor/Linker';
 import CsvToTable from './PreProcessor/CsvToTable';
 import XssFilter from './PreProcessor/XssFilter';
-import CrowiTemplate from './PostProcessor/CrowiTemplate';
 
 import EmojiConfigurer from './markdown-it/emoji';
 import FooternoteConfigurer from './markdown-it/footernote';
@@ -42,7 +41,6 @@ export default class GrowiRenderer {
         new XssFilter(appContainer),
       ];
       this.postProcessors = [
-        new CrowiTemplate(appContainer),
       ];
     }
 

+ 0 - 0
src/client/js/util/PostProcessor/.keep


+ 0 - 85
src/client/js/util/PostProcessor/CrowiTemplate.js

@@ -1,85 +0,0 @@
-import dateFnsFormat from 'date-fns/format';
-
-export default class CrowiTemplate {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    this.getUser = this.getUser.bind(this);
-
-    this.templatePattern = {
-      year: this.getYear,
-      month: this.getMonth,
-      date: this.getDate,
-      user: this.getUser,
-    };
-  }
-
-  process(markdown) {
-    // see: https://regex101.com/r/WR6IvX/3
-    return markdown.replace(/:::\s*(\S+)[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group1, group2) => {
-      const lang = group1;
-      let code = group2;
-
-      if (!lang.match(/^template/)) {
-        return all;
-      }
-
-      const templateId = new Date().getTime().toString(16) + Math.floor(1000 * Math.random()).toString(16);
-      let pageName = lang;
-      if (lang.match(':')) {
-        pageName = this.parseTemplateString(lang.split(':')[1]);
-      }
-      code = this.parseTemplateString(code);
-
-      return (
-        /* eslint-disable quotes */
-        `<div class="page-template-builder">`
-          + `<button class="template-create-button btn btn-default" data-template="${templateId}" data-path="${pageName}">`
-            + `<i class="fa fa-pencil"></i> ${pageName}`
-          + `</button>`
-          + `<pre><code id="${templateId}" class="lang-${lang}">${code}\n</code></pre>`
-        + `</div>`
-        /* eslint-enable quotes */
-      );
-    });
-  }
-
-  getYear() {
-    return dateFnsFormat(new Date(), 'YYYY');
-  }
-
-  getMonth() {
-    return dateFnsFormat(new Date(), 'YYYY/MM');
-  }
-
-  getDate() {
-    return dateFnsFormat(new Date(), 'YYYY/MM/DD');
-  }
-
-  getUser() {
-    const username = this.crowi.me || null;
-
-    if (!username) {
-      return '';
-    }
-
-    return `/user/${username}`;
-  }
-
-  parseTemplateString(templateString) {
-    let parsed = templateString;
-
-    Object.keys(this.templatePattern).forEach((key) => {
-      const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-      const matcher = new RegExp(`{${k}}`, 'g');
-      if (parsed.match(matcher)) {
-        const replacer = this.templatePattern[key]();
-        parsed = parsed.replace(matcher, replacer);
-      }
-    });
-
-    return parsed;
-  }
-
-}

+ 28 - 16
src/client/styles/agile-admin/inverse/colors/antarctic.scss

@@ -16,20 +16,23 @@ $active-nav-tabs-bgcolor: $themecolor;
 $logo-mark-fill: $themelight;
 $wikilinktext: lighten($themecolor, 5%);
 $wikilinktext-hover: lighten($wikilinktext, 15%);
-$inline-code-color: darken($subthemecolor, 5%);
-$inline-code-bg: lighten($subthemecolor, 70%);
+$inline-code-color: #c7254e;
+$inline-code-bg: #f9f2f4;
 $border: $subthemecolor;
 $border-original: $subthemecolor;
 $navbar-border: $themecolor;
-$background-color: rgba($color: $themelight, $alpha: 0.8);
-$info:$subthemecolor;
+$background-color: rgba(
+  $color: $themelight,
+  $alpha: 0.8,
+);
+$info: $subthemecolor;
 
 @import 'apply-colors';
 @import 'apply-colors-light';
 
 // change color of highlighted header in wiki (default: orange)
 .code-line,
-ul>.text-muted {
+ul > .text-muted {
   color: $subthemecolor;
 }
 
@@ -50,7 +53,7 @@ ul>.text-muted {
 }
 
 // add background-image
-.main-container>#wrapper>#page-wrapper,
+.main-container > #wrapper > #page-wrapper,
 .page-editor-preview-container {
   background-image: url('/images/themes/antarctic/bg.svg');
   background-attachment: fixed;
@@ -99,7 +102,7 @@ header.affix {
   }
 }
 
-#wrapper>.navbar>.navbar-header {
+#wrapper > .navbar > .navbar-header {
   border-bottom: 4px solid $accentcolor;
 }
 
@@ -111,7 +114,7 @@ header.affix {
   .page-comment-main {
     box-shadow: 0px 3px 15px rgba(0, 0, 0, 0.2);
 
-    .page-comment-creator>a {
+    .page-comment-creator > a {
       border-bottom: 1px double $subthemecolor;
     }
   }
@@ -131,7 +134,7 @@ header.affix {
     }
 
     .nav.nav-tabs {
-      >li.active>a {
+      > li.active > a {
         background: $themecolor;
         border-bottom: solid 1px $themecolor;
         border-bottom-color: $themecolor;
@@ -144,21 +147,23 @@ header.affix {
  * Tabs
  */
 
-.nav.nav-tabs>li.active>a {
+.nav.nav-tabs > li.active > a {
   color: $themelight;
 }
 
 .text-info,
 body:not(.on-edit) .nav.nav-tabs {
-  >li>a {
+  > li > a {
     color: $subthemecolor;
   }
 
-  >li.active>a {
+  > li.active > a {
     color: $themelight;
-    background: linear-gradient(rgba($active-nav-tabs-bgcolor, 0) 50%,
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 50%,
       rgba($active-nav-tabs-bgcolor, 0) 90%,
-      $active-nav-tabs-bgcolor 100%); // overwrite only the bottom pixel
+      $active-nav-tabs-bgcolor 100%
+    ); // overwrite only the bottom pixel
     background-color: $themecolor;
   }
 }
@@ -199,7 +204,6 @@ body:not(.on-edit) .nav.nav-tabs {
       ul {
         padding-left: 5px;
       }
-
     }
   }
 }
@@ -208,7 +212,7 @@ body:not(.on-edit) .nav.nav-tabs {
  *  Login page
  */
 
-.login-page>#wrapper>#page-wrapper {
+.login-page > #wrapper > #page-wrapper {
   background-image: url('/images/themes/antarctic/topimage.svg');
   background-attachment: fixed;
   background-position: center center;
@@ -222,3 +226,11 @@ body:not(.on-edit) .nav.nav-tabs {
     }
   }
 }
+
+/*
+ *  for Hightlight-js
+ */
+
+.hljs-ln {
+  background-color: transparent;
+}

+ 56 - 46
src/client/styles/scss/_comment.scss

@@ -1,46 +1,56 @@
-.main-container {
-  .page-comment-main {
-    // delete button
-    .page-comment-control {
-      position: absolute;
-      top: 0;
-      right: 0;
-      display: none; // default hidden
-    }
-  }
-
-  // modal
-  .page-comment-delete-modal .modal-content {
-    .modal-body {
-      .comment-body {
-        max-height: 13em;
-        // scrollable
-        overflow-y: auto;
-      }
-    }
-  }
-}
-
-.main-container {
-  .page-comments {
-    .page-comments-list-toggle-newer,
-    .page-comments-list-toggle-older {
-      display: block;
-      margin: 8px;
-      font-size: 0.9em;
-      text-align: center;
-    }
-
-    // older comments
-    .page-comments-list-older .page-comment {
-    }
-    // newer comments
-    .page-comments-list-newer .page-comment {
-      opacity: 0.7;
-
-      &:hover {
-        opacity: 1;
-      }
-    }
-  }
-}
+.main-container {
+  .page-comment-main {
+    // delete button
+    .page-comment-control {
+      position: absolute;
+      top: 0;
+      right: 0;
+      display: none; // default hidden
+    }
+  }
+
+  // modal
+  .page-comment-delete-modal .modal-content {
+    .modal-body {
+      .comment-body {
+        max-height: 13em;
+        // scrollable
+        overflow-y: auto;
+      }
+    }
+  }
+}
+
+.main-container {
+  .page-comments {
+    .page-comments-list-toggle-newer,
+    .page-comments-list-toggle-older {
+      display: block;
+      margin: 8px;
+      font-size: 0.9em;
+      text-align: center;
+    }
+
+    // older comments
+    .page-comments-list-older .page-comment {
+    }
+    // newer comments
+    .page-comments-list-newer .page-comment {
+      opacity: 0.7;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+.btn-xxs {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 50px;
+  height: 10px;
+  font-size: 11px;
+  border-radius: 1px;
+}

+ 7 - 0
src/client/styles/scss/_shortcuts.scss

@@ -51,6 +51,13 @@
     &.key-long {
       width: 72px;
     }
+    &.key-small {
+      width: 24px;
+      height: 24px;
+      margin: 4px 2px;
+      font-size: 18px;
+      line-height: 22px;
+    }
   }
 
   .dl-horizontal {

+ 93 - 0
src/client/styles/scss/_staff_credit.scss

@@ -0,0 +1,93 @@
+// Staff Credit
+#staff-credit {
+  // see https://css-tricks.com/old-timey-terminal-styling/
+  @mixin old-timey-terminal-styling() {
+    text-shadow: 0 0 10px #c8c8c8;
+    background-color: black;
+    background-image: radial-gradient(rgba(50, 100, 100, 0.75), black 120%);
+    &::after {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100vw;
+      height: 100vh;
+      content: '';
+      background: repeating-linear-gradient(0deg, rgba(black, 0.15), rgba(black, 0.15) 2px, transparent 2px, transparent 4px);
+    }
+  }
+
+  font-family: 'Press Start 2P', $basefont1;
+  color: white;
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    font-family: 'Press Start 2P', $basefont1;
+    color: white;
+  }
+
+  $credit-length: -240em;
+
+  // see https://css-tricks.com/old-timey-terminal-styling/
+  .credit-curtain {
+    position: fixed;
+    top: 10vh;
+    left: 20vh;
+    width: 80vw;
+    height: 80vh;
+    overflow-y: hidden;
+
+    @include old-timey-terminal-styling();
+  }
+
+  .credit-body {
+    position: relative;
+    top: $credit-length;
+    animation-name: Credit;
+    // credit duration
+    animation-duration: 20s;
+    animation-timing-function: linear;
+  }
+
+  @keyframes Credit {
+    from {
+      top: 100%;
+    }
+    to {
+      // credit length
+      top: $credit-length;
+    }
+  }
+
+  h1 {
+    font-size: 3em;
+  }
+
+  h2 {
+    font-size: 2.2em;
+  }
+
+  .dev-position {
+    font-size: 1em;
+  }
+
+  .dev-name {
+    font-size: 1.8em;
+  }
+
+  .staff-credit-mt-10 {
+    margin-top: 6rem;
+  }
+
+  .staff-credit-mb-10 {
+    margin-bottom: 6rem;
+  }
+
+  .staff-credit-my-10 {
+    @extend .staff-credit-mt-10;
+    @extend .staff-credit-mb-10;
+  }
+}

+ 2 - 0
src/client/styles/scss/style-app.scss

@@ -39,7 +39,9 @@
 @import 'user_growi';
 @import 'handsontable';
 @import 'wiki';
+@import 'staff_credit';
 @import 'tag';
+@import 'staff_credit';
 @import 'draft';
 
 /*

+ 12 - 0
src/lib/util/mongoose-utils.js

@@ -0,0 +1,12 @@
+const mongoose = require('mongoose');
+
+const getModelSafely = (modelName) => {
+  if (mongoose.modelNames().includes(modelName)) {
+    return mongoose.model(modelName);
+  }
+  return null;
+};
+
+module.exports = {
+  getModelSafely,
+};

+ 3 - 2
src/migrations/20180926134048-make-email-unique.js

@@ -1,16 +1,17 @@
-require('module-alias/register');
 const logger = require('@alias/logger')('growi:migrate:make-email-unique');
 
 const mongoose = require('mongoose');
 const config = require('@root/config/migrate');
 
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
 module.exports = {
 
   async up(db, next) {
     logger.info('Start migration');
     mongoose.connect(config.mongoUri, config.mongodb.options);
 
-    const User = require('@server/models/user')();
+    const User = getModelSafely('User') || require('@server/models/user')();
 
     // get all users who has 'deleted@deleted' email
     const users = await User.find({ email: 'deleted@deleted' });

+ 13 - 6
src/migrations/20180927102719-init-serverurl.js

@@ -1,11 +1,12 @@
 
 
-require('module-alias/register');
 const logger = require('@alias/logger')('growi:migrate:init-serverurl');
 
 const mongoose = require('mongoose');
 const config = require('@root/config/migrate');
 
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
 /**
  * check all values of the array are equal
  * @see https://stackoverflow.com/a/35568895
@@ -22,7 +23,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(config.mongoUri, config.mongodb.options);
 
-    const Config = require('@server/models/config')();
+    const Config = getModelSafely('Config') || require('@server/models/config')();
 
     // find 'app:siteUrl'
     const siteUrlConfig = await Config.findOne({
@@ -66,16 +67,22 @@ module.exports = {
     }
 
     if (siteUrl != null) {
-      await Config.findOneAndUpdateByNsAndKey('crowi', 'app:siteUrl', siteUrl);
+      const ns = 'crowi';
+      const key = 'app:siteUrl';
+      await Config.findOneAndUpdate(
+        { ns, key },
+        { ns, key, value: JSON.stringify(siteUrl) },
+        { upsert: true },
+      );
       logger.info('Migration has successfully applied');
     }
   },
 
   async down(db) {
-    logger.info('Undo migration');
+    logger.info('Rollback migration');
     mongoose.connect(config.mongoUri, config.mongodb.options);
 
-    const Config = require('@server/models/config')();
+    const Config = getModelSafely('Config') || require('@server/models/config')();
 
     // remote 'app:siteUrl'
     await Config.findOneAndDelete({
@@ -83,7 +90,7 @@ module.exports = {
       key: 'app:siteUrl',
     });
 
-    logger.info('Migration has successfully undoed');
+    logger.info('Migration has been successfully rollbacked');
   },
 
 };

+ 11 - 8
src/migrations/20181019114028-abolish-page-group-relation.js

@@ -1,9 +1,10 @@
-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');
 
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
 
 async function isCollectionExists(db, collectionName) {
   const collections = await db.listCollections({ name: collectionName }).toArray();
@@ -37,8 +38,8 @@ module.exports = {
       return;
     }
 
-    const Page = require('@server/models/page')();
-    const UserGroup = require('@server/models/user-group')();
+    const Page = getModelSafely('Page') || require('@server/models/page')();
+    const UserGroup = getModelSafely('UserGroup') || require('@server/models/user-group')();
 
     // retrieve all documents from 'pagegrouprelations'
     const relations = await db.collection('pagegrouprelations').find().toArray();
@@ -71,11 +72,11 @@ module.exports = {
   },
 
   async down(db) {
-    logger.info('Undo migration');
+    logger.info('Rollback migration');
     mongoose.connect(config.mongoUri, config.mongodb.options);
 
-    const Page = require('@server/models/page')();
-    const UserGroup = require('@server/models/user-group')();
+    const Page = getModelSafely('Page') || require('@server/models/page')();
+    const UserGroup = getModelSafely('UserGroup') || require('@server/models/user-group')();
 
     // retrieve all Page documents which granted by UserGroup
     const relatedPages = await Page.find({ grant: Page.GRANT_USER_GROUP });
@@ -106,9 +107,11 @@ module.exports = {
     }
     /* eslint-enable no-await-in-loop */
 
-    await db.collection('pagegrouprelations').insertMany(insertDocs);
+    if (insertDocs.length > 0) {
+      await db.collection('pagegrouprelations').insertMany(insertDocs);
+    }
 
-    logger.info('Migration has successfully undoed');
+    logger.info('Migration has been successfully rollbacked');
   },
 
 };

+ 43 - 0
src/migrations/20190618055300-abolish-crowi-classic-auth.js

@@ -0,0 +1,43 @@
+const logger = require('@alias/logger')('growi:migrate:abolish-crowi-classic-auth');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+
+module.exports = {
+  async up(db, next) {
+    logger.info('Start migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    // enable passport and delete configs for crowi classic auth
+    await Promise.all([
+      Config.findOneAndUpdate(
+        { ns: 'crowi', key: 'security:isEnabledPassport' },
+        { ns: 'crowi', key: 'security:isEnabledPassport', value: JSON.stringify(true) },
+        { upsert: true },
+      ),
+      Config.findOneAndUpdate(
+        { ns: 'crowi', key: 'google:clientId' },
+        { ns: 'crowi', key: 'google:clientId', value: JSON.stringify(null) },
+        { upsert: true },
+      ),
+      Config.findOneAndUpdate(
+        { ns: 'crowi', key: 'google:clientSecret' },
+        { ns: 'crowi', key: 'google:clientSecret', value: JSON.stringify(null) },
+        { upsert: true },
+      ),
+    ]);
+
+    logger.info('Migration has successfully terminated');
+    next();
+  },
+
+  async down(db, next) {
+    // do not rollback
+    next();
+  },
+};

+ 64 - 0
src/migrations/20190618104011-add-config-app-installed.js

@@ -0,0 +1,64 @@
+const logger = require('@alias/logger')('growi:migrate:add-config-app-installed');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+
+/**
+ * BEFORE
+ *   - Config document { ns: 'crowi', key: 'app:installed' } does not exist
+ * AFTER
+ *   - Config document { ns: 'crowi', key: 'app:installed' } is created
+ *     - value will be true if one or more users exist
+ *     - value will be false if no users exist
+ */
+module.exports = {
+
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+    const User = getModelSafely('User') || require('@server/models/user')();
+
+    // find 'app:siteUrl'
+    const appInstalled = await Config.findOne({
+      ns: 'crowi',
+      key: 'app:installed',
+    });
+    // exit if exists
+    if (appInstalled != null) {
+      logger.info('\'app:appInstalled\' is already exists. This migration terminates without any changes.');
+      return;
+    }
+
+    const userCount = await User.count();
+
+    if (userCount > 0) {
+      await Config.create({
+        ns: 'crowi',
+        key: 'app:installed',
+        value: true,
+      });
+    }
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    // remote 'app:siteUrl'
+    await Config.findOneAndDelete({
+      ns: 'crowi',
+      key: 'app:installed',
+    });
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 49 - 0
src/migrations/20190624110950-fill-last-update-user.js

@@ -0,0 +1,49 @@
+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');
+
+/**
+ * FIX https://github.com/weseek/growi/issues/1067
+ */
+module.exports = {
+
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Page = require('@server/models/page')();
+
+    // see https://stackoverflow.com/questions/3974985/update-mongodb-field-using-value-of-another-field/37280419#37280419
+
+    // retrieve target data
+    const pages = await Page.find({
+      $or: [
+        { lastUpdateUser: { $exists: false } },
+        { lastUpdateUser: { $eq: null } },
+      ],
+    }).select('_id creator');
+
+    // create requests for bulkWrite
+    const requests = pages.map((page) => {
+      return {
+        updateOne: {
+          filter: { _id: page._id },
+          update: { $set: { lastUpdateUser: page.creator } },
+        },
+      };
+    });
+
+    if (requests.length > 0) {
+      await db.collection('pages').bulkWrite(requests);
+    }
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+
+};

+ 29 - 0
src/migrations/20190629193445-make-root-page-public.js

@@ -0,0 +1,29 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:make-root-page-public');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Page = require('@server/models/page')();
+
+    await Page.findOneAndUpdate(
+      { path: '/' },
+      {
+        grant: Page.GRANT_PUBLIC,
+        grantedUsers: [],
+        grantedGroup: null,
+      },
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 26 - 47
src/server/crowi/express-init.js

@@ -9,9 +9,8 @@ module.exports = function(crowi, app) {
   const cookieParser = require('cookie-parser');
   const methodOverride = require('method-override');
   const passport = require('passport');
-  const session = require('express-session');
+  const expressSession = require('express-session');
   const sanitizer = require('express-sanitizer');
-  const basicAuth = require('basic-auth-connect');
   const flash = require('connect-flash');
   const swig = require('swig-templates');
   const webpackAssets = require('express-webpack-assets');
@@ -19,15 +18,15 @@ module.exports = function(crowi, app) {
   const i18nFsBackend = require('i18next-node-fs-backend');
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nMiddleware = require('i18next-express-middleware');
+
+  const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
+
   const env = crowi.node_env;
-  const middleware = require('../util/middlewares');
 
-  // Old type config API
-  const config = crowi.getConfig();
-  const Config = crowi.model('Config');
   // New type config API
   const configManager = crowi.configManager;
+  const getConfig = configManager.getConfig;
 
   const User = crowi.model('User');
   const lngDetector = new i18nMiddleware.LanguageDetector();
@@ -57,7 +56,7 @@ module.exports = function(crowi, app) {
 
   app.use((req, res, next) => {
     const now = new Date();
-    const tzoffset = -(config.crowi['app:timezone'] || 9) * 60;
+    const tzoffset = -(getConfig('crowi', 'app:timezone') || 9) * 60;
     // for datez
 
     const Page = crowi.model('Page');
@@ -65,12 +64,10 @@ module.exports = function(crowi, app) {
     const Config = crowi.model('Config');
     app.set('tzoffset', tzoffset);
 
-    req.config = config;
     req.csrfToken = null;
 
     res.locals.req = req;
-    res.locals.baseUrl = configManager.getSiteUrl();
-    res.locals.config = config;
+    res.locals.baseUrl = crowi.appService.getSiteUrl();
     res.locals.env = env;
     res.locals.now = now;
     res.locals.tzoffset = tzoffset;
@@ -78,10 +75,10 @@ module.exports = function(crowi, app) {
       pageGrants: Page.getGrantLabels(),
       userStatus: User.getUserStatusLabels(),
       language:   User.getLanguageLabels(),
-      restrictGuestMode: Config.getRestrictGuestModeLabels(),
-      registrationMode: Config.getRegistrationModeLabels(),
+      restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
+      registrationMode: crowi.aclService.getRegistrationModeLabels(),
     };
-    res.locals.local_config = Config.getLocalconfig(config); // config for browser context
+    res.locals.local_config = Config.getLocalconfig(); // config for browser context
 
     next();
   });
@@ -102,52 +99,34 @@ module.exports = function(crowi, app) {
   app.use(bodyParser.json({ limit: '50mb' }));
   app.use(sanitizer());
   app.use(cookieParser());
-  app.use(session(crowi.sessionConfig));
 
-  // Set basic auth middleware
+  // configure express-session
   app.use((req, res, next) => {
-    if (req.query.access_token || req.body.access_token) {
-      return next();
-    }
-
-    // FIXME:
-    //   healthcheck endpoint exclude from basic authentication.
-    //   however, hard coding is not desirable.
-    //   need refactoring (ex. setting basic authentication for each routes)
-    if (req.path === '/_api/v3/healthcheck') {
-      return next();
+    // test whether the route is listed in avoidSessionTroutes
+    for (const regex of avoidSessionRoutes) {
+      if (regex.test(req.path)) {
+        return next();
+      }
     }
 
-    const basicName = configManager.getConfig('crowi', 'security:basicName');
-    const basicSecret = configManager.getConfig('crowi', 'security:basicSecret');
-    if (basicName && basicSecret) {
-      return basicAuth(basicName, basicSecret)(req, res, next);
-    }
-
-    next();
+    expressSession(crowi.sessionConfig)(req, res, next);
   });
 
   // passport
-  if (Config.isEnabledPassport(config)) {
-    debug('initialize Passport');
-    app.use(passport.initialize());
-    app.use(passport.session());
-  }
+  debug('initialize Passport');
+  app.use(passport.initialize());
+  app.use(passport.session());
 
   app.use(flash());
 
-  app.use(middleware.swigFilters(crowi, app, swig));
-  app.use(middleware.swigFunctions(crowi, app));
+  const middlewares = require('../util/middlewares')(crowi, app);
+
+  app.use(middlewares.swigFilters(swig));
+  app.use(middlewares.swigFunctions());
 
-  app.use(middleware.csrfKeyGenerator(crowi, app));
+  app.use(middlewares.csrfKeyGenerator());
 
-  // switch loginChecker
-  if (Config.isEnabledPassport(config)) {
-    app.use(middleware.loginCheckerForPassport(crowi, app));
-  }
-  else {
-    app.use(middleware.loginChecker(crowi, app));
-  }
+  app.use(middlewares.loginCheckerForPassport);
 
   app.use(i18nMiddleware.handle(i18next));
 };

+ 131 - 46
src/server/crowi/index.js

@@ -39,6 +39,11 @@ function Crowi(rootdir) {
   this.mailer = {};
   this.passportService = null;
   this.globalNotificationService = null;
+  this.slackNotificationService = null;
+  this.xssService = null;
+  this.aclService = null;
+  this.appService = null;
+  this.fileUploadService = null;
   this.restQiitaAPIService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
@@ -76,9 +81,17 @@ Crowi.prototype.init = async function() {
   await this.setupModels();
   await this.setupMiddlewares();
   await this.setupSessionConfig();
-  await this.setupAppConfig();
   await this.setupConfigManager();
 
+  // customizeService depends on AppService and XssService
+  // passportService depends on appService
+  // slack depends on setUpSlacklNotification
+  await Promise.all([
+    this.setUpApp(),
+    this.setUpXss(),
+    this.setUpSlacklNotification(),
+  ]);
+
   await Promise.all([
     this.scanRuntimeVersions(),
     this.setupPassport(),
@@ -87,7 +100,39 @@ Crowi.prototype.init = async function() {
     this.setupSlack(),
     this.setupCsrf(),
     this.setUpGlobalNotification(),
+    this.setUpFileUpload(),
+    this.setUpAcl(),
+    this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
+    this.setupUserGroup(),
+  ]);
+};
+
+Crowi.prototype.initForTest = async function() {
+  await this.setupModels();
+  await this.setupConfigManager();
+
+  // // customizeService depends on AppService and XssService
+  // // passportService depends on appService
+  // // slack depends on setUpSlacklNotification
+  await Promise.all([
+    this.setUpApp(),
+    // this.setUpXss(),
+    // this.setUpSlacklNotification(),
+  ]);
+
+  await Promise.all([
+  //   this.scanRuntimeVersions(),
+  //   this.setupPassport(),
+  //   this.setupSearcher(),
+  //   this.setupMailer(),
+  //   this.setupSlack(),
+  //   this.setupCsrf(),
+  //   this.setUpGlobalNotification(),
+  //   this.setUpFileUpload(),
+    this.setUpAcl(),
+  //   this.setUpCustomize(),
+  //   this.setUpRestQiitaAPI(),
   ]);
 };
 
@@ -183,36 +228,16 @@ Crowi.prototype.setupSessionConfig = function() {
   }));
 };
 
-Crowi.prototype.setupAppConfig = function() {
-  return new Promise((resolve, reject) => {
-    this.model('Config', require('../models/config')(this));
-    const Config = this.model('Config');
-    Config.loadAllConfig((err, doc) => {
-      if (err) {
-        return reject();
-      }
-
-      this.setConfig(doc);
-
-      return resolve();
-    });
-  });
-};
-
 Crowi.prototype.setupConfigManager = async function() {
   const ConfigManager = require('../service/config-manager');
   this.configManager = new ConfigManager(this.model('Config'));
   return this.configManager.loadConfigs();
 };
 
-Crowi.prototype.setupModels = function() {
-  const self = this;
-  return new Promise(((resolve, reject) => {
-    Object.keys(models).forEach((key) => {
-      self.model(key, models[key](self));
-    });
-    resolve();
-  }));
+Crowi.prototype.setupModels = async function() {
+  Object.keys(models).forEach((key) => {
+    return this.model(key, models[key](this));
+  });
 };
 
 Crowi.prototype.setupMiddlewares = async function() {
@@ -224,7 +249,7 @@ Crowi.prototype.getIo = function() {
   return this.io;
 };
 
-Crowi.prototype.scanRuntimeVersions = function() {
+Crowi.prototype.scanRuntimeVersions = async function() {
   const self = this;
 
   const check = require('check-node-version');
@@ -259,15 +284,7 @@ Crowi.prototype.getRestQiitaAPIService = function() {
   return this.restQiitaAPIService;
 };
 
-Crowi.prototype.setupPassport = function() {
-  const config = this.getConfig();
-  const Config = this.model('Config');
-
-  if (!Config.isEnabledPassport(config)) {
-    // disabled
-    return;
-  }
-
+Crowi.prototype.setupPassport = async function() {
   debug('Passport is enabled');
 
   // initialize service
@@ -283,7 +300,9 @@ Crowi.prototype.setupPassport = function() {
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();
     this.passportService.setupTwitterStrategy();
+    this.passportService.setupOidcStrategy();
     this.passportService.setupSamlStrategy();
+    this.passportService.setupBasicStrategy();
   }
   catch (err) {
     logger.error(err);
@@ -291,7 +310,7 @@ Crowi.prototype.setupPassport = function() {
   return Promise.resolve();
 };
 
-Crowi.prototype.setupSearcher = function() {
+Crowi.prototype.setupSearcher = async function() {
   const self = this;
   const searcherUri = this.env.ELASTICSEARCH_URI
     || this.env.BONSAI_URL
@@ -310,7 +329,7 @@ Crowi.prototype.setupSearcher = function() {
   }));
 };
 
-Crowi.prototype.setupMailer = function() {
+Crowi.prototype.setupMailer = async function() {
   const self = this;
   return new Promise(((resolve, reject) => {
     self.mailer = require('../util/mailer')(self);
@@ -318,13 +337,11 @@ Crowi.prototype.setupMailer = function() {
   }));
 };
 
-Crowi.prototype.setupSlack = function() {
+Crowi.prototype.setupSlack = async function() {
   const self = this;
-  const config = this.getConfig();
-  const Config = this.model('Config');
 
   return new Promise(((resolve, reject) => {
-    if (Config.hasSlackConfig(config)) {
+    if (this.slackNotificationService.hasSlackConfig()) {
       self.slack = require('../util/slack')(self);
     }
 
@@ -332,7 +349,7 @@ Crowi.prototype.setupSlack = function() {
   }));
 };
 
-Crowi.prototype.setupCsrf = function() {
+Crowi.prototype.setupCsrf = async function() {
   const Tokens = require('csrf');
   this.tokens = new Tokens();
 
@@ -383,8 +400,7 @@ Crowi.prototype.buildServer = function() {
   require('./express-init')(this, express);
 
   // import plugins
-  const Config = this.model('Config');
-  const isEnabledPlugins = Config.isEnabledPlugins(this.config);
+  const isEnabledPlugins = this.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
   if (isEnabledPlugins) {
     debug('Plugins are enabled');
     const PluginService = require('../plugins/plugin.service');
@@ -437,21 +453,90 @@ Crowi.prototype.require = function(modulePath) {
 /**
  * setup GlobalNotificationService
  */
-Crowi.prototype.setUpGlobalNotification = function() {
+Crowi.prototype.setUpGlobalNotification = async function() {
   const GlobalNotificationService = require('../service/global-notification');
   if (this.globalNotificationService == null) {
     this.globalNotificationService = new GlobalNotificationService(this);
   }
 };
 
+/**
+ * setup SlackNotificationService
+ */
+Crowi.prototype.setUpSlacklNotification = async function() {
+  const SlackNotificationService = require('../service/slack-notification');
+  if (this.slackNotificationService == null) {
+    this.slackNotificationService = new SlackNotificationService(this.configManager);
+  }
+};
+
+/**
+ * setup XssService
+ */
+Crowi.prototype.setUpXss = async function() {
+  const XssService = require('../service/xss');
+  if (this.xssService == null) {
+    this.xssService = new XssService(this.configManager);
+  }
+};
+
+/**
+ * setup AclService
+ */
+Crowi.prototype.setUpAcl = async function() {
+  const AclService = require('../service/acl');
+  if (this.aclService == null) {
+    this.aclService = new AclService(this.configManager);
+  }
+};
+
+/**
+ * setup CustomizeService
+ */
+Crowi.prototype.setUpCustomize = async function() {
+  const CustomizeService = require('../service/customize');
+  if (this.customizeService == null) {
+    this.customizeService = new CustomizeService(this.configManager, this.appService, this.xssService);
+    this.customizeService.initCustomCss();
+    this.customizeService.initCustomTitle();
+  }
+};
+
+/**
+ * setup AppService
+ */
+Crowi.prototype.setUpApp = async function() {
+  const AppService = require('../service/app');
+  if (this.appService == null) {
+    this.appService = new AppService(this.configManager);
+  }
+};
+
+/**
+ * setup FileUploadService
+ */
+Crowi.prototype.setUpFileUpload = async function() {
+  if (this.fileUploadService == null) {
+    this.fileUploadService = require('../service/file-uploader')(this);
+  }
+};
+
 /**
  * setup RestQiitaAPIService
  */
-Crowi.prototype.setUpRestQiitaAPI = function() {
+Crowi.prototype.setUpRestQiitaAPI = async function() {
   const RestQiitaAPIService = require('../service/rest-qiita-API');
   if (this.restQiitaAPIService == null) {
     this.restQiitaAPIService = new RestQiitaAPIService(this);
   }
 };
 
+Crowi.prototype.setupUserGroup = async function() {
+  const UserGroupService = require('../service/user-group');
+  if (this.userGroupService == null) {
+    this.userGroupService = new UserGroupService(this);
+    return this.userGroupService.init();
+  }
+};
+
 module.exports = Crowi;

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

@@ -4,6 +4,7 @@ const field = form.field;
 
 module.exports = form(
   field('settingForm[customize:isEnabledTimeline]').trim().toBooleanStrict(),
+  field('settingForm[customize:isEnabledDeleteCompletely]').trim().toBooleanStrict(),
   field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
   field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict(),
   field('settingForm[customize:showRecentCreatedNumber]').trim().toInt(),

+ 1 - 2
src/server/form/admin/securityGeneral.js

@@ -5,11 +5,10 @@ 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:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),
   field('settingForm[security:list-policy:hideRestrictedByGroup]').trim().toBooleanStrict(),
+  field('settingForm[security:pageCompleteDeletionAuthority]'),
 );

+ 0 - 8
src/server/form/admin/securityGoogle.js

@@ -1,8 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[google:clientId]').trim().is(/^[\da-z\-.]+$/),
-  field('settingForm[google:clientSecret]').trim().is(/^[\da-zA-Z\-_]+$/),
-);

+ 0 - 7
src/server/form/admin/securityMechanism.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[security:isEnabledPassport]').trim().toBooleanStrict(),
-);

+ 9 - 0
src/server/form/admin/securityPassportBasic.js

@@ -0,0 +1,9 @@
+const form = require('express-form');
+
+const field = form.field;
+
+module.exports = form(
+  field('settingForm[security:passport-basic:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-basic:id]').trim(),
+  field('settingForm[security:passport-basic:password]').trim(),
+);

+ 17 - 0
src/server/form/admin/securityPassportOidc.js

@@ -0,0 +1,17 @@
+const form = require('express-form');
+
+const field = form.field;
+
+module.exports = form(
+  field('settingForm[security:passport-oidc:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-oidc:providerName]').trim(),
+  field('settingForm[security:passport-oidc:issuerHost]').trim(),
+  field('settingForm[security:passport-oidc:clientId]').trim(),
+  field('settingForm[security:passport-oidc:clientSecret]').trim(),
+  field('settingForm[security:passport-oidc:attrMapId]').trim(),
+  field('settingForm[security:passport-oidc:attrMapUserName]').trim(),
+  field('settingForm[security:passport-oidc:attrMapName]').trim(),
+  field('settingForm[security:passport-oidc:attrMapMail]').trim(),
+  field('settingForm[security:passport-oidc:isSameEmailTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-oidc:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+);

+ 2 - 2
src/server/form/index.js

@@ -19,13 +19,13 @@ module.exports = {
     importerQiita: require('./admin/importerQiita'),
     plugin: require('./admin/plugin'),
     securityGeneral: require('./admin/securityGeneral'),
-    securityGoogle: require('./admin/securityGoogle'),
-    securityMechanism: require('./admin/securityMechanism'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
     securityPassportSaml: require('./admin/securityPassportSaml'),
+    securityPassportBasic: require('./admin/securityPassportBasic'),
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),
+    securityPassportOidc: require('./admin/securityPassportOidc'),
     markdown: require('./admin/markdown'),
     markdownXss: require('./admin/markdownXss'),
     markdownPresentation: require('./admin/markdownPresentation'),

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

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

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

@@ -11,8 +11,6 @@ const mongoose = require('mongoose');
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 module.exports = function(crowi) {
-  const fileUploader = require('../service/file-uploader')(crowi);
-
   function generateFileHash(fileName) {
     const hash = require('crypto').createHash('md5');
     hash.update(`${fileName}_${Date.now()}`);
@@ -44,6 +42,7 @@ module.exports = function(crowi) {
 
 
   attachmentSchema.statics.create = async function(pageId, user, fileStream, originalName, fileFormat, fileSize) {
+    const fileUploader = require('../service/file-uploader')(crowi);
     const Attachment = this;
 
     const extname = path.extname(originalName);
@@ -94,6 +93,7 @@ module.exports = function(crowi) {
   };
 
   attachmentSchema.statics.removeWithSubstanceById = async function(id) {
+    const fileUploader = require('../service/file-uploader')(crowi);
     // retrieve data from DB to get a completely populated instance
     const attachment = await this.findById(id);
     await fileUploader.deleteFile(attachment);

+ 8 - 0
src/server/models/comment.js

@@ -78,6 +78,14 @@ module.exports = function(crowi) {
     }));
   };
 
+  commentSchema.methods.removeWithReplies = async function() {
+    const Comment = crowi.model('Comment');
+    return Comment.remove({
+      $or: (
+        [{ replyTo: this._id }, { _id: this._id }]),
+    });
+  };
+
   /**
    * post save hook
    */

+ 60 - 534
src/server/models/config.js

@@ -5,17 +5,6 @@
 
 module.exports = function(crowi) {
   const mongoose = require('mongoose');
-  const debug = require('debug')('growi:models:config');
-  const uglifycss = require('uglifycss');
-  const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
-
-  const SECURITY_RESTRICT_GUEST_MODE_DENY = 'Deny';
-  const SECURITY_RESTRICT_GUEST_MODE_READONLY = 'Readonly';
-  const SECURITY_REGISTRATION_MODE_OPEN = 'Open';
-  const SECURITY_REGISTRATION_MODE_RESTRICTED = 'Resricted';
-  const SECURITY_REGISTRATION_MODE_CLOSED = 'Closed';
-
-  let Config;
 
   const configSchema = new mongoose.Schema({
     ns: { type: String, required: true, index: true },
@@ -23,21 +12,15 @@ module.exports = function(crowi) {
     value: { type: String, required: true },
   });
 
-  function validateCrowi() {
-    if (crowi == null) {
-      throw new Error('"crowi" is null. Init Config model with "crowi" argument first.');
-    }
-  }
-
   /**
    * default values when GROWI is cleanly installed
    */
-  function getArrayForInstalling() {
+  function getConfigsForInstalling() {
     const config = getDefaultCrowiConfigs();
 
     // overwrite
+    config['app:installed'] = true;
     config['app:fileUpload'] = true;
-    config['security:isEnabledPassport'] = true;
     config['customize:behavior'] = 'growi';
     config['customize:layout'] = 'growi';
     config['customize:isSavedStatesOfTabChanges'] = false;
@@ -51,8 +34,8 @@ module.exports = function(crowi) {
   function getDefaultCrowiConfigs() {
     /* eslint-disable key-spacing */
     return {
-      // 'app:installed'     : "0.0.0",
-      'app:confidential'  : '',
+      'app:installed'     : false,
+      'app:confidential'  : undefined,
 
       'app:fileUpload'    : false,
       'app:globalLang'    : 'en-US',
@@ -64,8 +47,8 @@ module.exports = function(crowi) {
 
       'security:list-policy:hideRestrictedByOwner' : false,
       'security:list-policy:hideRestrictedByGroup' : false,
+      'security:pageCompleteDeletionAuthority' : undefined,
 
-      'security:isEnabledPassport' : false,
       'security:passport-ldap:isEnabled' : false,
       'security:passport-ldap:serverUrl' : undefined,
       'security:passport-ldap:isUserBind' : undefined,
@@ -84,27 +67,29 @@ module.exports = function(crowi) {
       'security:passport-google:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
+      'security:passport-oidc:isEnabled' : false,
+      'security:passport-basic:isEnabled' : false,
 
       'aws:bucket'          : 'growi',
       'aws:region'          : 'ap-northeast-1',
-      'aws:accessKeyId'     : '',
-      'aws:secretAccessKey' : '',
+      'aws:accessKeyId'     : undefined,
+      'aws:secretAccessKey' : undefined,
 
-      'mail:from'         : '',
-      'mail:smtpHost'     : '',
-      'mail:smtpPort'     : '',
-      'mail:smtpUser'     : '',
-      'mail:smtpPassword' : '',
+      'mail:from'         : undefined,
+      'mail:smtpHost'     : undefined,
+      'mail:smtpPort'     : undefined,
+      'mail:smtpUser'     : undefined,
+      'mail:smtpPassword' : undefined,
 
-      'google:clientId'     : '',
-      'google:clientSecret' : '',
+      'google:clientId'     : undefined,
+      'google:clientSecret' : undefined,
 
       'plugin:isEnabledPlugins' : true,
 
-      'customize:css' : '',
-      'customize:script' : '',
-      'customize:header' : '',
-      'customize:title' : '',
+      'customize:css' : undefined,
+      'customize:script' : undefined,
+      'customize:header' : undefined,
+      'customize:title' : undefined,
       'customize:highlightJsStyle' : 'github',
       'customize:highlightJsStyleBorder' : false,
       'customize:theme' : 'default',
@@ -115,10 +100,10 @@ module.exports = function(crowi) {
       'customize:isEnabledAttachTitleHeader' : false,
       'customize:showRecentCreatedNumber' : 10,
 
-      'importer:esa:team_name': '',
-      'importer:esa:access_token': '',
-      'importer:qiita:team_name': '',
-      'importer:qiita:access_token': '',
+      'importer:esa:team_name': undefined,
+      'importer:esa:access_token': undefined,
+      'importer:qiita:team_name': undefined,
+      'importer:qiita:access_token': undefined,
     };
     /* eslint-enable key-spacing */
   }
@@ -132,27 +117,24 @@ module.exports = function(crowi) {
       'markdown:isEnabledLinebreaks': false,
       'markdown:isEnabledLinebreaksInComments': true,
       'markdown:presentation:pageBreakSeparator': 1,
-      'markdown:presentation:pageBreakCustomSeparator': '',
+      'markdown:presentation:pageBreakCustomSeparator': undefined,
     };
   }
 
-  function getValueForCrowiNS(config, key) {
-    // return the default value if undefined
-    if (undefined === config.crowi || undefined === config.crowi[key]) {
-      return getDefaultCrowiConfigs()[key];
-    }
-
-    return config.crowi[key];
+  function getDefaultNotificationConfigs() {
+    return {
+      'slack:isIncomingWebhookPrioritized': false,
+      'slack:incomingWebhookUrl': undefined,
+      'slack:token': undefined,
+    };
   }
 
-  function getValueForMarkdownNS(config, key) {
-    // return the default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs()[key];
-    }
-
-    return config.markdown[key];
-  }
+  /**
+   * It is deprecated to use this for anything other than AppService#isDBInitialized.
+   */
+  configSchema.statics.getConfigsObjectForInstalling = function() {
+    return getConfigsForInstalling();
+  };
 
   /**
    * It is deprecated to use this for anything other than ConfigLoader#load.
@@ -168,462 +150,36 @@ module.exports = function(crowi) {
     return getDefaultMarkdownConfigs();
   };
 
-  configSchema.statics.getRestrictGuestModeLabels = function() {
-    const labels = {};
-    labels[SECURITY_RESTRICT_GUEST_MODE_DENY] = 'security_setting.guest_mode.deny';
-    labels[SECURITY_RESTRICT_GUEST_MODE_READONLY] = 'security_setting.guest_mode.readonly';
-
-    return labels;
-  };
-
-  configSchema.statics.getRegistrationModeLabels = function() {
-    const labels = {};
-    labels[SECURITY_REGISTRATION_MODE_OPEN] = 'security_setting.registration_mode.open';
-    labels[SECURITY_REGISTRATION_MODE_RESTRICTED] = 'security_setting.registration_mode.restricted';
-    labels[SECURITY_REGISTRATION_MODE_CLOSED] = 'security_setting.registration_mode.closed';
-
-    return labels;
-  };
-
-  configSchema.statics.updateConfigCache = function(ns, config) {
-    validateCrowi();
-
-    const originalConfig = crowi.getConfig();
-    const newNSConfig = originalConfig[ns] || {};
-    Object.keys(config).forEach((key) => {
-      if (config[key] || config[key] === '' || config[key] === false) {
-        newNSConfig[key] = config[key];
-      }
-    });
-
-    originalConfig[ns] = newNSConfig;
-    crowi.setConfig(originalConfig);
-
-    // initialize custom css/script
-    Config.initCustomCss(originalConfig);
-    Config.initCustomScript(originalConfig);
-  };
-
-  // Execute only once for installing application
-  configSchema.statics.applicationInstall = function(callback) {
-    const Config = this;
-    Config.count({ ns: 'crowi' }, (err, count) => {
-      if (count > 0) {
-        return callback(new Error('Application already installed'), null);
-      }
-      Config.updateNamespaceByArray('crowi', getArrayForInstalling(), (err, configs) => {
-        Config.updateConfigCache('crowi', configs);
-        return callback(err, configs);
-      });
-    });
-  };
-
-  configSchema.statics.setupConfigFormData = function(ns, config) {
-    let defaultConfig = {};
-
-    // set Default Settings
-    if (ns === 'crowi') {
-      defaultConfig = getDefaultCrowiConfigs();
-    }
-    else if (ns === 'markdown') {
-      defaultConfig = getDefaultMarkdownConfigs();
-    }
-
-    if (!defaultConfig[ns]) {
-      defaultConfig[ns] = {};
-    }
-    Object.keys(config[ns] || {}).forEach((key) => {
-      if (config[ns][key] !== undefined) {
-        defaultConfig[key] = config[ns][key];
-      }
-    });
-    return defaultConfig;
-  };
-
-
-  configSchema.statics.updateNamespaceByArray = function(ns, configs, callback) {
-    const Config = this;
-    if (configs.length < 0) {
-      return callback(new Error('Argument #1 is not array.'), null);
-    }
-
-    Object.keys(configs).forEach((key) => {
-      const value = configs[key];
-
-      Config.findOneAndUpdate(
-        { ns, key },
-        { ns, key, value: JSON.stringify(value) },
-        { upsert: true },
-        (err, config) => {
-          debug('Config.findAndUpdate', err, config);
-        },
-      );
-    });
-
-    return callback(null, configs);
-  };
-
-  configSchema.statics.findOneAndUpdateByNsAndKey = async function(ns, key, value) {
-    return this.findOneAndUpdate(
-      { ns, key },
-      { ns, key, value: JSON.stringify(value) },
-      { upsert: true },
-    );
-  };
-
-  configSchema.statics.getConfig = function(callback) {
-  };
-
-  configSchema.statics.loadAllConfig = function(callback) {
-    const Config = this;
-
-
-    const config = {};
-    config.crowi = {}; // crowi namespace
-
-    Config.find()
-      .sort({ ns: 1, key: 1 })
-      .exec((err, doc) => {
-        doc.forEach((el) => {
-          if (!config[el.ns]) {
-            config[el.ns] = {};
-          }
-          config[el.ns][el.key] = JSON.parse(el.value);
-        });
-
-        debug('Config loaded', config);
-
-        // initialize custom css/script
-        Config.initCustomCss(config);
-        Config.initCustomScript(config);
-
-        return callback(null, config);
-      });
-  };
-
-  configSchema.statics.appTitle = function(config) {
-    const key = 'app:title';
-    return getValueForCrowiNS(config, key) || 'GROWI';
-  };
-
-  configSchema.statics.globalLang = function(config) {
-    const key = 'app:globalLang';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isEnabledPassport = function(config) {
-    // always true if growi installed cleanly
-    if (Object.keys(config.crowi).length === 0) {
-      return true;
-    }
-
-    const key = 'security:isEnabledPassport';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isEnabledPassportLdap = function(config) {
-    const key = 'security:passport-ldap:isEnabled';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isEnabledPassportGoogle = function(config) {
-    const key = 'security:passport-google:isEnabled';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isEnabledPassportGitHub = function(config) {
-    const key = 'security:passport-github:isEnabled';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isEnabledPassportTwitter = function(config) {
-    const key = 'security:passport-twitter:isEnabled';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isUploadable = function(config) {
-    const method = process.env.FILE_UPLOAD || 'aws';
-
-    if (method === 'aws' && (
-      !config.crowi['aws:accessKeyId']
-        || !config.crowi['aws:secretAccessKey']
-        || !config.crowi['aws:region']
-        || !config.crowi['aws:bucket'])) {
-      return false;
-    }
-
-    return method !== 'none';
-  };
-
-  configSchema.statics.isGuestAllowedToRead = function(config) {
-    // return true if puclic wiki mode
-    if (Config.isPublicWikiOnly(config)) {
-      return true;
-    }
-
-    // return false if undefined
-    if (undefined === config.crowi || undefined === config.crowi['security:restrictGuestMode']) {
-      return false;
-    }
-
-    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);
-  };
-
-  configSchema.statics.isEnabledLinebreaks = function(config) {
-    const key = 'markdown:isEnabledLinebreaks';
-    return getValueForMarkdownNS(config, key);
-  };
-
-  configSchema.statics.isEnabledLinebreaksInComments = function(config) {
-    const key = 'markdown:isEnabledLinebreaksInComments';
-    return getValueForMarkdownNS(config, key);
-  };
-  configSchema.statics.isPublicWikiOnly = function(config) {
-    const publicWikiOnly = process.env.PUBLIC_WIKI_ONLY;
-    if (publicWikiOnly === 'true' || publicWikiOnly === 1) {
-      return true;
-    }
-    return false;
-  };
-
-  configSchema.statics.pageBreakSeparator = function(config) {
-    const key = 'markdown:presentation:pageBreakSeparator';
-    return getValueForMarkdownNS(config, key);
-  };
-
-  configSchema.statics.pageBreakCustomSeparator = function(config) {
-    const key = 'markdown:presentation:pageBreakCustomSeparator';
-    return getValueForMarkdownNS(config, key);
-  };
-
-  configSchema.statics.isEnabledXssPrevention = function(config) {
-    const key = 'markdown:xss:isEnabledPrevention';
-    return getValueForMarkdownNS(config, key);
-  };
-
-  configSchema.statics.xssOption = function(config) {
-    const key = 'markdown:xss:option';
-    return getValueForMarkdownNS(config, key);
-  };
-
-  configSchema.statics.tagWhiteList = function(config) {
-    const key = 'markdown:xss:tagWhiteList';
-
-    if (this.isEnabledXssPrevention(config)) {
-      switch (this.xssOption(config)) {
-        case 1: // ignore all: use default option
-          return [];
-
-        case 2: // recommended
-          return recommendedWhitelist.tags;
-
-        case 3: // custom white list
-          return config.markdown[key];
-
-        default:
-          return [];
-      }
-    }
-    else {
-      return [];
-    }
-  };
-
-  configSchema.statics.attrWhiteList = function(config) {
-    const key = 'markdown:xss:attrWhiteList';
-
-    if (this.isEnabledXssPrevention(config)) {
-      switch (this.xssOption(config)) {
-        case 1: // ignore all: use default option
-          return [];
-
-        case 2: // recommended
-          return recommendedWhitelist.attrs;
-
-        case 3: // custom white list
-          return config.markdown[key];
-
-        default:
-          return [];
-      }
-    }
-    else {
-      return [];
-    }
-  };
-
-  /**
-   * initialize custom css strings
-   */
-  configSchema.statics.initCustomCss = function(config) {
-    const key = 'customize:css';
-    const rawCss = getValueForCrowiNS(config, key);
-    // uglify and store
-    this._customCss = uglifycss.processString(rawCss);
-  };
-
-  configSchema.statics.customCss = function(config) {
-    return this._customCss;
-  };
-
-  configSchema.statics.initCustomScript = function(config) {
-    const key = 'customize:script';
-    const rawScript = getValueForCrowiNS(config, key);
-    // store as is
-    this._customScript = rawScript;
-  };
-
-  configSchema.statics.customScript = function(config) {
-    return this._customScript;
-  };
-
-  configSchema.statics.customHeader = function(config) {
-    const key = 'customize:header';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.theme = function(config) {
-    const key = 'customize:theme';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.customTitle = function(config, page) {
-    validateCrowi();
-
-    const key = 'customize:title';
-    let customTitle = getValueForCrowiNS(config, key);
-
-    if (customTitle == null || customTitle.trim().length === 0) {
-      customTitle = '{{page}} - {{sitename}}';
-    }
-
-    // replace
-    customTitle = customTitle
-      .replace('{{sitename}}', this.appTitle(config))
-      .replace('{{page}}', page);
-
-    return crowi.xss.process(customTitle);
-  };
-
-  configSchema.statics.behaviorType = function(config) {
-    const key = 'customize:behavior';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.layoutType = function(config) {
-    const key = 'customize:layout';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.highlightJsStyle = function(config) {
-    const key = 'customize:highlightJsStyle';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.highlightJsStyleBorder = function(config) {
-    const key = 'customize:highlightJsStyleBorder';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isEnabledTimeline = function(config) {
-    const key = 'customize:isEnabledTimeline';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isSavedStatesOfTabChanges = function(config) {
-    const key = 'customize:isSavedStatesOfTabChanges';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isEnabledAttachTitleHeader = function(config) {
-    const key = 'customize:isEnabledAttachTitleHeader';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.showRecentCreatedNumber = function(config) {
-    const key = 'customize:showRecentCreatedNumber';
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.fileUploadEnabled = function(config) {
-    const Config = this;
-
-    if (!Config.isUploadable(config)) {
-      return false;
-    }
-
-    // convert to boolean
-    return !!config.crowi['app:fileUpload'];
-  };
-
-  configSchema.statics.hasSlackConfig = function(config) {
-    return Config.hasSlackToken(config) || Config.hasSlackIwhUrl(config);
-  };
-
   /**
-   * for Slack Incoming Webhooks
+   * It is deprecated to use this for anything other than ConfigLoader#load.
    */
-  configSchema.statics.hasSlackIwhUrl = function(config) {
-    if (!config.notification) {
-      return false;
-    }
-    return (!!config.notification['slack:incomingWebhookUrl']);
-  };
-
-  configSchema.statics.isIncomingWebhookPrioritized = function(config) {
-    if (!config.notification) {
-      return false;
-    }
-    return (!!config.notification['slack:isIncomingWebhookPrioritized']);
+  configSchema.statics.getDefaultNotificationConfigsObject = function() {
+    return getDefaultNotificationConfigs();
   };
 
-  configSchema.statics.hasSlackToken = function(config) {
-    if (!config.notification) {
-      return false;
-    }
-
-    return (!!config.notification['slack:token']);
-  };
-
-  configSchema.statics.getLocalconfig = function(config) {
-    const Config = this;
+  configSchema.statics.getLocalconfig = function() {
     const env = process.env;
 
     const localConfig = {
       crowi: {
-        title: Config.appTitle(crowi),
-        url: crowi.configManager.getSiteUrl(),
+        title: crowi.appService.getAppTitle(),
+        url: crowi.appService.getSiteUrl(),
       },
       upload: {
-        image: Config.isUploadable(config),
-        file: Config.fileUploadEnabled(config),
+        image: crowi.fileUploadService.getIsUploadable(),
+        file: crowi.fileUploadService.getFileUploadEnabled(),
       },
-      behaviorType: Config.behaviorType(config),
-      layoutType: Config.layoutType(config),
-      isEnabledLinebreaks: Config.isEnabledLinebreaks(config),
-      isEnabledLinebreaksInComments: Config.isEnabledLinebreaksInComments(config),
-      isEnabledXssPrevention: Config.isEnabledXssPrevention(config),
-      xssOption: Config.xssOption(config),
-      tagWhiteList: Config.tagWhiteList(config),
-      attrWhiteList: Config.attrWhiteList(config),
-      highlightJsStyleBorder: Config.highlightJsStyleBorder(config),
-      isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
-      hasSlackConfig: Config.hasSlackConfig(config),
+      behaviorType: crowi.configManager.getConfig('crowi', 'customize:behavior'),
+      layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
+      isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+      isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+      isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+      xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
+      tagWhiteList: crowi.xssService.getTagWhiteList(),
+      attrWhiteList: crowi.xssService.getAttrWhiteList(),
+      highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+      isSavedStatesOfTabChanges: crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
+      hasSlackConfig: crowi.slackNotificationService.hasSlackConfig(),
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
@@ -631,45 +187,15 @@ module.exports = function(crowi) {
         MATHJAX: env.MATHJAX || null,
         NO_CDN: env.NO_CDN || null,
       },
-      recentCreatedLimit: Config.showRecentCreatedNumber(config),
-      isAclEnabled: !Config.isPublicWikiOnly(config),
-      globalLang: Config.globalLang(config),
+      recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+      isAclEnabled: !crowi.aclService.getIsPublicWikiOnly(),
+      globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };
 
     return localConfig;
   };
 
-  configSchema.statics.userUpperLimit = function(crowi) {
-    const key = 'USER_UPPER_LIMIT';
-    const env = crowi.env[key];
-
-    if (undefined === crowi.env || undefined === crowi.env[key]) {
-      return 0;
-    }
-    return Number(env);
-  };
-
-  /*
-  configSchema.statics.isInstalled = function(config)
-  {
-    if (!config.crowi) {
-      return false;
-    }
-
-    if (config.crowi['app:installed']
-       && config.crowi['app:installed'] !== '0.0.0') {
-      return true;
-    }
-
-    return false;
-  }
-  */
-
-  Config = mongoose.model('Config', configSchema);
-  Config.SECURITY_REGISTRATION_MODE_OPEN = SECURITY_REGISTRATION_MODE_OPEN;
-  Config.SECURITY_REGISTRATION_MODE_RESTRICTED = SECURITY_REGISTRATION_MODE_RESTRICTED;
-  Config.SECURITY_REGISTRATION_MODE_CLOSED = SECURITY_REGISTRATION_MODE_CLOSED;
-
+  const Config = mongoose.model('Config', configSchema);
 
   return Config;
 };

+ 1 - 1
src/server/models/index.js

@@ -1,6 +1,6 @@
 module.exports = {
+  Config: require('./config'),
   Page: require('./page'),
-  PageGroupRelation: require('./page-group-relation'),
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   ExternalAccount: require('./external-account'),

+ 0 - 262
src/server/models/page-group-relation.js

@@ -1,262 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const debug = require('debug')('growi:models:pageGroupRelation');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
-  targetPage: { type: ObjectId, ref: 'Page', required: true },
-  createdAt: { type: Date, default: Date.now },
-}, {
-  toJSON: { getters: true },
-  toObject: { getters: true },
-});
-// apply plugins
-schema.plugin(mongoosePaginate);
-
-
-/**
- * PageGroupRelation Class
- *
- * @class PageGroupRelation
- */
-class PageGroupRelation {
-
-  /**
-   * limit items num for pagination
-   *
-   * @readonly
-   * @static
-   * @memberof PageGroupRelation
-   */
-  static get PAGE_ITEMS() {
-    return 50;
-  }
-
-  static set crowi(crowi) {
-    this._crowi = crowi;
-  }
-
-  static get crowi() {
-    return this._crowi;
-  }
-
-  static init() {
-    this.removeAllInvalidRelations();
-  }
-
-  /**
-   * remove all invalid relations that has reference to unlinked document
-   */
-  static removeAllInvalidRelations() {
-    return this.findAllRelation()
-      .then((relations) => {
-        // filter invalid documents
-        return relations.filter((relation) => {
-          return relation.targetPage == null || relation.relatedGroup == null;
-        });
-      })
-      .then((invalidRelations) => {
-        const ids = invalidRelations.map((relation) => { return relation._id });
-        return this.deleteMany({ _id: { $in: ids } });
-      });
-  }
-
-  /**
-   * find all page and group relation
-   *
-   * @static
-   * @returns {Promise<PageGroupRelation[]>}
-   * @memberof PageGroupRelation
-   */
-  static findAllRelation() {
-    return this
-      .find()
-      .populate('targetPage')
-      .populate('relatedGroup')
-      .exec();
-  }
-
-  /**
-   * find all page and group relation for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @returns {Promise<PageGroupRelation[]>}
-   * @memberof PageGroupRelation
-   */
-  static findAllRelationForUserGroup(userGroup) {
-    debug('findAllRelationForUserGroup is called', userGroup);
-
-    return this
-      .find({ relatedGroup: userGroup.id })
-      .populate('targetPage')
-      .exec();
-  }
-
-  /**
-   * find all entities with pagination
-   *
-   * @see https://github.com/edwardhotchkiss/mongoose-paginate
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @param {any} opts mongoose-paginate options object
-   * @returns {Promise<any>} mongoose-paginate result object
-   * @memberof UserGroupRelation
-   */
-  // static findPageGroupRelationsWithPagination(userGroup, opts) {
-  //   const query = { relatedGroup: userGroup };
-  //   const options = Object.assign({}, opts);
-  //   if (options.page == null) {
-  //     options.page = 1;
-  //   }
-  //   if (options.limit == null) {
-  //     options.limit = UserGroupRelation.PAGE_ITEMS;
-  //   }
-
-  //   return this.paginate(query, options);
-  // }
-
-  /**
-   * find the relation or create(if not exists) for page and group
-   *
-   * @static
-   * @param {Page} page
-   * @param {UserGroup} userGroup
-   * @returns {Promise<PageGroupRelation>}
-   * @memberof PageGroupRelation
-   */
-  static findOrCreateRelationForPageAndGroup(page, userGroup) {
-    const query = { targetPage: page.id, relatedGroup: userGroup.id };
-
-    return this
-      .count(query)
-      .then((count) => {
-        // return (0 < count);
-        if (count > 0) {
-          return this.find(query).exec();
-        }
-
-        return this.createRelation(userGroup, page);
-      });
-  }
-
-  /**
-   * find page and group relation for Page
-   *
-   * @static
-   * @param {Page} page
-   * @returns {Promise<PageGroupRelation[]>}
-   * @memberof PageGroupRelation
-   */
-  static findByPage(page) {
-    if (page == null) {
-      return null;
-    }
-    return this
-      .findOne({ targetPage: page.id })
-      .populate('relatedGroup')
-      .exec();
-  }
-
-  /**
-   * get is exists granted group for relatedPage and relatedUser
-   *
-   * @static
-   * @param {any} pageData relatedPage
-   * @param {any} userData relatedUser
-   * @returns is exists granted group(or not)
-   * @memberof PageGroupRelation
-   */
-  static async isExistsGrantedGroupForPageAndUser(pageData, userData) {
-    const UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
-
-    const pageRelation = await this.findByPage(pageData);
-    if (pageRelation == null) {
-      return false;
-    }
-    return await UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
-  }
-
-  /**
-   * create page and group relation
-   *
-   * @static
-   * @param {any} userGroup
-   * @param {any} page
-   * @returns
-   * @memberof PageGroupRelation
-   */
-  static createRelation(userGroup, page) {
-    return this.create({
-      relatedGroup: userGroup.id,
-      targetPage: page.id,
-    });
-  }
-
-  /**
-   * remove all relation for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup related group for remove
-   * @returns {Promise<any>}
-   * @memberof PageGroupRelation
-   */
-  static removeAllByUserGroup(userGroup) {
-    return this.deleteMany({ relatedGroup: userGroup });
-  }
-
-  /**
-   * remove all relation for Page
-   *
-   * @static
-   * @param {Page} page related page for remove
-   * @returns {Promise<any>}
-   * @memberof PageGroupRelation
-   */
-  static removeAllByPage(page) {
-    return this.findByPage(page)
-      .then((relation) => {
-        if (relation != null) {
-          relation.remove();
-        }
-      });
-  }
-
-  /**
-   * remove relation by id
-   *
-   * @static
-   * @param {ObjectId} id for remove
-   * @returns {Promise<any>}
-   * @memberof PageGroupRelation
-   */
-  static removeById(id) {
-    return this.findById(id)
-      .then((relationData) => {
-        if (relationData == null) {
-          throw new Error('PageGroupRelation data is not exists. id:', id);
-        }
-        else {
-          relationData.remove();
-        }
-      });
-  }
-
-}
-
-module.exports = function(crowi) {
-  PageGroupRelation.crowi = crowi;
-  schema.loadClass(PageGroupRelation);
-  const model = mongoose.model('PageGroupRelation', schema);
-  model.init();
-  return model;
-};

+ 10 - 15
src/server/models/page.js

@@ -468,12 +468,12 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
-    this.grant = grant;
-
     // reset
     this.grantedUsers = [];
     this.grantedGroup = null;
 
+    this.grant = grant || GRANT_PUBLIC;
+
     if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP) {
       this.grantedUsers.push(user._id);
     }
@@ -807,12 +807,9 @@ module.exports = function(crowi) {
   async function addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink) {
     validateCrowi();
 
-    const Config = crowi.model('Config');
-    const config = crowi.getConfig();
-
     // determine User condition
-    const hidePagesRestrictedByOwner = Config.hidePagesRestrictedByOwnerInList(config);
-    const hidePagesRestrictedByGroup = Config.hidePagesRestrictedByGroupInList(config);
+    const hidePagesRestrictedByOwner = crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
+    const hidePagesRestrictedByGroup = crowi.configManager.getConfig('crowi', 'security:list-policy:hidePagesRestrictedByGroupInList');
 
     // determine UserGroup condition
     let userGroups = null;
@@ -934,7 +931,7 @@ module.exports = function(crowi) {
       .cursor();
   };
 
-  async function pushRevision(pageData, newRevision, user, grant, grantUserGroupId) {
+  async function pushRevision(pageData, newRevision, user) {
     await newRevision.save();
     debug('Successfully saved new revision', newRevision);
 
@@ -973,7 +970,7 @@ module.exports = function(crowi) {
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
 
-    let grant = options.grant || GRANT_PUBLIC;
+    let grant = options.grant;
     // force public
     if (isPortalPath(path)) {
       grant = GRANT_PUBLIC;
@@ -997,7 +994,7 @@ module.exports = function(crowi) {
 
     let savedPage = await page.save();
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    const revision = await pushRevision(savedPage, newRevision, user, grant, grantUserGroupId);
+    const revision = await pushRevision(savedPage, newRevision, user);
     savedPage = await this.findByPath(revision.path)
       .populate('revision')
       .populate('creator');
@@ -1012,8 +1009,8 @@ module.exports = function(crowi) {
     validateCrowi();
 
     const Revision = crowi.model('Revision');
-    const grant = options.grant || null;
-    const grantUserGroupId = options.grantUserGroupId || null;
+    const grant = options.grant || pageData.grant; //                                  use the previous data if absence
+    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const socketClientId = options.socketClientId || null;
 
@@ -1023,7 +1020,7 @@ module.exports = function(crowi) {
     // update existing page
     let savedPage = await pageData.save();
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-    const revision = await pushRevision(savedPage, newRevision, user, grant, grantUserGroupId);
+    const revision = await pushRevision(savedPage, newRevision, user);
     savedPage = await this.findByPath(revision.path)
       .populate('revision')
       .populate('creator');
@@ -1161,7 +1158,6 @@ module.exports = function(crowi) {
     const Attachment = crowi.model('Attachment');
     const Comment = crowi.model('Comment');
     const Revision = crowi.model('Revision');
-    const PageGroupRelation = crowi.model('PageGroupRelation');
     const pageId = pageData._id;
     const socketClientId = options.socketClientId || null;
 
@@ -1173,7 +1169,6 @@ module.exports = function(crowi) {
     await Revision.removeRevisionsByPath(pageData.path);
     await this.findByIdAndRemove(pageId);
     await this.removeRedirectOriginPageByPath(pageData.path);
-    await PageGroupRelation.removeAllByPage(pageData);
     if (socketClientId != null) {
       pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
     }

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

@@ -41,10 +41,6 @@ class UserGroupRelation {
     return this._crowi;
   }
 
-  static init() {
-    this.removeAllInvalidRelations();
-  }
-
   /**
    * remove all invalid relations that has reference to unlinked document
    */
@@ -288,6 +284,5 @@ module.exports = function(crowi) {
   UserGroupRelation.crowi = crowi;
   schema.loadClass(UserGroupRelation);
   const model = mongoose.model('UserGroupRelation', schema);
-  model.init();
   return model;
 };

+ 0 - 2
src/server/models/user-group.js

@@ -91,7 +91,6 @@ class UserGroup {
 
   // グループの完全削除
   static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
-    const PageGroupRelation = mongoose.model('PageGroupRelation');
     const UserGroupRelation = mongoose.model('UserGroupRelation');
     const Page = mongoose.model('Page');
 
@@ -103,7 +102,6 @@ class UserGroup {
 
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      PageGroupRelation.removeAllByUserGroup(deletedGroup),
       Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
     ]);
 

+ 42 - 27
src/server/models/user.js

@@ -78,21 +78,20 @@ module.exports = function(crowi) {
   function decideUserStatusOnRegistration() {
     validateCrowi();
 
-    const Config = crowi.model('Config');
+    const { configManager, aclService } = crowi;
 
-
-    const config = crowi.getConfig();
-
-    if (!config.crowi) {
+    const isInstalled = configManager.getConfig('crowi', 'app:installed');
+    if (!isInstalled) {
       return STATUS_ACTIVE; // is this ok?
     }
 
     // status decided depends on registrationMode
-    switch (config.crowi['security:registrationMode']) {
-      case Config.SECURITY_REGISTRATION_MODE_OPEN:
+    const registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
+    switch (registrationMode) {
+      case aclService.labels.SECURITY_REGISTRATION_MODE_OPEN:
         return STATUS_ACTIVE;
-      case Config.SECURITY_REGISTRATION_MODE_RESTRICTED:
-      case Config.SECURITY_REGISTRATION_MODE_CLOSED: // 一応
+      case aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED:
+      case aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED: // 一応
         return STATUS_REGISTERED;
       default:
         return STATUS_ACTIVE; // どっちにすんのがいいんだろうな
@@ -199,6 +198,21 @@ module.exports = function(crowi) {
     });
   };
 
+  userSchema.methods.canDeleteCompletely = function(creatorId) {
+    const pageCompleteDeletionAuthority = crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    if (this.admin) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
+      return (this._id.equals(creatorId));
+    }
+
+    return false;
+  };
+
   userSchema.methods.updateApiToken = function(callback) {
     const self = this;
 
@@ -227,7 +241,7 @@ module.exports = function(crowi) {
     this.image = undefined;
 
     if (this.imageAttachment != null) {
-      Attachment.removeWithSubstance(this.imageAttachment._id);
+      Attachment.removeWithSubstanceById(this.imageAttachment._id);
     }
 
     this.imageAttachment = undefined;
@@ -336,11 +350,11 @@ module.exports = function(crowi) {
   userSchema.statics.getLanguageLabels = getLanguageLabels;
   userSchema.statics.getUserStatusLabels = function() {
     const userStatus = {};
-    userStatus[STATUS_REGISTERED] = '承認待ち';
+    userStatus[STATUS_REGISTERED] = 'Approval Pending';
     userStatus[STATUS_ACTIVE] = 'Active';
     userStatus[STATUS_SUSPENDED] = 'Suspended';
     userStatus[STATUS_DELETED] = 'Deleted';
-    userStatus[STATUS_INVITED] = '招待済み';
+    userStatus[STATUS_INVITED] = 'Invited';
 
     return userStatus;
   };
@@ -348,11 +362,10 @@ module.exports = function(crowi) {
   userSchema.statics.isEmailValid = function(email, callback) {
     validateCrowi();
 
-    const config = crowi.getConfig();
-    const whitelist = config.crowi['security:registrationWhiteList'];
+    const whitelist = crowi.configManager.getConfig('crowi', 'security:registrationWhiteList');
 
     if (Array.isArray(whitelist) && whitelist.length > 0) {
-      return config.crowi['security:registrationWhiteList'].some((allowedEmail) => {
+      return whitelist.some((allowedEmail) => {
         const re = new RegExp(`${allowedEmail}$`);
         return re.test(email);
       });
@@ -511,8 +524,9 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.isUserCountExceedsUpperLimit = async function() {
-    const Config = crowi.model('Config');
-    const userUpperLimit = Config.userUpperLimit(crowi);
+    const { aclService } = crowi;
+
+    const userUpperLimit = aclService.userUpperLimit();
     if (userUpperLimit === 0) {
       return false;
     }
@@ -620,12 +634,12 @@ module.exports = function(crowi) {
   userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {
     validateCrowi();
 
+    const configManager = crowi.configManager;
+
     const User = this;
     const createdUserList = [];
-    const Config = crowi.model('Config');
-    const config = crowi.getConfig();
-
     const mailer = crowi.getMailer();
+
     if (!Array.isArray(emailList)) {
       debug('emailList is not array');
     }
@@ -665,7 +679,7 @@ module.exports = function(crowi) {
           newUser.createdAt = Date.now();
           newUser.status = STATUS_INVITED;
 
-          const globalLang = Config.globalLang(config);
+          const globalLang = configManager.getConfig('crowi', 'app:globalLang');
           if (globalLang != null) {
             newUser.lang = globalLang;
           }
@@ -706,15 +720,17 @@ module.exports = function(crowi) {
                 return next();
               }
 
+              const appTitle = crowi.appService.getAppTitle();
+
               mailer.send({
                 to: user.email,
-                subject: `Invitation to ${Config.appTitle(config)}`,
+                subject: `Invitation to ${appTitle}`,
                 template: path.join(crowi.localeDir, 'en-US/admin/userInvitation.txt'),
                 vars: {
                   email: user.email,
                   password: user.password,
-                  url: crowi.configManager.getSiteUrl(),
-                  appTitle: Config.appTitle(config),
+                  url: crowi.appService.getSiteUrl(),
+                  appTitle,
                 },
               },
               (err, s) => {
@@ -759,9 +775,8 @@ module.exports = function(crowi) {
       newUser.setPassword(password);
     }
 
-    const Config = crowi.model('Config');
-    const config = crowi.getConfig();
-    const globalLang = Config.globalLang(config);
+    const configManager = crowi.configManager;
+    const globalLang = configManager.getConfig('crowi', 'app:globalLang');
     if (globalLang != null) {
       newUser.lang = globalLang;
     }

+ 9 - 3
src/server/plugins/plugin.service.js

@@ -34,15 +34,21 @@ class PluginService {
     switch (meta.pluginSchemaVersion) {
       // v1 is deprecated
       case 1:
-        logger.warn('pluginSchemaVersion 1 is deprecated');
+        logger.warn('pluginSchemaVersion 1 is deprecated', definition);
         break;
-      // v2 or above
-      default:
+      // v2 is deprecated
+      case 2:
+        logger.warn('pluginSchemaVersion 2 is deprecated', definition);
+        break;
+      case 3:
         logger.info(`load plugin '${definition.name}'`);
         definition.entries.forEach((entryPath) => {
           const entry = require(entryPath);
           entry(this.crowi, this.app);
         });
+        break;
+      default:
+        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
     }
   }
 

+ 149 - 167
src/server/routes/admin.js

@@ -9,11 +9,17 @@ module.exports = function(crowi, app) {
   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 {
+    configManager,
+    aclService,
+    slackNotificationService,
+    customizeService,
+  } = crowi;
+
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
   const PluginUtils = require('../plugins/plugin-utils');
   const ApiResponse = require('../util/apiResponse');
@@ -25,7 +31,6 @@ module.exports = function(crowi, app) {
   const MAX_PAGE_LIST = 50;
   const actions = {};
 
-
   function createPager(total, limit, page, pagesCount, maxPageList) {
     const pager = {
       page,
@@ -91,11 +96,7 @@ module.exports = function(crowi, app) {
   // app.get('/admin/app'                  , admin.app.index);
   actions.app = {};
   actions.app.index = function(req, res) {
-    const settingForm = Config.setupConfigFormData('crowi', req.config);
-
-    return res.render('admin/app', {
-      settingForm,
-    });
+    return res.render('admin/app');
   };
 
   actions.app.settingUpdate = function(req, res) {
@@ -104,16 +105,13 @@ module.exports = function(crowi, app) {
   // app.get('/admin/security'                  , admin.security.index);
   actions.security = {};
   actions.security.index = function(req, res) {
-    const settingForm = Config.setupConfigFormData('crowi', req.config);
-    const isAclEnabled = !Config.isPublicWikiOnly(req.config);
-    return res.render('admin/security', { settingForm, isAclEnabled });
+    return res.render('admin/security');
   };
 
   // app.get('/admin/markdown'                  , admin.markdown.index);
   actions.markdown = {};
   actions.markdown.index = function(req, res) {
-    const config = crowi.getConfig();
-    const markdownSetting = Config.setupConfigFormData('markdown', config);
+    const markdownSetting = configManager.getConfigByPrefix('markdown', 'markdown:');
 
     return res.render('admin/markdown', {
       markdownSetting,
@@ -122,66 +120,54 @@ module.exports = function(crowi, app) {
   };
 
   // app.post('/admin/markdown/lineBreaksSetting' , admin.markdown.lineBreaksSetting);
-  actions.markdown.lineBreaksSetting = function(req, res) {
+  actions.markdown.lineBreaksSetting = async function(req, res) {
     const markdownSetting = req.form.markdownSetting;
 
-    req.session.markdownSetting = markdownSetting;
     if (req.form.isValid) {
-      Config.updateNamespaceByArray('markdown', markdownSetting, (err, config) => {
-        Config.updateConfigCache('markdown', config);
-        req.session.markdownSetting = null;
-        req.flash('successMessage', ['Successfully updated!']);
-        return res.redirect('/admin/markdown');
-      });
+      await configManager.updateConfigsInTheSameNamespace('markdown', markdownSetting);
+      req.flash('successMessage', ['Successfully updated!']);
     }
     else {
       req.flash('errorMessage', req.form.errors);
-      return res.redirect('/admin/markdown');
     }
+
+    return res.redirect('/admin/markdown');
   };
 
   // app.post('/admin/markdown/presentationSetting' , admin.markdown.presentationSetting);
-  actions.markdown.presentationSetting = function(req, res) {
-    const presentationSetting = req.form.markdownSetting;
+  actions.markdown.presentationSetting = async function(req, res) {
+    const markdownSetting = req.form.markdownSetting;
 
-    req.session.markdownSetting = presentationSetting;
     if (req.form.isValid) {
-      Config.updateNamespaceByArray('markdown', presentationSetting, (err, config) => {
-        Config.updateConfigCache('markdown', config);
-        req.session.markdownSetting = null;
-        req.flash('successMessage', ['Successfully updated!']);
-        return res.redirect('/admin/markdown');
-      });
+      await configManager.updateConfigsInTheSameNamespace('markdown', markdownSetting);
+      req.flash('successMessage', ['Successfully updated!']);
     }
     else {
       req.flash('errorMessage', req.form.errors);
-      return res.redirect('/admin/markdown');
     }
+
+    return res.redirect('/admin/markdown');
   };
 
   // app.post('/admin/markdown/xss-setting' , admin.markdown.xssSetting);
-  actions.markdown.xssSetting = function(req, res) {
+  actions.markdown.xssSetting = async function(req, res) {
     const xssSetting = req.form.markdownSetting;
 
-    xssSetting['markdown:xss:tagWhiteList'] = stringToArray(xssSetting['markdown:xss:tagWhiteList']);
-    xssSetting['markdown:xss:attrWhiteList'] = stringToArray(xssSetting['markdown:xss:attrWhiteList']);
+    xssSetting['markdown:xss:tagWhiteList'] = csvToArray(xssSetting['markdown:xss:tagWhiteList']);
+    xssSetting['markdown:xss:attrWhiteList'] = csvToArray(xssSetting['markdown:xss:attrWhiteList']);
 
-    req.session.markdownSetting = xssSetting;
     if (req.form.isValid) {
-      Config.updateNamespaceByArray('markdown', xssSetting, (err, config) => {
-        Config.updateConfigCache('markdown', config);
-        req.session.xssSetting = null;
-        req.flash('successMessage', ['Successfully updated!']);
-        return res.redirect('/admin/markdown');
-      });
+      await configManager.updateConfigsInTheSameNamespace('markdown', xssSetting);
+      req.flash('successMessage', ['Successfully updated!']);
     }
     else {
       req.flash('errorMessage', req.form.errors);
-      return res.redirect('/admin/markdown');
     }
+
+    return res.redirect('/admin/markdown');
   };
 
-  const stringToArray = (string) => {
+  const csvToArray = (string) => {
     const array = string.split(',');
     return array.map((item) => { return item.trim() });
   };
@@ -189,7 +175,7 @@ module.exports = function(crowi, app) {
   // app.get('/admin/customize' , admin.customize.index);
   actions.customize = {};
   actions.customize.index = function(req, res) {
-    const settingForm = Config.setupConfigFormData('crowi', req.config);
+    const settingForm = configManager.getConfigByPrefix('crowi', 'customize:');
 
     /* eslint-disable quote-props, no-multi-spaces */
     const highlightJsCssSelectorOptions = {
@@ -215,13 +201,12 @@ module.exports = function(crowi, app) {
   // app.get('/admin/notification'               , admin.notification.index);
   actions.notification = {};
   actions.notification.index = async(req, res) => {
-    const config = crowi.getConfig();
     const UpdatePost = crowi.model('UpdatePost');
-    let slackSetting = Config.setupConfigFormData('notification', config);
-    const hasSlackIwhUrl = Config.hasSlackIwhUrl(config);
-    const hasSlackToken = Config.hasSlackToken(config);
+    let slackSetting = configManager.getConfigByPrefix('notification', 'slack:');
+    const hasSlackIwhUrl = !!configManager.getConfig('notification', 'slack:incomingWebhookUrl');
+    const hasSlackToken = !!configManager.getConfig('notification', 'slack:token');
 
-    if (!Config.hasSlackIwhUrl(req.config)) {
+    if (!hasSlackIwhUrl) {
       slackSetting['slack:incomingWebhookUrl'] = '';
     }
 
@@ -243,51 +228,46 @@ module.exports = function(crowi, app) {
   };
 
   // app.post('/admin/notification/slackSetting' , admin.notification.slackauth);
-  actions.notification.slackSetting = function(req, res) {
+  actions.notification.slackSetting = async function(req, res) {
     const slackSetting = req.form.slackSetting;
 
-    req.session.slackSetting = slackSetting;
     if (req.form.isValid) {
-      Config.updateNamespaceByArray('notification', slackSetting, (err, config) => {
-        Config.updateConfigCache('notification', config);
-        req.flash('successMessage', ['Successfully Updated!']);
-        req.session.slackSetting = null;
-
-        // Re-setup
-        crowi.setupSlack().then(() => {
-          return res.redirect('/admin/notification');
-        });
+      await configManager.updateConfigsInTheSameNamespace('notification', slackSetting);
+      req.flash('successMessage', ['Successfully Updated!']);
+
+      // Re-setup
+      crowi.setupSlack().then(() => {
       });
     }
     else {
       req.flash('errorMessage', req.form.errors);
-      return res.redirect('/admin/notification');
     }
+
+    return res.redirect('/admin/notification');
   };
 
   // app.get('/admin/notification/slackAuth'     , admin.notification.slackauth);
   actions.notification.slackAuth = function(req, res) {
     const code = req.query.code;
 
-    if (!code || !Config.hasSlackConfig(req.config)) {
+    if (!code || !slackNotificationService.hasSlackConfig()) {
       return res.redirect('/admin/notification');
     }
 
     const slack = crowi.slack;
     slack.getOauthAccessToken(code)
-      .then((data) => {
+      .then(async(data) => {
         debug('oauth response', data);
-        Config.updateNamespaceByArray('notification', { 'slack:token': data.access_token }, (err, config) => {
-          if (err) {
-            req.flash('errorMessage', ['Failed to save access_token. Please try again.']);
-          }
-          else {
-            Config.updateConfigCache('notification', config);
-            req.flash('successMessage', ['Successfully Connected!']);
-          }
 
-          return res.redirect('/admin/notification');
-        });
+        try {
+          await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': data.access_token });
+          req.flash('successMessage', ['Successfully Connected!']);
+        }
+        catch (err) {
+          req.flash('errorMessage', ['Failed to save access_token. Please try again.']);
+        }
+
+        return res.redirect('/admin/notification');
       })
       .catch((err) => {
         debug('oauth response ERROR', err);
@@ -297,18 +277,16 @@ module.exports = function(crowi, app) {
   };
 
   // app.post('/admin/notification/slackIwhSetting' , admin.notification.slackIwhSetting);
-  actions.notification.slackIwhSetting = function(req, res) {
+  actions.notification.slackIwhSetting = async function(req, res) {
     const slackIwhSetting = req.form.slackIwhSetting;
 
     if (req.form.isValid) {
-      Config.updateNamespaceByArray('notification', slackIwhSetting, (err, config) => {
-        Config.updateConfigCache('notification', config);
-        req.flash('successMessage', ['Successfully Updated!']);
+      await configManager.updateConfigsInTheSameNamespace('notification', slackIwhSetting);
+      req.flash('successMessage', ['Successfully Updated!']);
 
-        // Re-setup
-        crowi.setupSlack().then(() => {
-          return res.redirect('/admin/notification#slack-incoming-webhooks');
-        });
+      // Re-setup
+      crowi.setupSlack().then(() => {
+        return res.redirect('/admin/notification#slack-incoming-webhooks');
       });
     }
     else {
@@ -318,13 +296,11 @@ module.exports = function(crowi, app) {
   };
 
   // app.post('/admin/notification/slackSetting/disconnect' , admin.notification.disconnectFromSlack);
-  actions.notification.disconnectFromSlack = function(req, res) {
-    Config.updateNamespaceByArray('notification', { 'slack:token': '' }, (err, config) => {
-      Config.updateConfigCache('notification', config);
-      req.flash('successMessage', ['Successfully Disconnected!']);
+  actions.notification.disconnectFromSlack = async function(req, res) {
+    await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': '' });
+    req.flash('successMessage', ['Successfully Disconnected!']);
 
-      return res.redirect('/admin/notification');
-    });
+    return res.redirect('/admin/notification');
   };
 
   actions.globalNotification = {};
@@ -431,8 +407,7 @@ module.exports = function(crowi, app) {
   actions.user = {};
   actions.user.index = async function(req, res) {
     const activeUsers = await User.countListByStatus(User.STATUS_ACTIVE);
-    const Config = crowi.model('Config');
-    const userUpperLimit = Config.userUpperLimit(crowi);
+    const userUpperLimit = aclService.userUpperLimit();
     const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
 
     const page = parseInt(req.query.page) || 1;
@@ -649,7 +624,7 @@ module.exports = function(crowi, app) {
   actions.userGroup = {};
   actions.userGroup.index = function(req, res) {
     const page = parseInt(req.query.page) || 1;
-    const isAclEnabled = !Config.isPublicWikiOnly(req.config);
+    const isAclEnabled = aclService.getIsPublicWikiOnly();
     const renderVar = {
       userGroups: [],
       userGroupRelations: new Map(),
@@ -819,7 +794,7 @@ module.exports = function(crowi, app) {
   // Importer management
   actions.importer = {};
   actions.importer.index = function(req, res) {
-    const settingForm = Config.setupConfigFormData('crowi', req.config);
+    const settingForm = configManager.getConfigByPrefix('crowi', 'importer:');
 
     return res.render('admin/importer', {
       settingForm,
@@ -827,7 +802,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.api = {};
-  actions.api.appSetting = function(req, res) {
+  actions.api.appSetting = async function(req, res) {
     const form = req.form.settingForm;
 
     if (req.form.isValid) {
@@ -835,18 +810,20 @@ module.exports = function(crowi, app) {
 
       // mail setting ならここで validation
       if (form['mail:from']) {
-        validateMailSetting(req, form, (err, data) => {
+        validateMailSetting(req, form, async(err, data) => {
           debug('Error validate mail setting: ', err, data);
           if (err) {
             req.form.errors.push('SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。');
             return res.json({ status: false, message: req.form.errors.join('\n') });
           }
 
-          return saveSetting(req, res, form);
+          await configManager.updateConfigsInTheSameNamespace('crowi', form);
+          return res.json({ status: true });
         });
       }
       else {
-        return saveSetting(req, res, form);
+        await configManager.updateConfigsInTheSameNamespace('crowi', form);
+        return res.json({ status: true });
       }
     }
     else {
@@ -864,7 +841,7 @@ module.exports = function(crowi, app) {
     debug('form content', form);
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
+      await configManager.updateConfigsInTheSameNamespace('crowi', form);
       return res.json({ status: true });
     }
     catch (err) {
@@ -879,15 +856,7 @@ module.exports = function(crowi, app) {
     }
 
     const form = req.form.settingForm;
-    const config = crowi.getConfig();
-    const isPublicWikiOnly = Config.isPublicWikiOnly(config);
-    if (isPublicWikiOnly) {
-      const basicName = form['security:basicName'];
-      const basicSecret = form['security:basicSecret'];
-      if (basicName !== '' || basicSecret !== '') {
-        req.form.errors.push('Public Wikiのため、Basic認証は利用できません。');
-        return res.json({ status: false, message: req.form.errors.join('\n') });
-      }
+    if (aclService.getIsPublicWikiOnly()) {
       const guestMode = form['security:restrictGuestMode'];
       if (guestMode === 'Deny') {
         req.form.errors.push('Private Wikiへの設定変更はできません。');
@@ -896,7 +865,7 @@ module.exports = function(crowi, app) {
     }
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
+      await configManager.updateConfigsInTheSameNamespace('crowi', form);
       return res.json({ status: true });
     }
     catch (err) {
@@ -913,14 +882,12 @@ module.exports = function(crowi, app) {
     }
 
     debug('form content', form);
-    return saveSettingAsync(form)
+    return configManager.updateConfigsInTheSameNamespace('crowi', form)
       .then(() => {
-        const config = crowi.getConfig();
-
         // reset strategy
         crowi.passportService.resetLdapStrategy();
         // setup strategy
-        if (Config.isEnabledPassportLdap(config)) {
+        if (configManager.getConfig('crowi', 'security:passport-ldap:isEnabled')) {
           crowi.passportService.setupLdapStrategy(true);
         }
         return;
@@ -940,12 +907,12 @@ module.exports = function(crowi, app) {
     }
 
     debug('form content', form);
-    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
 
     // reset strategy
     await crowi.passportService.resetSamlStrategy();
     // setup strategy
-    if (crowi.configManager.getConfig('crowi', 'security:passport-saml:isEnabled')) {
+    if (configManager.getConfig('crowi', 'security:passport-saml:isEnabled')) {
       try {
         await crowi.passportService.setupSamlStrategy(true);
       }
@@ -959,6 +926,33 @@ module.exports = function(crowi, app) {
     return res.json({ status: true });
   };
 
+  actions.api.securityPassportBasicSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({ status: false, message: req.form.errors.join('\n') });
+    }
+
+    debug('form content', form);
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
+
+    // reset strategy
+    await crowi.passportService.resetBasicStrategy();
+    // setup strategy
+    if (configManager.getConfig('crowi', 'security:passport-basic:isEnabled')) {
+      try {
+        await crowi.passportService.setupBasicStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetBasicStrategy();
+        return res.json({ status: false, message: err.message });
+      }
+    }
+
+    return res.json({ status: true });
+  };
+
   actions.api.securityPassportGoogleSetting = async(req, res) => {
     const form = req.form.settingForm;
 
@@ -967,13 +961,12 @@ module.exports = function(crowi, app) {
     }
 
     debug('form content', form);
-    await saveSettingAsync(form);
-    const config = await crowi.getConfig();
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
 
     // reset strategy
     await crowi.passportService.resetGoogleStrategy();
     // setup strategy
-    if (Config.isEnabledPassportGoogle(config)) {
+    if (configManager.getConfig('crowi', 'security:passport-google:isEnabled')) {
       try {
         await crowi.passportService.setupGoogleStrategy(true);
       }
@@ -995,13 +988,12 @@ module.exports = function(crowi, app) {
     }
 
     debug('form content', form);
-    await saveSettingAsync(form);
-    const config = await crowi.getConfig();
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
 
     // reset strategy
     await crowi.passportService.resetGitHubStrategy();
     // setup strategy
-    if (Config.isEnabledPassportGitHub(config)) {
+    if (configManager.getConfig('crowi', 'security:passport-github:isEnabled')) {
       try {
         await crowi.passportService.setupGitHubStrategy(true);
       }
@@ -1023,14 +1015,12 @@ module.exports = function(crowi, app) {
     }
 
     debug('form content', form);
-    await saveSettingAsync(form);
-    const config = await crowi.getConfig();
-
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
 
     // reset strategy
     await crowi.passportService.resetTwitterStrategy();
     // setup strategy
-    if (Config.isEnabledPassportTwitter(config)) {
+    if (configManager.getConfig('crowi', 'security:passport-twitter:isEnabled')) {
       try {
         await crowi.passportService.setupTwitterStrategy(true);
       }
@@ -1043,23 +1033,44 @@ module.exports = function(crowi, app) {
 
     return res.json({ status: true });
   };
-  actions.api.customizeSetting = function(req, res) {
+
+  actions.api.securityPassportOidcSetting = async(req, res) => {
     const form = req.form.settingForm;
 
-    if (req.form.isValid) {
-      debug('form content', form);
-      return saveSetting(req, res, form);
+    if (!req.form.isValid) {
+      return res.json({ status: false, message: req.form.errors.join('\n') });
     }
 
-    return res.json({ status: false, message: req.form.errors.join('\n') });
+    debug('form content', form);
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
+
+    // reset strategy
+    await crowi.passportService.resetOidcStrategy();
+    // setup strategy
+    if (configManager.getConfig('crowi', 'security:passport-oidc:isEnabled')) {
+      try {
+        await crowi.passportService.setupOidcStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetOidcStrategy();
+        return res.json({ status: false, message: err.message });
+      }
+    }
+
+    return res.json({ status: true });
   };
 
-  actions.api.customizeSetting = function(req, res) {
+  actions.api.customizeSetting = async function(req, res) {
     const form = req.form.settingForm;
 
     if (req.form.isValid) {
       debug('form content', form);
-      return saveSetting(req, res, form);
+      await configManager.updateConfigsInTheSameNamespace('crowi', form);
+      customizeService.initCustomCss();
+      customizeService.initCustomTitle();
+
+      return res.json({ status: true });
     }
 
     return res.json({ status: false, message: req.form.errors.join('\n') });
@@ -1152,8 +1163,10 @@ module.exports = function(crowi, app) {
       return res.json({ status: false, message: req.form.errors.join('\n') });
     }
 
-    await saveSetting(req, res, form);
-    await importer.initializeEsaClient();
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
+    importer.initializeEsaClient(); // let it run in the back aftert res
+
+    return res.json({ status: true });
   };
 
   /**
@@ -1169,8 +1182,10 @@ module.exports = function(crowi, app) {
       return res.json({ status: false, message: req.form.errors.join('\n') });
     }
 
-    await saveSetting(req, res, form);
-    await importer.initializeQiitaClient();
+    await configManager.updateConfigsInTheSameNamespace('crowi', form);
+    importer.initializeQiitaClient(); // let it run in the back aftert res
+
+    return res.json({ status: true });
   };
 
   /**
@@ -1294,39 +1309,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
   };
 
-  /**
-   * save settings, update config cache, and response json
-   *
-   * @param {any} req
-   * @param {any} res
-   * @param {any} form
-   */
-  function saveSetting(req, res, form) {
-    Config.updateNamespaceByArray('crowi', form, (err, config) => {
-      Config.updateConfigCache('crowi', config);
-      return res.json({ status: true });
-    });
-  }
-
-  /**
-   * save settings, update config cache ONLY. (this method don't response json)
-   *
-   * @param {any} form
-   * @returns
-   */
-  function saveSettingAsync(form) {
-    return new Promise((resolve, reject) => {
-      Config.updateNamespaceByArray('crowi', form, (err, config) => {
-        if (err) {
-          return reject(err);
-        }
-
-        Config.updateConfigCache('crowi', config);
-        return resolve();
-      });
-    });
-  }
-
   function validateMailSetting(req, form, callback) {
     const mailer = crowi.mailer;
     const option = {
@@ -1363,7 +1345,7 @@ module.exports = function(crowi, app) {
   function validateSamlSettingForm(form, t) {
     for (const key of crowi.passportService.mandatoryConfigKeysForSaml) {
       const formValue = form.settingForm[key];
-      if (crowi.configManager.getConfigFromEnvVars('crowi', key) === null && formValue === '') {
+      if (configManager.getConfigFromEnvVars('crowi', key) === null && formValue === '') {
         const formItemName = t(`security_setting.form_item_name.${key}`);
         form.errors.push(t('form_validation.required', formItemName));
       }

+ 30 - 2
src/server/routes/attachment.js

@@ -3,7 +3,6 @@
 
 const logger = require('@alias/logger')('growi:routes:attachment');
 
-const path = require('path');
 const fs = require('fs');
 
 const ApiResponse = require('../util/apiResponse');
@@ -234,7 +233,7 @@ module.exports = function(crowi, app) {
     if (pageId == null) {
       logger.debug('Create page before file upload');
 
-      page = await Page.create(path, `# ${path}`, req.user, { grant: Page.GRANT_OWNER });
+      page = await Page.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
       pageCreated = true;
       pageId = page._id;
     }
@@ -339,5 +338,34 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({}));
   };
 
+  /**
+   * @api {post} /attachments.removeProfileImage Remove profile image attachments
+   * @apiGroup Attachment
+   * @apiParam {String} attachment_id
+   */
+  api.removeProfileImage = async function(req, res) {
+    const user = req.user;
+    const attachment = await Attachment.findById(user.imageAttachment);
+
+    if (attachment == null) {
+      return res.json(ApiResponse.error('attachment not found'));
+    }
+
+    const isDeletable = await isDeletableByUser(user, attachment);
+    if (!isDeletable) {
+      return res.json(ApiResponse.error(`Forbidden to remove the attachment '${attachment.id}'`));
+    }
+
+    try {
+      await user.deleteImage();
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).json(ApiResponse.error('Error while deleting image'));
+    }
+
+    return res.json(ApiResponse.success({}));
+  };
+
   return actions;
 };

+ 5 - 0
src/server/routes/avoid-session-routes.js

@@ -0,0 +1,5 @@
+module.exports = [
+  /^\/_api\/v3\/healthcheck/,
+  /^\/_hackmd\//,
+  /^\/api-docs\//,
+];

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

@@ -174,7 +174,7 @@ module.exports = function(crowi, app) {
         throw new Error('Current user is not accessible to this page.');
       }
 
-      await comment.remove();
+      await comment.removeWithReplies();
       await Page.updateCommentCount(comment.page);
     }
     catch (err) {

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

@@ -40,7 +40,7 @@ module.exports = function(crowi, app) {
       agentScriptContentTpl = swig.compileFile(agentScriptPath);
     }
 
-    const origin = crowi.configManager.getSiteUrl();
+    const origin = crowi.appService.getSiteUrl();
 
     // generate definitions to replace
     const definitions = {

+ 158 - 166
src/server/routes/index.js

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

+ 13 - 21
src/server/routes/installer.js

@@ -2,8 +2,10 @@ module.exports = function(crowi, app) {
   const logger = require('@alias/logger')('growi:routes:installer');
   const path = require('path');
   const fs = require('graceful-fs');
+
   const models = crowi.models;
-  const Config = models.Config;
+  const { appService } = crowi;
+
   const User = models.User;
   const Page = models.Page;
 
@@ -65,6 +67,9 @@ module.exports = function(crowi, app) {
     const password = registerForm.password;
     const language = registerForm['app:globalLang'] || 'en-US';
 
+    await appService.initDB(language);
+
+    // create first admin user
     let adminUser;
     try {
       adminUser = await User.createUser(name, username, email, password, language);
@@ -74,29 +79,16 @@ module.exports = function(crowi, app) {
       req.form.errors.push(`管理ユーザーの作成に失敗しました。${err.message}`);
       return res.render('installer');
     }
+    // create initial pages
+    await createInitialPages(adminUser, language);
 
-    Config.applicationInstall((err, configs) => {
-      if (err) {
-        logger.error(err);
-        return;
-      }
-
-      // save the globalLang config, and update the config cache
-      Config.updateNamespaceByArray('crowi', { 'app:globalLang': language }, (err, config) => {
-        Config.updateConfigCache('crowi', config);
-      });
-
-      // login with passport
-      req.logIn(adminUser, (err) => {
-        if (err) { return next() }
+    // login with passport
+    req.logIn(adminUser, (err) => {
+      if (err) { return next() }
 
-        req.flash('successMessage', 'GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。');
-        return res.redirect('/admin/app');
-      });
+      req.flash('successMessage', 'GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。');
+      return res.redirect('/admin/app');
     });
-
-    // create initial pages
-    await createInitialPages(adminUser, language);
   };
 
   return actions;

+ 97 - 1
src/server/routes/login-passport.js

@@ -177,7 +177,7 @@ module.exports = function(crowi, app) {
         if (!isValidLdapUserByGroupFilter(user)) {
           return res.json({
             status: 'warning',
-            message: 'The user is found, but that has no groups.',
+            message: 'This user does not belong to any groups designated by the group search filter.',
             ldapConfiguration: req.ldapConfiguration,
             ldapAccountInfo: req.ldapAccountInfo,
           });
@@ -350,6 +350,54 @@ module.exports = function(crowi, app) {
     });
   };
 
+  const loginWithOidc = function(req, res, next) {
+    if (!passportService.isOidcStrategySetup) {
+      debug('OidcStrategy has not been set up');
+      req.flash('warningMessage', 'OidcStrategy has not been set up');
+      return next();
+    }
+
+    passport.authenticate('oidc')(req, res);
+  };
+
+  const loginPassportOidcCallback = async(req, res, next) => {
+    const providerId = 'oidc';
+    const strategyName = 'oidc';
+    const attrMapId = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId');
+    const attrMapUserName = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName');
+    const attrMapName = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapName');
+    const attrMapMail = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail');
+
+    let response;
+    try {
+      response = await promisifiedPassportAuthentication(strategyName, req, res);
+    }
+    catch (err) {
+      debug(err);
+      return loginFailure(req, res, next);
+    }
+
+    const userInfo = {
+      id: response[attrMapId],
+      username: response[attrMapUserName],
+      name: response[attrMapName],
+      email: response[attrMapMail],
+    };
+    debug('mapping response to userInfo', userInfo, response, attrMapId, attrMapUserName, attrMapMail);
+
+    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    // login
+    const user = await externalAccount.getPopulatedUser();
+    req.logIn(user, (err) => {
+      if (err) { return next(err) }
+      return loginSuccess(req, res, user);
+    });
+  };
+
   const loginWithSaml = function(req, res, next) {
     if (!passportService.isSamlStrategySetup) {
       debug('SamlStrategy has not been set up');
@@ -407,6 +455,51 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * middleware that login with BasicStrategy
+   * @param {*} req
+   * @param {*} res
+   * @param {*} next
+   */
+  const loginWithBasic = async(req, res, next) => {
+    if (!passportService.isBasicStrategySetup) {
+      debug('BasicStrategy has not been set up');
+      req.flash('warningMessage', 'Basic has not been set up');
+      return next();
+    }
+
+    const providerId = 'basic';
+    const strategyName = 'basic';
+    let userId;
+
+    try {
+      userId = await promisifiedPassportAuthentication(strategyName, req, res);
+    }
+    catch (err) {
+      // display prompt in browser
+      res.setHeader('WWW-Authenticate', 'Basic realm="Users"');
+      res.sendStatus(401).end();
+      return;
+    }
+
+    const userInfo = {
+      id: userId,
+      username: userId,
+      name: userId,
+    };
+
+    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+    await req.logIn(user, (err) => {
+      if (err) { return next() }
+      return loginSuccess(req, res, user);
+    });
+  };
+
   const promisifiedPassportAuthentication = (strategyName, req, res) => {
     return new Promise((resolve, reject) => {
       passport.authenticate(strategyName, (err, response, info) => {
@@ -480,10 +573,13 @@ module.exports = function(crowi, app) {
     loginWithGoogle,
     loginWithGitHub,
     loginWithTwitter,
+    loginWithOidc,
     loginWithSaml,
+    loginWithBasic,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
     loginPassportTwitterCallback,
+    loginPassportOidcCallback,
     loginPassportSamlCallback,
   };
 };

+ 12 - 124
src/server/routes/login.js

@@ -8,16 +8,12 @@ module.exports = function(crowi, app) {
   const logger = require('@alias/logger')('growi:routes:login');
   const path = require('path');
   const async = require('async');
-  const config = crowi.getConfig();
   const mailer = crowi.getMailer();
   const User = crowi.model('User');
-  const Config = crowi.model('Config');
+  const { configManager, appService, aclService } = crowi;
 
   const actions = {};
 
-  const clearGoogleSession = function(req) {
-    req.session.googleAuthCode = req.session.googleId = req.session.googleEmail = req.session.googleName = req.session.googleImage = null;
-  };
   const loginSuccess = function(req, res, userData) {
     req.user = req.session.user = userData;
 
@@ -32,8 +28,6 @@ module.exports = function(crowi, app) {
       return res.redirect('/me/password');
     }
 
-    clearGoogleSession(req);
-
     const jumpTo = req.session.jumpTo;
     if (jumpTo) {
       req.session.jumpTo = null;
@@ -60,16 +54,6 @@ module.exports = function(crowi, app) {
     return res.redirect('/login');
   };
 
-  actions.googleCallback = function(req, res) {
-    const nextAction = req.session.googleCallbackAction || '/login';
-    debug('googleCallback.nextAction', nextAction);
-    req.session.googleAuthCode = req.query.code || '';
-    debug('google auth code', req.query.code);
-
-
-    return res.redirect(nextAction);
-  };
-
   actions.error = function(req, res) {
     const reason = req.params.reason;
 
@@ -114,43 +98,7 @@ module.exports = function(crowi, app) {
     }
   };
 
-  actions.loginGoogle = function(req, res) {
-    const googleAuth = require('../util/googleAuth')(crowi);
-    const code = req.session.googleAuthCode || null;
-
-    if (!code) {
-      googleAuth.createAuthUrl(req, (err, redirectUrl) => {
-        if (err) {
-          // TODO
-        }
-
-        req.session.googleCallbackAction = '/login/google';
-        return res.redirect(redirectUrl);
-      });
-    }
-    else {
-      googleAuth.handleCallback(req, (err, tokenInfo) => {
-        debug('handleCallback', err, tokenInfo);
-        if (err) {
-          return loginFailure(req, res);
-        }
-
-        const googleId = tokenInfo.user_id;
-        User.findUserByGoogleId(googleId, (err, userData) => {
-          debug('findUserByGoogleId', err, userData);
-          if (!userData) {
-            clearGoogleSession(req);
-            return loginFailure(req, res);
-          }
-          return loginSuccess(req, res, userData);
-        });
-      });
-    }
-  };
-
   actions.register = function(req, res) {
-    const googleAuth = require('../util/googleAuth')(crowi);
-
     // redirect to '/' if both of these are true:
     //  1. user has logged in
     //  2. req.user is not username/email string (which is set by basic-auth-connect)
@@ -159,7 +107,7 @@ module.exports = function(crowi, app) {
     }
 
     // config で closed ならさよなら
-    if (config.crowi['security:registrationMode'] == Config.SECURITY_REGISTRATION_MODE_CLOSED) {
+    if (configManager.getConfig('crowi', 'security:registrationMode') == aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
       return res.redirect('/');
     }
 
@@ -170,8 +118,6 @@ module.exports = function(crowi, app) {
       const username = registerForm.username;
       const email = registerForm.email;
       const password = registerForm.password;
-      var googleId = registerForm.googleId || null;
-      var googleImage = registerForm.googleImage || null;
 
       // email と username の unique チェックする
       User.isRegisterable(email, username, (isRegisterable, errOn) => {
@@ -208,8 +154,8 @@ module.exports = function(crowi, app) {
 
 
           // 作成後、承認が必要なモードなら、管理者に通知する
-          const appTitle = Config.appTitle(config);
-          if (config.crowi['security:registrationMode'] === Config.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+          const appTitle = appService.getAppTitle();
+          if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
             // TODO send mail
             User.findAdmins((err, admins) => {
               async.each(
@@ -222,7 +168,7 @@ module.exports = function(crowi, app) {
                     vars: {
                       createdUser: userData,
                       adminUser,
-                      url: crowi.configManager.getSiteUrl(),
+                      url: appService.getSiteUrl(),
                       appTitle,
                     },
                   },
@@ -238,81 +184,23 @@ module.exports = function(crowi, app) {
             });
           }
 
-          if (googleId) {
-            userData.updateGoogleId(googleId, (err, userData) => {
-              if (err) { // TODO
-              }
-              return loginSuccess(req, res, userData);
-            });
-          }
-          else {
-            // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
-            // cz. loginSuccess method doesn't work on it's own when using passport
-            //      because `req.login()` prepared by passport is not called.
-            req.flash('successMessage', `The user '${userData.username}' is successfully created.`);
 
-            return loginSuccess(req, res, userData);
-          }
+          // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
+          // cz. loginSuccess method doesn't work on it's own when using passport
+          //      because `req.login()` prepared by passport is not called.
+          req.flash('successMessage', `The user '${userData.username}' is successfully created.`);
+
+          return loginSuccess(req, res, userData);
         });
       });
     }
     else { // method GET of form is not valid
       debug('session is', req.session);
       const isRegistering = true;
-      // google callback を受ける可能性もある
-      const code = req.session.googleAuthCode || null;
-      var googleId = req.session.googleId || null;
-      let googleEmail = req.session.googleEmail || null;
-      let googleName = req.session.googleName || null;
-      var googleImage = req.session.googleImage || null;
-
-      debug('register. if code', code);
-      // callback 経由で reigster にアクセスしてきた時最初だけこの if に入る
-      // code から email などを取得したらそれを session にいれて code は消去
-      if (code) {
-        googleAuth.handleCallback(req, (err, tokenInfo) => {
-          debug('tokenInfo on register GET', tokenInfo);
-          req.session.googleAuthCode = null;
-
-          if (err) {
-            req.flash('registerWarningMessage', 'Error on connectiong Google');
-            return res.redirect('/login?register=1'); // TODO Handling
-          }
-
-          req.session.googleId = googleId = tokenInfo.user_id;
-          req.session.googleEmail = googleEmail = tokenInfo.email;
-          req.session.googleName = googleName = tokenInfo.name;
-          req.session.googleImage = googleImage = tokenInfo.picture;
-
-          if (!User.isEmailValid(googleEmail)) {
-            req.flash('registerWarningMessage', 'このメールアドレスのGoogleアカウントはコネクトできません。');
-            return res.redirect('/login?register=1');
-          }
-          return res.render('login', {
-            isRegistering, googleId, googleEmail, googleName, googleImage,
-          });
-        });
-      }
-      else {
-        return res.render('login', {
-          isRegistering, googleId, googleEmail, googleName, googleImage,
-        });
-      }
+      return res.render('login', { isRegistering });
     }
   };
 
-  actions.registerGoogle = function(req, res) {
-    const googleAuth = require('../util/googleAuth')(crowi);
-    googleAuth.createAuthUrl(req, (err, redirectUrl) => {
-      if (err) {
-        // TODO
-      }
-
-      req.session.googleCallbackAction = '/register';
-      return res.redirect(redirectUrl);
-    });
-  };
-
   actions.invited = async function(req, res) {
     if (!req.user) {
       return res.redirect('/login');

+ 0 - 67
src/server/routes/me.js

@@ -309,72 +309,5 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.authGoogle = function(req, res) {
-    const googleAuth = require('../util/googleAuth')(crowi);
-
-    const userData = req.user;
-
-    const toDisconnect = !!req.body.disconnectGoogle;
-    const toConnect = !!req.body.connectGoogle;
-    if (toDisconnect) {
-      userData.deleteGoogleId((err, userData) => {
-        req.flash('successMessage', 'Disconnected from Google account');
-
-        return res.redirect('/me');
-      });
-    }
-    else if (toConnect) {
-      googleAuth.createAuthUrl(req, (err, redirectUrl) => {
-        if (err) {
-          // TODO
-        }
-
-        req.session.googleCallbackAction = '/me/auth/google/callback';
-        return res.redirect(redirectUrl);
-      });
-    }
-    else {
-      return res.redirect('/me');
-    }
-  };
-
-  actions.authGoogleCallback = function(req, res) {
-    const googleAuth = require('../util/googleAuth')(crowi);
-    const userData = req.user;
-
-    googleAuth.handleCallback(req, (err, tokenInfo) => {
-      if (err) {
-        req.flash('warningMessage.auth.google', err.message); // FIXME: show library error message directly
-        return res.redirect('/me'); // TODO Handling
-      }
-
-      const googleId = tokenInfo.user_id;
-      const googleEmail = tokenInfo.email;
-      if (!User.isEmailValid(googleEmail)) {
-        req.flash('warningMessage.auth.google', 'You can\'t connect with this  Google\'s account');
-        return res.redirect('/me');
-      }
-
-      User.findUserByGoogleId(googleId, (err, googleUser) => {
-        if (!err && googleUser) {
-          req.flash('warningMessage.auth.google', 'This Google\'s account is connected by another user');
-          return res.redirect('/me');
-        }
-
-        userData.updateGoogleId(googleId, (err, userData) => {
-          if (err) {
-            debug('Failed to updateGoogleId', err);
-            req.flash('warningMessage.auth.google', 'Failed to connect Google Account');
-            return res.redirect('/me');
-          }
-
-          // TODO if err
-          req.flash('successMessage', 'Connected with Google');
-          return res.redirect('/me');
-        });
-      });
-    });
-  };
-
   return actions;
 };

+ 29 - 20
src/server/routes/page.js

@@ -2,18 +2,21 @@
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = require('@alias/logger')('growi:routes:page');
+  const swig = require('swig-templates');
+
   const pathUtils = require('growi-commons').pathUtils;
+
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const Config = crowi.model('Config');
-  const config = crowi.getConfig();
   const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const UpdatePost = crowi.model('UpdatePost');
+
   const ApiResponse = require('../util/apiResponse');
-  const interceptorManager = crowi.getInterceptorManager();
-  const swig = require('swig-templates');
   const getToday = require('../util/getToday');
+
+  const { configManager, slackNotificationService } = crowi;
+  const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
 
   const actions = {};
@@ -94,7 +97,7 @@ module.exports = function(crowi, app) {
         logger.error('Error occured in updating slack channels: ', err);
       });
 
-    if (Config.hasSlackConfig(config)) {
+    if (slackNotificationService.hasSlackConfig()) {
       const promises = slackChannels.split(',').map((chan) => {
         return crowi.slack.postPage(page, user, chan, updateOrCreate, previousRevision);
       });
@@ -303,9 +306,9 @@ module.exports = function(crowi, app) {
    */
   /* eslint-disable no-else-return */
   actions.showPageWithEndOfSlash = function(req, res, next) {
-    const behaviorType = Config.behaviorType(config);
+    const behaviorType = configManager.getConfig('crowi', 'customize:behavior');
 
-    if (!behaviorType || behaviorType === 'crowi') {
+    if (behaviorType === 'crowi') {
       return showPageListForCrowiBehavior(req, res, next);
     }
     else {
@@ -327,10 +330,10 @@ module.exports = function(crowi, app) {
       return showPageForPresentation(req, res, next);
     }
 
-    const behaviorType = Config.behaviorType(config);
+    const behaviorType = configManager.getConfig('crowi', 'customize:behavior');
 
     // check whether this page has portal page
-    if (!behaviorType || behaviorType === 'crowi') {
+    if (behaviorType === 'crowi') {
       const portalPagePath = pathUtils.addTrailingSlash(getPathFromRequest(req));
       const hasPortalPage = await Page.count({ path: portalPagePath }) > 0;
 
@@ -349,9 +352,9 @@ module.exports = function(crowi, app) {
    */
   /* eslint-disable no-else-return */
   actions.trashPageListShowWrapper = function(req, res) {
-    const behaviorType = Config.behaviorType(config);
+    const behaviorType = configManager.getConfig('crowi', 'customize:behavior');
 
-    if (!behaviorType || behaviorType === 'crowi') {
+    if (behaviorType === 'crowi') {
       // Crowi behavior for '/trash/*'
       return actions.deletedPageListShow(req, res);
     }
@@ -367,9 +370,9 @@ module.exports = function(crowi, app) {
    */
   /* eslint-disable no-else-return */
   actions.trashPageShowWrapper = function(req, res) {
-    const behaviorType = Config.behaviorType(config);
+    const behaviorType = configManager.getConfig('crowi', 'customize:behavior');
 
-    if (!behaviorType || behaviorType === 'crowi') {
+    if (behaviorType === 'crowi') {
       // redirect to '/trash/'
       return res.redirect('/trash/');
     }
@@ -385,9 +388,9 @@ module.exports = function(crowi, app) {
    */
   /* eslint-disable no-else-return */
   actions.deletedPageListShowWrapper = function(req, res) {
-    const behaviorType = Config.behaviorType(config);
+    const behaviorType = configManager.getConfig('crowi', 'customize:behavior');
 
-    if (!behaviorType || behaviorType === 'crowi') {
+    if (behaviorType === 'crowi') {
       // Crowi behavior for '/trash/*'
       return actions.deletedPageListShow(req, res);
     }
@@ -567,9 +570,12 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
 
-    const options = {
-      grant, grantUserGroupId, overwriteScopesOfDescendants, socketClientId, pageTags,
-    };
+    const options = { socketClientId };
+    if (grant != null) {
+      options.grant = grant;
+      options.grantUserGroupId = grantUserGroupId;
+    }
+
     const createdPage = await Page.create(pagePath, body, req.user, options);
 
     let savedTags;
@@ -648,8 +654,6 @@ module.exports = function(crowi, app) {
     const options = { isSyncRevisionToHackmd, socketClientId };
     if (grant != null) {
       options.grant = grant;
-    }
-    if (grantUserGroupId != null) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
@@ -950,6 +954,9 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
+        if (!req.user.canDeleteCompletely(page.creator)) {
+          return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
+        }
         if (isRecursively) {
           page = await Page.completelyDeletePageRecursively(page, req.user, options);
         }
@@ -1116,6 +1123,8 @@ module.exports = function(crowi, app) {
     req.body.path = newPagePath;
     req.body.body = page.revision.body;
     req.body.grant = page.grant;
+    req.body.grantedUsers = page.grantedUsers;
+    req.body.grantedGroup = page.grantedGroup;
     req.body.pageTags = originTags;
 
     return api.create(req, res);

+ 69 - 0
src/server/service/acl.js

@@ -0,0 +1,69 @@
+const logger = require('@alias/logger')('growi:service:AclService'); // eslint-disable-line no-unused-vars
+
+/**
+ * the service class of AclService
+ */
+class AclService {
+
+  constructor(configManager) {
+    this.configManager = configManager;
+    this.labels = {
+      SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
+      SECURITY_RESTRICT_GUEST_MODE_READONLY: 'Readonly',
+      SECURITY_REGISTRATION_MODE_OPEN: 'Open',
+      SECURITY_REGISTRATION_MODE_RESTRICTED: 'Restricted',
+      SECURITY_REGISTRATION_MODE_CLOSED: 'Closed',
+    };
+  }
+
+  getIsPublicWikiOnly() {
+    const publicWikiOnly = process.env.PUBLIC_WIKI_ONLY;
+    return !!publicWikiOnly;
+  }
+
+  getIsGuestAllowedToRead() {
+    // return true if puclic wiki mode
+    if (this.getIsPublicWikiOnly()) {
+      return true;
+    }
+
+    // return false if undefined
+    const isRestrictGuestMode = this.configManager.getConfig('crowi', 'security:restrictGuestMode');
+    if (isRestrictGuestMode) {
+      return false;
+    }
+
+    return this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY === isRestrictGuestMode;
+  }
+
+  getRestrictGuestModeLabels() {
+    const labels = {};
+    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY] = 'security_setting.guest_mode.deny';
+    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY] = 'security_setting.guest_mode.readonly';
+
+    return labels;
+  }
+
+  getRegistrationModeLabels() {
+    const labels = {};
+    labels[this.labels.SECURITY_REGISTRATION_MODE_OPEN] = 'security_setting.registration_mode.open';
+    labels[this.labels.SECURITY_REGISTRATION_MODE_RESTRICTED] = 'security_setting.registration_mode.restricted';
+    labels[this.labels.SECURITY_REGISTRATION_MODE_CLOSED] = 'security_setting.registration_mode.closed';
+
+    return labels;
+  }
+
+  userUpperLimit() {
+    // const limit = this.configManager.getConfig('crowi', 'USER_UPPER_LIMIT');
+    const limit = process.env.USER_UPPER_LIMIT;
+
+    if (limit) {
+      return Number(limit);
+    }
+
+    return 0;
+  }
+
+}
+
+module.exports = AclService;

+ 54 - 0
src/server/service/app.js

@@ -0,0 +1,54 @@
+const logger = require('@alias/logger')('growi:service:AppService'); // eslint-disable-line no-unused-vars
+const { pathUtils } = require('growi-commons');
+
+/**
+ * the service class of AppService
+ */
+class AppService {
+
+  constructor(configManager) {
+    this.configManager = configManager;
+  }
+
+  getAppTitle() {
+    return this.configManager.getConfig('crowi', 'app:title') || 'GROWI';
+  }
+
+  /**
+   * get the site url
+   *
+   * If the config for the site url is not set, this returns a message "[The site URL is not set. Please set it!]".
+   *
+   * With version 3.2.3 and below, there is no config for the site URL, so the system always uses auto-generated site URL.
+   * With version 3.2.4 to 3.3.4, the system uses the auto-generated site URL only if the config is not set.
+   * With version 3.3.5 and above, the system use only a value from the config.
+   */
+  /* eslint-disable no-else-return */
+  getSiteUrl() {
+    const siteUrl = this.configManager.getConfig('crowi', 'app:siteUrl');
+    if (siteUrl != null) {
+      return pathUtils.removeTrailingSlash(siteUrl);
+    }
+    else {
+      return '[The site URL is not set. Please set it!]';
+    }
+  }
+  /* eslint-enable no-else-return */
+
+  /**
+   * Execute only once for installing application
+   */
+  async initDB(globalLang) {
+    const initialConfig = this.configManager.configModel.getConfigsObjectForInstalling();
+    initialConfig['app:globalLang'] = globalLang;
+    await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig);
+  }
+
+  async isDBInitialized() {
+    const appInstalled = await this.configManager.getConfigFromDB('crowi', 'app:installed');
+    return appInstalled;
+  }
+
+}
+
+module.exports = AppService;

+ 1 - 1
src/server/service/config-loader.js

@@ -218,7 +218,7 @@ class ConfigLoader {
     // merge defaults
     let mergedConfigFromDB = Object.assign({ crowi: this.configModel.getDefaultCrowiConfigsObject() }, configFromDB);
     mergedConfigFromDB = Object.assign({ markdown: this.configModel.getDefaultMarkdownConfigsObject() }, mergedConfigFromDB);
-
+    mergedConfigFromDB = Object.assign({ notification: this.configModel.getDefaultNotificationConfigsObject() }, mergedConfigFromDB);
 
     // In getConfig API, only null is used as a value to indicate that a config is not set.
     // So, if a value loaded from the database is emtpy,

+ 86 - 28
src/server/service/config-manager.js

@@ -1,5 +1,4 @@
-const debug = require('debug')('growi:service:ConfigManager');
-const pathUtils = require('growi-commons').pathUtils;
+const logger = require('@alias/logger')('growi:service:ConfigManager');
 const ConfigLoader = require('../service/config-loader');
 
 const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
@@ -20,6 +19,9 @@ class ConfigManager {
     this.configModel = configModel;
     this.configLoader = new ConfigLoader(this.configModel);
     this.configObject = null;
+    this.configKeys = [];
+
+    this.getConfig = this.getConfig.bind(this);
   }
 
   /**
@@ -27,8 +29,10 @@ class ConfigManager {
    */
   async loadConfigs() {
     this.configObject = await this.configLoader.load();
+    logger.debug('ConfigManager#loadConfigs', this.configObject);
 
-    debug('ConfigManager#loadConfigs', this.configObject);
+    // cache all config keys
+    this.reloadConfigKeys();
   }
 
   /**
@@ -44,11 +48,77 @@ class ConfigManager {
    * - undefined: a specified config does not exist.
    */
   getConfig(namespace, key) {
+    let value;
+
     if (this.searchOnlyFromEnvVarConfigs('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')) {
-      return this.searchInSAMLUseOnlyEnvMode(namespace, key);
+      value = this.searchInSAMLUseOnlyEnvMode(namespace, key);
+    }
+
+    value = this.defaultSearch(namespace, key);
+
+    logger.debug(key, value);
+    return value;
+  }
+
+  /**
+   * get a config specified by namespace and regular expresssion
+   */
+  getConfigByRegExp(namespace, regexp) {
+    const result = {};
+
+    for (const key of this.configKeys) {
+      if (regexp.test(key)) {
+        result[key] = this.getConfig(namespace, key);
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * get a config specified by namespace and prefix
+   */
+  getConfigByPrefix(namespace, prefix) {
+    const regexp = new RegExp(`^${prefix}`);
+
+    return this.getConfigByRegExp(namespace, regexp);
+  }
+
+  /**
+   * generate an array of config keys from this.configObject
+   */
+  getConfigKeys() {
+    // type: fromDB, fromEnvVars
+    const types = Object.keys(this.configObject);
+    let namespaces = [];
+    let keys = [];
+
+    for (const type of types) {
+      if (this.configObject[type] != null) {
+        // ns: crowi, markdown, notification
+        namespaces = [...namespaces, ...Object.keys(this.configObject[type])];
+      }
     }
 
-    return this.defaultSearch(namespace, key);
+    // remove duplicates
+    namespaces = [...new Set(namespaces)];
+
+    for (const type of types) {
+      for (const ns of namespaces) {
+        if (this.configObject[type][ns] != null) {
+          keys = [...keys, ...Object.keys(this.configObject[type][ns])];
+        }
+      }
+    }
+
+    // remove duplicates
+    keys = [...new Set(keys)];
+
+    return keys;
+  }
+
+  reloadConfigKeys() {
+    this.configKeys = this.getConfigKeys();
   }
 
   /**
@@ -69,27 +139,6 @@ class ConfigManager {
     return this.searchOnlyFromEnvVarConfigs(namespace, key);
   }
 
-  /**
-   * get the site url
-   *
-   * If the config for the site url is not set, this returns a message "[The site URL is not set. Please set it!]".
-   *
-   * With version 3.2.3 and below, there is no config for the site URL, so the system always uses auto-generated site URL.
-   * With version 3.2.4 to 3.3.4, the system uses the auto-generated site URL only if the config is not set.
-   * With version 3.3.5 and above, the system use only a value from the config.
-   */
-  /* eslint-disable no-else-return */
-  getSiteUrl() {
-    const siteUrl = this.getConfig('crowi', 'app:siteUrl');
-    if (siteUrl != null) {
-      return pathUtils.removeTrailingSlash(siteUrl);
-    }
-    else {
-      return '[The site URL is not set. Please set it!]';
-    }
-  }
-  /* eslint-enable no-else-return */
-
   /**
    * update configs in the same namespace
    *
@@ -122,6 +171,7 @@ class ConfigManager {
     await this.configModel.bulkWrite(queries);
 
     await this.loadConfigs();
+    this.reloadConfigKeys();
   }
 
   /*
@@ -133,27 +183,35 @@ class ConfigManager {
    * and then from configs loaded from the environment variables
    */
   defaultSearch(namespace, key) {
+    // does not exist neither in db nor in env vars
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
+      logger.debug(`${namespace}.${key} does not exist neither in db nor in env vars`);
       return undefined;
     }
 
+    // only exists in db
     if (this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
+      logger.debug(`${namespace}.${key} only exists in db`);
       return this.configObject.fromDB[namespace][key];
     }
 
+    // only exists env vars
     if (!this.configExistsInDB(namespace, key) && this.configExistsInEnvVars(namespace, key)) {
+      logger.debug(`${namespace}.${key} only exists in env vars`);
       return this.configObject.fromEnvVars[namespace][key];
     }
 
+    // exists both in db and in env vars [db > env var]
     if (this.configExistsInDB(namespace, key) && this.configExistsInEnvVars(namespace, key)) {
-      /* eslint-disable no-else-return */
       if (this.configObject.fromDB[namespace][key] !== null) {
+        logger.debug(`${namespace}.${key} exists both in db and in env vars. loaded from db`);
         return this.configObject.fromDB[namespace][key];
       }
+      /* eslint-disable-next-line no-else-return */
       else {
+        logger.debug(`${namespace}.${key} exists both in db and in env vars. loaded from env vars`);
         return this.configObject.fromEnvVars[namespace][key];
       }
-      /* eslint-enable no-else-return */
     }
   }
 

+ 56 - 0
src/server/service/customize.js

@@ -0,0 +1,56 @@
+const logger = require('@alias/logger')('growi:service:CustomizeService'); // eslint-disable-line no-unused-vars
+
+/**
+ * the service class of CustomizeService
+ */
+class CustomizeService {
+
+  constructor(configManager, appService, xssService) {
+    this.configManager = configManager;
+    this.appService = appService;
+    this.xssService = xssService;
+  }
+
+  /**
+   * initialize custom css strings
+   */
+  initCustomCss() {
+    const uglifycss = require('uglifycss');
+
+    const rawCss = this.configManager.getConfig('crowi', 'customize:css') || '';
+
+    // uglify and store
+    this.customCss = uglifycss.processString(rawCss);
+  }
+
+  getCustomCss() {
+    return this.customCss;
+  }
+
+  getCustomScript() {
+    return this.configManager.getConfig('crowi', 'customize:script') || '';
+  }
+
+  initCustomTitle() {
+    let configValue = this.configManager.getConfig('crowi', 'customize:title');
+
+    if (configValue == null || configValue.trim().length === 0) {
+      configValue = '{{page}} - {{sitename}}';
+    }
+
+    this.customTitleTemplate = configValue;
+  }
+
+  generateCustomTitle(page) {
+    // replace
+    const customTitle = this.customTitleTemplate
+      .replace('{{sitename}}', this.appService.getAppTitle())
+      .replace('{{page}}', page);
+
+    return this.xssService.process(customTitle);
+  }
+
+
+}
+
+module.exports = CustomizeService;

+ 11 - 12
src/server/service/file-uploader/aws.js

@@ -5,24 +5,23 @@ const urljoin = require('url-join');
 const aws = require('aws-sdk');
 
 module.exports = function(crowi) {
-  const lib = {};
+  const Uploader = require('./uploader');
+  const { configManager } = crowi;
+  const lib = new Uploader(configManager);
 
   function getAwsConfig() {
-    const config = crowi.getConfig();
     return {
-      accessKeyId: config.crowi['aws:accessKeyId'],
-      secretAccessKey: config.crowi['aws:secretAccessKey'],
-      region: config.crowi['aws:region'],
-      bucket: config.crowi['aws:bucket'],
+      accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
+      secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
+      region: configManager.getConfig('crowi', 'aws:region'),
+      bucket: configManager.getConfig('crowi', 'aws:bucket'),
     };
   }
 
-  function S3Factory() {
+  function S3Factory(isUploadable) {
     const awsConfig = getAwsConfig();
-    const Config = crowi.model('Config');
-    const config = crowi.getConfig();
 
-    if (!Config.isUploadable(config)) {
+    if (!isUploadable) {
       throw new Error('AWS is not configured.');
     }
 
@@ -54,7 +53,7 @@ module.exports = function(crowi) {
   };
 
   lib.deleteFileByFilePath = async function(filePath) {
-    const s3 = S3Factory();
+    const s3 = S3Factory(this.getIsUploadable());
     const awsConfig = getAwsConfig();
 
     const params = {
@@ -68,7 +67,7 @@ module.exports = function(crowi) {
   lib.uploadFile = function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
-    const s3 = S3Factory();
+    const s3 = S3Factory(this.getIsUploadable());
     const awsConfig = getAwsConfig();
 
     const filePath = getFilePathOnStorage(attachment);

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

@@ -3,7 +3,8 @@ const mongoose = require('mongoose');
 const util = require('util');
 
 module.exports = function(crowi) {
-  const lib = {};
+  const Uploader = require('./uploader');
+  const lib = new Uploader(crowi.configManager);
   const COLLECTION_NAME = 'attachmentFiles';
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 

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

@@ -22,6 +22,7 @@ class FileUploaderFactory {
 }
 
 const factory = new FileUploaderFactory();
+
 module.exports = (crowi) => {
   return factory.getUploader(crowi);
 };

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

@@ -6,7 +6,8 @@ const mkdir = require('mkdirp');
 const streamToPromise = require('stream-to-promise');
 
 module.exports = function(crowi) {
-  const lib = {};
+  const Uploader = require('./uploader');
+  const lib = new Uploader(crowi.configManager);
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
   function getFilePathOnStorage(attachment) {

+ 2 - 2
src/server/service/file-uploader/none.js

@@ -2,8 +2,8 @@
 
 module.exports = function(crowi) {
   const debug = require('debug')('growi:service:fileUploaderNone');
-
-  const lib = {};
+  const Uploader = require('./uploader');
+  const lib = new Uploader(crowi.configManager);
 
   lib.deleteFile = function(filePath) {
     debug(`File deletion: ${filePath}`);

+ 34 - 0
src/server/service/file-uploader/uploader.js

@@ -0,0 +1,34 @@
+// file uploader virtual class
+// 各アップローダーで共通のメソッドはここで定義する
+
+class Uploader {
+
+  constructor(configManager) {
+    this.configManager = configManager;
+  }
+
+  getIsUploadable() {
+    const method = process.env.FILE_UPLOAD || 'aws';
+
+    if (method === 'aws' && (
+      !this.configManager.getConfig('crowi', 'aws:accessKeyId')
+        || !this.configManager.getConfig('crowi', 'aws:secretAccessKey')
+        || !this.configManager.getConfig('crowi', 'aws:region')
+        || !this.configManager.getConfig('crowi', 'aws:bucket'))) {
+      return false;
+    }
+
+    return method !== 'none';
+  }
+
+  getFileUploadEnabled() {
+    if (!this.getIsUploadable()) {
+      return false;
+    }
+
+    return !!this.configManager.getConfig('crowi', 'app:fileUpload');
+  }
+
+}
+
+module.exports = Uploader;

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