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

Merge remote-tracking branch 'origin/master' into imprv/add-option-preventXSS-morita

# Conflicts:
#	lib/locales/ja/translation.json
mayu morita 7 лет назад
Родитель
Сommit
f462b2d759
89 измененных файлов с 2629 добавлено и 971 удалено
  1. 1 0
      .babelrc
  2. 2 0
      .eslintrc.js
  3. 4 0
      .gitignore
  4. 4 0
      .vscode/settings.json
  5. 47 6
      CHANGES.md
  6. 7 6
      README.md
  7. 73 45
      config/webpack.common.js
  8. 42 85
      config/webpack.dev.js
  9. 51 0
      config/webpack.dll.js
  10. 45 96
      config/webpack.prod.js
  11. 1 1
      lib/crowi/express-init.js
  12. 9 3
      lib/crowi/index.js
  13. 12 0
      lib/form/admin/securityPassportGitHub.js
  14. 12 0
      lib/form/admin/securityPassportGoogle.js
  15. 2 0
      lib/form/index.js
  16. 4 1
      lib/form/revision.js
  17. 150 13
      lib/locales/en-US/sandbox.md
  18. 33 17
      lib/locales/en-US/translation.json
  19. 1 1
      lib/locales/en-US/welcome.md
  20. 150 13
      lib/locales/ja/sandbox.md
  21. 35 17
      lib/locales/ja/translation.json
  22. 1 1
      lib/locales/ja/welcome.md
  23. 5 5
      lib/models/bookmark.js
  24. 18 4
      lib/models/config.js
  25. 27 13
      lib/models/page-group-relation.js
  26. 22 33
      lib/models/page.js
  27. 16 13
      lib/models/revision.js
  28. 31 14
      lib/models/user-group-relation.js
  29. 18 5
      lib/models/user-group.js
  30. 91 12
      lib/routes/admin.js
  31. 9 9
      lib/routes/attachment.js
  32. 21 17
      lib/routes/comment.js
  33. 8 0
      lib/routes/index.js
  34. 155 67
      lib/routes/login-passport.js
  35. 11 11
      lib/routes/page.js
  36. 3 3
      lib/routes/revision.js
  37. 21 2
      lib/service/logger/index.js
  38. 105 1
      lib/service/passport.js
  39. 10 0
      lib/util/swigFunctions.js
  40. 1 1
      lib/views/_form.html
  41. 5 5
      lib/views/admin/customize.html
  42. 1 1
      lib/views/admin/index.html
  43. 3 4
      lib/views/admin/markdown.html
  44. 16 13
      lib/views/admin/security.html
  45. 88 4
      lib/views/admin/widget/passport/github.html
  46. 83 74
      lib/views/admin/widget/passport/google-oauth.html
  47. 2 2
      lib/views/admin/widget/passport/ldap.html
  48. 1 1
      lib/views/admin/widget/theme-colorbox.html
  49. 1 7
      lib/views/layout-crowi/widget/page_side_header.html
  50. 0 36
      lib/views/layout-growi/base/layout.html
  51. 3 3
      lib/views/layout-growi/widget/header.html
  52. 1 1
      lib/views/layout/admin.html
  53. 16 15
      lib/views/layout/layout.html
  54. 71 4
      lib/views/login.html
  55. 12 12
      lib/views/me/external-accounts.html
  56. 5 5
      lib/views/modal/create_page.html
  57. 1 1
      lib/views/modal/shortcuts.html
  58. 6 7
      lib/views/page_presentation.html
  59. 7 0
      lib/views/widget/page_alerts.html
  60. 2 9
      lib/views/widget/page_attachments.html
  61. 1 1
      lib/views/widget/page_list.html
  62. 1 1
      lib/views/widget/system-version.html
  63. 0 3
      local_modules/crowi-fileupload-local/index.js
  64. 34 23
      package.json
  65. 0 1
      public/css/.gitignore
  66. 20 19
      resource/js/app.js
  67. 2 2
      resource/js/components/Admin/CustomCssEditor.js
  68. 2 2
      resource/js/components/Admin/CustomHeaderEditor.js
  69. 2 2
      resource/js/components/Admin/CustomScriptEditor.js
  70. 2 1
      resource/js/components/Page/RevisionBody.js
  71. 108 28
      resource/js/components/PageComment/CommentForm.js
  72. 2 3
      resource/js/components/PageEditor.js
  73. 24 0
      resource/js/components/PageEditor/AbstractEditor.js
  74. 84 14
      resource/js/components/PageEditor/CodeMirrorEditor.js
  75. 14 0
      resource/js/components/PageEditor/Editor.js
  76. 1 1
      resource/js/components/PageEditor/MarkdownListUtil.js
  77. 41 6
      resource/js/components/PageEditor/TextAreaEditor.js
  78. 1 1
      resource/js/components/PageList/Page.js
  79. 1 1
      resource/js/components/SearchTypeahead.js
  80. 2 0
      resource/js/ie11-polyfill.js
  81. 0 1
      resource/js/legacy/crowi-form.js
  82. 74 3
      resource/js/legacy/crowi.js
  83. 16 3
      resource/js/util/GrowiRenderer.js
  84. 51 0
      resource/js/util/codemirror/autorefresh.ext.js
  85. 16 0
      resource/js/util/markdown-it/blockdiag.js
  86. 1 0
      resource/styles/scss/_layout_growi.scss
  87. 51 3
      resource/styles/scss/_login.scss
  88. 8 1
      resource/styles/scss/_wiki.scss
  89. 488 132
      yarn.lock

+ 1 - 0
.babelrc

@@ -1,4 +1,5 @@
 {
+  "plugins": ["lodash"],
   "presets": [
     ["env", {
       "targets": {

+ 2 - 0
.eslintrc.js

@@ -17,6 +17,7 @@ module.exports = {
     "window": true
   },
   "parserOptions": {
+    "ecmaVersion": 8,
     "ecmaFeatures": {
       "experimentalObjectRestSpread": true,
       "jsx": true
@@ -68,6 +69,7 @@ module.exports = {
       "error",
       { "args": "none" }
     ],
+    "no-var": [ "error" ],
     "quotes": [
       "error",
       "single"

+ 4 - 0
.gitignore

@@ -18,8 +18,12 @@ package-lock.json
 
 # Dist #
 /report/
+/public/*.chunk.js
+/public/*.bundle.js
+/public/manifest.json
 /public/dll
 /public/js
+/public/styles
 /public/uploads
 /src/*/__build__/
 /__build__/**

+ 4 - 0
.vscode/settings.json

@@ -1,4 +1,8 @@
 {
   // 既定の改行文字。LF の場合には \n を CRLF の場合には \r\n を使用してください。
   "files.eol": "\n",
+  // 指定した構文に対してプロファイルを定義するか、特定の規則がある独自のプロファイルをご使用ください。
+  "emmet.syntaxProfiles": {
+    "javascript": "jsx"
+  }
 }

+ 47 - 6
CHANGES.md

@@ -1,23 +1,64 @@
 CHANGES
 ========
 
-## 3.1.4-RC
+## 3.1.8-RC
+
+* Feature: Login with Google Account
+* Feature: Login with GitHub Account
+* Feature: Attach files in Comment
+* Improvement: Write comment with CodeMirror Editor
+* Improvement: Place the commented page at the beginning of the list
+* Improvement: Resolve errors on IE11 (Experimental)
+* Support: Migrate to webpack 4 
+* Support: Upgrade libs
+    * react-bootstrap-typeahead
+    * react-codemirror2
+    * webpack
 
-* Fix: sanitize `#raw-text-original` content with 'entities'
-* Fix: page.rename api doesn't work
+## 3.1.7
+
+* Fix: Update hidden input 'pageForm[grant]' when save with Ctrl-S
+* Fix: Show alert message when conflict
+* Fix: `BLOCKDIAG_URI` environment variable doesn't work
+* Fix: Paste in markdown list doesn't work correctly
+* Support: Ensure to inject logger configuration from environment variables
 * Support: Upgrade libs
-    * markdown-it-toc-and-anchor-with-slugid
+    * sinon
+    * sinon-chai
 
+## 3.1.6
+
+* Feature: Support [blockdiag](http://blockdiag.com)
+* Feature: Add `BLOCKDIAG_URI` environment variable
+* Fix: Select modal for group is not shown 
+* Support: Upgrade libs
+    * googleapis
+    * throttle-debounce
 
-## 3.1.3
+## 3.1.5
 
 * Feature: Write comment with Markdown
 * Improvement: Support some placeholders for template page
 * Improvement: Omit unnecessary response header
 * Improvement: Support LDAP attribute mappings for user's full name
-* Fix: HTML escaped characters in markdown are unescaped unexpectedly after page is saved
+* Improvement: Enable to scroll revision-toc
 * Fix: Posting to Slack doesn't work
     * Introduced by 3.1.0
+* Fix: page.rename api doesn't work
+* Fix: HTML escaped characters in markdown are unescaped unexpectedly after page is saved
+* Fix: sanitize `#raw-text-original` content with 'entities'
+* Fix: Double newline character posted
+    * Introduced by 3.1.4
+* Fix: List and Comment components do not displayed
+    * Introduced by 3.1.4
+* Support: Upgrade libs
+    * markdown-it-toc-and-anchor-with-slugid
+
+
+## 3.1.4 (Missing number)
+
+## 3.1.3 (Missing number)
+
 
 ## 3.1.2
 

+ 7 - 6
README.md

@@ -4,8 +4,8 @@
   </a>
 </p>
 <p align="center">
-  <a href="https://github.com/weseek/crowi-plus/releases/latest"><img src="https://img.shields.io/github/release/weseek/crowi-plus.svg"></a>
-  <a href="https://growi-slackin.weseek.co.jp/"><img src="https://crowi-plus-slackin.weseek.co.jp/badge.svg"></a>
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
+  <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 </p>
 
 <p align="center">
@@ -105,11 +105,10 @@ MONGO_URI=mongodb://MONGO_HOST:MONGO_PORT/growi npm start
 
 **DO NOT USE `npm install`**, use `yarn` instead.
 
-If you launch growi with Redis and ElasticSearch, add environment variables before `npm start` like following:
+If you launch growi with ElasticSearch, add environment variables before `npm start` like following:
 
 ```
 export MONGO_URI=mongodb://MONGO_HOST:MONGO_PORT/growi
-export REDIS_URL=redis://REDIS_HOST:REDIS_PORT/growi
 export ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi
 npm start
 ```
@@ -158,11 +157,13 @@ Environment Variables
 * **Option**
     * NODE_ENV: `production` OR `development`.
     * PORT: Server port. default: `3000`
-    * REDIS_URL: URI to connect to Redis (to session store).
-    * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
     * ELASTICSEARCH_URI: URI to connect to Elasticearch.
+    * REDIS_URI: URI to connect to Redis (to session store).
+    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
+    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * PASSWORD_SEED: A password seed used by password hash generator.
     * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
+    * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
     * FILE_UPLOAD: `aws` (default), `local`, `none`
 
 

+ 73 - 45
config/webpack.common.js

@@ -8,32 +8,41 @@ const helpers = require('./helpers');
 /*
  * Webpack Plugins
  */
-const AssetsPlugin = require('assets-webpack-plugin');
-const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
+const WebpackAssetsManifest = require('webpack-assets-manifest');
+const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
 
 /*
  * Webpack configuration
  *
  * See: http://webpack.github.io/docs/configuration.html#cli
  */
-module.exports = function(options) {
+module.exports = (options) => {
   return {
-    entry: {
-      'app':                  './resource/js/app',
-      'legacy':               './resource/js/legacy/crowi',
-      'legacy-form':          './resource/js/legacy/crowi-form',
-      'legacy-admin':         './resource/js/legacy/crowi-admin',
-      'legacy-presentation':  './resource/js/legacy/crowi-presentation',
-      'plugin':               './resource/js/plugin',
-      'style':                './resource/styles/scss/style.scss',
-      'style-theme-default':  './resource/styles/scss/theme/default.scss',
-      'style-theme-default-dark':  './resource/styles/scss/theme/default-dark.scss',
-      'style-theme-nature':   './resource/styles/scss/theme/nature.scss',
-      'style-theme-mono-blue':   './resource/styles/scss/theme/mono-blue.scss',
-      'style-theme-future': './resource/styles/scss/theme/future.scss',
-      'style-theme-blue-night': './resource/styles/scss/theme/blue-night.scss',
-      'style-presentation':   './resource/styles/scss/style-presentation.scss',
-    },
+    mode: options.mode,
+    entry: Object.assign({
+      'js/app':                   './resource/js/app',
+      'js/legacy':                './resource/js/legacy/crowi',
+      'js/legacy-form':           './resource/js/legacy/crowi-form',
+      'js/legacy-admin':          './resource/js/legacy/crowi-admin',
+      'js/legacy-presentation':   './resource/js/legacy/crowi-presentation',
+      'js/plugin':                './resource/js/plugin',
+      'js/ie11-polyfill':         './resource/js/ie11-polyfill',
+      // styles
+      'styles/style':                './resource/styles/scss/style.scss',
+      'styles/style-presentation':   './resource/styles/scss/style-presentation.scss',
+      // themes
+      'styles/theme-default':        './resource/styles/scss/theme/default.scss',
+      'styles/theme-default-dark':   './resource/styles/scss/theme/default-dark.scss',
+      'styles/theme-nature':         './resource/styles/scss/theme/nature.scss',
+      'styles/theme-mono-blue':      './resource/styles/scss/theme/mono-blue.scss',
+      'styles/theme-future':         './resource/styles/scss/theme/future.scss',
+      'styles/theme-blue-night':     './resource/styles/scss/theme/blue-night.scss',
+    }, options.entry || {}),  // Merge with env dependent settings
+    output: Object.assign({
+      path: helpers.root('public'),
+      publicPath: '/',
+      filename: '[name].bundle.js',
+    }, options.output || {}), // Merge with env dependent settings
     externals: {
       // require("jquery") is external and available
       //  on the global var jQuery
@@ -53,7 +62,7 @@ module.exports = function(options) {
       }
     },
     module: {
-      rules: [
+      rules: options.module.rules.concat([
         {
           test: /.jsx?$/,
           exclude: {
@@ -64,10 +73,7 @@ module.exports = function(options) {
             ]
           },
           use: [{
-            loader: 'babel-loader?cacheDirectory',
-            options: {
-              plugins: ['lodash'],
-            }
+            loader: 'babel-loader?cacheDirectory'
           }]
         },
         {
@@ -98,40 +104,62 @@ module.exports = function(options) {
         */
         {
           test: /\.(eot|woff2?|svg|ttf)([?]?.*)$/,
-          use: 'file-loader',
+          use: 'null-loader',
         }
-      ]
+      ])
     },
-    plugins: [
+    plugins: options.plugins.concat([
 
-      new AssetsPlugin({
-        path: helpers.root('public/js'),
-        filename: 'webpack-assets.json',
-        prettyPrint: true,
-      }),
+      new WebpackAssetsManifest({ publicPath: true }),
 
-      new CommonsChunkPlugin({
-        name: 'commons',
-        chunks: ['app', 'legacy', 'legacy-form', 'legacy-admin'],
-        minChunks: module => /node_modules/.test(module.resource),
-      }),
-      new CommonsChunkPlugin({
-        name: 'commons',
-        chunks: ['commons', 'legacy-presentation'],
-      }),
-      new CommonsChunkPlugin({
-        name: 'commons',
-        chunks: ['commons', 'plugin'],
+      new webpack.DefinePlugin({
+        'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
       }),
 
       // ignore
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
 
+      new LodashModuleReplacementPlugin({
+        flattening: true
+      }),
+
       new webpack.ProvidePlugin({ // refs externals
         jQuery: 'jquery',
         $: 'jquery',
       }),
 
-    ]
+    ]),
+
+    devtool: options.devtool,
+    target: 'web', // Make web variables accessible to webpack, e.g. window
+    optimization: {
+      namedModules: true,
+      splitChunks: {
+        cacheGroups: {
+          commons: {
+            test: /resource/,
+            chunks: 'initial',
+            name: 'js/commons',
+            minChunks: 2,
+            minSize: 1,
+            priority: 20
+          },
+          vendors: {
+            test: /node_modules/,
+            chunks: (chunk) => {
+              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill/);
+            },
+            name: 'js/vendors',
+            // minChunks: 2,
+            minSize: 1,
+            priority: 10,
+            enforce: true
+          }
+        }
+      },
+      minimizer: options.optimization.minimizer || [],
+    },
+    performance: options.performance || {},
+    stats: options.stats || {},
   };
 };

+ 42 - 85
config/webpack.dev.js

@@ -5,98 +5,55 @@
 const path = require('path');
 const webpack = require('webpack');
 const helpers = require('./helpers');
-const webpackMerge = require('webpack-merge');
-const webpackMergeDll = webpackMerge.strategy({plugins: 'replace'});
-const commonConfig = require('./webpack.common.js');
 
 /*
  * Webpack Plugins
  */
-const DllBundlesPlugin = require('webpack-dll-bundles-plugin').DllBundlesPlugin;
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
-/*
+/**
  * Webpack Constants
  */
 const ANALYZE = process.env.ANALYZE;
-const ENV = process.env.ENV = process.env.NODE_ENV = 'development';
-
-/*
- * Webpack configuration
- *
- * See: http://webpack.github.io/docs/configuration.html#cli
- */
-module.exports = function(options) {
-  return webpackMerge(commonConfig({ env: ENV }), {
-    devtool: 'cheap-module-eval-source-map',
-    entry: {
-      dev: './resource/js/dev',
-    },
-    output: {
-      path: helpers.root('public/js'),
-      publicPath: '/js/',
-      filename: '[name].bundle.js',
-    },
-    resolve: {
-      extensions: ['.js', '.json'],
-      modules: [helpers.root('src'), helpers.root('node_modules'), path.join(process.env.HOME, '.node_modules')],
-    },
-    module: {
-      rules: [
-        {
-          test: /\.css$/,
-          use: [
-            'style-loader',
-            { loader: 'css-loader', options: { sourceMap: true } },
-          ],
-          include: [helpers.root('resource/styles/scss')]
-        },
-        {
-          test: /\.scss$/,
-          use: [
-            'style-loader',
-            { loader: 'css-loader', options: { sourceMap: true } },
-            { loader: 'sass-loader', options: { sourceMap: true } },
-          ],
-          include: [helpers.root('resource/styles/scss')]
-        },
-      ],
-    },
-    plugins: [
-
-      new DllBundlesPlugin({
-        bundles: {
-          vendor: [
-            'axios',
-            'codemirror',
-            'date-fns',
-            'diff',
-            'diff2html',
-            'jquery-ui',
-            'markdown-it',
-            'metismenu',
-            'react',
-            'react-dom',
-            'react-bootstrap',
-            'react-bootstrap-typeahead',
-            'react-dropzone',
-            'socket.io-client',
-            'toastr',
-          ],
-        },
-        dllDir: helpers.root('public/dll'),
-        webpackConfig: webpackMergeDll(commonConfig({env: ENV}), {
-          devtool: undefined,
-          plugins: [],
-        })
-      }),
-
-      new webpack.NoEmitOnErrorsPlugin(),
-
-      new BundleAnalyzerPlugin({
-        analyzerMode: ANALYZE ? 'server' : 'disabled',
-      }),
 
-    ]
-  });
-};
+module.exports = require('./webpack.common')({
+  mode: 'development',
+  devtool: 'cheap-module-eval-source-map',
+  entry: {
+    'js/dev': './resource/js/dev',
+  },
+  resolve: {
+    // TODO merge in webpack.common.js
+    modules: [path.join(process.env.HOME, '.node_modules')],
+  },
+  module: {
+    rules: [
+      {
+        test: /\.scss$/,
+        use: [
+          'style-loader',
+          { loader: 'css-loader', options: { sourceMap: true } },
+          { loader: 'sass-loader', options: { sourceMap: true } },
+        ],
+        include: [helpers.root('resource/styles/scss')]
+      },
+    ],
+  },
+  plugins: [
+
+    new webpack.DllReferencePlugin({
+      context: helpers.root(),
+      manifest: require(helpers.root('public/dll', 'manifest.json')),
+    }),
+
+    new BundleAnalyzerPlugin({
+      analyzerMode: ANALYZE ? 'server' : 'disabled',
+    }),
+
+  ],
+  optimization: {},
+  performance: {
+    hints: false
+  }
+
+});

+ 51 - 0
config/webpack.dll.js

@@ -0,0 +1,51 @@
+/**
+ * @author: Yuki Takei <yuki@weseek.co.jp>
+ */
+const webpack = require('webpack');
+const helpers = require('./helpers');
+
+
+module.exports = {
+  mode: 'development',
+  entry: {
+    dlls: [
+      // Libraries
+      'axios',
+      'babel-polyfill',
+      'bootstrap-select',
+      'browser-bunyan', 'bunyan-format',
+      'codemirror', 'react-codemirror2',
+      'clipboard',
+      'date-fns',
+      'diff2html',
+      'debug',
+      'entities',
+      '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',
+      'socket.io-client',
+      'toastr',
+      'xss',
+      // GROWI Libraries
+      'growi-pluginkit',
+    ]
+  },
+  output: {
+    path: helpers.root('public/dll'),
+    filename: 'dll.js',
+    library: 'growi_dlls',
+  },
+  resolve: {
+    extensions: ['.js', '.json'],
+    modules: [helpers.root('src'), helpers.root('node_modules')],
+  },
+  plugins: [
+    new webpack.DllPlugin({
+      path: helpers.root('public/dll/manifest.json'),
+      name: 'growi_dlls'
+    })
+  ],
+};

+ 45 - 96
config/webpack.prod.js

@@ -3,117 +3,66 @@
  */
 
 const helpers = require('./helpers');
-const webpack = require('webpack');
-const webpackMerge = require('webpack-merge'); // used to merge webpack configs
-const commonConfig = require('./webpack.common.js'); // the settings that are common to prod and dev
 
 /**
  * Webpack Plugins
  */
-const ExtractTextPlugin = require('extract-text-webpack-plugin');
-const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
-const OptimizeJsPlugin = require('optimize-js-plugin');
+const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
 /**
  * Webpack Constants
  */
 const ANALYZE = process.env.ANALYZE;
-const ENV = process.env.NODE_ENV = process.env.ENV = 'production';
 
-module.exports = function(env) {
-  return webpackMerge(commonConfig({ env: ENV }), {
-    devtool: undefined,
-    output: {
-      path: helpers.root('public/js'),
-      publicPath: '/js/',
-      filename: '[name].[chunkhash].bundle.js',
-      sourceMapFilename: '[name].[chunkhash].bundle.map',
-      chunkFilename: '[id].[chunkhash].chunk.js'
-    },
-    module: {
-      rules: [
-        {
-          test: /\.scss$/,
-          use: ExtractTextPlugin.extract({
-            fallback: 'style-loader',
-            use: [
-              { loader: 'css-loader', options: {
-                sourceMap: false,
-                minimize: true
-              } },
-              { loader: 'postcss-loader', options: {
-                sourceMap: false,
-                plugins: (loader) => [
-                  require('autoprefixer')()
-                ]
-              } },
-              { loader: 'sass-loader', options: { sourceMap: false } }
+module.exports = require('./webpack.common')({
+  mode: 'production',
+  devtool: undefined,
+  output: {
+    filename: '[name].[chunkhash].bundle.js',
+    chunkFilename: '[name].[chunkhash].chunk.js'
+  },
+  module: {
+    rules: [
+      {
+        test: /\.scss$/,
+        use: [
+          MiniCssExtractPlugin.loader,
+          'css-loader',
+          { loader: 'postcss-loader', options: {
+            sourceMap: false,
+            plugins: (loader) => [
+              require('autoprefixer')()
             ]
-          }),
-          include: [helpers.root('resource/styles/scss')]
-        }
-      ]
-    },
-    plugins: [
+          } },
+          'sass-loader'
+        ],
+        include: [helpers.root('resource/styles/scss')]
+      }
+    ]
+  },
+  plugins: [
 
-      new webpack.DefinePlugin({
-        'process.env': {
-          NODE_ENV: JSON.stringify(ENV),
-        }
-      }),
-
-      new ExtractTextPlugin('[name].[contenthash].css'),
+    new MiniCssExtractPlugin({
+      filename: '[name].[hash].css',
+    }),
 
-      new OptimizeJsPlugin({
-        sourceMap: false
-      }),
+    new BundleAnalyzerPlugin({
+      analyzerMode: ANALYZE ? 'static' : 'disabled',
+      reportFilename: helpers.root('report/bundle-analyzer.html'),
+      openAnalyzer: false,
+    }),
 
+  ],
+  optimization: {
+    minimizer: [
       new UglifyJsPlugin({
-        // beautify: true, //debug
-        // mangle: false, //debug
-        // dead_code: false, //debug
-        // unused: false, //debug
-        // deadCode: false, //debug
-        // compress: {
-        //   screw_ie8: true,
-        //   keep_fnames: true,
-        //   drop_debugger: false,
-        //   dead_code: false,
-        //   unused: false
-        // }, // debug
-        // comments: true, //debug
-
-
-        beautify: false, //prod
-        output: {
-          comments: false
-        }, //prod
-        mangle: {
-          screw_ie8: true
-        }, //prod
-        compress: {
-          screw_ie8: true,
-          warnings: false,
-          conditionals: true,
-          unused: true,
-          comparisons: true,
-          sequences: true,
-          dead_code: true,
-          evaluate: true,
-          if_return: true,
-          join_vars: true,
-          negate_iife: false // we need this for lazy v8
-        },
+        cache: true,
+        parallel: true,
       }),
-
-      new BundleAnalyzerPlugin({
-        analyzerMode: ANALYZE ? 'static' : 'disabled',
-        reportFilename: helpers.root('report/bundle-analyzer.html'),
-        openAnalyzer: false,
-      }),
-
+      new OptimizeCSSAssetsPlugin({})
     ],
-
-  });
-};
+  },
+});

+ 1 - 1
lib/crowi/express-init.js

@@ -89,7 +89,7 @@ module.exports = function(crowi, app) {
   app.use(express.static(crowi.publicDir, staticOption));
   app.engine('html', swig.renderFile);
   app.use(webpackAssets(
-    path.join(crowi.publicDir, 'js/webpack-assets.json'),
+    path.join(crowi.publicDir, 'manifest.json'),
     { devMode: (crowi.node_env === 'development') })
   );
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei

+ 9 - 3
lib/crowi/index.js

@@ -147,7 +147,7 @@ Crowi.prototype.setupSessionConfig = function() {
     , session  = require('express-session')
     , sessionConfig
     , sessionAge = (1000*3600*24*30)
-    , redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URL || null
+    , redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null
     , mongoUrl = getMongoUrl(this.env)
     ;
 
@@ -262,8 +262,14 @@ Crowi.prototype.setupPassport = function() {
   this.passportService.setupSerializer();
   // setup strategies
   this.passportService.setupLocalStrategy();
-  this.passportService.setupLdapStrategy();
-
+  try {
+    this.passportService.setupLdapStrategy();
+    this.passportService.setupGoogleStrategy();
+    this.passportService.setupGitHubStrategy();
+  }
+  catch (err) {
+    logger.error(err);
+  }
   return Promise.resolve();
 };
 

+ 12 - 0
lib/form/admin/securityPassportGitHub.js

@@ -0,0 +1,12 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-github:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-github:clientId]').trim(),
+  field('settingForm[security:passport-github:clientSecret]').trim(),
+  field('settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict()
+);

+ 12 - 0
lib/form/admin/securityPassportGoogle.js

@@ -0,0 +1,12 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-google:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-google:clientId]').trim(),
+  field('settingForm[security:passport-google:clientSecret]').trim(),
+  field('settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict()
+);

+ 2 - 0
lib/form/index.js

@@ -19,6 +19,8 @@ module.exports = {
     securityGoogle: require('./admin/securityGoogle'),
     securityMechanism: require('./admin/securityMechanism'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
+    securityPassportGoogle: require('./admin/securityPassportGoogle'),
+    securityPassportGitHub: require('./admin/securityPassportGitHub'),
     markdown: require('./admin/markdown'),
     customcss: require('./admin/customcss'),
     customscript: require('./admin/customscript'),

+ 4 - 1
lib/form/revision.js

@@ -5,7 +5,10 @@ var form = require('express-form')
 
 module.exports = form(
   field('pageForm.path').required(),
-  field('pageForm.body').required().custom(function(value) { return value.replace(/\r/g, '\n') }),
+  field('pageForm.body').required().custom(function(value) {
+    // see https://github.com/weseek/growi/issues/463
+    return value.replace(/\r\n?/g, '\n');
+  }),
   field('pageForm.currentRevision'),
   field('pageForm.grant').toInt().required(),
   field('pageForm.grantUserGroupId'),

+ 150 - 13
lib/locales/en-US/sandbox.md

@@ -48,7 +48,7 @@
 
 ## Br 改行
 
-改行の前に半角スペース`  `を2つ記述します。  
+改行の前に半角スペース`  `を2つ記述します。
 ***この挙動は、オプションで変更可能です***
 
 ```
@@ -58,7 +58,7 @@ piyo
 ```
 
 hoge
-fuga  
+fuga
 piyo
 
 ## Blockquotes 引用
@@ -66,12 +66,12 @@ piyo
 先頭に`>`を記述します。ネストは`>`を多重に記述します。
 
 ```
-> 引用  
+> 引用
 > 引用
 >> 多重引用
 ```
 
-> 引用  
+> 引用
 > 引用
 >> 多重引用
 
@@ -235,7 +235,7 @@ ___
 &lt;/user/admin1>
 ```
 
-[/Sandbox]  
+[/Sandbox]
 </user/admin1>
 
 ## Pukiwiki like linker
@@ -243,7 +243,7 @@ ___
 (available by [weseek/growi-plugin-pukiwiki-like-linker
 ](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
 
-最も柔軟な Linker です。  
+最も柔軟な Linker です。
 記述中のページを基点とした相対リンクと、表示テキストに対するリンクを同時に実現できます。
 
 ```
@@ -251,14 +251,14 @@ ___
 Bootstrap3のExampleは[[こちら>./Bootstrap3]]
 ```
 
-[[../user]]  
+[[../user]]
 Bootstrap3のExampleは[[こちら>./Bootstrap3]]
 
 # :pencil: Lists
 
 ## Ul 箇条書きリスト
 
-ハイフン`-`、プラス`+`、アスタリスク`*`のいずれかを先頭に記述します。  
+ハイフン`-`、プラス`+`、アスタリスク`*`のいずれかを先頭に記述します。
 ネストはタブで表現します。
 
 ```
@@ -281,7 +281,7 @@ Bootstrap3のExampleは[[こちら>./Bootstrap3]]
 
 ## Ol 番号付きリスト
 
-`番号.`を先頭に記述します。ネストはタブで表現します。  
+`番号.`を先頭に記述します。ネストはタブで表現します。
 番号は自動的に採番されるため、すべての行を1.と記述するのがお勧めです。
 
 ```
@@ -567,7 +567,7 @@ class BaseClass
 namespace net.dummy #DDDDDD {
     .BaseClass <|-- Person
     Meeting o-- Person
-    
+
     .BaseClass <|- Meeting
 }
 
@@ -594,11 +594,11 @@ package "Some Group" {
   HTTP - [First Component]
   [Another Component]
 }
- 
+
 node "Other Groups" {
   FTP - [Second Component]
   [First Component] --> FTP
-} 
+}
 
 cloud {
   [Example 1]
@@ -646,10 +646,147 @@ state State3 {
 State3 --> State3 : Failed
 State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
- 
+
 @enduml
 
 <!-- Reset PlantUML -->
 <div class="clearfix"></div>
 
+# :pencil: blockdiag
+
+See [blockdiag](http://blockdiag.com/).
+
+## blockdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: blockdiag
+blockdiag {
+   A -> B -> C -> D;
+   A -> E -> F -> G;
+}
+:::
+
+</div>
+
+## seqdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: seqdiag
+seqdiag {
+  browser  -> webserver [label = "GET /index.html"];
+  browser <-- webserver;
+  browser  -> webserver [label = "POST /blog/comment"];
+              webserver  -> database [label = "INSERT comment"];
+              webserver <-- database;
+  browser <-- webserver;
+}
+:::
+
+</div>
+
+## actdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: actdiag
+actdiag {
+  write -> convert -> image
 
+  lane user {
+     label = "User"
+     write [label = "Writing reST"];
+     image [label = "Get diagram IMAGE"];
+  }
+  lane actdiag {
+     convert [label = "Convert reST to Image"];
+  }
+}
+:::
+
+</div>
+
+## nwdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: nwdiag
+nwdiag {
+  network dmz {
+      address = "210.x.x.x/24"
+
+      web01 [address = "210.x.x.1"];
+      web02 [address = "210.x.x.2"];
+  }
+  network internal {
+      address = "172.x.x.x/24";
+
+      web01 [address = "172.x.x.1"];
+      web02 [address = "172.x.x.2"];
+      db01;
+      db02;
+  }
+}
+:::
+
+</div>
+
+## rackdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: rackdiag
+rackdiag {
+  // define height of rack
+  8U;
+
+  // define rack items
+  1: UPS [2U];
+  3: DB Server
+  4: Web Server
+  5: Web Server
+  6: Web Server
+  7: Load Balancer
+  8: L3 Switch
+}
+:::
+
+</div>
+
+## packetdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: packetdiag
+packetdiag {
+  colwidth = 32
+  node_height = 72
+
+  0-15: Source Port
+  16-31: Destination Port
+  32-63: Sequence Number
+  64-95: Acknowledgment Number
+  96-99: Data Offset
+  100-105: Reserved
+  106: URG [rotate = 270]
+  107: ACK [rotate = 270]
+  108: PSH [rotate = 270]
+  109: RST [rotate = 270]
+  110: SYN [rotate = 270]
+  111: FIN [rotate = 270]
+  112-127: Window
+  128-143: Checksum
+  144-159: Urgent Pointer
+  160-191: (Options and Padding)
+  192-223: data [colheight = 3]
+}
+:::
+
+</div>

+ 33 - 17
lib/locales/en-US/translation.json

@@ -307,22 +307,23 @@
     "username_email_password": "Username, Email and Password authentication",
     "ldap_auth": "LDAP authentication",
     "google_auth2": "Google OAuth2 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 OAuth2 authentication",
     "twitter_auth2": "Twitter OAuth authentication",
-    "github_auth2": "Github OAuth2 authentication",
+    "github_auth2": "GitHub OAuth2 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\">API Manager on Google Cloud Platform</a>",
-		"access_api_manager": "Access <a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a>",
+    "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\".",
+    "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": "Client Secret",
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "readonly": "View Only"
@@ -332,8 +333,10 @@
       "restricted": "Reuqire Admin permission",
       "closed": "Invitation Only"
     },
-    "configuration": "Configuration",
+    "configuration": " Configuration",
     "optional": "Optional",
+    "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
+    "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
     "ldap": {
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "bind_mode": "Binding Mode",
@@ -353,8 +356,6 @@
       "search_filter_example2": "Match with 'sAMAccountName' for Active Directory",
       "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",
-      "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
-  		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
       "group_search_base_DN": "Group Search Base DN",
       "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",
@@ -365,13 +366,29 @@
       "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"
     },
-    "Google OAuth": {
-    },
-    "Facebook": {
-    },
-    "Twitter": {
-    },
-    "Github": {
+    "OAuth": {
+      "register": "Register for %s",
+      "change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
+      "Google": {
+        "name": "Google OAuth",
+        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_2": "Create Project if no projects exist",
+        "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_5": "Copy and paste your ClientID and Client Secret above"
+      },
+      "Facebook": {
+        "name": "Facebook OAuth"
+      },
+      "Twitter": {
+        "name": "Twitter OAuth"
+      },
+      "GitHub": {
+        "name": "GitHub OAuth",
+        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "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"
+      }
     }
 	},
 
@@ -380,8 +397,7 @@
     "Enable Line Break": "Enable Line Break",
     "Enable Line Break desc": "Treat line break in the text page as <code>&lt;br&gt;</code> in HTML",
     "Enable Line Break for comment": "Enable Line Break in comment",
-    "Enable Line Break for comment desc": "Treat line break in comment as <code>&lt;br&gt;</code> in HTML",
-    "TBD": "(TBD: Markdown function in the comment section has not been implemented yet)"
+    "Enable Line Break for comment desc": "Treat line break in comment as <code>&lt;br&gt;</code> in HTML"
   },
 
   "customize_page": {

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

@@ -1,6 +1,6 @@
 # Welcome to GROWI :anchor:
 
-[![Github Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
+[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 <div class="panel panel-default">

+ 150 - 13
lib/locales/ja/sandbox.md

@@ -48,7 +48,7 @@
 
 ## Br 改行
 
-改行の前に半角スペース`  `を2つ記述します。  
+改行の前に半角スペース`  `を2つ記述します。
 ***この挙動は、オプションで変更可能です***
 
 ```
@@ -58,7 +58,7 @@ piyo
 ```
 
 hoge
-fuga  
+fuga
 piyo
 
 ## Blockquotes 引用
@@ -66,12 +66,12 @@ piyo
 先頭に`>`を記述します。ネストは`>`を多重に記述します。
 
 ```
-> 引用  
+> 引用
 > 引用
 >> 多重引用
 ```
 
-> 引用  
+> 引用
 > 引用
 >> 多重引用
 
@@ -235,7 +235,7 @@ ___
 &lt;/user/admin1>
 ```
 
-[/Sandbox]  
+[/Sandbox]
 </user/admin1>
 
 ## Pukiwiki like linker
@@ -243,7 +243,7 @@ ___
 (available by [weseek/growi-plugin-pukiwiki-like-linker
 ](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
 
-最も柔軟な Linker です。  
+最も柔軟な Linker です。
 記述中のページを基点とした相対リンクと、表示テキストに対するリンクを同時に実現できます。
 
 ```
@@ -251,14 +251,14 @@ ___
 Bootstrap3のExampleは[[こちら>./Bootstrap3]]
 ```
 
-[[../user]]  
+[[../user]]
 Bootstrap3のExampleは[[こちら>./Bootstrap3]]
 
 # :pencil: Lists
 
 ## Ul 箇条書きリスト
 
-ハイフン`-`、プラス`+`、アスタリスク`*`のいずれかを先頭に記述します。  
+ハイフン`-`、プラス`+`、アスタリスク`*`のいずれかを先頭に記述します。
 ネストはタブで表現します。
 
 ```
@@ -281,7 +281,7 @@ Bootstrap3のExampleは[[こちら>./Bootstrap3]]
 
 ## Ol 番号付きリスト
 
-`番号.`を先頭に記述します。ネストはタブで表現します。  
+`番号.`を先頭に記述します。ネストはタブで表現します。
 番号は自動的に採番されるため、すべての行を1.と記述するのがお勧めです。
 
 ```
@@ -567,7 +567,7 @@ class BaseClass
 namespace net.dummy #DDDDDD {
     .BaseClass <|-- Person
     Meeting o-- Person
-    
+
     .BaseClass <|- Meeting
 }
 
@@ -594,11 +594,11 @@ package "Some Group" {
   HTTP - [First Component]
   [Another Component]
 }
- 
+
 node "Other Groups" {
   FTP - [Second Component]
   [First Component] --> FTP
-} 
+}
 
 cloud {
   [Example 1]
@@ -646,10 +646,147 @@ state State3 {
 State3 --> State3 : Failed
 State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
- 
+
 @enduml
 
 <!-- Reset PlantUML -->
 <div class="clearfix"></div>
 
+# :pencil: blockdiag
+
+See [blockdiag](http://blockdiag.com/).
+
+## blockdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: blockdiag
+blockdiag {
+   A -> B -> C -> D;
+   A -> E -> F -> G;
+}
+:::
+
+</div>
+
+## seqdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: seqdiag
+seqdiag {
+  browser  -> webserver [label = "GET /index.html"];
+  browser <-- webserver;
+  browser  -> webserver [label = "POST /blog/comment"];
+              webserver  -> database [label = "INSERT comment"];
+              webserver <-- database;
+  browser <-- webserver;
+}
+:::
+
+</div>
+
+## actdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: actdiag
+actdiag {
+  write -> convert -> image
 
+  lane user {
+     label = "User"
+     write [label = "Writing reST"];
+     image [label = "Get diagram IMAGE"];
+  }
+  lane actdiag {
+     convert [label = "Convert reST to Image"];
+  }
+}
+:::
+
+</div>
+
+## nwdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: nwdiag
+nwdiag {
+  network dmz {
+      address = "210.x.x.x/24"
+
+      web01 [address = "210.x.x.1"];
+      web02 [address = "210.x.x.2"];
+  }
+  network internal {
+      address = "172.x.x.x/24";
+
+      web01 [address = "172.x.x.1"];
+      web02 [address = "172.x.x.2"];
+      db01;
+      db02;
+  }
+}
+:::
+
+</div>
+
+## rackdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: rackdiag
+rackdiag {
+  // define height of rack
+  8U;
+
+  // define rack items
+  1: UPS [2U];
+  3: DB Server
+  4: Web Server
+  5: Web Server
+  6: Web Server
+  7: Load Balancer
+  8: L3 Switch
+}
+:::
+
+</div>
+
+## packetdiag
+
+<!-- Resize blockdiag -->
+<div style="max-width: 600px">
+
+::: packetdiag
+packetdiag {
+  colwidth = 32
+  node_height = 72
+
+  0-15: Source Port
+  16-31: Destination Port
+  32-63: Sequence Number
+  64-95: Acknowledgment Number
+  96-99: Data Offset
+  100-105: Reserved
+  106: URG [rotate = 270]
+  107: ACK [rotate = 270]
+  108: PSH [rotate = 270]
+  109: RST [rotate = 270]
+  110: SYN [rotate = 270]
+  111: FIN [rotate = 270]
+  112-127: Window
+  128-143: Checksum
+  144-159: Urgent Pointer
+  160-191: (Options and Padding)
+  192-223: data [colheight = 3]
+}
+:::
+
+</div>

+ 35 - 17
lib/locales/ja/translation.json

@@ -84,7 +84,7 @@
   "Table of Contents": "目次",
   "Management Wiki Home": "Wiki管理トップ",
   "App settings": "アプリ設定",
-  "Markdown settings": "Markdown設定",
+  "Markdown settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Notification settings": "通知設定",
   "User management": "ユーザー管理",
@@ -248,11 +248,11 @@
     },
     "children": {
       "label": "同一階層テンプレート",
-      "desc": "テンプレートページが存在する階層にのみ適されます"
+      "desc": "テンプレートページが存在する階層にのみ適されます"
     },
     "decendants": {
       "label": "下位層テンプレート",
-      "desc": "テンプレートページが存在する下位層のすべてのページに適されます"
+      "desc": "テンプレートページが存在する下位層のすべてのページに適されます"
     }
   },
 
@@ -325,15 +325,16 @@
     "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
     "ldap_auth": "LDAP 認証",
     "google_auth2": "Google OAuth2 認証",
+    "google_auth2_by_crowi_desc": "ただし、この機能では新たなユーザーは作成されず、関連付け設定を行った既存ユーザーをログインできるようにするだけです。",
     "facebook_auth2": "Facebook OAuth2 認証",
     "twitter_auth2": "Twitter OAuth 認証",
-    "github_auth2": "Github OAuth2 認証",
+    "github_auth2": "GitHub OAuth2 認証",
     "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\">API Manager</a>から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。",
-    "access_api_manager": "<a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a> へアクセス",
+    "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": "「ウェブアプリケーション」を選択",
@@ -349,8 +350,10 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
-    "configuration": "コンフィギュレーション",
+    "configuration": "設定",
     "optional": "オプション",
+    "Treat username matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat username matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
@@ -370,8 +373,6 @@
       "search_filter_example2": "'sAMAccountName' に一致 (Active Directory)",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
       "name_detail": "新規ユーザーの表示名(<code>name</code>)に関連付ける属性",
-      "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-      "Treat username matching as identical_warn": "WARNING: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
       "group_search_base_DN": "グループ検索ベース DN",
       "group_search_base_DN_detail": "グループ検索を実行するベース DN。利用する場合は <code>グループ検索フィルター</code> も入力する必要があります。",
       "group_search_filter": "グループ検索フィルター",
@@ -379,15 +380,32 @@
       "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_user_DN_property": "ユーザーの DN プロパティー",
-      "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー"
+      "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
+      "test_config": "ログインテスト"
     },
-    "Google OAuth": {
-    },
-    "Facebook": {
-    },
-    "Twitter": {
-    },
-    "Github": {
+    "OAuth": {
+      "register": "%sに登録",
+      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力<br>(<code>%s</code>は環境に合わせて変更してください)",
+      "Google": {
+        "name": "Google OAuth認証",
+        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_2": "プロジェクトがない場合はプロジェクトを作成",
+        "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
+        "register_4": "承認済みのリダイレクトURIを<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
+      },
+      "Facebook": {
+        "name": "Facebook OAuth認証"
+      },
+      "Twitter": {
+        "name": "Twitter OAuth認証"
+      },
+      "GitHub": {
+        "name": "GitHub OAuth認証",
+        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
+        "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
+      }
     }
   },
   "markdown_setting": {

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

@@ -1,6 +1,6 @@
 # Welcome to GROWI :anchor:
 
-[![Github Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
+[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 <div class="panel panel-default">

+ 5 - 5
lib/models/bookmark.js

@@ -13,9 +13,9 @@ module.exports = function(crowi) {
   bookmarkSchema.index({page: 1, user: 1}, {unique: true});
 
   bookmarkSchema.statics.populatePage = function(bookmarks, requestUser) {
-    var Bookmark = this;
-    var User = crowi.model('User');
-    var Page = crowi.model('Page');
+    const Bookmark = this;
+    const User = crowi.model('User');
+    const Page = crowi.model('Page');
 
     requestUser = requestUser || null;
 
@@ -36,13 +36,13 @@ module.exports = function(crowi) {
           return bookmark.page.isGrantedFor(requestUser);
         });
 
-        return Bookmark.populate(bookmarks, {path: 'page.revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS});
+        return Bookmark.populate(bookmarks, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS});
       });
   };
 
   // bookmark チェック用
   bookmarkSchema.statics.findByPageIdAndUserId = function(pageId, userId) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
       return Bookmark.findOne({ page: pageId, user: userId }, function(err, doc) {

+ 18 - 4
lib/models/config.js

@@ -64,6 +64,8 @@ module.exports = function(crowi) {
       'security:passport-ldap:groupSearchFilter' : undefined,
       'security:passport-ldap:groupDnProperty' : undefined,
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
+      'security:passport-google:isEnabled' : false,
+      'security:passport-github:isEnabled' : false,
 
       'aws:bucket'          : 'growi',
       'aws:region'          : 'ap-northeast-1',
@@ -99,7 +101,7 @@ module.exports = function(crowi) {
 
   function getDefaultMarkdownConfigs() {
     return {
-      'markdown:isEnabledLinebreaks': true,
+      'markdown:isEnabledLinebreaks': false,
       'markdown:isEnabledLinebreaksInComments': true,
     };
   }
@@ -267,6 +269,16 @@ module.exports = function(crowi) {
     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.isSameUsernameTreatedAsIdenticalUser = function(config, providerType) {
     const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
     return getValueForCrowiNS(config, key);
@@ -305,7 +317,7 @@ module.exports = function(crowi) {
 
     // return default value if undefined
     if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
+      return getDefaultMarkdownConfigs()[key];
     }
 
     return config.markdown[key];
@@ -316,7 +328,7 @@ module.exports = function(crowi) {
 
     // return default value if undefined
     if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
+      return getDefaultMarkdownConfigs()[key];
     }
 
     return config.markdown[key];
@@ -460,11 +472,13 @@ module.exports = function(crowi) {
       },
       behaviorType: Config.behaviorType(config),
       layoutType: Config.layoutType(config),
-      isEnabledLineBreaks: Config.isEnabledLinebreaks(config),
+      isEnabledLinebreaks: Config.isEnabledLinebreaks(config),
+      isEnabledLinebreaksInComments: Config.isEnabledLinebreaksInComments(config),
       highlightJsStyleBorder: Config.highlightJsStyleBorder(config),
       isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
+        BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
         MATHJAX: env.MATHJAX || null,
       },
     };

+ 27 - 13
lib/models/page-group-relation.js

@@ -45,6 +45,27 @@ class PageGroupRelation {
     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 => relation._id);
+        return this.deleteMany({ _id: { $in: ids }});
+      });
+  }
+
   /**
    * find all page and group relation
    *
@@ -57,6 +78,7 @@ class PageGroupRelation {
     return this
       .find()
       .populate('targetPage')
+      .populate('relatedGroup')
       .exec();
   }
 
@@ -188,18 +210,7 @@ class PageGroupRelation {
    * @memberof PageGroupRelation
    */
   static removeAllByUserGroup(userGroup) {
-
-    return this.findAllRelationForUserGroup(userGroup)
-      .then((relations) => {
-        if (relations == null) {
-          return;
-        }
-        else {
-          relations.map((relation) => {
-            relation.remove();
-          });
-        }
-      });
+    return this.deleteMany({ relatedGroup: userGroup });
   }
 
   /**
@@ -240,10 +251,13 @@ class PageGroupRelation {
         }
       });
   }
+
 }
 
 module.exports = function(crowi) {
   PageGroupRelation.crowi = crowi;
   schema.loadClass(PageGroupRelation);
-  return mongoose.model('PageGroupRelation', schema);
+  const model = mongoose.model('PageGroupRelation', schema);
+  model.init();
+  return model;
 };

+ 22 - 33
lib/models/page.js

@@ -37,8 +37,6 @@ module.exports = function(crowi) {
     grant: { type: Number, default: GRANT_PUBLIC, index: true },
     grantedUsers: [{ type: ObjectId, ref: 'User' }],
     creator: { type: ObjectId, ref: 'User', index: true },
-    // lastUpdateUser: this schema is from 1.5.x (by deletion feature), and null is default.
-    // the last update user on the screen is by revesion.author for B.C.
     lastUpdateUser: { type: ObjectId, ref: 'User', index: true },
     liker: [{ type: ObjectId, ref: 'User', index: true }],
     seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
@@ -263,8 +261,8 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.populatePageData = function(pageData, revisionId) {
-    var Page = crowi.model('Page');
-    var User = crowi.model('User');
+    const Page = crowi.model('Page');
+    const User = crowi.model('User');
 
     pageData.latestRevision = pageData.revision;
     if (revisionId) {
@@ -491,8 +489,8 @@ module.exports = function(crowi) {
 
   // find page and check if granted user
   pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
-    var self = this;
-    var PageGroupRelation = crowi.model('PageGroupRelation');
+    const self = this;
+    const PageGroupRelation = crowi.model('PageGroupRelation');
 
     return new Promise(function(resolve, reject) {
       self.findOne({path: path}, function(err, pageData) {
@@ -505,7 +503,7 @@ module.exports = function(crowi) {
             return resolve(null);
           }
 
-          var pageNotFoundError = new Error('Page Not Found');
+          const pageNotFoundError = new Error('Page Not Found');
           pageNotFoundError.name = 'Crowi:Page:NotFound';
           return reject(pageNotFoundError);
         }
@@ -641,12 +639,12 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.findListByPageIds = function(ids, options) {
-    var Page = this;
-    var User = crowi.model('User');
-    var options = options || {}
-      , limit = options.limit || 50
+    const Page = this;
+    const User = crowi.model('User');
+    const limit = options.limit || 50
       , offset = options.skip || 0
       ;
+    options = options || {};
 
     return new Promise(function(resolve, reject) {
       Page
@@ -663,7 +661,7 @@ module.exports = function(crowi) {
           return reject(err);
         }
 
-        Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+        Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
           if (err) {
             return reject(err);
           }
@@ -715,7 +713,7 @@ module.exports = function(crowi) {
       .populate('revision')
       .exec()
       .then(function(pages) {
-        return Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}).then(resolve);
+        return Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS}).then(resolve);
       });
     });
   };
@@ -796,7 +794,7 @@ module.exports = function(crowi) {
 
       q.exec()
         .then(function(pages) {
-          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS})
+          Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS})
           .then(resolve)
           .catch(reject);
         });
@@ -1053,32 +1051,23 @@ module.exports = function(crowi) {
       });
   };
 
-  pageSchema.statics.updatePage = function(pageData, body, user, options) {
+  pageSchema.statics.updatePage = async function(pageData, body, user, options) {
     var Page = this
       , Revision = crowi.model('Revision')
       , grant = options.grant || null
       , grantUserGroupId = options.grantUserGroupId || null
       ;
     // update existing page
-    var newRevision = Revision.prepareRevision(pageData, body, user);
+    var newRevision = await Revision.prepareRevision(pageData, body, user);
 
-    let savedPage = undefined;
-    return Page.pushRevision(pageData, newRevision, user)
-      .then((revision) => {
-        // fetch Page
-        return Page.findPageByPath(revision.path).populate('revision');
-      })
-      .then((page) => {
-        savedPage = page;
-      })
-      .then(() => {
-        return Page.updateGrant(savedPage, grant, user, grantUserGroupId);
-      })
-      .then((data) => {
-        debug('Page grant update:', data);
-        pageEvent.emit('update', savedPage, user);
-        return savedPage;
-      });
+    const revision = await Page.pushRevision(pageData, newRevision, user);
+    const savedPage = await Page.findPageByPath(revision.path).populate('revision');
+    if (grant != null) {
+      const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
+      debug('Page grant update:', grantData);
+    }
+    pageEvent.emit('update', savedPage, user);
+    return savedPage;
   };
 
   pageSchema.statics.deletePage = function(pageData, user, options) {

+ 16 - 13
lib/models/revision.js

@@ -3,14 +3,17 @@ module.exports = function(crowi) {
   const logger = require('@alias/logger')('growi:models:revision');
   /* eslint-enable */
 
-  var mongoose = require('mongoose')
+  const mongoose = require('mongoose')
     , ObjectId = mongoose.Schema.Types.ObjectId
-    , revisionSchema;
+    ;
 
-
-  revisionSchema = new mongoose.Schema({
+  const revisionSchema = new mongoose.Schema({
     path: { type: String, required: true },
-    body: { type: String, required: true },
+    body: { type: String, required: true, get: (data) => {
+      // replace CR/CRLF to LF above v3.1.5
+      // see https://github.com/weseek/growi/issues/463
+      return data.replace(/\r\n?/g, '\n');
+    }},
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },
     createdAt: { type: Date, default: Date.now }
@@ -38,7 +41,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevision = function(id) {
-    var Revision = this;
+    const Revision = this;
 
     return new Promise(function(resolve, reject) {
       Revision.findById(id)
@@ -54,7 +57,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevisions = function(ids) {
-    var Revision = this,
+    const Revision = this,
       User = crowi.model('User');
 
     if (!Array.isArray(ids)) {
@@ -84,7 +87,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevisionList = function(path, options) {
-    var Revision = this,
+    const Revision = this,
       User = crowi.model('User');
 
     return new Promise(function(resolve, reject) {
@@ -102,7 +105,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options) {
-    var Revision = this;
+    const Revision = this;
 
     return new Promise(function(resolve, reject) {
       Revision.update({path: path}, {$set: updateData}, {multi: true}, function(err, data) {
@@ -116,18 +119,18 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.prepareRevision = function(pageData, body, user, options) {
-    var Revision = this;
+    const Revision = this;
 
     if (!options) {
       options = {};
     }
-    var format = options.format || 'markdown';
+    const format = options.format || 'markdown';
 
     if (!user._id) {
       throw new Error('Error: user should have _id');
     }
 
-    var newRevision = new Revision();
+    const newRevision = new Revision();
     newRevision.path = pageData.path;
     newRevision.body = body;
     newRevision.format = format;
@@ -138,7 +141,7 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.removeRevisionsByPath = function(path) {
-    var Revision = this;
+    const Revision = this;
 
     return new Promise(function(resolve, reject) {
       Revision.remove({path: path}, function(err, data) {

+ 31 - 14
lib/models/user-group-relation.js

@@ -40,6 +40,27 @@ class UserGroupRelation {
     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.relatedUser == null || relation.relatedGroup == null;
+        });
+      })
+      .then(invalidRelations => {
+        const ids = invalidRelations.map(relation => relation._id);
+        return this.deleteMany({ _id: { $in: ids }});
+      });
+  }
+
   /**
    * find all user and group relation
    *
@@ -99,7 +120,12 @@ class UserGroupRelation {
     return this
       .find({ relatedUser: user.id })
       .populate('relatedGroup')
-      .exec();
+      // filter documents only relatedGroup is not null
+      .then(userGroupRelations => {
+        return userGroupRelations.filter(relation => {
+          return relation.relatedGroup != null;
+        });
+      });
   }
 
   /**
@@ -223,18 +249,7 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    */
   static removeAllByUserGroup(userGroup) {
-
-    return this.findAllRelationForUserGroup(userGroup)
-      .then((relations) => {
-        if (relations == null) {
-          return;
-        }
-        else {
-          relations.map((relation) => {
-            relation.remove();
-          });
-        }
-      });
+    return this.deleteMany({ relatedGroup: userGroup });
   }
 
   /**
@@ -263,5 +278,7 @@ class UserGroupRelation {
 module.exports = function(crowi) {
   UserGroupRelation.crowi = crowi;
   schema.loadClass(UserGroupRelation);
-  return mongoose.model('UserGroupRelation', schema);
+  const model = mongoose.model('UserGroupRelation', schema);
+  model.init();
+  return model;
 };

+ 18 - 5
lib/models/user-group.js

@@ -100,21 +100,34 @@ class UserGroup {
 
   // グループの完全削除
   static removeCompletelyById(id) {
+    const PageGroupRelation = mongoose.model('PageGroupRelation');
+    const UserGroupRelation = mongoose.model('UserGroupRelation');
 
+    let removed = undefined;
     return this.findById(id)
-      .then((userGroupData) => {
+      .then(userGroupData => {
         if (userGroupData == null) {
           throw new Exception('UserGroup data is not exists. id:', id);
         }
-        else {
-          return userGroupData.remove();
-        }
+        return userGroupData.remove();
+      })
+      .then(removedUserGroupData => {
+        removed = removedUserGroupData;
+      })
+      // remove relations
+      .then(() => {
+        return Promise.all([
+          UserGroupRelation.removeAllByUserGroup(removed),
+          PageGroupRelation.removeAllByUserGroup(removed),
+        ]);
+      })
+      .then(() => {
+        return removed;
       });
   }
 
   // グループ生成(名前が要る)
   static createGroupByName(name) {
-
     return this.create({name: name});
   }
 

+ 91 - 12
lib/routes/admin.js

@@ -137,8 +137,8 @@ module.exports = function(crowi, app) {
     settingForm = Config.setupCofigFormData('crowi', req.config);
 
     const highlightJsCssSelectorOptions = {
-      'github':           { name: '[Light] Github',         border: false },
-      'github-gist':      { name: '[Light] Github Gist',    border: true },
+      'github':           { name: '[Light] GitHub',         border: false },
+      'github-gist':      { name: '[Light] GitHub Gist',    border: true },
       'atom-one-light':   { name: '[Light] Atom One Light', border: true },
       'xcode':            { name: '[Light] Xcode',          border: true },
       'vs':               { name: '[Light] Vs',             border: true },
@@ -771,16 +771,27 @@ module.exports = function(crowi, app) {
   actions.userGroup.removeCompletely = function(req, res) {
     const id = req.body.user_group_id;
 
+    const fileUploader = require('../util/fileUploader')(crowi, app);
+
     UserGroup.removeCompletelyById(id)
-    .then(() => {
-      req.flash('successMessage', '削除しました');
-      return res.redirect('/admin/user-groups');
-    })
-    .catch((err) => {
-      debug('Error while removing userGroup.', err, id);
-      req.flash('errorMessage', '完全な削除に失敗しました。');
-      return res.redirect('/admin/user-groups');
-    });
+      //// TODO remove attachments
+      // couldn't remove because filePath includes '/uploads/uploads'
+      // Error: ENOENT: no such file or directory, unlink 'C:\dev\growi\public\uploads\uploads\userGroup\5b1df18ab69611651cc71495.png
+      //
+      // .then(removed => {
+      //   if (removed.image != null) {
+      //     fileUploader.deleteFile(null, removed.image);
+      //   }
+      // })
+      .then(() => {
+        req.flash('successMessage', '削除しました');
+        return res.redirect('/admin/user-groups');
+      })
+      .catch((err) => {
+        debug('Error while removing userGroup.', err, id);
+        req.flash('errorMessage', '完全な削除に失敗しました。');
+        return res.redirect('/admin/user-groups');
+      });
   };
 
   actions.userGroupRelation = {};
@@ -903,8 +914,76 @@ module.exports = function(crowi, app) {
       });
   };
 
+  actions.api.securityPassportGoogleSetting = 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 saveSettingAsync(form);
+    const config = await crowi.getConfig();
+
+    // reset strategy
+    await crowi.passportService.resetGoogleStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportGoogle(config)) {
+      try {
+        await crowi.passportService.setupGoogleStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetGoogleStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
+
+  actions.api.securityPassportGitHubSetting = 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 saveSettingAsync(form);
+    const config = await crowi.getConfig();
+
+    // reset strategy
+    await crowi.passportService.resetGitHubStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportGoogle(config)) {
+      try {
+        await crowi.passportService.setupGitHubStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetGoogleStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
+
   actions.api.customizeSetting = function(req, res) {
-    var form = req.form.settingForm;
+    const form = req.form.settingForm;
+
+    if (req.form.isValid) {
+      debug('form content', form);
+      return saveSetting(req, res, form);
+    }
+    else {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+  };
+
+  actions.api.customizeSetting = function(req, res) {
+    const form = req.form.settingForm;
 
     if (req.form.isValid) {
       debug('form content', form);

+ 9 - 9
lib/routes/attachment.js

@@ -1,11 +1,11 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routs:attachment')
+  var debug = require('debug')('growi:routss:attachment')
+    , logger = require('@alias/logger')('growi:routes:attachment')
     , Attachment = crowi.model('Attachment')
     , User = crowi.model('User')
     , Page = crowi.model('Page')
-    , config = crowi.getConfig()
     , path = require('path')
     , fs = require('fs')
     , fileUploader = require('../util/fileUploader')(crowi, app)
@@ -42,7 +42,7 @@ module.exports = function(crowi, app) {
       })
       // not found
       .catch((err) => {
-        debug('download err', err);
+        logger.error('download err', err);
         return res.status(404).sendFile(crowi.publicDir + '/images/file-not-found.png');
       });
   };
@@ -140,7 +140,6 @@ module.exports = function(crowi, app) {
           return Attachment.create(id, req.user, filePath, originalName, fileName, fileType, fileSize);
         }).then(function(data) {
           var fileUrl = data.fileUrl;
-          var config = crowi.getConfig();
 
           var result = {
             page: page.toObject(),
@@ -157,18 +156,18 @@ module.exports = function(crowi, app) {
 
           return res.json(ApiResponse.success(result));
         }).catch(function(err) {
-          debug('Error on saving attachment data', err);
+          logger.error('Error on saving attachment data', err);
           // @TODO
           // Remove from S3
 
           // delete anyway
-          fs.unlink(tmpPath, function(err) { if (err) { debug('Error while deleting tmp file.') } });
+          fs.unlink(tmpPath, function(err) { if (err) { logger.error('Error while deleting tmp file.') } });
 
           return res.json(ApiResponse.error('Error while uploading.'));
         });
-      
+
     }).catch(function(err) {
-      debug('Attachement upload error', err);
+      logger.error('Attachement upload error', err);
       return res.json(ApiResponse.error('Error.'));
     });
   };
@@ -192,10 +191,11 @@ module.exports = function(crowi, app) {
         debug('removeAttachment', data);
         return res.json(ApiResponse.success({}));
       }).catch(err => {
+        logger.error('Error', err);
         return res.status(500).json(ApiResponse.error('Error while deleting file'));
       });
     }).catch(err => {
-      debug('Error', err);
+      logger.error('Error', err);
       return res.status(404);
     });
   };

+ 21 - 17
lib/routes/comment.js

@@ -1,9 +1,8 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routs:comment')
+  const debug = require('debug')('growi:routs:comment')
     , Comment = crowi.model('Comment')
-    , User = crowi.model('User')
     , Page = crowi.model('Page')
     , ApiResponse = require('../util/apiResponse')
     , actions = {}
@@ -20,8 +19,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} revision_id Revision Id.
    */
   api.get = function(req, res) {
-    var pageId = req.query.page_id;
-    var revisionId = req.query.revision_id;
+    const pageId = req.query.page_id;
+    const revisionId = req.query.revision_id;
 
     if (revisionId) {
       return Comment.getCommentsByRevisionId(revisionId)
@@ -50,27 +49,32 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment Comment body
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
-  api.add = function(req, res) {
-    var form = req.form.commentForm;
+  api.add = async function(req, res) {
+    const form = req.form.commentForm;
 
     if (!req.form.isValid) {
       // return res.json(ApiResponse.error('Invalid comment.'));
       return res.json(ApiResponse.error('コメントを入力してください。'));
     }
 
-    var pageId = form.page_id;
-    var revisionId = form.revision_id;
-    var comment = form.comment;
-    var position = form.comment_position || -1;
-    var isMarkdown = form.is_markdown;
+    const pageId = form.page_id;
+    const revisionId = form.revision_id;
+    const comment = form.comment;
+    const position = form.comment_position || -1;
+    const isMarkdown = form.is_markdown;
 
-    return Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
-      .then(function(createdComment) {
-        createdComment.creator = req.user;
-        return res.json(ApiResponse.success({comment: createdComment}));
-      }).catch(function(err) {
+    const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
+      .catch(function(err) {
         return res.json(ApiResponse.error(err));
       });
+
+    // update page
+    await Page.findOneAndUpdate({ _id: pageId }, {
+      lastUpdateUser: req.user,
+      updatedAt: new Date()
+    });
+
+    return res.json(ApiResponse.success({comment: createdComment}));
   };
 
   /**
@@ -81,7 +85,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment_id Comment Id.
    */
   api.remove = function(req, res) {
-    var commentId = req.body.comment_id;
+    const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
     }

+ 8 - 0
lib/routes/index.js

@@ -67,6 +67,14 @@ module.exports = function(crowi, app) {
   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);
 
+  // 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.get('/passport/google'                      , loginPassport.loginPassportGoogle);
+  app.get('/passport/github'                      , loginPassport.loginPassportGitHub);
+  app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
+  app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
+
   // 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);

+ 155 - 67
lib/routes/login-passport.js

@@ -66,7 +66,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    * @param {*} next
    */
-  const loginWithLdap = (req, res, next) => {
+  const loginWithLdap = async(req, res, next) => {
     if (!passportService.isLdapStrategySetup) {
       debug('LdapStrategy has not been set up');
       return next();
@@ -78,77 +78,43 @@ module.exports = function(crowi, app) {
       });
     }
 
-    passport.authenticate('ldapauth', (err, ldapAccountInfo, info) => {
-      if (res.headersSent) {  // dirty hack -- 2017.09.25
-        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
-      }
-
-      debug('--- authenticate with LdapStrategy ---');
-      debug('ldapAccountInfo', ldapAccountInfo);
-      debug('info', info);
+    const providerId = 'ldap';
+    const strategyName = 'ldapauth';
+    const ldapAccountInfo = await promisifiedPassportAuthentication(req, res, next, strategyName);
 
-      if (err) {  // DB Error
-        logger.error('LDAP Server Error: ', err);
-        req.flash('warningMessage', 'LDAP Server Error occured.');
-        return next(); // pass and the flash message is displayed when all of authentications are failed.
-      }
-
-      // authentication failure
-      if (!ldapAccountInfo) { return next() }
-      // check groups
-      if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
-        return loginFailure(req, res, next);
-      }
+    // check groups for LDAP
+    if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
+      return loginFailure(req, res, next);
+    }
 
-      /*
-       * authentication success
-       */
-      // it is guaranteed that username that is input from form can be acquired
-      // because this processes after authentication
-      const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+    /*
+      * authentication success
+      */
+    // it is guaranteed that username that is input from form can be acquired
+    // because this processes after authentication
+    const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+    const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
+    const attrMapName = passportService.getLdapAttrNameMappedToName();
+    const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
+    const nameToBeRegistered = ldapAccountInfo[attrMapName];
+    const userInfo = {
+      'id': ldapAccountId,
+      'username': usernameToBeRegistered,
+      'name': nameToBeRegistered
+    }
 
-      const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
-      const attrMapName = passportService.getLdapAttrNameMappedToName();
-      const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
-      const nameToBeRegistered = ldapAccountInfo[attrMapName];
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
 
-      // find or register(create) user
-      ExternalAccount.findOrRegister('ldap', ldapAccountId, usernameToBeRegistered, nameToBeRegistered)
-        .catch((err) => {
-          if (err.name === 'DuplicatedUsernameException') {
-            // get option
-            const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, 'ldap');
-            if (isSameUsernameTreatedAsIdenticalUser) {
-              // associate to existing user
-              debug(`ExternalAccount '${ldapAccountId}' will be created and bound to the exisiting User account`);
-              return ExternalAccount.associate('ldap', ldapAccountId, err.user);
-            }
-          }
-          throw err;  // throw again
-        })
-        .then((externalAccount) => {
-          return externalAccount.getPopulatedUser();
-        })
-        .then((user) => {
-          // login
-          req.logIn(user, (err) => {
-            if (err) { return next() }
-            else {
-              return loginSuccess(req, res, user);
-            }
-          });
-        })
-        .catch((err) => {
-          if (err.name === 'DuplicatedUsernameException') {
-            req.flash('isDuplicatedUsernameExceptionOccured', true);
-            return next();
-          }
-          else {
-            return next(err);
-          }
-        });
+    const user = await externalAccount.getPopulatedUser();
 
-    })(req, res, next);
+    // login
+    await req.logIn(user, err => {
+      if (err) { return next(err) };
+      return loginSuccess(req, res, user);
+    });
   };
 
   /**
@@ -239,10 +205,132 @@ module.exports = function(crowi, app) {
     })(req, res, next);
   };
 
+  const loginPassportGoogle = function(req, res) {
+    if (!passportService.isGoogleStrategySetup) {
+      debug('GoogleStrategy has not been set up');
+      return;
+    }
+
+    passport.authenticate('google', {
+      scope: ['profile', 'email'],
+    })(req, res);
+  };
+
+  const loginPassportGoogleCallback = async(req, res, next) => {
+    const providerId = 'google';
+    const strategyName = 'google';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response.id,
+      'username': response.displayName,
+      'name': `${response.name.givenName} ${response.name.familyName}`
+    }
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) };
+      return loginSuccess(req, res, user);
+    });
+  };
+
+  const loginPassportGitHub = function(req, res) {
+    if (!passportService.isGitHubStrategySetup) {
+      debug('GitHubStrategy has not been set up');
+      return;
+    }
+
+    passport.authenticate('github')(req, res);
+  };
+
+  const loginPassportGitHubCallback = async(req, res, next) => {
+    const providerId = 'github';
+    const strategyName = 'github';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response.id,
+      'username': response.username,
+      'name': response.displayName
+    }
+
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) };
+      return loginSuccess(req, res, user);
+    });
+  };
+
+  const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
+    return new Promise((resolve, reject) => {
+      passport.authenticate(strategyName, (err, response, info) => {
+        if (res.headersSent) {  // dirty hack -- 2017.09.25
+          return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+        }
+
+        if (err) {
+          logger.error(`'${strategyName}' passport authentication error: `, err);
+          req.flash('warningMessage', `Error occured in '${strategyName}' passport authentication`);
+          return next(); // pass and the flash message is displayed when all of authentications are failed.
+        }
+
+        // authentication failure
+        if (!response) {
+          return next();
+        }
+
+        resolve(response)
+      })(req, res, next);
+    });
+  };
+
+  const getOrCreateUser = async(req, res, next, userInfo, providerId) => {
+    try {
+      // find or register(create) user
+      const externalAccount = await ExternalAccount.findOrRegister(
+        providerId,
+        userInfo.id,
+        userInfo.username,
+        userInfo.name
+      );
+      return externalAccount;
+    }
+    catch (err) {
+      if (err.name === 'DuplicatedUsernameException') {
+        // get option
+        const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, providerId);
+        if (isSameUsernameTreatedAsIdenticalUser) {
+          // associate to existing user
+          debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
+          return ExternalAccount.associate(providerId, userInfo.id, err.user);
+        }
+        else {
+          req.flash('provider-DuplicatedUsernameException', providerId);
+          return;
+        }
+      }
+    }
+  }
+
   return {
     loginFailure,
     loginWithLdap,
     testLdapCredentials,
     loginWithLocal,
+    loginPassportGoogle,
+    loginPassportGitHub,
+    loginPassportGoogleCallback,
+    loginPassportGitHubCallback,
   };
 };

+ 11 - 11
lib/routes/page.js

@@ -607,11 +607,16 @@ module.exports = function(crowi, app) {
 
   actions.pageEdit = function(req, res) {
 
-    var pageForm = req.body.pageForm;
+    if (!req.form.isValid) {
+      req.flash('dangerMessage', 'Request is invalid.');
+      return res.redirect(req.headers.referer);
+    }
+
+    var pageForm = req.form.pageForm;
+    var path = pageForm.path;
     var body = pageForm.body;
     var currentRevision = pageForm.currentRevision;
     var grant = pageForm.grant;
-    var path = pageForm.path;
     var grantUserGroupId = pageForm.grantUserGroupId;
 
     // TODO: make it pluggable
@@ -638,15 +643,10 @@ module.exports = function(crowi, app) {
     .then(function(data) {
       pageData = data;
 
-      if (!req.form.isValid) {
-        debug('Form data not valid');
-        throw new Error('Form data not valid.');
-      }
-
       if (data && !data.isUpdatable(currentRevision)) {
         debug('Conflict occured');
-        req.form.errors.push('page_edit.notice.conflict');
-        throw new Error('Conflict.');
+        req.flash('dangerMessage', 'Conflict occured');
+        return res.redirect(req.headers.referer);
       }
 
       if (data) {
@@ -834,8 +834,8 @@ module.exports = function(crowi, app) {
         throw new Error('Revision error.');
       }
 
-      var grantOption = {grant: pageData.grant};
-      if (grant !== null) {
+      var grantOption = {};
+      if (grant != null) {
         grantOption.grant = grant;
       }
       if (grantUserGroupId != null) {

+ 3 - 3
lib/routes/revision.js

@@ -41,7 +41,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id      Page Id.
    */
   actions.api.ids = function(req, res) {
-    var pageId = req.query.page_id || null;
+    const pageId = req.query.page_id || null;
 
     if (pageId && crowi.isPageId(pageId)) {
       Page.findPageByIdAndGrantedUser(pageId, req.user)
@@ -68,8 +68,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id      Page Id.
    */
   actions.api.list = function(req, res) {
-    var revisionIds = (req.query.revision_ids || '').split(',');
-    var pageId = req.query.page_id || null;
+    const revisionIds = (req.query.revision_ids || '').split(',');
+    const pageId = req.query.page_id || null;
 
     if (pageId) {
       Page.findPageByIdAndGrantedUser(pageId, req.user)

+ 21 - 2
lib/service/logger/index.js

@@ -4,13 +4,32 @@ const minimatch = require('minimatch');
 const isBrowser = typeof window !== 'undefined';
 const isProd = process.env.NODE_ENV === 'production';
 
-const config = require('@root/config').logger;
-
+let config = require('@root/config').logger;
 let stream = isProd ? require('./stream.prod') : require('./stream.dev');
 
 // logger store
 let loggers = {};
 
+
+// merge configuration from environment variables
+const envLevelMap = {
+  INFO:   'info',
+  DEBUG:  'debug',
+  WARN:   'warn',
+  TRACE:  'trace',
+  ERROR:  'error',
+};
+Object.keys(envLevelMap).forEach(envName => {   // ['INFO', 'DEBUG', ...].forEach
+  const envVars = process.env[envName];         // process.env.DEBUG should have a value like 'growi:routes:page,growi:models.page,...'
+  if (envVars != null) {
+    const level = envLevelMap[envName];
+    envVars.split(',').forEach(ns => {          // ['growi:routes:page', 'growi:models.page', ...].forEach
+      config[ns.trim()] = level;
+    });
+  }
+});
+
+
 /**
  * determine logger level
  * @param {string} name Logger name

+ 105 - 1
lib/service/passport.js

@@ -2,6 +2,8 @@ const debug = require('debug')('growi:service:PassportService');
 const passport = require('passport');
 const LocalStrategy = require('passport-local').Strategy;
 const LdapStrategy = require('passport-ldapauth');
+const GoogleStrategy = require('passport-google-auth').Strategy;
+const GitHubStrategy = require('passport-github').Strategy;
 
 /**
  * the service class of Passport
@@ -25,6 +27,11 @@ class PassportService {
      */
     this.isLdapStrategySetup = false;
 
+    /**
+     * the flag whether LdapStrategy is set up successfully
+     */
+    this.isGoogleStrategySetup = false;
+
     /**
      * the flag whether serializer/deserializer are set up successfully
      */
@@ -181,7 +188,7 @@ class PassportService {
 
     // parse serverUrl
     // see: https://regex101.com/r/0tuYBB/1
-    const match = serverUrl.match(/(ldaps?:\/\/[^\/]+)\/(.*)?/);
+    const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     if (match == null || match.length < 1) {
       debug('LdapStrategy: serverUrl is invalid');
       return (req, callback) => { callback({ message: 'serverUrl is invalid'}) };
@@ -235,6 +242,103 @@ class PassportService {
     };
   }
 
+  /**
+   * Asynchronous configuration retrieval
+   *
+   * @memberof PassportService
+   */
+  setupGoogleStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isGoogleStrategySetup) {
+      throw new Error('GoogleStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    //this
+    const isGoogleEnabled = Config.isEnabledPassportGoogle(config);
+
+    // when disabled
+    if (!isGoogleEnabled) {
+      return;
+    }
+
+    debug('GoogleStrategy: setting up..');
+    passport.use(new GoogleStrategy({
+      clientId: config.crowi['security:passport-google:clientId'],
+      clientSecret: config.crowi['security:passport-google:clientSecret'],
+      callbackURL: 'http://localhost:3000/passport/google/callback',  //change this
+      skipUserProfile: false,
+    }, function(accessToken, refreshToken, profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isGoogleStrategySetup = true;
+    debug('GoogleStrategy: setup is done');
+  }
+
+  /**
+   * reset GoogleStrategy
+   *
+   * @memberof PassportService
+   */
+  resetGoogleStrategy() {
+    debug('GoogleStrategy: reset');
+    passport.unuse('google');
+    this.isGoogleStrategySetup = false;
+  }
+
+  setupGitHubStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isGitHubStrategySetup) {
+      throw new Error('GitHubStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    //this
+    const isGitHubEnabled = Config.isEnabledPassportGitHub(config);
+
+    // when disabled
+    if (!isGitHubEnabled) {
+      return;
+    }
+
+    debug('GitHubStrategy: setting up..');
+    passport.use(new GitHubStrategy({
+      clientID: config.crowi['security:passport-github:clientId'],
+      clientSecret: config.crowi['security:passport-github:clientSecret'],
+      callbackURL: 'http://localhost:3000/passport/github/callback',  //change this
+      skipUserProfile: false,
+    }, function(accessToken, refreshToken, profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isGitHubStrategySetup = true;
+    debug('GitHubStrategy: setup is done');
+  }
+
+  /**
+   * reset GoogleStrategy
+   *
+   * @memberof PassportService
+   */
+  resetGitHubStrategy() {
+    debug('GitHubStrategy: reset');
+    passport.unuse('github');
+    this.isGitHubStrategySetup = false;
+  }
+
   /**
    * setup serializer and deserializer
    *

+ 10 - 0
lib/util/swigFunctions.js

@@ -92,6 +92,16 @@ module.exports = function(crowi, app, req, locals) {
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];
   };
 
+  locals.passportGoogleLoginEnabled = function() {
+    var config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-google:isEnabled'];
+  };
+
+  locals.passportGitHubLoginEnabled = function() {
+    var config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-github:isEnabled'];
+  };
+
   locals.searchConfigured = function() {
     if (crowi.getSearcher()) {
       return true;

+ 1 - 1
lib/views/_form.html

@@ -1,5 +1,5 @@
 {% block html_head_loading_legacy %}
-  <script src="{{ webpack_asset('legacy-form').js }}" defer></script>
+  <script src="{{ webpack_asset('js/legacy-form.js') }}" defer></script>
   {% parent %}
 {% endblock %}
 

+ 5 - 5
lib/views/admin/customize.html

@@ -4,11 +4,11 @@
 
 {% block style_css_block %}
   {% if env === 'development' %}
-    <script src="{{ webpack_asset('style').js }}"></script>
-    <script src="{{ webpack_asset('style-theme-' + theme()).js }}"></script>
+    <script src="{{ webpack_asset('styles/style.js') }}"></script>
+    <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
   {% else %}
-    <link rel="stylesheet" href="{{ webpack_asset('style').css }}">
-    <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('style-theme-' + theme()).css }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
+    <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
   {% endif %}
 {% endblock %}
 
@@ -298,7 +298,7 @@
           </div>
 
           <div class="form-group">
-            <label for="settingForm[customize:highlightJsStyleBorder]" class="col-xs-3 control-label">(TBD) Border</label>
+            <label for="settingForm[customize:highlightJsStyleBorder]" class="col-xs-3 control-label">Border</label>
             <div class="col-xs-9">
               <div class="btn-group btn-toggle" data-toggle="buttons">
                 <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:highlightJsStyleBorder'] %}active{% endif %}" data-active-class="primary" onclick="selectBorderOn()">

+ 1 - 1
lib/views/admin/index.html

@@ -68,7 +68,7 @@
         <tr>
           <td>{{ pluginName }}</td>
           <td class="text-center">{{ plugins[pluginName] }}</td>
-          <td class="text-center">(TBD)</td>
+          <td class="text-center"><span class="tbd">(TBD)</span></td>
         </tr>
         {% endfor %}
       </table>

+ 3 - 4
lib/views/admin/markdown.html

@@ -57,14 +57,13 @@
                     {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> OFF
               </label>
             </div>
-            <p class="help-block">{{ t("markdown_setting.Enable Line Break desc") }}
-</p>
+            <p class="help-block">{{ t("markdown_setting.Enable Line Break desc") }}</p>
           </div>
         </div>
 
         <div class="form-group">
           <label for="markdownSetting[markdown:isEnabledLinebreaksInComments]" class="col-xs-4 control-label">
-            (TBD)<br>{{ t("markdown_setting.Enable Line Break for comment") }}
+            {{ t("markdown_setting.Enable Line Break for comment") }}
           </label>
           <div class="col-xs-5">
             <div class="btn-group btn-toggle" data-toggle="buttons">
@@ -77,7 +76,7 @@
                     {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> OFF
               </label>
             </div>
-            <p class="help-block">{{ t("markdown_setting.Enable Line Break for comment desc") }}<br>{{ t("markdown_setting.TBD") }}</p>
+            <p class="help-block">{{ t("markdown_setting.Enable Line Break for comment desc") }}</p>
           </div>
         </div>
 

+ 16 - 13
lib/views/admin/security.html

@@ -111,7 +111,7 @@
                   <input type="radio" id="radioPassportAuthMech" name="settingForm[security:isEnabledPassport]" value="true"
                       {% if true === settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
                   <label for="radioPassportAuthMech">
-                    <a href="http://passportjs.org/">
+                    <a href="http://passportjs.org/" target="_blank">
                       <img src="/images/admin/security/passport-logo.svg" class="passport-logo"> Passport
                     </a> {{ t("security_setting.auth_mechanism") }} <small class="text-success">({{ t("security_setting.recommended") }})</small>
                   </label>
@@ -120,10 +120,10 @@
               <ul>
                 <li>{{ t("security_setting.username_email_password") }}</li>
                 <li>{{ t("security_setting.ldap_auth") }}</li>
-                <li class="text-muted">(TBD) <del>{{ t("security_setting.google_auth2") }}</del></li>
+                <li>{{ t("security_setting.google_auth2") }}</li>
+                <li>{{ t("security_setting.github_auth2") }}</li>
                 <li class="text-muted">(TBD) <del>{{ t("security_setting.facebook_auth2") }}</del></li>
                 <li class="text-muted">(TBD) <del>{{ t("security_setting.twitter_auth2") }}</del></li>
-                <li class="text-muted">(TBD) <del>{{ t("security_setting.github_auth2") }}</del></li>
               </ul>
             </div>
             <div class="col-xs-6">
@@ -138,7 +138,10 @@
               </h4>
               <ul>
                 <li>{{ t("security_setting.username_email_password") }}</li>
-                <li>{{ t("security_setting.google_auth2") }}</li>
+                <li class="text-muted">
+                  {{ t("security_setting.google_auth2") }}
+                  <ul><li>{{ t("security_setting.google_auth2_by_crowi_desc") }}</li></ul>
+                </li>
               </ul>
             </div>
           </div>
@@ -179,7 +182,7 @@
               </p>
 
               <ol class="help-block">
-                <li>{{ t("security_setting.access_api_manager") }}</li>
+                <li>{{ t("security_setting.access_api_manager", "https://console.cloud.google.com/apis/credentials", "API Manager") }}</li>
                 <li>{{ t("security_setting.create_project") }}</li>
                 <li>{{ t("security_setting.create_auth_to_oauth") }}</li>
                 <ol>
@@ -230,19 +233,19 @@
           </p>
           <ul class="nav nav-tabs" role="tablist" {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
             <li class="active">
-              <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="icon-organization"></i> LDAP</a>
+              <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
             </li>
             <li>
-              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="icon-social-google"></i> Google OAuth</a>
+              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google</a>
             </li>
             <li>
-              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="icon-social-facebook"></i> Facebook</a>
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
             </li>
-            <li>
-              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="icon-social-twitter"></i> Twitter</a>
+            <li class="tbd">
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
-            <li>
-              <a href="#passport-github" data-toggle="tab" role="tab"><i class="icon-social-github"></i> Github</a>
+            <li class="tbd">
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> (TBD) Twitter</a>
             </li>
           </ul>
 
@@ -276,7 +279,7 @@
   </div>
 
   <script>
-    $('#generalSetting, #googleSetting, #mechanismSetting').each(function() {
+    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

+ 88 - 4
lib/views/admin/widget/passport/github.html

@@ -1,6 +1,90 @@
-<form action="" method="post" class="form-horizontal passportStrategy" id="githubOauthSetting" role="form">
-  <fieldset>
-    <legend>Github OAuth {{ t("security_setting.configuration") }}</legend>
-    <p class="well">(TBD)</p>
+<form action="/_api/admin/security/passport-github" method="post" class="form-horizontal passportStrategy" id="githubSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.OAuth.GitHub.name") }}{{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsGitHubEnabled = "settingForm[security:passport-github:isEnabled]" %}
+  {% set isGitHubEnabled = settingForm['security:passport-github:isEnabled'] %}
+
+  <div class="form-group">
+    <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isGitHubEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsGitHubEnabled}}" value="true" type="radio"
+              {% if true === isGitHubEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isGitHubEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsGitHubEnabled}}" value="false" type="radio"
+              {% if !isGitHubEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-github-hide-when-disabled" {%if !isGitHubEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-github:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-github:clientId]" value="{{ settingForm['security:passport-github:clientId'] || '' }}">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-github:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-github:clientSecret]" value="{{ settingForm['security:passport-github:clientSecret'] || '' }}">
+      </div>
+    </div>
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-GitHub" name="settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-github:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-GitHub">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
   </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
 </form>
+
+{# Help Section #}
+<hr>
+<h4>
+  <i class="fa fa-question-circle" aria-hidden="true"></i>
+  <a href="#collapseHelpForGithubOauth" data-toggle="collapse">How to configure GitHub OAuth?</a>
+</h4>
+<ol id="collapseHelpForGithubOauth" class="collapse">
+  <li>{{ t("security_setting.OAuth.GitHub.register_1", "https://github.com/settings/developers", "GitHub Developer Settings") }}</li>
+  <li>{{ t("security_setting.OAuth.GitHub.register_2", "https://${growi.host}/passport/github/callback", "${growi.host}") }}</li>
+  <li>{{ t("security_setting.OAuth.GitHub.register_3") }}</li>
+</ol>
+
+<script>
+  $('input[name="settingForm[security:passport-github:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-github-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-github-hide-when-disabled').hide(400);
+    }
+  });
+</script>
+

+ 83 - 74
lib/views/admin/widget/passport/google-oauth.html

@@ -1,82 +1,91 @@
-<form action="" method="post" class="form-horizontal passportStrategy" id="googleOauthSetting" role="form">
-  <fieldset>
-    <legend>Google OAuth {{ t("security_setting.configuration") }}</legend>
-    <p class="well">(TBD)</p>
+<form action="/_api/admin/security/passport-google" method="post" class="form-horizontal passportStrategy" id="googleSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.OAuth.Google.name") }}{{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsGoogleEnabled = "settingForm[security:passport-google:isEnabled]" %}
+  {% set isGoogleEnabled = settingForm['security:passport-google:isEnabled'] %}
+
+  <div class="form-group">
+    <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isGoogleEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsGoogleEnabled}}" value="true" type="radio"
+              {% if true === isGoogleEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isGoogleEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsGoogleEnabled}}" value="false" type="radio"
+              {% if !isGoogleEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-google-hide-when-disabled" {%if !isGoogleEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-google:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-google:clientId]" value="{{ settingForm['security:passport-google:clientId'] || '' }}">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-google:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-google:clientSecret]" value="{{ settingForm['security:passport-google:clientSecret'] || '' }}">
+      </div>
+    </div>
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-Google" name="settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-google:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-Google">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
   </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
 </form>
 
-{% if false %}
+{# Help Section #}
 <hr>
 <h4>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
-  <a href="#collapseHelpForApp" data-toggle="collapse">How to configure Slack App?</a>
+  <a href="#collapseHelpForGoogleOauth" data-toggle="collapse">How to configure Google OAuth?</a>
 </h4>
-
-<ol id="collapseHelpForApp" class="collapse">
-  <li>
-    Register Slack App
-    <ol>
-      <li>
-        Create App from <a href="https://api.slack.com/applications/new">this link</a>, and fill the form out as below:
-        <dl class="dl-horizontal">
-          <dt>App Name</dt> <dd><code>growi</code> </dd>
-          <dt>Development Slack Team</dt> <dd>Select the team you want to notify to.</dd>
-        </dl>
-      </li>
-      <li><strong>Save</strong> it.</li>
-    </ol>
-  </li>
-  <li>
-    Get App Credentials
-    <ol>
-      <li>Go To "Basic Information" page and make a note "Client ID" and "Client Secret".</li>
-    </ol>
-  </li>
-  <li>
-    Set Redirect URLs
-    <ol>
-      <li>Go to "OAuth &amp; Permissions" page.</li>
-      <li>Add <code><script>document.write(location.origin);</script>/admin/notification/slackAuth</code> .</li>
-      <li>Don't forget to <strong>save</strong>.</li>
-    </ol>
-  </li>
-  <li>
-    Set Permission Scopes to the App
-    <ol>
-      <li>Go to "OAuth &amp; Permissions" page.</li>
-      <li>Add "Send messages as GROWI"(<code>chat:write:bot</code>).</li>
-      <li>Don't forget to <strong>save</strong>.</li>
-    </ol>
-  </li>
-  <li>
-    Create a bot user
-    <ol>
-      <li>Go to "Bot Users" page and add.</li>
-    </ol>
-  </li>
-  <li>
-    Install the app
-    <ol>
-      <li>Go to "Install App to Your Team" page and install.</li>
-    </ol>
-  </li>
-  <li>
-    (At Team) Approve the app
-    <ol>
-      <li>Go to the management Apps page for the team you installed the app and approve "growi".</li>
-    </ol>
-  </li>
-  <li>
-    (At Team) Invite the bot to your team
-    <ol>
-      <li>Invite the user you created in <code>4. Add a bot user</code> to the channel you notify to.</li>
-    </ol>
-  </li>
-  <li>
-    (At GROWI admin page) Input "clientId" and "clientSecret" and submit on this page.
-  </li>
-  <li>
-    (At GROWI admin page) Click "Connect to Slack" button to start OAuth process.
-  </li>
+<ol id="collapseHelpForGoogleOauth" class="collapse">
+  <li>{{ t("security_setting.OAuth.Google.register_1", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_2") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_3") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_4", "https://${growi.host}/passport/google/callback", "${growi.host}") }}</li>
+  <li>{{ t("security_setting.OAuth.Google.register_5") }}</li>
 </ol>
-{% endif %}
+
+<script>
+  $('input[name="settingForm[security:passport-google:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-google-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-google-hide-when-disabled').hide(400);
+    }
+  });
+</script>

+ 2 - 2
lib/views/admin/widget/passport/ldap.html

@@ -139,11 +139,11 @@
             <input type="checkbox" id="cbSameUsernameTreatedAsIdenticalUser" name="settingForm[security:passport-ldap:isSameUsernameTreatedAsIdenticalUser]" value="1"
                 {% if settingForm['security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
             <label for="cbSameUsernameTreatedAsIdenticalUser">
-              {{ t("security_setting.ldap.Treat username matching as identical") }}
+              {{ t("security_setting.Treat username matching as identical", "username") }}
             </label>
             <p class="help-block">
               <small>
-                {{ t("security_setting.ldap.Treat username matching as identical_warn") }}
+                {{ t("security_setting.Treat username matching as identical_warn", "username") }}
               </small>
             </p>
           </div>

+ 1 - 1
lib/views/admin/widget/theme-colorbox.html

@@ -1,7 +1,7 @@
 <a id="theme-option-{{name}}" href="#"
     class="{{name}} {% if name === settingForm['customize:theme'] %}active{% endif %}"
     onclick="selectTheme('{{name}}')"
-    data-theme="{{ webpack_asset('style-theme-' + name).css }}">
+    data-theme="{{ webpack_asset('styles/theme-' + name + '.css') }}">
 
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
     <title>{{name}}</title>

+ 1 - 7
lib/views/layout-crowi/widget/page_side_header.html

@@ -13,13 +13,7 @@
       </p>
       <p class="created-at">
         {{ t('Created') }}: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
-
-        {% if page.lastUpdateUser %}
-          {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.lastUpdateUser.username }}"><img src="{{ page.lastUpdateUser|picture }}" class="picture picture-xs img-circle" alt="{{ page.lastUpdateUser.name }}"></a>
-        {% else %}
-          {# for BC 1.5.x #}
-          {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs img-circle" alt="{{ page.revision.author.name }}"></a>
-        {% endif %}
+        {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs img-circle" alt="{{ page.revision.author.name }}"></a>
       </p>
     </div>
   </div>

+ 0 - 36
lib/views/layout-growi/base/layout.html

@@ -26,42 +26,6 @@
 
 </div><!-- /.container-fluid -->
 
-
-<!-- Side Scroll Bar-->
-<script>
-  /*
-   * Disabled temporally -- 2018.06.06 Yuki Takei
-   * see https://weseek.myjetbrains.com/youtrack/issue/GC-278
-   *
-  function DrawScrollbar() {
-    var h = window.innerHeight - document.getElementById('page-header').clientHeight ;
-    $('#revision-toc-content').slimScroll({
-      railVisible: true,
-      position: 'right',
-      height: h,
-    });
-  }
-
-  $(function(){
-    DrawScrollbar();
-  });
-
-  (function () {
-    var timer = 0;
-
-    window.onresize = function () {
-      if (timer > 0) {
-        clearTimeout(timer);
-      }
-
-      timer = setTimeout(function () {
-        DrawScrollbar();
-      }, 200);
-    };
-  }());
-  */
-  </script>
-
 <footer class="footer">
   {% include '../../widget/system-version.html' %}
 </footer>

+ 3 - 3
lib/views/layout-growi/widget/header.html

@@ -27,11 +27,11 @@
         </li>
         <li class="m-t-5">
           <div class="d-flex align-items-center">
-            <a class="m-r-5" href="{{ userPageRoot(page.lastUpdateUser) }}">
-              <img src="{{ page.lastUpdateUser|default(author)|picture }}" class="picture img-circle">
+            <a class="m-r-5" href="{{ userPageRoot(page.revision.author) }}">
+              <img src="{{ page.revision.author|default(author)|picture }}" class="picture img-circle">
             </a>
             <div>
-              <div>Updated by <a href="{{ userPageRoot(page.lastUpdateUser) }}">{{ page.lastUpdateUser.name|default(author.name) }}</a></div>
+              <div>Updated by <a href="{{ userPageRoot(page.revision.author) }}">{{ page.revision.author.name|default(author.name) }}</a></div>
               <div class="text-muted">{{ page.updatedAt|datetz('Y/m/d H:i:s') }}</div>
             </div>
           </div>

+ 1 - 1
lib/views/layout/admin.html

@@ -6,7 +6,7 @@
 
 {% block html_additional_headers %}
   {% parent %}
-  <script src="{{ webpack_asset('legacy-admin').js }}" defer></script>
+  <script src="{{ webpack_asset('js/legacy-admin.js') }}" defer></script>
 {% endblock %}
 
 {# disable custom script in admin page #}

+ 16 - 15
lib/views/layout/layout.html

@@ -16,20 +16,19 @@
 
   {{ customHeader() }}
 
-
   <!-- polyfills for IE11 -->
   <script>
     var userAgent = window.navigator.userAgent.toLowerCase();
     if (userAgent.indexOf('msie') != -1 || userAgent.indexOf('trident') != -1) {
       var scriptElement = document.createElement('script');
-      scriptElement.src = 'https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.23.0/polyfill.min.js';
+      scriptElement.src = '{{ webpack_asset("js/ie11-polyfill.js") }}';
       var headElement = document.getElementsByTagName('head')[0];
       headElement.appendChild(scriptElement);
     }
   </script>
 
-  <!-- jQuery, emojione -->
-  <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+  <!-- jQuery, emojione, bootstrap -->
+  <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1,npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"></script>
   <!-- highlight.js -->
   <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
   <script src="https://cdn.jsdelivr.net/combine/
@@ -63,33 +62,35 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
   {% endif %}
 
   {% if env === 'development' %}
-    <script src="/dll/vendor.dll.js"></script>
-    <script src="{{ webpack_asset('dev').js }}" async></script>
+    <script src="/dll/dll.js"></script>
+    <script src="{{ webpack_asset('js/dev.js') }}" async></script>
     <!-- Browsersync -->
     <script id="__bs_script__">//<![CDATA[
       document.write("<script async src='http://HOST:3001/browser-sync/browser-sync-client.js?v=2.23.6'><\/script>".replace("HOST", location.hostname));
     //]]></script>
   {% endif %}
 
-  <script src="{{ webpack_asset('commons').js }}" defer></script>
+  <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
+  <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
   {% if isEnabledPlugins() %}
-  <script src="{{ webpack_asset('plugin').js }}" defer></script>
+  <script src="{{ webpack_asset('js/plugin.js') }}" defer></script>
   {% endif %}
   {% block html_head_loading_legacy %}
-  <script src="{{ webpack_asset('legacy').js }}" defer></script>
+  <script src="{{ webpack_asset('js/legacy.js') }}" defer></script>
   {% endblock %}
   {% block html_head_loading_app %}
-  <script src="{{ webpack_asset('app').js }}" defer></script>
+  <script src="{{ webpack_asset('js/app.js') }}" defer></script>
   {% endblock %}
 
   <!-- styles -->
   {% block style_css_block %}
     {% if env === 'development' %}
-      <script src="{{ webpack_asset('style').js }}"></script>
-      <script src="{{ webpack_asset('style-theme-' + theme()).js }}"></script>
+      <script src="{{ webpack_asset('styles/style.js') }}"></script>
+      <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
     {% else %}
-      <link rel="stylesheet" href="{{ webpack_asset('style').css }}">
-      <link rel="stylesheet" href="{{ webpack_asset('style-theme-' + theme()).css }}">
+      <link rel="stylesheet" href="{{ webpack_asset('js/vendors.css') }}">
+      <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
+      <link rel="stylesheet" href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
     {% endif %}
   {% endblock %}
 
@@ -219,7 +220,7 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
           {% endif %}
         </li>
 
-        <li><a href="#">(TBD) Create /Sidebar</a></li>
+        <li class="tbd"><a href="#">(TBD) Create /Sidebar</a></li>
       </ul>
     </div>
   </div>

+ 71 - 4
lib/views/login.html

@@ -42,12 +42,12 @@
         # The case that there already exists a user whose username matches ID of the newly created LDAP user
         # https://github.com/weseek/growi/issues/193
         #}
-        {% set isDuplicatedUsernameExceptionOccured = req.flash('isDuplicatedUsernameExceptionOccured') %}
-        {% if isDuplicatedUsernameExceptionOccured != null %}
+        {% set failedProviderForDuplicatedUsernameException = req.flash('provider-DuplicatedUsernameException') %}
+        {% if failedProviderForDuplicatedUsernameException != null %}
         <div class="alert alert-warning small">
           <p><strong><i class="icon-fw icon-ban"></i>DuplicatedUsernameException occured</strong></p>
           <p>
-            Your LDAP authentication was succeess, but a new user could not be created.
+            Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeess, but a new user could not be created.
             See the issue <a href="https://github.com/weseek/growi/issues/193">#193</a>.
           </p>
         </div>
@@ -136,7 +136,7 @@
         <div class="input-group m-t-15 m-b-10 mx-auto">
           <form role="form" action="/login/google" method="get">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="fcbtn btn btn-danger btn-1b btn-login-google">
+            <button type="submit" class="fcbtn btn btn-danger btn-1b btn-login-oauth" id="google">
               <span class="btn-label"><i class="icon-social-google"></i></span>
               {{ t('Sign in') }}
             </button>
@@ -145,7 +145,74 @@
         </div>
         {% endif %}
 
+        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() %}
+        <hr class="mb-1">
+        <div class="collapse collapse-oauth collapse-anchor">
+          <div class="spacer"></div>
+          <div class="d-flex flex-row justify-content-between flex-wrap">
+            {% if passportGoogleLoginEnabled() %}
+            <form role="form" action="/passport/google" class="d-inline-flex flex-column">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-flex" id="google">
+                <span class="btn-label"><i class="fa fa-google"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by Google Account</div>
+            </form>
+            {% endif %}
+            {% if passportGitHubLoginEnabled() %}
+            <form role="form" action="/passport/github" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="github">
+                <span class="btn-label"><i class="fa fa-github"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by GitHub Account</div>
+            </form>
+            {% endif %}
+            {% if passportFacebookLoginEnabled() %}
+            <form role="form" action="/passport/facebook" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="facebook">
+                <span class="btn-label"><i class="fa fa-facebook"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by Facebook Account</div>
+            </form>
+            {% endif %}
+            {% if passportTwitterLoginEnabled() %}
+            <form role="form" action="/passport/twitter" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="twitter">
+                <span class="btn-label"><i class="fa fa-twitter"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">by Twitter Account</div>
+            </form>
+            {% endif %}
+          </div>{# ./d-flex flex-row flex-wrap #}
+          <div class="spacer"></div>
+        </div>
+        <hr class="mt-2 mb-0">
+        <div class="text-center">
+          <button class="collapse-anchor btn btn-xs btn-collapse-oauth mb-3" data-toggle="collapse" data-parent="#accordion" href="#collapse-oauth" aria-expanded="true" aria-controls="collapseOne">
+            OAuth
+          </button>
+        </div>
+        {% else %}
         <hr>
+        {% endif %}
+
+
+        <script>
+          $(".collapse-anchor").hover(
+            function() {
+              $('.collapse-oauth').collapse('show');
+            },
+            function() {
+              $('.collapse-oauth').collapse('hide');
+            }
+          );
+        </script>
 
         <div class="row">
           <div class="col-xs-12 text-right">

+ 12 - 12
lib/views/me/external-accounts.html

@@ -120,19 +120,19 @@
 
           <ul class="nav nav-tabs passport-settings" role="tablist">
             <li class="active">
-              <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="icon-organization"></i> LDAP</a>
+              <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
             </li>
-            <li>
-              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="icon-social-google"></i> Google OAuth</a>
+            <li class="tbd">
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> (TBD) GitHub</a>
             </li>
-            <li>
-              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="icon-social-facebook"></i> Facebook</a>
+            <li class="tbd">
+              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> (TBD) Google OAuth</a>
             </li>
-            <li>
-              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="icon-social-twitter"></i> Twitter</a>
+            <li class="tbd">
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
-            <li>
-              <a href="#passport-github" data-toggle="tab" role="tab"><i class="icon-social-github"></i> Github</a>
+            <li class="tbd">
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> (TBD) Twitter</a>
             </li>
           </ul>
 
@@ -153,15 +153,15 @@
               (TBD)
             </div>
 
-            <div id="passport-facebook" class="tab-pane" role="tabpanel">
+            <div id="passport-github" class="tab-pane" role="tabpanel">
               (TBD)
             </div>
 
-            <div id="passport-twitter" class="tab-pane" role="tabpanel">
+            <div id="passport-facebook" class="tab-pane" role="tabpanel">
               (TBD)
             </div>
 
-            <div id="passport-github" class="tab-pane" role="tabpanel">
+            <div id="passport-twitter" class="tab-pane" role="tabpanel">
               (TBD)
             </div>
 

+ 5 - 5
lib/views/modal/create_page.html

@@ -78,13 +78,13 @@
       pagePath = pagePath.slice(0, -1);
   };
 
-  $.get(`/_api/pages.templates?path=${pagePath}`)
-    .then(templateInfo => {
-      buttonTextChildren = templateInfo.childrenTemplateExists ? `{{ t('Edit') }}` : `{{ t('Create') }}`;
-      buttonTextDecendants = templateInfo.decendantsTemplateExists ? `{{ t('Edit') }}` : `{{ t('Create') }}`;
+  $.get('/_api/pages.templates?path=' + pagePath)   // don't use template literal(`...${}`) for IE11
+    .then(function(templateInfo) {                  // don't use arrow function for IE11
+      buttonTextChildren = templateInfo.childrenTemplateExists ? '{{ t("Edit") }}' : '{{ t("Create") }}';
+      buttonTextDecendants = templateInfo.decendantsTemplateExists ? '{{ t("Edit") }}' : '{{ t("Create") }}';
     });
 
-  $("#template-type").on("change", () => {
+  $("#template-type").on("change", function() {
     // enable button
     $('#link-to-template').removeClass("disabled");
 

+ 1 - 1
lib/views/modal/shortcuts.html

@@ -67,7 +67,7 @@
     var platform = navigator.platform.toLowerCase();
     var isMac = (platform.indexOf('mac') > -1);
 
-    document.querySelectorAll('#shortcuts-modal .cmd-key').forEach((element) => {
+    document.querySelectorAll('#shortcuts-modal .cmd-key').forEach(function(element) {
       if (isMac) {
         element.classList.add('mac');
       }

+ 6 - 7
lib/views/page_presentation.html

@@ -35,19 +35,18 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
 " defer></script>
 
     {% if env === 'development' %}
-      <script src="/dll/vendor.dll.js"></script>
-      <script src="{{ webpack_asset('dev').js }}" async></script>
+      <script src="/dll/dll.js"></script>
+      <script src="{{ webpack_asset('js/dev.js') }}" async></script>
     {% endif %}
 
-    <script src="{{ webpack_asset('commons').js }}" defer></script>
-    <script src="{{ webpack_asset('legacy-presentation').js }}" defer></script>
+    <script src="{{ webpack_asset('js/legacy-presentation.js') }}" defer></script>
 
     <title>{{ path|path2name }} | {{ path }}</title>
 
     <!-- styles -->
-    <link rel="stylesheet" href="{{ webpack_asset('style').css }}">
-    <link rel="stylesheet" href="{{ webpack_asset('style-theme-default').css }}">
-    <link rel="stylesheet" href="{{ webpack_asset('style-presentation').css }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/theme-default.css') }}">
+    <link rel="stylesheet" href="{{ webpack_asset('styles/style-presentation.css') }}">
 
     <!-- Google Fonts -->
     <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>

+ 7 - 0
lib/views/widget/page_alerts.html

@@ -79,5 +79,12 @@
       <a href="{{ page.path }}"><i class="icon-fw icon-arrow-right-circle"></i>{{ t('Show latest') }}</a>
     </div>
     {% endif %}
+
+    {% set dmessage = req.flash('dangerMessage') %}
+    {% if dmessage.length %}
+    <div class="alert alert-danger m-b-15">
+      {{ dmessage }}
+    </div>
+    {% endif %}
   </div>
 </div>

+ 2 - 9
lib/views/widget/page_attachments.html

@@ -4,15 +4,8 @@
       <div class="page-attachments" id="page-attachment"></div>
 
       <p class="page-meta">
-        Path: <span id="pagePath">{{ page.path }}</span><br>
-        {# for BC #}
-        {% if page.lastUpdateUser %}
-          Last updated at {{ page.updatedAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.lastUpdateUser|picture }}" class="picture img-circle"> {{ page.lastUpdateUser.name }}<br>
-        {% else %}
-          Last updated at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.revision.author|picture }}" class="picture img-circle"> {{ page.revision.author.name }}<br>
-        {% endif %}
-        {# /for BC #}
-        Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(page.creator)|picture }}" class="picture img-circle"> {{ page.creator.name }}<br>
+        <p>Last revision posted at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-sm img-circle"> {{ page.revision.author.name }}</a></p>
+        <p>Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <a href="/user/{{ page.creator.username }}"><img src="{{ page.creator|default(page.creator)|picture }}" class="picture picture-sm img-circle"> {{ page.creator.name }}</a></p>
       </p>
     </div>
   </div>

+ 1 - 1
lib/views/widget/page_list.html

@@ -8,7 +8,7 @@
 {% endif %}
 
 <li>
-  <img src="{{ page.revision.author|picture }}" class="picture img-circle">
+  <img src="{{ page.lastUpdateUser|picture }}" class="picture img-circle">
   <a href="{{ page.path }}"
     class="page-list-link"
     data-path="{{ page.path }}"

+ 1 - 1
lib/views/widget/system-version.html

@@ -13,7 +13,7 @@
   var platform = navigator.platform.toLowerCase();
   var isMac = (platform.indexOf('mac') > -1);
 
-  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+  document.querySelectorAll('.system-version .cmd-key').forEach(function(element) {
     if (isMac) {
       element.classList.add('mac');
     }

+ 0 - 3
local_modules/crowi-fileupload-local/index.js

@@ -7,8 +7,6 @@ module.exports = function(crowi) {
     , fs = require('fs')
     , path = require('path')
     , mkdir = require('mkdirp')
-    , Config = crowi.model('Config')
-    , config = crowi.getConfig()
     , lib = {}
     , basePath = path.posix.join(crowi.publicDir, 'uploads'); // TODO: to configurable
 
@@ -17,7 +15,6 @@ module.exports = function(crowi) {
     return new Promise(function(resolve, reject) {
       fs.unlink(path.posix.join(basePath, filePath), function(err) {
         if (err) {
-          debug(err);
           return reject(err);
         }
 

+ 34 - 23
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.4-RC",
+  "version": "3.1.8-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -20,13 +20,17 @@
     "url": "https://github.com/weseek/growi/issues"
   },
   "scripts": {
-    "build:dev:analyze": "cross-env ANALYZE=1 npm run build:dev",
-    "build:dev:watch": "npm run build:dev -- --watch",
-    "build:dev": "npm run clean:js && webpack --config config/webpack.dev.js --progress --profile",
+    "build:dev:analyze": "npm-run-all -s build:dev:dll build:dev:app:analyze",
+    "build:dev:app:analyze": "cross-env ANALYZE=1 npm run build:dev:app:watch -- --profile",
+    "build:dev:app:watch": "npm run build:dev:app -- --watch",
+    "build:dev:app": "npm run clean:app && env-cmd config/env.dev.js webpack --config config/webpack.dev.js --progress",
+    "build:dev:dll": "webpack --config config/webpack.dll.js",
+    "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
+    "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
-    "build:prod": "npm run clean && webpack --config config/webpack.prod.js --progress --profile --bail",
+    "build:prod": "npm run clean && env-cmd config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
-    "clean:js": "rimraf -- public/js",
+    "clean:app": "rimraf -- public/js public/styles",
     "clean:dll": "rimraf -- public/dll",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
@@ -35,13 +39,11 @@
     "lint": "eslint .",
     "mkdirp": "mkdirp",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
-    "prebuild:dev": "env-cmd config/env.dev.js npm run plugin:def",
+    "prebuild:dev:app": "env-cmd config/env.dev.js npm run plugin:def",
     "prebuild:prod": "npm run plugin:def",
     "prestart": "npm run build:prod",
-    "postserver:prod:container": "echo ---------------------------------------- && echo [WARNING] && echo   'server:prod:container' is deprecated. && echo   Please use 'sever:prod' && echo ----------------------------------------",
     "server:debug": "env-cmd config/env.dev.js node-dev --inspect app.js",
     "server:dev": "env-cmd config/env.dev.js node-dev --respawn app.js",
-    "server:prod:container": "npm run server:prod",
     "server:prod:ci": "npm run server:prod -- --ci",
     "server:prod": "env-cmd config/env.prod.js node app.js",
     "server": "npm run server:dev",
@@ -76,13 +78,14 @@
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^31.0.2",
+    "googleapis": "^32.0.0",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "i18next": "^11.1.1",
     "i18next-express-middleware": "^1.1.1",
     "i18next-node-fs-backend": "^1.0.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
+    "markdown-it-blockdiag": "^1.0.2",
     "md5": "^2.2.1",
     "method-override": "^2.3.10",
     "mkdirp": "~0.5.1",
@@ -95,11 +98,10 @@
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "passport": "^0.4.0",
+    "passport-github": "^1.1.0",
+    "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
-    "react": "^16.2.0",
-    "react-dom": "^16.2.0",
-    "react-i18next": "^7.6.1",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
@@ -110,11 +112,11 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "assets-webpack-plugin": "^3.6.0",
     "autoprefixer": "^8.2.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
     "babel-plugin-lodash": "^3.3.2",
+    "babel-polyfill": "^6.26.0",
     "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
     "bootstrap-sass": "~3.3.6",
@@ -135,14 +137,15 @@
     "eazy-logger": "^3.0.2",
     "eslint": "^4.19.1",
     "eslint-plugin-react": "^7.7.0",
-    "extract-text-webpack-plugin": "^3.0.2",
     "file-loader": "^1.1.0",
     "i18next-browser-languagedetector": "^2.2.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
+    "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^8.4.0",
+    "markdown-it-blockdiag": "^1.0.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
@@ -152,33 +155,41 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^2.7.4",
+    "mini-css-extract-plugin": "^0.4.0",
     "mocha": "^5.0.0",
     "morgan": "^1.9.0",
     "node-dev": "^3.1.3",
     "node-sass": "^4.5.0",
+    "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
+    "null-loader": "^0.1.1",
     "on-headers": "^1.0.1",
-    "optimize-js-plugin": "0.0.4",
+    "optimize-css-assets-webpack-plugin": "^4.0.2",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^2.1.3",
+    "react": "^16.2.0",
     "react-bootstrap": "^0.32.1",
-    "react-bootstrap-typeahead": "^3.0.3",
+    "react-bootstrap-typeahead": "^3.1.4",
     "react-clipboard.js": "^2.0.0",
-    "react-codemirror2": "^5.0.0",
+    "react-codemirror2": "^5.0.4",
+    "react-dom": "^16.2.0",
     "react-dropzone": "^4.2.7",
+    "react-i18next": "^7.6.1",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.0.1",
     "simple-load-script": "^1.0.2",
-    "sinon": "^5.0.2",
-    "sinon-chai": "^3.0.0",
+    "sinon": "^6.0.0",
+    "sinon-chai": "^3.2.0",
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.21.0",
-    "throttle-debounce": "^1.0.1",
+    "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
+    "uglifyjs-webpack-plugin": "^1.2.5",
     "url-join": "^4.0.0",
-    "webpack": "3.11.0",
+    "webpack": "^4.12.0",
+    "webpack-assets-manifest": "^3.0.1",
     "webpack-bundle-analyzer": "^2.9.0",
-    "webpack-dll-bundles-plugin": "^1.0.0-beta.5",
+    "webpack-cli": "^3.0.8",
     "webpack-merge": "~4.1.0"
   },
   "_moduleAliases": {

+ 0 - 1
public/css/.gitignore

@@ -1 +0,0 @@
-

+ 20 - 19
resource/js/app.js

@@ -80,14 +80,14 @@ const crowiRenderer = new GrowiRenderer(crowi, null, {
 window.crowiRenderer = crowiRenderer;
 
 // FIXME
-var isEnabledPlugins = $('body').data('plugin-enabled');
+const isEnabledPlugins = $('body').data('plugin-enabled');
 if (isEnabledPlugins) {
-  var crowiPlugin = window.crowiPlugin;
+  const crowiPlugin = window.crowiPlugin;
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 
-// configure renderer
-crowiRenderer.setup(crowi.config);
+// setup renderer after plugins are installed
+crowiRenderer.setup();
 
 // restore draft when the first time to edit
 const draft = crowi.findDraft(pagePath);
@@ -134,20 +134,6 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
-// render comment form
-const writeCommentElem = document.getElementById('page-comment-write');
-if (writeCommentElem) {
-  const pageCommentsElem = componentInstances['page-comments-list'];
-  const postCompleteHandler = (comment) => {
-    if (pageCommentsElem != null) {
-      pageCommentsElem.retrieveData();
-    }
-  };
-  ReactDOM.render(
-    <CommentForm crowi={crowi} pageId={pageId} revisionId={pageRevisionId} onPostComplete={postCompleteHandler} crowiRenderer={crowiRenderer}/>,
-    writeCommentElem);
-}
-
 /*
  * PageEditor
  */
@@ -160,7 +146,7 @@ if (pageEditorElem) {
   // create onSave event handler
   const onSaveSuccess = function(page) {
     // modify the revision id value to pass checking id when updating
-    crowi.getCrowiForJquery().updateCurrentRevision(page.revision._id);
+    crowi.getCrowiForJquery().updatePageForm(page);
     // re-render Page component if exists
     if (componentInstances.page != null) {
       componentInstances.page.setMarkdown(page.revision.body);
@@ -178,6 +164,21 @@ if (pageEditorElem) {
   // set refs for pageEditor
   crowi.setPageEditor(pageEditor);
 }
+
+// render comment form
+const writeCommentElem = document.getElementById('page-comment-write');
+if (writeCommentElem) {
+  const pageCommentsElem = componentInstances['page-comments-list'];
+  const postCompleteHandler = (comment) => {
+    if (pageCommentsElem != null) {
+      pageCommentsElem.retrieveData();
+    }
+  };
+  ReactDOM.render(
+    <CommentForm crowi={crowi} crowiOriginRenderer={crowiRenderer} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath} onPostComplete={postCompleteHandler} editorOptions={editorOptions}/>,
+    writeCommentElem);
+}
+
 // render OptionsSelector
 const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
 if (pageEditorOptionsSelectorElem) {

+ 2 - 2
resource/js/components/Admin/CustomCssEditor.js

@@ -2,13 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/lint/css-lint');
 require('codemirror/addon/hint/css-hint');
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/css/css');
+require('../../util/codemirror/autorefresh.ext');
 
 require('jquery-ui/ui/widgets/resizable');
 
@@ -32,7 +32,7 @@ export default class CustomCssEditor extends React.Component {
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
           extraKeys: {'Ctrl-Space': 'autocomplete'},

+ 2 - 2
resource/js/components/Admin/CustomHeaderEditor.js

@@ -2,11 +2,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/htmlmixed/htmlmixed');
+require('../../util/codemirror/autorefresh.ext');
 
 require('jquery-ui/ui/widgets/resizable');
 
@@ -30,7 +30,7 @@ export default class CustomHeaderEditor extends React.Component {
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
           extraKeys: {'Ctrl-Space': 'autocomplete'},

+ 2 - 2
resource/js/components/Admin/CustomScriptEditor.js

@@ -2,13 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { UnControlled as CodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/lint/javascript-lint');
 require('codemirror/addon/hint/javascript-hint');
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/javascript/javascript');
+require('../../util/codemirror/autorefresh.ext');
 
 require('jquery-ui/ui/widgets/resizable');
 
@@ -32,7 +32,7 @@ export default class CustomScriptEditor extends React.Component {
           tabSize: 2,
           indentUnit: 2,
           theme: 'eclipse',
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           matchBrackets: true,
           autoCloseBrackets: true,
           extraKeys: {'Ctrl-Space': 'autocomplete'},

+ 2 - 1
resource/js/components/Page/RevisionBody.js

@@ -43,6 +43,7 @@ export default class RevisionBody extends React.Component {
   }
 
   render() {
+    const additionalClassName = this.props.additionalClassName || '';
     return (
       <div
         ref={(elm) => {
@@ -51,7 +52,7 @@ export default class RevisionBody extends React.Component {
             this.props.inputRef(elm);
           }
         }}
-        className={'wiki ' + this.props.additionalClassName} dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+        className={`wiki ${additionalClassName}`} dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
       </div>
     );
   }

+ 108 - 28
resource/js/components/PageComment/CommentForm.js

@@ -2,12 +2,16 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ReactUtils from '../ReactUtils';
 
-import CommentPreview from '../PageComment/CommentPreview';
-
 import Button from 'react-bootstrap/es/Button';
 import Tab from 'react-bootstrap/es/Tab';
 import Tabs from 'react-bootstrap/es/Tabs';
 import UserPicture from '../User/UserPicture';
+import * as toastr from 'toastr';
+
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import Editor from '../PageEditor/Editor';
+import CommentPreview from '../PageComment/CommentPreview';
 
 /**
  *
@@ -23,27 +27,40 @@ export default class CommentForm extends React.Component {
   constructor(props) {
     super(props);
 
+    const config = this.props.crowi.getConfig();
+    const isUploadable = config.upload.image || config.upload.file;
+    const isUploadableFile = config.upload.file;
+
     this.state = {
       comment: '',
       isMarkdown: true,
       html: '',
       key: 1,
+      isUploadable,
+      isUploadableFile,
+      errorMessage: undefined,
     };
 
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, {mode: 'comment'});
+
     this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
     this.postComment = this.postComment.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.handleSelect = this.handleSelect.bind(this);
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+    this.onUpload = this.onUpload.bind(this);
   }
 
-  updateState(event) {
-    const target = event.target;
-    const value = target.type === 'checkbox' ? target.checked : target.value;
-    const name = target.name;
+  updateState(value) {
+    this.setState({comment: value});
+  }
 
-    this.setState({
-      [name]: value
-    });
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({isMarkdown: value});
+    // changeMode
+    this.refs.editor.setGfmMode(value);
   }
 
   handleSelect(key) {
@@ -74,7 +91,14 @@ export default class CommentForm extends React.Component {
           isMarkdown: true,
           html: '',
           key: 1,
+          errorMessage: undefined,
         });
+        // reset value
+        this.refs.editor.setValue('');
+      })
+      .catch(err => {
+        const errorMessage = err.message || 'An unknown error occured when posting comment';
+        this.setState({ errorMessage });
       });
   }
 
@@ -87,26 +111,26 @@ export default class CommentForm extends React.Component {
   }
 
   renderHtml(markdown) {
-    var context = {
+    const context = {
       markdown,
       dom: this.previewElement,
     };
 
-    const crowiRenderer = this.props.crowiRenderer;
+    const growiRenderer = this.growiRenderer;
     const interceptorManager = this.props.crowi.interceptorManager;
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => interceptorManager.process('prePreProcess', context))
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       .then(() => interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = crowiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown);
         context['parsedHTML'] = parsedHTML;
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderCommentPreviewHtml', context))
@@ -121,6 +145,48 @@ export default class CommentForm extends React.Component {
     return {__html: html};
   }
 
+  onUpload(file) {
+    const endpoint = '/attachments.add';
+
+    // create a FromData instance
+    const formData = new FormData();
+    formData.append('_csrf', this.props.crowi.csrfToken);
+    formData.append('file', file);
+    formData.append('path', this.props.pagePath);
+    formData.append('page_id', this.props.pageId || 0);
+
+    // post
+    this.props.crowi.apiPost(endpoint, formData)
+      .then((res) => {
+        const url = res.url;
+        const attachment = res.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${url})`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = '!' + insertText;
+        }
+        this.refs.editor.insertText(insertText);
+      })
+      .catch(this.apiErrorHandler)
+      // finally
+      .then(() => {
+        this.refs.editor.terminateUploadingState();
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
 
   render() {
     const crowi = this.props.crowi;
@@ -129,6 +195,7 @@ export default class CommentForm extends React.Component {
     const creatorsPage = `/user/${username}`;
     const comment = this.state.comment;
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml(): ReactUtils.nl2br(comment);
+    const emojiStrategy = this.props.crowi.getEmojiStrategy();
 
     return (
       <div>
@@ -144,8 +211,18 @@ export default class CommentForm extends React.Component {
                 <div className="comment-write">
                   <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
                     <Tab eventKey={1} title="Write">
-                      <textarea className="comment-form-comment form-control" id="comment-form-comment" name="comment" required placeholder="Write comments here..." value={this.state.comment} onChange={this.updateState} >
-                      </textarea>
+                      <Editor ref="editor"
+                        value={this.state.comment}
+                        isGfmMode={this.state.isMarkdown}
+                        editorOptions={this.props.editorOptions}
+                        lineNumbers={false}
+                        isMobile={this.props.crowi.isMobile}
+                        isUploadable={this.state.isUploadable}
+                        isUploadableFile={this.state.isUploadableFile}
+                        emojiStrategy={emojiStrategy}
+                        onChange={this.updateState}
+                        onUpload={this.onUpload}
+                      />
                     </Tab>
                     { this.state.isMarkdown == true &&
                     <Tab eventKey={2} title="Preview">
@@ -156,21 +233,19 @@ export default class CommentForm extends React.Component {
                     }
                   </Tabs>
                 </div>
-                <div className="comment-submit">
-                  <div className="pull-left">
+                <div className="comment-submit d-flex">
                   { this.state.key == 1 &&
                     <label>
-                      <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateState} /> Markdown
+                      <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
                     </label>
                   }
-                  </div>
-                  <div className="pull-right">
-                    <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
-                        Comment
-                    </Button>
-                  </div>
-                  <div className="clearfix">
-                  </div>
+                  <div style={{flex: 1}}></div>{/* spacer */}
+                  { this.state.errorMessage &&
+                    <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>
+                  }
+                  <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
+                    Comment
+                  </Button>
                 </div>
               </div>
             </div>
@@ -183,8 +258,13 @@ export default class CommentForm extends React.Component {
 
 CommentForm.propTypes = {
   crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
   onPostComplete: PropTypes.func,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
-  crowiRenderer:  PropTypes.object.isRequired,
+  pagePath: PropTypes.string,
+  editorOptions: PropTypes.object,
+};
+CommentForm.defaultProps = {
+  editorOptions: {},
 };

+ 2 - 3
resource/js/components/PageEditor.js

@@ -311,7 +311,6 @@ export default class PageEditor extends React.Component {
   }
 
   apiErrorHandler(error) {
-    console.error(error);
     toastr.error(error.message, 'Error occured', {
       closeButton: true,
       progressBar: true,
@@ -326,7 +325,7 @@ export default class PageEditor extends React.Component {
     this.setState({ markdown: value });
 
     // render html
-    var context = {
+    const context = {
       markdown: this.state.markdown,
       dom: this.previewElement,
       currentPagePath: decodeURIComponent(location.pathname)
@@ -341,7 +340,7 @@ export default class PageEditor extends React.Component {
       })
       .then(() => interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = growiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown);
         context['parsedHTML'] = parsedHTML;
       })
       .then(() => interceptorManager.process('prePostProcess', context))

+ 24 - 0
resource/js/components/PageEditor/AbstractEditor.js

@@ -21,6 +21,19 @@ export default class AbstractEditor extends React.Component {
   forceToFocus() {
   }
 
+  /**
+   * set new value
+   */
+  setValue(newValue) {
+  }
+
+  /**
+   * Enable/Disable GFM mode
+   * @param {bool} bool
+   */
+  setGfmMode(bool) {
+  }
+
   /**
    * set caret position of codemirror
    * @param {string} number
@@ -49,6 +62,13 @@ export default class AbstractEditor extends React.Component {
     throw new Error('this method should be impelemented in subclass');
   }
 
+  /**
+   * return strings from BOL(beginning of line) to current position
+   */
+  getStrFromBolToSelectedUpperPos() {
+    throw new Error('this method should be impelemented in subclass');
+  }
+
   /**
    * replace Beggining Of Line to current position with param 'text'
    * @param {string} text
@@ -93,6 +113,7 @@ export default class AbstractEditor extends React.Component {
 
 AbstractEditor.propTypes = {
   value: PropTypes.string,
+  ifGfmMode: PropTypes.bool,
   editorOptions: PropTypes.object,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
@@ -101,4 +122,7 @@ AbstractEditor.propTypes = {
   onPasteFiles: PropTypes.func,
   onDragEnter: PropTypes.func,
 };
+AbstractEditor.defaultProps = {
+  isGfmMode: true,
+};
 

+ 84 - 14
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -10,7 +10,6 @@ const loadCssSync = require('load-css-file');
 import * as codemirror from 'codemirror';
 
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/matchtags');
 require('codemirror/addon/edit/closetag');
@@ -27,6 +26,7 @@ require('codemirror/addon/fold/foldgutter.css');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/mode/gfm/gfm');
+require('../../util/codemirror/autorefresh.ext');
 
 import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
@@ -45,6 +45,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
+      isGfmMode: this.props.isGfmMode,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       additionalClass: '',
@@ -129,6 +130,26 @@ export default class CodeMirrorEditor extends AbstractEditor {
     }, 100);
   }
 
+  /**
+   * @inheritDoc
+   */
+  setValue(newValue) {
+    this.setState({ value: newValue });
+    this.getCodeMirror().getDoc().setValue(newValue);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setGfmMode(bool) {
+    this.setState({
+      isGfmMode: bool,
+      isEnabledEmojiAutoComplete: bool,
+    });
+    const mode = bool ? 'gfm' : undefined;
+    this.getCodeMirror().setOption('mode', mode);
+  }
+
   /**
    * @inheritDoc
    */
@@ -154,7 +175,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     const editor = this.getCodeMirror();
     // get top position of the line
-    var top = editor.charCoords({line, ch: 0}, 'local').top;
+    const top = editor.charCoords({line, ch: 0}, 'local').top;
     editor.scrollTo(null, top);
   }
 
@@ -176,12 +197,22 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return editor.getDoc().getRange(curPos, this.getEol());
   }
 
+  /**
+   * @inheritDoc
+   */
+  getStrFromBolToSelectedUpperPos() {
+    const editor = this.getCodeMirror();
+    const pos = this.selectUpperPos(editor.getCursor('from'), editor.getCursor('to'));
+    return editor.getDoc().getRange(this.getBol(), pos);
+  }
+
   /**
    * @inheritDoc
    */
   replaceBolToCurrentPos(text) {
     const editor = this.getCodeMirror();
-    editor.getDoc().replaceRange(text, this.getBol(), editor.getCursor());
+    const pos = this.selectLowerPos(editor.getCursor('from'), editor.getCursor('to'));
+    editor.getDoc().replaceRange(text, this.getBol(), pos);
   }
 
   /**
@@ -211,6 +242,32 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return { line: curPos.line, ch: lineLength };
   }
 
+  /**
+   * select the upper position of pos1 and pos2
+   * @param {{line: number, ch: number}} pos1
+   * @param {{line: number, ch: number}} pos2
+   */
+  selectUpperPos(pos1, pos2) {
+    // if both is in same line
+    if (pos1.line === pos2.line) {
+      return (pos1.ch < pos2.ch) ? pos1 : pos2;
+    }
+    return (pos1.line < pos2.line) ? pos1 : pos2;
+  }
+
+  /**
+   * select the lower position of pos1 and pos2
+   * @param {{line: number, ch: number}} pos1
+   * @param {{line: number, ch: number}} pos2
+   */
+  selectLowerPos(pos1, pos2) {
+    // if both is in same line
+    if (pos1.line === pos2.line) {
+      return (pos1.ch < pos2.ch) ? pos2 : pos1;
+    }
+    return (pos1.line < pos2.line) ? pos2 : pos1;
+  }
+
   loadCss(source) {
     return new Promise((resolve) => {
       loadCssSync(source);
@@ -286,7 +343,12 @@ export default class CodeMirrorEditor extends AbstractEditor {
    * handle ENTER key
    */
   handleEnterKey() {
-    var context = {
+    if (!this.state.isGfmMode) {
+      codemirror.commands.newlineAndIndent(this.getCodeMirror());
+      return;
+    }
+
+    const context = {
       handlers: [],  // list of handlers which process enter key
       editor: this,
     };
@@ -360,8 +422,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   render() {
-    const theme = this.props.editorOptions.theme || 'elegant';
-    const styleActiveLine = this.props.editorOptions.styleActiveLine || undefined;
+    const mode = this.state.isGfmMode ? 'gfm' : undefined;
+    const defaultEditorOptions = {
+      theme: 'elegant',
+      lineNumbers: true,
+    };
+    const editorOptions = Object.assign(defaultEditorOptions, this.props.editorOptions || {});
+
     return <React.Fragment>
       <ReactCodeMirror
         ref="cm"
@@ -373,20 +440,20 @@ export default class CodeMirrorEditor extends AbstractEditor {
         }}
         value={this.state.value}
         options={{
-          mode: 'gfm',
-          theme: theme,
-          styleActiveLine: styleActiveLine,
-          lineNumbers: true,
+          mode: mode,
+          theme: editorOptions.theme,
+          styleActiveLine: editorOptions.styleActiveLine,
+          lineNumbers: this.props.lineNumbers,
           tabSize: 4,
           indentUnit: 4,
           lineWrapping: true,
-          autoRefresh: true,
+          autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           autoCloseTags: true,
           matchBrackets: true,
           matchTags: {bothTags: true},
           // folding
-          foldGutter: true,
-          gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
+          foldGutter: this.props.lineNumbers,
+          gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
           // match-highlighter, matchesonscrollbar, annotatescrollbar options
           highlightSelectionMatches: {annotateScrollbar: true},
           // markdown mode options
@@ -434,5 +501,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 CodeMirrorEditor.propTypes = Object.assign({
   emojiStrategy: PropTypes.object,
+  lineNumbers: PropTypes.bool,
 }, AbstractEditor.propTypes);
-
+CodeMirrorEditor.defaultProps = {
+  lineNumbers: true,
+};

+ 14 - 0
resource/js/components/PageEditor/Editor.js

@@ -50,6 +50,20 @@ export default class Editor extends AbstractEditor {
     this.getEditorSubstance().forceToFocus();
   }
 
+  /**
+   * @inheritDoc
+   */
+  setValue(newValue) {
+    this.getEditorSubstance().setValue(newValue);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setGfmMode(bool) {
+    this.getEditorSubstance().setGfmMode(bool);
+  }
+
   /**
    * @inheritDoc
    */

+ 1 - 1
resource/js/components/PageEditor/MarkdownListUtil.js

@@ -42,7 +42,7 @@ class MarkdownListUtil {
    */
   pasteText(editor, event, text) {
     // get strings from BOL(beginning of line) to current position
-    const strFromBol = editor.getStrFromBol();
+    const strFromBol = editor.getStrFromBolToSelectedUpperPos();
 
     // when match indentAndMarkOnlyRE
     // (this means the current position is the beginning of the list item)

+ 41 - 6
resource/js/components/PageEditor/TextAreaEditor.js

@@ -20,6 +20,7 @@ export default class TextAreaEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
+      isGfmMode: this.props.isGfmMode,
     };
 
     this.init();
@@ -57,6 +58,23 @@ export default class TextAreaEditor extends AbstractEditor {
     }, 150);
   }
 
+  /**
+   * @inheritDoc
+   */
+  setValue(newValue) {
+    this.setState({ value: newValue });
+    this.textarea.value = newValue;
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setGfmMode(bool) {
+    this.setState({
+      isGfmMode: bool,
+    });
+  }
+
   /**
    * @inheritDoc
    */
@@ -109,12 +127,24 @@ export default class TextAreaEditor extends AbstractEditor {
     return this.textarea.value.substring(currentPos, this.getEolPos());
   }
 
+  /**
+   * @inheritDoc
+   */
+  getStrFromBolToSelectedUpperPos() {
+    const startPos = this.textarea.selectionStart;
+    const endPos = this.textarea.selectionEnd;
+    const upperPos = (startPos < endPos) ? startPos : endPos;
+    return this.textarea.value.substring(this.getBolPos(), upperPos);
+  }
+
   /**
    * @inheritDoc
    */
   replaceBolToCurrentPos(text) {
-    const currentPos = this.textarea.selectionStart;
-    this.replaceValue(text, this.getBolPos(), currentPos);
+    const startPos = this.textarea.selectionStart;
+    const endPos = this.textarea.selectionEnd;
+    const lowerPos = (startPos < endPos) ? endPos : startPos;
+    this.replaceValue(text, this.getBolPos(), lowerPos);
   }
 
   getBolPos() {
@@ -153,16 +183,20 @@ export default class TextAreaEditor extends AbstractEditor {
         return;
       }
 
-      event.preventDefault();
-      this.handleEnterKey();
+      this.handleEnterKey(event);
     }
   }
 
   /**
    * handle ENTER key
+   * @param {string} event
    */
-  handleEnterKey() {
-    var context = {
+  handleEnterKey(event) {
+    if (!this.state.isGfmMode) {
+      return; // do nothing
+    }
+
+    const context = {
       handlers: [],  // list of handlers which process enter key
       editor: this,
     };
@@ -170,6 +204,7 @@ export default class TextAreaEditor extends AbstractEditor {
     const interceptorManager = this.interceptorManager;
     interceptorManager.process('preHandleEnter', context)
       .then(() => {
+        event.preventDefault();
         if (context.handlers.length == 0) {
           mlu.newlineAndIndentContinueMarkdownList(this);
         }

+ 1 - 1
resource/js/components/PageList/Page.js

@@ -20,7 +20,7 @@ export default class Page extends React.Component {
 
     return (
       <li className="page-list-li d-flex align-items-center">
-        <UserPicture user={page.revision.author} />
+        <UserPicture user={page.lastUpdateUser} />
         <a className="page-list-link" href={link}>
           <PagePath page={page} excludePathString={this.props.excludePathString} />
         </a>

+ 1 - 1
resource/js/components/SearchTypeahead.js

@@ -106,7 +106,7 @@ export default class SearchTypeahead extends React.Component {
     const page = option;
     return (
       <span>
-      <UserPicture user={page.revision.author} size="sm" />
+      <UserPicture user={page.lastUpdateUser} size="sm" />
       <PagePath page={page} />
       <PageListMeta page={page} />
       </span>

+ 2 - 0
resource/js/ie11-polyfill.js

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

+ 0 - 1
resource/js/legacy/crowi-form.js

@@ -2,7 +2,6 @@ var pageId = $('#content-main').data('page-id');
 var pagePath= $('#content-main').data('path');
 
 require('bootstrap-select');
-require('bootstrap-sass');
 
 // show/hide
 function FetchPagesUpdatePostAndInsert(path) {

+ 74 - 3
resource/js/legacy/crowi.js

@@ -8,13 +8,14 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 
+import { debounce } from 'throttle-debounce';
+
 import GrowiRenderer from '../util/GrowiRenderer';
 import Page from '../components/Page';
 
 const io = require('socket.io-client');
 const entities = require('entities');
 const escapeStringRegexp = require('escape-string-regexp');
-require('bootstrap-sass');
 require('jquery.cookie');
 
 require('./thirdparty-js/agile-admin');
@@ -135,8 +136,9 @@ Crowi.modifyScrollTop = function() {
   }, timeout);
 };
 
-Crowi.updateCurrentRevision = function(revisionId) {
-  $('#page-form [name="pageForm[currentRevision]"]').val(revisionId);
+Crowi.updatePageForm = function(page) {
+  $('#page-form [name="pageForm[currentRevision]"]').val(page.revision._id);
+  $('#page-form [name="pageForm[grant]"]').val(page.grant);
 };
 
 Crowi.handleKeyEHandler = (event) => {
@@ -165,6 +167,60 @@ Crowi.handleKeyCtrlSlashHandler = (event) => {
   event.preventDefault();
 };
 
+Crowi.initSlimScrollForRevisionToc = () => {
+  const revisionTocElem = document.querySelector('.growi .revision-toc');
+  const tocContentElem = document.querySelector('.growi .revision-toc .markdownIt-TOC');
+
+  // growi layout only
+  if (revisionTocElem == null || tocContentElem == null) {
+    return;
+  }
+
+  function getCurrentRevisionTocTop() {
+    // calculate absolute top of '#revision-toc' element
+    return revisionTocElem.getBoundingClientRect().top;
+  }
+
+  function resetScrollbar(revisionTocTop) {
+    // window height - revisionTocTop - .system-version height
+    let h = window.innerHeight - revisionTocTop - 20;
+
+    const tocContentHeight = tocContentElem.getBoundingClientRect().height + 15;  // add margin
+
+    h = Math.min(h, tocContentHeight);
+
+    $('#revision-toc-content').slimScroll({
+      railVisible: true,
+      position: 'right',
+      height: h,
+    });
+  }
+
+  const resetScrollbarDebounced = debounce(100, resetScrollbar);
+
+  // initialize
+  const revisionTocTop = getCurrentRevisionTocTop();
+  resetScrollbar(revisionTocTop);
+
+  /*
+   * set event listener
+   */
+  // resize
+  window.addEventListener('resize', (event) => {
+    resetScrollbarDebounced(getCurrentRevisionTocTop());
+  });
+  // affix on
+  $('#revision-toc').on('affixed.bs.affix', function() {
+    resetScrollbar(getCurrentRevisionTocTop());
+  });
+  // affix off
+  $('#revision-toc').on('affixed-top.bs.affix', function() {
+    // calculate sum of height (.navbar-header + .bg-title) + margin-top of .main
+    const sum = 138;
+    resetScrollbar(sum);
+  });
+};
+
 $(function() {
   var config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
 
@@ -529,6 +585,20 @@ $(function() {
       });
     }
 
+    // (function () {
+    //   var timer = 0;
+
+    //   window.onresize = function () {
+    //     if (timer > 0) {
+    //       clearTimeout(timer);
+    //     }
+
+    //     timer = setTimeout(function () {
+    //       DrawScrollbar();
+    //     }, 200);
+    //   };
+    // }());
+
     /*
      * transplanted to React components -- 2017.06.02 Yuki Takei
      *
@@ -890,6 +960,7 @@ window.addEventListener('load', function(e) {
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
   Crowi.setCaretLineAndFocusToEditor();
+  Crowi.initSlimScrollForRevisionToc();
 });
 
 window.addEventListener('hashchange', function(e) {

+ 16 - 3
resource/js/util/GrowiRenderer.js

@@ -15,6 +15,7 @@ import PlantUMLConfigurer from './markdown-it/plantuml';
 import TableConfigurer from './markdown-it/table';
 import TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
+import BlockdiagConfigurer from './markdown-it/blockdiag';
 
 export default class GrowiRenderer {
 
@@ -75,6 +76,7 @@ export default class GrowiRenderer {
       new EmojiConfigurer(crowi),
       new MathJaxConfigurer(crowi),
       new PlantUMLConfigurer(crowi),
+      new BlockdiagConfigurer(crowi),
     ];
 
     // add configurers according to mode
@@ -104,11 +106,22 @@ export default class GrowiRenderer {
 
   /**
    * setup with crowi config
-   * @param {any} config crowi config
    */
-  setup(config) {
+  setup() {
+    const crowiConfig = this.crowi.config;
+
+    let isEnabledLinebreaks = undefined;
+    switch (this.options.mode) {
+      case 'comment':
+        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
+        break;
+      default:
+        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaks;
+        break;
+    }
+
     this.md.set({
-      breaks: config.isEnabledLineBreaks,
+      breaks: isEnabledLinebreaks,
     });
 
     if (!this.isMarkdownItConfigured) {

+ 51 - 0
resource/js/util/codemirror/autorefresh.ext.js

@@ -0,0 +1,51 @@
+/**
+ * extends codemirror/addon/display/autorefresh
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ * @see https://codemirror.net/addon/display/autorefresh.js
+ * @see https://github.com/scniro/react-codemirror2/issues/83#issuecomment-398825212
+ */
+/* eslint-disable */
+
+// CodeMirror, copyright (c) by Marijn Haverbeke and others
+// Distributed under an MIT license: http://codemirror.net/LICENSE
+
+(function(mod) {
+  mod(require("codemirror"));
+})(function(CodeMirror) {
+  "use strict"
+
+  CodeMirror.defineOption("autoRefresh", false, function(cm, val) {
+    if (cm.state.autoRefresh) {
+      stopListening(cm, cm.state.autoRefresh)
+      cm.state.autoRefresh = null
+    }
+    if (val && (val.force || cm.display.wrapper.offsetHeight == 0))
+      startListening(cm, cm.state.autoRefresh = {delay: val.delay || 250})
+  })
+
+  function startListening(cm, state) {
+    function check() {
+      if (cm.display.wrapper.offsetHeight) {
+        stopListening(cm, state)
+        if (cm.display.lastWrapHeight != cm.display.wrapper.clientHeight)
+          cm.refresh()
+      } else {
+        state.timeout = setTimeout(check, state.delay)
+      }
+    }
+    state.timeout = setTimeout(check, state.delay)
+    state.hurry = function() {
+      clearTimeout(state.timeout)
+      state.timeout = setTimeout(check, 50)
+    }
+    CodeMirror.on(window, "mouseup", state.hurry)
+    CodeMirror.on(window, "keyup", state.hurry)
+  }
+
+  function stopListening(_cm, state) {
+    clearTimeout(state.timeout)
+    CodeMirror.off(window, "mouseup", state.hurry)
+    CodeMirror.off(window, "keyup", state.hurry)
+  }
+});

+ 16 - 0
resource/js/util/markdown-it/blockdiag.js

@@ -0,0 +1,16 @@
+export default class BlockdiagConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    const config = crowi.getConfig();
+
+    this.generateSourceUrl = config.env.BLOCKDIAG_URI || 'https://blockdiag-api.com/';
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-blockdiag'), {
+      generateSourceUrl: this.generateSourceUrl,
+      marker: ':::',
+    });
+  }
+}

+ 1 - 0
resource/styles/scss/_layout_growi.scss

@@ -7,6 +7,7 @@
     &.affix {
       margin-top: 5px;
       top: calc(46px + 5px);
+      min-width: calc(#{100/12*2%} - #{$grid-gutter-width});  // width of 2column - padding
     }
 
     .revision-toc-content {

+ 51 - 3
resource/styles/scss/_login.scss

@@ -98,8 +98,26 @@
     }
   }
 
+  .collapse-oauth {
+    overflow: hidden;
+    &:not(.in) {
+      height: 0;
+      padding: 0 !important;
+    }
+
+    form {
+      flex: 1;
+      @media(min-width: 350px) {
+        flex: 0.49;
+      }
+    }
+    .spacer {
+      height: 10px;
+    }
+  }
+
   // button style
-  .btn-login.fcbtn, .btn-register.fcbtn, .btn-login-google.fcbtn {
+  .btn-login.fcbtn, .btn-register.fcbtn, .btn-login-oauth.fcbtn, .btn-collapse-oauth {
     border: none;
     color: white;
     background-color: rgba(lighten(black, 20%), 0.4);
@@ -112,6 +130,12 @@
       border: none;
     }
   }
+  .btn-login-oauth {
+    flex: 1;
+    .btn-label-text {
+      flex: 1;
+    }
+  }
   .btn-login.fcbtn {
     .btn-label {
       background-color: rgba($brand-danger, 0.4);
@@ -120,9 +144,33 @@
       background-color: #7e4153;
     }
   }
-  .btn-login-google.fcbtn {
+  .btn-login-oauth.fcbtn#google {
+    .btn-label {
+      background: rgba(#f13d25, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
+  .btn-login-oauth.fcbtn#github {
+    .btn-label {
+      background-color: rgba(#24292e, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
+  .btn-login-oauth.fcbtn#facebook {
+    .btn-label {
+      background-color: rgba(#29487d, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
+  .btn-login-oauth.fcbtn#twitter {
     .btn-label {
-      background-color: rgba(#444, 0.4);
+      background-color: rgba(#1da1f2, 0.4);
     }
     &:after {
       background-color: #555;

+ 8 - 1
resource/styles/scss/_wiki.scss

@@ -152,6 +152,10 @@ div.body {
     h1, h2, h3, h4, h5, h6 {
       margin-top: 1.6em * $ratio;
       margin-bottom: .8em * $ratio;
+
+      &:first-child {
+        margin-top: 15px;
+      }
     }
 
     h1 {
@@ -178,8 +182,11 @@ div.body {
     }
 
     ul, ol {
+      padding-left: 15px;
+      margin: 10px 0;
+
       li {
-        line-height: 1.8em * $ratio;
+        line-height: 1.1em;
       }
     }
 

Разница между файлами не показана из-за своего большого размера
+ 488 - 132
yarn.lock


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