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

Merge branch 'master' into feat/OAuth-twitter

久保田隼基 7 лет назад
Родитель
Сommit
05f0dd825c
90 измененных файлов с 2327 добавлено и 1276 удалено
  1. 1 0
      .babelrc
  2. 2 0
      .eslintrc.js
  3. 4 0
      .gitignore
  4. 4 0
      .vscode/settings.json
  5. 47 3
      CHANGES.md
  6. 11 5
      README.md
  7. 12 3
      THIRD-PARTY-NOTICES.md
  8. 73 45
      config/webpack.common.js
  9. 42 85
      config/webpack.dev.js
  10. 51 0
      config/webpack.dll.js
  11. 46 96
      config/webpack.prod.js
  12. 1 1
      lib/crowi/express-init.js
  13. 9 4
      lib/crowi/index.js
  14. 2 1
      lib/form/admin/securityPassportGitHub.js
  15. 2 1
      lib/form/admin/securityPassportGoogle.js
  16. 150 13
      lib/locales/en-US/sandbox.md
  17. 25 21
      lib/locales/en-US/translation.json
  18. 1 1
      lib/locales/en-US/welcome.md
  19. 150 13
      lib/locales/ja/sandbox.md
  20. 13 9
      lib/locales/ja/translation.json
  21. 1 1
      lib/locales/ja/welcome.md
  22. 5 5
      lib/models/bookmark.js
  23. 5 4
      lib/models/config.js
  24. 27 13
      lib/models/page-group-relation.js
  25. 22 53
      lib/models/page.js
  26. 11 12
      lib/models/revision.js
  27. 31 14
      lib/models/user-group-relation.js
  28. 18 5
      lib/models/user-group.js
  29. 43 18
      lib/routes/admin.js
  30. 9 9
      lib/routes/attachment.js
  31. 21 17
      lib/routes/comment.js
  32. 2 3
      lib/routes/index.js
  33. 20 19
      lib/routes/login-passport.js
  34. 4 30
      lib/routes/page.js
  35. 3 3
      lib/routes/revision.js
  36. 21 2
      lib/service/logger/index.js
  37. 7 7
      lib/service/passport.js
  38. 1 1
      lib/views/_form.html
  39. 1 1
      lib/views/admin/app.html
  40. 5 5
      lib/views/admin/customize.html
  41. 1 1
      lib/views/admin/index.html
  42. 3 4
      lib/views/admin/markdown.html
  43. 224 194
      lib/views/admin/notification.html
  44. 14 11
      lib/views/admin/security.html
  45. 1 1
      lib/views/admin/users.html
  46. 45 20
      lib/views/admin/widget/passport/github.html
  47. 42 92
      lib/views/admin/widget/passport/google-oauth.html
  48. 1 1
      lib/views/admin/widget/theme-colorbox.html
  49. 1 7
      lib/views/layout-crowi/widget/page_side_header.html
  50. 3 3
      lib/views/layout-growi/widget/header.html
  51. 1 1
      lib/views/layout/admin.html
  52. 16 15
      lib/views/layout/layout.html
  53. 64 42
      lib/views/login.html
  54. 12 12
      lib/views/me/external-accounts.html
  55. 1 32
      lib/views/modal/create_page.html
  56. 3 1
      lib/views/modal/create_template.html
  57. 24 2
      lib/views/modal/shortcuts.html
  58. 6 7
      lib/views/page_presentation.html
  59. 7 0
      lib/views/widget/icon-keyboard-return-enter.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. 30 22
      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. 113 29
      resource/js/components/PageComment/CommentForm.js
  72. 2 3
      resource/js/components/PageEditor.js
  73. 26 0
      resource/js/components/PageEditor/AbstractEditor.js
  74. 108 19
      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. 4 4
      resource/js/legacy/crowi.js
  83. 14 3
      resource/js/util/GrowiRenderer.js
  84. 51 0
      resource/js/util/codemirror/autorefresh.ext.js
  85. 5 6
      resource/js/util/markdown-it/blockdiag.js
  86. 4 0
      resource/styles/agile-admin/inverse/colors/mono-blue.scss
  87. 26 2
      resource/styles/scss/_login.scss
  88. 4 0
      resource/styles/scss/_shortcuts.scss
  89. 8 1
      resource/styles/scss/_wiki.scss
  90. 468 167
      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 - 3
CHANGES.md

@@ -1,10 +1,53 @@
 CHANGES
 ========
 
-## 3.1.6-RC
+## 3.1.11
 
-* (WIP) Feature: Support [blockdiag](http://blockdiag.com)
+* 
+
+## 3.1.10
+
+* Fix: OAuth doesn't work in production because callback URL field cannot be specified
+    * Introduced by 3.1.9
+* Fix: Enter key on react-bootstrap-typeahead doesn't submit
+    * Introduced by 3.1.9
+* Fix: CodeMirror of `/admin/customize` is broken
+    * Introduced by 3.1.9
+
+## 3.1.9
+
+* Feature: Login with Google Account
+* Feature: Login with GitHub Account
+* Feature: Attach files in Comment
+* Improvement: Write comment with CodeMirror Editor
+* Improvement: Post comment with `Ctrl-Enter`
+* 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
+    * eslint
+    * react-bootstrap-typeahead
+    * react-codemirror2
+    * webpack
+
+## 3.1.8 (Missing number)
+
+## 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
+    * 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
@@ -31,6 +74,7 @@ CHANGES
 
 ## 3.1.4 (Missing number)
 
+
 ## 3.1.3 (Missing number)
 
 
@@ -93,7 +137,7 @@ CHANGES
 * Fix: Sidebar breaks editor layouts
 * Support: Switch the logger from 'pino' to 'bunyan'
 * Support: Set the alias for 'debug' to the debug function of 'bunyan'
-* Support: Translate /admin/security
+* Support: Translate `/admin/security`
 * Support: Optimize bundles
     * upgrade 'markdown-it-toc-and-anchor-with-slugid' and omit 'uslug'
 * Support: Optimize .eslintrc.js

+ 11 - 5
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,14 +157,21 @@ Environment Variables
 * **Option**
     * NODE_ENV: `production` OR `development`.
     * PORT: Server port. default: `3000`
-    * REDIS_URL: URI to connect to Redis (to session store).
     * ELASTICSEARCH_URI: URI to connect to Elasticearch.
+    * REDIS_URI: URI to connect to Redis (use it as a session store instead of MongoDB).
     * 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`
+* **Option (Overwritable in admin page)**
+    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login
+    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login
+    * OAUTH_GOOGLE_CALLBACK_URI: Google API callback URI for OAuth login (Set `https://${growi.host}/passport/google/callback`)
+    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login
+    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login
+    * OAUTH_GITHUB_CALLBACK_URI: GitHub API callback URI for OAuth login (Set `https://${growi.host}/passport/github/callback`)
 
 
 Documentation

+ 12 - 3
THIRD-PARTY-NOTICES.md

@@ -12,9 +12,18 @@ For any licenses that require disclosure of source, sources are available at
 https://github.com/weseek/growi.
 
 
-1. crowi/crowi (https://github.com/crowi/crowi)
-2. Microsoft/vscode (https://github.com/Microsoft/vscode)
-3. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
+1. Apache License, Version 2.0 Derivative Works
+2. crowi/crowi (https://github.com/crowi/crowi)
+3. Microsoft/vscode (https://github.com/Microsoft/vscode)
+4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
+
+
+License Notice for Apache License, Version 2.0 Derivative Works
+--------------------------------------------------------
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+This software includes works that is distributed in the Apache License 2.0
 
 
 License Notice for Crowi

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

+ 46 - 96
config/webpack.prod.js

@@ -3,117 +3,67 @@
  */
 
 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 UglifyJsPlugin = require('uglifyjs-webpack-plugin');
 const ExtractTextPlugin = require('extract-text-webpack-plugin');
-const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
-const OptimizeJsPlugin = require('optimize-js-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 } }
-            ]
-          }),
-          include: [helpers.root('resource/styles/scss')]
-        }
-      ]
-    },
-    plugins: [
+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: ExtractTextPlugin.extract({
+          use: [
+            'css-loader',
+            { loader: 'postcss-loader', options: {
+              sourceMap: false,
+              plugins: (loader) => [
+                require('autoprefixer')()
+              ]
+            } },
+            '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 ExtractTextPlugin({
+      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 - 4
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,9 +262,14 @@ Crowi.prototype.setupPassport = function() {
   this.passportService.setupSerializer();
   // setup strategies
   this.passportService.setupLocalStrategy();
-  this.passportService.setupLdapStrategy();
-  this.passportService.setupGoogleStrategy();
-  this.passportService.setupGitHubStrategy();
+  try {
+    this.passportService.setupLdapStrategy();
+    this.passportService.setupGoogleStrategy();
+    this.passportService.setupGitHubStrategy();
+  }
+  catch (err) {
+    logger.error(err);
+  }
   return Promise.resolve();
 };
 

+ 2 - 1
lib/form/admin/securityPassportGitHub.js

@@ -8,5 +8,6 @@ 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()
+  field('settingForm[security:passport-github:callbackUrl]').trim(),
+  field('settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );

+ 2 - 1
lib/form/admin/securityPassportGoogle.js

@@ -8,5 +8,6 @@ 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()
+  field('settingForm[security:passport-google:callbackUrl]').trim(),
+  field('settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );

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

+ 25 - 21
lib/locales/en-US/translation.json

@@ -206,20 +206,23 @@
   },
 
   "modal_shortcuts": {
-      "global": {
-          "title": "Global shortcuts",
-          "Open/Close shortcut help": "Open/Close shortcut help",
-          "Edit Page": "Edit Page",
-          "Create Page": "Create Page"
-      },
-      "editor": {
-          "title": "Editor shortcuts",
-          "Indent": "Indent",
-          "Outdent": "Outdent",
-          "Save Page": "Save Page",
-          "Delete Line": "Delete Line"
-
-              }
+    "global": {
+      "title": "Global shortcuts",
+      "Open/Close shortcut help": "Open/Close shortcut help",
+      "Edit Page": "Edit Page",
+      "Create Page": "Create Page"
+    },
+    "editor": {
+      "title": "Editor shortcuts",
+      "Indent": "Indent",
+      "Outdent": "Outdent",
+      "Save Page": "Save Page",
+      "Delete Line": "Delete Line"
+    },
+    "commentform": {
+      "title": "Comment Form shortcuts",
+      "Post": "Post"
+    }
   },
 
   "template": {
@@ -307,22 +310,24 @@
     "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\" target=\"_blank\">API Manager on Google Cloud Platform</a>",
-		"access_api_manager": "Access <a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager</a>",
+		"access_api_manager": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
 		"create_project": "Create Project if no projects have been created.",
 		"create_auth_to_oauth": "\"Create credentials\" -> \"OAuth clientID\"",
 		"select_webapp": "Select \"Web Application\"",
     "change_redirect_url": "Enter <code>https://${crowi.host}/google/callback</code> <br>(where <code>${crowi.host}</code> is your host name) for \"Authorized redirect URIs\".",
     "clientID": "Client ID",
     "client_secret": "Client Secret",
+    "callback_URL": "Callback URL",
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "readonly": "View Only"
@@ -336,6 +341,7 @@
     "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>.",
+    "Use env var if empty": "Use env var <code>%s</code> if empty",
     "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",
@@ -367,7 +373,6 @@
     },
     "OAuth": {
       "register": "Register for %s",
-      "connect_api_manager": "Register your Growi at <a href=\"%s\" target=\"_blank\">%s</a>",
       "change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
       "Google": {
         "name": "Google OAuth",
@@ -375,7 +380,7 @@
         "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 below"
+        "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "Facebook": {
         "name": "Facebook OAuth"
@@ -387,7 +392,7 @@
         "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 below"
+        "register_3": "Copy and paste your ClientID and Client Secret above"
       }
     }
 	},
@@ -397,8 +402,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>

+ 13 - 9
lib/locales/ja/translation.json

@@ -234,6 +234,9 @@
         "Outdent": "左インデント",
         "Save Page": "保存",
         "Delete Line": "行削除"
+    },
+    "commentform": {
+      "Post": "投稿"
     }
   },
 
@@ -248,11 +251,11 @@
     },
     "children": {
       "label": "同一階層テンプレート",
-      "desc": "テンプレートページが存在する階層にのみ適されます"
+      "desc": "テンプレートページが存在する階層にのみ適されます"
     },
     "decendants": {
       "label": "下位層テンプレート",
-      "desc": "テンプレートページが存在する下位層のすべてのページに適されます"
+      "desc": "テンプレートページが存在する下位層のすべてのページに適されます"
     }
   },
 
@@ -325,21 +328,23 @@
     "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\" target=\"_blank\">API Manager</a>から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。",
-    "access_api_manager": "<a href=\"https://console.cloud.google.com/apis/credentials\" target=\"_blank\">API Manager</a> へアクセス",
+    "access_api_manager": "<a href=\"%s\" target=\"_blank\">%s</a> へアクセス",
     "create_project": "プロジェクトを作成していない場合は作成してください",
     "create_auth_to_oauth": "「認証情報を作成」-> OAuthクライアントID",
     "select_webapp": "「ウェブアプリケーション」を選択",
     "change_redirect_url": "承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>(<code>${crowi.host}</code>は環境に合わせて変更してください)",
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
+    "callback_URL": "コールバックURL",
     "guest_mode": {
       "deny": "アカウントを持たないユーザーはアクセス不可",
       "readonly": "閲覧のみ許可"
@@ -353,6 +358,7 @@
     "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
@@ -384,7 +390,6 @@
     },
     "OAuth": {
       "register": "%sに登録",
-      "connect_api_manager": "あなたのGrowiを<a href=\"%s\" target=\"_blank\">%s</a>で登録してください。",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力<br>(<code>%s</code>は環境に合わせて変更してください)",
       "Google": {
         "name": "Google OAuth認証",
@@ -392,7 +397,7 @@
         "register_2": "プロジェクトがない場合はプロジェクトを作成",
         "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
         "register_4": "承認済みのリダイレクトURIを<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
-        "register_5": "以下にクライアントIDとクライアントシークレットを貼り付ける"
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       "Facebook": {
         "name": "Facebook OAuth認証"
@@ -404,7 +409,7 @@
         "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とクライアントシークレットを貼り付ける"
+        "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
       }
     }
   },
@@ -413,8 +418,7 @@
     "Enable Line Break": "Line Break を有効にする",
     "Enable Line Break desc": "ページテキスト中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います",
     "Enable Line Break for comment": "コメント欄で Line Break を有効にする",
-    "Enable Line Break for comment desc": "コメント中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います",
-    "TBD": "(TBD: コメント欄の Markdown 化は未だ実装されていません)"
+    "Enable Line Break for comment desc": "コメント中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います"
 
   },
 

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

+ 5 - 4
lib/models/config.js

@@ -101,7 +101,7 @@ module.exports = function(crowi) {
 
   function getDefaultMarkdownConfigs() {
     return {
-      'markdown:isEnabledLinebreaks': true,
+      'markdown:isEnabledLinebreaks': false,
       'markdown:isEnabledLinebreaksInComments': true,
     };
   }
@@ -317,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];
@@ -328,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];
@@ -472,7 +472,8 @@ 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: {

+ 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 - 53
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);
         }
@@ -532,26 +530,6 @@ module.exports = function(crowi) {
     });
   };
 
-  // check if a given page has a children and decendants tempalte
-  pageSchema.statics.checkIfTemplatesExist = function(path) {
-    const Page = this;
-    const pathList = generatePathsOnTree(path, []);
-    const regexpList = pathList.map(path => new RegExp(`${path}/_{1,2}template`));
-    let templateInfo = {
-      childrenTemplateExists: false,
-      decendantsTemplateExists: false,
-    };
-
-    return Page
-      .find({path: {$in: regexpList}})
-      .then(templates => {
-        templateInfo.childrenTemplateExists = (assignTemplateByType(templates, path, '_') ? true : false);
-        templateInfo.decendantsTemplateExists = (assignTemplateByType(templates, path, '__') ? true : false);
-
-        return templateInfo;
-      });
-  };
-
   /**
    * find all templates applicable to the new page
    */
@@ -641,12 +619,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 +641,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 +693,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 +774,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 +1031,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) {

+ 11 - 12
lib/models/revision.js

@@ -3,12 +3,11 @@ 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, get: (data) => {
       // replace CR/CRLF to LF above v3.1.5
@@ -42,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)
@@ -58,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)) {
@@ -88,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) {
@@ -106,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) {
@@ -120,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;
@@ -142,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});
   }
 

+ 43 - 18
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 = {};
@@ -904,7 +915,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.api.securityPassportGoogleSetting = async(req, res) => {
-    var form = req.form.settingForm;
+    const form = req.form.settingForm;
 
     if (!req.form.isValid) {
       return res.json({status: false, message: req.form.errors.join('\n')});
@@ -918,14 +929,21 @@ module.exports = function(crowi, app) {
     await crowi.passportService.resetGoogleStrategy();
     // setup strategy
     if (Config.isEnabledPassportGoogle(config)) {
-      await crowi.passportService.setupGoogleStrategy(true);
+      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) => {
-    var form = req.form.settingForm;
+    const form = req.form.settingForm;
 
     if (!req.form.isValid) {
       return res.json({status: false, message: req.form.errors.join('\n')});
@@ -938,15 +956,22 @@ module.exports = function(crowi, app) {
     // reset strategy
     await crowi.passportService.resetGitHubStrategy();
     // setup strategy
-    if (Config.isEnabledPassportGoogle(config)) {
-      await crowi.passportService.setupGitHubStrategy(true);
+    if (Config.isEnabledPassportGitHub(config)) {
+      try {
+        await crowi.passportService.setupGitHubStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetGitHubStrategy();
+        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);
@@ -958,7 +983,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.api.customizeSetting = function(req, res) {
-    var form = req.form.settingForm;
+    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')));
     }

+ 2 - 3
lib/routes/index.js

@@ -70,8 +70,8 @@ module.exports = function(crowi, app) {
   // 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'                      , loginPassport.loginWithGoogle);
+  app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
 
@@ -175,7 +175,6 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
-  app.get('/_api/pages.templates'   , accessTokenParser , loginRequired(crowi, app, false) , page.api.templates);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);

+ 20 - 19
lib/routes/login-passport.js

@@ -1,7 +1,7 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:login-passport')
+  const debug = require('debug')('growi:routes:login-passport')
     , logger = require('@alias/logger')('growi:routes:login-passport')
     , passport = require('passport')
     , config = crowi.getConfig()
@@ -24,7 +24,7 @@ module.exports = function(crowi, app) {
       }
     });
 
-    var jumpTo = req.session.jumpTo;
+    const jumpTo = req.session.jumpTo;
     if (jumpTo) {
       req.session.jumpTo = null;
       return res.redirect(jumpTo);
@@ -101,7 +101,7 @@ module.exports = function(crowi, app) {
       'id': ldapAccountId,
       'username': usernameToBeRegistered,
       'name': nameToBeRegistered
-    }
+    };
 
     const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
     if (!externalAccount) {
@@ -112,7 +112,7 @@ module.exports = function(crowi, app) {
 
     // login
     await req.logIn(user, err => {
-      if (err) { return next(err) };
+      if (err) { return next(err) }
       return loginSuccess(req, res, user);
     });
   };
@@ -205,10 +205,11 @@ module.exports = function(crowi, app) {
     })(req, res, next);
   };
 
-  const loginPassportGoogle = function(req, res) {
+  const loginWithGoogle = function(req, res, next) {
     if (!passportService.isGoogleStrategySetup) {
       debug('GoogleStrategy has not been set up');
-      return;
+      req.flash('warningMessage', 'GoogleStrategy has not been set up');
+      return next();
     }
 
     passport.authenticate('google', {
@@ -224,7 +225,7 @@ module.exports = function(crowi, app) {
       '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);
@@ -233,16 +234,17 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
 
     // login
-    await req.logIn(user, err => {
-      if (err) { return next(err) };
+    req.logIn(user, err => {
+      if (err) { return next(err) }
       return loginSuccess(req, res, user);
     });
   };
 
-  const loginPassportGitHub = function(req, res) {
+  const loginWithGitHub = function(req, res, next) {
     if (!passportService.isGitHubStrategySetup) {
       debug('GitHubStrategy has not been set up');
-      return;
+      req.flash('warningMessage', 'GitHubStrategy has not been set up');
+      return next();
     }
 
     passport.authenticate('github')(req, res);
@@ -256,7 +258,7 @@ module.exports = function(crowi, app) {
       'id': response.id,
       'username': response.username,
       'name': response.displayName
-    }
+    };
 
     const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
     if (!externalAccount) {
@@ -266,11 +268,10 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
 
     // login
-    await req.logIn(user, err => {
-      if (err) { return next(err) };
+    req.logIn(user, err => {
+      if (err) { return next(err) }
       return loginSuccess(req, res, user);
     });
-    return next()
   };
 
   const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
@@ -291,7 +292,7 @@ module.exports = function(crowi, app) {
           return next();
         }
 
-        resolve(response)
+        resolve(response);
       })(req, res, next);
     });
   };
@@ -322,15 +323,15 @@ module.exports = function(crowi, app) {
         }
       }
     }
-  }
+  };
 
   return {
     loginFailure,
     loginWithLdap,
     testLdapCredentials,
     loginWithLocal,
-    loginPassportGoogle,
-    loginPassportGitHub,
+    loginWithGoogle,
+    loginWithGitHub,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
   };

+ 4 - 30
lib/routes/page.js

@@ -281,9 +281,6 @@ module.exports = function(crowi, app) {
         .then(function(tree) {
           renderVars.tree = tree;
         })
-        .then(function() {
-          return Page.checkIfTemplatesExist(path);
-        })
         .then(() => {
           return PageGroupRelation.findByPage(renderVars.page);
         })
@@ -643,15 +640,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) {
@@ -839,8 +831,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) {
@@ -1194,23 +1186,5 @@ module.exports = function(crowi, app) {
     });
   };
 
-  /**
-   * @api {get} /pages.templates Check if templates exist for page
-   * @apiName FindTemplates
-   * @apiGroup Page
-   *
-   * @apiParam {String} path
-   */
-  api.templates = function(req, res) {
-    const pagePath = req.query.path;
-    const templateFinder = Page.checkIfTemplatesExist(pagePath);
-
-    templateFinder.then(function(templateInfo) {
-      return res.json(ApiResponse.success(templateInfo));
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
-  };
-
   return actions;
 };

+ 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

+ 7 - 7
lib/service/passport.js

@@ -188,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'}) };
@@ -265,9 +265,9 @@ class PassportService {
 
     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
+      clientId: config.crowi['security:passport-google:clientId'] || process.env.OAUTH_GOOGLE_CLIENT_ID,
+      clientSecret: config.crowi['security:passport-google:clientSecret'] || process.env.OAUTH_GOOGLE_CLIENT_SECRET,
+      callbackURL: config.crowi['security:passport-google:callbackUrl'] || process.env.OAUTH_GOOGLE_CALLBACK_URI,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {
@@ -311,9 +311,9 @@ class PassportService {
 
     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
+      clientID: config.crowi['security:passport-github:clientId'] || process.env.OAUTH_GITHUB_CLIENT_ID,
+      clientSecret: config.crowi['security:passport-github:clientSecret'] || process.env.OAUTH_GITHUB_CLIENT_SECRET,
+      callbackURL: config.crowi['security:passport-github:callbackUrl'] || process.env.OAUTH_GITHUB_CALLBACK_URI,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {

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

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

@@ -92,7 +92,7 @@
         <div class="form-group">
           <label for="settingForm[mail.from]" class="col-xs-3 control-label">{{ t('app_setting.From e-mail address') }}</label>
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[mail:from]" placeholder="例: mail@crowi.wiki" value="{{ settingForm['mail:from'] }}">
+            <input class="form-control" type="text" name="settingForm[mail:from]" placeholder="例: mail@growi.org" value="{{ settingForm['mail:from'] }}">
           </div>
         </div>
 

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

+ 224 - 194
lib/views/admin/notification.html

@@ -38,231 +38,261 @@
 
       <ul class="nav nav-tabs" role="tablist">
         <li class="active">
-          <a href="#slack-incoming-webhooks" data-toggle="tab" role="tab"><i class="icon-settings"></i> Slack Incoming Webhooks</a>
+          <a href="#user-trigger-notification" data-toggle="tab" role="tab"><i class="icon-settings"></i> User Trigger Notification</a>
         </li>
         <li role="tab">
-          <a href="#slack-app" data-toggle="tab" role="tab"><i class="icon-settings"></i> Slack App</a>
+          <a href="#global-notification" data-toggle="tab" role="tab"><i class="icon-settings"></i> Global Notification</a>
         </li>
       </ul>
-
       <div class="tab-content m-t-15">
-        <div id="slack-incoming-webhooks" class="tab-pane active" role="tabpanel">
-
-          <form action="/admin/notification/slackIwhSetting" method="post" class="form-horizontal" id="appSettingForm" role="form">
-            <fieldset>
-              <legend>Slack Incoming Webhooks Configuration</legend>
-
-              <div class="form-group">
-                <label for="slackIwhSetting[slack:incomingWebhookUrl]" class="col-xs-3 control-label">Webhook URL</label>
-                <div class="col-xs-9">
-                  <input class="form-control" type="text" name="slackIwhSetting[slack:incomingWebhookUrl]" value="{{ slackSetting['slack:incomingWebhookUrl'] }}">
-                </div>
-              </div>
-
-              <div class="form-group">
-                <label for="slackIwhSetting[slack:isIncomingWebhookPrioritized]" class="col-xs-3 control-label"></label>
-                <div class="col-xs-9">
-                  <div class="checkbox checkbox-info">
-                    <input type="checkbox" id ="cbPrioritizeIWH" name="slackIwhSetting[slack:isIncomingWebhookPrioritized]" value="1"
-                      {% if slackSetting['slack:isIncomingWebhookPrioritized'] %}checked{% endif %}>
-                    <label for="cbPrioritizeIWH">
-                      Prioritize Incoming Webhook than Slack App
-                    </label>
+        <div id="user-trigger-notification" class="tab-pane active" role="tabpanel">
+
+          <select class="selectpicker" id="selectSlackOption" data-width="auto">
+            <option value="1">Slack Incoming Webhooks</option>
+            <option value="2">Slack App</option>
+          </select><!-- /.select-tab -->
+
+          <div class="tab-content m-t-15">
+            <div id="slack-incoming-webhooks" class="tab-pane active" role="tabpanel">
+
+              <form action="/admin/notification/slackIwhSetting" method="post" class="form-horizontal" id="appSettingForm" role="form">
+                <fieldset>
+                  <legend>Slack Incoming Webhooks Configuration</legend>
+
+                  <div class="form-group">
+                    <label for="slackIwhSetting[slack:incomingWebhookUrl]" class="col-xs-3 control-label">Webhook URL</label>
+                    <div class="col-xs-9">
+                      <input class="form-control" type="text" name="slackIwhSetting[slack:incomingWebhookUrl]" value="{{ slackSetting['slack:incomingWebhookUrl'] }}">
+                    </div>
                   </div>
-                  <p class="help-block">Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.</p>
-                </div>
-              </div>
-
-              <div class="form-group">
-                <div class="col-xs-offset-3 col-xs-6">
-                  <button type="submit" class="btn btn-primary">Save</button>
-                </div>
-              </div>
-            </fieldset>
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-          </form>
 
-          <hr>
-          <h3>
-            <i class="icon-question" aria-hidden="true"></i>
-            <a href="#collapseHelpForIwh" data-toggle="collapse">How to configure Incoming Webhooks?</a>
-          </h3>
-
-          <ol id="collapseHelpForIwh" class="collapse">
-            <li>
-              (At Workspace) Add a hook
-              <ol>
-                <li>Go to <a href="https://slack.com/services/new/incoming-webhook">Incoming Webhooks Configuration page</a>.</li>
-                <li>Choose the default channel to post.</li>
-                <li>Add.</li>
-              </ol>
-            </li>
-            <li>
-              (At GROWI admin page) Set Webhook URL
-              <ol>
-                <li>Input "Webhook URL" and submit on this page.</li>
-              </ol>
-            </li>
-          </ol>
-
-        </div><!-- /#slack-incoming-webhooks -->
-
-        <div id="slack-app" class="tab-pane" role="tabpanel" >
-
-          <form action="/admin/notification/slackSetting" method="post" class="form-horizontal" id="appSettingForm" role="form">
-            <fieldset>
-              <legend>Slack App Configuration</legend>
-
-              <p class="well">
-                <i class="icon-fw icon-exclamation text-danger"></i><span class="text-danger">NOT RECOMMENDED</span>
-                <br><br>
-                This is the way that compatible with Crowi,<br>
-                but not recommended in GROWI because it is <strong>too complex</strong>.
-                <br><br>
-                Please use <a href="#slack-incoming-webhooks" data-toggle="tab" onclick="activateTab('slack-incoming-webhooks')">Slack incomming webhooks Configuration</a> instead.
-              </p>
-
-              <div class="form-group">
-                <label for="slackSetting[slack:token]" class="col-xs-3 control-label">OAuth Access Token</label>
-                <div class="col-xs-6">
-                  <input class="form-control" type="text" name="slackSetting[slack:token]" value="{{ slackSetting['slack:token'] || '' }}">
-                </div>
-              </div>
-
-              <div class="form-group">
-                <div class="col-xs-offset-3 col-xs-6">
-                  <button type="submit" class="btn btn-primary">Save</button>
-                </div>
-              </div>
-            </fieldset>
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-          </form>
+                  <div class="form-group">
+                    <label for="slackIwhSetting[slack:isIncomingWebhookPrioritized]" class="col-xs-3 control-label"></label>
+                    <div class="col-xs-9">
+                      <div class="checkbox checkbox-info">
+                        <input type="checkbox" id ="cbPrioritizeIWH" name="slackIwhSetting[slack:isIncomingWebhookPrioritized]" value="1"
+                         {% if slackSetting['slack:isIncomingWebhookPrioritized'] %}checked{% endif %}>
+                        <label for="cbPrioritizeIWH">
+                         Prioritize Incoming Webhook than Slack App
+                        </label>
+                      </div>
+                      <p class="help-block">Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.</p>
+                    </div>
+                  </div>
 
-          <hr>
-          <h3>
-            <i class="icon-question" aria-hidden="true"></i>
-            <a href="#collapseHelpForApp" data-toggle="collapse">How to configure Slack App?</a>
-          </h3>
-
-          <ol id="collapseHelpForApp" class="collapse">
-            <li>
-              Register Slack App
-              <ol>
+                  <div class="form-group">
+                    <div class="col-xs-offset-3 col-xs-6">
+                      <button type="submit" class="btn btn-primary">Save</button>
+                    </div>
+                  </div>
+                </fieldset>
+                <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              </form>
+
+              <hr>
+              <h3>
+                <i class="icon-question" aria-hidden="true"></i>
+                <a href="#collapseHelpForIwh" data-toggle="collapse">How to configure Incoming Webhooks?</a>
+              </h3>
+
+              <ol id="collapseHelpForIwh" class="collapse">
                 <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 Workspace</dt> <dd>Select the workspace you want to notify to.</dd>
-                  </dl>
+                 (At Workspace) Add a hook
+                  <ol>
+                    <li>Go to <a href="https://slack.com/services/new/incoming-webhook">Incoming Webhooks Configuration page</a>.</li>
+                    <li>Choose the default channel to post.</li>
+                    <li>Add.</li>
+                  </ol>
+                </li>
+                <li>
+                (At GROWI admin page) Set Webhook URL
+                  <ol>
+                    <li>Input "Webhook URL" and submit on this page.</li>
+                  </ol>
                 </li>
-                <li><strong>Save</strong> it.</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 Workspace" page and install.</li>
-                <li>Go to "OAuth &amp; Permissions" page and copy <code>OAuth Access Token</code>.</li>
-              </ol>
-            </li>
-            <li>
-              (At this page) Set OAuth Access Token
-              <ol>
-                <li>Input "OAuth Access Token".</li>
-                <li>Don't forget to <strong>save</strong>.</li>
               </ol>
-            </li>
-          </ol>
 
-        </div><!-- /#slack-app -->
+            </div><!-- /#slack-incoming-webhooks -->
 
+            <div id="slack-app" class="tab-pane" role="tabpanel" >
 
+              <form action="/admin/notification/slackSetting" method="post" class="form-horizontal" id="appSettingForm" role="form">
+                <fieldset>
+                  <legend>Slack App Configuration</legend>
 
-      </div><!-- /.tab-content -->
+                  <p class="well">
+                    <i class="icon-fw icon-exclamation text-danger"></i><span class="text-danger">NOT RECOMMENDED</span>
+                    <br><br>
+                    This is the way that compatible with Crowi,<br>
+                    but not recommended in GROWI because it is <strong>too complex</strong>.
+                    <br><br>
+                    Please use <a href="#slack-incoming-webhooks" data-toggle="tab" onclick="activateSlackIwh()">Slack incomming webhooks Configuration</a> instead.
+                  </p>
+
+                  <div class="form-group">
+                    <label for="slackSetting[slack:token]" class="col-xs-3 control-label">OAuth Access Token</label>
+                    <div class="col-xs-6">
+                      <input class="form-control" type="text" name="slackSetting[slack:token]" value="{{ slackSetting['slack:token'] || '' }}">
+                    </div>
+                  </div>
 
-      <hr>
-
-      <h4>Default Notification Settings for Patterns</h4>
-
-      <table class="table table-bordered">
-        <thead>
-          <th>Pattern</th>
-          <th>Channel</th>
-          <th>Operation</th>
-        </thead>
-        <tbody class="admin-notif-list">
-          <form id="slackNotificationForm">
-          <tr>
-            <td>
-              <input class="form-control" type="text" name="pathPattern" value="" placeholder="e.g. /projects/xxx/MTG/*">
-              <p class="help-block">
-                Path name of wiki. Pattern expression with <code>*</code> can be used.
-              </p>
-            </td>
-            <td>
-              <input class="form-control form-inline" type="text" name="channel" value="" placeholder="e.g. project-xxx">
-              <p class="help-block">
-                Slack channel name. Without <code>#</code>.
-              </p>
-            </td>
-            <td>
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <input type="submit" value="Add" class="btn btn-primary">
-            </td>
-          </tr>
-          </form>
-
-          {% for notif in settings %}
-          <tr class="admin-notif-row" data-updatepost-id="{{ notif._id.toString() }}">
-            <td>
-              {{ notif.pathPattern }}
-            </td>
-            <td>
-              {{ notif.channel }}
-            </td>
-            <td>
-              <form class="admin-remove-updatepost">
-                <input type="hidden" name="id" value="{{ notif._id.toString() }}">
+                  <div class="form-group">
+                    <div class="col-xs-offset-3 col-xs-6">
+                      <button type="submit" class="btn btn-primary">Save</button>
+                    </div>
+                  </div>
+                </fieldset>
                 <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <input type="submit" value="Delete" class="btn btn-default">
               </form>
-            </td>
-          </tr>
-          {% endfor %}
-        </tbody>
-      </table>
 
+              <hr>
+              <h3>
+                <i class="icon-question" aria-hidden="true"></i>
+                <a href="#collapseHelpForApp" data-toggle="collapse">How to configure Slack App?</a>
+              </h3>
+
+              <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 Workspace</dt> <dd>Select the workspace you want to notify to.</dd>
+                      </dl>
+                    </li>
+                    <li><strong>Save</strong> it.</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 Workspace" page and install.</li>
+                    <li>Go to "OAuth &amp; Permissions" page and copy <code>OAuth Access Token</code>.</li>
+                  </ol>
+                </li>
+                <li>
+                  (At this page) Set OAuth Access Token
+                  <ol>
+                    <li>Input "OAuth Access Token".</li>
+                    <li>Don't forget to <strong>save</strong>.</li>
+                  </ol>
+                </li>
+              </ol>
+
+            </div><!-- /#slack-app -->
+
+          </div><!-- /.tab-content -->
+          <hr>
+          <h4>Default Notification Settings for Patterns</h4>
+
+          <table class="table table-bordered">
+            <thead>
+              <th>Pattern</th>
+              <th>Channel</th>
+              <th>Operation</th>
+            </thead>
+            <tbody class="admin-notif-list">
+              <form id="slackNotificationForm">
+              <tr>
+                <td>
+                  <input class="form-control" type="text" name="pathPattern" value="" placeholder="e.g. /projects/xxx/MTG/*">
+                  <p class="help-block">
+                    Path name of wiki. Pattern expression with <code>*</code> can be used.
+                  </p>
+                </td>
+                <td>
+                  <input class="form-control form-inline" type="text" name="channel" value="" placeholder="e.g. project-xxx">
+                  <p class="help-block">
+                    Slack channel name. Without <code>#</code>.
+                  </p>
+                </td>
+                <td>
+                  <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  <input type="submit" value="Add" class="btn btn-primary">
+                </td>
+              </tr>
+              </form>
+
+              {% for notif in settings %}
+              <tr class="admin-notif-row" data-updatepost-id="{{ notif._id.toString() }}">
+                <td>
+                  {{ notif.pathPattern }}
+                </td>
+                <td>
+                  {{ notif.channel }}
+                </td>
+                <td>
+                  <form class="admin-remove-updatepost">
+                    <input type="hidden" name="id" value="{{ notif._id.toString() }}">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                    <input type="submit" value="Delete" class="btn btn-default">
+                  </form>
+                </td>
+              </tr>
+              {% endfor %}
+            </tbody>
+          </table>
+        </div><!-- /#user-trigger-notification -->
+
+        <div id="global-notification" class="tab-pane" role="tabpanel" >
+          <p class="alert alert-info">not implemented now</p>
+        </div><!-- /#global-notification -->
+
+      </div><!-- /.tab-content -->
 
     </div>
   </div>
 
   <script>
+    function activateTab(tab){
+      $('.nav-tabs a[href="#' + tab + '"]').tab('show');
+    };
+
+    function activateSlackIwh() {
+      $("#selectSlackOption").selectpicker('val', '1');
+      $("#slack-app").removeClass('active');
+      $("#slack-incoming-webhooks").addClass('active');
+    }
+
+    function activateSlackApp() {
+      $("#selectSlackOption").selectpicker('val', '2');
+      $("#slack-incoming-webhooks").removeClass('active');
+      $("#slack-app").addClass('active');
+    }
+
     window.addEventListener('load', function(e) {
       // hash on page
       if (location.hash) {
-        if (location.hash == '#slack-app') {
-          activateTab('slack-app');
+        if (location.hash == '#global-notification') {
+          activateTab('global-notification');
         }
       }
     });
 
-    function activateTab(tab){
-      $('.nav-tabs a[href="#' + tab + '"]').tab('show');
-    };
+    $("#selectSlackOption").on('change', function() {
+      if (this.value === "1") {
+        activateSlackIwh();
+      }
+      else if (this.value === "2") {
+        activateSlackApp();
+      }
+    });
   </script>
 </div>
 {% endblock content_main %}

+ 14 - 11
lib/views/admin/security.html

@@ -84,7 +84,7 @@
           <div class="form-group">
             <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">{{ t('The whitelist of registration permission E-mail address') }}</label>
             <div class="col-xs-8">
-              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
+              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @growi.org">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
               <p class="help-block">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
               {{ t("security_setting.insert_single") }}</p>
             </div>
@@ -121,9 +121,9 @@
                 <li>{{ t("security_setting.username_email_password") }}</li>
                 <li>{{ t("security_setting.ldap_auth") }}</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>{{ t("security_setting.github_auth2") }}</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</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>
 

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

@@ -45,7 +45,7 @@
         <div id="inviteUserForm" class="collapse">
           <div class="form-group">
             <label for="inviteForm[emailList]">メールアドレス (複数行入力で複数人招待可能)</label>
-            <textarea class="form-control" name="inviteForm[emailList]" placeholder="例: user@crowi.wiki"></textarea>
+            <textarea class="form-control" name="inviteForm[emailList]" placeholder="例: user@growi.org"></textarea>
           </div>
           <div class="checkbox checkbox-info">
             <input type="checkbox" id="inviteWithEmail" name="inviteForm[sendEmail]" checked>

+ 45 - 20
lib/views/admin/widget/passport/github.html

@@ -1,9 +1,10 @@
 <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>{{ t("security_setting.OAuth.GitHub.name") }}{{ t("security_setting.configuration") }}</legend>
-  <p class="well alert-anchor">{{ t("security_setting.OAuth.connect_api_manager", "https://github.com/settings/developers", "GitHub Developer Settings") }}</p>
+  <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">
@@ -22,29 +23,41 @@
   <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.OAuth.register", t("security_setting.OAuth.GitHub.name") ) }}</label>
+      <label for="settingForm[security:passport-github:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
       <div class="col-xs-6">
-        <ol class="help-block">
-          <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>
+        <input class="form-control" type="text" name="settingForm[security:passport-github:clientId]" value="{{ settingForm['security:passport-github:clientId'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CLIENT_SECRET") }}
+          </small>
+        </p>
       </div>
     </div>
 
     <div class="form-group">
-      <label for="settingForm[security:passport-github:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <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:clientId]" value="{{ settingForm['security:passport-github:clientId'] || '' }}">
+        <input class="form-control" type="text" name="settingForm[security:passport-github:clientSecret]" value="{{ settingForm['security:passport-github:clientSecret'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CLIENT_SECRET") }}
+          </small>
+        </p>
       </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>
+      <label for="settingForm[security:passport-github:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
       <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-github:clientSecret]" value="{{ settingForm['security:passport-github:clientSecret'] || '' }}">
+        <input class="form-control" type="text" name="settingForm[security:passport-github:callbackUrl]" value="{{ settingForm['security:passport-github:callbackUrl'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CALLBACK_URL") }}
+          </small>
+        </p>
       </div>
     </div>
+
     <div class="form-group">
       <div class="col-xs-6 col-xs-offset-3">
         <div class="checkbox checkbox-info">
@@ -73,16 +86,28 @@
 
 </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");
+    const isEnabled = ($(this).val() === "true");
 
-      if (isEnabled) {
-        $('#passport-github-hide-when-disabled').show(400);
-      }
-      else {
-        $('#passport-github-hide-when-disabled').hide(400);
-      }
-    });
+    if (isEnabled) {
+      $('#passport-github-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-github-hide-when-disabled').hide(400);
+    }
+  });
 </script>
 

+ 42 - 92
lib/views/admin/widget/passport/google-oauth.html

@@ -1,9 +1,10 @@
 <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>{{ t("security_setting.OAuth.Google.name") }}{{ t("security_setting.configuration") }}</legend>
-  <p class="well alert-anchor">{{ t("security_setting.OAuth.connect_api_manager", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</p>
+  <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">
@@ -22,31 +23,41 @@
   <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.OAuth.register", t("security_setting.OAuth.Google.name") ) }}</label>
+      <label for="settingForm[security:passport-google:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
       <div class="col-xs-6">
-        <ol class="help-block">
-          <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>
+        <input class="form-control" type="text" name="settingForm[security:passport-google:clientId]" value="{{ settingForm['security:passport-google:clientId'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CLIENT_ID") }}
+          </small>
+        </p>
       </div>
     </div>
 
     <div class="form-group">
-      <label for="settingForm[security:passport-google:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <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:clientId]" value="{{ settingForm['security:passport-google:clientId'] || '' }}">
+        <input class="form-control" type="text" name="settingForm[security:passport-google:clientSecret]" value="{{ settingForm['security:passport-google:clientSecret'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CLIENT_SECRET") }}
+          </small>
+        </p>
       </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>
+      <label for="settingForm[security:passport-google:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
       <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-google:clientSecret]" value="{{ settingForm['security:passport-google:clientSecret'] || '' }}">
+        <input class="form-control" type="text" name="settingForm[security:passport-google:callbackUrl]" value="{{ settingForm['security:passport-google:callbackUrl'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CALLBACK_URL") }}
+          </small>
+        </p>
       </div>
     </div>
+
     <div class="form-group">
       <div class="col-xs-6 col-xs-offset-3">
         <div class="checkbox checkbox-info">
@@ -74,91 +85,30 @@
   </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");
+    const isEnabled = ($(this).val() === "true");
 
-      if (isEnabled) {
-        $('#passport-google-hide-when-disabled').show(400);
-      }
-      else {
-        $('#passport-google-hide-when-disabled').hide(400);
-      }
-    });
+    if (isEnabled) {
+      $('#passport-google-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-google-hide-when-disabled').hide(400);
+    }
+  });
 </script>

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

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

+ 64 - 42
lib/views/login.html

@@ -146,51 +146,73 @@
         {% endif %}
 
         {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() %}
-        <hr>
-        <div class="input-group m-t-15 m-b-10 mx-auto d-flex flex-row justify-content-around flex-wrap">
-          {% if passportGoogleLoginEnabled() %}
-          <form role="form" action="/passport/google" method="get">
-            <button type="submit" class="fcbtn btn btn-1b btn-login-oauth" id="google">
-              <span class="btn-label"><i class="icon-social-google"></i></span>
-              {{ t('Sign in') }}
-            </button>
-            <div class="small text-right">by Google Account</div>
-          </form>
-          {% endif %}
-          {% if passportGitHubLoginEnabled() %}
-          <form role="form" action="/passport/github" method="get">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="fcbtn btn btn-1b btn-login-oauth" id="github">
-              <span class="btn-label"><i class="icon-social-github"></i></span>
-              {{ t('Sign in') }}
-            </button>
-            <div class="small text-right">by Github Account</div>
-          </form>
-          {% endif %}
-          {% if passportFacebookLoginEnabled() %}
-          <form role="form" action="/passport/facebook" method="get">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="fcbtn btn btn-1b btn-login-oauth" id="facebook">
-              <span class="btn-label"><i class="icon-social-facebook"></i></span>
-              {{ t('Sign in') }}
-            </button>
-            <div class="small text-right">by Facebook Account</div>
-          </form>
-          {% endif %}
-          {% if passportTwitterLoginEnabled() %}
-          <form role="form" action="/passport/twitter" method="get">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="fcbtn btn btn-1b btn-login-oauth" id="twitter">
-              <span class="btn-label"><i class="icon-social-twitter"></i></span>
-              {{ t('Sign in') }}
-            </button>
-            <div class="small text-right">by Twitter Account</div>
-          </form>
-          {% endif %}
+        <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 %}
 
-        <hr>
+
+        <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>
 

+ 1 - 32
lib/views/modal/create_page.html

@@ -57,7 +57,7 @@
               <div class="create-page-button-container">
                 <a id="link-to-template" href="{{ page.path || path }}" class="fcbtn btn btn-outline btn-rounded btn-primary btn-1b disabled">
                   <i class="icon-fw icon-doc"></i>
-                  <span id="create-template-button-link">{{ t('Create') }}/{{ t('Edit') }}</span>
+                  <span id="create-template-button-link">{{ t('Edit') }}</span>
                 </a>
               </div>
             </div>
@@ -69,34 +69,3 @@
     </div><!-- /.modal-content -->
   </div><!-- /.modal-dialog -->
 </div><!-- /.modal -->
-<script>
-  let buttonTextChildren;
-  let buttonTextDecendants;
-  let pagePath = $("#link-to-template").attr("href");
-
-  if (pagePath.endsWith("/")) {
-      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') }}`;
-    });
-
-  $("#template-type").on("change", () => {
-    // enable button
-    $('#link-to-template').removeClass("disabled");
-
-    if ($("#template-type").val() === "children") {
-      href = pagePath + "/_template#edit-form";
-      $("#link-to-template").attr("href", href);
-      $('#create-template-button-link').text(buttonTextChildren);
-    }
-    else if ($("#template-type").val() === "decentants") {
-      href = pagePath + "/__template#edit-form";
-      $("#link-to-template").attr("href", href);
-      $('#create-template-button-link').text(buttonTextDecendants);
-    };
-  });
-</script>

+ 3 - 1
lib/views/modal/create_template.html

@@ -20,6 +20,7 @@
                 <div class="panel-footer text-center">
                   <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}_template#edit-form"
                       class="btn btn-sm btn-primary" id="template-button-children">
+                      {{ t("Edit") }}
                   </a>
                 </div>
               </div>
@@ -32,8 +33,9 @@
                   <p class="help-block text-center"><small>{{ t('template.decendants.desc') }}</small></p>
                 </div>
                 <div class="panel-footer text-center">
-                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}__template#edit-form"
+                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path }}/{% endif %}__template#edit-form"
                       class="btn btn-sm btn-primary" id="template-button-decendants">
+                      {{ t("Edit") }}
                   </a>
                 </div>
               </div>

+ 24 - 2
lib/views/modal/shortcuts.html

@@ -52,7 +52,29 @@
             </table>
           </div><!-- /.col-sm-6 -->
 
-        </div>
+        </div><!-- /.row -->
+
+        <div class="row">
+          <div class="col-sm-6">
+            <h3><strong></strong></h3>
+          </div><!-- /.col-sm-6 -->
+
+          <div class="col-sm-6">
+            <h3><strong>{{ t('modal_shortcuts.commentform.title') }}</strong></h3>
+
+            <table class="table">
+              <tr>
+                <th>{{ t('modal_shortcuts.commentform.Post') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key key-longer">{% include '../widget/icon-keyboard-return-enter.html' %}</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Delete Line') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key">D</span></td>
+              </tr>
+            </table>
+          </div><!-- /.col-sm-6 -->
+
+        </div><!-- /.row -->
 
       </div>
 
@@ -67,7 +89,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/icon-keyboard-return-enter.html

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20px" viewBox="0 0 34 21">
+  <g id="ba5f4106-f870-416b-bb0c-2580c9a76268">
+    <g id="1def15e1-5198-4ca2-9457-3b509e83053f">
+      <polygon points="31 0 31 9 5 9 11.8 1.8 10 0 0 10.5 10 21 11.8 19.2 5 12 34 12 34 0 31 0" />
+    </g>
+  </g>
+</svg>

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

+ 30 - 22
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.6-RC",
+  "version": "3.1.11-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",
@@ -83,6 +85,7 @@
     "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",
@@ -99,9 +102,6 @@
     "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",
@@ -112,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,15 +135,16 @@
     "date-fns": "^1.29.0",
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
-    "eslint": "^4.19.1",
+    "eslint": "^5.0.0",
     "eslint-plugin-react": "^7.7.0",
-    "extract-text-webpack-plugin": "^3.0.2",
+    "extract-text-webpack-plugin": "^4.0.0-beta.0",
     "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",
@@ -159,29 +160,36 @@
     "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.0.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": "^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>
     );
   }

+ 113 - 29
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) {
@@ -55,7 +72,10 @@ export default class CommentForm extends React.Component {
    * Load data of comments and rerender <PageComments />
    */
   postComment(event) {
-    event.preventDefault();
+    if (event != null) {
+      event.preventDefault();
+    }
+
     this.props.crowi.apiPost('/comments.add', {
       commentForm: {
         comment: this.state.comment,
@@ -74,7 +94,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 +114,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 +148,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 +198,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 +214,19 @@ 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}
+                        onCtrlEnter={this.postComment}
+                      />
                     </Tab>
                     { this.state.isMarkdown == true &&
                     <Tab eventKey={2} title="Preview">
@@ -156,21 +237,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 +262,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))

+ 26 - 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
@@ -89,10 +109,12 @@ export default class AbstractEditor extends React.Component {
       this.props.onPasteFiles(event);
     }
   }
+
 }
 
 AbstractEditor.propTypes = {
   value: PropTypes.string,
+  ifGfmMode: PropTypes.bool,
   editorOptions: PropTypes.object,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
@@ -100,5 +122,9 @@ AbstractEditor.propTypes = {
   onSave: PropTypes.func,
   onPasteFiles: PropTypes.func,
   onDragEnter: PropTypes.func,
+  onCtrlEnter: PropTypes.func,
+};
+AbstractEditor.defaultProps = {
+  isGfmMode: true,
 };
 

+ 108 - 19
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -8,9 +8,17 @@ const loadScript = require('simple-load-script');
 const loadCssSync = require('load-css-file');
 
 import * as codemirror from 'codemirror';
+// set save handler
+codemirror.commands.save = (instance) => {
+  if (instance.codeMirrorEditor != null) {
+    instance.codeMirrorEditor.dispatchSave();
+  }
+};
+// set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
+window.CodeMirror = require('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 +35,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 +54,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
+      isGfmMode: this.props.isGfmMode,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       additionalClass: '',
@@ -61,6 +71,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.loadKeymapMode = this.loadKeymapMode.bind(this);
     this.setKeymapMode = this.setKeymapMode.bind(this);
     this.handleEnterKey = this.handleEnterKey.bind(this);
+    this.handleCtrlEnterKey = this.handleCtrlEnterKey.bind(this);
 
     this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
     this.pasteHandler = this.pasteHandler.bind(this);
@@ -90,13 +101,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   componentDidMount() {
+    // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
+    this.getCodeMirror().codeMirrorEditor = this;
+
     // initialize caret line
     this.setCaretLine(0);
-    // set save handler
-    codemirror.commands.save = this.dispatchSave;
-
-    // set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
-    window.CodeMirror = require('codemirror');
   }
 
   componentWillReceiveProps(nextProps) {
@@ -129,6 +138,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 +183,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 +205,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 +250,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 +351,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,
     };
@@ -300,6 +370,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
       });
   }
 
+  /**
+   * handle Ctrl+ENTER key
+   */
+  handleCtrlEnterKey() {
+    if (this.props.onCtrlEnter != null) {
+      this.props.onCtrlEnter();
+    }
+  }
+
   scrollCursorIntoViewHandler(editor, event) {
     if (this.props.onScrollCursorIntoView != null) {
       const line = editor.getCursor().line;
@@ -360,8 +439,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 +457,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
@@ -394,6 +478,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
           // continuelist, indentlist
           extraKeys: {
             'Enter': this.handleEnterKey,
+            'Ctrl-Enter': this.handleCtrlEnterKey,
+            'Cmd-Enter': this.handleCtrlEnterKey,
             'Tab': 'indentMore',
             'Shift-Tab': 'indentLess',
             'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
@@ -434,5 +520,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) {

+ 4 - 4
resource/js/legacy/crowi.js

@@ -16,7 +16,6 @@ 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');
@@ -137,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) => {
@@ -987,7 +987,7 @@ window.addEventListener('keydown', (event) => {
 
   // ignore when target dom is input
   const inputPattern = /^input|textinput|textarea$/i;
-  if (target.tagName.match(inputPattern) || target.isContentEditable) {
+  if (inputPattern.test(target.tagName) || target.isContentEditable) {
     return;
   }
 

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

@@ -106,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)
+  }
+});

+ 5 - 6
resource/js/util/markdown-it/blockdiag.js

@@ -4,14 +4,13 @@ export default class BlockdiagConfigurer {
     this.crowi = crowi;
     const config = crowi.getConfig();
 
-    this.generateSourceUrl = config.env.BLOCKDIAG_URL || 'https://blockdiag-api.com/';
+    this.generateSourceUrl = config.env.BLOCKDIAG_URI || 'https://blockdiag-api.com/';
   }
 
   configure(md) {
-    //// disable temporary because this breaks /Sandbox -- 2018.06.08 Yuki Takei
-    // md.use(require('markdown-it-blockdiag'), {
-    //   generateSourceUrl: this.generateSourceUrl,
-    //   marker: ':::',
-    // });
+    md.use(require('markdown-it-blockdiag'), {
+      generateSourceUrl: this.generateSourceUrl,
+      marker: ':::',
+    });
   }
 }

+ 4 - 0
resource/styles/agile-admin/inverse/colors/mono-blue.scss

@@ -33,5 +33,9 @@ $inline-code-bg: lighten($subthemecolor,70%);
   .code-line.revision-head.highlighted {
     background-color: lighten($themecolor,20%);
     color: $themelight;
+
+    .icon-note, .icon-link {
+      color: $themelight;
+    }
   }
 }

+ 26 - 2
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-oauth.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);
@@ -122,7 +146,7 @@
   }
   .btn-login-oauth.fcbtn#google {
     .btn-label {
-      background: linear-gradient(to bottom right, darken(#db3236, 20%), darken(#f4c20d, 20%), darken(#3cba54, 20%), darken(#4885ed, 20%));
+      background: rgba(#f13d25, 0.4);
     }
     &:after {
       background-color: #555;

+ 4 - 0
resource/styles/scss/_shortcuts.scss

@@ -38,6 +38,10 @@
     text-transform: uppercase;
     text-align: center;
     color: #666;
+    /* SVG Properties*/
+    polygon {
+      fill: #666;
+    }
 
     &.key-longer {
       width: 64px;

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

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


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