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

Merge branch 'master' into feat/OAuth-twitter

久保田隼基 7 лет назад
Родитель
Сommit
6a7112e1af
81 измененных файлов с 2154 добавлено и 715 удалено
  1. 6 1
      .babelrc
  2. 2 1
      .node-dev.json
  3. 24 2
      CHANGES.md
  4. 2 0
      config/env.dev.js
  5. 8 4
      config/webpack.common.js
  6. 15 0
      config/webpack.dev.js
  7. 0 1
      config/webpack.dll.js
  8. 1 1
      config/webpack.prod.js
  9. 1 1
      lib/crowi/express-init.js
  10. 5 2
      lib/crowi/index.js
  11. 11 0
      lib/form/admin/markdownXss.js
  12. 1 1
      lib/form/admin/userGroupCreate.js
  13. 4 1
      lib/form/comment.js
  14. 1 0
      lib/form/index.js
  15. 15 2
      lib/locales/en-US/translation.json
  16. 15 3
      lib/locales/ja/translation.json
  17. 94 1
      lib/models/config.js
  18. 7 6
      lib/models/page-group-relation.js
  19. 71 42
      lib/models/page.js
  20. 0 6
      lib/models/user-group.js
  21. 64 39
      lib/routes/admin.js
  22. 33 8
      lib/routes/comment.js
  23. 92 0
      lib/routes/hackmd.js
  24. 9 4
      lib/routes/index.js
  25. 118 55
      lib/routes/page.js
  26. 7 3
      lib/util/middlewares.js
  27. 19 0
      lib/util/recommendedXssWhiteList.js
  28. 115 53
      lib/util/slack.js
  29. 25 33
      lib/util/swigFunctions.js
  30. 18 10
      lib/util/xss.js
  31. 13 0
      lib/util/xssOption.js
  32. 5 26
      lib/views/_form.html
  33. 162 40
      lib/views/admin/markdown.html
  34. 11 2
      lib/views/admin/security.html
  35. 3 3
      lib/views/admin/user-group-detail.html
  36. 3 3
      lib/views/admin/user-groups.html
  37. 4 2
      lib/views/admin/widget/passport/github.html
  38. 4 2
      lib/views/admin/widget/passport/google-oauth.html
  39. 5 0
      lib/views/customlayout-selector/forbidden.html
  40. 41 0
      lib/views/layout-crowi/forbidden.html
  41. 25 0
      lib/views/layout-growi/forbidden.html
  42. 0 1
      lib/views/layout/layout.html
  43. 14 1
      lib/views/modal/create_page.html
  44. 2 2
      lib/views/modal/create_template.html
  45. 50 0
      lib/views/widget/forbidden_content.html
  46. 5 4
      lib/views/widget/not_found_content.html
  47. 1 1
      lib/views/widget/not_found_tabs.html
  48. 1 1
      lib/views/widget/page_alerts.html
  49. 13 4
      lib/views/widget/page_content.html
  50. 1 1
      lib/views/widget/page_list_and_timeline.html
  51. 9 2
      lib/views/widget/page_tabs.html
  52. 5 3
      package.json
  53. 61 0
      resource/js/agent-for-hackmd.js
  54. 64 3
      resource/js/app.js
  55. 6 1
      resource/js/components/CopyButton.js
  56. 3 3
      resource/js/components/NewPageNameInput.js
  57. 5 2
      resource/js/components/Page/RevisionPath.js
  58. 10 1
      resource/js/components/Page/RevisionUrl.js
  59. 77 30
      resource/js/components/PageComment/CommentForm.js
  60. 10 21
      resource/js/components/PageEditor/Editor.js
  61. 4 1
      resource/js/components/PageEditor/GrantSelector.js
  62. 5 5
      resource/js/components/PageEditor/OptionsSelector.js
  63. 115 0
      resource/js/components/PageEditorByHackmd.jsx
  64. 43 0
      resource/js/components/PageEditorByHackmd/HackmdEditor.jsx
  65. 78 0
      resource/js/components/SlackNotification.js
  66. 2 0
      resource/js/legacy/crowi-admin.js
  67. 12 27
      resource/js/legacy/crowi-form.js
  68. 33 20
      resource/js/legacy/crowi.js
  69. 13 3
      resource/js/util/PreProcessor/XssFilter.js
  70. 10 1
      resource/styles/agile-admin/inverse/colors/_apply-colors-dark.scss
  71. 10 1
      resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss
  72. 15 0
      resource/styles/hackmd/style.scss
  73. 3 2
      resource/styles/scss/_admin.scss
  74. 16 0
      resource/styles/scss/_comment_growi.scss
  75. 3 0
      resource/styles/scss/_create-page.scss
  76. 154 0
      resource/styles/scss/_editor-attachment.scss
  77. 10 0
      resource/styles/scss/_notification.scss
  78. 208 208
      resource/styles/scss/_on-edit.scss
  79. 2 0
      resource/styles/scss/style.scss
  80. 6 2
      test/util/slack.test.js
  81. 16 6
      yarn.lock

+ 6 - 1
.babelrc

@@ -1,5 +1,10 @@
 {
-  "plugins": ["lodash"],
+  "plugins": [
+    "lodash",
+    ["transform-runtime", {
+      "regenerator": true
+    }]
+  ],
   "presets": [
     ["env", {
       "targets": {

+ 2 - 1
.node-dev.json

@@ -1,5 +1,6 @@
 {
   "ignore": [
-    "package.json"
+    "package.json",
+    "public/manifest.json"
   ]
 }

+ 24 - 2
CHANGES.md

@@ -1,14 +1,36 @@
 CHANGES
 ========
 
-## 3.1.11
+## 3.2.0-RC
+
+* Feature: Simultaneously edit by multiple people with HackMD integration
+* Support: Upgrade libs
+    * react
+    * react-dom
+
+## 3.1.13-RC
 
 * 
 
-## 3.1.10
+## 3.1.12
+
+* Feature: Add XSS Settings
+* Feature: Notify to Slack when comment
+* Improvement: Prevent XSS in various situations
+* Improvement: Show forbidden message when the user accesses to ungranted page
+* Improvement: Add overlay styles for pasting file to comment form
+* Fix: Omit unnecessary css link
+    * Introduced by 3.1.10
+* Fix: Invitation mail do not be sent
+* Fix: Edit template button on New Page modal doesn't work
+
+## 3.1.11
 
 * Fix: OAuth doesn't work in production because callback URL field cannot be specified
     * Introduced by 3.1.9
+
+## 3.1.10
+
 * Fix: Enter key on react-bootstrap-typeahead doesn't submit
     * Introduced by 3.1.9
 * Fix: CodeMirror of `/admin/customize` is broken

+ 2 - 0
config/env.dev.js

@@ -1,8 +1,10 @@
 module.exports = {
   NODE_ENV: 'development',
   FILE_UPLOAD: 'local',
+  SESSION_NAME: 'connect.growi-dev.sid',
   // MATHJAX: 1,
   ELASTICSEARCH_URI: 'http://localhost:9200/growi',
+  // HACKMD_URI: 'http://localhost:3100',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 8 - 4
config/webpack.common.js

@@ -27,6 +27,7 @@ module.exports = (options) => {
       'js/legacy-presentation':   './resource/js/legacy/crowi-presentation',
       'js/plugin':                './resource/js/plugin',
       'js/ie11-polyfill':         './resource/js/ie11-polyfill',
+      'js/agent-for-hackmd':      './resource/js/agent-for-hackmd',
       // styles
       'styles/style':                './resource/styles/scss/style.scss',
       'styles/style-presentation':   './resource/styles/scss/style-presentation.scss',
@@ -37,6 +38,8 @@ module.exports = (options) => {
       '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',
+      // styles for external services
+      'styles/style-hackmd':         './resource/styles/hackmd/style.scss',
     }, options.entry || {}),  // Merge with env dependent settings
     output: Object.assign({
       path: helpers.root('public'),
@@ -51,7 +54,7 @@ module.exports = (options) => {
       'hljs': 'hljs',
     },
     resolve: {
-      extensions: ['.js', '.json'],
+      extensions: ['.js', '.jsx', '.json'],
       modules: [helpers.root('src'), helpers.root('node_modules')],
       alias: {
         '@root': helpers.root('/'),
@@ -86,12 +89,12 @@ module.exports = (options) => {
         {
           test: /\.css$/,
           use: ['style-loader', 'css-loader'],
-          exclude: [helpers.root('resource/styles/scss')]
+          exclude: [helpers.root('resource/styles')]
         },
         {
           test: /\.scss$/,
           use: ['style-loader', 'css-loader', 'sass-loader'],
-          exclude: [helpers.root('resource/styles/scss')]
+          exclude: [helpers.root('resource/styles')]
         },
         /*
          * File loader for supporting images, for example, in CSS files.
@@ -147,7 +150,8 @@ module.exports = (options) => {
           vendors: {
             test: /node_modules/,
             chunks: (chunk) => {
-              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill/);
+              // ignore patterns
+              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|agent-for-hackmd/);
             },
             name: 'js/vendors',
             // minChunks: 2,

+ 15 - 0
config/webpack.dev.js

@@ -9,6 +9,7 @@ const helpers = require('./helpers');
 /*
  * Webpack Plugins
  */
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
 /**
@@ -37,10 +38,24 @@ module.exports = require('./webpack.common')({
         ],
         include: [helpers.root('resource/styles/scss')]
       },
+      { // Dump CSS for HackMD
+        test: /\.scss$/,
+        use: ExtractTextPlugin.extract({
+          use: [
+            'css-loader',
+            'sass-loader'
+          ]
+        }),
+        include: [helpers.root('resource/styles/hackmd')]
+      },
     ],
   },
   plugins: [
 
+    new ExtractTextPlugin({
+      filename: '[name].bundle.css',
+    }),
+
     new webpack.DllReferencePlugin({
       context: helpers.root(),
       manifest: require(helpers.root('public/dll', 'manifest.json')),

+ 0 - 1
config/webpack.dll.js

@@ -12,7 +12,6 @@ module.exports = {
       // Libraries
       'axios',
       'babel-polyfill',
-      'bootstrap-select',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',
       'clipboard',

+ 1 - 1
config/webpack.prod.js

@@ -40,7 +40,7 @@ module.exports = require('./webpack.common')({
             'sass-loader'
           ]
         }),
-        include: [helpers.root('resource/styles/scss')]
+        include: [helpers.root('resource/styles/scss'), helpers.root('resource/styles/hackmd')]
       }
     ]
   },

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

@@ -127,7 +127,7 @@ module.exports = function(crowi, app) {
 
   app.use(flash());
 
-  app.use(middleware.swigFilters(app, swig));
+  app.use(middleware.swigFilters(crowi, app, swig));
   app.use(middleware.swigFunctions(crowi, app));
 
   app.use(middleware.csrfKeyGenerator(crowi, app));

+ 5 - 2
lib/crowi/index.js

@@ -1,7 +1,7 @@
 'use strict';
 
 
-var debug = require('debug')('growi:crowi')
+const debug = require('debug')('growi:crowi')
   , logger = require('@alias/logger')('growi:crowi')
   , pkg = require('@root/package.json')
   , path = require('path')
@@ -10,10 +10,12 @@ var debug = require('debug')('growi:crowi')
   , mongoose    = require('mongoose')
 
   , models = require('../models')
+
+  , Xss = require('../util/xss')
   ;
 
 function Crowi(rootdir, env) {
-  var self = this;
+  const self = this;
 
   this.version = pkg.version;
   this.runtimeVersions = undefined;   // initialized by scanRuntimeVersions()
@@ -35,6 +37,7 @@ function Crowi(rootdir, env) {
   this.mailer = {};
   this.interceptorManager = {};
   this.passportService = null;
+  this.xss = new Xss();
 
   this.tokens = null;
 

+ 11 - 0
lib/form/admin/markdownXss.js

@@ -0,0 +1,11 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('markdownSetting[markdown:xss:isEnabledPrevention]').trim().toBooleanStrict(),
+  field('markdownSetting[markdown:xss:option]').trim().toInt(),
+  field('markdownSetting[markdown:xss:tagWhiteList]').trim(),
+  field('markdownSetting[markdown:xss:attrWhiteList]').trim()
+);

+ 1 - 1
lib/form/admin/userGroupCreate.js

@@ -1,6 +1,6 @@
 'use strict';
 
-var form = require('express-form')
+const form = require('express-form')
   , field = form.field;
 
 module.exports = form(

+ 4 - 1
lib/form/comment.js

@@ -8,5 +8,8 @@ module.exports = form(
   field('commentForm.revision_id').trim().required(),
   field('commentForm.comment').trim().required(),
   field('commentForm.comment_position').trim().toInt(),
-  field('commentForm.is_markdown').trim().toBooleanStrict()
+  field('commentForm.is_markdown').trim().toBooleanStrict(),
+
+  field('slackNotificationForm.isSlackEnabled').trim().toBooleanStrict().required(),
+  field('slackNotificationForm.slackChannels').trim(),
 );

+ 1 - 0
lib/form/index.js

@@ -23,6 +23,7 @@ module.exports = {
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),
     markdown: require('./admin/markdown'),
+    markdownXss: require('./admin/markdownXss'),
     customcss: require('./admin/customcss'),
     customscript: require('./admin/customscript'),
     customheader: require('./admin/customheader'),

+ 15 - 2
lib/locales/en-US/translation.json

@@ -327,6 +327,8 @@
     "change_redirect_url": "Enter <code>https://${crowi.host}/google/callback</code> <br>(where <code>${crowi.host}</code> is your host name) for \"Authorized redirect URIs\".",
     "clientID": "Client ID",
     "client_secret": "Client Secret",
+    "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
+    "xss_prevent_setting_link":"Go to Markdown settings",
     "callback_URL": "Callback URL",
     "guest_mode": {
       "deny": "Deny Unregistered Users",
@@ -408,11 +410,22 @@
 	},
 
   "markdown_setting": {
-    "markdown_rendering": "You can change Markdown rendering settings.",
+    "line_break_setting": "Line Break Setting",
+    "line_break_setting_desc": "You can change line break settings.",
     "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"
+    "Enable Line Break for comment desc": "Treat line break in comment as <code>&lt;br&gt;</code> in HTML",
+    "XSS_setting": "Prevent XSS(Cross Site Scripting) Setting",
+    "XSS_setting_desc": "You can change the handling of HTML tags in markdown text.",
+    "Enable XSS prevention": "Enable XSS Prevention",
+    "Ignore all tags": "Ignore All Tags",
+    "Ignore all tags desc": "Stripe all HTML tags and attributes",
+    "Recommended setting": "Recommended Setting",
+    "Custom Whitelist": "Custom Whitelist",
+    "Tag names":"Tag names",
+    "Tag attributes":"Tag attributes",
+    "import_recommended": "Import recommended %s"
   },
 
   "customize_page": {

+ 15 - 3
lib/locales/ja/translation.json

@@ -344,6 +344,8 @@
     "change_redirect_url": "承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>(<code>${crowi.host}</code>は環境に合わせて変更してください)",
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
+    "xss_prevent_setting":"XSS(Cross Site Scripting)対策設定",
+    "xss_prevent_setting_link":"マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "guest_mode": {
       "deny": "アカウントを持たないユーザーはアクセス不可",
@@ -424,12 +426,22 @@
     }
   },
   "markdown_setting": {
-    "markdown_rendering": "Markdownレンダリングの設定を変更できます。",
+    "line_break_setting": "Line Break設定",
+    "line_break_setting_desc": "Line Breakの設定を変更できます。",
     "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>として扱います"
-
+    "Enable Line Break for comment desc": "コメント中の改行を、HTML内で<code>&lt;br&gt;</code>として扱います",
+    "XSS_setting": "XSS(Cross Site Scripting)対策設定",
+    "XSS_setting_desc": "マークダウンテキスト内の HTML タグの扱いを設定し、悪意のあるプログラムからの攻撃を防ぎます",
+    "Enable XSS prevention": "XSSを抑制する",
+    "Ignore all tags": "すべてのタグを抑制する",
+    "Ignore all tags desc": "すべてのHTMLタグと属性を使用不可にします",
+    "Recommended setting": "おすすめ設定",
+    "Custom Whitelist": "カスタムホワイトリスト",
+    "Tag names": "タグ名のホワイトリスト",
+    "Tag attributes": "タグ属性のホワイトリスト",
+    "import_recommended": "おすすめをインポート"
   },
 
   "customize_page": {

+ 94 - 1
lib/models/config.js

@@ -2,6 +2,7 @@ module.exports = function(crowi) {
   var mongoose = require('mongoose')
     , debug = require('debug')('growi:models:config')
     , uglifycss = require('uglifycss')
+    , recommendedXssWhiteList = require('../util/recommendedXssWhiteList')
     , configSchema
     , Config
 
@@ -102,6 +103,10 @@ module.exports = function(crowi) {
 
   function getDefaultMarkdownConfigs() {
     return {
+      'markdown:xss:isEnabledPrevention': true,
+      'markdown:xss:option': 2,
+      'markdown:xss:tagWhiteList': [],
+      'markdown:xss:attrWhiteList': [],
       'markdown:isEnabledLinebreaks': false,
       'markdown:isEnabledLinebreaksInComments': true,
     };
@@ -340,6 +345,85 @@ module.exports = function(crowi) {
     return config.markdown[key];
   };
 
+  configSchema.statics.isEnabledXssPrevention = function(config) {
+    const key = 'markdown:xss:isEnabledPrevention';
+
+    // return default value if undefined
+    if (undefined === config.markdown || undefined === config.markdown[key]) {
+      return getDefaultMarkdownConfigs[key];
+    }
+
+    return config.markdown[key];
+  };
+
+  configSchema.statics.xssOption = function(config) {
+    const key = 'markdown:xss:option';
+
+    // return default value if undefined
+    if (undefined === config.markdown || undefined === config.markdown[key]) {
+      return getDefaultMarkdownConfigs[key];
+    }
+
+    return config.markdown[key];
+  };
+
+  configSchema.statics.tagWhiteList = function(config) {
+    const key = 'markdown:xss:tagWhiteList';
+
+    // return default value if undefined
+    if (undefined === config.markdown || undefined === config.markdown[key]) {
+      return getDefaultMarkdownConfigs[key];
+    }
+
+    if (this.isEnabledXssPrevention(config)) {
+      switch (this.xssOption(config)) {
+        case 1: // ignore all: use default option
+          return [];
+
+        case 2: // recommended
+          return recommendedXssWhiteList.tags;
+
+        case 3: // custom white list
+          return config.markdown[key];
+
+        default:
+          return [];
+      }
+    }
+    else {
+      return [];
+    }
+
+  };
+
+  configSchema.statics.attrWhiteList = function(config) {
+    const key = 'markdown:xss:attrWhiteList';
+
+    // return default value if undefined
+    if (undefined === config.markdown || undefined === config.markdown[key]) {
+      return getDefaultMarkdownConfigs[key];
+    }
+
+    if (this.isEnabledXssPrevention(config)) {
+      switch (this.xssOption(config)) {
+        case 1: // ignore all: use default option
+          return [];
+
+        case 2: // recommended
+          return recommendedXssWhiteList.attrs;
+
+        case 3: // custom white list
+          return config.markdown[key];
+
+        default:
+          return [];
+      }
+    }
+    else {
+      return [];
+    }
+  };
+
   /**
    * initialize custom css strings
    */
@@ -383,9 +467,12 @@ module.exports = function(crowi) {
       customTitle = '{{page}} - {{sitename}}';
     }
 
-    return customTitle
+    // replace
+    customTitle = customTitle
       .replace('{{sitename}}', this.appTitle(config))
       .replace('{{page}}', page);
+
+    return crowi.xss.process(customTitle);
   };
 
   configSchema.statics.behaviorType = function(config) {
@@ -480,11 +567,17 @@ module.exports = function(crowi) {
       layoutType: Config.layoutType(config),
       isEnabledLinebreaks: Config.isEnabledLinebreaks(config),
       isEnabledLinebreaksInComments: Config.isEnabledLinebreaksInComments(config),
+      isEnabledXssPrevention: Config.isEnabledXssPrevention(config),
+      xssOption: Config.xssOption(config),
+      tagWhiteList: Config.tagWhiteList(config),
+      attrWhiteList: Config.attrWhiteList(config),
       highlightJsStyleBorder: Config.highlightJsStyleBorder(config),
       isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
+      hasSlackConfig: Config.hasSlackConfig(config),
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
+        HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
       },
     };

+ 7 - 6
lib/models/page-group-relation.js

@@ -176,13 +176,14 @@ class PageGroupRelation {
    * @returns is exists granted group(or not)
    * @memberof PageGroupRelation
    */
-  static isExistsGrantedGroupForPageAndUser(pageData, userData) {
-    var UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
+  static async isExistsGrantedGroupForPageAndUser(pageData, userData) {
+    const UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
 
-    return this.findByPage(pageData)
-      .then(pageRelation => {
-        return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
-      });
+    const pageRelation = await this.findByPage(pageData);
+    if (pageRelation == null) {
+      return false;
+    }
+    return await UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
   }
 
   /**

+ 71 - 42
lib/models/page.js

@@ -1,3 +1,16 @@
+/**
+ * The Exception class thrown when the user has no grant to see the page
+ *
+ * @class UserHasNoGrantException
+ */
+class UserHasNoGrantException {
+  constructor(message, user) {
+    this.name = this.constructor.name;
+    this.message = message;
+    this.user = user;
+  }
+}
+
 module.exports = function(crowi) {
   var debug = require('debug')('growi:models:page')
     , mongoose = require('mongoose')
@@ -56,6 +69,8 @@ module.exports = function(crowi) {
         return JSON.stringify(data);
       }
     },
+    pageIdOnHackmd: String,
+    revisionHackmdSynced: { type: ObjectId, ref: 'Revision' },
     createdAt: { type: Date, default: Date.now },
     updatedAt: Date
   }, {
@@ -488,46 +503,33 @@ module.exports = function(crowi) {
   };
 
   // find page and check if granted user
-  pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
-    const self = this;
+  pageSchema.statics.findPage = async function(path, userData, revisionId, ignoreNotFound) {
     const PageGroupRelation = crowi.model('PageGroupRelation');
 
-    return new Promise(function(resolve, reject) {
-      self.findOne({path: path}, function(err, pageData) {
-        if (err) {
-          return reject(err);
-        }
+    const pageData = await this.findOne({path: path});
 
-        if (pageData === null) {
-          if (ignoreNotFound) {
-            return resolve(null);
-          }
+    if (pageData == null) {
+      if (ignoreNotFound) {
+        return null;
+      }
 
-          const pageNotFoundError = new Error('Page Not Found');
-          pageNotFoundError.name = 'Crowi:Page:NotFound';
-          return reject(pageNotFoundError);
-        }
+      const pageNotFoundError = new Error('Page Not Found');
+      pageNotFoundError.name = 'Crowi:Page:NotFound';
+      throw new Error(pageNotFoundError);
+    }
 
-        if (!pageData.isGrantedFor(userData)) {
-          PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData)
-            .then(isExists => {
-              if (!isExists) {
-                return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
-              }
-              else {
-                // return resolve(pageData);
-                self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
-              }
-            })
-            .catch(function(err) {
-              return reject(err);
-            });
-        }
-        else {
-          self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
-        }
-      });
-    });
+    if (!pageData.isGrantedFor(userData)) {
+      const isRelationExists = await PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData);
+      if (isRelationExists) {
+        return await this.populatePageData(pageData, revisionId || null);
+      }
+      else {
+        throw new UserHasNoGrantException('Page is not granted for the user', userData);
+      }
+    }
+    else {
+      return await this.populatePageData(pageData, revisionId || null);
+    }
   };
 
   /**
@@ -982,13 +984,17 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.create = function(path, body, user, options) {
-    var Page = this
+    const Page = this
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
-      , grant = options.grant || GRANT_PUBLIC
       , redirectTo = options.redirectTo || null
       , grantUserGroupId = options.grantUserGroupId || null;
 
+    let grant = options.grant || GRANT_PUBLIC;
+
+    // sanitize path
+    path = crowi.xss.process(path);
+
     // force public
     if (isPortalPath(path)) {
       grant = GRANT_PUBLIC;
@@ -1001,7 +1007,7 @@ module.exports = function(crowi) {
           throw new Error('Cannot create new page to existed path');
         }
 
-        var newPage = new Page();
+        const newPage = new Page();
         newPage.path = path;
         newPage.creator = user;
         newPage.lastUpdateUser = user;
@@ -1249,11 +1255,14 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
-    var Page = this
+    const Page = this
       , Revision = crowi.model('Revision')
       , path = pageData.path
       , createRedirectPage = options.createRedirectPage || 0
-      , moveUnderTrees     = options.moveUnderTrees || 0;
+      ;
+
+    // sanitize path
+    newPagePath = crowi.xss.process(newPagePath);
 
     return Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})  // pageData の path を変更
       .then((data) => {
@@ -1264,7 +1273,7 @@ module.exports = function(crowi) {
         pageData.path = newPagePath;
 
         if (createRedirectPage) {
-          var body = 'redirect ' + newPagePath;
+          const body = 'redirect ' + newPagePath;
           Page.create(path, body, user, {redirectTo: newPagePath});
         }
         pageEvent.emit('update', pageData, user); // update as renamed page
@@ -1274,10 +1283,13 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
-    var Page = this
+    const Page = this
       , path = pageData.path
       , pathRegExp = new RegExp('^' + escapeStringRegexp(path), 'i');
 
+    // sanitize path
+    newPagePathPrefix = crowi.xss.process(newPagePathPrefix);
+
     return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
         return Promise.all(pages.map(function(page) {
@@ -1291,6 +1303,23 @@ module.exports = function(crowi) {
       });
   };
 
+  /**
+   * associate GROWI page and HackMD page
+   * @param {Page} pageData
+   * @param {string} pageIdOnHackmd
+   */
+  pageSchema.statics.registerHackmdPage = function(pageData, pageIdOnHackmd) {
+    if (pageData.pageIdOnHackmd != null) {
+      throw new Error(`'pageIdOnHackmd' of the page '${pageData.path}' is not empty`);
+    }
+
+    pageData.pageIdOnHackmd = pageIdOnHackmd;
+    pageData.revisionHackmdSynced = pageData.revision;
+
+    // update page
+    return pageData.save();
+  };
+
   pageSchema.statics.getHistories = function() {
     // TODO
     return;

+ 0 - 6
lib/models/user-group.js

@@ -82,12 +82,6 @@ class UserGroup {
       });
   }
 
-  // TBD: グループ名によるグループ検索
-  static findUserGroupByName(name) {
-    const query = { name: name };
-    return this.findOne(query);
-  }
-
   // 登録可能グループ名確認
   static isRegisterableName(name) {
     const query = { name: name };

+ 64 - 39
lib/routes/admin.js

@@ -14,6 +14,7 @@ module.exports = function(crowi, app) {
     , PluginUtils = require('../plugins/plugin-utils')
     , pluginUtils = new PluginUtils()
     , ApiResponse = require('../util/apiResponse')
+    , recommendedXssWhiteList = require('../util/recommendedXssWhiteList')
 
     , MAX_PAGE_LIST = 50
     , actions = {};
@@ -104,10 +105,12 @@ module.exports = function(crowi, app) {
   // app.get('/admin/markdown'                  , admin.markdown.index);
   actions.markdown = {};
   actions.markdown.index = function(req, res) {
-    var config = crowi.getConfig();
-    var markdownSetting = Config.setupCofigFormData('markdown', config);
+    const config = crowi.getConfig();
+    const markdownSetting = Config.setupCofigFormData('markdown', config);
+
     return res.render('admin/markdown', {
       markdownSetting: markdownSetting,
+      recommendedXssWhiteList: recommendedXssWhiteList,
     });
   };
 
@@ -130,6 +133,33 @@ module.exports = function(crowi, app) {
     }
   };
 
+  // app.post('/admin/markdown/xss-setting' , admin.markdown.xssSetting);
+  actions.markdown.xssSetting = function(req, res) {
+    let xssSetting = req.form.markdownSetting;
+
+    xssSetting['markdown:xss:tagWhiteList'] = stringToArray(xssSetting['markdown:xss:tagWhiteList']);
+    xssSetting['markdown:xss:attrWhiteList'] = stringToArray(xssSetting['markdown:xss:attrWhiteList']);
+
+    req.session.markdownSetting = xssSetting;
+    if (req.form.isValid) {
+      Config.updateNamespaceByArray('markdown', xssSetting, function(err, config) {
+        Config.updateConfigCache('markdown', config);
+        req.session.xssSetting = null;
+        req.flash('successMessage', ['Successfully updated!']);
+        return res.redirect('/admin/markdown');
+      });
+    }
+    else {
+      req.flash('errorMessage', req.form.errors);
+      return res.redirect('/admin/markdown');
+    }
+  };
+
+  const stringToArray = (string) => {
+    const array = string.split(',');
+    return array.map(item => item.trim());
+  };
+
   // app.get('/admin/customize' , admin.customize.index);
   actions.customize = {};
   actions.customize.index = function(req, res) {
@@ -422,7 +452,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.user.remove = function(req, res) {
-    var id = req.params.id;
+    const id = req.params.id;
     let username = '';
 
     return new Promise((resolve, reject) => {
@@ -443,17 +473,10 @@ module.exports = function(crowi, app) {
     })
     .then((userData) => {
       // remove all External Accounts
-      ExternalAccount.remove({user: userData})
-      .then((err) => {
-        if (err) {
-          throw new Error(err.message);
-        }
-        return userData;
-      });
+      return ExternalAccount.remove({user: userData}).then(() => userData);
     })
     .then((userData) => {
-      return Page.removePageByPath(`/user/${username}`)
-        .then(() => userData);
+      return Page.removePageByPath(`/user/${username}`).then(() => userData);
     })
     .then((userData) => {
       req.flash('successMessage', `${username} さんのアカウントを削除しました`);
@@ -568,15 +591,15 @@ module.exports = function(crowi, app) {
 
   // グループ詳細
   actions.userGroup.detail = function(req, res) {
-    var name = req.params.name;
-    var renderVar = {
+    const userGroupId = req.params.id;
+    const renderVar = {
       userGroup: null,
       userGroupRelations: [],
       pageGroupRelations: [],
       notRelatedusers: []
     };
-    var targetUserGroup = null;
-    UserGroup.findUserGroupByName(name)
+    let targetUserGroup = null;
+    UserGroup.findOne({ _id: userGroupId})
       .then(function(userGroup) {
         targetUserGroup = userGroup;
         if (targetUserGroup == null) {
@@ -613,18 +636,20 @@ module.exports = function(crowi, app) {
 
   //グループの生成
   actions.userGroup.create = function(req, res) {
-    var form = req.form.createGroupForm;
+    const form = req.form.createGroupForm;
     if (req.form.isValid) {
-      UserGroup.createGroupByName(form.userGroupName)
-      .then((newUserGroup) => {
-        req.flash('successMessage', newUserGroup.name);
-        req.flash('createdUserGroup', newUserGroup);
-        return res.redirect('/admin/user-groups');
-      })
-      .catch((err) => {
-        debug('create userGroup error:', err);
-        req.flash('errorMessage', '同じグループ名が既に存在します。');
-      });
+      const userGroupName = crowi.xss.process(form.userGroupName);
+
+      UserGroup.createGroupByName(userGroupName)
+        .then((newUserGroup) => {
+          req.flash('successMessage', newUserGroup.name);
+          req.flash('createdUserGroup', newUserGroup);
+          return res.redirect('/admin/user-groups');
+        })
+        .catch((err) => {
+          debug('create userGroup error:', err);
+          req.flash('errorMessage', '同じグループ名が既に存在します。');
+        });
     }
     else {
       req.flash('errorMessage', req.form.errors.join('\n'));
@@ -635,8 +660,8 @@ module.exports = function(crowi, app) {
   //
   actions.userGroup.update = function(req, res) {
 
-    var userGroupId = req.params.userGroupId;
-    var name = req.body.name;
+    const userGroupId = req.params.userGroupId;
+    const name = crowi.xss.process(req.body.name);
 
     UserGroup.findById(userGroupId)
     .then((userGroupData) => {
@@ -665,7 +690,7 @@ module.exports = function(crowi, app) {
       }
     })
     .then(() => {
-      return res.redirect('/admin/user-group-detail/' + name);
+      return res.redirect('/admin/user-group-detail/' + userGroupId);
     });
   };
 
@@ -736,7 +761,7 @@ module.exports = function(crowi, app) {
 
   actions.userGroup.deletePicture = function(req, res) {
 
-    var userGroupId = req.params.userGroupId;
+    const userGroupId = req.params.userGroupId;
     let userGroupName = null;
 
     UserGroup.findById(userGroupId)
@@ -752,7 +777,7 @@ module.exports = function(crowi, app) {
     .then((updated) => {
       req.flash('successMessage', 'Deleted group picture');
 
-      return res.redirect('/admin/user-group-detail/' + userGroupName);
+      return res.redirect('/admin/user-group-detail/' + userGroupId);
     })
     .catch((err) => {
       debug('An error occured.', err);
@@ -762,7 +787,7 @@ module.exports = function(crowi, app) {
         return res.redirect('/admin/user-groups/');
       }
       else {
-        return res.redirect('/admin/user-group-detail/' + userGroupName);
+        return res.redirect('/admin/user-group-detail/' + userGroupId);
       }
     });
   };
@@ -824,23 +849,22 @@ module.exports = function(crowi, app) {
       UserGroupRelation.createRelation(userGroup, user);
     })
     .then((result) => {
-      return res.redirect('/admin/user-group-detail/' + userGroup.name);
+      return res.redirect('/admin/user-group-detail/' + userGroup.id);
     }).catch((err) => {
       debug('Error on create user-group relation', err);
       req.flash('errorMessage', 'Error on create user-group relation');
-      return res.redirect('/admin/user-group-detail/' + userGroup.name);
+      return res.redirect('/admin/user-group-detail/' + userGroup.id);
     });
   };
 
   actions.userGroupRelation.remove = function(req, res) {
     const UserGroupRelation = crowi.model('UserGroupRelation');
-    var name = req.params.name;
-    var relationId = req.params.relationId;
+    const userGroupId = req.params.id;
+    const relationId = req.params.relationId;
 
-    debug(name, relationId);
     UserGroupRelation.removeById(relationId)
     .then(() =>{
-      return res.redirect('/admin/user-group-detail/' + name);
+      return res.redirect('/admin/user-group-detail/' + userGroupId);
     })
     .catch((err) => {
       debug('Error on remove user-group-relation', err);
@@ -1127,6 +1151,7 @@ module.exports = function(crowi, app) {
     debug('mailer setup for validate SMTP setting', smtpClient);
 
     smtpClient.sendMail({
+      from: form['mail:from'],
       to: req.user.email,
       subject: 'Wiki管理設定のアップデートによるメール通知',
       text: 'このメールは、WikiのSMTP設定のアップデートにより送信されています。'

+ 33 - 8
lib/routes/comment.js

@@ -2,7 +2,9 @@ module.exports = function(crowi, app) {
   'use strict';
 
   const debug = require('debug')('growi:routs:comment')
+    , logger = require('@alias/logger')('growi:routes:comment')
     , Comment = crowi.model('Comment')
+    , User = crowi.model('User')
     , Page = crowi.model('Page')
     , ApiResponse = require('../util/apiResponse')
     , actions = {}
@@ -50,18 +52,19 @@ module.exports = function(crowi, app) {
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
   api.add = async function(req, res) {
-    const form = req.form.commentForm;
+    const commentForm = req.form.commentForm;
+    const slackNotificationForm = req.form.slackNotificationForm;
 
     if (!req.form.isValid) {
       // return res.json(ApiResponse.error('Invalid comment.'));
       return res.json(ApiResponse.error('コメントを入力してください。'));
     }
 
-    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;
+    const pageId = commentForm.page_id;
+    const revisionId = commentForm.revision_id;
+    const comment = commentForm.comment;
+    const position = commentForm.comment_position || -1;
+    const isMarkdown = commentForm.is_markdown;
 
     const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
       .catch(function(err) {
@@ -69,12 +72,34 @@ module.exports = function(crowi, app) {
       });
 
     // update page
-    await Page.findOneAndUpdate({ _id: pageId }, {
+    const page = await Page.findOneAndUpdate({ _id: pageId }, {
       lastUpdateUser: req.user,
       updatedAt: new Date()
     });
 
-    return res.json(ApiResponse.success({comment: createdComment}));
+    res.json(ApiResponse.success({comment: createdComment}));
+
+    // slack notification
+    if (slackNotificationForm.isSlackEnabled) {
+      const user = await User.findUserByUsername(req.user.username);
+      const path = page.path;
+      const channels = slackNotificationForm.slackChannels;
+
+      if (channels) {
+        page.updateSlackChannel(channels).catch(err => {
+          logger.error('Error occured in updating slack channels: ', err);
+        });
+
+        const promises = channels.split(',').map(function(chan) {
+          return crowi.slack.postComment(createdComment, user, chan, path);
+        });
+
+        Promise.all(promises)
+          .catch(err => {
+            logger.error('Error occured in sending slack notification: ', err);
+          });
+      }
+    }
   };
 
   /**

+ 92 - 0
lib/routes/hackmd.js

@@ -0,0 +1,92 @@
+const logger = require('@alias/logger')('growi:routes:hackmd');
+const path = require('path');
+const swig = require('swig-templates');
+const axios = require('axios');
+
+const ApiResponse = require('../util/apiResponse');
+
+module.exports = function(crowi, app) {
+  const Page = crowi.models.Page;
+
+  // load GROWI agent script for HackMD
+  const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
+  const agentScriptPath = path.join(crowi.publicDir, manifest['js/agent-for-hackmd.js']);
+  // generate swig template
+  const agentScriptContentTpl = swig.compileFile(agentScriptPath);
+
+
+  /**
+   * loadAgent action
+   * This should be access from HackMD and send agent script
+   *
+   * @param {object} req
+   * @param {object} res
+   */
+  const loadAgent = function(req, res) {
+    const origin = `${req.protocol}://${req.get('host')}`;
+    const styleFilePath = origin + manifest['styles/style-hackmd.css'];
+
+    // generate definitions to replace
+    const definitions = {
+      origin,
+      styleFilePath,
+    };
+    // inject
+    const script = agentScriptContentTpl(definitions);
+
+    res.set('Content-Type', 'application/javascript');
+    res.send(script);
+  };
+
+  /**
+   * Create page on HackMD and start to integrate
+   * @param {object} req
+   * @param {object} res
+   */
+  const integrate = async function(req, res) {
+    // validate process.env.HACKMD_URI
+    const hackMdUri = process.env.HACKMD_URI;
+    if (hackMdUri == null) {
+      return res.json(ApiResponse.error('HackMD for GROWI has not been setup'));
+    }
+    // validate pageId
+    const pageId = req.body.pageId;
+    if (pageId == null) {
+      return res.json(ApiResponse.error('pageId required'));
+    }
+    // validate page
+    const page = await Page.findOne({ _id: pageId });
+    if (page == null) {
+      return res.json(ApiResponse.error(`Page('${pageId}') does not exist`));
+    }
+    if (page.pageIdOnHackmd != null) {
+      return res.json(ApiResponse.error(`'pageIdOnHackmd' of the page '${page.path}' is not empty`));
+    }
+
+    // access to HackMD and create page
+    const response = await axios.get(`${hackMdUri}/new`);
+    logger.debug('HackMD responds', response);
+
+    // extract page id on HackMD
+    const pagePathOnHackmd = response.request.path;     // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
+    const pageIdOnHackmd = pagePathOnHackmd.substr(1);  // strip the head '/'
+
+    // persist
+    try {
+      await Page.registerHackmdPage(page, pageIdOnHackmd);
+
+      const data = {
+        pageIdOnHackmd,
+      };
+      return res.json(ApiResponse.success(data));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+  return {
+    loadAgent,
+    integrate,
+  };
+};

+ 9 - 4
lib/routes/index.js

@@ -1,5 +1,5 @@
 module.exports = function(crowi, app) {
-  var middleware = require('../util/middlewares')
+  const middleware = require('../util/middlewares')
     , multer    = require('multer')
     , uploads   = multer({dest: crowi.tmpDir + 'uploads'})
     , form      = require('../form')
@@ -16,6 +16,7 @@ module.exports = function(crowi, app) {
     , bookmark  = require('./bookmark')(crowi, app)
     , revision  = require('./revision')(crowi, app)
     , search    = require('./search')(crowi, app)
+    , hackmd    = require('./hackmd')(crowi, app)
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser(crowi, app)
     , csrf      = middleware.csrfVerify(crowi, app)
@@ -80,7 +81,8 @@ module.exports = function(crowi, app) {
 
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
-  app.post('/admin/markdown/lineBreaksSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting);
+  app.post('/admin/markdown/lineBreaksSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting); //change form name
+  app.post('/admin/markdown/xss-setting'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
 
   // markdown admin
   app.get('/admin/customize'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.customize.index);
@@ -124,7 +126,7 @@ module.exports = function(crowi, app) {
 
   // user-groups admin
   app.get('/admin/user-groups'             , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.index);
-  app.get('/admin/user-group-detail/:name'          , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.detail);
+  app.get('/admin/user-group-detail/:id'          , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.detail);
   app.post('/admin/user-group/create'      , form.admin.userGroupCreate, loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.create);
   app.post('/admin/user-group/:userGroupId/update', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.update);
   app.post('/admin/user-group/:userGroupId/picture/delete', loginRequired(crowi, app), admin.userGroup.deletePicture);
@@ -133,7 +135,7 @@ module.exports = function(crowi, app) {
 
   // user-group-relations admin
   app.post('/admin/user-group-relation/create', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.create);
-  app.post('/admin/user-group-relation/:name/remove-relation/:relationId', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.remove);
+  app.post('/admin/user-group-relation/:id/remove-relation/:relationId', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.remove);
 
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
@@ -202,6 +204,9 @@ module.exports = function(crowi, app) {
   app.get('/trash/$'                 , loginRequired(crowi, app, false) , page.trashPageListShowWrapper);
   app.get('/trash/*/$'               , loginRequired(crowi, app, false) , page.deletedPageListShowWrapper);
 
+  app.get('/_hackmd/load-agent'      , hackmd.loadAgent);
+  app.post('/_api/hackmd/integrate'  , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.integrate);
+
   app.get('/*/$'                   , loginRequired(crowi, app, false) , page.pageListShowWrapper);
   app.get('/*'                     , loginRequired(crowi, app, false) , page.pageShowWrapper);
 };

+ 118 - 55
lib/routes/page.js

@@ -1,15 +1,16 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:page')
+  const debug = require('debug')('growi:routes:page')
+    , logger = require('@alias/logger')('growi:routes:page')
     , Page = crowi.model('Page')
     , User = crowi.model('User')
     , Config   = crowi.model('Config')
     , config   = crowi.getConfig()
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
-    , UserGroupRelation = crowi.model('UserGroupRelation')
     , PageGroupRelation = crowi.model('PageGroupRelation')
+    , UpdatePost = crowi.model('UpdatePost')
     , ApiResponse = require('../util/apiResponse')
     , interceptorManager = crowi.getInterceptorManager()
     , pagePathUtil = require('../util/pagePathUtil')
@@ -178,6 +179,8 @@ module.exports = function(crowi, app) {
 
       if (portalPage) {
         renderVars.revision = portalPage.revision;
+        renderVars.revisionHackmdSynced = portalPage.revisionHackmdSynced;
+        renderVars.pageIdOnHackmd = portalPage.pageIdOnHackmd;
         return Revision.findRevisionList(portalPage.path, {});
       }
       else {
@@ -219,7 +222,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.pageListShowForCrowiPlus = function(req, res) {
-    var path = getPathFromRequest(req);
+    let path = getPathFromRequest(req);
     // omit the slash of the last
     path = path.replace((/\/$/), '');
     // redirect
@@ -227,25 +230,25 @@ module.exports = function(crowi, app) {
   };
 
   actions.pageShowForCrowiPlus = function(req, res) {
-    var path = getPathFromRequest(req);
+    const path = getPathFromRequest(req);
 
-    var limit = 50;
-    var offset = parseInt(req.query.offset)  || 0;
-    var SEENER_THRESHOLD = 10;
+    const limit = 50;
+    const offset = parseInt(req.query.offset)  || 0;
+    const SEENER_THRESHOLD = 10;
 
     // index page
-    var pagerOptions = {
+    const pagerOptions = {
       offset: offset,
       limit: limit
     };
-    var queryOptions = {
+    const queryOptions = {
       offset: offset,
       limit: limit + 1,
       isPopulateRevisionBody: Config.isEnabledTimeline(config),
       includeDeletedPage: path.startsWith('/trash/'),
     };
 
-    var renderVars = {
+    const renderVars = {
       path: path,
       page: null,
       revision: {},
@@ -254,11 +257,13 @@ module.exports = function(crowi, app) {
       tree: [],
       pageRelatedGroup: null,
       template: null,
+      revisionHackmdSynced: null,
+      slack: '',
     };
 
-    var pageTeamplate = 'customlayout-selector/page';
+    let view = 'customlayout-selector/page';
 
-    var isRedirect = false;
+    let isRedirect = false;
     Page.findPage(path, req.user, req.query.revision)
     .then(function(page) {
       debug('Page found', page._id, page.path);
@@ -276,6 +281,8 @@ module.exports = function(crowi, app) {
         renderVars.path = page.path;
         renderVars.revision = page.revision;
         renderVars.author = page.revision.author;
+        renderVars.revisionHackmdSynced = page.revisionHackmdSynced;
+        renderVars.pageIdOnHackmd = page.pageIdOnHackmd;
 
         return Revision.findRevisionList(page.path, {})
         .then(function(tree) {
@@ -289,13 +296,19 @@ module.exports = function(crowi, app) {
             renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
           }
         })
+        .then(() => {
+          return getSlackChannels(page);
+        })
+        .then((channels) => {
+          renderVars.slack = channels;
+        })
         .then(function() {
-          var userPage = isUserPage(page.path);
-          var userData = null;
+          const userPage = isUserPage(page.path);
+          let userData = null;
 
           if (userPage) {
             // change template
-            pageTeamplate = 'customlayout-selector/user_page';
+            view = 'customlayout-selector/user_page';
 
             return User.findUserByUsername(User.getUsernameByPath(page.path))
             .then(function(data) {
@@ -321,18 +334,30 @@ module.exports = function(crowi, app) {
         });
       }
     })
-    // look for templates if page not exists
+    // page is not found or user is forbidden
     .catch(function(err) {
-      pageTeamplate = 'customlayout-selector/not_found';
+      let isForbidden = false;
+      if (err.name === 'UserHasNoGrantException') {
+        isForbidden = true;
+      }
 
-      return Page.findTemplate(path)
-        .then(template => {
-          if (template) {
-            template = replacePlaceholders(template, req);
-          }
+      if (isForbidden) {
+        view = 'customlayout-selector/forbidden';
+        return;
+      }
+      else {
+        view = 'customlayout-selector/not_found';
 
-          renderVars.template = template;
-        });
+        // look for templates
+        return Page.findTemplate(path)
+          .then(template => {
+            if (template) {
+              template = replacePlaceholders(template, req);
+            }
+
+            renderVars.template = template;
+          });
+      }
     })
     // get list pages
     .then(function() {
@@ -357,16 +382,26 @@ module.exports = function(crowi, app) {
             return interceptorManager.process('beforeRenderPage', req, res, renderVars);
           })
           .then(function() {
-            res.render(req.query.presentation ? 'page_presentation' : pageTeamplate, renderVars);
+            res.render(req.query.presentation ? 'page_presentation' : view, renderVars);
           })
           .catch(function(err) {
-            console.log(err);
-            debug('Error on rendering pageListShowForCrowiPlus', err);
+            logger.error('Error on rendering pageListShowForCrowiPlus', err);
           });
       }
     });
   };
 
+  const getSlackChannels = async page => {
+    if (page.extended.slack) {
+      return page.extended.slack;
+    }
+    else {
+      const data = await UpdatePost.findSettingsByPath(page.path);
+      const channels = data.map(e => e.channel).join(', ');
+      return channels;
+    }
+  };
+
   const replacePlaceholders = (template, req) => {
     const definitions = {
       pagepath: getPathFromRequest(req),
@@ -447,22 +482,28 @@ module.exports = function(crowi, app) {
     });
   };
 
-  function renderPage(pageData, req, res) {
-    // create page
+  async function renderPage(pageData, req, res, isForbidden) {
     if (!pageData) {
-      const path = getPathFromRequest(req);
-      return Page.findTemplate(path)
-        .then(template => {
-          if (template) {
-            template = replacePlaceholders(template, req);
-          }
+      let view = 'customlayout-selector/not_found';
+      let template = undefined;
 
-          return res.render('customlayout-selector/not_found', {
-            author: {},
-            page: false,
-            template,
-          });
-        });
+      // forbidden
+      if (isForbidden) {
+        view = 'customlayout-selector/forbidden';
+      }
+      else {
+        const path = getPathFromRequest(req);
+        template = await Page.findTemplate(path);
+        if (template != null) {
+          template = replacePlaceholders(template, req);
+        }
+      }
+
+      return res.render(view, {
+        author: {},
+        page: false,
+        template,
+      });
     }
 
 
@@ -470,14 +511,15 @@ module.exports = function(crowi, app) {
       return res.redirect(encodeURI(pageData.redirectTo + '?redirectFrom=' + pagePathUtil.encodePagePath(pageData.path)));
     }
 
-    var renderVars = {
+    const renderVars = {
       path: pageData.path,
       page: pageData,
       revision: pageData.revision || {},
       author: pageData.revision.author || false,
+      slack: '',
     };
-    var userPage = isUserPage(pageData.path);
-    var userData = null;
+    const userPage = isUserPage(pageData.path);
+    let userData = null;
 
     Revision.findRevisionList(pageData.path, {})
     .then(function(tree) {
@@ -491,6 +533,12 @@ module.exports = function(crowi, app) {
         renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
       }
     })
+    .then(() => {
+      return getSlackChannels(pageData);
+    })
+    .then(channels => {
+      renderVars.slack = channels;
+    })
     .then(function() {
       if (userPage) {
         return User.findUserByUsername(User.getUsernameByPath(pageData.path))
@@ -520,11 +568,11 @@ module.exports = function(crowi, app) {
     }).then(function() {
       return interceptorManager.process('beforeRenderPage', req, res, renderVars);
     }).then(function() {
-      var defaultPageTeamplate = 'customlayout-selector/page';
+      let view = 'customlayout-selector/page';
       if (userData) {
-        defaultPageTeamplate = 'customlayout-selector/user_page';
+        view = 'customlayout-selector/user_page';
       }
-      res.render(req.query.presentation ? 'page_presentation' : defaultPageTeamplate, renderVars);
+      res.render(req.query.presentation ? 'page_presentation' : view, renderVars);
     }).catch(function(err) {
       debug('Error: renderPage()', err);
       if (err) {
@@ -551,7 +599,14 @@ module.exports = function(crowi, app) {
       }
 
       return renderPage(page, req, res);
-    }).catch(function(err) {
+    })
+    // page is not found or the user is forbidden
+    .catch(function(err) {
+
+      let isForbidden = false;
+      if (err.name === 'UserHasNoGrantException') {
+        isForbidden = true;
+      }
 
       const normalizedPath = Page.normalizePath(path);
       if (normalizedPath !== path) {
@@ -562,7 +617,7 @@ module.exports = function(crowi, app) {
       // これ以前に定義されているはずなので、こうしてしまって問題ない。
       if (!Page.isCreatableName(path)) {
         // 削除済みページの場合 /trash 以下に移動しているので creatableName になっていないので、表示を許可
-        debug('Page is not creatable name.', path);
+        logger.warn('Page is not creatable name.', path);
         res.redirect('/');
         return ;
       }
@@ -580,9 +635,9 @@ module.exports = function(crowi, app) {
           return res.redirect(pagePathUtil.encodePagePath(path) + '/');
         }
         else {
-          var fixed = Page.fixToCreatableName(path);
+          const fixed = Page.fixToCreatableName(path);
           if (fixed !== path) {
-            debug('fixed page name', fixed);
+            logger.warn('fixed page name', fixed);
             res.redirect(pagePathUtil.encodePagePath(fixed));
             return ;
           }
@@ -594,7 +649,7 @@ module.exports = function(crowi, app) {
 
           // render editor
           debug('Catch pageShow', err);
-          return renderPage(null, req, res);
+          return renderPage(null, req, res, isForbidden);
         }
       }).catch(function(err) {
         debug('Error on rendering pageShow (redirect to portal)', err);
@@ -664,11 +719,19 @@ module.exports = function(crowi, app) {
       // TODO: move to events
       if (notify.slack) {
         if (notify.slack.on && notify.slack.channel) {
-          data.updateSlackChannel(notify.slack.channel).then(function() {}).catch(function() {});
+          data.updateSlackChannel(notify.slack.channel)
+          .catch(err => {
+            logger.error('Error occured in updating slack channels: ', err);
+          });
 
           if (crowi.slack) {
-            notify.slack.channel.split(',').map(function(chan) {
-              crowi.slack.post(pageData, req.user, chan, updateOrCreate, previousRevision);
+            const promises = notify.slack.channel.split(',').map(function(chan) {
+              return crowi.slack.postPage(pageData, req.user, chan, updateOrCreate, previousRevision);
+            });
+
+            Promise.all(promises)
+            .catch(err => {
+              logger.error('Error occured in sending slack notification: ', err);
             });
           }
         }

+ 7 - 3
lib/util/middlewares.js

@@ -77,7 +77,7 @@ exports.swigFunctions = function(crowi, app) {
   };
 };
 
-exports.swigFilters = function(app, swig) {
+exports.swigFilters = function(crowi, app, swig) {
 
   // define a function for Gravatar
   const generateGravatarSrc = function(user) {
@@ -139,7 +139,7 @@ exports.swigFilters = function(app, swig) {
 
     swig.setFilter('datetz', function(input, format) {
       // timezone
-      var swigFilters = require('swig-templates/lib/filters');
+      const swigFilters = require('swig-templates/lib/filters');
       return swigFilters.date(input, format, app.get('tzoffset'));
     });
 
@@ -179,10 +179,14 @@ exports.swigFilters = function(app, swig) {
       }
     });
 
-    swig.setFilter('sanitize', function(string) {
+    swig.setFilter('encodeHTML', function(string) {
       return entities.encodeHTML(string);
     });
 
+    swig.setFilter('preventXss', function(string) {
+      return crowi.xss.process(string);
+    });
+
     next();
   };
 };

+ 19 - 0
lib/util/recommendedXssWhiteList.js

@@ -0,0 +1,19 @@
+/**
+ * reference: https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites
+ * added tags: h4, h5, h6, span, div, iframe, table, thead, tbody, tfoot, th, td, tr, colgroup, col
+ * added attributes: class, style
+ */
+
+const tags = [
+  'a', 'b', 'blockquote', 'blockquote', 'code', 'del', 'dd', 'dl', 'dt', 'em',
+  'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'i', 'img', 'kbd', 'li', 'ol', 'p', 'pre',
+  's', 'sup', 'sub', 'strong', 'strike', 'ul', 'br', 'hr', 'span', 'div', 'iframe',
+  'table', 'thead', 'tbody', 'tfoot', 'th', 'td', 'tr', 'colgroup', 'col',
+];
+
+const attrs = ['src', 'href', 'class', 'id', 'width', 'height', 'alt', 'title', 'style'];
+
+module.exports = {
+  'tags': tags,
+  'attrs': attrs,
+};

+ 115 - 53
lib/util/slack.js

@@ -11,19 +11,37 @@ module.exports = function(crowi) {
     Slack = require('slack-node'),
     slack = {};
 
-  const postWithIwh = function(messageObj, callback) {
-    const client = new Slack();
-    client.setWebhook(config.notification['slack:incomingWebhookUrl']);
-    client.webhook(messageObj, callback);
+  const postWithIwh = function(messageObj) {
+    return new Promise((resolve, reject) => {
+      const client = new Slack();
+      client.setWebhook(config.notification['slack:incomingWebhookUrl']);
+      client.webhook(messageObj, function(err, res) {
+        if (err) {
+          debug('Post error', err, res);
+          debug('Sent data to slack is:', messageObj);
+          return reject(err);
+        }
+        resolve(res);
+      });
+    });
   };
 
-  const postWithWebApi = function(messageObj, callback) {
-    const client = new Slack(config.notification['slack:token']);
-    // stringify attachments
-    if (messageObj.attachments != null) {
-      messageObj.attachments = JSON.stringify(messageObj.attachments);
-    }
-    client.api('chat.postMessage', messageObj, callback);
+  const postWithWebApi = function(messageObj) {
+    return new Promise((resolve, reject) => {
+      const client = new Slack(config.notification['slack:token']);
+      // stringify attachments
+      if (messageObj.attachments != null) {
+        messageObj.attachments = JSON.stringify(messageObj.attachments);
+      }
+      client.api('chat.postMessage', messageObj, function(err, res) {
+        if (err) {
+          debug('Post error', err, res);
+          debug('Sent data to slack is:', messageObj);
+          return reject(err);
+        }
+        resolve(res);
+      });
+    });
   };
 
   const convertMarkdownToMrkdwn = function(body) {
@@ -80,9 +98,23 @@ module.exports = function(crowi) {
     return diffText;
   };
 
-  const prepareSlackMessage = function(page, user, channel, updateType, previousRevision) {
-    var url = config.crowi['app:url'] || '';
-    var body = page.revision.body;
+  const prepareAttachmentTextForComment = function(comment) {
+    let body = comment.comment;
+    if (body.length > 2000) {
+      body = body.substr(0, 2000) + '...';
+    }
+
+    if (comment.isMarkdown) {
+      return convertMarkdownToMrkdwn(body);
+    }
+    else {
+      return body;
+    }
+  };
+
+  const prepareSlackMessageForPage = function(page, user, channel, updateType, previousRevision) {
+    const url = config.crowi['app:url'] || '';
+    let body = page.revision.body;
 
     if (updateType == 'create') {
       body = prepareAttachmentTextForCreate(page, user);
@@ -91,7 +123,7 @@ module.exports = function(crowi) {
       body = prepareAttachmentTextForUpdate(page, user, previousRevision);
     }
 
-    var attachment = {
+    const attachment = {
       color: '#263a3c',
       author_name: '@' + user.username,
       author_link: url + '/user/' + user.username,
@@ -105,17 +137,43 @@ module.exports = function(crowi) {
       attachment.author_icon = user.image;
     }
 
-    var message = {
+    const message = {
       channel: '#' + channel,
       username: Config.appTitle(config),
-      text: getSlackMessageText(page.path, user, updateType),
+      text: getSlackMessageTextForPage(page.path, user, updateType),
       attachments: [attachment],
     };
 
     return message;
   };
 
-  const getSlackMessageText = function(path, user, updateType) {
+  const prepareSlackMessageForComment = function(comment, user, channel, path) {
+    const url = config.crowi['app:url'] || '';
+    const body = prepareAttachmentTextForComment(comment);
+
+    const attachment = {
+      color: '#263a3c',
+      author_name: '@' + user.username,
+      author_link: url + '/user/' + user.username,
+      author_icon: user.image,
+      text: body,
+      mrkdwn_in: ['text'],
+    };
+    if (user.image) {
+      attachment.author_icon = user.image;
+    }
+
+    const message = {
+      channel: '#' + channel,
+      username: Config.appTitle(config),
+      text: getSlackMessageTextForComment(path, user),
+      attachments: [attachment],
+    };
+
+    return message;
+  };
+
+  const getSlackMessageTextForPage = function(path, user, updateType) {
     let text;
     const url = config.crowi['app:url'] || '';
 
@@ -130,46 +188,50 @@ module.exports = function(crowi) {
     return text;
   };
 
+  const getSlackMessageTextForComment = function(path, user) {
+    const url = config.crowi['app:url'] || '';
+    const pageUrl = `<${url}${path}|${path}>`;
+    const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
+
+    return text;
+  };
+
   // slack.post = function (channel, message, opts) {
-  slack.post = (page, user, channel, updateType, previousRevision) => {
-    const messageObj = prepareSlackMessage(page, user, channel, updateType, previousRevision);
+  slack.postPage = (page, user, channel, updateType, previousRevision) => {
+    const messageObj = prepareSlackMessageForPage(page, user, channel, updateType, previousRevision);
 
-    return new Promise((resolve, reject) => {
-      // define callback function for Promise
-      const callback = function(err, res) {
-        if (err) {
-          debug('Post error', err, res);
-          debug('Sent data to slack is:', messageObj);
-          return reject(err);
-        }
-        resolve(res);
-      };
+    return slackPost(messageObj);
+  };
 
-      // when incoming Webhooks is prioritized
-      if (Config.isIncomingWebhookPrioritized(config)) {
-        if (Config.hasSlackIwhUrl(config)) {
-          debug('posting message with IncomingWebhook');
-          postWithIwh(messageObj, callback);
-        }
-        else if (Config.hasSlackToken(config)) {
-          debug('posting message with Web API');
-          postWithWebApi(messageObj, callback);
-        }
+  slack.postComment = (comment, user, channel, path) => {
+    const messageObj = prepareSlackMessageForComment(comment, user, channel, path);
+
+    return slackPost(messageObj);
+  };
+
+  const slackPost = (messageObj) => {
+    // when incoming Webhooks is prioritized
+    if (Config.isIncomingWebhookPrioritized(config)) {
+      if (Config.hasSlackIwhUrl(config)) {
+        debug('posting message with IncomingWebhook');
+        return postWithIwh(messageObj);
       }
-      // else
-      else {
-        if (Config.hasSlackToken(config)) {
-          debug('posting message with Web API');
-          postWithWebApi(messageObj, callback);
-        }
-        else if (Config.hasSlackIwhUrl(config)) {
-          debug('posting message with IncomingWebhook');
-          postWithIwh(messageObj, callback);
-        }
+      else if (Config.hasSlackToken(config)) {
+        debug('posting message with Web API');
+        return postWithWebApi(messageObj);
       }
-
-      resolve();
-    });
+    }
+    // else
+    else {
+      if (Config.hasSlackToken(config)) {
+        debug('posting message with Web API');
+        return postWithWebApi(messageObj);
+      }
+      else if (Config.hasSlackIwhUrl(config)) {
+        debug('posting message with IncomingWebhook');
+        return postWithIwh(messageObj);
+      }
+    }
   };
 
   return slack;

+ 25 - 33
lib/util/swigFunctions.js

@@ -1,5 +1,5 @@
 module.exports = function(crowi, app, req, locals) {
-  var debug = require('debug')('growi:lib:swigFunctions')
+  const debug = require('debug')('growi:lib:swigFunctions')
     , stringWidth = require('string-width')
     , Page = crowi.model('Page')
     , Config = crowi.model('Config')
@@ -45,15 +45,15 @@ module.exports = function(crowi, app, req, locals) {
    * return app title
    */
   locals.appTitle = function() {
-    var config = crowi.getConfig();
-    return Config.appTitle(config);
+    const config = crowi.getConfig();
+    return crowi.xss.process(Config.appTitle(config));
   };
 
   /**
    * return true if enabled
    */
   locals.isEnabledPassport = function() {
-    var config = crowi.getConfig();
+    const config = crowi.getConfig();
     return Config.isEnabledPassport(config);
   };
 
@@ -69,7 +69,7 @@ module.exports = function(crowi, app, req, locals) {
    * return true if enabled and strategy has been setup successfully
    */
   locals.isLdapSetup = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && passportService.isLdapStrategySetup;
   };
 
@@ -77,7 +77,7 @@ module.exports = function(crowi, app, req, locals) {
    * return true if enabled but strategy has some problem
    */
   locals.isLdapSetupFailed = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && !passportService.isLdapStrategySetup;
   };
 
@@ -88,17 +88,17 @@ module.exports = function(crowi, app, req, locals) {
       return false;
     }
 
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];
   };
 
   locals.passportGoogleLoginEnabled = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return locals.isEnabledPassport() && config.crowi['security:passport-google:isEnabled'];
   };
 
   locals.passportGitHubLoginEnabled = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return locals.isEnabledPassport() && config.crowi['security:passport-github:isEnabled'];
   };
 
@@ -115,17 +115,17 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.isEnabledPlugins = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isEnabledPlugins(config);
   };
 
   locals.isEnabledLinebreaks = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isEnabledLinebreaks(config);
   };
 
   locals.isEnabledLinebreaksInComments = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isEnabledLinebreaksInComments(config);
   };
 
@@ -138,12 +138,12 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.customHeader = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.customHeader(config);
   };
 
   locals.theme = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.theme(config);
   };
 
@@ -153,45 +153,37 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.behaviorType = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.behaviorType(config);
   };
 
   locals.layoutType = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.layoutType(config);
   };
 
   locals.highlightJsStyle = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.highlightJsStyle(config);
   };
 
   locals.highlightJsStyleBorder = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.highlightJsStyleBorder(config);
   };
 
   locals.isEnabledTimeline = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isEnabledTimeline(config);
   };
 
-  locals.slackConfigured = function() {
-    var config = crowi.getConfig();
-    if (Config.hasSlackToken(config) || Config.hasSlackIwhUrl(config)) {
-      return true;
-    }
-    return false;
-  };
-
   locals.isUploadable = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isUploadable(config);
   };
 
   locals.isEnabledAttachTitleHeader = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return Config.isEnabledAttachTitleHeader(config);
   };
 
@@ -216,7 +208,7 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.isTopPage = function() {
-    var path = req.path || '';
+    let path = req.path || '';
     if (path === '/') {
       return true;
     }
@@ -225,7 +217,7 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.isTrashPage = function() {
-    var path = req.path || '';
+    let path = req.path || '';
     if (path.match(/^\/trash\/.*/)) {
       return true;
     }
@@ -234,8 +226,8 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.isDeletablePage = function() {
-    var Page = crowi.model('Page');
-    var path = req.path || '';
+    let Page = crowi.model('Page');
+    let path = req.path || '';
 
     return Page.isDeletableName(path);
   };

+ 18 - 10
lib/util/xss.js

@@ -1,26 +1,34 @@
 class Xss {
 
-  constructor(isAllowAllAttrs) {
+  constructor(xssOption) {
     const xss = require('xss');
 
-    // create the option object
+    xssOption = xssOption || {};
+
+    const tagWhiteList = xssOption.tagWhiteList || [];
+    const attrWhiteList = xssOption.attrWhiteList || [];
+
+    let whiteListContent = {};
+
+    // default
     let option = {
       stripIgnoreTag: true,
+      stripIgnoreTagBody: false,    // see https://github.com/weseek/growi/pull/505
       css: false,
+      whiteList: whiteListContent,
       escapeHtml: (html) => html,   // resolve https://github.com/weseek/growi/issues/221
     };
-    if (isAllowAllAttrs) {
-      // allow all attributes
-      option.onTagAttr = function(tag, name, value, isWhiteAttr) {
-        return `${name}="${value}"`;
-      };
-    }
+
+    tagWhiteList.forEach(tag => {
+      whiteListContent[tag] = attrWhiteList;
+    });
+
     // create the XSS Filter instance
     this.myxss = new xss.FilterXSS(option);
   }
 
-  process(markdown) {
-    return this.myxss.process(markdown);
+  process(document) {
+    return this.myxss.process(document);
   }
 
 }

+ 13 - 0
lib/util/xssOption.js

@@ -0,0 +1,13 @@
+class XssOption {
+
+  constructor(config) {
+    const recommendedXssWhiteList = require('../util/recommendedXssWhiteList');
+    const initializedConfig = (config != null) ? config : {};
+
+    this.isEnabledXssPrevention = initializedConfig.isEnabledXssPrevention || true;
+    this.tagWhiteList = initializedConfig.tagWhiteList || recommendedXssWhiteList.tags;
+    this.attrWhiteList = initializedConfig.attrWhiteList || recommendedXssWhiteList.attrs;
+  }
+
+}
+module.exports = XssOption;

+ 5 - 26
lib/views/_form.html

@@ -16,38 +16,17 @@
 
 <form action="/_/edit" id="page-form" method="post" class="{% if isUploadable() %}uploadable{% endif %} page-form">
 
-  <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
-
   <input type="hidden" id="form-body" name="pageForm[body]" value="{% if pageForm.body %}{{ pageForm.body }}{% endif %}">
-  <input type="hidden" name="pageForm[path]" value="{{ path }}">
+  <input type="hidden" name="pageForm[path]" value="{{ path | preventXss }}">
   <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
-  <div class="page-editor-footer form-submit-group form-group form-inline
-      d-flex align-items-center justify-content-between">
+  <div class="page-editor-footer d-flex flex-row align-items-center justify-content-between">
     <div>
-      <div id="page-editor-options-selector"></div>
+      <div id="page-editor-options-selector" class="hidden-xs"></div>
     </div>
 
-    <div class="form-inline page-form-setting d-flex align-items-center" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">
-      {% if slackConfigured() %}
-      <span class="input-group input-group-sm input-group-slack extended-setting m-r-5">
-        <div class="input-group-addon">
-          <img id="slack-mark-white" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18">
-          <img id="slack-mark-black" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18">
-          <input class="" type="checkbox" name="pageForm[notify][slack][on]" value="1">
-        </div>
-        <input class="form-control" type="text" name="pageForm[notify][slack][channel]" value="{{ page.extended.slack|default('') }}" placeholder="slack-channel-name"
-          id="page-form-slack-channel"
-          data-toggle="popover"
-          title="Slack通知"
-          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
-          data-trigger="focus"
-          data-placement="top"
-        >
-      </span>
-      {% endif %}
-
+    <div class="form-inline d-flex align-items-center" id="page-form-setting">
+      <div id="editor-slack-notification" class="mr-2"></div>
       <div id="page-grant-selector"></div>
-
       <input type="hidden" id="page-grant" name="pageForm[grant]" value="{{ page.grant }}">
       <input type="hidden" id="grant-group" name="pageForm[grantUserGroupId]" value="{{ pageRelatedGroup._id.toString() }}">
       <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">

+ 162 - 40
lib/views/admin/markdown.html

@@ -38,61 +38,179 @@
       {% endif %}
 
       <form action="/admin/markdown/lineBreaksSetting" method="post" class="form-horizontal" id="markdownSettingForm" role="form">
-      <fieldset>
-        <legend>{{ t('Markdown settings') }}</legend>
-        <p class="well">{{ t("markdown_setting.markdown_rendering") }}</p>
-
-        <div class="form-group">
-          <label for="markdownSetting[markdown:isEnabledLinebreaks]" class="col-xs-4 control-label">
-            {{ t('markdown_setting.Enable Line Break') }}
-          </label>
-          <div class="col-xs-5">
-            <div class="btn-group btn-toggle" data-toggle="buttons">
-              <label class="btn btn-default btn-rounded btn-outline {% if markdownSetting['markdown:isEnabledLinebreaks'] %}active{% endif %}" data-active-class="primary">
-                <input name="markdownSetting[markdown:isEnabledLinebreaks]" value="true" type="radio"
-                    {% if true === markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> ON
-              </label>
-              <label class="btn btn-default btn-rounded btn-outline {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}active{% endif %}" data-active-class="default">
-                <input name="markdownSetting[markdown:isEnabledLinebreaks]" value="false" type="radio"
-                    {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> OFF
-              </label>
+        <fieldset>
+          <legend>{{ t('markdown_setting.line_break_setting') }}</legend>
+          <p class="well">{{ t("markdown_setting.line_break_setting_desc") }}</p>
+
+          <div class="form-group">
+            <label for="markdownSetting[markdown:isEnabledLinebreaks]" class="col-xs-4 control-label">
+              {{ t('markdown_setting.Enable Line Break') }}
+            </label>
+            <div class="col-xs-5">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if markdownSetting['markdown:isEnabledLinebreaks'] %}active{% endif %}" data-active-class="primary">
+                  <input name="markdownSetting[markdown:isEnabledLinebreaks]" value="true" type="radio"
+                      {% if true === markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}active{% endif %}" data-active-class="default">
+                  <input name="markdownSetting[markdown:isEnabledLinebreaks]" value="false" type="radio"
+                      {% if !markdownSetting['markdown:isEnabledLinebreaks'] %}checked{% endif %}> OFF
+                </label>
+              </div>
+              <p class="help-block">{{ t("markdown_setting.Enable Line Break desc") }}</p>
             </div>
-            <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">
-            {{ t("markdown_setting.Enable Line Break for comment") }}
-          </label>
-          <div class="col-xs-5">
-            <div class="btn-group btn-toggle" data-toggle="buttons">
-              <label class="btn btn-default btn-rounded btn-outline {% if markdownSetting['markdown:isEnabledLinebreaksInComments'] %}active{% endif %}" data-active-class="primary">
-                <input name="markdownSetting[markdown:isEnabledLinebreaksInComments]" value="true" type="radio"
-                    {% if true === markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> ON
-              </label>
-              <label class="btn btn-default btn-rounded btn-outline {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}active{% endif %}" data-active-class="default">
-                <input name="markdownSetting[markdown:isEnabledLinebreaksInComments]" value="false" type="radio"
-                    {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> OFF
-              </label>
+          <div class="form-group">
+            <label for="markdownSetting[markdown:isEnabledLinebreaksInComments]" class="col-xs-4 control-label">
+              {{ t("markdown_setting.Enable Line Break for comment") }}
+            </label>
+            <div class="col-xs-5">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if markdownSetting['markdown:isEnabledLinebreaksInComments'] %}active{% endif %}" data-active-class="primary">
+                  <input name="markdownSetting[markdown:isEnabledLinebreaksInComments]" value="true" type="radio"
+                      {% if true === markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}active{% endif %}" data-active-class="default">
+                  <input name="markdownSetting[markdown:isEnabledLinebreaksInComments]" value="false" type="radio"
+                      {% if !markdownSetting['markdown:isEnabledLinebreaksInComments'] %}checked{% endif %}> OFF
+                </label>
+              </div>
+              <p class="help-block">{{ t("markdown_setting.Enable Line Break for comment desc") }}</p>
             </div>
-            <p class="help-block">{{ t("markdown_setting.Enable Line Break for comment desc") }}</p>
           </div>
-        </div>
 
-        <div class="form-group">
-          <div class="col-xs-offset-4 col-xs-5">
+          <div class="form-group my-3">
+            <div class="col-xs-offset-4 col-xs-5">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-primary">{{ t("Update") }}</button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+
+      <form action="/admin/markdown/xss-setting" method="post" class="form-horizontal" id="markdownSettingForm" role="form">
+        {% set nameForIsXssEnabled = "markdownSetting[markdown:xss:isEnabledPrevention]" %}
+        {% set isXssEnabled = markdownSetting['markdown:xss:isEnabledPrevention'] %}
+
+        <legend>{{ t('markdown_setting.XSS_setting') }}</legend>
+        <p class="well">{{ t("markdown_setting.XSS_setting_desc") }}</p>
+
+        <fieldset class="row">
+          <div class="form-group">
+            <label for="markdownSetting[markdown:isEnabledLinebreaks]" class="col-xs-4 control-label">
+              {{ t('markdown_setting.Enable XSS prevention') }}
+            </label>
+            <div class="col-xs-5">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if isXssEnabled %}active{% endif %}" data-active-class="primary">
+                  <input name="{{nameForIsXssEnabled}}" value="true" type="radio"
+                      {% if isXssEnabled %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !isXssEnabled %}active{% endif %}" data-active-class="default">
+                  <input name="{{nameForIsXssEnabled}}" value="false" type="radio"
+                      {% if !isXssEnabled %}checked{% endif %}> OFF
+                </label>
+              </div>
+            </div>
+          </div>
+        </fieldset>
+
+        <fieldset class="form-group row my-3" id="xss-hide-when-disabled" {% if !isXssEnabled %}style="display: none;"{% endif %}>
+          {% set nameForXssOption = "markdownSetting[markdown:xss:option]" %}
+          {% set xssOption = markdownSetting['markdown:xss:option'] %}
+
+          <div class="col-xs-4 radio radio-primary">
+            <input type="radio" id="option1" name="{{nameForXssOption}}" value="1" {% if xssOption === 1 %}checked{% endif %}>
+            <label for="option1">
+              <p class="font-weight-bold">{{ t('markdown_setting.Ignore all tags') }}</p>
+              <div class="m-t-15">
+                  {{ t('markdown_setting.Ignore all tags desc') }}
+              </div>
+            </label>
+          </div>
+
+          <div class="col-xs-4 radio radio-primary">
+            <input type="radio" id="option2" name="{{nameForXssOption}}" value="2" {% if xssOption === 2 %}checked{% endif %}>
+            <label for="option2">
+              <p class="font-weight-bold">{{ t('markdown_setting.Recommended setting') }}</p>
+              <div class="m-t-15">
+                {{ t('markdown_setting.Tag names') }}
+                <textarea class="form-control xss-list" name="recommendedTags" rows="6" cols="40" readonly>{{ recommendedXssWhiteList.tags }}</textarea>
+              </div>
+              <div class="m-t-15">
+                {{ t('markdown_setting.Tag attributes') }}
+                <textarea class="form-control xss-list" name="recommendedAttrs" rows="6" cols="40" readonly>{{ recommendedXssWhiteList.attrs }}</textarea>
+              </div>
+            </label>
+          </div>
+
+          <div class="col-xs-4 radio radio-primary">
+            <input type="radio" id="option3" name="{{nameForXssOption}}" value="3" {% if xssOption === 3 %}checked{% endif %}>
+            <label for="option3">
+              <p class="font-weight-bold">{{ t('markdown_setting.Custom Whitelist') }}</p>
+              <div class="m-t-15">
+                <div class="d-flex justify-content-between">
+                  {{ t('markdown_setting.Tag names') }}
+                  <p id="btn-import-tags" class="btn btn-xs btn-primary">
+                    {{ t('markdown_setting.import_recommended', 'tags') }}
+                  </p>
+                </div>
+                <textarea class="form-control xss-list" type="text" name="markdownSetting[markdown:xss:tagWhiteList]" rows="6" cols="40" placeholder="e.g. iframe, script, video...">{{ markdownSetting['markdown:xss:tagWhiteList'] }}</textarea>
+              </div>
+              <div class="m-t-15">
+                <div class="d-flex justify-content-between">
+                  {{ t('markdown_setting.Tag attributes') }}
+                  <p id="btn-import-attrs" class="btn btn-xs btn-primary">
+                    {{ t('markdown_setting.import_recommended', 'attributes') }}
+                  </p>
+                </div>
+                <textarea class="form-control xss-list" name="markdownSetting[markdown:xss:attrWhiteList]" rows="6" cols="40" placeholder="e.g. src, id, name...">{{ markdownSetting['markdown:xss:attrWhiteList'] }}</textarea>
+              </div>
+            </label>
+          </div>
+
+        </fieldset>
+
+        <div class="form-group row">
+          <div class="col-xs-12 d-flex justify-content-center">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">
             <button type="submit" class="btn btn-primary">{{ t("Update") }}</button>
           </div>
         </div>
-      </fieldset>
-      </form>
 
+      </form>
     </div>
   </div>
 
 </div>
+
+<script>
+  // give a space between items in textarea(',' => ', ')
+  for (var i = 0; i < $('textarea.xss-list').length; i++) {
+    $($('textarea.xss-list')[i]).val($($('textarea.xss-list')[i]).val().replace(/,/g, ', '));
+  };
+
+  $('input[name="markdownSetting[markdown:xss:isEnabledPrevention]"]').change(function() {
+    if ($(this).val() === 'true') {
+      $('#xss-hide-when-disabled').slideDown();
+    }
+    else {
+      $('#xss-hide-when-disabled').slideUp();
+    }
+  });
+
+  $('#btn-import-tags').on('click', () => {
+    var $tagWhiteList = $('textarea[name="markdownSetting[markdown:xss:tagWhiteList]"]');
+    var $recommendedTagList = $('textarea[name="recommendedTags"]');
+    $tagWhiteList.val($recommendedTagList.val());
+  });
+  $('#btn-import-attrs').on('click', () => {
+    var $attrWhiteList = $('textarea[name="markdownSetting[markdown:xss:attrWhiteList]"]');
+    var $recommendedAttrList = $('textarea[name="recommendedAttrs"]');
+    $attrWhiteList.val($recommendedAttrList.val());
+  });
+</script>
 {% endblock content_main %}
 
 {% block content_footer %}
@@ -100,3 +218,7 @@
 
 
 
+
+
+
+

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

@@ -100,7 +100,17 @@
         </fieldset>
       </form>
 
-      <form action="/_api/admin/security/mechanism" method="post" class="form-horizontal m-t-30" id="mechanismSetting" role="form">
+      <!-- prevent XSS link -->
+      <div class="mt-5">
+        <legend>{{ t('security_setting.xss_prevent_setting') }}</legend>
+        <div class="text-center">
+          <a class="flexbox" style="font-size: large;" href="/admin/markdown/#preventXSS">
+            <i class="fa-fw icon-login"></i> {{ t('security_setting.xss_prevent_setting_link') }}
+          </a>
+        </div>
+       </div>
+
+      <form action="/_api/admin/security/mechanism" method="post" class="form-horizontal mt-5" id="mechanismSetting" role="form">
         <fieldset>
           <legend class="alert-anchor">{{ t('Selecting authentication mechanism') }}</legend>
           <p class="alert alert-info"><b>{{ t("security_setting.note") }}: </b>{{ t("security_setting.require_server_restart_change_auth") }}</p>
@@ -274,7 +284,6 @@
         </div>
 
       </div><!-- /.auth-mechanism-configurations -->
-
     </div>
   </div>
 

+ 3 - 3
lib/views/admin/user-group-detail.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customTitle(t('UserGroup management') + '/' + userGroup.name) }}{% endblock %}
+{% block html_title %}{{ customTitle(t('UserGroup management') + '/' + userGroup.name) | preventXss }}{% endblock %}
 
 {% block content_header %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 class="title" id="">{{ t('UserGroup management') + '/' + userGroup.name }}</h1>
+    <h1 class="title" id="">{{ t('UserGroup management') + '/' + userGroup.name | preventXss }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -199,7 +199,7 @@
                   <i class="icon-settings"></i> <span class="caret"></span>
                 </button>
                 <ul class="dropdown-menu" role="menu">
-                  <form id="form_removeFromGroup_{{ sUser.id }}" action="/admin/user-group-relation/{{userGroup.name}}/remove-relation/{{ sRelation._id.toString() }}" method="post">
+                  <form id="form_removeFromGroup_{{ sUser.id }}" action="/admin/user-group-relation/{{userGroup._id.toString()}}/remove-relation/{{ sRelation._id.toString() }}" method="post">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                   </form>
                   <li>

+ 3 - 3
lib/views/admin/user-groups.html

@@ -119,12 +119,12 @@
         </thead>
         <tbody>
           {% for sGroup in userGroups %}
-          {% set sGroupDetailPageUrl = '/admin/user-group-detail/' + sGroup.name %}
+          {% set sGroupDetailPageUrl = '/admin/user-group-detail/' + sGroup._id.toString() %}
           <tr>
             <td>
               <img src="{{ sGroup|picture }}" class="picture img-circle" />
             </td>
-            <td><a href="{{ sGroupDetailPageUrl }}">{{ sGroup.name }}</a></td>
+            <td><a href="{{ sGroupDetailPageUrl }}">{{ sGroup.name | preventXss }}</a></td>
             <td><ul class="list-inline">
               {% for relation in userGroupRelations.get(sGroup) %}
               <li class="list-inline-item badge badge-primary">{{relation.relatedUser.username}}</li>
@@ -146,7 +146,7 @@
                   <li>
                     <a href="#"
                         data-user-group-id="{{ sGroup._id.toString() }}"
-                        data-user-group-name="{{ sGroup.name.toString() }}"
+                        data-user-group-name="{{ sGroup.name.toString() | encodeHTML }}"
                         data-target="#admin-delete-user-group-modal"
                         data-toggle="modal">
                       <i class="icon-fw icon-fire text-danger"></i> 削除する

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

@@ -49,10 +49,12 @@
     <div class="form-group">
       <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:callbackUrl]" value="{{ settingForm['security:passport-github:callbackUrl'] || '' }}">
+        <input class="form-control" type="text" name="settingForm[security:passport-github:callbackUrl]" value="{{ settingForm['security:passport-github:callbackUrl'] || '' }}"
+            placeholder="http(s)://${growi.host}/passport/github/callback">
         <p class="help-block">
+          Input <code>http(s)://${growi.host}/passport/github/callback</code><br>
           <small>
-            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CALLBACK_URL") }}
+            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CALLBACK_URI") }}
           </small>
         </p>
       </div>

+ 4 - 2
lib/views/admin/widget/passport/google-oauth.html

@@ -49,10 +49,12 @@
     <div class="form-group">
       <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:callbackUrl]" value="{{ settingForm['security:passport-google:callbackUrl'] || '' }}">
+        <input class="form-control" type="text" name="settingForm[security:passport-google:callbackUrl]" value="{{ settingForm['security:passport-google:callbackUrl'] || '' }}"
+            placeholder="http(s)://${growi.host}/passport/google/callback">
         <p class="help-block">
+          Input <code>http(s)://${growi.host}/passport/google/callback</code><br>
           <small>
-            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CALLBACK_URL") }}
+            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CALLBACK_URI") }}
           </small>
         </p>
       </div>

+ 5 - 0
lib/views/customlayout-selector/forbidden.html

@@ -0,0 +1,5 @@
+{% if !layoutType() || 'crowi' === layoutType() %}
+  {% include '../layout-crowi/forbidden.html' %}
+{% else %}
+  {% include '../layout-growi/forbidden.html' %}
+{% endif %}

+ 41 - 0
lib/views/layout-crowi/forbidden.html

@@ -0,0 +1,41 @@
+{% extends 'base/layout.html' %}
+
+{% block content_header %}
+
+  {% block content_header_before %}
+  {% endblock %}
+
+  <div class="header-wrap">
+    <header id="page-header">
+      <div>
+        <div>
+          <h1 class="title" id="revision-path"></h1>
+          <div id="revision-url" class="url-line"></div>
+        </div>
+      </div>
+
+    </header>
+  </div>
+
+  {% block content_header_after %}
+  {% endblock %}
+
+{% endblock %} {# /content_head #}
+
+
+{% block content_main_before %}
+  {% include '../widget/page_alerts.html' %}
+{% endblock %}
+
+
+{% block content_main %}
+  {% include '../widget/forbidden_content.html' %}
+{% endblock %}
+
+
+{% block content_main_after %}
+{% endblock %}
+
+
+{% block content_footer %}
+{% endblock %}

+ 25 - 0
lib/views/layout-growi/forbidden.html

@@ -0,0 +1,25 @@
+{% extends 'base/layout.html' %}
+
+
+{% block content_header %}
+  {% include 'widget/header.html' %}
+{% endblock %}
+
+
+{% block content_main_before %}
+  {% include '../widget/page_alerts.html' %}
+{% endblock %}
+
+
+{% block content_main %}
+  <div class="row">
+    <div class="col-lg-10 col-md-9">
+      {% include '../widget/forbidden_content.html' %}
+    </div> {# /.col- #}
+  </div>
+{% endblock %}
+
+{% block body_end %}
+  <div id="crowi-modals">
+  </div>
+{% endblock %}

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

@@ -88,7 +88,6 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
       <script src="{{ webpack_asset('styles/style.js') }}"></script>
       <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
     {% else %}
-      <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 %}

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

@@ -46,7 +46,7 @@
 
         <div id="template-form" class="row form-horizontal m-t-15">
           <fieldset class="col-xs-12">
-            <legend>{{ t('template.modal_label.Create template under', parentPath(path)) }}</legend>
+            <legend>{{ t('template.modal_label.Create template under', parentPath(path | preventXss)) }}</legend>
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
                 <select id="template-type" class="form-control selectpicker" title="{{ t('template.option_label.select') }}">
@@ -64,6 +64,19 @@
           </fieldset>
         </div>
 
+        <script>
+          $('#template-type').on('change', function() {
+            // enable button
+            $('#link-to-template').removeClass('disabled');
+
+            // modify href
+            const value = $(this).val();
+            const pageName = (value === 'children') ? '_template' : '__template';
+            const link = '{{ page.path || path }}/' + pageName + '#edit-form';
+            $('#link-to-template').attr('href', link);
+          });
+        </script>
+
       </div><!-- /.modal-body -->
 
     </div><!-- /.modal-content -->

+ 2 - 2
lib/views/modal/create_template.html

@@ -18,7 +18,7 @@
                   <p class="help-block text-center"><small>{{ t('template.children.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"
                       class="btn btn-sm btn-primary" id="template-button-children">
                       {{ t("Edit") }}
                   </a>
@@ -33,7 +33,7 @@
                   <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"
                       class="btn btn-sm btn-primary" id="template-button-decendants">
                       {{ t("Edit") }}
                   </a>

+ 50 - 0
lib/views/widget/forbidden_content.html

@@ -0,0 +1,50 @@
+{% block html_head_loading_legacy %}
+  <script src="{{ webpack_asset('js/legacy-form.js') }}" defer></script>  {# load legacy-form for using bootstrap-select(.selectpicker) #}
+  {% parent %}
+{% endblock %}
+
+<div class="row not-found-message-row m-b-20">
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-ban" aria-hidden="true"></i>
+      Forbidden
+    </h2>
+  </div>
+</div>
+
+<div id="content-main" class="content-main content-main-not-found page-list"
+  data-path="{{ path | preventXss }}"
+  data-path-shortname="{{ path|path2name | preventXss }}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  >
+
+  <div class="row row-alerts">
+    <div class="col-xs-12">
+        <p class="alert alert-inverse alert-grant">
+          <i class="icon-fw icon-lock" aria-hidden="true"></i> Browsing of this page is restricted
+        </p>
+    </div>
+  </div>
+
+  <ul class="nav nav-tabs hidden-print">
+    <li class="nav-main-left-tab active">
+      <a href="#revision-body" data-toggle="tab">
+        <i class="icon-notebook"></i> List
+      </a>
+    </li>
+  </ul>
+
+  <div class="tab-content">
+    {# list view #}
+    <div class="p-t-10 active tab-pane page-list-container" id="revision-body">
+      {% if pages.length == 0 %}
+        <div class="m-t-10">
+          There are no pages under <strong>{{ path }}</strong>.
+        </div>
+      {% endif  %}
+
+      {% include '../widget/page_list.html' with { pages: pages, pager: pager, viewConfig: viewConfig } %}
+    </div>
+
+  </div>
+</div>

+ 5 - 4
lib/views/widget/not_found_content.html

@@ -8,8 +8,8 @@
 </div>
 
 <div id="content-main" class="content-main content-main-not-found page-list"
-  data-path="{{ path }}"
-  data-path-shortname="{{ path|path2name }}"
+  data-path="{{ path | preventXss }}"
+  data-path-shortname="{{ path|path2name | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   >
 
@@ -39,9 +39,10 @@
     </div>
 
     {# edit view #}
-    <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
-      {% include '../_form.html' %}
+    <div class="tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit">
+      <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
     </div>
+    {% include '../_form.html' %}
 
   </div>
 </div>

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

@@ -6,7 +6,7 @@
   </li>
 
   <li class="nav-main-left-tab">
-    <a {% if user %}href="#edit-form" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
       <i class="icon-note"></i> {{ t('Create') }}
     </a>
   </li>

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

@@ -7,7 +7,7 @@
       {% elseif page.grant == 4 %}
         <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
       {% elseif page.grant == 5 %}
-        <i class="icon-fw icon-organization"></i><strong>'{{ pageRelatedGroup.name }}' only</strong> ({{ t('Browsing of this page is restricted') }})
+        <i class="icon-fw icon-organization"></i><strong>'{{ pageRelatedGroup.name | preventXss }}' only</strong> ({{ t('Browsing of this page is restricted') }})
       {% endif %}
       </p>
     {% endif %}

+ 13 - 4
lib/views/widget/page_content.html

@@ -5,7 +5,10 @@
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
+  data-page-revision-id-hackmd-synced="{% if revisionHackmdSynced %}{{ revisionHackmdSynced.toString() }}{% endif %}"
+  data-page-id-on-hackmd="{% if pageIdOnHackmd %}{{ pageIdOnHackmd.toString() }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  data-slack-channels="{{ slack|default('') }}"
   >
 
   {% include 'page_alerts.html' %}
@@ -15,7 +18,7 @@
   <div class="tab-content">
 
     {% if page %}
-      <script type="text/template" id="raw-text-original">{{ revision.body.toString() | sanitize }}</script>
+      <script type="text/template" id="raw-text-original">{{ revision.body.toString() | encodeHTML }}</script>
 
       {# formatted text #}
       <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
@@ -27,11 +30,17 @@
       </div>
     {% endif %}
 
-    {# edit form #}
     {% if not page.isDeleted() %}
-    <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
+      {# edit form #}
+      <div class="tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit">
+        <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
+      </div>
+      {# disabled temporary -- 2018.07.06 Yuki Takei
+      <div class="tab-pane" id="hackmd">
+        <div id="page-editor-with-hackmd"></div>
+      </div>
+      #}
       {% include '../_form.html' %}
-    </div>
     {% endif %}
 
     {# raw revision history #}

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

@@ -33,7 +33,7 @@
             <div class="revision-body wiki"></div>
           </div>
         </div>
-        <script type="text/template">{{ page.revision.body.toString() | sanitize }}</script>
+        <script type="text/template">{{ page.revision.body.toString() | encodeHTML }}</script>
       </div>
       <hr>
       {% endfor %}

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

@@ -11,11 +11,18 @@
   </li>
 
   {% if !isTrashPage() %}
-  <li class="nav-main-left-tab {% if req.body.pageForm %}active{% endif %}">
-    <a {% if user %}href="#edit-form" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+  <li class="nav-main-left-tab nav-tab-edit {% if req.body.pageForm %}active{% endif %}">
+    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
       <i class="icon-note"></i> {{ t('Edit') }}
     </a>
   </li>
+  {# disabled temporary -- 2018.07.06 Yuki Takei
+  <li class="nav-main-left-tab nav-tab-hackmd">
+    <a {% if user %}href="#hackmd" data-toggle="tab"{% endif %} class="{% if not user %}edit-button-disabled{% endif %}">
+      <i class="fa fa-file-text-o"></i> {{ t('HackMD') }}
+    </a>
+  </li>
+  #}
   {% endif %}
 
   {#

+ 5 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.11-RC",
+  "version": "3.1.13-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -117,6 +117,7 @@
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
     "babel-plugin-lodash": "^3.3.2",
+    "babel-plugin-transform-runtime": "^6.23.0",
     "babel-polyfill": "^6.26.0",
     "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
@@ -168,13 +169,14 @@
     "optimize-css-assets-webpack-plugin": "^4.0.2",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^2.1.3",
-    "react": "^16.2.0",
+    "react": "^16.4.1",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "=3.0.4",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.4",
-    "react-dom": "^16.2.0",
+    "react-dom": "^16.4.1",
     "react-dropzone": "^4.2.7",
+    "react-frame-component": "^4.0.0",
     "react-i18next": "^7.6.1",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.0.1",

+ 61 - 0
resource/js/agent-for-hackmd.js

@@ -0,0 +1,61 @@
+/**
+ * GROWI agent for HackMD
+ *
+ * This file will be transpiled as a single JS
+ *  and should be load from HackMD head via 'lib/routes/hackmd.js' route
+ *
+ * USAGE:
+ *  <script src="${hostname of GROWI}/_hackmd/load-agent"></script>
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+
+/* eslint-disable no-console  */
+console.log('[HackMD] Loading GROWI agent for HackMD...');
+
+const allowedOrigin = '{{origin}}';         // will be replaced by swig
+const styleFilePath = '{{styleFilePath}}';  // will be replaced by swig
+
+/**
+ * Validate origin
+ * @param {object} event
+ */
+function validateOrigin(event) {
+  if (event.origin !== allowedOrigin) {
+    console.error('[HackMD] Message is rejected.', 'Cause: "event.origin" and "allowedOrigin" does not match');
+    return;
+  }
+}
+
+/**
+ * Insert link tag to load style file
+ */
+function insertStyle() {
+  const element = document.createElement('link');
+  element.href = styleFilePath;
+  element.rel = 'stylesheet';
+  document.getElementsByTagName('head')[0].appendChild(element);
+}
+
+
+insertStyle();
+
+window.addEventListener('message', (event) => {
+  validateOrigin(event);
+
+  const data = JSON.parse(event.data);
+  switch (data.operation) {
+    case 'getValue':
+      console.log('getValue called');
+      break;
+    case 'setValue':
+      console.log('setValue called');
+      break;
+  }
+});
+
+window.addEventListener('load', (event) => {
+  console.log('loaded');
+});
+
+console.log('[HackMD] GROWI agent for HackMD has successfully loaded.');

+ 64 - 3
resource/js/app.js

@@ -4,6 +4,8 @@ import { I18nextProvider } from 'react-i18next';
 
 import i18nFactory from './i18n';
 
+import Xss from '../../lib/util/xss';
+
 import Crowi from './util/Crowi';
 // import CrowiRenderer from './util/CrowiRenderer';
 import GrowiRenderer from './util/GrowiRenderer';
@@ -14,17 +16,19 @@ import PageEditor       from './components/PageEditor';
 import OptionsSelector  from './components/PageEditor/OptionsSelector';
 import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
 import GrantSelector    from './components/PageEditor/GrantSelector';
+import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
+import SlackNotification from './components/SlackNotification';
 import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
-import NewPageNameInputter from './components/NewPageNameInputter';
+import NewPageNameInput from './components/NewPageNameInput';
 
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -39,19 +43,29 @@ if (!window) {
 const userlang = $('body').data('userlang');
 const i18n = i18nFactory(userlang);
 
+// setup xss library
+const xss = new Xss();
+window.xss = xss;
+
 const mainContent = document.querySelector('#content-main');
 let pageId = null;
 let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
+let pageRevisionIdHackmdSynced = null;
+let pageIdOnHackmd = null;
 let pagePath;
 let pageContent = '';
 let markdown = '';
 let pageGrant = null;
+let slackChannels = '';
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id');
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
   pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
+  pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
+  pageIdOnHackmd = mainContent.getAttribute('data-page-id-on-hackmd') || null;
   pagePath = mainContent.attributes['data-path'].value;
+  slackChannels = mainContent.getAttribute('data-slack-channels');
   const rawText = document.getElementById('raw-text-original');
   if (rawText) {
     pageContent = rawText.innerHTML;
@@ -111,7 +125,7 @@ const componentMappings = {
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
-  'page-name-inputter': <NewPageNameInputter crowi={crowi} parentPageName={pagePath} />,
+  'page-name-inputter': <NewPageNameInput crowi={crowi} parentPageName={pagePath} />,
 
 };
 // additional definitions if data exists
@@ -175,10 +189,31 @@ if (writeCommentElem) {
     }
   };
   ReactDOM.render(
-    <CommentForm crowi={crowi} crowiOriginRenderer={crowiRenderer} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath} onPostComplete={postCompleteHandler} editorOptions={editorOptions}/>,
+    <CommentForm crowi={crowi}
+      crowiOriginRenderer={crowiRenderer}
+      pageId={pageId}
+      revisionId={pageRevisionId}
+      pagePath={pagePath}
+      onPostComplete={postCompleteHandler}
+      editorOptions={editorOptions}
+      slackChannels = {slackChannels}/>,
     writeCommentElem);
 }
 
+// render slack notification form
+const editorSlackElem = document.getElementById('editor-slack-notification');
+if (editorSlackElem) {
+  ReactDOM.render(
+    <SlackNotification
+      crowi={crowi}
+      pageId={pageId}
+      pagePath={pagePath}
+      isSlackEnabled={false}
+      slackChannels={slackChannels}
+      formName='pageForm' />,
+    editorSlackElem);
+}
+
 // render OptionsSelector
 const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
 if (pageEditorOptionsSelectorElem) {
@@ -228,6 +263,32 @@ if (pageEditorGrantSelectorElem) {
   );
 }
 
+/*
+ * HackMD Editor
+ */
+// render PageEditorWithHackmd
+const pageEditorWithHackmdElem = document.getElementById('page-editor-with-hackmd');
+if (pageEditorWithHackmdElem) {
+  // create onSave event handler
+  const onSaveSuccess = function(page) {
+    // modify the revision id value to pass checking id when updating
+    crowi.getCrowiForJquery().updatePageForm(page);
+    // re-render Page component if exists
+    if (componentInstances.page != null) {
+      componentInstances.page.setMarkdown(page.revision.body);
+    }
+  };
+
+  pageEditor = ReactDOM.render(
+    <PageEditorByHackmd crowi={crowi}
+        pageId={pageId} revisionId={pageRevisionId}
+        revisionIdHackmdSynced={pageRevisionIdHackmdSynced} pageIdOnHackmd={pageIdOnHackmd}
+        markdown={markdown}
+        onSaveSuccess={onSaveSuccess} />,
+    pageEditorWithHackmdElem
+  );
+}
+
 // render for admin
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {

+ 6 - 1
resource/js/components/CopyButton.js

@@ -8,6 +8,9 @@ export default class CopyButton extends React.Component {
     super(props);
 
     this.showToolTip = this.showToolTip.bind(this);
+
+    // retrieve xss library from window
+    this.xss = window.xss;
   }
 
   showToolTip() {
@@ -27,12 +30,14 @@ export default class CopyButton extends React.Component {
       verticalAlign: 'text-top',
     }, this.props.buttonStyle);
 
+    const text = this.xss.process(this.props.text);
+
     return (
       <span className="btn-copy-container" style={containerStyle}>
         <ClipboardButton className={this.props.buttonClassName}
             button-id={this.props.buttonId} button-data-toggle="tooltip" button-data-container="body" button-title="copied!" button-data-placement="bottom" button-data-trigger="manual"
             button-style={style}
-            data-clipboard-text={this.props.text} onSuccess={this.showToolTip}>
+            data-clipboard-text={text} onSuccess={this.showToolTip}>
 
           <i className={this.props.iconClassName}></i>
         </ClipboardButton>

+ 3 - 3
resource/js/components/NewPageNameInputter.js → resource/js/components/NewPageNameInput.js

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 
 import SearchTypeahead from './SearchTypeahead';
 
-export default class NewPageNameInputter extends React.Component {
+export default class NewPageNameInput extends React.Component {
 
   constructor(props) {
 
@@ -59,11 +59,11 @@ export default class NewPageNameInputter extends React.Component {
   }
 }
 
-NewPageNameInputter.propTypes = {
+NewPageNameInput.propTypes = {
   crowi:          PropTypes.object.isRequired,
   parentPageName: PropTypes.string,
 };
 
-NewPageNameInputter.defaultProps = {
+NewPageNameInput.defaultProps = {
   parentPageName: '',
 };

+ 5 - 2
resource/js/components/Page/RevisionPath.js

@@ -13,6 +13,9 @@ export default class RevisionPath extends React.Component {
       isListPage: false,
       isLinkToListPage: true,
     };
+
+    // retrieve xss library from window
+    this.xss = window.xss;
   }
 
   componentWillMount() {
@@ -37,7 +40,7 @@ export default class RevisionPath extends React.Component {
     splitted.forEach((pageName) => {
       pages.push({
         pagePath: parentPath + encodeURIComponent(pageName),
-        pageName: pageName,
+        pageName: this.xss.process(pageName),
       });
       parentPath += pageName + '/';
     });
@@ -107,7 +110,7 @@ export default class RevisionPath extends React.Component {
         {afterElements}
         <CopyButton buttonId="btnCopyRevisionPath" text={this.props.pagePath}
             buttonClassName="btn btn-default btn-copy" iconClassName="ti-clipboard" />
-        <a href="#edit-form" className="btn btn-default btn-edit" style={editButtonStyle}>
+        <a href="#edit" className="btn btn-default btn-edit" style={editButtonStyle}>
           <i className="icon-note"></i>
         </a>
       </span>

+ 10 - 1
resource/js/components/Page/RevisionUrl.js

@@ -5,15 +5,24 @@ import CopyButton from '../CopyButton';
 
 export default class RevisionUrl extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    // retrieve xss library from window
+    this.xss = window.xss;
+  }
+
   render() {
     const buttonStyle = {
       fontSize: '1em'
     };
 
+    const pagePath = this.xss.process(this.props.pagePath);
+
     const url = (this.props.pageId == null)
       ? decodeURIComponent(location.href)
       : `${location.origin}/${this.props.pageId}`;
-    const copiedText = this.props.pagePath + '\n' + url;
+    const copiedText = pagePath + '\n' + url;
 
     return (
       <span>

+ 77 - 30
resource/js/components/PageComment/CommentForm.js

@@ -12,6 +12,7 @@ import GrowiRenderer from '../../util/GrowiRenderer';
 
 import Editor from '../PageEditor/Editor';
 import CommentPreview from '../PageComment/CommentPreview';
+import SlackNotification from '../SlackNotification';
 
 /**
  *
@@ -39,6 +40,9 @@ export default class CommentForm extends React.Component {
       isUploadable,
       isUploadableFile,
       errorMessage: undefined,
+      hasSlackConfig: config.hasSlackConfig,
+      isSlackEnabled: false,
+      slackChannels: this.props.slackChannels,
     };
 
     this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, {mode: 'comment'});
@@ -50,6 +54,8 @@ export default class CommentForm extends React.Component {
     this.handleSelect = this.handleSelect.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.onUpload = this.onUpload.bind(this);
+    this.onChannelChange = this.onChannelChange.bind(this);
+    this.onSlackOnChange = this.onSlackOnChange.bind(this);
   }
 
   updateState(value) {
@@ -68,6 +74,14 @@ export default class CommentForm extends React.Component {
     this.renderHtml(this.state.comment);
   }
 
+  onSlackOnChange(value) {
+    this.setState({isSlackEnabled: value});
+  }
+
+  onChannelChange(value) {
+    this.setState({slackChannels: value});
+  }
+
   /**
    * Load data of comments and rerender <PageComments />
    */
@@ -83,26 +97,31 @@ export default class CommentForm extends React.Component {
         page_id: this.props.pageId,
         revision_id: this.props.revisionId,
         is_markdown: this.state.isMarkdown,
+      },
+      slackNotificationForm: {
+        isSlackEnabled: this.state.isSlackEnabled,
+        slackChannels: this.state.slackChannels,
       }
     })
-      .then((res) => {
-        if (this.props.onPostComplete != null) {
-          this.props.onPostComplete(res.comment);
-        }
-        this.setState({
-          comment: '',
-          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 });
+    .then((res) => {
+      if (this.props.onPostComplete != null) {
+        this.props.onPostComplete(res.comment);
+      }
+      this.setState({
+        comment: '',
+        isMarkdown: true,
+        html: '',
+        key: 1,
+        errorMessage: undefined,
+        isSlackEnabled: false,
       });
+      // reset value
+      this.refs.editor.setValue('');
+    })
+    .catch(err => {
+      const errorMessage = err.message || 'An unknown error occured when posting comment';
+      this.setState({ errorMessage });
+    });
   }
 
   getCommentHtml() {
@@ -191,6 +210,10 @@ export default class CommentForm extends React.Component {
     });
   }
 
+  renderControls() {
+
+  }
+
   render() {
     const crowi = this.props.crowi;
     const username = crowi.me;
@@ -200,6 +223,13 @@ export default class CommentForm extends React.Component {
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml(): ReactUtils.nl2br(comment);
     const emojiStrategy = this.props.crowi.getEmojiStrategy();
 
+    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
+    const submitButton = (
+      <Button type="submit"bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
+        Comment
+      </Button>
+    );
+
     return (
       <div>
         <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
@@ -237,19 +267,35 @@ export default class CommentForm extends React.Component {
                     }
                   </Tabs>
                 </div>
-                <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.updateStateCheckbox} /> Markdown
-                    </label>
-                  }
-                  <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 className="comment-submit">
+                  <div className="d-flex">
+                    { this.state.key == 1 &&
+                      <label style={{flex: 1}}>
+                        <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
+                      </label>
+                    }
+                    <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                    { this.state.hasSlackConfig &&
+                      <div className="form-inline align-self-center mr-md-2">
+                        <SlackNotification
+                          crowi={this.props.crowi}
+                          pageId={this.props.pageId}
+                          pagePath={this.props.pagePath}
+                          onSlackOnChange={this.onSlackOnChange}
+                          onChannelChange={this.onChannelChange}
+                          isSlackEnabled={this.state.isSlackEnabled}
+                          slackChannels={this.state.slackChannels}
+                        />
+                      </div>
+                    }
+                    <div className="hidden-xs">{submitButton}</div>
+                  </div>
+                  <div className="visible-xs mt-2">
+                    <div className="d-flex justify-content-end">
+                      { this.state.errorMessage && errorMessage }
+                      <div>{submitButton}</div>
+                    </div>
+                  </div>
                 </div>
               </div>
             </div>
@@ -268,6 +314,7 @@ CommentForm.propTypes = {
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
 };
 CommentForm.defaultProps = {
   editorOptions: {},

+ 10 - 21
resource/js/components/PageEditor/Editor.js

@@ -185,22 +185,9 @@ export default class Editor extends AbstractEditor {
     return className;
   }
 
-  getOverlayStyle() {
-    return {
-      position: 'absolute',
-      zIndex: 4,  // forward than .CodeMirror-gutters
-      top: 0,
-      right: 0,
-      bottom: 0,
-      left: 0,
-    };
-  }
-
   renderDropzoneOverlay() {
-    const overlayStyle = this.getOverlayStyle();
-
     return (
-      <div style={overlayStyle} className="overlay">
+      <div className="overlay">
         {this.state.isUploading &&
           <span className="overlay-content">
             <div className="speeding-wheel d-inline-block"></div>
@@ -221,8 +208,8 @@ export default class Editor extends AbstractEditor {
 
     const isMobile = this.props.isMobile;
 
-    return <React.Fragment>
-      <div style={flexContainer}>
+    return (
+      <div style={flexContainer} className="editor-container">
         <Dropzone
             ref="dropzone"
             disableClick
@@ -264,14 +251,16 @@ export default class Editor extends AbstractEditor {
           onClick={() => {this.refs.dropzone.open()}}>
 
           <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-          Attach files by dragging &amp; dropping,&nbsp;
-          <span className="btn-link">selecting them</span>,&nbsp;
-          or pasting from the clipboard.
+          Attach files
+          <span className="desc-long">
+            &nbsp;by dragging &amp; dropping,&nbsp;
+            <span className="btn-link">selecting them</span>,&nbsp;
+            or pasting from the clipboard.
+          </span>
         </button>
 
       </div>
-
-    </React.Fragment>;
+    );
   }
 
 }

+ 4 - 1
resource/js/components/PageEditor/GrantSelector.js

@@ -43,6 +43,9 @@ class GrantSelector extends React.Component {
       };
     }
 
+    // retrieve xss library from window
+    this.xss = window.xss;
+
     this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
     this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
@@ -81,7 +84,7 @@ class GrantSelector extends React.Component {
 
   getGroupName() {
     const pageGrantGroup = this.state.pageGrantGroup;
-    return pageGrantGroup ? pageGrantGroup.name : '';
+    return pageGrantGroup ? this.xss.process(pageGrantGroup.name) : '';
   }
 
   /**

+ 5 - 5
resource/js/components/PageEditor/OptionsSelector.js

@@ -121,7 +121,7 @@ export default class OptionsSelector extends React.Component {
     const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
 
     return (
-      <FormGroup controlId="formControlsSelect">
+      <FormGroup controlId="formControlsSelect" className="my-0">
         <ControlLabel>Theme:</ControlLabel>
         <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
             onChange={this.onChangeTheme}
@@ -149,7 +149,7 @@ export default class OptionsSelector extends React.Component {
     const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
 
     return (
-      <FormGroup controlId="formControlsSelect">
+      <FormGroup controlId="formControlsSelect" className="my-0">
         <ControlLabel>Keymap:</ControlLabel>
         <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
             onChange={this.onChangeKeymapMode}
@@ -164,7 +164,7 @@ export default class OptionsSelector extends React.Component {
 
   renderConfigurationDropdown() {
     return (
-      <FormGroup controlId="formControlsSelect">
+      <FormGroup controlId="formControlsSelect" className="my-0">
 
         <Dropdown dropup id="configurationDropdown" className="configuration-dropdown"
             open={this.state.isCddMenuOpened} onToggle={this.onToggleConfigurationDropdown}>
@@ -227,11 +227,11 @@ export default class OptionsSelector extends React.Component {
   }
 
   render() {
-    return <span>
+    return <div className="d-flex flex-row">
       <span className="m-l-5">{this.renderThemeSelector()}</span>
       <span className="m-l-5">{this.renderKeymapModeSelector()}</span>
       <span className="m-l-5">{this.renderConfigurationDropdown()}</span>
-    </span>;
+    </div>;
   }
 }
 

+ 115 - 0
resource/js/components/PageEditorByHackmd.jsx

@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import * as toastr from 'toastr';
+
+import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
+
+export default class PageEditorByHackmd extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isInitializing: false,
+      pageIdOnHackmd: this.props.pageIdOnHackmd,
+    };
+
+    this.getHackmdUri = this.getHackmdUri.bind(this);
+    this.startIntegrationWithHackmd = this.startIntegrationWithHackmd.bind(this);
+
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+  }
+
+  componentWillMount() {
+  }
+
+  getHackmdUri() {
+    const envVars = this.props.crowi.config.env;
+    return envVars.HACKMD_URI;
+  }
+
+  syncToLatestRevision() {
+
+  }
+
+  /**
+   * Start integration with HackMD
+   */
+  startIntegrationWithHackmd() {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null) {
+      // do nothing
+      return;
+    }
+
+    this.setState({isInitializing: true});
+
+    const params = {
+      pageId: this.props.pageId,
+    };
+    this.props.crowi.apiPost('/hackmd/integrate', params)
+      .then(res => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+
+        this.setState({pageIdOnHackmd: res.pageIdOnHackmd});
+      })
+      .catch(this.apiErrorHandler)
+      .then(() => {
+        this.setState({isInitializing: false});
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  render() {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null || this.state.pageIdOnHackmd == null) {
+      return (
+        <div className="hackmd-nopage d-flex justify-content-center align-items-center">
+          <div>
+            <p className="text-center">
+              <button className="btn btn-success btn-lg waves-effect waves-light" type="button"
+                  onClick={() => this.startIntegrationWithHackmd()} disabled={this.state.isInitializing}>
+                <span className="btn-label"><i className="fa fa-file-text-o"></i></span>
+                Start to edit with HackMD
+              </button>
+            </p>
+            <p className="text-center">Clone this page and start to edit with multiple peoples.</p>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <HackmdEditor
+        markdown={this.props.markdown}
+        hackmdUri={hackmdUri}
+        pageIdOnHackmd={this.state.pageIdOnHackmd}
+      >
+      </HackmdEditor>
+    );
+  }
+}
+
+PageEditorByHackmd.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
+  revisionId: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+  pageIdOnHackmd: PropTypes.string,
+};

+ 43 - 0
resource/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class HackmdEditor extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+    };
+
+    this.loadHandler = this.loadHandler.bind(this);
+  }
+
+  componentWillMount() {
+  }
+
+  syncToLatestRevision() {
+
+  }
+
+  loadHandler() {
+
+  }
+
+  render() {
+    const src = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}`;
+    return (
+      <iframe id='iframe-hackmd'
+        ref='iframe'
+        src={src}
+        onLoad={this.loadHandler}
+      >
+      </iframe>
+    );
+  }
+}
+
+HackmdEditor.propTypes = {
+  markdown: PropTypes.string.isRequired,
+  hackmdUri: PropTypes.string.isRequired,
+  pageIdOnHackmd: PropTypes.string.isRequired,
+};

+ 78 - 0
resource/js/components/SlackNotification.js

@@ -0,0 +1,78 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class SlackNotification
+ * @extends {React.Component}
+ */
+
+export default class SlackNotification extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isSlackEnabled: this.props.isSlackEnabled,
+      slackChannels: this.props.slackChannels,
+    };
+
+    this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.setState({
+      isSlackEnabled: nextProps.isSlackEnabled,
+      slackChannels: nextProps.slackChannels
+    });
+  }
+
+  updateState(value) {
+    this.setState({slackChannels: value});
+    this.props.onChannelChange(value);
+  }
+
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({isSlackEnabled: value});
+    this.props.onSlackOnChange(value);
+  }
+
+  render() {
+    const formNameSlackOn = this.props.formName && this.props.formName + '[notify][slack][on]';
+    const formNameChannels = this.props.formName && this.props.formName + '[notify][slack][channel]';
+
+    return (
+      <div className="input-group input-group-sm input-group-slack extended-setting">
+        <label className="input-group-addon">
+          <img id="slack-mark-white" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18"/>
+          <img id="slack-mark-black" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18"/>
+          <input type="checkbox" name={formNameSlackOn} value="1" checked={this.state.isSlackEnabled} onChange={this.updateStateCheckbox}/>
+        </label>
+        <input className="form-control" type="text" name={formNameChannels} value={this.state.slackChannels} placeholder="slack channel name"
+          data-toggle="popover"
+          title="Slack通知"
+          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
+          data-trigger="focus"
+          data-placement="top"
+          onChange={e => this.updateState(e.target.value)}
+          />
+      </div>
+    );
+  }
+}
+
+SlackNotification.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  pagePath: PropTypes.string,
+  onChannelChange: PropTypes.func,
+  onSlackOnChange: PropTypes.func,
+  isSlackEnabled: PropTypes.bool,
+  slackChannels: PropTypes.string,
+  formName: PropTypes.string,
+};

+ 2 - 0
resource/js/legacy/crowi-admin.js

@@ -106,3 +106,5 @@ $(function() {
   // style switcher
   $('#styleOptions').styleSwitcher();
 });
+
+

+ 12 - 27
resource/js/legacy/crowi-form.js

@@ -3,45 +3,30 @@ var pagePath= $('#content-main').data('path');
 
 require('bootstrap-select');
 
-// show/hide
-function FetchPagesUpdatePostAndInsert(path) {
-  $.get('/_api/pages.updatePost', {path: path}, function(res) {
-    if (res.ok) {
-      var $slackChannels = $('#page-form-slack-channel');
-      $slackChannels.val(res.updatePost.join(','));
-    }
-  });
-}
-
-var slackConfigured = $('#page-form-setting').data('slack-configured');
-
 // for new page
 if (!pageId) {
   if (!pageId && pagePath.match(/(20\d{4}|20\d{6}|20\d{2}_\d{1,2}|20\d{2}_\d{1,2}_\d{1,2})/)) {
     $('#page-warning-modal').modal('show');
   }
-
-  if (slackConfigured) {
-    FetchPagesUpdatePostAndInsert(pagePath);
-  }
 }
 
-$('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
+$('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
   $('body').addClass('on-edit');
+  $('body').addClass('builtin-editor');
+});
 
-  if (slackConfigured) {
-    var $slackChannels = $('#page-form-slack-channel');
-    var slackChannels = $slackChannels.val();
-    // if slackChannels is empty, then fetch default (admin setting)
-    // if not empty, it means someone specified this setting for the page.
-    if (slackChannels === '') {
-      FetchPagesUpdatePostAndInsert(pagePath);
-    }
-  }
+$('a[data-toggle="tab"][href="#edit"]').on('hide.bs.tab', function() {
+  $('body').removeClass('on-edit');
+  $('body').removeClass('builtin-editor');
+});
+$('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
+  $('body').addClass('on-edit');
+  $('body').addClass('hackmd');
 });
 
-$('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
+$('a[data-toggle="tab"][href="#hackmd"]').on('hide.bs.tab', function() {
   $('body').removeClass('on-edit');
+  $('body').removeClass('hackmd');
 });
 
 /**

+ 33 - 20
resource/js/legacy/crowi.js

@@ -50,7 +50,7 @@ Crowi.appendEditSectionButtons = function(parentElement) {
     // add button
     $(this).append(`
       <span class="revision-head-edit-button">
-        <a href="#edit-form" onClick="Crowi.setCaretLineData(${line})">
+        <a href="#edit" onClick="Crowi.setCaretLineData(${line})">
           <i class="icon-note"></i>
         </a>
       </span>
@@ -147,7 +147,7 @@ Crowi.handleKeyEHandler = (event) => {
     return;
   }
   // show editor
-  $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+  $('a[data-toggle="tab"][href="#edit"]').tab('show');
   event.preventDefault();
 };
 
@@ -282,7 +282,7 @@ $(function() {
     if (input2 === '') {
       prefix2 = prefix2.slice(0, -1);
     }
-    top.location.href = prefix1 + input1 + prefix2 + input2 + '#edit-form';
+    top.location.href = prefix1 + input1 + prefix2 + input2 + '#edit';
     return false;
   });
 
@@ -294,7 +294,7 @@ $(function() {
     if (name.match(/.+\/$/)) {
       name = name.substr(0, name.length - 1);
     }
-    top.location.href = pagePathUtil.encodePagePath(name) + '#edit-form';
+    top.location.href = pagePathUtil.encodePagePath(name) + '#edit';
     return false;
   });
 
@@ -504,7 +504,7 @@ $(function() {
       var template = $('#' + templateId).html();
 
       crowi.saveDraft(path, template);
-      top.location.href = `${path}#edit-form`;
+      top.location.href = `${path}#edit`;
     });
 
     /*
@@ -824,9 +824,13 @@ $(function() {
       window.location.hash = '#revision-history';
       window.history.replaceState('', 'History', '#revision-history');
     });
-    $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
-      window.location.hash = '#edit-form';
-      window.history.replaceState('', 'Edit', '#edit-form');
+    $('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
+      window.location.hash = '#edit';
+      window.history.replaceState('', 'Edit', '#edit');
+    });
+    $('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
+      window.location.hash = '#hackmd';
+      window.history.replaceState('', 'HackMD', '#hackmd');
     });
     $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', function() {
       // couln't solve https://github.com/weseek/crowi-plus/issues/119 completely -- 2017.07.03 Yuki Takei
@@ -838,20 +842,23 @@ $(function() {
     $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
       window.history.replaceState('', 'History', '#revision-history');
     });
-    $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
-      window.history.replaceState('', 'Edit', '#edit-form');
+    $('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
+      window.history.replaceState('', 'Edit', '#edit');
+    });
+    $('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
+      window.history.replaceState('', 'HackMD', '#hackmd');
     });
     $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', function() {
       window.history.replaceState('', '',  location.href.replace(location.hash, ''));
     });
-    // replace all href="#edit-form" link behaviors
-    $(document).on('click', 'a[href="#edit-form"]', function() {
-      window.location.replace('#edit-form');
+    // replace all href="#edit" link behaviors
+    $(document).on('click', 'a[href="#edit"]', function() {
+      window.location.replace('#edit');
     });
   }
 
   // focus to editor when 'shown.bs.tab' event fired
-  $('a[href="#edit-form"]').on('shown.bs.tab', function(e) {
+  $('a[href="#edit"]').on('shown.bs.tab', function(e) {
     Crowi.setCaretLineAndFocusToEditor();
   });
 });
@@ -911,12 +918,15 @@ Crowi.highlightSelectedSection = function(hash) {
 window.addEventListener('load', function(e) {
   // hash on page
   if (location.hash) {
-    if (location.hash == '#edit-form') {
-      $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+    if (location.hash === '#edit' || location.hash === '#edit-form') {
+      $('a[data-toggle="tab"][href="#edit"]').tab('show');
       // focus
       Crowi.setCaretLineAndFocusToEditor();
     }
-    if (location.hash == '#revision-history') {
+    else if (location.hash == '#hackmd') {
+      $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
+    }
+    else if (location.hash == '#revision-history') {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
@@ -970,10 +980,13 @@ window.addEventListener('hashchange', function(e) {
 
   // hash on page
   if (location.hash) {
-    if (location.hash == '#edit-form') {
-      $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+    if (location.hash === '#edit') {
+      $('a[data-toggle="tab"][href="#edit"]').tab('show');
+    }
+    else if (location.hash == '#hackmd') {
+      $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
     }
-    if (location.hash == '#revision-history') {
+    else if (location.hash == '#revision-history') {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }

+ 13 - 3
resource/js/util/PreProcessor/XssFilter.js

@@ -1,14 +1,24 @@
 import Xss from '../../../../lib/util/xss';
+import xssOption from '../../../../lib/util/xssOption';
 
 export default class XssFilter {
 
   constructor(crowi) {
-    // TODO read options
-    this.xss = new Xss(true);
+    this.crowi = crowi;
+
+    if (crowi.config.isEnabledXssPrevention) {
+      this.xssOption = new xssOption(crowi.config);
+      this.xss = new Xss(this.xssOption);
+    }
   }
 
   process(markdown) {
-    return this.xss.process(markdown);
+    if (this.crowi.config.isEnabledXssPrevention) {
+      return this.xss.process(markdown);
+    }
+    else {
+      return markdown;
+    }
   }
 
 }

+ 10 - 1
resource/styles/agile-admin/inverse/colors/_apply-colors-dark.scss

@@ -154,7 +154,7 @@ legend {
 /*
  * GROWI admin page #themeOptions
  */
- .admin-page {
+.admin-page {
   #themeOptions {
     a.active {
       background-color: darken($themecolor,15%);
@@ -162,3 +162,12 @@ legend {
     }
   }
 }
+
+/*
+ * GROWI comment form
+ */
+.comment-form {
+  #slack-mark-black {
+    display: none;
+  }
+}

+ 10 - 1
resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss

@@ -70,7 +70,7 @@
 /*
  * GROWI admin page #themeOptions
  */
- .admin-page {
+.admin-page {
   #themeOptions {
     a.active {
       background-color: lighten($themecolor,20%);
@@ -78,3 +78,12 @@
     }
   }
 }
+
+/*
+ * GROWI comment form
+ */
+.comment-form {
+  #slack-mark-white {
+    display: none;
+  }
+}

+ 15 - 0
resource/styles/hackmd/style.scss

@@ -0,0 +1,15 @@
+.navbar-header {
+  .navbar-brand {
+    display: none;
+  }
+}
+
+.navbar-form {
+  margin-left: 15px;
+}
+
+.navbar-right {
+  .ui-new, .ui-publish {
+    display: none;
+  }
+}

+ 3 - 2
resource/styles/scss/_admin.scss

@@ -1,4 +1,5 @@
 .admin-page {
+    //security XSS prevent
 
   .admin-user-menu {
     .dropdown-menu {
@@ -6,7 +7,7 @@
       right: 0;
       width: 300px;
     }
-  }
+   }
 
   .admin-group-menu {
     .dropdown-menu {
@@ -37,7 +38,7 @@
     }
 
     .auth-mechanism-configurations {
-      min-height: 800px;
+      min-height: 300px;
     }
   }
 

+ 16 - 0
resource/styles/scss/_comment_growi.scss

@@ -5,6 +5,12 @@
     padding: 1em;
     margin-left: 4.5em;
     margin-bottom: 1em;
+    // screen-xs
+    @media (max-width: $screen-xs) {
+      margin-left: 3.5em;
+    }
+
+    // speech balloon
     &:before {
       border: 1em solid transparent;
       border-left-width: 0;
@@ -14,6 +20,11 @@
       top: 1.5em;
       position: absolute;
       width: 0;
+
+      // screen-xs
+      @media (max-width: $screen-xs) {
+        top: 1em;
+      }
     }
   }
 
@@ -22,6 +33,11 @@
     margin-top: 0.8em;
     width: 3em;
     height: 3em;
+    // screen-xs
+    @media (max-width: $screen-xs) {
+      width: 2em;
+      height: 2em;
+    }
   }
 
   .page-comments-row {

+ 3 - 0
resource/styles/scss/_create-page.scss

@@ -20,6 +20,9 @@
         }
         .create-page-button-container {
           margin-left: 15px;
+          .btn {
+            min-width: 105px;
+          }
         }
 
         // change layout by screen size

+ 154 - 0
resource/styles/scss/_editor-attachment.scss

@@ -0,0 +1,154 @@
+.editor-container {
+  .overlay {
+    // layout
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    position: absolute;
+    z-index: 7;  // forward than .CodeMirror-vscrollbar
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+  }
+
+  .overlay-content {
+    padding: 0.5em;
+  }
+
+  .page-editor-editor-container {
+    .overlay-content {
+      font-size: 2.5em;
+    }
+  }
+
+  @mixin overlay-processing-style() {
+    .overlay {
+      background: rgba(255,255,255,0.5);
+    }
+    .overlay-content {
+      padding: 0.3em;
+      background: rgba(200,200,200,0.5);
+      color: #444;
+    }
+  }
+  // add icon on cursor
+  .autoformat-markdown-table-activated .CodeMirror-cursor {
+    &:after {
+      font-family: 'FontAwesome';
+      content: '\f0ce';
+    }
+  }
+
+  // for Dropzone
+  .dropzone {
+    @mixin insertSimpleLineIcons($code) {
+      &:before {
+        margin-right: 0.2em;
+        font-family: 'simple-line-icons';
+        content: $code;
+      }
+    }
+
+    position: relative;   // against .overlay position: absolute
+
+    // unuploadable or rejected
+    &.dropzone-unuploadable, &.dropzone-rejected {
+      .overlay {
+        background: rgba(200,200,200,0.8);
+      }
+      .overlay-content {
+        color: #444;
+      }
+    }
+    // uploading
+    &.dropzone-uploading {
+      @include overlay-processing-style();
+    }
+
+    // unuploadable
+    &.dropzone-unuploadable {
+      .overlay-content {
+        // insert content
+        @include insertSimpleLineIcons("\e617");  // icon-exclamation
+        &:after {
+          content: "File uploading is disabled";
+        }
+      }
+    }
+    // uploadable
+    &.dropzone-uploadable {
+      // accepted
+      &.dropzone-accepted:not(.dropzone-rejected) {
+        .overlay {
+          border: 4px dashed #ccc;
+        }
+        .overlay-content {
+          // insert content
+          @include insertSimpleLineIcons("\e084");  // icon-cloud-upload
+          &:after {
+            content: "Drop here to upload";
+          }
+          // style
+          color: #666;
+          background: rgba(200,200,200,0.8);
+        }
+      }
+      // file type mismatch
+      &.dropzone-rejected:not(.dropzone-uploadablefile) .overlay-content {
+        // insert content
+        @include insertSimpleLineIcons("\e032");  // icon-picture
+        &:after {
+          content: "Only an image file is allowed";
+        }
+      }
+      // multiple files
+      &.dropzone-accepted.dropzone-rejected .overlay-content {
+        // insert content
+        @include insertSimpleLineIcons("\e617");  // icon-exclamation
+        &:after {
+          content: "Only 1 file is allowed";
+        }
+      }
+    }
+  } // end of.dropzone
+
+  .textarea-editor {
+    border: none;
+    font-family: monospace;
+  }
+
+  .loading-keymap {
+    @include overlay-processing-style();
+  }
+
+  .btn-open-dropzone {
+    z-index: 2;
+    font-size: small;
+    padding-top: 3px;
+    padding-bottom: 3px;
+    border: none;
+    border-radius: 0;
+    border-top: 1px dotted #ccc;
+    &:active {
+      box-shadow: none;
+    }
+  }
+
+}
+
+#page-editor {
+  @media (max-width: $screen-xs) {
+    .desc-long {
+      display: none;
+    }
+  }
+}
+.comment-form {
+  @media (max-width: $screen-sm) {
+    .desc-long {
+      display: none;
+    }
+  }
+}

+ 10 - 0
resource/styles/scss/_notification.scss

@@ -0,0 +1,10 @@
+// Slack
+.input-group-slack {
+  .input-group-addon {
+    padding: 2px 8px;
+    line-height: 1em;
+    img, input {
+      vertical-align: middle;
+    }
+  }
+}

+ 208 - 208
resource/styles/scss/_on-edit.scss

@@ -1,5 +1,25 @@
+body:not(.on-edit) {
+  // hide #page-form
+  #page-form {
+    display: none;
+  }
+}
+
 body.on-edit {
 
+  %expand-by-flex {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+  }
+
+  // calculate margin
+  $header-plus-footer: 2px                      // .main padding-top
+                      + 42px                    // .nav height
+                      + 1px                     // .page-editor-footer border-top
+                      + 40px;                   // .page-editor-footer min-height
+  $editor-margin: $header-plus-footer + 22px;   // .btn-open-dropzone height
+
   // hide unnecessary elements
   .navbar.navbar-static-top,
   .row.row-alerts,
@@ -29,15 +49,17 @@ body.on-edit {
     display: none;
   }
 
+  // show only either Edit button or HackMD button
+  &.hackmd .nav-tab-edit {
+    display: none;
+  }
+  &:not(.hackmd) .nav-tab-hackmd {
+    display: none;
+  }
 
   /*****************
    * Expand Editor
    *****************/
-  .expand-by-flex {
-    display: flex;
-    flex-direction: column;
-    flex: 1;
-  }
   .container-fluid {
     padding-bottom: 0;
   }
@@ -60,71 +82,8 @@ body.on-edit {
 
     &,
     .content-main,
-    .tab-content,
-    .edit-form,
-    .page-form {
-      @extend .expand-by-flex;
-    }
-
-    .page-form {
-
-      // calculate margin
-      $header-plus-footer: 2px                      // .main padding-top
-                         + 42px                     // .nav height
-                         + 1px                      // .page-editor-footer border-top
-                         + 40px;                    // .page-editor-footer min-height
-      $editor-margin: $header-plus-footer + 22px;   // .btn-open-dropzone height
-      $editor-margin-sm: $header-plus-footer;
-
-      #page-editor {
-        // right(preview)
-        &,
-        .row,
-        .page-editor-preview-container,
-        .page-editor-preview-body {
-          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-          height: calc(100vh - #{$header-plus-footer});
-        }
-        // left(editor)
-        .page-editor-editor-container {
-          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-          height: calc(100vh - #{$header-plus-footer});
-
-          .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
-          .textarea-editor {
-            height: calc(100vh - #{$editor-margin});
-            // less than smartphone
-            @media (max-width: $screen-xs) {
-              height: calc(100vh - #{$editor-margin-sm});
-            }
-          }
-        }
-      }
-
-
-      .page-editor-footer {
-        width: 100%;
-        margin: 0;
-        padding: 3px;
-        min-height: 40px;
-        border-top: solid 1px transparent;
-
-        .btn-submit {
-          width: 100px;
-        }
-      }
-
-      // slack
-      .input-group-slack {
-        .input-group-addon {
-          padding: 2px 8px;
-          line-height: 1em;
-          img, input {
-            vertical-align: middle;
-          }
-        }
-      }
-
+    .tab-content {
+      @extend %expand-by-flex;
     }
   }
 
@@ -167,198 +126,239 @@ body.on-edit {
     }
   }
 
-  /*****************
-   * Editor styles
-   *****************/
-  .page-editor-editor-container {
-    border-right: 1px solid transparent;
-    padding-right: 0;
-    // override CodeMirror styles
-    .CodeMirror {
-      .cm-matchhighlight {
-        background-color: cyan;
-      }
-      .CodeMirror-selection-highlight-scrollbar {
-        background-color: darkcyan;
-      }
-    }
+  .page-editor-footer {
+    width: 100%;
+    margin: 0;
+    padding: 3px;
+    min-height: 40px;
+    border-top: solid 1px transparent;
 
-    .overlay {
-      // layout
-      display: flex;
-      justify-content: center;
-      align-items: center;
-      // style
-      margin: 0 15px;
-    }
-    .overlay-content {
-      font-size: 2.5em;
-      padding: 0.5em;
+    .btn-submit {
+      width: 100px;
     }
+  }
 
-    @mixin overlay-processing-style() {
-      .overlay {
-        background: rgba(255,255,255,0.5);
-      }
-      .overlay-content {
-        padding: 0.3em;
-        background: rgba(200,200,200,0.5);
-        color: #444;
+
+  &.builtin-editor .tab-pane#edit {
+    @extend %expand-by-flex;
+
+    #page-editor {
+      // right(preview)
+      &,
+      .row,
+      .page-editor-preview-container,
+      .page-editor-preview-body {
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
       }
-    }
-    // add icon on cursor
-    .autoformat-markdown-table-activated .CodeMirror-cursor {
-      &:after {
-        font-family: 'FontAwesome';
-        content: '\f0ce';
+      // left(editor)
+      .page-editor-editor-container {
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
+
+        .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
+        .textarea-editor {
+          height: calc(100vh - #{$editor-margin});
+        }
       }
     }
 
-    // for Dropzone
-    .dropzone {
-      @mixin insertSimpleLineIcons($code) {
-        &:before {
-          margin-right: 0.2em;
-          font-family: 'simple-line-icons';
-          content: $code;
+    /*****************
+    * Editor styles
+    *****************/
+    .page-editor-editor-container {
+      border-right: 1px solid transparent;
+      padding-right: 0;
+      // override CodeMirror styles
+      .CodeMirror {
+        .cm-matchhighlight {
+          background-color: cyan;
+        }
+        .CodeMirror-selection-highlight-scrollbar {
+          background-color: darkcyan;
         }
       }
 
-      // unuploadable or rejected
-      &.dropzone-unuploadable, &.dropzone-rejected {
+      .overlay {
+        // layout
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        // style
+        margin: 0 15px;
+      }
+      .overlay-content {
+        font-size: 2.5em;
+        padding: 0.5em;
+      }
+
+      @mixin overlay-processing-style() {
         .overlay {
-          background: rgba(200,200,200,0.8);
+          background: rgba(255,255,255,0.5);
         }
         .overlay-content {
+          padding: 0.3em;
+          background: rgba(200,200,200,0.5);
           color: #444;
         }
       }
-      // uploading
-      &.dropzone-uploading {
-        @include overlay-processing-style();
+      // add icon on cursor
+      .autoformat-markdown-table-activated .CodeMirror-cursor {
+        &:after {
+          font-family: 'FontAwesome';
+          content: '\f0ce';
+        }
       }
 
-      // unuploadable
-      &.dropzone-unuploadable {
-        .overlay-content {
-          // insert content
-          @include insertSimpleLineIcons("\e617");  // icon-exclamation
-          &:after {
-            content: "File uploading is disabled";
+      // for Dropzone
+      .dropzone {
+        @mixin insertSimpleLineIcons($code) {
+          &:before {
+            margin-right: 0.2em;
+            font-family: 'simple-line-icons';
+            content: $code;
           }
         }
-      }
-      // uploadable
-      &.dropzone-uploadable {
-        // accepted
-        &.dropzone-accepted:not(.dropzone-rejected) {
+
+        // unuploadable or rejected
+        &.dropzone-unuploadable, &.dropzone-rejected {
           .overlay {
-            border: 4px dashed #ccc;
+            background: rgba(200,200,200,0.8);
           }
+          .overlay-content {
+            color: #444;
+          }
+        }
+        // uploading
+        &.dropzone-uploading {
+          @include overlay-processing-style();
+        }
+
+        // unuploadable
+        &.dropzone-unuploadable {
           .overlay-content {
             // insert content
-            @include insertSimpleLineIcons("\e084");  // icon-cloud-upload
+            @include insertSimpleLineIcons("\e617");  // icon-exclamation
             &:after {
-              content: "Drop here to upload";
+              content: "File uploading is disabled";
             }
-            // style
-            color: #666;
-            background: rgba(200,200,200,0.8);
           }
         }
-        // file type mismatch
-        &.dropzone-rejected:not(.dropzone-uploadablefile) .overlay-content {
-          // insert content
-          @include insertSimpleLineIcons("\e032");  // icon-picture
-          &:after {
-            content: "Only an image file is allowed";
+        // uploadable
+        &.dropzone-uploadable {
+          // accepted
+          &.dropzone-accepted:not(.dropzone-rejected) {
+            .overlay {
+              border: 4px dashed #ccc;
+            }
+            .overlay-content {
+              // insert content
+              @include insertSimpleLineIcons("\e084");  // icon-cloud-upload
+              &:after {
+                content: "Drop here to upload";
+              }
+              // style
+              color: #666;
+              background: rgba(200,200,200,0.8);
+            }
           }
-        }
-        // multiple files
-        &.dropzone-accepted.dropzone-rejected .overlay-content {
-          // insert content
-          @include insertSimpleLineIcons("\e617");  // icon-exclamation
-          &:after {
-            content: "Only 1 file is allowed";
+          // file type mismatch
+          &.dropzone-rejected:not(.dropzone-uploadablefile) .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e032");  // icon-picture
+            &:after {
+              content: "Only an image file is allowed";
+            }
+          }
+          // multiple files
+          &.dropzone-accepted.dropzone-rejected .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e617");  // icon-exclamation
+            &:after {
+              content: "Only 1 file is allowed";
+            }
           }
         }
+      } // end of.dropzone
+
+      .textarea-editor {
+        border: none;
+        font-family: monospace;
+      }
+
+      .loading-keymap {
+        @include overlay-processing-style();
       }
-    } // end of.dropzone
 
-    .textarea-editor {
-      border: none;
-      font-family: monospace;
+    }
+    .page-editor-preview-container {
     }
 
-    .loading-keymap {
-      @include overlay-processing-style();
+    .page-editor-preview-body {
+      padding-top: 18px;
+      padding-right: 15px;
+      overflow-y: scroll;
     }
 
-    .btn-open-dropzone {
-      z-index: 2;
-      font-size: small;
-      text-align: right;
-      padding-top: 3px;
-      padding-bottom: 0;
-      border: none;
-      border-radius: 0;
-      border-top: 1px dotted #ccc;
-
-      &:active {
-        box-shadow: none;
+    #page-editor-options-selector {
+      label {
+        margin-right: 0.5em;
+      }
+
+      // configuration dropdown
+      .configuration-dropdown {
+        .icon-container {
+          display: inline-block;
+          width: 20px;
+        }
+        .dropdown-menu > li > a {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+
+          .menuitem-label {
+            flex: 1;
+            margin-right: 10px;
+          }
+        }
       }
 
-      // hide if screen size is less than smartphone
-      @media (max-width: $screen-xs) {
+      @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
         display: none;
       }
     }
-  }
-  .page-editor-preview-container {
-  }
-
-  .page-editor-preview-body {
-    padding-top: 18px;
-    padding-right: 15px;
-    overflow-y: scroll;
-  }
 
-  #page-editor-options-selector {
-    label {
-      margin-right: 0.5em;
+    #page-grant-selector {
+      .btn-group {
+        min-width: 150px;
+      }
     }
 
-    // configuration dropdown
-    .configuration-dropdown {
-      .icon-container {
-        display: inline-block;
-        width: 20px;
-      }
-      .dropdown-menu > li > a {
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
+  } // .builtin-editor .tab-pane#edit
 
-        .menuitem-label {
-          flex: 1;
-          margin-right: 10px;
-        }
-      }
-    }
 
-    @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+  &.hackmd {
+    #page-editor-options-selector {
       display: none;
     }
-  }
 
-  #page-grant-selector {
-    .btn-group {
-      min-width: 150px;
+    .tab-pane#hackmd {
+      @extend %expand-by-flex;
+
+      #hackmd-editor,
+      .hackmd-nopage, #iframe-hackmd {
+        width: 100vw;
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
+
+        border: none;
+      }
     }
+
   }
 
+}
 
-} // }}}
 
 /*
  * for creating portal

+ 2 - 0
resource/styles/scss/style.scss

@@ -21,11 +21,13 @@
 @import 'comment_growi';
 @import 'create-page';
 @import 'create-template';
+@import 'editor-attachment';
 @import 'layout';
 @import 'layout_crowi';
 @import 'layout_crowi_sidebar';
 @import 'layout_growi';
 @import 'login';
+@import 'notification';
 @import 'on-edit';
 @import 'page_list';
 @import 'page';

+ 6 - 2
test/util/slack.test.js

@@ -10,7 +10,11 @@ describe('Slack Util', function () {
   var crowi = new (require(ROOT_DIR + '/lib/crowi'))(ROOT_DIR, process.env);
   var slack = require(crowi.libDir + '/util/slack')(crowi);
 
-  it('post method exists', function() {
-    expect(slack).to.respondTo('post');
+  it('post comment method exists', function() {
+    expect(slack).to.respondTo('postComment');
+  });
+
+  it('post page method exists', function() {
+    expect(slack).to.respondTo('postPage');
   });
 });

+ 16 - 6
yarn.lock

@@ -1074,6 +1074,12 @@ babel-plugin-transform-regenerator@^6.22.0:
   dependencies:
     regenerator-transform "^0.10.0"
 
+babel-plugin-transform-runtime@^6.23.0:
+  version "6.23.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-runtime/-/babel-plugin-transform-runtime-6.23.0.tgz#88490d446502ea9b8e7efb0fe09ec4d99479b1ee"
+  dependencies:
+    babel-runtime "^6.22.0"
+
 babel-plugin-transform-strict-mode@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758"
@@ -6807,9 +6813,9 @@ react-codemirror2@^5.0.4:
   version "5.0.4"
   resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-5.0.4.tgz#d44a2d7a63a96509ba65db9b771bd61a781b8a0d"
 
-react-dom@^16.2.0:
-  version "16.2.0"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
+react-dom@^16.4.1:
+  version "16.4.1"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.1.tgz#7f8b0223b3a5fbe205116c56deb85de32685dad6"
   dependencies:
     fbjs "^0.8.16"
     loose-envify "^1.1.0"
@@ -6823,6 +6829,10 @@ react-dropzone@^4.2.7:
     attr-accept "^1.0.3"
     prop-types "^15.5.7"
 
+react-frame-component@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-4.0.0.tgz#57d51cdb2da3b204cc34577349f9f5bb84a76aac"
+
 react-i18next@^7.6.1:
   version "7.6.1"
   resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-7.6.1.tgz#c61d8284f3c695893d51033f67c39e65f01212b6"
@@ -6870,9 +6880,9 @@ react-transition-group@^2.0.0, react-transition-group@^2.2.0:
     prop-types "^15.5.8"
     warning "^3.0.0"
 
-react@^16.2.0:
-  version "16.2.0"
-  resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
+react@^16.4.1:
+  version "16.4.1"
+  resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32"
   dependencies:
     fbjs "^0.8.16"
     loose-envify "^1.1.0"