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

Merge pull request #3692 from weseek/imprv/reorganize-slack

Imprv/reorganize slack
Yuki Takei 5 лет назад
Родитель
Сommit
40f63c5055

+ 1 - 0
packages/slack/package.json

@@ -22,6 +22,7 @@
   },
   "devDependencies": {
     "@slack/bolt": "^3.3.0",
+    "@types/express": "^4.17.11",
     "@types/jest": "^26.0.22",
     "@typescript-eslint/eslint-plugin": "^4.18.0",
     "@typescript-eslint/parser": "^4.18.0",

+ 3 - 1
packages/slack/src/index.ts

@@ -8,8 +8,10 @@ export const supportedGrowiCommands: string[] = [
 ];
 
 export * from './interfaces/growi-command';
+export * from './interfaces/request-from-slack';
 export * from './models/errors';
-export * from './middlewares/verification-slack-request';
+export * from './middlewares/verify-slack-request';
 export * from './utils/block-creater';
+export * from './utils/post-ephemeral-errors';
 export * from './utils/slash-command-parser';
 export * from './utils/webclient-factory';

+ 9 - 0
packages/slack/src/interfaces/request-from-slack.ts

@@ -0,0 +1,9 @@
+import { Request } from 'express';
+
+export type RequestFromSlack = Request & {
+  // appended by slack
+  headers:{'x-slack-signature'?:string, 'x-slack-request-timestamp':number},
+
+  // appended by GROWI or slackbot-proxy
+  slackSigningSecret?:string,
+};

+ 13 - 15
packages/slack/src/middlewares/verification-slack-request.ts → packages/slack/src/middlewares/verify-slack-request.ts

@@ -1,19 +1,18 @@
 import { createHmac, timingSafeEqual } from 'crypto';
 import { stringify } from 'qs';
-import { Request, Response, NextFunction } from 'express';
+import { Response, NextFunction } from 'express';
+
+import { RequestFromSlack } from '../interfaces/request-from-slack';
+
 /**
-   * Verify if the request came from slack
-   * See: https://api.slack.com/authentication/verifying-requests-from-slack
-   */
-
-type signingSecretType = {
-  signingSecret?:string; headers:{'x-slack-signature'?:string, 'x-slack-request-timestamp':number}
-}
-
-// eslint-disable-next-line max-len
-export const verificationSlackRequest = (req : Request & signingSecretType, res:Response, next:NextFunction):Record<string, any>| void => {
-  if (req.signingSecret == null) {
-    return res.send('No signing secret.');
+ * Verify if the request came from slack
+ * See: https://api.slack.com/authentication/verifying-requests-from-slack
+ */
+export const verifySlackRequest = (req: RequestFromSlack, res: Response, next: NextFunction): Record<string, any> | void => {
+  const signingSecret = req.slackSigningSecret;
+
+  if (signingSecret == null) {
+    return res.status(400).send({ message: 'No signing secret.' });
   }
 
   // take out slackSignature and timestamp from header
@@ -32,7 +31,7 @@ export const verificationSlackRequest = (req : Request & signingSecretType, res:
 
   // generate growi signature
   const sigBaseString = `v0:${timestamp}:${stringify(req.body, { format: 'RFC1738' })}`;
-  const hasher = createHmac('sha256', req.signingSecret);
+  const hasher = createHmac('sha256', signingSecret);
   hasher.update(sigBaseString, 'utf8');
   const hashedSigningSecret = hasher.digest('hex');
   const growiSignature = `v0=${hashedSigningSecret}`;
@@ -40,7 +39,6 @@ export const verificationSlackRequest = (req : Request & signingSecretType, res:
   // compare growiSignature and slackSignature
   if (timingSafeEqual(Buffer.from(growiSignature, 'utf8'), Buffer.from(slackSignature, 'utf8'))) {
     return next();
-
   }
 
   return res.send('Verification failed.');

+ 35 - 0
packages/slack/src/utils/post-ephemeral-errors.ts

@@ -0,0 +1,35 @@
+import { WebAPICallResult } from '@slack/web-api';
+
+import { generateMarkdownSectionBlock } from './block-creater';
+import { generateWebClient } from './webclient-factory';
+
+export const postEphemeralErrors = async(
+  rejectedResults: PromiseRejectedResult[],
+  channelId: string,
+  userId: string,
+  botToken: string,
+): Promise<WebAPICallResult|void> => {
+
+  if (rejectedResults.length > 0) {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const client = generateWebClient(botToken);
+
+    return client.chat.postEphemeral({
+      text: 'Error occured.',
+      channel: channelId,
+      user: userId,
+      blocks: [
+        generateMarkdownSectionBlock('*Error occured:*'),
+        ...rejectedResults.map((rejectedResult) => {
+          const reason = rejectedResult.reason.toString();
+          const resData = rejectedResult.reason.response?.data;
+          const resDataMessage = resData?.message || resData.toString();
+          const errorMessage = `${reason} (${resDataMessage})`;
+          return generateMarkdownSectionBlock(errorMessage);
+        }),
+      ],
+    });
+  }
+
+  return;
+};

+ 10 - 21
packages/slackbot-proxy/src/controllers/slack.ts

@@ -4,7 +4,9 @@ import {
 
 import axios from 'axios';
 
-import { generateMarkdownSectionBlock, generateWebClient, parseSlashCommand } from '@growi/slack';
+import {
+  generateMarkdownSectionBlock, parseSlashCommand, postEphemeralErrors,
+} from '@growi/slack';
 import { Installation } from '~/entities/installation';
 
 import { InstallationRepository } from '~/repositories/installation';
@@ -120,7 +122,7 @@ export class SlackCtrl {
 
     const promises = relations.map((relation: Relation) => {
       // generate API URL
-      const url = new URL('/_api/v3/slack-bot/commands', relation.growiUri);
+      const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
       return axios.post(url.toString(), {
         ...body,
         tokenPtoG: relation.tokenPtoG,
@@ -131,27 +133,14 @@ export class SlackCtrl {
     // pickup PromiseRejectedResult only
     const results = await Promise.allSettled(promises);
     const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+    const botToken = installation?.data.bot?.token;
 
-    if (rejectedResults.length > 0) {
-      const botToken = installation?.data.bot?.token;
-
+    try {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const client = generateWebClient(botToken!);
-
-      try {
-        client.chat.postEphemeral({
-          text: 'Error occured.',
-          channel: body.channel_id,
-          user: body.user_id,
-          blocks: [
-            generateMarkdownSectionBlock('*Error occured:*'),
-            ...rejectedResults.map(result => generateMarkdownSectionBlock(result.reason.toString())),
-          ],
-        });
-      }
-      catch (err) {
-        logger.error(err);
-      }
+      postEphemeralErrors(rejectedResults, body.channel_id, body.user_id, botToken!);
+    }
+    catch (err) {
+      logger.error(err);
     }
   }
 

+ 10 - 3
src/client/js/components/Admin/Common/AdminNavigation.jsx

@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
+import { pathUtils } from 'growi-commons';
 
 const AdminNavigation = (props) => {
   const { t } = props;
@@ -21,7 +22,7 @@ const AdminNavigation = (props) => {
       case 'importer':                 return <><i className="icon-fw icon-cloud-upload"></i>    { t('Import Data') }</>;
       case 'export':                   return <><i className="icon-fw icon-cloud-download"></i>  { t('Export Archive Data') }</>;
       case 'notification':             return <><i className="icon-fw icon-bell"></i>            { t('External_Notification')}</>;
-      case 'legacy-slack-integration': return <><i className="fa fa-slack mr-2"></i>             { t('Legacy_Slack_Integration')}</>;
+      case 'slack-integration-legacy': return <><i className="fa fa-slack mr-2"></i>             { t('Legacy_Slack_Integration')}</>;
       case 'slack-integration':        return <><i className="fa fa-slack mr-2"></i>             { t('slack_integration') }</>;
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
@@ -49,7 +50,13 @@ const AdminNavigation = (props) => {
   };
 
   const isActiveMenu = (path) => {
-    return (pathname.startsWith(urljoin('/admin', path)));
+    const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
+    const basisParentPath = pathUtils.addTrailingSlash(basisPath);
+
+    return (
+      pathname === basisPath
+      || pathname.startsWith(basisParentPath)
+    );
   };
 
   const getListGroupItemOrDropdownItemList = (isListGroupItems) => {
@@ -64,7 +71,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="export"       isListGroupItems isActive={isActiveMenu('/export')} />
         <MenuLink menu="notification" isListGroupItems isActive={isActiveMenu('/notification') || isActiveMenu('/global-notification')} />
         <MenuLink menu="slack-integration" isListGroupItems isActive={isActiveMenu('/slack-integration')} />
-        <MenuLink menu="legacy-slack-integration" isListGroupItems isActive={isActiveMenu('/legacy-slack-integration')} />
+        <MenuLink menu="slack-integration-legacy" isListGroupItems isActive={isActiveMenu('/slack-integration-legacy')} />
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />

+ 3 - 3
src/server/routes/admin.js

@@ -223,9 +223,9 @@ module.exports = function(crowi, app) {
     return res.render('admin/external-accounts');
   };
 
-  actions.legacySlackIntegration = {};
-  actions.legacySlackIntegration = function(req, res) {
-    return res.render('admin/legacy-slack-integration');
+  actions.slackIntegrationLegacy = {};
+  actions.slackIntegrationLegacy = function(req, res) {
+    return res.render('admin/slack-integration-legacy');
   };
 
   actions.slackIntegration = {};

+ 1 - 1
src/server/routes/apiv3/index.js

@@ -46,8 +46,8 @@ module.exports = (crowi) => {
   router.use('/bookmarks', require('./bookmarks')(crowi));
   router.use('/attachment', require('./attachment')(crowi));
 
-  router.use('/slack-bot', require('./slack-bot')(crowi));
   router.use('/slack-integration', require('./slack-integration')(crowi));
+  router.use('/slack-integration-legacy', require('./slack-integration-legacy')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
 
   return router;

+ 0 - 135
src/server/routes/apiv3/slack-bot.js

@@ -1,135 +0,0 @@
-const express = require('express');
-
-const loggerFactory = require('@alias/logger');
-
-const logger = loggerFactory('growi:routes:apiv3:slack-bot');
-
-const router = express.Router();
-const { verificationSlackRequest } = require('@growi/slack');
-
-module.exports = (crowi) => {
-  this.app = crowi.express;
-
-  // Check if the access token is correct
-  function verificationAccessToken(req, res, next) {
-    const botType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (botType === 'customBotWithoutProxy') {
-      return next();
-    }
-    const slackBotAccessToken = req.body.slack_bot_access_token || null;
-
-    if (slackBotAccessToken == null || slackBotAccessToken !== this.crowi.configManager.getConfig('crowi', 'slackbot:access-token')) {
-      logger.error('slack_bot_access_token is invalid.');
-      return res.send('*Access token is inValid*');
-    }
-
-    return next();
-  }
-
-  function verificationRequestUrl(req, res, next) {
-    // for verification request URL on Event Subscriptions
-    if (req.body.type === 'url_verification') {
-      return res.send(req.body);
-    }
-
-    return next();
-  }
-
-  const addSlackBotSigningSecret = (req, res, next) => {
-    req.signingSecret = crowi.configManager.getConfig('crowi', 'slackbot:signingSecret');
-    return next();
-  };
-
-  router.post('/commands', verificationRequestUrl, addSlackBotSigningSecret, verificationSlackRequest, verificationAccessToken, async(req, res) => {
-
-    // Send response immediately to avoid opelation_timeout error
-    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
-
-    const { body } = req;
-    const args = body.text.split(' ');
-    const command = args[0];
-
-    try {
-      switch (command) {
-        case 'search':
-          await crowi.slackBotService.showEphemeralSearchResults(body, args);
-          break;
-        case 'create':
-          await crowi.slackBotService.createModal(body);
-          break;
-        default:
-          await crowi.slackBotService.notCommand(body);
-          break;
-      }
-    }
-    catch (error) {
-      logger.error(error);
-      return res.send(error.message);
-    }
-  });
-
-  const handleBlockActions = async(payload) => {
-    const { action_id: actionId } = payload.actions[0];
-
-    switch (actionId) {
-      case 'shareSearchResults': {
-        await crowi.slackBotService.shareSearchResults(payload);
-        break;
-      }
-      case 'showNextResults': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-
-        const { body, args, offset } = parsedValue;
-        const newOffset = offset + 10;
-        await crowi.slackBotService.showEphemeralSearchResults(body, args, newOffset);
-        break;
-      }
-      default:
-        break;
-    }
-  };
-
-  const handleViewSubmission = async(payload) => {
-    const { callback_id: callbackId } = payload.view;
-
-    switch (callbackId) {
-      case 'createPage':
-        await crowi.slackBotService.createPageInGrowi(payload);
-        break;
-      default:
-        break;
-    }
-  };
-
-  router.post('/interactions', verificationRequestUrl, addSlackBotSigningSecret, verificationSlackRequest, async(req, res) => {
-
-    // Send response immediately to avoid opelation_timeout error
-    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
-
-    const payload = JSON.parse(req.body.payload);
-    const { type } = payload;
-
-    try {
-      switch (type) {
-        case 'block_actions':
-          await handleBlockActions(payload);
-          break;
-        case 'view_submission':
-          await handleViewSubmission(payload);
-          break;
-        default:
-          break;
-      }
-    }
-    catch (error) {
-      logger.error(error);
-      return res.send(error.message);
-    }
-
-  });
-
-
-  return router;
-};

+ 354 - 0
src/server/routes/apiv3/slack-integration-legacy.js

@@ -0,0 +1,354 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:notification-setting');
+const express = require('express');
+const { body } = require('express-validator');
+const crypto = require('crypto');
+const { WebClient, LogLevel } = require('@slack/web-api');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: SlackIntegration
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      CustomBotWithoutProxy:
+ *        description: CustomBotWithoutProxy
+ *        type: object
+ *        properties:
+ *          slackSigningSecret:
+ *            type: string
+ *          slackBotToken:
+ *            type: string
+ *          currentBotType:
+ *            type: string
+ *      SlackIntegration:
+ *        description: SlackIntegration
+ *        type: object
+ *        properties:
+ *          currentBotType:
+ *            type: string
+ */
+
+
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  const validator = {
+    CustomBotWithoutProxy: [
+      body('slackSigningSecret').isString(),
+      body('slackBotToken').isString(),
+      body('currentBotType').isString(),
+    ],
+    SlackIntegration: [
+      body('currentBotType')
+        .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
+    ],
+    NotificationTestToSlackWorkSpace: [
+      body('channel').trim().not().isEmpty()
+        .isString(),
+    ],
+  };
+
+  async function updateSlackBotSettings(params) {
+    const { configManager } = crowi;
+    // update config without publishing S2sMessage
+    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+  }
+
+
+  function generateAccessToken(user) {
+    const hasher = crypto.createHash('sha512');
+    hasher.update(new Date().getTime() + user._id);
+
+    return hasher.digest('base64');
+  }
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration/:
+   *      get:
+   *        tags: [SlackBotSettingParams]
+   *        operationId: getSlackBotSettingParams
+   *        summary: get /slack-integration
+   *        description: Get slackBot setting params.
+   *        responses:
+   *          200:
+   *            description: Succeeded to get slackBot setting params.
+   */
+  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
+    const slackBotSettingParams = {
+      accessToken: crowi.configManager.getConfig('crowi', 'slackbot:access-token'),
+      currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
+      // TODO impl when creating official bot
+      officialBotSettings: {
+        // TODO impl this after GW-4939
+        // AccessToken: "tempaccessdatahogehoge",
+      },
+      customBotWithoutProxySettings: {
+        // TODO impl this after GW-4939
+        // AccessToken: "tempaccessdatahogehoge",
+        slackSigningSecretEnvVars: crowi.configManager.getConfigFromEnvVars('crowi', 'slackbot:signingSecret'),
+        slackBotTokenEnvVars: crowi.configManager.getConfigFromEnvVars('crowi', 'slackbot:token'),
+        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
+        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:token'),
+        isConnectedToSlack: crowi.slackBotService.isConnectedToSlack,
+      },
+      // TODO imple when creating with proxy
+      customBotWithProxySettings: {
+        // TODO impl this after GW-4939
+        // AccessToken: "tempaccessdatahogehoge",
+      },
+    };
+    return res.apiv3({ slackBotSettingParams });
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration/:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: putSlackIntegration
+   *        summary: put /slack-integration
+   *        description: Put SlackIntegration setting.
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/SlackIntegration'
+   *        responses:
+   *           200:
+   *             description: Succeeded to put Slack Integration setting.
+   */
+  router.put('/',
+    accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.SlackIntegration, apiV3FormValidator, async(req, res) => {
+      const { currentBotType } = req.body;
+
+      const requestParams = {
+        'slackbot:currentBotType': currentBotType,
+      };
+
+      try {
+        await updateSlackBotSettings(requestParams);
+
+        // initialize slack service
+        await crowi.slackBotService.initialize();
+        crowi.slackBotService.publishUpdatedMessage();
+
+        const slackIntegrationSettingsParams = {
+          currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
+        };
+        return res.apiv3({ slackIntegrationSettingsParams });
+      }
+      catch (error) {
+        const msg = 'Error occured in updating Slack bot setting';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-SlackIntegrationSetting-failed'), 500);
+      }
+    });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration/custom-bot-without-proxy/:
+   *      put:
+   *        tags: [CustomBotWithoutProxy]
+   *        operationId: putCustomBotWithoutProxy
+   *        summary: /slack-integration/custom-bot-without-proxy
+   *        description: Put customBotWithoutProxy setting.
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomBotWithoutProxy'
+   *        responses:
+   *           200:
+   *             description: Succeeded to put CustomBotWithoutProxy setting.
+   */
+  router.put('/custom-bot-without-proxy',
+    accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.CustomBotWithoutProxy, apiV3FormValidator, async(req, res) => {
+      const { slackSigningSecret, slackBotToken, currentBotType } = req.body;
+      const requestParams = {
+        'slackbot:signingSecret': slackSigningSecret,
+        'slackbot:token': slackBotToken,
+        'slackbot:currentBotType': currentBotType,
+      };
+      try {
+        await updateSlackBotSettings(requestParams);
+
+        // initialize slack service
+        await crowi.slackBotService.initialize();
+        crowi.slackBotService.publishUpdatedMessage();
+
+        // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
+        const customBotWithoutProxySettingParams = {
+          slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
+          slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:token'),
+          slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
+        };
+        return res.apiv3({ customBotWithoutProxySettingParams });
+      }
+      catch (error) {
+        const msg = 'Error occured in updating Custom bot setting';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      }
+    });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration/custom-bot-without-proxy/slack-workspace-name:
+   *      get:
+   *        tags: [slackWorkSpaceName]
+   *        operationId: getSlackWorkSpaceName
+   *        summary: Get slack work space name for custom bot without proxy
+   *        description: get slack WS name in custom bot without proxy
+   *        responses:
+   *          200:
+   *            description: Succeeded to get slack ws name for custom bot without proxy
+   */
+  router.get('/custom-bot-without-proxy/slack-workspace-name', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    try {
+      const slackWorkSpaceName = await crowi.slackBotService.getSlackChannelName();
+      return res.apiv3({ slackWorkSpaceName });
+    }
+    catch (error) {
+      let msg = 'Error occured in slack_bot_token';
+      if (error.data.error === 'missing_scope') {
+        msg = 'missing_scope';
+      }
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'get-SlackWorkSpaceName-failed'), 500);
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration/access-token:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: getCustomBotSetting
+   *        summary: /slack-integration
+   *        description: Generate accessToken
+   *        responses:
+   *          200:
+   *            description: Succeeded to update access token for slack
+   */
+  router.put('/access-token', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+
+    try {
+      const accessToken = generateAccessToken(req.user);
+      await updateSlackBotSettings({ 'slackbot:access-token': accessToken });
+
+      // initialize slack service
+      await crowi.slackBotService.initialize();
+      crowi.slackBotService.publishUpdatedMessage();
+
+      return res.apiv3({ accessToken });
+    }
+    catch (error) {
+      const msg = 'Error occured in updating access token for access token';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'update-accessToken-failed'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration/test-notification-to-slack-work-space:
+   *      post:
+   *        tags: [SlackTestToWorkSpace]
+   *        operationId: postSlackMessageToSlackWorkSpace
+   *        summary: test to send message to slack work space
+   *        description: post message to slack work space
+   *        responses:
+   *          200:
+   *            description: Succeeded to send a message to slack work space
+   */
+  router.post('/notification-test-to-slack-work-space',
+    loginRequiredStrictly, adminRequired, csrf, validator.NotificationTestToSlackWorkSpace, apiV3FormValidator, async(req, res) => {
+      const isConnectedToSlack = crowi.slackBotService.isConnectedToSlack;
+      const { channel } = req.body;
+
+      if (isConnectedToSlack === false) {
+        const msg = 'Bot User OAuth Token is not setup.';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'not-setup-slack-bot-token', 400));
+      }
+
+      const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:token');
+      this.client = new WebClient(slackBotToken, { logLevel: LogLevel.DEBUG });
+      logger.debug('SlackBot: setup is done');
+
+      try {
+        await this.client.chat.postMessage({
+          channel: `#${channel}`,
+          text: 'Your test was successful!',
+        });
+        logger.info(`SlackTest: send success massage to slack work space at #${channel}.`);
+        logger.info(`If you do not receive a message, you may not have invited the bot to the #${channel} channel.`);
+        // eslint-disable-next-line max-len
+        const message = `Successfully send message to Slack work space. See #general channel. If you do not receive a message, you may not have invited the bot to the #${channel} channel.`;
+        return res.apiv3({ message });
+      }
+      catch (error) {
+        const msg = `Error: ${error.data.error}. Needed:${error.data.needed}`;
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'notification-test-slack-work-space-failed'), 500);
+      }
+    });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration/access-token:
+   *      delete:
+   *        tags: [SlackIntegration]
+   *        operationId: deleteAccessTokenForSlackBot
+   *        summary: /slack-integration
+   *        description: Delete accessToken
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete accessToken
+   */
+  router.delete('/access-token', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+
+    try {
+      await updateSlackBotSettings({ 'slackbot:access-token': null });
+
+      // initialize slack service
+      await crowi.slackBotService.initialize();
+      crowi.slackBotService.publishUpdatedMessage();
+
+      return res.apiv3({});
+    }
+    catch (error) {
+      const msg = 'Error occured in discard of slackbotAccessToken';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'discard-slackbotAccessToken-failed'), 500);
+    }
+  });
+
+  return router;
+};

+ 110 - 312
src/server/routes/apiv3/slack-integration.js

@@ -1,353 +1,151 @@
-const loggerFactory = require('@alias/logger');
-
-const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 const express = require('express');
-const { body } = require('express-validator');
-const crypto = require('crypto');
-const { WebClient, LogLevel } = require('@slack/web-api');
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
-const router = express.Router();
 
-/**
- * @swagger
- *  tags:
- *    name: SlackIntegration
- */
+const loggerFactory = require('@alias/logger');
 
-/**
- * @swagger
- *
- *  components:
- *    schemas:
- *      CustomBotWithoutProxy:
- *        description: CustomBotWithoutProxy
- *        type: object
- *        properties:
- *          slackSigningSecret:
- *            type: string
- *          slackBotToken:
- *            type: string
- *          currentBotType:
- *            type: string
- *      SlackIntegration:
- *        description: SlackIntegration
- *        type: object
- *        properties:
- *          currentBotType:
- *            type: string
- */
+const { verifySlackRequest } = require('@growi/slack');
 
+const logger = loggerFactory('growi:routes:apiv3:slack-integration');
+const router = express.Router();
 
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
-
-  const validator = {
-    CustomBotWithoutProxy: [
-      body('slackSigningSecret').isString(),
-      body('slackBotToken').isString(),
-      body('currentBotType').isString(),
-    ],
-    SlackIntegration: [
-      body('currentBotType')
-        .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
-    ],
-    NotificationTestToSlackWorkSpace: [
-      body('channel').trim().not().isEmpty()
-        .isString(),
-    ],
-  };
-
-  async function updateSlackBotSettings(params) {
-    const { configManager } = crowi;
-    // update config without publishing S2sMessage
-    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
-  }
-
-
-  function generateAccessToken(user) {
-    const hasher = crypto.createHash('sha512');
-    hasher.update(new Date().getTime() + user._id);
-
-    return hasher.digest('base64');
-  }
-
-  /**
-   * @swagger
-   *
-   *    /slack-integration/:
-   *      get:
-   *        tags: [SlackBotSettingParams]
-   *        operationId: getSlackBotSettingParams
-   *        summary: get /slack-integration
-   *        description: Get slackBot setting params.
-   *        responses:
-   *          200:
-   *            description: Succeeded to get slackBot setting params.
-   */
-  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
-    const slackBotSettingParams = {
-      accessToken: crowi.configManager.getConfig('crowi', 'slackbot:access-token'),
-      currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
-      // TODO impl when creating official bot
-      officialBotSettings: {
-        // TODO impl this after GW-4939
-        // AccessToken: "tempaccessdatahogehoge",
-      },
-      customBotWithoutProxySettings: {
-        // TODO impl this after GW-4939
-        // AccessToken: "tempaccessdatahogehoge",
-        slackSigningSecretEnvVars: crowi.configManager.getConfigFromEnvVars('crowi', 'slackbot:signingSecret'),
-        slackBotTokenEnvVars: crowi.configManager.getConfigFromEnvVars('crowi', 'slackbot:token'),
-        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
-        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:token'),
-        isConnectedToSlack: crowi.slackBotService.isConnectedToSlack,
-      },
-      // TODO imple when creating with proxy
-      customBotWithProxySettings: {
-        // TODO impl this after GW-4939
-        // AccessToken: "tempaccessdatahogehoge",
-      },
-    };
-    return res.apiv3({ slackBotSettingParams });
-  });
-
-  /**
-   * @swagger
-   *
-   *    /slack-integration/:
-   *      put:
-   *        tags: [SlackIntegration]
-   *        operationId: putSlackIntegration
-   *        summary: put /slack-integration
-   *        description: Put SlackIntegration setting.
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/SlackIntegration'
-   *        responses:
-   *           200:
-   *             description: Succeeded to put Slack Integration setting.
-   */
-  router.put('/',
-    accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.SlackIntegration, apiV3FormValidator, async(req, res) => {
-      const { currentBotType } = req.body;
+  this.app = crowi.express;
 
-      const requestParams = {
-        'slackbot:currentBotType': currentBotType,
-      };
+  const { configManager } = crowi;
 
-      try {
-        await updateSlackBotSettings(requestParams);
+  // Check if the access token is correct
+  function verifyAccessTokenFromProxy(req, res, next) {
+    const { body } = req;
+    const { tokenPtoG } = body;
 
-        // initialize slack service
-        await crowi.slackBotService.initialize();
-        crowi.slackBotService.publishUpdatedMessage();
+    const correctToken = configManager.getConfig('crowi', 'slackbot:access-token');
 
-        const slackIntegrationSettingsParams = {
-          currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
-        };
-        return res.apiv3({ slackIntegrationSettingsParams });
-      }
-      catch (error) {
-        const msg = 'Error occured in updating Slack bot setting';
-        logger.error('Error', error);
-        return res.apiv3Err(new ErrorV3(msg, 'update-SlackIntegrationSetting-failed'), 500);
-      }
+    logger.debug('verifyAccessTokenFromProxy', {
+      tokenPtoG,
+      correctToken,
     });
 
-  /**
-   * @swagger
-   *
-   *    /slack-integration/custom-bot-without-proxy/:
-   *      put:
-   *        tags: [CustomBotWithoutProxy]
-   *        operationId: putCustomBotWithoutProxy
-   *        summary: /slack-integration/custom-bot-without-proxy
-   *        description: Put customBotWithoutProxy setting.
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/CustomBotWithoutProxy'
-   *        responses:
-   *           200:
-   *             description: Succeeded to put CustomBotWithoutProxy setting.
-   */
-  router.put('/custom-bot-without-proxy',
-    accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.CustomBotWithoutProxy, apiV3FormValidator, async(req, res) => {
-      const { slackSigningSecret, slackBotToken, currentBotType } = req.body;
-      const requestParams = {
-        'slackbot:signingSecret': slackSigningSecret,
-        'slackbot:token': slackBotToken,
-        'slackbot:currentBotType': currentBotType,
-      };
-      try {
-        await updateSlackBotSettings(requestParams);
+    if (tokenPtoG == null || tokenPtoG !== correctToken) {
+      return res.status(403).send({ message: 'The access token that identifies the request source is slackbot-proxy is invalid.' });
+    }
+  }
 
-        // initialize slack service
-        await crowi.slackBotService.initialize();
-        crowi.slackBotService.publishUpdatedMessage();
+  const addSlackBotSigningSecretToReq = (req, res, next) => {
+    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
+    return next();
+  };
 
-        // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
-        const customBotWithoutProxySettingParams = {
-          slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
-          slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:token'),
-          slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
-        };
-        return res.apiv3({ customBotWithoutProxySettingParams });
-      }
-      catch (error) {
-        const msg = 'Error occured in updating Custom bot setting';
-        logger.error('Error', error);
-        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
-      }
-    });
+  async function handleCommands(req, res) {
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    res.send();
 
-  /**
-   * @swagger
-   *
-   *    /slack-integration/custom-bot-without-proxy/slack-workspace-name:
-   *      get:
-   *        tags: [slackWorkSpaceName]
-   *        operationId: getSlackWorkSpaceName
-   *        summary: Get slack work space name for custom bot without proxy
-   *        description: get slack WS name in custom bot without proxy
-   *        responses:
-   *          200:
-   *            description: Succeeded to get slack ws name for custom bot without proxy
-   */
-  router.get('/custom-bot-without-proxy/slack-workspace-name', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const { body } = req;
+    const args = body.text.split(' ');
+    const command = args[0];
 
     try {
-      const slackWorkSpaceName = await crowi.slackBotService.getSlackChannelName();
-      return res.apiv3({ slackWorkSpaceName });
+      switch (command) {
+        case 'search':
+          await crowi.slackBotService.showEphemeralSearchResults(body, args);
+          break;
+        case 'create':
+          await crowi.slackBotService.createModal(body);
+          break;
+        default:
+          await crowi.slackBotService.notCommand(body);
+          break;
+      }
     }
     catch (error) {
-      let msg = 'Error occured in slack_bot_token';
-      if (error.data.error === 'missing_scope') {
-        msg = 'missing_scope';
-      }
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'get-SlackWorkSpaceName-failed'), 500);
+      logger.error(error);
+      return res.send(error.message);
     }
+  }
 
+  router.post('/commands', addSlackBotSigningSecretToReq, verifySlackRequest, async(req, res) => {
+    return handleCommands(req, res);
   });
 
-  /**
-   * @swagger
-   *
-   *    /slack-integration/access-token:
-   *      put:
-   *        tags: [SlackIntegration]
-   *        operationId: getCustomBotSetting
-   *        summary: /slack-integration
-   *        description: Generate accessToken
-   *        responses:
-   *          200:
-   *            description: Succeeded to update access token for slack
-   */
-  router.put('/access-token', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-
-    try {
-      const accessToken = generateAccessToken(req.user);
-      await updateSlackBotSettings({ 'slackbot:access-token': accessToken });
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, async(req, res) => {
+    const { body } = req;
 
-      // initialize slack service
-      await crowi.slackBotService.initialize();
-      crowi.slackBotService.publishUpdatedMessage();
-
-      return res.apiv3({ accessToken });
-    }
-    catch (error) {
-      const msg = 'Error occured in updating access token for access token';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-accessToken-failed'), 500);
+    // eslint-disable-next-line max-len
+    // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
+    if (body.type === 'url_verification') {
+      return body.challenge;
     }
-  });
 
-  /**
-   * @swagger
-   *
-   *    /slack-integration/test-notification-to-slack-work-space:
-   *      post:
-   *        tags: [SlackTestToWorkSpace]
-   *        operationId: postSlackMessageToSlackWorkSpace
-   *        summary: test to send message to slack work space
-   *        description: post message to slack work space
-   *        responses:
-   *          200:
-   *            description: Succeeded to send a message to slack work space
-   */
-  router.post('/notification-test-to-slack-work-space',
-    loginRequiredStrictly, adminRequired, csrf, validator.NotificationTestToSlackWorkSpace, apiV3FormValidator, async(req, res) => {
-      const isConnectedToSlack = crowi.slackBotService.isConnectedToSlack;
-      const { channel } = req.body;
+    return handleCommands(req, res);
+  });
 
-      if (isConnectedToSlack === false) {
-        const msg = 'Bot User OAuth Token is not setup.';
-        logger.error('Error', msg);
-        return res.apiv3Err(new ErrorV3(msg, 'not-setup-slack-bot-token', 400));
-      }
 
-      const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:token');
-      this.client = new WebClient(slackBotToken, { logLevel: LogLevel.DEBUG });
-      logger.debug('SlackBot: setup is done');
+  const handleBlockActions = async(payload) => {
+    const { action_id: actionId } = payload.actions[0];
 
-      try {
-        await this.client.chat.postMessage({
-          channel: `#${channel}`,
-          text: 'Your test was successful!',
-        });
-        logger.info(`SlackTest: send success massage to slack work space at #${channel}.`);
-        logger.info(`If you do not receive a message, you may not have invited the bot to the #${channel} channel.`);
-        // eslint-disable-next-line max-len
-        const message = `Successfully send message to Slack work space. See #general channel. If you do not receive a message, you may not have invited the bot to the #${channel} channel.`;
-        return res.apiv3({ message });
+    switch (actionId) {
+      case 'shareSearchResults': {
+        await crowi.slackBotService.shareSearchResults(payload);
+        break;
       }
-      catch (error) {
-        const msg = `Error: ${error.data.error}. Needed:${error.data.needed}`;
-        logger.error('Error', error);
-        return res.apiv3Err(new ErrorV3(msg, 'notification-test-slack-work-space-failed'), 500);
+      case 'showNextResults': {
+        const parsedValue = JSON.parse(payload.actions[0].value);
+
+        const { body, args, offset } = parsedValue;
+        const newOffset = offset + 10;
+        await crowi.slackBotService.showEphemeralSearchResults(body, args, newOffset);
+        break;
       }
-    });
+      default:
+        break;
+    }
+  };
 
-  /**
-   * @swagger
-   *
-   *    /slack-integration/access-token:
-   *      delete:
-   *        tags: [SlackIntegration]
-   *        operationId: deleteAccessTokenForSlackBot
-   *        summary: /slack-integration
-   *        description: Delete accessToken
-   *        responses:
-   *          200:
-   *            description: Succeeded to delete accessToken
-   */
-  router.delete('/access-token', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  const handleViewSubmission = async(payload) => {
+    const { callback_id: callbackId } = payload.view;
 
-    try {
-      await updateSlackBotSettings({ 'slackbot:access-token': null });
+    switch (callbackId) {
+      case 'createPage':
+        await crowi.slackBotService.createPageInGrowi(payload);
+        break;
+      default:
+        break;
+    }
+  };
 
-      // initialize slack service
-      await crowi.slackBotService.initialize();
-      crowi.slackBotService.publishUpdatedMessage();
+  async function handleInteractions(req, res) {
 
-      return res.apiv3({});
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    res.send();
+
+    const payload = JSON.parse(req.body.payload);
+    const { type } = payload;
+
+    try {
+      switch (type) {
+        case 'block_actions':
+          await handleBlockActions(payload);
+          break;
+        case 'view_submission':
+          await handleViewSubmission(payload);
+          break;
+        default:
+          break;
+      }
     }
     catch (error) {
-      const msg = 'Error occured in discard of slackbotAccessToken';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'discard-slackbotAccessToken-failed'), 500);
+      logger.error(error);
+      return res.send(error.message);
     }
+
+  }
+
+  router.post('/interactions', addSlackBotSigningSecretToReq, verifySlackRequest, async(req, res) => {
+    return handleInteractions(req, res);
+  });
+
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, async(req, res) => {
+    return handleInteractions(req, res);
   });
 
   return router;

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

@@ -93,7 +93,7 @@ module.exports = function(crowi, app) {
   app.get('/admin/notification/slackSetting/disconnect' , loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
   app.get('/admin/global-notification/new'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
   app.get('/admin/global-notification/:id'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
-  app.get('/admin/legacy-slack-integration'         , loginRequiredStrictly , adminRequired,  admin.legacySlackIntegration);
+  app.get('/admin/slack-integration-legacy'             , loginRequiredStrictly , adminRequired,  admin.slackIntegrationLegacy);
   app.get('/admin/slack-integration'                    , loginRequiredStrictly , adminRequired,  admin.slackIntegration);
 
   app.get('/admin/users'                                , loginRequiredStrictly , adminRequired , admin.user.index);

+ 0 - 0
src/server/views/admin/legacy-slack-integration.html → src/server/views/admin/slack-integration-legacy.html