hakumizuki 4 лет назад
Родитель
Сommit
e3f3f3cecc

+ 5 - 0
CHANGES.md

@@ -17,6 +17,11 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/43x.html>
     * Searching GROWI pages from Slack
     * Creating GROWI pages from Slack
         * Easy record conversations
+* Feature: Enable/Disable option for share link
+* Feature: Re-send invitation mail from user management page
+* Improvement: Mark users who failed to send invitation emails
+* Fix: lsx plugin in the custom sidebar does not work when showing search result page
+* Support: Switch the official docker base image from Alpine based to Ubuntu based
 
 ## v4.2.21
 

+ 0 - 1
packages/slack/.eslintrc.js

@@ -6,7 +6,6 @@ module.exports = {
     'plugin:jest/recommended',
   ],
   env: {
-    jquery: true,
     'jest/globals': true,
   },
   globals: {

+ 114 - 0
packages/slack/src/utils/reshape-contents-body.test.ts

@@ -0,0 +1,114 @@
+import { reshapeContentsBody } from './reshape-contents-body';
+
+describe('reshapeContentsBody', () => {
+
+  describe('Markdown only', () => {
+    test('Return the same input', () => {
+      const input = `
+      # Title\u0020\u0020
+      ## Section\u0020\u0020
+      I tested this code at 12:00 AM.\u0020\u0020
+      **bold** text
+      some texts`;
+
+      expect(reshapeContentsBody(input)).toBe(input);
+    });
+  });
+
+  describe('Contains time but no headers', () => {
+    test('Return the same input', () => {
+      const input = `
+12:23
+some messages...
+12:23
+some messages...
+12:23`;
+
+      expect(reshapeContentsBody(input)).toBe(input);
+    });
+  });
+
+  describe('Copied from Slack only', () => {
+    test('Reshape', () => {
+      const input = `
+taichi-m  12:23 PM
+some messages...
+some messages...
+some messages...
+12:23
+some messages...
+12:23
+some messages...`;
+
+      const output = `
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+  describe('Copied from Slack only (24 hours format)', () => {
+    test('Reshape', () => {
+      const input = `
+taichi-m  12:23
+some messages...
+some messages...
+some messages...
+12:23
+some messages...
+12:23
+some messages...`;
+
+      const output = `
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23</span>
+\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+  describe('Markdown and copied from Slack', () => {
+    test('Reshape only after the first header', () => {
+      const input = `
+some messages...
+
+taichi-m  12:23 PM
+some messages...`;
+
+      const output = `some messages...
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+});

+ 2 - 2
packages/slack/src/utils/reshape-contents-body.ts

@@ -42,7 +42,7 @@ export const reshapeContentsBody = (str: string): string => {
   const splitted = str.split('\n');
   const { linesBeforeFirstHeader, linesAfterFirstHeader } = devideLinesBeforeAfterFirstHeader(splitted);
   if (linesAfterFirstHeader.length === 0) {
-    return linesBeforeFirstHeader.join('');
+    return linesBeforeFirstHeader.join('\n');
   }
 
   let didReactionRemoved = false;
@@ -64,7 +64,7 @@ export const reshapeContentsBody = (str: string): string => {
       }
       // ##*username*  HH:mm AM
       copyline = '\n## **'.concat(copyline);
-      copyline = copyline.replace(regexpTime, '**'.concat(time));
+      copyline = copyline.replace(regexpTime, '**<span class="grw-togetter-time">'.concat(time, '</span>\n'));
     }
     // Check 3: Is this line a short time(HH:mm)?
     else if (regexpShortTime.test(copyline)) {

+ 0 - 1
packages/slackbot-proxy/.eslintrc.js

@@ -6,7 +6,6 @@ module.exports = {
     'plugin:jest/recommended',
   ],
   env: {
-    jquery: true,
     'jest/globals': true,
   },
   globals: {

+ 17 - 9
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -170,15 +170,23 @@ export class GrowiToSlackCtrl {
     logger.debug('relation test is success', order);
 
     // Transaction is not considered because it is used infrequently,
-    const createdRelation = await this.relationRepository.save({
-      installation: order.installation,
-      tokenGtoP: order.tokenGtoP,
-      tokenPtoG: order.tokenPtoG,
-      growiUri: order.growiUrl,
-      siglePostCommands: temporarySinglePostCommands,
-    });
-
-    return res.send({ relation: createdRelation, slackBotToken: token });
+    const response = await this.relationRepository.createQueryBuilder('relation')
+      .insert()
+      .values({
+        installation: order.installation,
+        tokenGtoP: order.tokenGtoP,
+        tokenPtoG: order.tokenPtoG,
+        growiUri: order.growiUrl,
+        siglePostCommands: temporarySinglePostCommands,
+      })
+      // https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487
+      .orUpdate({ conflict_target: ['installation', 'growiUri'], overwrite: ['tokenGtoP', 'tokenPtoG', 'siglePostCommands'] })
+      .execute();
+
+    // Find the generated relation
+    const generatedRelation = await this.relationRepository.findOne({ id: response.identifiers[0].id });
+
+    return res.send({ relation: generatedRelation, slackBotToken: token });
   }
 
   injectGrowiUri(req: GrowiReq, growiUri: string): void {

+ 1 - 1
packages/slackbot-proxy/src/controllers/slack.ts

@@ -256,7 +256,7 @@ export class SlackCtrl {
   }
 
   @Post('/events')
-  async handleEvent(@BodyParams() body:{[key:string]:string}, @Res() res: Res): Promise<void|string> {
+  async handleEvent(@BodyParams() body:{[key:string]:string} /* , @Res() res: Res */): Promise<void|string> {
     // 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') {

+ 7 - 1
resource/locales/en_US/translation.json

@@ -151,6 +151,7 @@
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
+  "Link sharing is disabled": "Link sharing is disabled",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -170,7 +171,8 @@
     "page_not_exist_alert": "This page does not exist. Please create a new page."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_page_list": "There are no pages under this page.",
+    "link_sharing_is_disabled": "Link sharing is disabled."
   },
   "installer": {
     "setup": "Setup",
@@ -444,6 +446,7 @@
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",
+    "switch_disable_link_sharing_success": "Succeeded to update share link setting",
     "failed_to_reset_password":"Failed to reset password"
   },
   "template": {
@@ -587,6 +590,9 @@
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
     },
+    "share_link_rights": "Share link rights",
+    "enable_link_sharing": "Enable link sharing",
+    "all_share_links": "All share links",
     "configuration": " Configuration",
     "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",

+ 7 - 1
resource/locales/ja_JP/translation.json

@@ -154,6 +154,7 @@
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
+  "Link sharing is disabled": "リンクのシェアは無効化されています",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -173,7 +174,8 @@
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_page_list": "このページの配下にはページが存在しません。",
+    "link_sharing_is_disabled": "リンクのシェアは無効化されています"
   },
   "installer": {
     "setup": "セットアップ",
@@ -446,6 +448,7 @@
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
+    "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
     "failed_to_reset_password":"パスワードのリセットに失敗しました"
   },
   "template": {
@@ -586,6 +589,9 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
+    "share_link_rights": "シェアリンクの権限",
+    "enable_link_sharing": "リンクのシェアを許可",
+    "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
     "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",

+ 7 - 1
resource/locales/zh_CN/translation.json

@@ -160,6 +160,7 @@
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
+  "Link sharing is disabled": "你不允许分享该链接",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -171,7 +172,8 @@
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_page_list": "There are no pages under this page.",
+    "link_sharing_is_disabled": "链接共享已被禁用"
   },
 	"installer": {
 		"setup": "安装",
@@ -421,6 +423,7 @@
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"remove_user_success": "Succeeded to removing {{username}} ",
     "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password"
   },
 	"template": {
@@ -575,6 +578,9 @@
 			"restricted": "受限(需要管理员批准)",
 			"closed": "已关闭(仅限邀请)"
 		},
+    "share_link_rights": "分享链接权",
+    "enable_link_sharing": "启用链接共享",
+    "all_share_links": "所有共享链接",
 		"configuration": " 配置",
 		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",

+ 2 - 2
src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -225,9 +225,9 @@ class LdapSecuritySettingContents extends React.Component {
                 <p className="form-text text-muted">
                   <small>
                     {t('security_setting.example')}1 - {t('security_setting.ldap.search_filter_example1')}:
-                    <code>(|(uid={'{{ username }}'})(mail={'{{ username }}'}))</code><br />
+                    <code>(|(uid={'{{username}}'})(mail={'{{username}}'}))</code><br />
                     {t('security_setting.example')}2 - {t('security_setting.ldap.search_filter_example2')}:
-                    <code>(sAMAccountName={'{{ username }}'})</code>
+                    <code>(sAMAccountName={'{{username}}'})</code>
                   </small>
                 </p>
               </div>

+ 34 - 1
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -51,6 +51,7 @@ class ShareLinkSetting extends React.Component {
     this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
     this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
     this.deleteLinkById = this.deleteLinkById.bind(this);
+    this.switchDisableLinkSharing = this.switchDisableLinkSharing.bind(this);
   }
 
   componentWillMount() {
@@ -105,11 +106,22 @@ class ShareLinkSetting extends React.Component {
     this.getShareLinkList(shareLinksActivePage);
   }
 
+  async switchDisableLinkSharing() {
+    const { t, adminGeneralSecurityContainer } = this.props;
+    try {
+      await adminGeneralSecurityContainer.switchDisableLinkSharing();
+      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
 
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
     const {
-      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit, disableLinkSharing,
     } = adminGeneralSecurityContainer.state;
 
     return (
@@ -125,6 +137,27 @@ class ShareLinkSetting extends React.Component {
           </button>
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
+        <h4>{t('security_setting.share_link_rights')}</h4>
+        <div className="row mb-5">
+          <div className="col-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="disableLinkSharing"
+                checked={!disableLinkSharing}
+                onChange={() => this.switchDisableLinkSharing()}
+              />
+              <label className="custom-control-label" htmlFor="disableLinkSharing">
+                {t('security_setting.enable_link_sharing')}
+              </label>
+            </div>
+            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && disableLinkSharing && (
+              <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>
+            )}
+          </div>
+        </div>
+        <h4>{t('security_setting.all_share_links')}</h4>
         <Pager
           links={shareLinks}
           activePage={shareLinksActivePage}

+ 3 - 2
src/client/js/components/PageAccessoriesModal.jsx

@@ -26,6 +26,7 @@ const PageAccessoriesModal = (props) => {
   const {
     t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
+  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
   const [isWindowExpanded, setIsWindowExpanded] = useState(false);
@@ -60,10 +61,10 @@ const PageAccessoriesModal = (props) => {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage && !isLinkSharingDisabled,
       },
     };
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {

+ 6 - 2
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -19,6 +19,7 @@ const PageAccessoriesModalControl = (props) => {
   const {
     t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
+  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
   const accessoriesBtnList = useMemo(() => {
     return [
@@ -49,11 +50,11 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage,
+        disabled: isGuestUser || isSharedUser || isNotFoundPage || isLinkSharingDisabled,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -62,6 +63,9 @@ const PageAccessoriesModalControl = (props) => {
         let tooltipMessage;
         if (accessory.disabled) {
           tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+          if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
+            tooltipMessage = t('Link sharing is disabled');
+          }
         }
         else {
           tooltipMessage = accessory.i18n;

+ 8 - 0
src/client/js/components/PageList.jsx

@@ -64,6 +64,14 @@ const PageList = (props) => {
       </div>
     );
   }
+  if (appContainer.config.disableLinkSharing) {
+    return (
+      <div className="mt-2">
+        {/* eslint-disable-next-line react/no-danger */}
+        <p>{t('custom_navigation.link_sharing_is_disabled')}</p>
+      </div>
+    );
+  }
 
   return (
     <div className="page-list">

+ 22 - 1
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -35,6 +35,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isGitHubEnabled: false,
       isTwitterEnabled: false,
       setupStrategies: [],
+      disableLinkSharing: false,
       shareLinks: [],
       totalshareLinks: 0,
       shareLinksPagingLimit: Infinity,
@@ -46,7 +47,7 @@ export default class AdminGeneralSecurityContainer extends Container {
   async retrieveSecurityData() {
     await this.retrieveSetupStratedies();
     const response = await this.appContainer.apiv3.get('/security-setting/');
-    const { generalSetting, generalAuth } = response.data.securityParams;
+    const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
@@ -54,6 +55,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
+      disableLinkSharing: shareLinkSetting.disableLinkSharing,
       isLocalEnabled: generalAuth.isLocalEnabled,
       isLdapEnabled: generalAuth.isLdapEnabled,
       isSamlEnabled: generalAuth.isSamlEnabled,
@@ -88,6 +90,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ sessionMaxAge });
   }
 
+  /**
+   * setter for disableLinkSharing
+   */
+  setDisableLinkSharing(disableLinkSharing) {
+    this.setState({ disableLinkSharing });
+  }
+
   /**
    * Change restrictGuestMode
    */
@@ -137,6 +146,18 @@ export default class AdminGeneralSecurityContainer extends Container {
     return securitySettingParams;
   }
 
+  /**
+   * Switch disableLinkSharing
+   */
+  async switchDisableLinkSharing() {
+    const requestParams = {
+      disableLinkSharing: !this.state.disableLinkSharing,
+    };
+    const response = await this.appContainer.apiv3.put('/security-setting/share-link-setting', requestParams);
+    this.setDisableLinkSharing(!this.state.disableLinkSharing);
+    return response;
+  }
+
   /**
    * Switch authentication
    */

+ 6 - 0
src/client/styles/scss/_wiki.scss

@@ -225,6 +225,12 @@ div.body {
     margin: 0 7%;
     background-color: rgba(200, 200, 200, 0.2);
     border-radius: 10px;
+
+    .grw-togetter-time {
+      float: right;
+      font-size: 0.8em;
+      font-weight: normal;
+    }
   }
 }
 

+ 2 - 0
src/server/models/config.js

@@ -47,6 +47,7 @@ module.exports = function(crowi) {
       'security:list-policy:hideRestrictedByOwner' : false,
       'security:list-policy:hideRestrictedByGroup' : false,
       'security:pageCompleteDeletionAuthority' : undefined,
+      'security:disableLinkSharing' : false,
 
       'security:passport-local:isEnabled' : true,
       'security:passport-ldap:isEnabled' : false,
@@ -193,6 +194,7 @@ module.exports = function(crowi) {
         file: crowi.fileUploadService.getFileUploadEnabled(),
       },
       registrationWhiteList: crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+      disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),

+ 53 - 0
src/server/routes/apiv3/security-setting.js

@@ -22,6 +22,9 @@ const validator = {
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
   ],
+  shareLinkSetting: [
+    body('disableLinkSharing').if(value => value != null).isBoolean(),
+  ],
   authenticationSetting: [
     body('isEnabled').if(value => value != null).isBoolean(),
     body('authId').isString().isIn([
@@ -129,6 +132,12 @@ const validator = {
  *          hideRestrictedByGroup:
  *            type: boolean
  *            description: enable hide by group
+ *      ShareLinkSetting:
+ *        type: object
+ *        properties:
+ *          disableLinkSharing:
+ *            type: boolean
+ *            description: disable link sharing
  *      LocalSetting:
  *        type: object
  *        properties:
@@ -364,6 +373,9 @@ module.exports = (crowi) => {
         wikiMode: await crowi.configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
+      shareLinkSetting: {
+        disableLinkSharing: await crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+      },
       localSetting: {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
@@ -589,6 +601,47 @@ module.exports = (crowi) => {
         hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
       };
+
+      return res.apiv3({ securitySettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating security setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-secuirty-setting failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /_api/v3/security-setting/share-link-setting:
+   *      put:
+   *        tags: [SecuritySetting, apiv3]
+   *        description: Update ShareLink Setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/ShareLinkSetting'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update ShareLink Setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/ShareLinkSetting'
+   */
+  router.put('/share-link-setting', loginRequiredStrictly, adminRequired, csrf, validator.generalSetting, apiV3FormValidator, async(req, res) => {
+    const updateData = {
+      'security:disableLinkSharing': req.body.disableLinkSharing,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
+      const securitySettingParams = {
+        disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+      };
+
       return res.apiv3({ securitySettingParams });
     }
     catch (err) {

+ 15 - 2
src/server/routes/apiv3/share-links.js

@@ -30,6 +30,19 @@ module.exports = (crowi) => {
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
 
+  /**
+   * middleware to limit link sharing
+   */
+  const linkSharingRequired = (req, res, next) => {
+    const isLinkSharingDisabled = crowi.configManager.getConfig('crowi', 'security:disableLinkSharing');
+    logger.debug(`isLinkSharingDisabled: ${isLinkSharingDisabled}`);
+
+    if (isLinkSharingDisabled) {
+      return res.apiv3Err(new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'));
+    }
+    next();
+  };
+
   validator.getShareLinks = [
     // validate the page id is MongoId
     query('relatedPage').isMongoId().withMessage('Page Id is required'),
@@ -54,7 +67,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to get share links
    */
-  router.get('/', loginRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
+  router.get('/', loginRequired, linkSharingRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -115,7 +128,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    */
 
-  router.post('/', loginRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, linkSharingRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);

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

@@ -176,6 +176,14 @@ module.exports = (crowi) => {
         console.log('ここでCancelする');
         break;
       }
+      case 'showMoreTogetterResults': {
+        const parsedValue = JSON.parse(payload.actions[0].value);
+
+        const { body, args, limit } = parsedValue;
+        const newLimit = limit + 10;
+        await crowi.slackBotService.togetterCommand(client, body, args, newLimit);
+        break;
+      }
       default:
         break;
     }

+ 3 - 0
src/server/routes/page.js

@@ -413,6 +413,9 @@ module.exports = function(crowi, app) {
       // page or sharelink are not found
       return res.render('layout-growi/not_found_shared_page');
     }
+    if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
+      return res.render('layout-growi/forbidden');
+    }
 
     const renderVars = {};
 

+ 6 - 0
src/server/service/config-loader.js

@@ -254,6 +254,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  DISABLE_LINK_SHARING: {
+    ns:      'crowi',
+    key:     'security:disableSharing',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   LOCAL_STRATEGY_ENABLED: {
     ns:      'crowi',
     key:     'security:passport-local:isEnabled',

+ 8 - 1
src/server/service/slackbot.js

@@ -93,7 +93,13 @@ class SlackBotService extends S2sMessageHandlable {
     return;
   }
 
-  async togetterCommand(client, body, args) {
+  async togetterCommand(client, body, args, limit = 10) {
+    // TODO GW-6721 Get the time from args
+    const reusult = await client.conversations.history({
+      channel: body.channel_id,
+      limit,
+    });
+    console.log(reusult);
     // Checkbox Message を返す
     client.chat.postEphemeral({
       channel: body.channel_id,
@@ -101,6 +107,7 @@ class SlackBotService extends S2sMessageHandlable {
       text: 'Select messages to use.',
       blocks: this.togetterMessageBlocks(),
     });
+    return;
   }
 
   getKeywords(args) {