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

Merge branch 'master' into feat/mail-notification-sou

# Conflicts:
#	lib/routes/comment.js
sou 7 лет назад
Родитель
Сommit
03a0133ef8
42 измененных файлов с 547 добавлено и 289 удалено
  1. 3 0
      CHANGES.md
  2. 0 1
      config/webpack.dll.js
  3. 1 1
      lib/crowi/express-init.js
  4. 5 2
      lib/crowi/index.js
  5. 1 1
      lib/form/admin/userGroupCreate.js
  6. 4 1
      lib/form/comment.js
  7. 5 1
      lib/models/config.js
  8. 17 7
      lib/models/page.js
  9. 0 6
      lib/models/user-group.js
  10. 28 27
      lib/routes/admin.js
  11. 33 8
      lib/routes/comment.js
  12. 2 2
      lib/routes/index.js
  13. 38 3
      lib/routes/page.js
  14. 7 3
      lib/util/middlewares.js
  15. 115 53
      lib/util/slack.js
  16. 25 33
      lib/util/swigFunctions.js
  17. 6 4
      lib/util/xss.js
  18. 3 21
      lib/views/_form.html
  19. 3 3
      lib/views/admin/user-group-detail.html
  20. 3 3
      lib/views/admin/user-groups.html
  21. 1 1
      lib/views/modal/create_page.html
  22. 2 2
      lib/views/widget/not_found_content.html
  23. 1 1
      lib/views/widget/page_alerts.html
  24. 2 1
      lib/views/widget/page_content.html
  25. 1 1
      lib/views/widget/page_list_and_timeline.html
  26. 33 3
      resource/js/app.js
  27. 6 1
      resource/js/components/CopyButton.js
  28. 3 3
      resource/js/components/NewPageNameInput.js
  29. 4 1
      resource/js/components/Page/RevisionPath.js
  30. 10 1
      resource/js/components/Page/RevisionUrl.js
  31. 51 17
      resource/js/components/PageComment/CommentForm.js
  32. 4 18
      resource/js/components/PageEditor/Editor.js
  33. 4 1
      resource/js/components/PageEditor/GrantSelector.js
  34. 78 0
      resource/js/components/SlackNotification.js
  35. 0 26
      resource/js/legacy/crowi-form.js
  36. 10 1
      resource/styles/agile-admin/inverse/colors/_apply-colors-dark.scss
  37. 10 1
      resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss
  38. 10 15
      resource/styles/scss/_editor-overlay.scss
  39. 10 0
      resource/styles/scss/_notification.scss
  40. 0 12
      resource/styles/scss/_on-edit.scss
  41. 2 1
      resource/styles/scss/style.scss
  42. 6 2
      test/util/slack.test.js

+ 3 - 0
CHANGES.md

@@ -4,8 +4,11 @@ CHANGES
 ## 3.1.12-RC
 
 * Feature: Add XSS Settings
+* Improvement: Prevent XSS in various situations
+* 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
 
 ## 3.1.11
 

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

+ 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(),
 );

+ 5 - 1
lib/models/config.js

@@ -461,9 +461,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) {
@@ -564,6 +567,7 @@ module.exports = function(crowi) {
       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,

+ 17 - 7
lib/models/page.js

@@ -982,13 +982,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 +1005,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 +1253,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 +1271,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 +1281,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) {

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

+ 28 - 27
lib/routes/admin.js

@@ -591,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) {
@@ -636,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'));
@@ -658,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) => {
@@ -688,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);
     });
   };
 
@@ -759,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)
@@ -775,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);
@@ -785,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);
       }
     });
   };
@@ -847,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);

+ 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);
+        });
+      }
+    }
     // NOTIFICATION: send comment notification here
   };
 

+ 2 - 2
lib/routes/index.js

@@ -123,7 +123,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);
@@ -132,7 +132,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);

+ 38 - 3
lib/routes/page.js

@@ -2,6 +2,7 @@ module.exports = function(crowi, app) {
   'use strict';
 
   var 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')
@@ -10,6 +11,7 @@ module.exports = function(crowi, app) {
     , 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')
@@ -254,6 +256,7 @@ module.exports = function(crowi, app) {
       tree: [],
       pageRelatedGroup: null,
       template: null,
+      slack: '',
     };
 
     var pageTeamplate = 'customlayout-selector/page';
@@ -289,6 +292,12 @@ 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;
@@ -367,6 +376,17 @@ module.exports = function(crowi, app) {
     });
   };
 
+  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),
@@ -475,6 +495,7 @@ module.exports = function(crowi, app) {
       page: pageData,
       revision: pageData.revision || {},
       author: pageData.revision.author || false,
+      slack: '',
     };
     var userPage = isUserPage(pageData.path);
     var userData = null;
@@ -491,6 +512,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))
@@ -670,11 +697,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();
   };
 };

+ 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'];
   };
 
@@ -110,17 +110,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);
   };
 
@@ -133,12 +133,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);
   };
 
@@ -148,45 +148,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);
   };
 
@@ -211,7 +203,7 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.isTopPage = function() {
-    var path = req.path || '';
+    let path = req.path || '';
     if (path === '/') {
       return true;
     }
@@ -220,7 +212,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;
     }
@@ -229,8 +221,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);
   };

+ 6 - 4
lib/util/xss.js

@@ -3,8 +3,10 @@ class Xss {
   constructor(xssOption) {
     const xss = require('xss');
 
-    const tagWhiteList = xssOption.tagWhiteList;
-    const attrWhiteList = xssOption.attrWhiteList;
+    xssOption = xssOption || {};
+
+    const tagWhiteList = xssOption.tagWhiteList || [];
+    const attrWhiteList = xssOption.attrWhiteList || [];
 
     let whiteListContent = {};
 
@@ -25,8 +27,8 @@ class Xss {
     this.myxss = new xss.FilterXSS(option);
   }
 
-  process(markdown) {
-    return this.myxss.process(markdown);
+  process(document) {
+    return this.myxss.process(document);
   }
 
 }

+ 3 - 21
lib/views/_form.html

@@ -19,7 +19,7 @@
   <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">
@@ -27,27 +27,9 @@
       <div id="page-editor-options-selector"></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"></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() }}">

+ 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> 削除する

+ 1 - 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') }}">

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

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

+ 2 - 1
lib/views/widget/page_content.html

@@ -6,6 +6,7 @@
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% 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 +16,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">

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

+ 33 - 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';
@@ -19,12 +21,13 @@ 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,6 +42,10 @@ 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;
@@ -47,11 +54,13 @@ 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');
   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 +120,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 +184,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) {

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

+ 4 - 1
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 + '/';
     });

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

+ 51 - 17
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() {
@@ -243,10 +262,24 @@ export default class CommentForm extends React.Component {
                       <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>
                   }
+                  { this.state.hasSlackConfig &&
+                    <div className="form-inline d-flex align-items-center">
+                      <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>
+                  }
                   <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
                     Comment
                   </Button>
@@ -268,6 +301,7 @@ CommentForm.propTypes = {
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
 };
 CommentForm.defaultProps = {
   editorOptions: {},

+ 4 - 18
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} className="dropzone-container">
+    return (
+      <div style={flexContainer} className="editor-container">
         <Dropzone
             ref="dropzone"
             disableClick
@@ -270,8 +257,7 @@ export default class Editor extends AbstractEditor {
         </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) : '';
   }
 
   /**

+ 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 m-r-5">
+        <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,
+};

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

@@ -3,41 +3,15 @@ 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() {
   $('body').addClass('on-edit');
-
-  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-form"]').on('hide.bs.tab', function() {

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

+ 10 - 15
resource/styles/scss/dropzone.scss → resource/styles/scss/_editor-overlay.scss

@@ -1,10 +1,16 @@
-.dropzone-container {
+.editor-container {
   .overlay {
     // layout
     display: flex;
     justify-content: center;
     align-items: center;
-    margin-left: 15px;
+
+    position: absolute;
+    z-index: 7;  // forward than .CodeMirror-vscrollbar
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
   }
 
   .overlay-content {
@@ -17,19 +23,6 @@
     }
   }
 
-  .comment-form {
-    .overlay {
-      // style
-      margin-right: 15px;
-      margin-top: 55px;
-      margin-bottom: 55px;
-    }
-    .overlay-content {
-      font-size: 1.8em;
-    }
-  }
-
-
   @mixin overlay-processing-style() {
     .overlay {
       background: rgba(255,255,255,0.5);
@@ -58,6 +51,8 @@
       }
     }
 
+    position: relative;   // against .overlay position: absolute
+
     // unuploadable or rejected
     &.dropzone-unuploadable, &.dropzone-rejected {
       .overlay {

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

+ 0 - 12
resource/styles/scss/_on-edit.scss

@@ -113,18 +113,6 @@ body.on-edit {
           width: 100px;
         }
       }
-
-      // slack
-      .input-group-slack {
-        .input-group-addon {
-          padding: 2px 8px;
-          line-height: 1em;
-          img, input {
-            vertical-align: middle;
-          }
-        }
-      }
-
     }
   }
 

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

@@ -21,11 +21,13 @@
 @import 'comment_growi';
 @import 'create-page';
 @import 'create-template';
+@import 'editor-overlay';
 @import 'layout';
 @import 'layout_crowi';
 @import 'layout_crowi_sidebar';
 @import 'layout_growi';
 @import 'login';
+@import 'notification';
 @import 'on-edit';
 @import 'page_list';
 @import 'page';
@@ -35,7 +37,6 @@
 @import 'user';
 @import 'user_growi';
 @import 'wiki';
-@import 'dropzone';
 
 /*
  * for Guest User Mode

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